diff --git a/docs/features/machine-ui-modernization.md b/docs/features/machine-ui-modernization.md new file mode 100644 index 0000000..582e146 --- /dev/null +++ b/docs/features/machine-ui-modernization.md @@ -0,0 +1,966 @@ +--- +title: Machine UI Modernization +created: 2026-01-22 +updated: 2026-01-22 +tags: + - feature + - vue + - ui + - lamassu-machine + - refactor +status: planning +priority: high +--- + +# Machine UI Modernization + +> [!abstract] Summary +> Replace the legacy vanilla JavaScript + jQuery UI in `lamassu-machine` with a modern **Vue 3** application using TypeScript, Pinia for state management, and Vite for building. + +## Quick Links + +- [[#Current State]] +- [[#Why Vue]] +- [[#Architecture]] +- [[#Migration Strategy]] +- [[#Implementation]] + +--- + +## Current State + +### Problems with Existing UI + +```javascript +// Current: lamassu-machine/ui/src/app.js (80KB single file) +/* globals $, URLSearchParams, WebSocket, Keyboard, BigNumber, ... */ +'use strict' + +var fiatCode = null +var locale = null +var currentState +var websocket = null +// ... 50+ global variables +``` + +> [!warning] Technical Debt +> - **Single 80KB file** with all logic +> - **50+ global variables** for state +> - **jQuery dependency** for DOM manipulation +> - **No type safety** - runtime errors only +> - **No component structure** - hard to test/maintain +> - **Babel 6** (2016) for transpilation +> - **Manual DOM updates** - error-prone + +### Current Tech Stack + +| Component | Current | Issues | +|-----------|---------|--------| +| Framework | Vanilla JS | No structure | +| DOM | jQuery | Dated, heavy | +| State | Global vars | Unmaintainable | +| Build | Babel 6 | Outdated | +| Styles | SCSS | OK, keep | +| i18n | Jed (gettext) | Works, but heavy | + +#currentstate #technicaldebt + +--- + +## Why Vue + +> [!decision] Vue 3 over React/Svelte/Solid + +| Framework | Bundle Size | Learning Curve | Kiosk Fit | +|-----------|-------------|----------------|-----------| +| **Vue 3** | ~33kb | Low | Excellent | +| React 19 | ~42kb | Medium | Good | +| Svelte 5 | ~2kb | Low | Excellent | +| Solid | ~7kb | Medium | Good | + +### Vue Advantages for Kiosk + +1. **Single-File Components (SFC)** + - HTML, CSS, JS in one file + - Natural for UI-focused development + - Easy to understand screen-by-screen + +2. **Composition API** + - TypeScript-first design + - Reusable composables for hardware + - Better than Options API for complex state + +3. **Progressive Adoption** + - Can migrate screen-by-screen + - Works alongside existing code during migration + +4. **Smaller Bundle** + - Critical for kiosk boot time + - Tree-shakeable + +5. **Vue Ecosystem** + - **Pinia** - Type-safe state management + - **VueUse** - Composables for common tasks + - **Vue I18n** - Internationalization + +> [!note] Why Not Svelte? +> Svelte has the smallest bundle, but Vue has: +> - Larger ecosystem for i18n, forms, etc. +> - More developers familiar with it +> - Better tooling maturity + +#vue #framework + +--- + +## Architecture + +### Target Stack + +``` +┌─────────────────────────────────────────────┐ +│ Tauri 2.x Shell │ +│ (Rust core, WebView for UI) │ +├─────────────────────────────────────────────┤ +│ Vue 3 Application │ +│ ┌─────────────────────────────────────┐ │ +│ │ Screens (Vue Components) │ │ +│ │ ├── IdleScreen.vue │ │ +│ │ ├── ChooseCoinScreen.vue │ │ +│ │ ├── InsertBillsScreen.vue │ │ +│ │ └── ... │ │ +│ └─────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────┐ │ +│ │ State (Pinia Stores) │ │ +│ │ ├── useTransactionStore │ │ +│ │ ├── useMachineStore │ │ +│ │ └── useUIStore │ │ +│ └─────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Composables │ │ +│ │ ├── useWebSocket │ │ +│ │ ├── useKeyboard │ │ +│ │ └── useQRScanner │ │ +│ └─────────────────────────────────────┘ │ +├─────────────────────────────────────────────┤ +│ Hardware Bridge │ +│ (WebSocket ↔ brain.js state machine) │ +└─────────────────────────────────────────────┘ +``` + +### Project Structure + +``` +lamassu-machine/ +├── ui/ +│ ├── src/ +│ │ ├── main.ts # Entry point +│ │ ├── App.vue # Root component +│ │ ├── router.ts # Screen routing +│ │ │ +│ │ ├── screens/ # Full-screen views +│ │ │ ├── IdleScreen.vue +│ │ │ ├── ChooseCoinScreen.vue +│ │ │ ├── ChooseLanguageScreen.vue +│ │ │ ├── ScanAddressScreen.vue +│ │ │ ├── InsertBillsScreen.vue +│ │ │ ├── SendingCoinsScreen.vue +│ │ │ ├── MembershipPromptScreen.vue +│ │ │ ├── MembershipScanScreen.vue +│ │ │ └── ... +│ │ │ +│ │ ├── components/ # Reusable components +│ │ │ ├── common/ +│ │ │ │ ├── BaseButton.vue +│ │ │ │ ├── QRCode.vue +│ │ │ │ ├── LoadingSpinner.vue +│ │ │ │ └── LanguageSelector.vue +│ │ │ ├── keyboard/ +│ │ │ │ ├── VirtualKeyboard.vue +│ │ │ │ └── Keypad.vue +│ │ │ └── transaction/ +│ │ │ ├── CoinSelector.vue +│ │ │ ├── BillAcceptor.vue +│ │ │ └── AmountDisplay.vue +│ │ │ +│ │ ├── stores/ # Pinia stores +│ │ │ ├── transaction.ts +│ │ │ ├── machine.ts +│ │ │ ├── ui.ts +│ │ │ └── i18n.ts +│ │ │ +│ │ ├── composables/ # Reusable logic +│ │ │ ├── useWebSocket.ts +│ │ │ ├── useKeyboard.ts +│ │ │ ├── useQRScanner.ts +│ │ │ ├── useIdleTimeout.ts +│ │ │ └── useSounds.ts +│ │ │ +│ │ ├── types/ # TypeScript types +│ │ │ ├── transaction.ts +│ │ │ ├── machine.ts +│ │ │ └── events.ts +│ │ │ +│ │ ├── i18n/ # Internationalization +│ │ │ ├── index.ts +│ │ │ └── locales/ +│ │ │ ├── en.json +│ │ │ ├── es.json +│ │ │ └── ... +│ │ │ +│ │ └── styles/ # Global styles +│ │ ├── main.scss +│ │ ├── variables.scss +│ │ └── themes/ +│ │ +│ ├── index.html +│ ├── vite.config.ts +│ ├── tsconfig.json +│ └── package.json +│ +├── lib/ +│ └── brain.js # State machine (unchanged) +│ +└── package.json +``` + +#architecture #structure + +--- + +## Core Components + +### App.vue - Root Component + +```vue + + + + + + + + + + + + + + + + + +``` + +### Transaction Store (Pinia) + +```typescript +// ui/src/stores/transaction.ts +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Coin, Membership, Transaction } from '../types' + +export const useTransactionStore = defineStore('transaction', () => { + // State + const direction = ref<'cashIn' | 'cashOut' | null>(null) + const selectedCoin = ref(null) + const fiatAmount = ref(0) + const cryptoAmount = ref(0) + const walletAddress = ref(null) + const membership = ref(null) + const bills = ref([]) + + // Computed + const hasMembership = computed(() => membership.value !== null) + const discountPercent = computed(() => membership.value?.tier.discountPercentage ?? 0) + const totalFiat = computed(() => bills.value.reduce((sum, bill) => sum + bill, 0)) + + const effectiveRate = computed(() => { + if (!selectedCoin.value) return 0 + const baseRate = selectedCoin.value.rate + return baseRate * (1 - discountPercent.value / 100) + }) + + // Actions + function startCashIn(coin: Coin) { + direction.value = 'cashIn' + selectedCoin.value = coin + bills.value = [] + } + + function startCashOut(coin: Coin) { + direction.value = 'cashOut' + selectedCoin.value = coin + } + + function addBill(denomination: number) { + bills.value.push(denomination) + fiatAmount.value = totalFiat.value + } + + function setMembership(m: Membership) { + membership.value = m + if (m.lightningAddress) { + walletAddress.value = m.lightningAddress + } + } + + function reset() { + direction.value = null + selectedCoin.value = null + fiatAmount.value = 0 + cryptoAmount.value = 0 + walletAddress.value = null + membership.value = null + bills.value = [] + } + + return { + // State + direction, + selectedCoin, + fiatAmount, + cryptoAmount, + walletAddress, + membership, + bills, + + // Computed + hasMembership, + discountPercent, + totalFiat, + effectiveRate, + + // Actions + startCashIn, + startCashOut, + addBill, + setMembership, + reset, + } +}) +``` + +### WebSocket Composable + +```typescript +// ui/src/composables/useWebSocket.ts +import { ref, onMounted, onUnmounted } from 'vue' +import { useUIStore } from '../stores/ui' +import { useTransactionStore } from '../stores/transaction' + +interface BrainMessage { + action: string + state?: string + data?: Record +} + +export function useWebSocket() { + const ws = ref(null) + const connected = ref(false) + const reconnectAttempts = ref(0) + + const ui = useUIStore() + const transaction = useTransactionStore() + + function connect() { + const host = import.meta.env.VITE_WS_HOST ?? 'localhost' + const port = import.meta.env.VITE_WS_PORT ?? '8080' + + ws.value = new WebSocket(`ws://${host}:${port}`) + + ws.value.onopen = () => { + connected.value = true + reconnectAttempts.value = 0 + console.log('WebSocket connected') + } + + ws.value.onclose = () => { + connected.value = false + scheduleReconnect() + } + + ws.value.onerror = (error) => { + console.error('WebSocket error:', error) + } + + ws.value.onmessage = (event) => { + const message: BrainMessage = JSON.parse(event.data) + handleMessage(message) + } + } + + function handleMessage(message: BrainMessage) { + switch (message.action) { + case 'stateChange': + ui.setScreen(message.state!) + break + + case 'billInserted': + transaction.addBill(message.data!.denomination as number) + break + + case 'membershipValidated': + transaction.setMembership(message.data!.membership as Membership) + break + + case 'transactionComplete': + transaction.reset() + break + + case 'error': + ui.setError(message.data!.message as string) + break + + default: + console.log('Unknown message:', message) + } + } + + function send(action: string, data?: Record) { + if (ws.value?.readyState === WebSocket.OPEN) { + ws.value.send(JSON.stringify({ action, data })) + } + } + + function scheduleReconnect() { + if (reconnectAttempts.value < 10) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.value), 30000) + setTimeout(() => { + reconnectAttempts.value++ + connect() + }, delay) + } + } + + onMounted(() => connect()) + onUnmounted(() => ws.value?.close()) + + return { + connected, + send, + } +} +``` + +### Example Screen Component + +```vue + + + + + + + + + + + {{ t('membership.prompt.title') }} + {{ t('membership.prompt.subtitle') }} + + + + {{ t('common.yes') }} + + + + {{ t('common.no') }} + + + + + + + +``` + +#components #vue + +--- + +## i18n Strategy + +### Vue I18n Setup + +```typescript +// ui/src/i18n/index.ts +import { createI18n } from 'vue-i18n' + +// Lazy load locales +const messages = Object.fromEntries( + Object.entries( + import.meta.glob('./locales/*.json', { eager: true }) + ).map(([path, module]) => { + const locale = path.match(/\/(\w+)\.json$/)?.[1] ?? 'en' + return [locale, (module as { default: Record }).default] + }) +) + +export const i18n = createI18n({ + legacy: false, // Composition API + locale: 'en', + fallbackLocale: 'en', + messages, +}) + +export function setLocale(locale: string) { + i18n.global.locale.value = locale + document.documentElement.lang = locale + document.documentElement.dir = isRTL(locale) ? 'rtl' : 'ltr' +} + +function isRTL(locale: string): boolean { + return ['ar', 'he', 'fa', 'ur'].includes(locale) +} +``` + +### Locale Files + +```json +// ui/src/i18n/locales/en.json +{ + "common": { + "yes": "Yes", + "no": "No", + "continue": "Continue", + "cancel": "Cancel", + "back": "Back" + }, + "idle": { + "tapToStart": "Tap to Start", + "buyBitcoin": "Buy Bitcoin", + "sellBitcoin": "Sell Bitcoin" + }, + "membership": { + "prompt": { + "title": "Do you have a membership card?", + "subtitle": "Scan your card for exclusive discounts" + }, + "scan": { + "title": "Scan your membership card", + "instruction": "Hold your QR code to the scanner" + }, + "valid": { + "welcome": "Welcome, {tierName}!", + "discount": "{percent}% discount applied", + "autoSend": "Bitcoin will be sent to your wallet automatically" + }, + "invalid": { + "title": "Membership not recognized", + "tryAgain": "Try Again", + "skip": "Continue without membership" + } + }, + "transaction": { + "insertBills": "Insert bills", + "currentAmount": "Current amount: {amount}", + "sendingCoins": "Sending {coin}...", + "complete": "Transaction complete!" + } +} +``` + +#i18n #localization + +--- + +## Build Configuration + +### Vite Config + +```typescript +// ui/vite.config.ts +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + + build: { + target: 'chrome90', // Kiosk browser target + outDir: 'dist', + assetsDir: 'assets', + sourcemap: false, + minify: 'esbuild', + + rollupOptions: { + output: { + manualChunks: { + vue: ['vue', 'vue-router', 'pinia'], + i18n: ['vue-i18n'], + }, + }, + }, + }, + + server: { + port: 3000, + host: true, + }, + + // For kiosk: inline assets to reduce HTTP requests + assetsInclude: ['**/*.svg', '**/*.png'], +}) +``` + +### Package.json + +```json +{ + "name": "lamassu-machine-ui", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "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", + "vue-i18n": "^10.0.0", + "@vueuse/core": "^11.0.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", + "sass": "^1.80.0", + "eslint": "^9.14.0", + "eslint-plugin-vue": "^9.30.0" + } +} +``` + +#build #vite + +--- + +## Migration Strategy + +### Phase 1: Setup & Parallel Development + +> [!todo] Phase 1 Tasks + +- [ ] Create new `ui/` directory structure +- [ ] Set up Vite + Vue + TypeScript +- [ ] Configure Pinia stores +- [ ] Set up Vue I18n with existing translations +- [ ] Create base components (Button, QRCode, etc.) +- [ ] Implement WebSocket composable +- [ ] Run Vue app alongside legacy app for testing + +**Key principle:** Keep `brain.js` state machine unchanged. Only replace the UI layer. + +### Phase 2: Screen Migration + +> [!todo] Phase 2 Tasks + +Migrate screens one-by-one, starting with simplest: + +1. [ ] IdleScreen +2. [ ] ChooseLanguageScreen +3. [ ] ChooseCoinScreen +4. [ ] MembershipPromptScreen (new) +5. [ ] MembershipScanScreen (new) +6. [ ] ScanAddressScreen +7. [ ] InsertBillsScreen +8. [ ] SendingCoinsScreen +9. [ ] CompleteScreen +10. [ ] ErrorScreen + +### Phase 3: Component Polish + +> [!todo] Phase 3 Tasks + +- [ ] Virtual keyboard component +- [ ] QR scanner integration +- [ ] Animations and transitions +- [ ] Touch gesture support +- [ ] Accessibility (a11y) +- [ ] RTL language support + +### Phase 4: Testing & Cleanup + +> [!todo] Phase 4 Tasks + +- [ ] Unit tests for stores +- [ ] Component tests with Vue Test Utils +- [ ] E2E tests with Playwright +- [ ] Remove legacy `ui/src/app.js` +- [ ] Remove jQuery dependency +- [ ] Update documentation + +#migration #phases + +--- + +## Testing + +### Component Tests + +```typescript +// ui/src/screens/__tests__/MembershipPromptScreen.test.ts +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import MembershipPromptScreen from '../MembershipPromptScreen.vue' + +describe('MembershipPromptScreen', () => { + it('renders prompt text', () => { + const wrapper = mount(MembershipPromptScreen, { + global: { + plugins: [createTestingPinia()], + }, + }) + + expect(wrapper.text()).toContain('membership card') + }) + + it('emits membershipYes when Yes clicked', async () => { + const send = vi.fn() + vi.mock('../composables/useWebSocket', () => ({ + useWebSocket: () => ({ send, connected: ref(true) }), + })) + + const wrapper = mount(MembershipPromptScreen) + await wrapper.find('[data-test="yes-btn"]').trigger('click') + + expect(send).toHaveBeenCalledWith('membershipYes') + }) +}) +``` + +### Store Tests + +```typescript +// ui/src/stores/__tests__/transaction.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useTransactionStore } from '../transaction' + +describe('Transaction Store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('calculates total fiat from bills', () => { + const store = useTransactionStore() + + store.addBill(20) + store.addBill(20) + store.addBill(10) + + expect(store.totalFiat).toBe(50) + }) + + it('applies membership discount to rate', () => { + const store = useTransactionStore() + + store.selectedCoin = { code: 'BTC', rate: 100 } + store.setMembership({ + tier: { discountPercentage: 15 }, + }) + + expect(store.effectiveRate).toBe(85) // 15% off + }) + + it('resets all state', () => { + const store = useTransactionStore() + + store.startCashIn({ code: 'BTC', rate: 100 }) + store.addBill(20) + store.reset() + + expect(store.direction).toBeNull() + expect(store.bills).toEqual([]) + }) +}) +``` + +#testing #vitest + +--- + +## Performance Considerations + +### Bundle Size Targets + +| Chunk | Target | Reason | +|-------|--------|--------| +| Vue core | < 40kb | Framework | +| App code | < 50kb | Screens + components | +| i18n | < 30kb | Lazy load locales | +| **Total** | **< 120kb** | Fast kiosk boot | + +### Optimization Strategies + +1. **Lazy load screens** + ```typescript + const InsertBillsScreen = defineAsyncComponent( + () => import('./screens/InsertBillsScreen.vue') + ) + ``` + +2. **Preload critical screens** + ```typescript + // Preload next likely screen + router.beforeEach((to, from) => { + if (to.name === 'chooseCoin') { + import('./screens/ScanAddressScreen.vue') + } + }) + ``` + +3. **Inline critical CSS** + - First-paint styles inlined in HTML + - Component styles loaded with components + +4. **Image optimization** + - SVG for icons (scalable, small) + - WebP for photos + - Lazy load non-critical images + +#performance #optimization + +--- + +## Related Documents + +- [[modernization-plan]] - Overall modernization roadmap +- [[membership-lightning-integration]] - Membership feature +- [[Machine State Management]] - XState migration (brain.js)
{{ t('membership.prompt.subtitle') }}