Files
loto/docs/system-architecture.md
T
tiennm99 f7db20c13a feat: UI polish v2 + installable PWA with offline audio
Phase 1 — Vietnamese-safe font + master empty state:
- Add @fontsource/roboto-condensed (700 weight, all subsets including
  Vietnamese). font-display:swap. tan-tan-num now resolves the bundled
  face on Android instead of system Arial Narrow.
- New MasterEmptyState.svelte: ghost 11×9 grid + "Chế độ Quản trò"
  pill + readiness microcopy. Replaces the bare line of text in
  MasterPanel's no-game state.

Phase 2 — Mode picker icons, color picker layout, header polish:
- Inline SVG glyphs above each mode button (player card / megaphone /
  two stacked cards). Communicates role at a glance.
- Color picker wrapped in a single bordered card with "Tuỳ chỉnh"
  and "Mẫu sẵn" sub-headers.
- "Mặc định" → "Đặt lại" with a bordered chip style — clearer
  affordance than the previous near-invisible footer link.
- Header subline: drop tracking-[0.28em] all-caps SaaS look; replace
  with dash-flanked lantern band ("— 🏮 Hội chợ TN1 —") for
  fairground mood.

Phase 3 — Installable PWA + offline audio:
- @vite-pwa/sveltekit with autoUpdate. Precache app shell (~353 KB
  → 213 entries) PLUS the default voice's 92 clips so first-install
  is fully offline-capable. Alternate voices fall through to a
  CacheFirst runtime rule (cached on first play, 30-day TTL,
  maxEntries: 400 for future-proofing).
