Both subprojects ship from the same commit, so ordinary CI is now a single ci.yml; only the tag-driven release stands apart. The web app is built twice per run — once per base path — and every consumer downloads the artifact instead of rebuilding, replacing three redundant base-"" builds on main. Nothing deploys unless the test job is green, and android-release runs the suite before signing (ci.yml does not fire on tags, so it was the only gap). Shared toolchain setup moves into composite actions, which puts the web build and the APK on the same Node version for the first time. The Firebase PR path was still on npm ci against a stale web/package-lock.json that could resolve a different tree than pnpm-lock.yaml; drop the lockfile and the npm path with it. Also: least-privilege permissions widened per job, persist-credentials off on every checkout, concurrency groups that cancel superseded PRs but never a live deploy, npm caching for android, and the Firebase action pinned by commit SHA to match how the release actions were already pinned.
6.9 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 (GitHub Pages, canonical)
npm run build:gh
basePath="/loto"
Output: build/index.html with /loto prefix
Deploy: https://tiennm99.github.io/loto/
Workflow: .github/workflows/ci.yml (build "gh" -> deploy-pages)
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-05-09 Last synced: 2026-05-09 (Cloudflare Pages removed; GitHub Pages is the sole production target)