Add Vue 3 migration plan for admin UI

Migrate from React 18 + MUI to Vue 3 for consistency with machine UI:
- PrimeVue for components (tree-shakeable vs MUI's 300kb)
- tRPC replacing Apollo GraphQL (~5kb vs ~50kb)
- Pinia for state (same as machine UI)
- Shared ui-shared package for common components

Benefits:
- Single framework across all UIs
- ~70% bundle size reduction (400kb → 120kb)
- End-to-end type safety with tRPC
- Shared composables between admin and machine

Includes:
- Complete project structure
- PrimeVue integration with Tailwind
- tRPC client setup
- Passkey authentication flow
- 4-phase migration strategy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-22 14:46:42 -05:00
commit efddb0db5f

View file

@ -0,0 +1,957 @@
---
title: Admin UI Modernization
created: 2026-01-22
updated: 2026-01-22
tags:
- feature
- vue
- admin
- ui
- refactor
status: planning
priority: high
---
# Admin UI Modernization
> [!abstract] Summary
> Migrate the admin dashboard from **React 18 + MUI** to **Vue 3** for consistency with the machine UI, using PrimeVue for components and tRPC for type-safe API communication.
## Quick Links
- [[#Current State]]
- [[#Why Unify on Vue]]
- [[#Architecture]]
- [[#Migration Strategy]]
- [[#Implementation]]
---
## Current State
### Existing Tech Stack
```
packages/admin-ui/
├── src/
│ ├── pages/ # React page components
│ ├── components/ # Reusable React components
│ ├── hooks/ # Custom React hooks
│ └── ...
├── package.json # React 18, MUI, Apollo Client
└── vite.config.js
```
| Component | Current | Notes |
|-----------|---------|-------|
| Framework | React 18.3 | Functional components + hooks |
| UI Library | MUI v7.1 | Heavy bundle (~300kb) |
| State | Zustand | Lightweight |
| API Client | Apollo Client | GraphQL |
| Styling | Tailwind CSS v4 | Already modern |
| Build | Vite + SWC | Already modern |
| Charts | D3 | Keep |
| Tables | Material React Table | Replace |
### Problems
> [!warning] Issues with Current Setup
1. **Two frameworks** - React (admin) + Vanilla JS (machine) = different mental models
2. **Heavy bundle** - MUI adds ~300kb to bundle
3. **GraphQL complexity** - Apollo Client is powerful but heavy
4. **Inconsistent patterns** - Different state management approaches
5. **Developer context switching** - Between React and future Vue machine UI
#currentstate
---
## Why Unify on Vue
> [!decision] Single Framework Strategy
### Benefits of Vue Everywhere
| Aspect | Two Frameworks | Single Framework (Vue) |
|--------|---------------|------------------------|
| Learning curve | High | Lower |
| Code sharing | None | Components, composables |
| Hiring | React + Vue devs | Vue devs only |
| Maintenance | 2x patterns | 1x patterns |
| Bundle optimization | Separate | Shared chunks possible |
### Vue 3 vs React 19 for Admin
| Feature | Vue 3 | React 19 |
|---------|-------|----------|
| Bundle size | ~33kb | ~42kb |
| DevTools | Excellent | Excellent |
| TypeScript | First-class | First-class |
| Learning curve | Lower | Medium |
| Component syntax | SFC (cleaner) | JSX |
| State management | Pinia (simpler) | Context/Zustand |
> [!tip] Strategic Alignment
> With machine UI moving to Vue 3, unifying admin UI reduces cognitive load and enables shared utilities.
#vue #consistency
---
## Architecture
### Target Stack
```
┌─────────────────────────────────────────────────────────┐
│ Admin Dashboard │
├─────────────────────────────────────────────────────────┤
│ Vue 3 + TypeScript │
│ ┌─────────────────────────────────────────────────────┐│
│ │ Pages (Vue Router) ││
│ │ ├── DashboardPage.vue ││
│ │ ├── TransactionsPage.vue ││
│ │ ├── MachinesPage.vue ││
│ │ ├── CustomersPage.vue ││
│ │ ├── MembershipsPage.vue (new) ││
│ │ └── SettingsPage.vue ││
│ └─────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────┐│
│ │ UI Components (PrimeVue) ││
│ │ ├── DataTable, Charts, Forms ││
│ │ └── Custom components ││
│ └─────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────┐│
│ │ State (Pinia) ││
│ │ ├── useAuthStore ││
│ │ ├── useMachinesStore ││
│ │ └── useSettingsStore ││
│ └─────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────┐│
│ │ API Layer (tRPC Client) ││
│ │ └── Type-safe server communication ││
│ └─────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────┤
│ Tailwind CSS v4 (keep) │
└─────────────────────────────────────────────────────────┘
```
### Project Structure
```
packages/admin-ui/
├── src/
│ ├── main.ts # Entry point
│ ├── App.vue # Root component
│ ├── router/
│ │ └── index.ts # Vue Router config
│ │
│ ├── pages/ # Route-level components
│ │ ├── DashboardPage.vue
│ │ ├── transactions/
│ │ │ ├── TransactionsPage.vue
│ │ │ └── TransactionDetailPage.vue
│ │ ├── machines/
│ │ │ ├── MachinesPage.vue
│ │ │ └── MachineDetailPage.vue
│ │ ├── customers/
│ │ │ ├── CustomersPage.vue
│ │ │ └── CustomerDetailPage.vue
│ │ ├── memberships/
│ │ │ ├── MembershipsPage.vue
│ │ │ ├── MembershipDetailPage.vue
│ │ │ └── TiersConfigPage.vue
│ │ ├── settings/
│ │ │ ├── SettingsPage.vue
│ │ │ ├── WalletSettingsPage.vue
│ │ │ └── LightningSettingsPage.vue
│ │ └── auth/
│ │ ├── LoginPage.vue
│ │ └── SetupPasskeyPage.vue
│ │
│ ├── components/ # Reusable components
│ │ ├── layout/
│ │ │ ├── AppSidebar.vue
│ │ │ ├── AppHeader.vue
│ │ │ └── AppBreadcrumb.vue
│ │ ├── common/
│ │ │ ├── DataTable.vue # Wrapper around PrimeVue
│ │ │ ├── StatusBadge.vue
│ │ │ ├── CryptoAmount.vue
│ │ │ ├── FiatAmount.vue
│ │ │ └── DateTimeDisplay.vue
│ │ ├── charts/
│ │ │ ├── TransactionChart.vue
│ │ │ ├── VolumeChart.vue
│ │ │ └── MachineStatusChart.vue
│ │ ├── machines/
│ │ │ ├── MachineCard.vue
│ │ │ ├── MachineStatusIndicator.vue
│ │ │ └── CassetteStatus.vue
│ │ └── memberships/
│ │ ├── MembershipCard.vue
│ │ ├── TierBadge.vue
│ │ └── QRCodeGenerator.vue
│ │
│ ├── stores/ # Pinia stores
│ │ ├── auth.ts
│ │ ├── machines.ts
│ │ ├── transactions.ts
│ │ ├── customers.ts
│ │ ├── memberships.ts
│ │ └── settings.ts
│ │
│ ├── composables/ # Reusable logic
│ │ ├── useAuth.ts
│ │ ├── usePagination.ts
│ │ ├── useFilters.ts
│ │ ├── useExport.ts
│ │ └── useNotifications.ts
│ │
│ ├── api/ # tRPC client
│ │ ├── client.ts
│ │ └── types.ts # Shared types from server
│ │
│ ├── types/ # TypeScript types
│ │ ├── machine.ts
│ │ ├── transaction.ts
│ │ ├── customer.ts
│ │ └── membership.ts
│ │
│ ├── utils/ # Utility functions
│ │ ├── formatters.ts
│ │ ├── validators.ts
│ │ └── constants.ts
│ │
│ └── styles/ # Global styles
│ ├── main.css # Tailwind imports
│ └── primevue-theme.css # PrimeVue customization
│
├── index.html
├── vite.config.ts
├── tsconfig.json
├── tailwind.config.ts
└── package.json
```
#architecture #structure
---
## UI Component Library
### PrimeVue vs Alternatives
| Library | Bundle | Components | Vue 3 | Tailwind |
|---------|--------|------------|-------|----------|
| **PrimeVue** | Tree-shakeable | 90+ | Yes | Yes |
| Naive UI | ~80kb | 80+ | Yes | Manual |
| Vuetify 3 | ~300kb | 80+ | Yes | No |
| Element Plus | ~200kb | 70+ | Yes | No |
| Headless UI | ~10kb | 10 | Yes | Yes |
> [!decision] PrimeVue
> - **Tree-shakeable** - Only import what you use
> - **Tailwind integration** - PrimeVue + Tailwind preset
> - **Rich data table** - Critical for admin dashboards
> - **Chart components** - Built-in Chart.js integration
> - **Accessibility** - WCAG compliant
> - **Unstyled mode** - Full control with Tailwind
### PrimeVue Setup
```typescript
// src/main.ts
import { createApp } from 'vue'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
import { pinia } from './stores'
const app = createApp(App)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: '.dark',
cssLayer: {
name: 'primevue',
order: 'tailwind-base, primevue, tailwind-utilities',
},
},
},
})
app.use(ToastService)
app.use(ConfirmationService)
app.use(router)
app.use(pinia)
app.mount('#app')
```
### Component Examples
#### Data Table Wrapper
```vue
<!-- src/components/common/DataTable.vue -->
<script setup lang="ts" generic="T">
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import InputText from 'primevue/inputtext'
import { ref, computed } from 'vue'
interface Props {
data: T[]
columns: ColumnDef<T>[]
loading?: boolean
paginator?: boolean
rows?: number
globalFilter?: boolean
}
interface ColumnDef<T> {
field: keyof T
header: string
sortable?: boolean
filterable?: boolean
body?: (row: T) => VNode
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
paginator: true,
rows: 20,
globalFilter: true,
})
const emit = defineEmits<{
rowClick: [row: T]
rowSelect: [rows: T[]]
}>()
const filters = ref({
global: { value: null, matchMode: 'contains' },
})
const selectedRows = ref<T[]>([])
</script>
<template>
<div class="card">
<div v-if="globalFilter" class="flex justify-end mb-4">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText
v-model="filters.global.value"
placeholder="Search..."
class="w-64"
/>
</span>
</div>
<DataTable
:value="data"
:loading="loading"
:paginator="paginator"
:rows="rows"
:filters="filters"
v-model:selection="selectedRows"
dataKey="id"
stripedRows
showGridlines
class="p-datatable-sm"
@row-click="(e) => emit('rowClick', e.data)"
>
<Column
v-for="col in columns"
:key="String(col.field)"
:field="String(col.field)"
:header="col.header"
:sortable="col.sortable"
>
<template v-if="col.body" #body="{ data: row }">
<component :is="col.body(row)" />
</template>
</Column>
</DataTable>
</div>
</template>
```
#### Transaction List Page
```vue
<!-- src/pages/transactions/TransactionsPage.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTransactionsStore } from '@/stores/transactions'
import { storeToRefs } from 'pinia'
import DataTable from '@/components/common/DataTable.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import CryptoAmount from '@/components/common/CryptoAmount.vue'
import FiatAmount from '@/components/common/FiatAmount.vue'
import DateTimeDisplay from '@/components/common/DateTimeDisplay.vue'
const store = useTransactionsStore()
const { transactions, loading } = storeToRefs(store)
const columns = [
{ field: 'id', header: 'ID', sortable: true },
{
field: 'status',
header: 'Status',
sortable: true,
body: (row) => h(StatusBadge, { status: row.status }),
},
{ field: 'direction', header: 'Type', sortable: true },
{
field: 'fiatAmount',
header: 'Fiat',
sortable: true,
body: (row) => h(FiatAmount, { amount: row.fiatAmount, currency: row.fiatCode }),
},
{
field: 'cryptoAmount',
header: 'Crypto',
sortable: true,
body: (row) => h(CryptoAmount, { amount: row.cryptoAmount, coin: row.cryptoCode }),
},
{
field: 'created',
header: 'Date',
sortable: true,
body: (row) => h(DateTimeDisplay, { date: row.created }),
},
]
function handleRowClick(transaction: Transaction) {
router.push(`/transactions/${transaction.id}`)
}
// Load on mount
onMounted(() => store.fetchTransactions())
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Transactions</h1>
<div class="flex gap-2">
<Button label="Export CSV" icon="pi pi-download" outlined />
<Button label="Refresh" icon="pi pi-refresh" @click="store.fetchTransactions" />
</div>
</div>
<DataTable
:data="transactions"
:columns="columns"
:loading="loading"
@row-click="handleRowClick"
/>
</div>
</template>
```
#primevue #components
---
## API Layer: tRPC
### Why tRPC over GraphQL
| Aspect | GraphQL (Apollo) | tRPC |
|--------|------------------|------|
| Bundle size | ~50kb | ~5kb |
| Type safety | Codegen required | Automatic |
| Learning curve | Higher | Lower |
| Caching | Built-in | TanStack Query |
| Boilerplate | Schema + resolvers | Just functions |
> [!decision] tRPC for Admin API
> Since both server and admin-ui are TypeScript, tRPC provides end-to-end type safety without code generation.
### tRPC Client Setup
```typescript
// src/api/client.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '@lamassu/server/trpc'
import { useAuthStore } from '@/stores/auth'
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_API_URL}/trpc`,
headers() {
const auth = useAuthStore()
return {
Authorization: auth.token ? `Bearer ${auth.token}` : '',
}
},
}),
],
})
```
### Using tRPC in Stores
```typescript
// src/stores/memberships.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { trpc } from '@/api/client'
import type { Membership, CreateMembershipInput } from '@/types/membership'
export const useMembershipsStore = defineStore('memberships', () => {
const memberships = ref<Membership[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchMemberships(filters?: { tierId?: string; search?: string }) {
loading.value = true
error.value = null
try {
// Fully typed! IDE knows exact return type
memberships.value = await trpc.membership.list.query(filters)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
async function createMembership(input: CreateMembershipInput) {
// TypeScript ensures input matches server expectations
const newMembership = await trpc.membership.create.mutate(input)
memberships.value.push(newMembership)
return newMembership
}
async function deleteMembership(id: string) {
await trpc.membership.delete.mutate({ id })
memberships.value = memberships.value.filter(m => m.id !== id)
}
return {
memberships,
loading,
error,
fetchMemberships,
createMembership,
deleteMembership,
}
})
```
### TanStack Query Integration (Optional)
```typescript
// For more advanced caching/refetching
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { trpc } from '@/api/client'
export function useMemberships(filters?: { tierId?: string }) {
return useQuery({
queryKey: ['memberships', filters],
queryFn: () => trpc.membership.list.query(filters),
})
}
export function useCreateMembership() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: CreateMembershipInput) =>
trpc.membership.create.mutate(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['memberships'] })
},
})
}
```
#trpc #api
---
## Authentication
### Passkey-First Auth Flow
```vue
<!-- src/pages/auth/LoginPage.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { startAuthentication } from '@simplewebauthn/browser'
const router = useRouter()
const auth = useAuthStore()
const email = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
async function handlePasskeyLogin() {
loading.value = true
error.value = null
try {
// Get challenge from server
const options = await trpc.auth.getPasskeyOptions.query({ email: email.value })
// Trigger browser passkey UI
const credential = await startAuthentication(options)
// Verify with server
const result = await trpc.auth.verifyPasskey.mutate({
email: email.value,
credential,
})
auth.setToken(result.token)
auth.setUser(result.user)
router.push('/dashboard')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Authentication failed'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<Card class="w-full max-w-md">
<template #title>
<div class="text-center">
<img src="/logo.svg" alt="Lamassu" class="h-12 mx-auto mb-4" />
<h1 class="text-2xl font-bold">Admin Login</h1>
</div>
</template>
<template #content>
<div class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium mb-1">Email</label>
<InputText
id="email"
v-model="email"
type="email"
class="w-full"
placeholder="admin@example.com"
/>
</div>
<Message v-if="error" severity="error" :closable="false">
{{ error }}
</Message>
<Button
label="Sign in with Passkey"
icon="pi pi-key"
class="w-full"
:loading="loading"
@click="handlePasskeyLogin"
/>
<Divider align="center">or</Divider>
<Button
label="Sign in with Password"
icon="pi pi-lock"
class="w-full"
severity="secondary"
outlined
@click="router.push('/auth/password')"
/>
</div>
</template>
</Card>
</div>
</template>
```
#auth #passkey
---
## Shared Code with Machine UI
### Shared Package Structure
```
packages/
├── admin-ui/ # Vue 3 admin dashboard
├── machine-ui/ # Vue 3 kiosk UI (moved from lamassu-machine)
└── ui-shared/ # Shared Vue components & utilities
├── src/
│ ├── components/
│ │ ├── QRCode.vue
│ │ ├── CryptoIcon.vue
│ │ └── LoadingSpinner.vue
│ ├── composables/
│ │ ├── useCrypto.ts
│ │ ├── useFormatters.ts
│ │ └── useValidators.ts
│ ├── types/
│ │ ├── coin.ts
│ │ ├── transaction.ts
│ │ └── membership.ts
│ └── index.ts
└── package.json
```
### Shared Composables
```typescript
// packages/ui-shared/src/composables/useFormatters.ts
import { computed } from 'vue'
export function useFormatters(locale = 'en-US') {
const formatFiat = (amount: number, currency: string) => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount)
}
const formatCrypto = (sats: number, coin: string) => {
if (coin === 'BTC') {
return `${(sats / 100_000_000).toFixed(8)} BTC`
}
return `${sats} sats`
}
const formatDate = (date: Date | string) => {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(date))
}
return {
formatFiat,
formatCrypto,
formatDate,
}
}
```
```typescript
// Usage in both admin-ui and machine-ui
import { useFormatters } from '@lamassu/ui-shared'
const { formatFiat, formatCrypto } = useFormatters()
```
#shared #monorepo
---
## Migration Strategy
### Phase 1: Setup & Infrastructure
> [!todo] Phase 1 Tasks
- [ ] Create new Vue 3 project in `packages/admin-ui-v2/`
- [ ] Set up Vite + Vue + TypeScript
- [ ] Configure PrimeVue with Tailwind
- [ ] Set up Vue Router with auth guards
- [ ] Set up Pinia stores
- [ ] Configure tRPC client
- [ ] Create layout components (Sidebar, Header)
### Phase 2: Core Pages Migration
> [!todo] Phase 2 Tasks
Migrate pages in order of complexity:
1. [ ] Login / Auth pages
2. [ ] Dashboard (overview)
3. [ ] Machines list & detail
4. [ ] Transactions list & detail
5. [ ] Customers list & detail
6. [ ] Settings pages
### Phase 3: New Features
> [!todo] Phase 3 Tasks
- [ ] Memberships management (new)
- [ ] Discount tiers configuration (new)
- [ ] Lightning/LNbits settings (new)
- [ ] Enhanced analytics dashboard
### Phase 4: Polish & Cutover
> [!todo] Phase 4 Tasks
- [ ] Dark mode support
- [ ] Responsive design review
- [ ] Accessibility audit
- [ ] Performance optimization
- [ ] E2E tests with Playwright
- [ ] Remove old React admin-ui
- [ ] Update deployment configs
#migration #phases
---
## Build & Deployment
### Vite Config
```typescript
// packages/admin-ui/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import Components from 'unplugin-vue-components/vite'
import { PrimeVueResolver } from '@primevue/auto-import-resolver'
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [PrimeVueResolver()],
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
target: 'es2022',
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
primevue: ['primevue'],
charts: ['chart.js', 'vue-chartjs'],
},
},
},
},
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/trpc': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})
```
### Package.json
```json
{
"name": "@lamassu/admin-ui",
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest",
"test:e2e": "playwright test",
"lint": "eslint src --ext .vue,.ts --fix",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0",
"@primevue/themes": "^4.2.0",
"primevue": "^4.2.0",
"primeicons": "^7.0.0",
"@trpc/client": "^11.0.0",
"@tanstack/vue-query": "^5.60.0",
"@vueuse/core": "^11.0.0",
"@simplewebauthn/browser": "^10.0.0",
"chart.js": "^4.4.0",
"vue-chartjs": "^5.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"vite": "^6.0.0",
"typescript": "^5.6.0",
"vue-tsc": "^2.1.0",
"vitest": "^2.1.0",
"@vue/test-utils": "^2.4.0",
"@playwright/test": "^1.48.0",
"tailwindcss": "^4.0.0",
"autoprefixer": "^10.4.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"unplugin-vue-components": "^0.27.0",
"@primevue/auto-import-resolver": "^4.2.0"
}
}
```
#build #deployment
---
## Comparison Summary
| Aspect | Before (React) | After (Vue 3) |
|--------|---------------|---------------|
| Framework | React 18 | Vue 3 |
| UI Library | MUI (~300kb) | PrimeVue (tree-shake) |
| State | Zustand | Pinia |
| API | Apollo GraphQL | tRPC |
| Styling | Tailwind | Tailwind (keep) |
| Build | Vite | Vite (keep) |
| Types | TypeScript | TypeScript (keep) |
| Machine UI | Different (Vanilla) | Same (Vue 3) |
### Bundle Size Targets
| Chunk | Before | After |
|-------|--------|-------|
| Framework | ~42kb | ~33kb |
| UI Library | ~300kb | ~80kb* |
| API Client | ~50kb | ~5kb |
| **Total** | **~400kb** | **~120kb** |
*PrimeVue with tree-shaking, only used components
#comparison #summary
---
## Related Documents
- [[machine-ui-modernization]] - Kiosk UI Vue migration
- [[modernization-plan]] - Overall modernization roadmap
- [[membership-lightning-integration]] - New membership feature
- [[lnbits-integration]] - Lightning backend