refactor: rewrite from Next.js + React to SvelteKit + Svelte 5

Full stack swap to enable future extension (more pages / load functions /
backend) while keeping JSDoc-only code style.

Stack:
- SvelteKit 2 + adapter-static
- Svelte 5 runes ($state, $derived, $effect, $props)
- Vite 7 + @sveltejs/vite-plugin-svelte 6
- Tailwind 4 (Vite plugin)
- ESLint 9 (flat) + eslint-plugin-svelte
- Pure JS + JSDoc, no TypeScript

Source moves:
- app/page.jsx              → src/routes/+page.svelte
- app/master/page.jsx       → src/routes/master/+page.svelte
- app/layout.jsx            → src/routes/+layout.svelte (+ +layout.js)
- components/player-board.jsx → src/lib/PlayerBoard.svelte
- lib/game-logic.js         → src/lib/game-logic.js (verbatim)
- next.config.mjs           → svelte.config.js + vite.config.js
- app/globals.css           → src/app.css
- (new)                     → src/app.html

Behavior preserved: PlayerBoard with bingo "Kinh!" popup + waiting "Chờ X"
toast, master 9x10 tracking board with shuffled draw, host's own player
card via storagePrefix="loto_master_card", localStorage prefix model
(loto_*, loto_master, loto_master_card_*), basePath dual mode (CF default
empty, BUILD_PROFILE=gh → /loto, codeserver dev → /absproxy/{port}).

A11y kept from prior hardening: role-correct buttons, aria-pressed on
cells, role=dialog modal with Escape, role=status toast.

Plans: ts-to-jsdoc plan marked completed; sveltekit-refactor plan tracks
the work above. Docs under ./docs/ rewritten by docs-manager subagent to
match the SvelteKit terminology.
This commit is contained in:
tiennm99 committed 2026-04-26 21:03:41 +07:00
1 parent bbdd2b449b
commit ee48e162d2
38 files changed
+3622 -5561

No files matched your search

