Add Vue 3 UI modernization plan for lamassu-machine

Replaces legacy vanilla JS + jQuery with modern stack:
- Vue 3 with Composition API
- TypeScript throughout
- Pinia for state management
- Vite for building
- Vue I18n for localization

Includes:
- Complete project structure
- Core component examples (App.vue, stores, composables)
- WebSocket integration pattern
- i18n strategy preserving existing translations
- 4-phase migration plan
- Testing patterns with Vitest
- Performance targets (<120kb total bundle)

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

View file

@ -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
<!-- ui/src/App.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useUIStore } from './stores/ui'
import { useWebSocket } from './composables/useWebSocket'
// Screens
import IdleScreen from './screens/IdleScreen.vue'
import ChooseCoinScreen from './screens/ChooseCoinScreen.vue'
import InsertBillsScreen from './screens/InsertBillsScreen.vue'
// ... other screens
const ui = useUIStore()
const { connected } = useWebSocket()
const screenComponent = computed(() => {
const screens: Record<string, Component> = {
idle: IdleScreen,
chooseCoin: ChooseCoinScreen,
insertBills: InsertBillsScreen,
// ... map all states to screens
}
return screens[ui.currentScreen] ?? IdleScreen
})
</script>
<template>
<div
class="app"
:class="[ui.theme, { rtl: ui.isRTL }]"
:dir="ui.isRTL ? 'rtl' : 'ltr'"
>
<Transition name="screen" mode="out-in">
<component :is="screenComponent" :key="ui.currentScreen" />
</Transition>
<!-- Global overlays -->
<LoadingOverlay v-if="ui.isLoading" />
<ErrorOverlay v-if="ui.error" :message="ui.error" />
<ConnectionLost v-if="!connected" />
</div>
</template>
<style lang="scss">
@import './styles/main.scss';
.app {
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-primary);
color: var(--text-primary);
&.rtl {
direction: rtl;
}
}
.screen-enter-active,
.screen-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.screen-enter-from {
opacity: 0;
transform: translateX(20px);
}
.screen-leave-to {
opacity: 0;
transform: translateX(-20px);
}
</style>
```
### 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<Coin | null>(null)
const fiatAmount = ref(0)
const cryptoAmount = ref(0)
const walletAddress = ref<string | null>(null)
const membership = ref<Membership | null>(null)
const bills = ref<number[]>([])
// 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<string, unknown>
}
export function useWebSocket() {
const ws = ref<WebSocket | null>(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<string, unknown>) {
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
<!-- ui/src/screens/MembershipPromptScreen.vue -->
<script setup lang="ts">
import { useWebSocket } from '../composables/useWebSocket'
import { useI18n } from 'vue-i18n'
import BaseButton from '../components/common/BaseButton.vue'
const { send } = useWebSocket()
const { t } = useI18n()
function handleYes() {
send('membershipYes')
}
function handleNo() {
send('membershipNo')
}
</script>
<template>
<div class="screen membership-prompt">
<div class="content">
<div class="icon">
<img src="/images/membership-card.svg" alt="" />
</div>
<h1 class="title">{{ t('membership.prompt.title') }}</h1>
<p class="subtitle">{{ t('membership.prompt.subtitle') }}</p>
<div class="actions">
<BaseButton
variant="primary"
size="large"
@click="handleYes"
>
{{ t('common.yes') }}
</BaseButton>
<BaseButton
variant="secondary"
size="large"
@click="handleNo"
>
{{ t('common.no') }}
</BaseButton>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.membership-prompt {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.content {
text-align: center;
max-width: 600px;
}
.icon {
margin-bottom: 2rem;
img {
width: 120px;
height: 120px;
}
}
.title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
}
.subtitle {
font-size: 1.25rem;
opacity: 0.8;
margin-bottom: 3rem;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
}
}
</style>
```
#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<string, string> }).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)