- New static/manifest.webmanifest (Lô tô — Hội chợ TN1, theme #1565c0,
  background #0a0f1f, standalone, vi).
- Icons: 192/512 standard + 512 maskable, generated from a single
  rose-amber-gradient SVG source.
- app.html: manifest link, dual theme-color meta (light + dark),
  apple-touch-icon, apple-mobile-web-app-capable for proper
  standalone launch on iOS Safari.
- _headers: add manifest-src + worker-src to CSP; no-cache on /sw.js
  and /manifest.webmanifest so deploys propagate.

Tests: 115/115 pass. Build clean (305 precache entries, 0 glob
warnings).

Reviewer concerns (addressed):
- maxEntries bumped 200 → 400 (was barely enough for 2 voices).
- Default voice precached so offline-first promise holds without
  requiring users to play every clip online first.
- "do NOT add skipWaiting" comment added next to autoUpdate.
2026-04-27 20:35:59 +07:00

7.0 KiB
Raw Blame History

System Architecture

Page Flow

Entry (+layout.svelte)
  ├─ onMount: loadSettings() — restore all 9 settings keys from loto_settings,
  │   apply CSS vars, toggle <html class="dark"> on theme/OS pref, setup auto-call effect
  │
  └─ / (single page)
      ├─ [if settings.mode !== "master"]
      │   ├─ Load loto_grid, loto_crossed from localStorage
      │   ├─ Display 9×9 PlayerBoard (empty cells use --empty-cell-bg from settings)
      │   ├─ Listen to call-bus for auto-tick when master draws
      │   ├─ Generate new grid on button click
      │   ├─ Mark/unmark cells on click
      │   └─ Show bingo popup + "Chờ X" toasts
      ├─ [if settings.mode !== "player"]
      │   └─ Mount MasterPanel (controls + draw history, publishes to call-bus)
      └─ PageFooter (tagline + miti99 link)

State Model

Player Card (storagePrefix="loto")

grid: number[][]          // 9×9 numbers (0 = empty)
crossed: boolean[][]      // 9×9 marked state

Each row has exactly 5 non-zero numbers AND each column has exactly 5 (constraint-aware picker — no slack). Numbers within a column are sorted ascending top-to-bottom (lô tô hội chợ Tân Tân convention).

Settings (loto_settings)

theme: "auto" | "light" | "dark"    // Display mode
mode: "player" | "master" | "both"  // Panel visibility (replaces masterMode)
autoCallEnabled: boolean            // Enable auto-call timer
autoCallSpeed: number (110)        // Speed in seconds
emptyCellColor: "#rrggbb"           // Hex color (default #7030A0 Excel Purple)
voiceEnabledMaster: boolean         // Speak called numbers (master view)
voiceEnabledPlayer: boolean         // Speak "Chờ"/"Kinh" (player events)
voiceWaitingNumber: boolean         // Include number after "Chờ"
voice: string                       // Voice ID from audio manifest

Host State (storagePrefix="loto_master")

called: number[]          // [5, 23, 67, ...] — drawn in order
remaining: number[]       // [1, 2, 3, ...] minus called — shuffled initially
autoRunning: boolean      // Auto-call timer active

Host's Card (storagePrefix="loto_master_card")

Same as player card; isolated by prefix to allow host to play.

localStorage Keys

Prefix Grid Key Crossed Key
"loto" loto_grid loto_crossed
"loto_master_card" loto_master_card_grid loto_master_card_crossed

(Special) loto_master stores { called, remaining } for the master state.

All keys are JSON stringified. Corruption is silent (returns null).

basePath & Asset Resolution

Production (Cloudflare Pages, default)

npm run build
basePath="" (root)
Output: build/index.html
Deploy: loto.miti99.com (root domain)

Production (GitHub Pages, manual)

npm run build:gh
basePath="/loto"
Output: build/index.html with /loto prefix
Deploy: https://tiennm99.github.io/loto

Development (Local)

npm run dev
basePath="" (empty)
Dev server: http://localhost:5173

Development (Code-Server)

npm run dev:codeserver
.env.local: CODESERVER_HOST + CODESERVER_PORT
basePath="/absproxy/{PORT}"
Access: https://<proxy>/absproxy/{PORT}/

Note: Use /absproxy/{port}/proxy/{port} strips the path prefix and breaks the SvelteKit base path.

Client-Only Architecture

All pages are client-only (no SSR) because:

  • ssr: false in +layout.js disables server-side rendering
  • localStorage is unavailable on server
  • State initialization (grid, crossed) must run in browser

Files that are client-only:

  • src/routes/+page.svelte (single page)
  • src/lib/PlayerBoard.svelte (player card component)
  • src/lib/MasterPanel.svelte (host panel, mounted when settings.masterMode)

Data Flow: Master Draw (mode: "both")

1. Master taps "Xổ số" button in MasterPanel
2. Draw logic removes number from remaining
3. broadcastDraw(num) publishes to call-bus
4. PlayerBoard's $effect listens to bus.lastDrawn
5. Auto-marks cell in player's grid (if it exists)
6. If row completes → bingo popup + auto "Kinh" voice
7. MasterPanel displays hero token, appends to history

Data Flow: Mark a Cell (Player)

1. User clicks button in PlayerBoard
2. handleCellClick(row, col) fires
3. crossed = crossed.map(...) toggled state
4. $effect listens to crossed → saveCrossedState()
5. localStorage updated with new crossed state
6. $derived updates rowCompleteness matrix
7. If row complete → bingo popup; if waiting → "Chờ X" toast

Data Flow: Initial Load

1. Component mounts
2. $effect runs initial loadGrid(storagePrefix)
3. If found, load crossed state (or initialize as all false)
4. Pre-populate celebratedRows + notifiedWaitingRows sets
5. Pass grid + crossed to UI render

Animations

Name Duration Use
fade-in 0.2s Modal background entry
pop-in 0.4s Bingo popup scale + scale-back
bounce-slow 1.5s infinite Emoji on bingo popup
spin-slow 3s infinite on bingo popup
spin-slow-reverse 3s infinite reverse 🎊 on bingo popup
toast 5s forwards "Chờ X" notification fade in/build
cell-crossed::after instant Red diagonal line in marked cells

Call Bus (Master ↔ Player Wiring)

In mode: "both", MasterPanel and PlayerBoard communicate via call-bus.svelte.js:

call-bus.svelte.js:
├─ bus.lastDrawn = { num, at }     // Published by master draw
├─ broadcastDraw(num)              // Called by MasterPanel on each draw
└─ resetBus()                       // Called on new game

PlayerBoard:
└─ $effect(() => { bus.lastDrawn })  // Listens for draw broadcast
   └─ Auto-marks cell if number exists on board
   └─ Triggers bingo popup if row completes

Each draw creates a new object (even repeat numbers) to ensure reactive re-fire.

Offline Capability & PWA

Static Storage: All game state in localStorage. No API calls. Fully functional offline.

Service Worker: @vite-pwa/sveltekit auto-generates /sw.js with:

  • App shell precache (~353 KB): HTML, CSS, JS bundles for instant load
  • Audio runtime caching: Voice clips cached on-demand (CacheFirst strategy)
  • Navigation: NetworkFirst (try network, fallback to cache)
  • Auto-update: Detects new versions, shows reload toast, skipWaiting on user action

Manifest: /manifest.webmanifest enables install prompt on Android/iOS:

  • Display: standalone (fullscreen app-like mode, no browser chrome)
  • Theme colors: Indigo primary, white background
  • Icons: 192px + 512px PNG (auto-generated)
  • Start URL: / with basePath awareness

Installation Flow:

  1. App loads, registers SW (production only, not dev)
  2. Browser detects manifest → shows install prompt (native, no custom banner)
  3. User installs → app appears on home screen, launches in standalone mode
  4. Updates: SW checks for new version; if found, toast appears; user can reload

Last reviewed: 2026-04-27 Last synced: 2026-04-27 (UI polish v2 + PWA: font, mode picker, color picker, empty state, PWA stack)