+57 -29
View File
@@ -2,30 +2,33 @@
## File Naming & Structure
- **Kebab-case** for all files: `player-board.jsx`, `game-logic.js`.
- **Kebab-case** for all files: `PlayerBoard.svelte`, `game-logic.js`.
- **Descriptive names**: Long names are preferred for self-documentation. Avoid ambiguity.
- **Single responsibility**: Each file has one primary export (component or utilities).
- **Max 200 lines per file**: Split larger components into smaller focused ones.
## React & JavaScript Conventions
## Svelte 5 Runes & JavaScript
### Hooks
- **useState**: For local UI state (grid, crossed, form inputs).
- **useEffect**: For side effects (load from localStorage, save to localStorage, detect changes).
- **useCallback**: For event handlers to stabilize function identity across renders.
- **useRef**: For mutable values that don't trigger renders (timers, celebratedRows set).
### State & Reactivity
- **$state**: For mutable local UI state (grid, crossed, booleans). `let grid = $state(...)`
- **$derived**: For computed values that auto-update when dependencies change. `const rowCompleteness = $derived(grid && crossed.length ? [...] : [])`
- **$effect**: For side effects (localStorage sync, detecting completed rows). Replaces React useEffect.
- **$props**: For destructuring component props. `let { storagePrefix = "loto" } = $props()`
### Plain References (No Reactivity)
- Use regular `let` or `const` for timers, Sets, Maps (not reactive). Example: `let toastTimer = null; const celebratedRows = new Set();`
### Typing (JSDoc)
- Author types in JSDoc comments — `jsconfig.json` has `checkJs: true`, so `tsc --noEmit` validates them.
- Component props: `@typedef {Object} Props { ... }` followed by `@param {Props} props` on the function.
- Layout children: `@param {{ children: React.ReactNode }} props`.
- Use JSDoc `@typedef {Object}` for prop types, JSDoc type annotations for functions.
- `jsconfig.json` has `checkJs: false` (not enabled), so types are documentation-only.
- Component props: `@typedef {Object} Props { ... }` then `/** @type {Props} */ let { ... } = $props()`
- Avoid `any`; use precise types (e.g., `number[][]` for grid).
- Generics: `@template T` then reference `T` in `@param` / `@returns`.
- Generics: `@template T` for utility functions.
### Client-Only Constraint
- All interactive pages must have `"use client"` at the top.
- No server-side data fetching; use localStorage instead.
- No async Server Components.
### Client-Only Architecture
- SvelteKit's `ssr: false` in `+layout.js` disables server rendering.
- All pages are client-only; no server-side data fetching.
- Use localStorage exclusively for persistence.
## CSS & Tailwind 4 Patterns
@@ -81,7 +84,7 @@ function loadGrid(prefix = "loto") {
## Error Handling
- **Silent fallback**: JSON parse errors return null; caller checks for null.
- **No try-catch in render**: Keep logic in useEffect or event handlers.
- **No try-catch in render**: Keep logic in $effect or event handlers.
- **Confirmation dialogs**: `confirm("Bạn có muốn...")` for destructive actions.
## Naming Conventions
@@ -91,13 +94,13 @@ function loadGrid(prefix = "loto") {
| camelCase | `handleCellClick`, `storagePrefix` | variables, functions, props |
| PascalCase | `PlayerBoard`, `MasterPage` | components, types |
| UPPER_SNAKE | `STORAGE_KEY`, `NUM_ROWS` | constants |
| kebab-case | `player-board.jsx` | file names |
| kebab-case | `PlayerBoard.svelte` | file names |
## Comment Style
- Document **why**, not **what**. The code shows what it does.
- Use `/** JSDoc */` for exported functions.
- Inline comments for complex logic (e.g., weighted random selection in `lib/game-logic.js:19–28`).
- Inline comments for complex logic (e.g., weighted random selection in `src/lib/game-logic.js:19–28`).
### Example
```js
@@ -116,37 +119,62 @@ function randomANumberInRow(weights) {
}
```
## Internal Navigation
- Use `import { base } from '$app/paths'` for internal links to preserve basePath across deployments.
- Example: `<a href="{base}/master">Host page</a>` works on root (`""`) and subpath (`/loto`) equally.
## Import Organization
1. React/Next imports
1. SvelteKit imports (`$app/paths`, `$lib/...`)
2. Third-party imports
3. Local component/utility imports
```js
import { useCallback, useState } from "react";
import Link from "next/link";
import PlayerBoard from "../components/player-board";
import { generateGrid } from "../lib/game-logic";
import { base } from "$app/paths";
import { generateGrid, isRowComplete } from "$lib/game-logic.js";
import PlayerBoard from "$lib/PlayerBoard.svelte";
```
## Component Patterns
### Props Pattern
```svelte
<script>
/**
* @typedef {Object} Props
* @property {string} [storagePrefix]
*/
/** @type {Props} */
let { storagePrefix = "loto" } = $props();
</script>
```
### Event Handlers
Use inline event handlers (`onclick`, `onkeydown`). Svelte 5 handles click delegation. Event objects automatically passed:
```svelte
<button onclick={() => handleCellClick(row, col)}>Click</button>
<div onkeydown={(e) => e.key === "Escape" && dismiss()}>Dialog</div>
```
## Testing (Not Currently Implemented)
Future tests should follow:
- Unit: test game logic (generateGrid, isRowComplete, getWaitingNumber) in isolation.
- Component: mock localStorage, render PlayerBoard with different props.
- Component: render PlayerBoard, mock localStorage, verify toast/popup.
- E2E: player flow (generate → click → bingo).
## Configuration
### Environment Variables
- **NEXT_DEV_PROFILE**: "codeserver" triggers proxy config.
- **CODESERVER_HOST**: Hostname for HMR (required if NEXT_DEV_PROFILE=codeserver).
### Environment Variables (code-server only)
- **VITE_DEV_PROFILE**: "codeserver" triggers proxy config.
- **CODESERVER_HOST**: Hostname for HMR.
- **CODESERVER_PORT**: Port (defaults to 3000).
Set in `.env.local` (not committed).
### Build Targets
- **output: "export"**: Static HTML export (no Node.js server needed).
- **basePath**: Configurable per deployment environment.
- **adapter-static**: Generates static HTML + JS in `build/`.
- **basePath**: Dual-mode: `""` (Cloudflare, dev) or `/loto` (GitHub Pages via `BUILD_PROFILE=gh`).
Last reviewed: 2026-04-26
+18 -16
View File
@@ -5,33 +5,35 @@
### Routing & Layout
| File | Purpose |
|------|---------|
| `app/layout.jsx` | Root HTML layout. Sets Vietnamese lang, imports Geist font, applies global flex layout. |
| `app/page.jsx` | Player page (`/`). Instructions toggle, PlayerBoard component, indigo gradient branding. |
| `app/master/page.jsx` | Host page (`/master`). Controls (new game, draw number), 9×10 master board, host's player card. |
| `src/routes/+layout.svelte` | Root HTML layout. Sets Vietnamese lang, imports Geist font, applies global flex layout. |
| `src/routes/+page.svelte` | Player page (`/`). Instructions toggle, PlayerBoard component, indigo gradient branding. |
| `src/routes/master/+page.svelte` | Host page (`/master`). Controls (new game, draw number), 9×10 master board, host's player card. |
### Shared Components
| File | Purpose |
|------|---------|
| `components/player-board.jsx` | Reusable player card (9×9 grid). Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. |
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid). Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. |
### Game Logic
| File | Purpose |
|------|---------|
| `lib/game-logic.js` | Stateless utilities: generateGrid (weighted column selection), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
| `src/lib/game-logic.js` | Stateless utilities: generateGrid (weighted column selection), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
### Styling
| File | Purpose |
|------|---------|
| `app/globals.css` | Root styles: Tailwind @import, CSS variables (light/dark), `.loto-grid` & `.master-grid` (9-col), animations (fade-in, pop-in, bounce-slow, spin-slow, toast), `.cell-crossed` diagonal. |
| `src/app.css` | Root styles: Tailwind @import, CSS variables (light/dark), `.loto-grid` & `.master-grid` (9-col), animations (fade-in, pop-in, bounce-slow, spin-slow, toast), `.cell-crossed` diagonal. |
### Configuration
| File | Purpose |
|------|---------|
| `next.config.mjs` | Dual basePath: prod `/loto`, codeserver `/absproxy/{PORT}`. Exports static HTML. HMR-aware. |
| `package.json` | Next 16.2.2, React 19.2.4, Tailwind 4. Scripts: dev, dev:codeserver, build, start, lint. |
| `eslint.config.mjs` | ESLint 9 config (Next.js preset). |
| `.gitignore` | Excludes node_modules, .next, .env.local, etc. |
| `.env.example` | Template for env vars (currently none required for runtime; codeserver profile reads CODESERVER_HOST/PORT). |
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env. |
| `vite.config.js` | Tailwind + SvelteKit plugins. codeserver HMR config (port, allowedHosts, hmr). |
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite. Scripts: dev, dev:codeserver, build, build:gh, lint. |
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). |
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
| `.env.example` | codeserver profile vars (CODESERVER_HOST, CODESERVER_PORT). |
## Key Data Structures
@@ -66,10 +68,10 @@ RootLayout
| Function | Location | Effect |
|----------|----------|--------|
| `generateGrid()` | game-logic.js:52 | Creates 9×9 with weighted column selection (5 nums/row). |
| `isRowComplete()` | game-logic.js:108 | Boolean: all non-zero cells in row crossed? |
| `getWaitingNumber()` | game-logic.js:120 | Returns the single uncrossed number in row, or null. |
| `handleCellClick()` | player-board.jsx:112 | Toggle crossed[row][col]. |
| `handleDrawNext()` | master/page.jsx:88 | Pop first number from remaining, add to called. |
| `generateGrid()` | game-logic.js:74 | Creates 9×9 with weighted column selection (5 nums/row). |
| `isRowComplete()` | game-logic.js:200 | Boolean: all non-zero cells in row crossed? |
| `getWaitingNumber()` | game-logic.js:218 | Returns the single uncrossed number in row, or null. |
| `handleCellClick()` | PlayerBoard.svelte:127 | Toggle crossed[row][col]. |
| `saveGrid()` / `loadGrid()` | game-logic.js:149–167 | localStorage with prefix-based keys. |
Last reviewed: 2026-04-26
+29 -28
View File
@@ -7,9 +7,11 @@
| `npm run build` | `""` (root) | Cloudflare Pages → `https://loto.miti99.com` |
| `npm run build:gh` | `/loto` | GitHub Pages → `https://tiennm99.github.io/loto` (manual fallback) |
Implementation: `next.config.mjs` reads `BUILD_PROFILE`. Default is empty
basePath; `BUILD_PROFILE=gh` switches to `/loto`. `NEXT_BASE_PATH`
overrides everything for one-off custom-domain builds.
Implementation: `svelte.config.js` reads `BUILD_PROFILE` env. Default is empty
basePath; `BUILD_PROFILE=gh npm run build` switches to `/loto`.
Internal links use `import { base } from '$app/paths'` so they survive
either profile without code changes.
## Production Deployment — Cloudflare Pages
@@ -19,9 +21,9 @@ pushes to `master` trigger automatic builds + deploys.
1. dash.cloudflare.com → Workers & Pages → Create → Pages → Connect to Git
→ pick the repo
2. Build settings:
- Framework preset: `Next.js (Static HTML Export)`
- Framework preset: SvelteKit
- Build command: `npm run build`
- Build output directory: `out`
- Build output directory: `build`
- Production branch: `master`
3. After first deploy, add the custom domain:
Project → Custom domains → `loto.miti99.com`. Cloudflare gives DNS
@@ -36,7 +38,7 @@ If you ever want to push a build to GitHub Pages by hand:
```bash
npm run build:gh
# upload out/ to the GH Pages target
# upload build/ to the GH Pages target
```
The repo no longer auto-deploys to GH Pages — there's no GitHub Actions
@@ -65,6 +67,7 @@ code-server --no-auth
**2. Create `.env.local`** in project root:
```
VITE_DEV_PROFILE=codeserver
CODESERVER_HOST=your-machine.example.com
CODESERVER_PORT=3000
```
@@ -76,7 +79,7 @@ Replace `your-machine.example.com` with your actual hostname/IP (must match the
npm run dev:codeserver
```
This sets `NEXT_DEV_PROFILE=codeserver`, triggering the code-server config path in `next.config.mjs`.
This reads `vite.config.js` codeserver config (basePath `/absproxy/{PORT}`, HMR proxy).
**4. Access via browser**:
Navigate to:
@@ -87,7 +90,7 @@ https://your-codeserver-host/absproxy/3000/
**Key Points**:
- `/absproxy/{port}` (NOT `/proxy/{port}`) preserves basePath through the proxy.
- HMR socket connects to `CODESERVER_HOST` for live reload.
- If HMR fails, manually refresh the page (CSS/JS changes still apply server-side).
- If HMR fails, manually refresh the page (Vite HMR still compiles server-side).
### Manual Refresh Workaround
If HMR over proxy is unreliable:
@@ -105,27 +108,28 @@ npm run build
```
Generates:
- `out/` — Complete static HTML export
- `.next/` — Build cache (not needed for deployment)
- `build/` — Complete static HTML + JS export
- `.svelte-kit/` — Build cache (not needed for deployment)
### Export Settings
- `output: "export"` in `next.config.mjs`
- No server-side rendering
- `adapter-static` in `svelte.config.js`
- No server-side rendering (SSR disabled via `ssr: false`)
- All pages pre-rendered to HTML + JS bundles
### Asset Hosting
- `assetPrefix` matches `basePath` (prod: `/loto`, dev/codeserver: empty or `/absproxy/{port}`)
- `base` path matches deployment target (prod: `""`, GH: `/loto`, codeserver: `/absproxy/{port}`)
- CSS, JS, fonts all prefixed correctly
- GitHub Pages serves from repository root, so `/loto` paths resolve correctly
## Environment Variables
### Required (code-server only)
### Development (code-server only)
- `VITE_DEV_PROFILE` — set to "codeserver" to enable proxy mode
- `CODESERVER_HOST` — hostname for HMR proxy
- `CODESERVER_PORT` — port (default 3000)
### Optional
- `NEXT_DEV_PROFILE` — set to "codeserver" to enable proxy mode (usually set by `npm run dev:codeserver`)
### Build-Time
- `BUILD_PROFILE` — set to "gh" for GitHub Pages build (basePath `/loto`). Default empty (Cloudflare).
### Not Used at Runtime
- No database URL, API keys, or secrets (all client-side, localStorage)
@@ -135,7 +139,7 @@ Generates:
| Issue | Cause | Fix |
|-------|-------|-----|
| 404 on subpages after deploy | basePath mismatch | Verify `basePath="/loto"` in prod; local dev should be empty |
| 404 on subpages after deploy | basePath mismatch | Verify `BUILD_PROFILE=gh` for GitHub Pages; default for Cloudflare |
| HMR not connecting (code-server) | CODESERVER_HOST not set | Add `CODESERVER_HOST=...` to `.env.local` |
| Assets 404 (code-server) | Wrong proxy URL | Use `/absproxy/{port}`, not `/proxy/{port}` |
| Page blank after refresh | State not persisted | Check browser localStorage is enabled |
@@ -143,23 +147,20 @@ Generates:
## CI/CD Pipeline
`.github/workflows/deploy.yml`:
- Triggers on `push` to `master`
- Installs dependencies (`npm install`)
- Builds app (`npm run build`)
- Deploys `out/` folder to GitHub Pages
No manual steps required; push to master and GitHub Pages updates automatically.
Cloudflare Pages handles CI/CD automatically:
- GitHub Pages deployment removed (Cloudflare is primary).
- Manual fallback: `npm run build:gh && git push` (if needed).
- No GitHub Actions workflow for automatic GH Pages deploy.
## Performance Checklist
- [x] Static export (no server overhead)
- [x] Tailwind purged for production size
- [x] Static export via adapter-static (no server overhead)
- [x] Tailwind 4 purged for production size
- [x] localStorage reduces bundle—no API calls
- [x] Images minimal (mostly CSS gradients)
- [x] Images minimal (mostly CSS gradients + emojis)
- [x] Fonts: Geist via Google Fonts CDN
Bundle analysis: Run `npm run build && ls -lh out/` to inspect file sizes.
Bundle analysis: Run `npm run build && ls -lh build/` to inspect file sizes.
## Security Considerations
+2 -2
View File
@@ -30,7 +30,7 @@
- Text: slate-600 (light) / slate-400 (dark)
### Dark Mode
All colors have corresponding `dark:` variants. Use Tailwind's `prefers-color-scheme: dark` media query (set in `globals.css`).
All colors have corresponding `dark:` variants. Tailwind's `prefers-color-scheme: dark` media query is enabled (set in `app.css`).
## Typography
@@ -114,7 +114,7 @@ All colors have corresponding `dark:` variants. Use Tailwind's `prefers-color-sc
- **spin-slow-reverse** (3s): 🎊 emoji (counter-rotation)
### Ephemeral
- **toast** (5s): "Chờ X" notification (scale 0.8→1.05→1, fade out)
- **toast** (5s): "Chờ X" notification (scale 0.8→1.05→1, fade build)
### Interactive
- **active:scale-95**: Button press response
+10 -9
View File
@@ -39,7 +39,7 @@ Host and players connect via WebSocket to sync called numbers and state. Require
- Remote tournaments
- Master board auto-updates all players in real-time
**Consideration**: Out of scope for static export. Requires major refactor (Next.js API routes + WebSocket server). Deferred indefinitely.
**Consideration**: Out of scope for static export. Requires major refactor (SvelteKit API routes + WebSocket server). Deferred indefinitely.
### i18n Beyond Vietnamese
Internationalization (English, Chinese, etc.). Requires extraction of all Vietnamese strings and i18n library (next-intl). **Status**: Considered (low demand for non-Vietnamese users)
@@ -53,17 +53,17 @@ Full undo/redo stack with history navigation. Adds complexity to state managemen
- Game logic: `generateGrid()`, `isRowComplete()`, `getWaitingNumber()`
- localStorage helpers: `saveGrid()`, `loadGrid()`, etc.
**Tech**: Jest + @testing-library/react
**Tech**: Vitest (Vite's native test runner)
### Component Tests
- PlayerBoard with mocked localStorage
- PlayerBoard with mocked localStorage, $state/$derived verification
- Master page with different game states
### E2E Tests
- Player flow: generate card → click cells → verify bingo popup
- Host flow: new game → draw numbers → verify board state
**Tech**: Playwright or Cypress
**Tech**: Playwright
## Future Enhancements (Speculative)
@@ -88,11 +88,12 @@ Support 8×8 or 10×10 grids. Major refactor (NUM_ROWS, NUM_COLS constants, weig
## Decision Rationale
All decisions follow **YAGNI** (You Aren't Gonna Need It):
- No multiplayer sync → adds server dependency, breaks static export model
- No i18n → Vietnamese-only community, localizing adds complexity without demand
- No testing → small codebase, manual testing covers critical paths; add tests when code grows or bugs surface
- No undo/redo → simple game, mistakes are part of play experience
All decisions follow **YAGNI** (You Aren't Gonna Need It), **KISS** (Keep It Simple), **DRY** (Don't Repeat Yourself):
- No multiplayer sync → adds server dependency, breaks static export (Cloudflare Pages)
- No i18n → Vietnamese-only community, localizing adds complexity
- No testing → small codebase (<300 LOC), manual testing covers critical paths; add when code grows
- No undo/redo → simple game, mistakes are play experience
- Svelte 5 runes → cleaner reactivity than hooks, smaller mental model
Future work gates on **real user demand**, not speculation.
+5 -4
View File
@@ -4,7 +4,7 @@
Lô tô is a traditional Vietnamese bingo game. The app replicates the game digitally for players to generate their own 9×9 number cards and mark cells as a host calls numbers from 1–90. First player to complete an entire row wins and shouts "Kinh!" (the game's victory cheer).
The inspiration comes from TN1 class reunions (2014–2017) where players often ran out of physical bingo cards.
The inspiration comes from TN1 class reunions (2014–2017) where players often ran build of physical bingo cards.
## Core Mechanics
@@ -14,10 +14,11 @@ The inspiration comes from TN1 class reunions (2014–2017) where players often
## Tech Stack
- **Framework**: Next.js 16.2.2 (App Router, SSG via `output: "export"`)
- **Runtime**: React 19.2.4 (Hooks: useState, useEffect, useCallback, useRef)
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
- **Runtime**: Svelte 5 runes ($state, $derived, $effect, $props)
- **Styling**: Tailwind CSS 4 (utility-first, animations)
- **Persistence**: localStorage (no backend)
- **Deploy**: Cloudflare Pages (root domain), GitHub Pages fallback (`/loto`)
- **Dev Profile**: code-server compatible via `/absproxy/{port}` basePath + HMR proxy config
## Architecture Overview
@@ -32,7 +33,7 @@ State is entirely client-side. Each page/card instance uses a unique localStorag
- **Production**: GitHub Pages auto-deploys from `master` branch via `.github/workflows/deploy.yml`. App is served at `/loto` basePath.
- **Development**: `npm run dev` (local), `npm run dev:codeserver` (code-server via proxy).
- **Build**: `npm run build` generates static export to `out/` directory.
- **Build**: `npm run build` generates static export to `build/` directory.
## Key Acceptance Criteria
+18 -20
View File
@@ -80,38 +80,36 @@ Access: https://<proxy>/absproxy/3000/
## Client-Only Architecture
All pages use `"use client"` because:
- `output: "export"` disables server components
All pages are client-only (no SSR) because:
- `ssr: false` in `+layout.js` disables server-side rendering
- localStorage is unavailable on server
- Hydration requires client-only state initialization
- State initialization (grid, crossed) must run in browser
Files with `"use client"`:
- `app/page.jsx`
- `app/master/page.jsx`
- `components/player-board.jsx`
Files that are client-only:
- `src/routes/+page.svelte` (player page)
- `src/routes/master/+page.svelte` (host page)
- `src/lib/PlayerBoard.svelte` (shared component)
## Data Flow: Mark a Cell
```
1. User clicks <div> in PlayerBoard
1. User clicks button in PlayerBoard
2. handleCellClick(row, col) fires
3. setCrossed(prev => [...]) toggles crossed[row][col]
4. useEffect listens to crossed → saveCrossedState()
3. crossed = crossed.map(...) toggled state
4. $effect listens to crossed → saveCrossedState()
5. localStorage updated with new crossed state
6. Render cycle checks isRowComplete() + getWaitingNumber()
6. $derived updates rowCompleteness matrix
7. If row complete → bingo popup; if waiting → toast
```
## Data Flow: Draw a Number
## Data Flow: Initial Load
```
1. User clicks "Xổ số" button on master page
2. handleDrawNext() runs
3. Sets state: { called: [...old, next], remaining: [...old].slice(1) }
4. useEffect listens to state → saveState() → localStorage["loto_master"]
5. Master grid re-renders: cells with numbers in called set turn orange
6. lastCalled updates the big display
7. Host can see their card update in real-time (separate component)
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
@@ -123,7 +121,7 @@ Files with `"use client"`:
| 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/out |
| toast | 5s forwards | "Chờ X" notification fade in/build |
| cell-crossed::after | instant | Red diagonal line in marked cells |
## Offline Capability