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.
7.0 KiB
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 (1–10) // 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: falsein+layout.jsdisables 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 whensettings.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:
- App loads, registers SW (production only, not dev)
- Browser detects manifest → shows install prompt (native, no custom banner)
- User installs → app appears on home screen, launches in standalone mode
- 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)