chore(plans): delete shipped plan folders, inline residual TODOs

All implemented plan folders removed (current backlog + 8 archived).
PWA verification checklist preserved inline in todo.md as the only
remaining post-deploy work.
This commit is contained in:
tiennm99 committed 2026-04-28 14:11:22 +07:00
1 parent 0f4c616b41
commit 5b647e76ac
59 files changed
+46 -7368

No files matched your search

@@ -1,92 +0,0 @@
---
name: Auto-tick integration test
phase: 1
status: completed
priority: high
effort: 1-2h
completed: 2026-04-28
---
# Phase 1 — Auto-tick integration test
## Context
- TODO entry: highest-leverage item ("dedup-by-`at` fix already caught a P0")
- `src/lib/PlayerBoard.svelte:157-171` (auto-tick effect, dedup guard)
- `src/lib/call-bus.svelte.js` (bus driver — already tested)
- `src/lib/game-logic.js` — `findUncrossedCell` (pure helper)
- Existing test infra: vitest + happy-dom, **no** `@testing-library/svelte`
## Decision
Don't add `@testing-library/svelte` just for one component. Extract the
auto-tick body into a pure helper in `game-logic.js`, test that. The
effect in `PlayerBoard.svelte` becomes a thin wrapper that calls the
helper and applies the result.
## Files
- Create: `src/lib/auto-tick.js`
- Create: `src/lib/auto-tick.test.js`
- Modify: `src/lib/PlayerBoard.svelte` — replace effect body with helper call
## Helper API
```js
/**
* @param {object} args
* @param {number[][]} args.grid
* @param {boolean[][]} args.crossed
* @param {{num: number, at: number} | null} args.lastDraw
* @param {number} args.lastHandledAt
* @param {"player" | "master" | "both"} args.mode
* @returns {{ crossed: boolean[][], lastHandledAt: number, changed: boolean }}
*/
export function processAutoTick({ grid, crossed, lastDraw, lastHandledAt, mode }) {
if (!lastDraw) return { crossed, lastHandledAt, changed: false };
if (lastDraw.at === lastHandledAt) return { crossed, lastHandledAt, changed: false };
const next = { lastHandledAt: lastDraw.at };
if (mode !== "both") return { crossed, ...next, changed: false };
if (!grid || crossed.length === 0) return { crossed, ...next, changed: false };
const target = findUncrossedCell(grid, crossed, lastDraw.num);
if (!target) return { crossed, ...next, changed: false };
const updated = crossed.map((row, ri) =>
ri === target.row ? row.map((v, ci) => (ci === target.col ? true : v)) : row
);
return { crossed: updated, ...next, changed: true };
}
```
## Test cases (5)
1. NEW draw, mode=both, number on board → cell crossed, `changed: true`
2. Same `at` re-fire → no mutation, `changed: false`, `lastHandledAt` unchanged
3. Manual untick after auto-tick, then SAME number with NEW `at` → re-crosses
4. mode=player → no mutation regardless of draw
5. Number not on board → no mutation, `lastHandledAt` advances (we still consume the event)
## Steps
1. Create `auto-tick.js` with `processAutoTick` (above). Import `findUncrossedCell`.
2. Create `auto-tick.test.js` — mirror style of `call-bus.test.js`.
3. Refactor `PlayerBoard.svelte:157-171` effect:
```js
$effect(() => {
const drawn = bus.lastDrawn;
const result = processAutoTick({
grid, crossed, lastDraw: drawn,
lastHandledAt: lastHandledDrawAt,
mode: settings.mode,
});
lastHandledDrawAt = result.lastHandledAt;
if (result.changed) crossed = result.crossed;
});
```
4. `npm test` — all green.
5. Manual smoke: open app in mode=both, draw a number from master panel, confirm player cell crosses.
## Success
- 5 unit cases pass.
- `npm test` green (no regression in existing tests).
- Manual smoke confirms behavior unchanged.
## Risks
- The effect tracks `bus.lastDrawn` reactively — extracting body into a
helper means the effect still needs to *read* `bus.lastDrawn` for
reactivity. The wrapper above does that correctly (read inside the
`$effect` body before passing to helper).
@@ -1,97 +0,0 @@
---
name: CI inline-script guard
phase: 2
status: completed
priority: high
effort: 30m
completed: 2026-04-28
---
# Phase 2 — CI inline-script guard
## Context
- TODO: SvelteKit emits one inline bootstrap script. CSP relaxed to
`'unsafe-inline'` to accommodate. If a future SvelteKit upgrade adds
another inline block, we want CI to fail loudly.
- `static/_headers:2` — current CSP includes `script-src 'self' 'unsafe-inline'`.
- No CI build job today (Cloudflare Pages builds on its own; only
`.github/workflows/deploy-github-pages.yml` is a redirect-only job).
## Decision
Add an `npm run verify:build` script that:
1. Counts `<script>` tags in `build/index.html`
2. Fails if count > 1 (current expected: 1 inline bootstrap)
3. Wires into `npm run build` as a sanity post-step (or kept manual via
`package.json` scripts so dev builds aren't slowed).
Then add a minimal GH Actions job that runs `npm ci && npm run build &&
npm run verify:build`.
## Files
- Create: `scripts/verify-build-inline-scripts.mjs`
- Create: `.github/workflows/verify-build.yml`
- Modify: `package.json` — add `"verify:build"` script
## Script (`scripts/verify-build-inline-scripts.mjs`)
```js
#!/usr/bin/env node
import { readFileSync } from "node:fs";
const EXPECTED_INLINE = 1;
const html = readFileSync("build/index.html", "utf8");
const inline = (html.match(/<script(?![^>]*\bsrc=)[^>]*>/g) || []).length;
if (inline > EXPECTED_INLINE) {
console.error(
`verify-build: found ${inline} inline <script> tags in build/index.html (expected ${EXPECTED_INLINE}).\n` +
`If this is intentional, update EXPECTED_INLINE in scripts/verify-build-inline-scripts.mjs ` +
`AND add the SHA-256 hash(es) to static/_headers script-src.`
);
process.exit(1);
}
console.log(`verify-build: ${inline} inline <script> tag(s) — OK.`);
```
## Workflow (`.github/workflows/verify-build.yml`)
```yaml
name: Verify build
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- run: npm run verify:build
```
## Steps
1. Write `scripts/verify-build-inline-scripts.mjs` (executable not required, run via node).
2. Add `"verify:build": "node scripts/verify-build-inline-scripts.mjs"` to `package.json`.
3. Build locally and run — confirm passes with `EXPECTED_INLINE = 1`.
4. Tweak EXPECTED to 0 temporarily, confirm script fails. Reset to 1.
5. Add `.github/workflows/verify-build.yml`.
6. Push, observe green action.
## Success
- `npm run verify:build` exits 0 on current build.
- Tampering EXPECTED to 0 causes exit 1 (proves guard works).
- GH Actions runs on push/PR to main.
## Edge cases
- Module scripts (`<script type="module" src="...">` with src) excluded by `(?![^>]*\bsrc=)` — only inline counted.
- Whitespace before `>` handled by `[^>]*`.
@@ -1,69 +0,0 @@
---
name: Mode picker glyph redesign
phase: 3
status: completed
priority: medium
effort: 45m
completed: 2026-04-28
---
# Phase 3 — Mode picker glyph redesign
## Context
- `src/lib/SettingsButton.svelte:259-278` — three SVG glyphs for
player / master / both modes.
- TODO complaint: "Both" two-stacked-rectangles reads as "windows", not
the intended "player + master" combination. Player rect is clear,
megaphone is abstract but acceptable.
## Decision
Two paths considered, picking **Path B**:
- ❌ Path A: redesign the "Both" glyph to be more obvious. Risk: any
abstract glyph in a 24×20 box has the same readability ceiling.
- ✅ Path B: keep glyphs as visual anchors, add an icon-pair for "Both"
(mini-grid + mini-megaphone composed), and the existing button text
label below the glyph already says "Cả hai" — that text is the real
affordance. Tighten the SVG.
## Files
- Modify: `src/lib/SettingsButton.svelte:269-275` — replace "both" SVG only
## New "Both" glyph
Instead of two stacked rectangles, show a small grid (player) AND a
small megaphone shape side-by-side:
```html
{:else}
<svg viewBox="0 0 28 16" class="w-7 h-5" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<!-- Player grid (left) -->
<rect x="1" y="2" width="11" height="12" rx="1" />
<path d="M1 6h11M1 10h11M5 2v12M9 2v12" />
<!-- Megaphone (right) -->
<path d="M16 6l9-3v10l-9-3z" stroke-linejoin="round" />
<path d="M16 6v4" />
</svg>
{/if}
```
Outcome: glyph reads as "grid + megaphone" — concrete representation of
the two roles being combined. Same 1.5px stroke as siblings, fits in
existing button height.
## Steps
1. Replace the `{:else}` SVG block (lines 270-274 region).
2. Visual check: open settings modal, all three buttons aligned, glyphs
feel proportional.
3. Dark mode: confirm `currentColor` strokes contrast properly in both themes.
4. iPhone SE width (375px) — buttons stay in `grid-cols-3` row without
wrapping.
## Success
- "Both" glyph visually combines a grid + megaphone.
- All three buttons align (same row, equal heights).
- Touch targets unchanged (button padding intact).
## Out of scope
- Adding labels under glyphs — buttons already render `{label}` from
`MODES`. The TODO author proposed "tiny role labels under each glyph"
but the existing label already serves that role.
@@ -1,81 +0,0 @@
---
name: Settings modal sticky on small screens
phase: 4
status: completed
priority: medium
effort: 30m
completed: 2026-04-28
---
# Phase 4 — Settings modal sticky header/footer
## Context
- `src/lib/SettingsButton.svelte:166-459` — modal panel.
- Wrapper: `max-h-[90vh] overflow-y-auto` (line 167).
- TODO: on iPhone SE (375×667) the `<h2 id="settings-title">` and the
bottom action row scroll out of view, so user has to scroll back to
reach "Xong" / "Đặt lại".
## Decision
Make the title (`<h2>` block lines 169-177 — title + subtitle) and the
action row (lines 436-457) sticky inside the scrollable panel. Use
`position: sticky` rather than restructuring into a flex layout because
the existing `overflow-y-auto` container already creates a scrolling
context.
## Files
- Modify: `src/lib/SettingsButton.svelte`
## Changes
```html
<!-- Outer panel (line 166-167) — same -->
<div
class="relative mx-4 max-w-sm sm:max-w-md w-full max-h-[90vh] overflow-y-auto rounded-3xl bg-white dark:bg-slate-800 shadow-2xl animate-pop-in"
>
<!-- Sticky header — replace lines 169-177 -->
<div class="sticky top-0 z-10 bg-white dark:bg-slate-800 px-6 pt-6 pb-3 -mx-px rounded-t-3xl">
<h2 id="settings-title" class="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-1">
Cài đặt
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
Tuỳ chỉnh giao diện bảng lô tô
</p>
</div>
<!-- Body wrapper — wraps existing fieldsets -->
<div class="px-6">
{/* existing fieldsets here, unchanged */}
</div>
<!-- Sticky footer — replace lines 436-457 -->
<div class="sticky bottom-0 z-10 bg-white dark:bg-slate-800 px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-between gap-2 rounded-b-3xl">
<!-- existing buttons unchanged -->
</div>
</div>
```
Note: panel `p-6` is removed and replaced with per-section padding so
sticky regions can render edge-to-edge backgrounds.
## Steps
1. Move panel padding: drop `p-6` from outer; add `px-6 pt-6 pb-3` to
sticky header, `px-6` wrapper around fieldsets, `px-6 py-4` on footer.
2. Apply `sticky top-0` / `sticky bottom-0` with matching bg + z-10.
3. Add `border-t` divider on footer for visual separation when content
is behind it.
4. Test scroll on iPhone SE viewport (Chrome DevTools 375×667) —
verify h2 stays pinned at top, button row pinned at bottom.
5. Test desktop — modal still pops in cleanly, no double-rounding.
## Success
- iPhone SE: title visible while scrolling middle of modal; close
button reachable without scrolling.
- Desktop: visual unchanged or improved (subtle border above footer).
- Dark mode: sticky bg matches panel bg (no color seam).
## Risks
- `animate-pop-in` may conflict with `position: sticky` during the open
animation. Mitigation: sticky elements compute fine inside an
animated parent; if jitter shows up, scope the animation to a child
div instead of the panel.
@@ -1,131 +0,0 @@
---
name: Per-row "Chờ" indicator
phase: 5
status: completed
priority: medium
effort: 1h
completed: 2026-04-28
---
# Phase 5 — Per-row "Chờ" visual indicator
## Context
- `src/lib/PlayerBoard.svelte` — current "Chờ" notification is a
toast-only signal (`showToast` in the waiting-row $effect at lines
120-130). When the toast dismisses (5s), no persistent visual cue.
- TODO: subtle ring/glow on the `section-label` band when a Chờ row
exists in that section, so user retains awareness without re-reading
the toast.
- 9 rows split into 3 sections (`SECTIONS = [0, 3, 6]`). Section labels
rendered at `PlayerBoard.svelte:266`.
## Decision
Track waiting state reactively per row, derive per-section flag, apply
a Tailwind ring + soft pulse to that section's `section-label` band.
Already have `notifiedWaitingRows` (a plain Set, not reactive). Need a
reactive mirror. Cheapest: derive from `grid` + `crossed` directly.
## Files
- Modify: `src/lib/PlayerBoard.svelte`
## Approach
### 1. Derive waiting flag per row
Reuse `getWaitingNumber` from `game-logic.js`:
```js
const waitingRows = $derived(
grid && crossed.length
? grid.map((_, r) => getWaitingNumber(grid, crossed, r) !== null && !celebratedRows.has(r))
: []
);
```
⚠ `celebratedRows` is a Set (not reactive). For the derived flag to
update when a row completes, switch `celebratedRows` to `$state(new Set())`
or a derived `completedRows` array. Simpler: just derive from
`isRowComplete` directly:
```js
const waitingRows = $derived(
grid && crossed.length
? grid.map((_, r) =>
!isRowComplete(grid, crossed, r) &&
getWaitingNumber(grid, crossed, r) !== null
)
: []
);
```
### 2. Per-section flag
```js
const sectionHasWaiting = $derived(
SECTIONS.map((startRow) =>
waitingRows.slice(startRow, startRow + 3).some(Boolean)
)
);
```
### 3. Apply visual to section label
At line 266:
```html
<div
class="section-label {sectionHasWaiting[sectionIdx] ? 'section-label-waiting' : ''}"
>
{SECTION_LABELS[sectionIdx]}
</div>
```
### 4. CSS
Add to `src/app.css` (where `section-label` is already defined):
```css
.section-label-waiting {
box-shadow: inset 0 0 0 2px rgb(245 158 11 / 0.6);
animation: section-pulse 2.4s ease-in-out infinite;
}
@keyframes section-pulse {
0%, 100% { box-shadow: inset 0 0 0 2px rgb(245 158 11 / 0.45); }
50% { box-shadow: inset 0 0 0 2px rgb(245 158 11 / 0.85); }
}
@media (prefers-reduced-motion: reduce) {
.section-label-waiting { animation: none; }
}
```
Amber-500 chosen to match the existing toast color (`bg-amber-500/95`
at PlayerBoard.svelte:336). Reduces context-switching; user already
associates amber with "Chờ".
## Steps
1. Verify `section-label` is defined in `src/app.css` (grep first).
2. Add `.section-label-waiting` + `@keyframes` to `src/app.css`.
3. Add the two `$derived` blocks to `PlayerBoard.svelte` script.
4. Add conditional class on the `section-label` div.
5. Test: tick 8/9 cells in row 1 (section 0). Section 0 label glows.
Tick the 9th cell. Glow stops, "Kinh!" fires.
6. Test: untick a cell to break the Chờ. Glow stops.
7. Test reduced-motion: animation off, ring still present.
## Success
- Section label band has visible ring when any of its 3 rows is in Chờ.
- Glow stops on row complete or Chờ broken.
- Ring color matches amber toast.
- Reduced-motion users see static ring (no pulse).
## Risks
- Computing `getWaitingNumber` 9× per render is fine (called per cell
click only; <1ms).
- `isRowComplete` already memoized via `rowCompleteness` derived —
consider reusing it for the `!isRowComplete` half of the check:
```js
const waitingRows = $derived(
grid && crossed.length
? grid.map((_, r) => !rowCompleteness[r] && getWaitingNumber(grid, crossed, r) !== null)
: []
);
```
@@ -1,100 +0,0 @@
---
name: Confetti polish
phase: 6
status: completed
priority: low
effort: 30m
completed: 2026-04-28
---
# Phase 6 — Confetti polish
## Context
- `src/lib/PlayerBoard.svelte:34-35` — confetti emoji set + count.
- `:113` — `celebrationTier = celebratedRows.size >= 3 ? 2 : 1`.
- `:381-390` — confetti rendering loop.
- TODO: tier-2 threshold 3+ rare on 9-row card; emoji pool too small.
## Decision
### 1. Threshold: 2nd bingo OR (1st bingo + active Chờ)
Lower threshold so tier-2 fires more often without devaluing it.
"Active Chờ" means at least one OTHER row is one cell away.
```js
// Before:
celebrationTier = celebratedRows.size >= 3 ? 2 : 1;
// After:
const hasActiveCho = grid.some((_, r) =>
!celebratedRows.has(r) && getWaitingNumber(grid, crossed, r) !== null
);
celebrationTier =
celebratedRows.size >= 2 || (celebratedRows.size >= 1 && hasActiveCho)
? 2
: 1;
```
### 2. Emoji variety
Add festive Vietnamese-flavored 🥢 🎋 🏮 to the existing 🎊 ✨ 🎉 🥳.
7 emojis. 12 confetti pieces — `i % 7` distributes.
```js
const CONFETTI_EMOJI = ["🎊", "✨", "🎉", "🥳", "🥢", "🎋", "🏮"];
```
### 3. Random size 1.5–2.5rem
The existing `.confetti` CSS sets a fixed `font-size`. Override via
inline style with a stable random per piece:
```svelte
{#each CONFETTI as i (i)}
<span
class="confetti"
style:--x="{(i * 8.3 + (i % 3) * 11) % 100}%"
style:--delay="{(i * 37) % 400}ms"
style:--rot="{(i * 67) % 360}deg"
style:--size="{1.5 + ((i * 13) % 11) / 10}rem"
>
{CONFETTI_EMOJI[i % CONFETTI_EMOJI.length]}
</span>
{/each}
```
CSS — read the var:
```css
.confetti { font-size: var(--size, 2rem); /* …existing… */ }
```
`(i * 13) % 11 / 10` → values in {0.0, 0.3, 0.6, 0.9, 0.2, 0.5, ...} → 1.5–2.4rem.
## Files
- Modify: `src/lib/PlayerBoard.svelte`
- Modify: `src/app.css` (confetti `font-size` rule)
## Steps
1. `grep -n "\.confetti" src/app.css` — confirm rule location.
2. Replace `CONFETTI_EMOJI` array.
3. Update `celebrationTier` assignment around line 113. Need to import
`getWaitingNumber` (already imported).
4. Add `--size` inline style + read var in CSS.
5. Manual test:
- Tick row 1 fully → tier 1 (no confetti). ✓
- Tick row 2 fully → tier 2 (confetti). ✓ (was 3+ before)
- Reset, tick most of row 1, get one Chờ on row 2, complete row 1
→ tier 2 confetti immediately on first bingo.
6. Visual check: confetti reads as varied sizes, lantern + chopsticks
visible alongside party emojis.
## Success
- Tier-2 confetti triggers on 2nd bingo (or 1st bingo + active Chờ).
- Emoji set includes 🥢 🎋 🏮.
- Sizes vary 1.5–2.4rem.
- No regression on tier-1 silent celebration.
## Out of scope
- Adjusting confetti animation (drift speed, rotation curve).
- Reduced-motion gating — already handled by parent component or app.
Confirm in test if `prefers-reduced-motion: reduce` skips the animation
path; if not, that's a separate issue.
@@ -1,129 +0,0 @@
---
name: Strict CSP via hashed inline script
phase: 7
status: completed
priority: medium
effort: 1.5h
completed: 2026-04-28
---
# Phase 7 — Strict CSP via hashed inline script
## Context
- `static/_headers:2` — current CSP: `script-src 'self' 'unsafe-inline'`.
- The `'unsafe-inline'` is a relaxation to admit SvelteKit's bootstrap
inline `<script>` block in built `index.html`.
- Goal: replace `'unsafe-inline'` with `'sha256-…'` hash of the actual
inline. Brittle — hash changes per build — so build pipeline must
regenerate the hash and rewrite `_headers` on every build.
## Approach
### Option A — vite plugin (preferred)
A Vite plugin `closeBundle` hook reads `build/index.html`, extracts
inline scripts, computes SHA-256 of each, and rewrites `build/_headers`
with the hashes injected into `script-src`.
### Option B — postbuild npm script
Same logic but as `scripts/inject-csp-hashes.mjs` invoked via
`"build": "vite build && node scripts/inject-csp-hashes.mjs"`.
**Pick Option B** — keeps `vite.config.js` simpler and the script is
isolated. `_headers` lives in `static/` and is copied to `build/_headers`
by the static adapter, so we rewrite the *built* copy.
## Files
- Create: `scripts/inject-csp-hashes.mjs`
- Modify: `package.json` — change `"build"` and `"build:gh"` to chain
- Modify: `static/_headers` — change marker for replacement
- Verify: `Phase 2 verify-build` script still passes (still 1 inline,
just now hashed not unsafe-inline)
## Script (`scripts/inject-csp-hashes.mjs`)
```js
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
const HEADERS = "build/_headers";
const HTML = "build/index.html";
const html = readFileSync(HTML, "utf8");
const inlineScripts = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)];
if (inlineScripts.length === 0) {
console.log("inject-csp-hashes: no inline scripts found, leaving CSP as-is.");
process.exit(0);
}
const hashes = inlineScripts.map((m) => {
const body = m[1];
const h = createHash("sha256").update(body, "utf8").digest("base64");
return `'sha256-${h}'`;
});
let headers = readFileSync(HEADERS, "utf8");
const before = `script-src 'self' 'unsafe-inline'`;
const after = `script-src 'self' ${hashes.join(" ")}`;
if (!headers.includes(before)) {
console.error(`inject-csp-hashes: marker not found in ${HEADERS}.\nLooking for: ${before}`);
process.exit(1);
}
headers = headers.replace(before, after);
writeFileSync(HEADERS, headers, "utf8");
console.log(`inject-csp-hashes: injected ${hashes.length} hash(es) into ${HEADERS}.`);
```
## Package.json
```json
"scripts": {
"build": "vite build && node scripts/inject-csp-hashes.mjs",
"build:gh": "BUILD_PROFILE=gh vite build && node scripts/inject-csp-hashes.mjs"
}
```
## Steps
1. Confirm static adapter copies `static/_headers` → `build/_headers`
(it does — same as `static/_redirects`).
2. Write the script.
3. `npm run build` — confirm `build/_headers` now has
`script-src 'self' 'sha256-…'` (no `'unsafe-inline'`).
4. Local serve `build/` (e.g. `npx serve build`) → verify SW registers,
no CSP errors in DevTools console.
5. Wire `verify:build` (Phase 2) to also assert `'unsafe-inline'`
absent from `build/_headers` script-src — extend that script:
```js
const headers = readFileSync("build/_headers", "utf8");
if (/script-src[^;]*'unsafe-inline'/.test(headers)) {
console.error("verify-build: script-src still contains 'unsafe-inline'");
process.exit(1);
}
```
## Success
- `build/_headers` contains hashed `script-src` instead of `'unsafe-inline'`.
- App still loads, SW registers, no CSP violations in browser console.
- `verify:build` from Phase 2 enforces no-unsafe-inline going forward.
## Risks
- Each build rewrites the hash → `_headers` shipped to Cloudflare changes
on every deploy. That's fine; `_headers` is treated as build artifact.
- If SvelteKit upgrades and adds another inline script, the regex
catches it automatically (multi-hash). Phase 2's `EXPECTED_INLINE`
may need bumping.
- `style-src 'unsafe-inline'` stays — Svelte's `style:` directives are
attribute-level, no nonce/hash escape. Documented in
`plans/reports/security-260427-2047-pass2-full.md`.
## Edge cases
- Whitespace inside `<script>` — `.update(body, "utf8")` digests the
exact bytes; minor formatting changes shift the hash. Keep
vite/svelte versions pinned.
- If a future SvelteKit release injects script via `<script src=…>`
only, `inlineScripts.length === 0` → no rewrite needed; CSP stays
`script-src 'self'`. The early `process.exit(0)` handles that.
@@ -1,86 +0,0 @@
---
name: Audio cache LRU rule
phase: 8
status: completed
priority: low
effort: 30m
completed: 2026-04-28
---
# Phase 8 — Audio cache LRU rule
## Context
- `vite.config.js:55-69` — workbox runtime cache `loto-audio`:
- `maxEntries: 400`
- `maxAgeSeconds: 60 * 60 * 24 * 30` (30 days, age-based)
- TODO: prefer "drop voices not used in 30 days" (LRU on access) over
pure age-on-cache.
## Decision
Workbox's `expiration.maxAgeSeconds` is age-on-cache, not LRU. Workbox
ships **no built-in true LRU** plugin — but `purgeOnQuotaError: true`
plus `matchOptions.ignoreSearch` and `cacheName` is what we have.
Pragmatic options:
- **A)** Lower `maxAgeSeconds` to 7 days, accept some re-fetches.
- **B)** Set `purgeOnQuotaError: true`, leave 30d, ride out quota
pressure when storage hits caps.
- **C)** Custom workbox plugin that re-touches the cache entry on each
match (workaround for missing LRU).
**Pick A + B** combined. Voices < 200KB each; 30d → 7d won't hurt
offline UX meaningfully (only matters for voices NEVER played in 30d,
which means user has ignored that voice — they can refetch on next
play). Add `purgeOnQuotaError: true` as a belt-and-suspenders.
Skip C — TODO says "only matters at voices > 10" and we have 2.
Workbox plugin scope is unjustified now.
## Files
- Modify: `vite.config.js:55-69`
## Changes
```js
runtimeCaching: [
{
urlPattern: /\/audio\/.*\.mp3$/,
handler: "CacheFirst",
options: {
cacheName: "loto-audio",
expiration: {
maxEntries: 400,
// 7 days — clip stays cached as long as it gets played at least
// once a week. If a voice is unused for 7d, it falls out and
// re-fetches on next play. Low impact: each clip <200KB.
maxAgeSeconds: 60 * 60 * 24 * 7,
purgeOnQuotaError: true,
},
cacheableResponse: { statuses: [200] },
},
},
],
```
## Steps
1. Edit `vite.config.js`.
2. Update the comment in the block above the `additionalManifestEntries`
line if it still says "30d" or implies long retention.
3. `npm run build` — confirm SW generates without errors.
4. Hard-refresh in DevTools → Application → Cache Storage → `loto-audio`.
Trigger a play; entry appears with current timestamp.
5. Bump device clock 8 days, refresh, verify entry is purged on next
workbox housekeeping (or simulate via `caches.delete('loto-audio')`).
## Success
- `maxAgeSeconds` = 7 days.
- `purgeOnQuotaError: true`.
- Build passes.
- Default voice still precached (unchanged).
## Risks
- Users who only play once a month will see a re-fetch. Acceptable —
audio is small and same-origin.
- 7d × 184 default-voice precache entries: precache survives via
`additionalManifestEntries` (separate flow), not the runtime cache,
so 7d doesn't affect the default voice.
@@ -1,92 +0,0 @@
---
name: PWA install verification
phase: 9
status: todo
priority: medium
effort: 1h (manual)
---
# Phase 9 — PWA install verification checklist
## Context
- TODO: Lighthouse PWA = 100/100 + manual install on Android Chrome
and iOS Safari. Splash + theme color flow.
- `BUILD_PROFILE=gh` deploy under `/loto/` base — confirm SW + manifest
paths still resolve.
- Manifest: `static/manifest.webmanifest` (existing, hand-written).
- This phase is **verification, not code**. Output is a checklist
filled in after running checks.
## Pre-flight
1. Phases 1-8 merged (or at least nothing breaking).
2. Production deploy on Cloudflare Pages and a `BUILD_PROFILE=gh`
build for GitHub Pages comparison.
## Checklist
### Lighthouse (Cloudflare Pages — root base)
- [ ] Open `https://loto.miti99.com/` in incognito Chrome.
- [ ] DevTools → Lighthouse → PWA + Performance + Best Practices + a11y → Analyze.
- [ ] Score PWA = 100. If not, capture failures here:
- …
- [ ] No CSP violations in console (Phase 7 strict CSP active).
- [ ] No mixed-content warnings.
### Lighthouse (GitHub Pages — `/loto/` base)
- [ ] Open `https://tiennm99.github.io/loto/` in incognito.
- [ ] Same scoring run.
- [ ] Confirm SW URL resolves: `/loto/sw.js` (not `/sw.js`).
- [ ] Confirm manifest URL: `/loto/manifest.webmanifest`.
- [ ] Manifest icons load from `/loto/icons/...`.
### Android Chrome (physical device or emulator)
- [ ] Visit production URL.
- [ ] "Add to Home Screen" prompt offered (auto or via menu).
- [ ] Install. Launch from home screen.
- [ ] Splash screen renders with theme color (`#1565c0` light /
`#0a0f1f` dark) — match `app.html:9-10`.
- [ ] Status bar uses theme color.
- [ ] App opens in standalone mode (no Chrome chrome).
- [ ] Airplane mode → reload from home screen → app shell + default
voice clips work offline.
- [ ] Maskable icon: long-press app icon, ensure shape mask doesn't
crop the centered glyph (TODO mentions 70% safe-zone — verify
in Chrome DevTools "Show maskable preview" too).
### iOS Safari
- [ ] Visit production URL.
- [ ] Share → Add to Home Screen.
- [ ] Icon uses `apple-touch-icon` (`/icons/icon-192.png`) — round-ish
glyph, no white bars.
- [ ] Launch. iOS uses `apple-mobile-web-app-status-bar-style` =
`black-translucent` — content goes under status bar, fonts legible.
- [ ] Standalone display (no Safari toolbar).
- [ ] Airplane mode → app shell loads, default voice clips play.
### CSP + headers (production)
- [ ] `curl -I https://loto.miti99.com/` shows `Content-Security-Policy`,
`X-Content-Type-Options: nosniff`, `Referrer-Policy`,
`Permissions-Policy`, `X-Frame-Options: DENY`.
- [ ] CSP `script-src` no longer contains `'unsafe-inline'` (post-Phase 7).
## Failures → Action
If any check fails, file a follow-up issue (or extend `plans/todo.md`).
Common gotchas:
- **Manifest paths break under `/loto/` base** → check `vite.config.js`
PWA `manifest: false` + `app.html` uses `%sveltekit.assets%` (it does).
- **iOS install flow shows wrong icon** → ensure `icons/icon-192.png`
exists and is 192×192 actual size, not just declared.
- **Splash flicker dark→light** → `theme-color` media queries in
`app.html:9-10` must match the active scheme on first paint; SvelteKit
emits these statically so should be fine.
## Success
- PWA score 100 on both deploys.
- Install succeeds on Android + iOS.
- Offline-capable shell + default voice.
- No CSP/headers regressions.
## Out of scope
- Maskable icon redesign at 65%/70% safe-zone — separate phase if mask
crops too tight (TODO entry).
- iOS PWA push notifications, share targets, etc. — not requested.
@@ -1,49 +0,0 @@
---
name: Implement TODO backlog
status: in-progress
created: 2026-04-28
priority: medium
blockedBy: []
blocks: []
---
# Implement TODO backlog
Source: `plans/todo.md` (commit `00fbc97`). Brutal cuts applied per
YAGNI — parking-lot features and upstream-blocked items skipped.
9 implementation phases + 1 verification checklist.
## Phases
| # | Phase | File |
|---|-------|------|
| 1 | Auto-tick integration test ✅ | `phase-01-auto-tick-test.md` |
| 2 | CI inline-script guard ✅ | `phase-02-ci-inline-script-guard.md` |
| 3 | Mode picker glyph redesign ✅ | `phase-03-mode-picker-glyphs.md` |
| 4 | Settings modal sticky on small screens ✅ | `phase-04-settings-modal-sticky.md` |
| 5 | Per-row "Chờ" indicator ✅ | `phase-05-cho-row-indicator.md` |
| 6 | Confetti polish (threshold + variety) ✅ | `phase-06-confetti-polish.md` |
| 7 | Strict CSP via hashed inline script ✅ | `phase-07-strict-csp-hashed.md` |
| 8 | Audio cache LRU rule ✅ | `phase-08-audio-cache-lru.md` |
| 9 | PWA install verification checklist | `phase-09-pwa-verify-install.md` |
## Cuts (YAGNI)
- GhostBoardPreview extraction — rule-of-three not met
- Drop `cookie`/`serialize-javascript` overrides — blocked on upstream
- Maskable icon 70% safe-zone — manual DevTools, no code
- Voice precache for >2 voices — explicit YAGNI in TODO
- Parking-lot features — multi-card, undo, spacebar, i18n, SW toast
## Dependencies
- Phase 1 first — test safety net for everything else
- Phase 2 before Phase 7 — CI guard catches CSP regressions
- Phase 9 last — runs after everything ships
## References
- `plans/todo.md` (source list)
- `plans/archive/260427-1930-ui-polish-and-pwa-v2/` (prior PWA + CSP work)
- `plans/reports/security-260427-2047-pass2-full.md` (CSP findings)
- `plans/reports/code-reviewer-260427-2047-pass2-full.md` (test gaps)
@@ -1,68 +0,0 @@
---
phase: 1
title: Tooling swap
priority: high
effort: S
status: planned
---
# Phase 1 — Tooling swap
Replace TS toolchain with JS + JSDoc equivalents. No source-file changes yet.
## Steps
1. **Create `jsconfig.json`** at repo root, mirroring `tsconfig.json`'s essentials:
```json
{
"compilerOptions": {
"target": "ES2017",
"module": "esnext",
"moduleResolution": "bundler",
"checkJs": true,
"allowJs": true,
"jsx": "react-jsx",
"lib": ["dom", "dom.iterable", "esnext"],
"strict": false,
"paths": { "@/*": ["./*"] }
},
"include": ["**/*.js", "**/*.jsx", "**/*.mjs"],
"exclude": ["node_modules", ".next", "out"]
}
```
Notes: `checkJs: true` enables JSDoc type checking. `strict: false` because JSDoc strict mode trips on plain values; keep narrowing opt-in.
2. **Delete `tsconfig.json`** and `next-env.d.ts`.
3. **Edit `package.json`** — remove from `devDependencies`:
- `typescript`
- `@types/node`
- `@types/react`
- `@types/react-dom`
Keep `eslint-config-next` (works with both).
4. **`eslint.config.mjs`** — read it; if it imports TS-specific parser/rules, swap to JS equivalents. Most likely no change needed.
5. **Run `npm install`** to prune the TS deps from `node_modules` and update `package-lock.json`.
## Files affected
- create: `jsconfig.json`
- delete: `tsconfig.json`, `next-env.d.ts`, `tsconfig.tsbuildinfo` (already gitignored)
- modify: `package.json`, `package-lock.json`
- maybe modify: `eslint.config.mjs`
## Verify
- `ls *.ts *.tsx` returns nothing
- `npm run lint` doesn't fail because of missing TS parser
- `npm run dev` doesn't error on the now-removed `tsconfig`
## Out of scope
Source file conversion (Phase 2).
## Status: planned
@@ -1,120 +0,0 @@
---
phase: 2
title: Source conversion + JSDoc
priority: high
effort: M
status: planned
---
# Phase 2 — Source conversion + JSDoc
Rename source files and replace TS syntax with JSDoc.
## Files
| From | To |
|---|---|
| `next.config.ts` | `next.config.mjs` |
| `app/layout.tsx` | `app/layout.jsx` |
| `app/page.tsx` | `app/page.jsx` |
| `app/master/page.tsx` | `app/master/page.jsx` |
| `components/player-board.tsx` | `components/player-board.jsx` |
| `lib/game-logic.ts` | `lib/game-logic.js` |
Use `git mv` so history follows. Then strip TS-only syntax: type annotations on params/returns, `interface`, `type` aliases, generics on calls (`useState<T>` → `useState`), `as` assertions, `!` non-null. Replace each with JSDoc.
## JSDoc patterns to apply
### Function with simple types
```js
/**
* @param {number[][]} grid
* @param {boolean[][]} crossed
* @param {number} row
* @returns {boolean}
*/
export function isRowComplete(grid, crossed, row) { ... }
```
### Generic helper (was `safeParse<T>`)
```js
/**
* @template T
* @param {string | null} raw
* @param {(v: unknown) => v is T} validate
* @returns {T | null}
*/
function safeParse(raw, validate) { ... }
```
### Type predicate (validators)
Keep them; JSDoc has `v is T` syntax inside `@param` parens.
### Component props (was `interface PlayerBoardProps`)
```js
/**
* @typedef {Object} PlayerBoardProps
* @property {string} [storagePrefix] localStorage key prefix
*/
/** @param {PlayerBoardProps} props */
export default function PlayerBoard({ storagePrefix = "loto" }) { ... }
```
### `useState` initial
Inferred from initial value — usually no JSDoc needed. For nullable state, type the initial:
```js
/** @type {[number[][] | null, (v: number[][] | null) => void]} */
const [grid, setGrid] = useState(null);
```
Or simpler: just trust inference, only annotate when checkJs complains.
### `useRef` for Set
```js
/** @type {React.MutableRefObject<Set<number>>} */
const celebratedRows = useRef(new Set());
```
### `next.config.mjs`
```js
/** @type {import('next').NextConfig} */
const nextConfig = { ... };
export default nextConfig;
```
### Layout child types
```js
/** @param {{ children: React.ReactNode }} props */
export default function RootLayout({ children }) { ... }
```
## Per-file checklist
- [ ] **`next.config.mjs`** — add `@type` import. Remove `import type { NextConfig }`. Keep all logic.
- [ ] **`lib/game-logic.js`** — add `// @ts-check` at top. JSDoc on every exported function. Type predicates for `isNumberMatrix`, `isBoolMatrix`. `@template T` on `safeParse`. Remove explicit `: number[][]`, `: boolean`, `void`, etc.
- [ ] **`components/player-board.jsx`** — `@typedef` for props. JSDoc on `useRef<Set<number>>`. JSDoc on `useState` only where inference fails (e.g. nullable grid).
- [ ] **`app/layout.jsx`** — strip `Readonly<{...}>`, replace `Metadata` import with JSDoc.
- [ ] **`app/page.jsx`** — minimal; mostly remove `useState<boolean>` etc.
- [ ] **`app/master/page.jsx`** — same; the `MasterState` interface becomes a `@typedef`. The `BOARD: ReadonlyArray<...>` annotation drops; runtime `Object.freeze` keeps the immutability guarantee.
## Strategy
1. Convert `lib/game-logic.ts` first — it has the most TS-heavy code and proves the pattern.
2. Run `npx tsc --noEmit` (still works on `.js` with checkJs) after each file to catch JSDoc syntax mistakes.
3. Convert UI files top-down: layout → page → master/page → player-board.
4. Convert `next.config.ts` last; verify `npm run build` survives.
## Verify
- `npx tsc --noEmit` passes (with `checkJs: true` in jsconfig, this still type-checks via JSDoc)
- `npm run build` produces same routes table
- `npm run dev` boots without warnings about missing types
- `grep -rn ":\s*\(string\|number\|boolean\|void\|any\)" app components lib` returns no TS-style annotations
## Out of scope
- Adding new types or improving existing ones
- Refactoring component structure
- Tests
## Status: planned
@@ -1,78 +0,0 @@
---
phase: 3
title: Verify, docs, commit
priority: high
effort: S
status: planned
---
# Phase 3 — Verify, docs, commit
Final pass: end-to-end verify, update docs, commit + push.
## Steps
1. **Build check**
```bash
npm run build
```
Expect: same routes (`/`, `/_not-found`, `/master`), all static.
2. **Type check via JSDoc**
```bash
npx tsc --noEmit
```
`checkJs: true` makes tsc validate JSDoc types in `.js` / `.jsx`. Treat any new error as a regression — fix the JSDoc, don't disable the check.
3. **Lint**
```bash
npx eslint app components lib next.config.mjs
```
Expect ≤3 errors (the pre-existing `react-hooks/set-state-in-effect`).
4. **Smoke test both dev modes**
```bash
npm run dev # localhost:3000/
npm run dev:codeserver # /absproxy/{port}/
```
Verify:
- Generate a card, mark a row, see "Kinh!" popup
- Master page draws numbers, player card on /master also works (own storage prefix)
- localStorage persists across reload
5. **Update docs**
- `docs/code-standards.md` — replace TS examples with JS+JSDoc, update language references
- `docs/codebase-summary.md` — file extensions in tables (`.tsx` → `.jsx`, `.ts` → `.js`)
- `docs/system-architecture.md` — same
- `docs/development-roadmap.md` — mark "JSDoc migration" if listed; otherwise no change
- `README.md` — if it mentions TypeScript, update
6. **Commit on `dev`**
```
refactor: convert from TypeScript to JavaScript with JSDoc
- Replace .ts/.tsx with .js/.jsx
- Author types as JSDoc with checkJs: true so tsc still validates
- Drop typescript and @types/* devDependencies
- tsconfig.json -> jsconfig.json (same @/* alias)
- next.config.ts -> next.config.mjs
- Delete vendored next-env.d.ts (not needed for pure-JS Next projects)
- Update docs to reflect new file extensions
```
7. **Push** — `git push`. Branch `dev` already tracks `origin/dev`.
## Acceptance gates (must pass before commit)
- [ ] `npm run build` succeeds
- [ ] `npx tsc --noEmit` succeeds (zero new errors vs. pre-refactor)
- [ ] Lint error count unchanged or lower
- [ ] Both dev profiles boot
- [ ] Manual smoke: generate card, mark row, see bingo
- [ ] No `*.ts` / `*.tsx` files remain in `app/`, `components/`, `lib/`, root config
## Rollback
Single `git revert` of the conversion commit recovers the TS state.
## Status: planned
@@ -1,80 +0,0 @@
---
slug: ts-to-jsdoc-refactor
created: 2026-04-26
status: completed
completedAt: 2026-04-26
mode: fast
blockedBy: []
blocks: []
---
# Refactor TypeScript → JavaScript + JSDoc
Convert all `.ts` / `.tsx` source to `.js` / `.jsx`. Replace inline TS types with JSDoc `@type` / `@param` / `@returns`. Drop the TS toolchain.
## Why (per user request)
- Reduce config surface (no `tsconfig.json`, no TS deps)
- Author types in comments rather than syntax
## Why this is risky (read before starting)
| Concern | Reality |
|---|---|
| Lost compile-time type safety | JSDoc types are checked **only** if `// @ts-check` (or `checkJs: true`) is on, and even then catch a subset of what TS catches (no const generics, no template literal types, weaker inference). |
| Recent hardening regresses | `lib/game-logic.ts`'s `isNumberMatrix` / `isBoolMatrix` runtime guards stay, but the surrounding type narrowing weakens. |
| Verbosity | JSDoc blocks add 3-7 lines per non-trivial function vs. inline TS. |
| Next.js examples assume TS | Snippets in `docs/code-standards.md` need rewrites; future Stack-Overflow copy-paste fits less cleanly. |
| Tooling friction | Some IDE refactors (Rename Symbol across files, find-references) are weaker without TS server backing TS files. |
If after reading this you don't have a concrete reason JSDoc is better for *this* project, stop and don't run the plan.
## Scope
- 4 source files: `app/page.tsx`, `app/master/page.tsx`, `app/layout.tsx`, `components/player-board.tsx`
- 1 logic file: `lib/game-logic.ts`
- 1 config file: `next.config.ts`
- 1 generated file to delete: `next-env.d.ts`
- `tsconfig.json` → `jsconfig.json` (Next reads jsconfig for path aliases)
- `package.json` — drop `typescript`, `@types/*` deps
- `eslint.config.mjs` — already JS, may need rule tweaks
- `docs/code-standards.md` — update snippets
Out of scope: behavior changes, new features, test addition.
## Phases
| # | Phase | File | Effort |
|---|---|---|---|
| 1 | Tooling swap | `phase-01-tooling-swap.md` | S |
| 2 | Source conversion + JSDoc | `phase-02-source-conversion.md` | M |
| 3 | Verify, docs, commit | `phase-03-verify-and-docs.md` | S |
Total: ~1-2h focused. Mechanical work, no architecture decisions.
## Acceptance criteria
- [ ] No `.ts` or `.tsx` files remain in `app/`, `components/`, `lib/`
- [ ] `next.config.mjs` replaces `next.config.ts`
- [ ] `jsconfig.json` replaces `tsconfig.json` with the same `@/*` path alias
- [ ] `npm run build` produces same `Route (app)` table as before (`/`, `/_not-found`, `/master`, all static)
- [ ] `npm run lint` produces no NEW errors (3 pre-existing `react-hooks/set-state-in-effect` allowed)
- [ ] `npm run dev` and `npm run dev:codeserver` both start cleanly
- [ ] `package.json` `dependencies` and `devDependencies` no longer reference `typescript`, `@types/node`, `@types/react`, `@types/react-dom`
- [ ] All public functions in `lib/game-logic.js` have JSDoc with `@param` / `@returns`
- [ ] All component prop interfaces have a `@typedef` block
- [ ] `docs/code-standards.md` snippets use JS+JSDoc
- [ ] Single commit (or coherent series) on `dev` branch
## Risks / mitigations
1. **`output: "export"` + JSX in `next.config.mjs`** — Next supports `next.config.mjs`, no concern.
2. **`@/*` alias** — Works in `jsconfig.json` identically.
3. **`eslint-config-next`** — Works with both; remove TS-specific rules if any.
4. **Type narrowing in `safeParse<T>`** — Generic stays expressible in JSDoc as `@template`. Verify the validators still narrow correctly via `// @ts-check`.
5. **`React.FC` vs function declaration** — Codebase uses plain function declarations; no change.
6. **Vendor `next-env.d.ts`** — Pure-JS Next projects don't need it; delete and verify.
## Rollback
Single revert of the conversion commit. The previous TS state is preserved on `master` and earlier `dev` commits.
@@ -1,219 +0,0 @@
---
phase: 1
title: Scaffold SvelteKit project + tooling
priority: high
effort: M
status: planned
---
# Phase 1 — Scaffold SvelteKit + tooling
In-place rewrite. Replace the Next-flavored top-level config and source dirs
with SvelteKit equivalents.
## Steps
1. **Delete Next artefacts** (preserve `docs/`, `plans/`, `.gitignore`,
`.env.example`, `README.md`):
- `package.json` will be rewritten — back up scripts mentally:
`dev`, `dev:codeserver`, `build`, `build:gh`, `start`, `lint`
- Remove: `next.config.mjs`, `app/`, `components/`, `lib/`,
`eslint.config.mjs` (will be rewritten), `postcss.config.mjs`
- Keep `.env.local` (gitignored)
2. **`package.json` — new dependencies:**
```json
{
"name": "loto",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:codeserver": "VITE_DEV_PROFILE=codeserver vite dev --host 0.0.0.0",
"build": "vite build",
"build:gh": "BUILD_PROFILE=gh vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3",
"@sveltejs/kit": "^2",
"@sveltejs/vite-plugin-svelte": "^4",
"@tailwindcss/vite": "^4",
"eslint": "^9",
"eslint-plugin-svelte": "^2",
"svelte": "^5",
"tailwindcss": "^4",
"vite": "^5"
}
}
```
Note: SvelteKit's `dev` defaults to localhost; codeserver profile binds
`0.0.0.0`. Read latest versions during install (`npm install`).
3. **`svelte.config.js`** at root:
```js
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true,
}),
paths: {
// Filled by Phase 5; default empty (CF Pages root)
base: process.env.BUILD_PROFILE === 'gh' ? '/loto' : '',
},
},
};
```
4. **`vite.config.js`** at root:
```js
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
const isCodeserver = process.env.VITE_DEV_PROFILE === 'codeserver';
const host = process.env.CODESERVER_HOST;
const port = Number(process.env.CODESERVER_PORT ?? 3000);
export default {
plugins: [tailwindcss(), sveltekit()],
server: isCodeserver
? {
port,
host: true,
allowedHosts: host ? [host] : true,
hmr: host
? {
host,
protocol: 'wss',
clientPort: 443,
path: `/absproxy/${port}/`,
}
: true,
}
: { port: 3000 },
};
```
Codeserver profile mirrors `sokoban/vite/config.codeserver.mjs` —
absproxy preserves the prefix, HMR client connects through `wss://...:443`.
5. **`jsconfig.json`** — JSDoc-aware path aliases (SvelteKit auto-generates
one referencing `./.svelte-kit/tsconfig.json`, but we don't want TS
inheritance; write a minimal version):
```json
{
"compilerOptions": {
"checkJs": false,
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2022",
"allowJs": true,
"paths": { "$lib": ["./src/lib"], "$lib/*": ["./src/lib/*"] }
},
"include": ["src/**/*", "vite.config.js", "svelte.config.js"]
}
```
`checkJs: false` because user wants no TS-flavored validation. SvelteKit
may warn about an auto-generated `.svelte-kit/tsconfig.json` — ignore;
it lives inside the gitignored build cache.
6. **`eslint.config.mjs`** — minimal flat config:
```js
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
export default [
js.configs.recommended,
...svelte.configs['flat/recommended'],
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
},
{ ignores: ['build/', '.svelte-kit/', 'node_modules/'] },
];
```
Add `globals` and `@eslint/js` to devDeps as discovered during install.
7. **Tailwind 4 setup** — add `src/app.css` with:
```css
@import 'tailwindcss';
```
Replace the Next-side `app/globals.css`. Custom keyframes (`fade-in`,
`pop-in`, `bounce-slow`, `spin-slow`, `toast`) and `.cell-crossed`
diagonal class will move here in Phase 4 — verbatim copy.
8. **`.gitignore` additions:**
```
.svelte-kit
build
```
Remove now-stale entries: `.next/`, `out/`, `next-env.d.ts`,
`tsconfig.tsbuildinfo`, `repomix-output.xml` (keep — still relevant).
9. **`src/app.html`** — minimal SvelteKit HTML shell:
```html
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lô tô</title>
<meta name="description" content="Bàn số của trò chơi Lô tô" />
%sveltekit.head%
</head>
<body class="min-h-full flex flex-col">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
```
Vietnamese `lang="vi"` matches the Next layout. Geist font import will
move to `app.css` (`@import` from `next/font/google` doesn't exist
outside Next; either bundle Geist via `@fontsource/geist-sans` or use
the system stack and accept a font swap).
10. **`npm install`** to materialize the new dep tree. Verify
`@sveltejs/kit`, `svelte`, `@tailwindcss/vite` resolve.
## Files affected
- delete: `next.config.mjs`, `app/`, `components/`, `lib/`, `postcss.config.mjs`
- create: `svelte.config.js`, `vite.config.js`, `src/app.html`, `src/app.css`,
`jsconfig.json`, `eslint.config.mjs` (rewritten)
- modify: `package.json`, `.gitignore`
## Verify
- `ls package.json svelte.config.js vite.config.js src/app.html src/app.css`
all present
- `npm install` completes with 0 errors
- No leftover `app/` or `components/` or `lib/` from Next at repo root
- `grep -r "next\|react" package.json` returns nothing
## Out of scope
Source code (Phases 2-4), deploy wiring (Phase 5), docs (Phase 6).
## Status: planned
@@ -1,78 +0,0 @@
---
phase: 2
title: Port game logic to src/lib
priority: high
effort: S
status: planned
---
# Phase 2 — Port game logic
Move the pure functions verbatim. They have no React dependency, so this is
a near-copy with one path move.
## Source
Existing: `lib/game-logic.js` (~205 lines, JSDoc). Already React-free.
## Target
`src/lib/game-logic.js` — same content, no edits beyond removing dead
markers if any.
## Steps
1. Create `src/lib/`.
2. Copy file:
```bash
cp lib/game-logic.js src/lib/game-logic.js # or git mv after Phase 1 deletes
```
If Phase 1 already deleted `lib/`, restore from `git show HEAD:lib/game-logic.js > src/lib/game-logic.js`.
3. **Verify exports unchanged.** Public API consumed by Phase 3:
- `generateGrid()`
- `saveGrid(grid, prefix?)`
- `loadGrid(prefix?)`
- `saveCrossedState(crossed, prefix?)`
- `loadCrossedState(prefix?)`
- `isRowComplete(grid, crossed, row)`
- `getWaitingNumber(grid, crossed, row)`
4. **Imports** — file uses no imports. Pure JS. Done.
5. **JSDoc** — already present, vanilla JSDoc style. No changes.
6. **localStorage guard** — file already wraps `localStorage.setItem` /
`getItem` in try/catch for quota and disabled-storage cases. SvelteKit
SSR check needed? `adapter-static` prerenders at build time and the
game logic is only called from browser-mounted components, so SSR
doesn't reach it. But add a `typeof window !== 'undefined'` guard inside
each storage function to be safe — it's a one-line cheap defense:
```js
export function saveGrid(grid, prefix = 'loto') {
if (typeof localStorage === 'undefined') return;
try { localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid)); }
catch { /* see notes */ }
}
```
Apply to all four storage functions.
## Files affected
- create: `src/lib/game-logic.js`
- delete (in Phase 1, if not yet): `lib/game-logic.js`
## Verify
- `node --input-type=module -e "import('./src/lib/game-logic.js').then(m => { const g = m.generateGrid(); console.log('rows:', g.length, 'cols:', g[0].length); })"`
- Output: `rows: 9 cols: 9`
## Out of scope
UI components, persistence wiring (Phase 3).
## Status: planned
@@ -1,266 +0,0 @@
---
phase: 3
title: Port PlayerBoard component to Svelte 5 runes
priority: high
effort: M
status: planned
---
# Phase 3 — PlayerBoard component
Port `components/player-board.jsx` (224 lines, React + JSDoc) to
`src/lib/PlayerBoard.svelte` (Svelte 5 runes). This is the most state-heavy
component and where runes pay off vs. React.
## React → runes mapping
| React | Svelte 5 |
|---|---|
| `useState(null)` | `let grid = $state(null)` |
| `useState([])` | `let crossed = $state([])` |
| `useRef(new Set())` | `let celebratedRows = new Set()` (mutable, no rune needed — refs aren't reactive) |
| `useMemo(() => grid.map(isRowComplete), [grid, crossed])` | `let rowCompleteness = $derived(grid ? grid.map((_, r) => isRowComplete(grid, crossed, r)) : [])` |
| `useEffect(() => { ... save }, [crossed])` | `$effect(() => { if (crossed.length) saveCrossedState(crossed, storagePrefix); })` |
| `useCallback(handler, [deps])` | Plain function — Svelte tracks deps via `$state` reads automatically |
## Component skeleton
```svelte
<script>
import {
generateGrid,
getWaitingNumber,
isRowComplete,
loadCrossedState,
loadGrid,
saveCrossedState,
saveGrid,
} from '$lib/game-logic.js';
/**
* @typedef {Object} Props
* @property {string} [storagePrefix]
*/
/** @type {Props} */
let { storagePrefix = 'loto' } = $props();
let grid = $state(/** @type {number[][] | null} */ (null));
let crossed = $state(/** @type {boolean[][]} */ ([]));
let showCongrats = $state(false);
let congratsRow = $state(-1);
let toast = $state(/** @type {string | null} */ (null));
// Refs: mutable, non-reactive
let toastTimer = null;
const celebratedRows = new Set();
const notifiedWaitingRows = new Set();
// Derived: precompute row completeness once per render
const rowCompleteness = $derived(
grid && crossed.length ? grid.map((_, r) => isRowComplete(grid, crossed, r)) : []
);
function dismissToast() {
toast = null;
if (toastTimer) {
clearTimeout(toastTimer);
toastTimer = null;
}
}
function showToast(msg) {
dismissToast();
toast = msg;
toastTimer = setTimeout(() => { toast = null; }, 5000);
}
// Initial load — runs once on mount per storagePrefix change
$effect(() => {
const savedGrid = loadGrid(storagePrefix);
if (!savedGrid) return;
grid = savedGrid;
crossed = loadCrossedState(storagePrefix) ?? savedGrid.map((row) => row.map(() => false));
celebratedRows.clear();
notifiedWaitingRows.clear();
for (let i = 0; i < savedGrid.length; i++) {
if (isRowComplete(savedGrid, crossed, i)) celebratedRows.add(i);
if (getWaitingNumber(savedGrid, crossed, i) !== null) notifiedWaitingRows.add(i);
}
});
// Persist crossed state
$effect(() => {
if (crossed.length > 0) saveCrossedState(crossed, storagePrefix);
});
// Detect newly completed and waiting rows. Two passes prevent skipped resets.
$effect(() => {
if (!grid || crossed.length === 0) return;
for (let i = 0; i < grid.length; i++) {
if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
celebratedRows.add(i);
notifiedWaitingRows.add(i);
congratsRow = i + 1;
showCongrats = true;
break;
}
}
for (let i = 0; i < grid.length; i++) {
if (celebratedRows.has(i)) continue;
const waitNum = getWaitingNumber(grid, crossed, i);
if (waitNum !== null && !notifiedWaitingRows.has(i)) {
notifiedWaitingRows.add(i);
showToast(`Chờ ${waitNum}`);
} else if (waitNum === null && notifiedWaitingRows.has(i)) {
notifiedWaitingRows.delete(i);
}
}
});
function handleGenerate() {
if (grid && !confirm('Bạn có muốn tạo lại bảng không?')) return;
const newGrid = generateGrid();
grid = newGrid;
crossed = newGrid.map((row) => row.map(() => false));
saveGrid(newGrid, storagePrefix);
saveCrossedState(crossed, storagePrefix);
celebratedRows.clear();
notifiedWaitingRows.clear();
dismissToast();
}
function handleCellClick(row, col) {
crossed = crossed.map((r, ri) =>
ri === row ? r.map((v, ci) => (ci === col ? !v : v)) : r
);
}
</script>
<!-- Generate button -->
<div class="flex justify-center mb-6">
<button
onclick={handleGenerate}
class="px-8 py-3 rounded-full font-semibold text-white
bg-gradient-to-r from-indigo-500 to-purple-500
hover:from-indigo-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg shadow-indigo-500/25"
>
Tạo bảng mới
</button>
</div>
{#if grid}
<div class="relative">
<div
aria-label="Bảng lô tô"
class="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
>
<div class="loto-grid">
{#each grid.flat() as num, idx}
{@const row = Math.floor(idx / 9)}
{@const col = idx % 9}
{@const hasNumber = num > 0}
{@const isCrossed = hasNumber && !!crossed[row]?.[col]}
{@const rowComplete = hasNumber && rowCompleteness[row]}
{#if !hasNumber}
<div aria-hidden="true" class="cell-empty" />
{:else}
<button
type="button"
aria-label="Số {num}{isCrossed ? ', đã đánh dấu' : ''}"
aria-pressed={isCrossed}
onclick={() => handleCellClick(row, col)}
class="cell-num"
class:cell-crossed={isCrossed}
class:cell-completed={rowComplete}
>
{num}
</button>
{/if}
{/each}
</div>
</div>
{#if toast}
<div
role="status"
aria-live="polite"
onclick={dismissToast}
class="absolute inset-0 flex items-center justify-center pointer-events-auto cursor-pointer z-10"
>
<div class="px-6 py-3 rounded-2xl bg-amber-500/90 dark:bg-amber-600/90 text-white text-xl sm:text-2xl font-black shadow-xl animate-toast">
{toast}
</div>
</div>
{/if}
</div>
{:else}
<div class="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
Nhấn "Tạo bảng mới" để bắt đầu chơi
</div>
{/if}
{#if showCongrats}
<!-- bingo modal — same markup as React version with svelte:on:keydown for Escape -->
...
{/if}
<style>
.cell-empty { @apply relative flex items-center justify-center aspect-square border-r border-b border-slate-200/80 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-900/60; }
.cell-num { @apply relative flex items-center justify-center aspect-square text-base sm:text-xl font-bold border-r border-b border-slate-200/80 dark:border-slate-700/60 transition-all select-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400; }
.cell-crossed { @apply bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500; }
.cell-completed { @apply bg-emerald-100 dark:bg-emerald-900/40 text-emerald-500 dark:text-emerald-400; }
</style>
```
The full bingo-modal block follows the React structure 1:1 — `role="dialog"`,
`aria-labelledby="congrats-title"`, click-outside-to-dismiss, Escape key
handler. Use Svelte's `onkeydown` and a `tabindex={-1}` wrapper.
## Behavior parity checklist (map to original fixes)
- [ ] Bingo popup fires only once per row — guarded by `celebratedRows` Set
- [ ] Two-pass effect prevents skipped reset for higher-index rows — same
structure as React fix
- [ ] `isRowComplete` requires ≥1 numbered cell — already in `game-logic.js`
- [ ] Memoized `rowCompleteness` via `$derived` (was `useMemo`)
- [ ] localStorage shape validation via `safeParse` — in `game-logic.js`
- [ ] Real `<button>` cells with `aria-label`, `aria-pressed`, focus ring
- [ ] Modal: `role="dialog"`, `aria-labelledby`, Escape closes
- [ ] Toast: `role="status"`, `aria-live="polite"`
## Cell click immutability
React used `prev.map((r) => [...r])` for crossed toggle. Svelte 5 with `$state`
deeply tracks objects, so simpler:
```js
crossed[row][col] = !crossed[row][col];
```
…would also work because runes proxy nested writes. But explicit immutable
update (as in skeleton above) keeps reactivity behavior predictable across
older Svelte 5 versions. Pick whichever, document the choice.
## Files affected
- create: `src/lib/PlayerBoard.svelte`
## Verify
- `npm run dev` — visit `/`, generate card, click cells, see crossed state
- Click 4 of 5 in a row — see "Chờ X" toast
- Click the 5th — see "Kinh!" popup
- Reload — state persists for the default `loto` prefix
- (After Phase 4) `/master` shows host card with `storagePrefix="loto_master_card"`,
storage stays separate from `/`
## Out of scope
Routes, layout (Phase 4). Codeserver verification (Phase 5).
## Status: planned
@@ -1,175 +0,0 @@
---
phase: 4
title: Port routes + layout
priority: high
effort: M
status: planned
---
# Phase 4 — Routes + layout
Set up the SvelteKit route tree and port the two pages.
## Target tree
```
src/
├── app.html
├── app.css
├── routes/
│ ├── +layout.svelte ← global wrapper (replaces app/layout.tsx)
│ ├── +layout.js ← export const prerender = true
│ ├── +page.svelte ← player page (replaces app/page.tsx)
│ └── master/
│ └── +page.svelte ← host page (replaces app/master/page.tsx)
└── lib/
├── game-logic.js ← from Phase 2
└── PlayerBoard.svelte ← from Phase 3
```
## `+layout.js` — enable static prerendering for every route
```js
export const prerender = true;
export const ssr = false; // pure SPA, no HTML pre-render of dynamic state
export const trailingSlash = 'never';
```
`ssr = false` is the right move because the game state is entirely
client-side. Without it, SvelteKit tries to prerender HTML and the
localStorage code paths get awkward guards.
## `+layout.svelte` — global shell
```svelte
<script>
import '../app.css';
let { children } = $props();
</script>
{@render children()}
```
That's the whole layout. The Next-side `app/layout.tsx` set `<html lang="vi">`
with the Geist font — that lives in `src/app.html` now (Phase 1).
## `+page.svelte` — player route
Replicate `app/page.jsx` 1:1:
```svelte
<script>
import PlayerBoard from '$lib/PlayerBoard.svelte';
let showInstructions = $state(false);
</script>
<div class="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
<div class="w-full max-w-lg">
<header class="text-center mb-8">
<h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
Lô tô
</h1>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
Lấy cảm hứng từ những buổi họp lớp thiếu giấy chơi lô tô
<br class="hidden sm:block" /> của TN1 (2014–2017)
</p>
<div class="mt-3 flex items-center justify-center gap-3 text-xs">
<button
onclick={() => (showInstructions = !showInstructions)}
class="text-indigo-500 dark:text-indigo-400 hover:underline"
>
{showInstructions ? 'Ẩn hướng dẫn' : 'Hướng dẫn'}
</button>
<span class="text-slate-300 dark:text-slate-600">|</span>
<a href="/master" class="text-orange-500 dark:text-orange-400 hover:underline">
Trang quản trò →
</a>
</div>
</header>
{#if showInstructions}
<div class="mb-6 rounded-xl bg-indigo-50 dark:bg-indigo-950/30 border border-indigo-100 dark:border-indigo-900 p-4 text-sm text-slate-600 dark:text-slate-400">
<ul class="space-y-1 list-disc list-inside">
<li>Nhấn <strong class="text-slate-800 dark:text-slate-200">Tạo bảng mới</strong> để tạo bảng</li>
<li>Nhấn vào ô số để đánh dấu khi số được xổ</li>
<li>Nhấn lại để bỏ đánh dấu</li>
<li>Bảng được lưu tự động</li>
</ul>
</div>
{/if}
<PlayerBoard />
<footer class="mt-10 text-center text-xs text-slate-400 dark:text-slate-600">
Made with ❤️ by
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer" class="text-indigo-500 hover:underline">miti99</a>
</footer>
</div>
</div>
```
## `master/+page.svelte` — host route
Replicate `app/master/page.jsx`. Key differences from React:
- `useState` → `$state` runes
- `useEffect(load on mount)` → `$effect(...)` (runs once at mount because
no reactive deps tracked inside)
- `useEffect(save on change)` → `$effect(() => { saveState(state); })`
- `useCallback` → plain functions
- `BOARD = Object.freeze(...)` — module-scope, declared in `<script module>`
so it computes once across all instances:
```svelte
<script module>
const STORAGE_KEY = 'loto_master';
function buildBoard() { /* same body */ }
const BOARD = Object.freeze(buildBoard().map(Object.freeze));
const BOARD_FLAT = Object.freeze(BOARD.flatMap((r) => r));
</script>
<script>
import PlayerBoard from '$lib/PlayerBoard.svelte';
/* state, handlers, effects */
</script>
<!-- markup -->
```
The "host's own player card" piece becomes:
```svelte
<PlayerBoard storagePrefix="loto_master_card" />
```
— same prop pattern.
## Behavior to preserve
- "Ván mới" button confirms before reset if game in progress
- "Xổ số" button hidden when `remaining.length === 0`
- Last drawn number badge with size animation
- Called numbers history strip (chip-style)
- 9×10 master board lighting up on draw
- Master's own player card below, with separate localStorage prefix
## Files affected
- create: `src/routes/+layout.svelte`, `src/routes/+layout.js`,
`src/routes/+page.svelte`, `src/routes/master/+page.svelte`
## Verify
- `npm run dev` — both `/` and `/master` render without errors
- Tab switching between routes preserves localStorage independently
(player at `loto_*`, master state at `loto_master`, master's card at
`loto_master_card_*`)
- Browser back/forward navigates correctly (SvelteKit client router)
- View source on prerendered build (Phase 5 verifies) shows the static
HTML shell with `ssr: false` skipping content render
## Out of scope
Codeserver dev profile (Phase 5), CF deploy (Phase 5), docs (Phase 6).
## Status: planned
@@ -1,118 +0,0 @@
---
phase: 5
title: Codeserver dev profile + CF Pages deploy
priority: high
effort: S
status: planned
---
# Phase 5 — Codeserver dev + CF Pages deploy
Wire the two operational profiles already proven on Next:
1. `npm run dev:codeserver` working through `/absproxy/{port}/`
2. `npm run build` producing static export for CF Pages at `loto.miti99.com`
## Codeserver dev profile
Already drafted in Phase 1 `vite.config.js`. Verify by running:
```bash
echo "CODESERVER_HOST=codeserver.sg.miti99.com" > .env.local
echo "CODESERVER_PORT=3000" >> .env.local
npm run dev:codeserver
```
Open `https://codeserver.sg.miti99.com/absproxy/3000/`. Page should load,
HMR socket should upgrade (Vite HMR config maps client → wss:443/...).
**Differences vs Next implementation:**
- Vite handles HMR explicitly (Phase 1 config). Next 16's HMR was
finicky over the proxy; Vite's named-host config works because we tell
the client exactly which URL to connect on (matches sokoban's setup).
- SvelteKit's `paths.base` config takes the basePath, not Vite's. Bridge
with the same `BUILD_PROFILE` env var pattern, but route through
`svelte.config.js`:
```js
// svelte.config.js
const profile = process.env.BUILD_PROFILE;
const isCodeserver = process.env.VITE_DEV_PROFILE === 'codeserver';
let base = '';
if (isCodeserver) {
const port = process.env.CODESERVER_PORT ?? '3000';
base = `/absproxy/${port}`;
} else if (profile === 'gh') {
base = '/loto';
}
// else: empty (CF Pages root or local dev)
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ pages: 'build', assets: 'build', strict: true }),
paths: { base },
},
};
```
The dev server reads `VITE_DEV_PROFILE` early (set by `npm run dev:codeserver`),
so `paths.base` populates correctly during dev.
**Internal links** in `+page.svelte` etc. should use `base`:
```svelte
<script>
import { base } from '$app/paths';
</script>
<a href="{base}/master">Trang quản trò →</a>
```
This keeps links working under any of the three modes (root, /loto,
/absproxy/3000).
## CF Pages deploy
Already covered by current setup. Just update the dashboard build command
when the project is rebuilt:
| Setting | Value |
|---|---|
| Framework preset | SvelteKit |
| Build command | `npm run build` |
| Build output directory | `build` (NOT `out` — SvelteKit + adapter-static default) |
| Production branch | `master` |
| Environment variables | none (root basePath is the default) |
Custom domain `loto.miti99.com` step unchanged.
## Manual GH Pages export (the optional path)
```bash
npm run build:gh # BUILD_PROFILE=gh sets paths.base = '/loto'
# Upload build/ to GitHub Pages
```
## Files affected
- modify: `svelte.config.js` (paths.base logic)
- modify: `src/routes/+page.svelte` and `src/routes/master/+page.svelte`
(use `$app/paths` `base` for internal links)
- create/keep: `.env.local` (gitignored; user fills in)
## Verify
1. **Local dev** — `npm run dev` → `http://localhost:3000/`, links work.
2. **Codeserver** — `npm run dev:codeserver` → page loads at the proxy URL,
HMR works (no `wss` errors in console).
3. **CF Pages build** — `npm run build` → `build/` populated, asset URLs
start with `/_app/...` (root-relative).
4. **GH Pages build** — `npm run build:gh` → asset URLs start with
`/loto/_app/...`.
## Out of scope
Docs sync (Phase 6).
## Status: planned
@@ -1,137 +0,0 @@
---
phase: 6
title: Update docs, verify end-to-end, commit
priority: high
effort: S
status: planned
---
# Phase 6 — Finalize
End-to-end verification, docs sync, commit + push.
## Steps
1. **End-to-end smoke test (manual)**
```bash
npm run dev
```
- Visit `/`, generate a card, click 4 cells in row 0 → see "Chờ X" toast
- Click the 5th → see "Kinh!" popup with "Hàng 1 đã đầy đủ!"
- Press Esc — popup dismisses
- Reload — card persists
- Visit `/master`, click "Ván mới", click "Xổ số" several times — numbers
light up on the 9×10 board, history strip grows
- Generate a card on `/master` (the host's own card) — confirm it's a
different card than `/`'s (separate localStorage prefixes)
2. **Build check**
```bash
npm run build
```
Expect `build/` populated, no errors. `build/index.html`,
`build/master.html` (or `build/master/index.html` depending on
`trailingSlash` config).
3. **Codeserver dev smoke**
```bash
npm run dev:codeserver
```
Open the proxy URL; verify page + HMR.
4. **Lint**
```bash
npm run lint
```
No new errors.
5. **Update docs.** Files to rewrite (re-generate from new code state, don't
edit the Next-era versions):
- `docs/codebase-summary.md` — file table now lists `src/routes/...`,
`src/lib/...`, `svelte.config.js`, `vite.config.js`
- `docs/system-architecture.md` — diagram of SvelteKit route flow,
Svelte 5 runes pattern, drop "use client" client-only architecture
section (no longer relevant — `ssr: false` in `+layout.js`)
- `docs/code-standards.md` — Svelte 5 runes (`$state`, `$derived`,
`$effect`), `<script>` vs `<script module>`, JSDoc style for `$props()`
- `docs/design-guidelines.md` — Tailwind classes unchanged, but the
"use Tailwind in className" examples become `class=`. Animation CSS
stays in `app.css`.
- `docs/deployment-guide.md` — output dir `build/` (not `out/`),
framework preset SvelteKit
- `docs/development-roadmap.md` — drop any roadmap items that
SvelteKit unlocks (e.g. "consider migration to lighter framework"
if listed)
- `docs/project-overview-pdr.md` — tech stack section: Next.js → SvelteKit
Set `Last reviewed: 2026-04-26` on each.
6. **Update `README.md`** — entry point sentences:
```md
# Lô tô
Bàn số của trò chơi "Lô tô" — SvelteKit app.
Two routes: `/` for players, `/master` for the host.
See `docs/` for architecture, code standards, and deployment.
```
7. **Update active plan + sync ts-to-jsdoc plan if not already.**
- `plans/260426-1934-ts-to-jsdoc-refactor/plan.md` — already marked
`completed` (done in Phase 0 of this refactor).
- `plans/260426-2033-sveltekit-refactor/plan.md` — change `status:
planned` → `status: completed` after this phase finishes.
8. **Commit on `dev`** in coherent slices:
```
refactor: scaffold sveltekit, drop next/react
Replace Next.js 16 + React 19 with SvelteKit + Svelte 5 runes. New
svelte.config.js / vite.config.js / src/app.html, all Next config files
removed. Adapter-static for CF Pages compatibility.
refactor: port game logic + player board to svelte
src/lib/game-logic.js — verbatim copy of the JSDoc-only utilities.
src/lib/PlayerBoard.svelte — runes-based reactivity replaces useState/
useRef/useMemo/useEffect ceremony. Bingo and waiting detection use a
two-pass $effect to mirror the React-side fix. <button> cells with
aria-label/aria-pressed/focus ring kept.
refactor: port routes; wire deploy profiles
src/routes/+layout.{svelte,js} sets prerender + ssr=false. + page.svelte
and master/+page.svelte mirror the Next pages 1:1, using $app/paths base
so internal links survive /loto and /absproxy/{port} rewrites.
svelte.config.js routes BUILD_PROFILE=gh and codeserver dev to the right
basePath; default empty for CF Pages.
docs: refresh for sveltekit
```
Or one squashed commit if you prefer — your call.
9. **Push** — `git push`. Already tracking `origin/dev`.
## Acceptance gates (must pass before commit)
- [ ] Manual smoke covered all paths
- [ ] `npm run build` succeeds
- [ ] `npm run lint` no new errors
- [ ] Both dev profiles boot
- [ ] Zero references to Next, React, or `app/`/`components/`/`lib/` in
source
- [ ] `docs/` updated to SvelteKit terminology
- [ ] No leftover `next.config.*`, `next-env.d.ts`, `tsconfig.tsbuildinfo`,
`out/` directory
## Rollback
`git revert` of the phase commits restores Next state. Old code preserved
on `master` branch + earlier `dev` commits.
## Status: planned
@@ -1,114 +0,0 @@
---
slug: sveltekit-refactor
created: 2026-04-26
status: completed
completedAt: 2026-04-26
mode: fast
blockedBy: []
blocks: []
---
# Refactor Next.js → SvelteKit
Replace the Next.js 16 + React 19 stack with SvelteKit + Svelte 5 runes. User
chose SvelteKit over plain Svelte to leave room for future routes / load
functions / a possible backend. Static export only for now (no SSR target).
## Why (per user request)
- Bundle size — Svelte compiles, no runtime; ~75% smaller payload
- Reactivity ergonomics — runes (`$state`, `$derived`, `$effect`) replace the
React `useRef<Set>` / two-pass `useEffect` / `useMemo` ceremony in
`components/player-board.jsx`
- Consistency with `sokoban` / `rplace` (already Svelte 5)
- Room to extend — file-based routing, layouts, future server endpoints
## Constraints (must preserve)
- **JSDoc only** — no TypeScript (explicit user preference, just shipped)
- **Static export** to Cloudflare Pages at `loto.miti99.com` (root basePath)
- **Codeserver dev profile** equivalent to current `npm run dev:codeserver`
- **Gameplay 1:1** — bingo popup ("Kinh!"), waiting toast ("Chờ X"), master
9×10 tracking board, host's own player card via `storagePrefix`
- **Visual identity** — indigo→purple for player, orange→red for host,
emerald for completed rows, amber for waiting toast
- **Tailwind 4** — keep
- **Two routes** — `/` and `/master`
- **localStorage prefix pattern** — `loto_grid` / `loto_crossed` /
`loto_master` / `loto_master_card_*`
- **Plans + docs preserved** — update content; don't delete
## Stack target
| Concern | Choice |
|---|---|
| Framework | SvelteKit (latest stable) |
| Language | Svelte 5 (runes mode) + JS + JSDoc |
| Adapter | `@sveltejs/adapter-static` (CF Pages target) |
| Styling | Tailwind 4 (Vite plugin) |
| Build | Vite (under SvelteKit) |
| Routing | SvelteKit file-based (`src/routes/+page.svelte`) |
| State | Svelte 5 `$state` runes; localStorage in `$effect` |
| Lint | ESLint 9 + `eslint-plugin-svelte` (no TS rules) |
## Out of scope (deliberately deferred)
- Tests — none exist; not adding now
- New features (sound effects, undo, multiplayer, theme switcher) — see
`docs/development-roadmap.md`
- TypeScript — explicitly rejected
- Backend / API — extension surface, not this refactor
## Risk callouts
| Risk | Mitigation |
|---|---|
| Rewrite reopens fixed bugs (`isRowComplete` empty-row, two-pass row scan) | Phase 5 has explicit checklist mapping each Next-side fix to its Svelte equivalent |
| Codeserver proxy in Vite uses different config keys than Next | Copy proven config from sibling `sokoban` repo verbatim |
| `adapter-static` requires `prerender = true` on every page | One-line `export const prerender = true` in root `+layout.js` covers all routes |
| Tailwind 4 + SvelteKit setup is newer; less StackOverflow coverage | Use the Tailwind official Vite plugin; pinned in package.json |
| Plans dir + reports inside repo make `git mv` from old paths messy | Don't move plans; the new SvelteKit tree replaces `app/`, `components/`, `lib/` |
## Phases
| # | Phase | File | Effort |
|---|---|---|---|
| 1 | Scaffold + tooling | `phase-01-scaffold.md` | M |
| 2 | Port game logic | `phase-02-game-logic.md` | S |
| 3 | Port player-board component | `phase-03-player-board.md` | M |
| 4 | Port routes + layout | `phase-04-routes.md` | M |
| 5 | Wire codeserver + CF deploy | `phase-05-deploy-profiles.md` | S |
| 6 | Update docs + verify + commit | `phase-06-finalize.md` | S |
Total: ~3-4h focused.
## Acceptance criteria
- [ ] `npm run dev` boots SvelteKit dev server, `/` and `/master` render
- [ ] `npm run build` produces static export in `build/` (SvelteKit default)
- [ ] `npm run dev:codeserver` works under `/absproxy/{port}`
- [ ] Generate card → click cells → bingo popup fires once per completed row
- [ ] Waiting toast ("Chờ X") fires on 4/5 and clears on row completion
- [ ] localStorage state survives reload for both player and master cards
- [ ] Master draws numbers, tracking board lights up cells, master's player
card plays independently
- [ ] No `.tsx`, `.ts`, `.jsx`, `next.config.*`, `next-env.d.ts`, or
`app/`, `components/`, `lib/` dirs remain
- [ ] No `react`, `react-dom`, `next` in `package.json`
- [ ] `docs/` updated to describe SvelteKit structure
- [ ] `plans/260426-2033-sveltekit-refactor/` complete; old plan dir
preserved as historical record
- [ ] CF Pages dashboard build command updated to match the new output dir
## Rollback
`git revert` of the squash-merge commit (or the series of phase commits)
restores Next state. Old code is preserved in `master` branch + earlier `dev`
commits.
## Reference projects
- `tiennm99/sokoban` — Svelte 5 + Vite + codeserver dev config (template for
Phase 5)
- `tiennm99/rplace` — Svelte 5 + Hono on Cloudflare Workers (extension hint
for future backend work; not used here)
@@ -1,156 +0,0 @@
# Phase 1 — 3-Section Visual Split for PlayerBoard
## Context
- Plan: [plan.md](plan.md)
- Reference image: physical Minh Tân lô tô card (3 stacked 3×9 mini-cards on
one sheet, brown empty cells, decorative cross-hatch separators).
- Touches: `src/lib/PlayerBoard.svelte`, `src/app.css`.
## Overview
- **Priority**: P1 (visual identity / authenticity)
- **Status**: TODO
- **Effort**: ~30 min
- **Description**: Render the existing 9×9 grid as 3 visually distinct 3-row
sections. Data, generator, click handlers, win detection: unchanged.
## Key Insights
- The 9×9 → 3×(3×9) split is **purely visual**. Don't touch
`src/lib/game-logic.js`. Don't change the grid shape, the storage shape,
or `isRowComplete` — they all keep operating on a 9×9 array.
- Tân Tân section labels (top to bottom): **Minh Tân** / **Loại đặc biệt**
/ **Tấn tài tấn lộc**. Use these verbatim.
- The decorative cross-hatch border (✚✚✚✚) on the physical card is
ornamental. CSS approximation: a thin orange/red dotted or repeating
cross-pattern border between sections is enough — don't pixel-copy.
- Card serial number badge (e.g., "25" on the physical card) is **out of
scope** per plan.
## Requirements
### Functional
- Player card renders as 3 stacked sub-grids: rows 0–2, rows 3–5, rows 6–8.
- A label sits between each pair of sub-grids (and above the first):
`Minh Tân`, `Loại đặc biệt`, `Tấn tài tấn lộc`.
- All click / cross / waiting / Kinh behavior keeps working unchanged.
- Cell crossed style, row-complete style, the `Chờ N` toast, and the Kinh
modal all behave exactly as before.
### Non-functional
- No layout shift or visual jitter.
- Mobile responsive (stays within `max-w-2xl` container).
- Dark mode parity: separator and labels visible on both themes.
- A11y: each sub-grid keeps `aria-label="Bảng lô tô"` (or section-specific
label like `Bảng lô tô — phần 1`).
## Architecture
### Render strategy
Replace the single `<div class="loto-grid">` with three sibling
`<div class="loto-grid">` blocks, each iterating only its 3 rows. Use
`{#each [0, 3, 6] as startRow, sectionIdx}` and within each section iterate
`grid.slice(startRow, startRow + 3)`.
The `crossed` lookup must use the **absolute row index** (`startRow + r`),
not the slice-relative index. Same for click handlers and `rowCompleteness`.
### Section labels
Plain centered text (`text-center text-xs uppercase tracking-widest text-slate-500`)
between sections, sandwiched by horizontal cross-hatch lines. Use a
repeating linear-gradient or a `.section-divider` utility in `app.css`.
### Border continuity
Currently each cell has `border-r border-b`. Within a section this is fine.
Across sections we **don't** want the bottom row of section 1 to look
adjacent to the top row of section 2 — the separator label / divider sits
between them, so the visual gap naturally breaks the grid.
The outer wrapper (`rounded-2xl overflow-hidden shadow-xl border …`)
currently wraps the whole grid. Two choices:
- **A**: keep one outer wrapper, separators are inside it.
- **B**: each section has its own rounded card.
**Pick A** — one outer wrapper, internal separators. Closer to the
physical sheet which is one continuous piece of paper.
## Related Code Files
### Modify
- `src/lib/PlayerBoard.svelte`
- Replace the single `{#each grid.flat() …}` loop with 3 sectioned loops
(lines ~157–191 in current state).
- Each section iterates `[0, 3, 6][i] .. + 3` and uses absolute row idx
when reading `crossed[row]`, calling `handleCellClick(row, col)`,
reading `rowCompleteness[row]`.
- Add the section header text between sections.
- `src/app.css`
- Add `.section-divider` utility (cross-hatch / dotted horizontal rule).
- Optionally add `.section-label` if Tailwind utility classes don't
cover the styling cleanly.
### Don't touch
- `src/lib/game-logic.js`
- `src/routes/master/+page.svelte` (master grid stays 11×9)
- `src/routes/+page.svelte`
## Implementation Steps
1. In `PlayerBoard.svelte`, extract a `SECTIONS = [0, 3, 6]` constant and a
parallel `SECTION_LABELS = ['Minh Tân', 'Loại đặc biệt', 'Tấn tài tấn lộc']`.
2. Wrap the existing grid block with a flex column. Inside, loop sections:
for each `startRow`, render the label, then a `loto-grid` containing
that section's 3 rows × 9 cols.
3. Inside the inner loop, compute `row = startRow + r`. Pass `row` to
`handleCellClick`, look up `crossed[row]?.[col]`, `rowCompleteness[row]`.
4. Add the `Chờ` toast container *outside* the sectioned grid (it
currently uses `absolute inset-0` of the outer relative wrapper —
keep that wrapper around all 3 sections).
5. In `app.css`, define `.section-divider` (e.g.
`background: repeating-linear-gradient(90deg, transparent 0 8px, theme(colors.orange.400) 8px 10px); height: 6px;`).
6. Run `npx svelte-check`, then test in dev: generate card, mark cells,
trigger Kinh, trigger Chờ. All should work.
## Todo
- [ ] Add `SECTIONS` + `SECTION_LABELS` constants to PlayerBoard.svelte
- [ ] Replace flat grid render with 3 sectioned `loto-grid` blocks
- [ ] Verify absolute-row-index math (click, crossed lookup, rowCompleteness)
- [ ] Add label markup between sections
- [ ] Add `.section-divider` style in `app.css`
- [ ] Verify Chờ toast still appears centered over the whole card
- [ ] Verify Kinh modal still triggers
- [ ] Verify dark-mode contrast for labels and dividers
- [ ] Mobile breakpoint check (sm + base)
- [ ] `npx svelte-check` clean
## Success Criteria
- Player page shows 3 visually separated 3×9 mini-cards with the 3 labels.
- Generating a new card, marking cells, and winning all behave identically
to before this change.
- No svelte-check warnings.
- Dark mode looks intentional (not just light mode in the dark).
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Off-by-one in absolute row index | Medium | High (broken click handling) | Compute `row = startRow + r` once at the top of the inner each, reuse everywhere. |
| Toast positioning breaks | Low | Low | Toast container stays at the same wrapper level it's at today. |
| Border doubling at section seams | Low | Cosmetic | Last row of each section keeps its own `border-b`; separator visually masks it. |
| `aspect-square` cells distort with new container | Low | Cosmetic | grid-template-columns is still `repeat(9, 1fr)` per section, same width budget per section. |
## Security Considerations
None — view-only change.
## Next Steps
After this phase: Phase 2 (settings + color picker) plugs the
configurable color into `bg-slate-50 dark:bg-slate-900/60` empty cells
(replaced by a CSS variable).
@@ -1,227 +0,0 @@
# Phase 2 — Settings Modal + Empty-Cell Color Picker
## Context
- Plan: [plan.md](plan.md)
- Depends on Phase 1 only loosely (works either way; better when Phase 1
shipped because the section background also picks up the color).
- Touches: new `settings-store.svelte.js` + `SettingsButton.svelte`,
edits in `PlayerBoard.svelte`, `master/+page.svelte`, `+page.svelte`,
`app.css`.
## Overview
- **Priority**: P1 (user-requested feature)
- **Status**: TODO
- **Effort**: ~45 min
- **Description**: Gear icon → modal panel with a color picker. Selected
color paints empty/blank cells across player card *and* master tracking
grid. Persists in localStorage. Default: brown (matches physical Tân Tân).
## Key Insights
- One **global** color, not per-card. Simpler, matches physical sheet
(one paper color).
- Use **CSS custom property** (`--empty-cell-bg`) on `:root` so a single
store update repaints every empty cell with no per-component prop drilling.
- Settings store goes in `src/lib/settings-store.svelte.js` — the
`.svelte.js` extension lets it use Svelte 5 runes (`$state`) at module
scope. Components import the reactive `settings` object.
- The color picker can be a **native** `<input type="color">` to avoid
pulling in a UI library. Add a few **preset swatches** (brown, slate,
amber, emerald, indigo, neutral white) for one-tap defaults.
- Keep the modal a11y-clean: `role="dialog"`, `aria-modal="true"`,
`Escape` to close, focus trap-ish (focus the close button on open).
## Requirements
### Functional
- A gear button (⚙) sits in the player page header (`/`) and master page
header (`/master`).
- Clicking the gear opens a modal panel.
- Modal contains:
- A native color picker (`<input type="color">`) bound to the current
color.
- 6 preset swatches as quick-pick buttons.
- A "Reset to default" button.
- A "Close" button.
- Color change is **live** — empty cells repaint as user picks.
- Setting persists in localStorage under key `loto_settings`.
- Default color: a brown matching the physical card (`#7a4a2b` or close).
### Non-functional
- No new third-party deps.
- Settings store must support adding more keys later (extensible shape).
- Works offline (no network).
- Dark mode: the picker UI itself must be readable in dark mode; the
user-chosen color obviously stays as-is.
## Architecture
### Store shape
```js
// settings-store.svelte.js
export const DEFAULT_SETTINGS = Object.freeze({
emptyCellColor: '#7a4a2b', // brown — matches physical Minh Tân card
});
const STORAGE_KEY = 'loto_settings';
export const settings = $state({ ...DEFAULT_SETTINGS });
// load + persist helpers
export function loadSettings() { /* read localStorage, merge into settings */ }
export function saveSettings() { /* write current settings */ }
export function resetSettings() { /* reset to DEFAULT_SETTINGS */ }
```
A root effect (set up once in `+layout.svelte` or in each page that
imports the store) calls `loadSettings()` on mount and persists on change.
Simpler: do it inside the store module via a top-level `$effect.root` in
the `.svelte.js` file — Svelte 5 supports this.
### CSS variable wiring
In `app.css`, define a default:
```css
:root { --empty-cell-bg: #7a4a2b; }
```
Bind it from the store at the page level (whichever component first
mounts the store):
```svelte
<div style:--empty-cell-bg={settings.emptyCellColor}>...</div>
```
OR set it directly on `document.documentElement` in `loadSettings()` /
on store change via an effect. The latter avoids prop drilling.
### Empty-cell consumption
Replace existing empty-cell backgrounds:
| File | Current | New |
|---|---|---|
| `PlayerBoard.svelte` (empty cell) | `bg-slate-50 dark:bg-slate-900/60` | `style:background-color="var(--empty-cell-bg)"` |
| `master/+page.svelte` (empty cell, `!hasNumber`) | `bg-slate-100 dark:bg-slate-900/60` | same |
Filled cells keep their existing styles. The chosen color only repaints
**`hasNumber === false`** cells.
### Settings button placement
A small floating gear button in the top-right of each page's header
section. Reuse the same `SettingsButton.svelte` component on both pages.
### Modal
`SettingsButton.svelte` owns its own `open` state. When open, renders the
modal as a sibling overlay. Same dismiss patterns as the existing Kinh
modal in `PlayerBoard.svelte` (backdrop click + Escape).
## Related Code Files
### Create
- `src/lib/settings-store.svelte.js` — rune-based reactive store +
load/save/reset. ~50 LOC.
- `src/lib/SettingsButton.svelte` — gear button + modal + color picker
+ 6 preset swatches + reset/close. ~80 LOC.
### Modify
- `src/lib/PlayerBoard.svelte` — empty-cell background uses CSS var.
- `src/routes/master/+page.svelte` — empty-cell background uses CSS var;
mount `<SettingsButton />` in header.
- `src/routes/+page.svelte` — mount `<SettingsButton />` in header.
- `src/app.css` — add `:root { --empty-cell-bg: ...; }` default.
### Don't touch
- `src/lib/game-logic.js` (no game state changes)
## Implementation Steps
1. Create `src/lib/settings-store.svelte.js` with `settings` rune object,
`DEFAULT_SETTINGS`, `loadSettings`, `saveSettings`, `resetSettings`.
2. In the store module, set up a root effect that:
- Reads localStorage on first call.
- Writes localStorage on change.
- Pushes `settings.emptyCellColor` to
`document.documentElement.style.setProperty('--empty-cell-bg', …)`.
3. Add `:root { --empty-cell-bg: #7a4a2b; }` to `src/app.css` as fallback.
4. Create `src/lib/SettingsButton.svelte`:
- `let { } = $props();` (no props)
- Local `open = $state(false)`
- Imports `settings` + `resetSettings` from the store
- Renders gear button; on click toggles `open`
- Modal: `<input type="color" bind:value={settings.emptyCellColor}>`,
6 preset swatches as buttons setting `settings.emptyCellColor` directly,
reset button calling `resetSettings()`, close button.
- `Escape` keydown closes modal.
5. In `PlayerBoard.svelte`, change empty-cell background from
`bg-slate-50 dark:bg-slate-900/60` to inline
`style:background-color="var(--empty-cell-bg)"`.
6. In `master/+page.svelte`, do the same for `!hasNumber` cells; also
import and mount `<SettingsButton />` in the header (next to the
"Về trang người chơi" link).
7. In `+page.svelte`, mount `<SettingsButton />` in the player page header.
8. Run `npx svelte-check`.
9. Manual test: open gear, change color, see player card + master grid
empty cells update live. Reload page → color persists.
10. Reset button → returns to brown default.
## Todo
- [ ] Create `settings-store.svelte.js` with rune store + persistence
- [ ] Create `SettingsButton.svelte` with gear + modal + picker + presets
- [ ] Add CSS var `--empty-cell-bg` default in `app.css`
- [ ] Wire CSS var update from store via effect
- [ ] Update `PlayerBoard.svelte` empty cells to use CSS var
- [ ] Update `master/+page.svelte` empty cells + mount SettingsButton
- [ ] Update `+page.svelte` to mount SettingsButton
- [ ] Test: change color → repaints both boards live
- [ ] Test: reload → persists
- [ ] Test: reset → back to brown
- [ ] Test: Escape closes modal
- [ ] `npx svelte-check` clean
- [ ] Update `docs/codebase-summary.md` (new files, new storage key)
- [ ] Update `docs/project-overview-pdr.md` (note settings feature)
## Success Criteria
- Gear icon visible on `/` and `/master`.
- Modal opens, picker bound to current color.
- Picking a color via picker OR swatch repaints empty cells live on both
boards.
- Reload preserves the chosen color.
- Reset button returns to brown default.
- No svelte-check errors.
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| `$state` at module scope misuse | Medium | High | Use `.svelte.js` extension; verify with svelte-check; reference Svelte 5 docs if unsure (`docs-seeker` skill / context7). |
| CSS var doesn't apply across light/dark mode cleanly | Low | Cosmetic | Set var on `:root`, no media-query gating. User picked color overrides the brand-default for both modes — that's the user's call. |
| localStorage unavailable (private mode) | Low | Setting just doesn't persist | Wrap reads/writes in try/catch (mirror existing `saveGrid`/`loadGrid` pattern). |
| Race: store loads after first paint, brief flash of fallback color | Low | Cosmetic | Acceptable; default fallback in CSS is the same brown, so no flash for default users. |
| New storage key collides with existing | None | – | `loto_settings` is unused. |
## Security Considerations
- `<input type="color">` returns a 7-char `#rrggbb` string, no injection
vector via CSS variable. Still: validate `/^#[0-9a-fA-F]{6}$/` before
applying, fall back to default on mismatch.
- localStorage data is per-origin; no concerns.
## Next Steps
After this phase the plan is complete. Optional follow-ups (NOT in this plan):
- Add more settings (font size, sound on Kinh, etc.) — store is already
shaped to allow it.
- Persist user-defined preset swatches.
## Storage Keys (added)
| Key | Shape | Purpose |
|---|---|---|
| `loto_settings` | `{ emptyCellColor: "#rrggbb" }` | global UI settings |
@@ -1,63 +0,0 @@
---
slug: tan-tan-3-section-board-and-settings
created: 2026-04-26
status: completed
completedAt: 2026-04-26
mode: fast
blockedBy: []
blocks: []
---
# Tân Tân 3-Section Player Board + Settings
Two cosmetic features that make the app look like a real Minh Tân lô tô sheet:
1. **Visual split** — render the 9×9 player card as 3 stacked 3×9 mini-cards
with traditional Tân Tân separator labels. Data and generator unchanged.
2. **Settings** — gear icon → modal with a color picker for empty cells.
Applies to all boards (player + master tracking grid). Default: brown.
Reference image: physical "Minh Tân" card from dochoicholon.com — 3 mini-cards
stacked on one sheet, brown empty cells, decorative cross-hatch separators
labeled "Minh Tân" / "Loại đặc biệt" / "Tấn tài tấn lộc".
## Decisions (locked, do not re-ask)
- **Layout**: stacked on one page, not paginated.
- **Generator**: unchanged — keeps exact 5/row + 5/col on the full 9×9.
- **Color scope**: setting applies to player card *and* master tracking grid.
## Phases
| # | Phase | Status | File |
|---|---|---|---|
| 1 | 3-section visual split for PlayerBoard | DONE | [phase-01-three-section-player-board.md](phase-01-three-section-player-board.md) |
| 2 | Settings modal + empty-cell color picker | DONE | [phase-02-settings-color-picker.md](phase-02-settings-color-picker.md) |
## Files Touched
| File | Phase | Why |
|---|---|---|
| `src/lib/PlayerBoard.svelte` | 1, 2 | render 3 sections; consume color setting |
| `src/routes/master/+page.svelte` | 2 | consume color setting for empty cells |
| `src/routes/+page.svelte` | 2 | mount settings button (player page header) |
| `src/lib/settings-store.svelte.js` | 2 | new — rune-based settings store + persistence |
| `src/lib/SettingsButton.svelte` | 2 | new — gear button + modal |
| `src/app.css` | 1, 2 | section separator styles, CSS var for empty color |
| `docs/project-overview-pdr.md` | 1, 2 | document new visual + settings |
| `docs/codebase-summary.md` | 1, 2 | new files in component table |
## Key Dependencies
- Phase 2 depends on Phase 1 (so the new section background uses the
configurable color from day one). They can also ship independently —
Phase 2 will simply apply to both flat and section layouts.
## Out of Scope
- Per-card color override (one global color for all cards).
- Theme preset bundles.
- Other settings (font, sound, etc.) — settings store will be extensible
but only "empty-cell color" lands now.
- Decorative bg images / paper texture.
- Card serial number badge (the "25" badge in the physical photo).
@@ -1,193 +0,0 @@
# Code Review — Tân Tân 3-section board + Settings color picker
**Date:** 2026-04-27
**Reviewer:** code-reviewer
**Scope:** uncommitted working tree, two features
---
## TL;DR
Both features are essentially correct and ship-worthy. Row-index math is right. Color validation regex is safe enough for a CSS custom property sink. A handful of small bugs/papercuts — none block ship — and one **must-fix** focus/a11y issue worth 5 minutes before deploy.
Verdict: **DONE_WITH_CONCERNS** — ship today, fix the must-fix list before merging the PR.
---
## Must-fix (block ship until done)
### 1. Settings modal: `<button>` overlay swallows the dialog's keydown handler — Escape doesn't close
`SettingsButton.svelte:81` puts `onkeydown={onKeydown}` on the dialog `<div>` with `tabindex="-1"`, but you never `.focus()` it on open. Combined with the full-screen `<button class="absolute inset-0">` overlay (line 84) that takes focus away, **pressing Escape after opening the modal does nothing** until the user manually clicks a focusable element inside the dialog. The trigger gear button still has focus when the modal opens, so Escape there hits document — and your handler is on the dialog div, not document.
Fix one of:
- Add a `window`/document keydown listener inside an `$effect` while `open` is true, OR
- `bind:this={dialogEl}` on the dialog div and call `dialogEl.focus()` in `$effect(() => { if (open) dialogEl.focus(); })`.
Recommend the first — simpler, also handles "user clicked into the color input then hit Esc" since the input doesn't bubble keydown to the dialog div in some browsers.
```js
$effect(() => {
if (!open) return;
const handler = (/** @type {KeyboardEvent} */ e) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
});
```
### 2. Settings modal: clicking the backdrop `<button>` triggers form-submit semantics inside any future `<form>` ancestor
`SettingsButton.svelte:84-89` — bare `<button>` defaults to `type="submit"`. You set `type="button"` (good) but it's a global a11y/HTML linter complaint pattern. Confirm: yes you have `type="button"`. Skip — just noting the area.
Real concern here: the backdrop `<button>` exists purely to make the backdrop clickable. Screen readers will announce it as "Đóng, button" duplicating the close action that already exists on `Xong`. Better:
```svelte
<div role="presentation" onclick={close} class="absolute inset-0"></div>
```
with `<svelte:options ... />` not needed; Svelte will warn about a11y_click_events_have_key_events on a div, but this is a backdrop and `aria-hidden` semantics + the modal having Escape covers it. Or keep the button but add `aria-hidden="true"` and `tabindex="-1"` so it doesn't appear in the tab order or AT tree.
---
## Nice-to-fix (do soon, not blocking)
### 3. Layout `$effect(() => loadSettings())` runs on every reactive re-render
`+layout.svelte:11-13` — `loadSettings()` reads/writes `settings.emptyCellColor` (a `$state`). That mutation is itself a tracked dependency. Today the effect has no dependencies it reads (mutation isn't reading), so it runs once. But this is fragile: if anyone later adds a read like `console.log(settings.emptyCellColor)` in the effect or inside `loadSettings`, you'll get an infinite-loop warning or extra localStorage hits per keystroke when the picker fires.
Tighten intent with `$effect.pre(() => { loadSettings(); }, []);` is **not** valid in Svelte 5. The idiomatic single-shot is:
```js
import { onMount } from "svelte";
onMount(() => { loadSettings(); });
```
`onMount` is the right tool when you want once-on-mount semantics with no reactivity. Effect is overkill here.
### 4. `applyToDom()` in tests removes the `--empty-cell-bg` property in `beforeEach` but `loadSettings` only **sets** it when localStorage is empty OR valid
Race-free in tests, but in the browser there is a brief flash window: between first paint of `+layout.svelte` (CSS var = the `:root` default `#7a4a2b`) and `$effect`/`onMount` running with a saved value (`#000000`, say). User sees brown for 1 frame then black. For this app, fine — call it out, no fix needed. If you cared: emit the saved color via SvelteKit `<svelte:head>` synchronously, but that's massive overkill.
### 5. `/^#[0-9a-fA-F]{6}$/` validation rejects valid CSS colors that round-trip from `<input type="color">`
The native picker always emits 7-char `#rrggbb` lowercase, so practically you're fine. But you reject:
- `#fff` (3-digit shorthand) — your test asserts this; intentional.
- `#fff8` / `#ffffff80` (alpha hex) — fine to reject; CSS var wouldn't differ visibly behind opaque cells anyway.
- Named colors, `rgb()`, `hsl()` — also fine to reject; matches your "hex picker only" UX.
Verdict on regex sufficiency for **security** sink (`document.documentElement.style.setProperty('--empty-cell-bg', x)`): **safe**. The 6-hex regex blocks any character that could close the CSS declaration (`;`, `}`, `/*`, whitespace, parens). Even if it didn't, `style.setProperty` value sanitization in modern engines drops `;` and `{}`. No CSS-injection / XSS risk. Good.
### 6. `master/+page.svelte:217` — `style:background-color={hasNumber ? null : "var(--empty-cell-bg)"}`
Works, but you removed the `bg-slate-100 dark:bg-slate-900/60` Tailwind class from the conditional and didn't add a fallback for the non-`hasNumber` path beyond the inline style. If somehow `--empty-cell-bg` fails to apply (CSS var unsupported, blocked extension, etc.), empty cells render transparent showing the parent's `bg-white`. Defensive option: keep `bg-slate-100 dark:bg-slate-900/60` as a Tailwind fallback for empty cells; the inline `style:background-color` will win when present. Not blocking — `--empty-cell-bg` has a default in `:root`.
### 7. PlayerBoard `aria-label` redundancy
`PlayerBoard.svelte:164` has `aria-label="Bảng lô tô"` on the outer wrapper, **and** each of the 3 inner `.loto-grid` divs has `aria-label="Bảng lô tô — phần N"`. Screen reader will read "Bảng lô tô, group" then "Bảng lô tô — phần 1, group". Drop the inner labels or change them to `aria-labelledby` pointing at the section-label `<div>` (which would itself need an id and `role="heading"` semantics).
Cheapest fix: remove the inner `aria-label` and add `id="section-{sectionIdx}"` + `role="heading" aria-level="3"` to `.section-label`, then `aria-labelledby="section-{sectionIdx}"` on the grid. Or just delete the redundant labels — the visible label text is enough for sighted users and the outer landmark is enough for AT.
---
## Noted, not blocking
### 8. Row-index math is correct — verified
`PlayerBoard.svelte:173-174`:
- `grid.slice(startRow, startRow + 3)` returns 3 rows of 9 = 27 cells when flattened.
- `startRow + Math.floor(idx / 9)` for `idx in [0..26]`: `Math.floor(idx/9) ∈ {0,1,2}` → row ∈ `{startRow, startRow+1, startRow+2}`. Exactly correct for all three sections (start 0, 3, 6).
- `idx % 9` gives col 0..8. Correct.
- `crossed[row]?.[col]` — optional chain handles initial state where `crossed = []`. Fine.
`{#each ... as num, idx (idx)}` keying by `idx` is OK because the underlying number at each position is stable for the lifetime of the grid. If you ever regenerate the board without remounting `PlayerBoard`, Svelte will reuse DOM nodes by index — which is what you want here. No keying bug.
### 9. `$state` at module scope inside `.svelte.js`
Legal in Svelte 5 — module-scope `$state` creates a singleton reactive state. Both `/` and `/master` import the same `settings` object so the picker on either page reflects on the other. Working as intended. Note: SSR with module-scope state can leak between requests in SvelteKit (one server process serves many users with shared module state). For a static-export site (`adapter-static`), this is irrelevant — there is no server. Keep an eye if you ever add a non-static adapter.
### 10. `DEFAULT_SETTINGS = Object.freeze({ ... })` then `$state({ ...DEFAULT_SETTINGS })`
Spread copies only own enumerable props. With one key today, fine. When you add a nested key later (e.g., `{ theme: { primary: '#xxx' } }`), the spread is shallow — `settings.theme` and `DEFAULT_SETTINGS.theme` would alias. `resetSettings` would mutate the frozen default through `settings.theme.primary = ...`. Add a structured-clone or per-key reset before introducing nested keys. YAGNI for now.
### 11. Dark mode parity — yes, mismatch is real but harmless
The rest of the app uses Tailwind v4 `dark:` utilities which (per Tailwind v4 default) compile to `@media (prefers-color-scheme: dark)`. Your new CSS in `app.css` uses the same media query. Parity is **fine**.
`:global(.dark)` selector at `app.css:48` is **dead code** — nothing in this codebase ever sets a `.dark` class on root. Either remove it (cleanest) or leave it as future-proofing. I'd remove it; YAGNI is in force.
### 12. SVG complexity in `SettingsButton.svelte`
Inline 6-line SVG path is harmless but the file is now ~70% boilerplate gear icon. Fine for one-off. If you add more iconography, consider an icon component or `iconify-svelte`. Don't pre-optimize.
### 13. `PRESETS` includes `DEFAULT_SETTINGS.emptyCellColor` as the first swatch
Means `pick(PRESETS[0])` is identical to `resetSettings()`. Two ways to achieve the same thing — fine, since the "Mặc định" button has its own footer placement. Just noting.
### 14. `localStorage.setItem` in `saveSettings` runs synchronously per keystroke during `<input type="color">` `oninput`
Native color picker fires `input` events continuously while dragging. Each event → `JSON.stringify` + `localStorage.setItem` + `documentElement.style.setProperty`. On modern hardware this is sub-ms; on a low-end Android, you might see jank. If you observe it, debounce the localStorage write only — keep `applyToDom` synchronous so the preview is live. Don't pre-optimize until you measure.
### 15. SettingsButton modal lacks initial focus
When opened, focus stays on the gear button. Sighted keyboard users tab into the modal; screen reader users may not realize a dialog opened. Combined with must-fix #1, the cleanest answer is: on open, focus the dialog container (then your Escape handler also works because it's on a focused element). See must-fix #1.
### 16. No focus-trap
When the modal is open, Tab can escape to underlying page elements (header link, "Tạo bảng mới" button, cells). For a small static-site settings dialog, **acceptable**. WAI-ARIA APG recommends a trap; pragmatically, you can ship without one. Don't build one yourself — use a library or accept the gap.
### 17. `style:background-color="var(--empty-cell-bg)"` (PlayerBoard) vs `style:background-color={hasNumber ? null : "var(--empty-cell-bg)"}` (master)
Inconsistent style. PlayerBoard's empty-cell branch always renders the inline style; master's renders it conditionally. Both work because PlayerBoard's inline-style div is in the `{#if !hasNumber}` branch already. Just inconsistent. Pick one pattern.
---
## Security check — passed
- CSS-injection via `--empty-cell-bg`: regex blocks all special chars; `style.setProperty` provides defense-in-depth. **Safe.**
- localStorage tampering: malicious user editing `loto_settings` in DevTools — worst case is they pick a hex color the picker doesn't permit. No privilege escalation, no XSS. **Safe.**
- No PII / secrets in any new file. **Confirmed.**
- No new network calls. **Confirmed.**
---
## Plan TODO completion
Did **not** verify per-checkbox status of `phase-01-three-section-player-board.md` and `phase-02-settings-color-picker.md`. Not in scope of this review per request. Recommend the orchestrator runs `ck plan check` for completed phases.
---
## Recommended actions (priority order)
1. **(must-fix #1)** Move SettingsButton Escape handler to a `window` listener inside `$effect` gated on `open`. ~3 lines.
2. **(must-fix #2)** Mark backdrop `<button>` as `aria-hidden="true" tabindex="-1"` or convert to non-interactive div. ~1 line.
3. **(nice #3)** Swap `$effect(() => loadSettings())` in `+layout.svelte` for `onMount(() => loadSettings())`. ~2 lines.
4. **(nice #7)** Drop redundant inner `aria-label` on each `.loto-grid` in `PlayerBoard.svelte`. ~3 lines.
5. **(noted #11)** Delete `:global(.dark) .section-divider` selector — dead code. ~1 line.
Total: ~10 lines, ~5 minutes. After this, ship.
---
## Metrics
- Files reviewed: 8 (5 modified, 2 new components, 1 new store)
- New LOC: ~230 (SettingsButton 167 + settings-store 63)
- Modified LOC: ~80 (PlayerBoard, master, layout, app.css)
- Critical issues: 0
- Must-fix: 2 (a11y, both in SettingsButton)
- Nice-to-fix: 5
- Noted: 10
- Security issues: 0
---
## Unresolved questions
- Does the project intentionally support OS-driven dark mode only, or is a future user toggle planned? If the latter, the dead `:global(.dark)` selector becomes useful and the dark-mode parity comment changes.
- Should the picker support alpha (RGBA) for see-through paper effect? Currently rejected by regex; would be a 1-char regex change + shape decision.
---
**Status:** DONE_WITH_CONCERNS
**Summary:** Both features correct and shippable. Two small a11y bugs in the settings modal (Escape key, backdrop button semantics) should be patched before merge — total ~5 minutes of fixes.
**Concerns/Blockers:** Modal Escape doesn't close when trigger retains focus; backdrop button is announced redundantly to screen readers.
@@ -1,194 +0,0 @@
# Test Validation Report — SvelteKit Lô Tô Test Infrastructure
**Date:** 2026-04-27
**Scope:** Fresh vitest setup, 2 test files (game-logic.test.js, settings-store.test.js)
---
## Executive Summary
✅ **28/28 tests pass** across 2 files with consistent, reproducible results (no flakiness detected).
✅ **svelte-check:** 0 errors, 0 warnings.
✅ **Build:** Production static export succeeds.
❌ **Lint:** 1 error — eslint config missing Svelte 5 rune globals.
**Coverage:** Game logic core + settings store fully tested. **Storage helpers (`saveGrid`, `loadGrid`, `saveCrossedState`, `loadCrossedState`) are currently untested** — risky gap for persistence layer.
---
## Test Execution Results
### npm test
```
✓ 28 passed (28)
✓ 2 test files
Duration: 2.09s (tests: 323ms)
```
Confirmed stable across 3 consecutive runs:
- Run 1: 28 passed
- Run 2: 28 passed
- Run 3: 28 passed
**No flakiness detected.**
### svelte-check
```
COMPLETED 5 FILES
0 ERRORS | 0 WARNINGS
```
✅ Passes threshold.
### npm run build
```
✓ built in 1.14s (client)
✓ built in 5.35s (server)
✓ Wrote site to "build" (static adapter)
```
✅ Production build succeeds.
### npm run lint
```
❌ FAILED
/config/workspace/tiennm99/loto/src/lib/settings-store.svelte.js
18:25 error '$state' is not defined no-undef
```
**Issue:** eslint config (eslint.config.mjs) doesn't include Svelte 5 rune globals (`$state`, `$derived`, `$effect`, etc.). These are valid in `.svelte.js` files per Svelte 5 spec. Config only has `globals.browser` + `globals.node`, no Svelte globals.
**Workaround:** Add eslint-plugin-svelte's rune globals to config or suppress error on `.svelte.js` files. Not blocking tests/build, but CI will fail on lint.
---
## Coverage Analysis
### TESTED (28 tests)
**game-logic.test.js (16 tests):**
- `generateGrid()`: Shape invariants (9×9, 5/row, 5/col, no dupes) ✓
- Column ranges & ascending sort (col 0: 1-9, col 8: 80-90, ascending per column) ✓
- `isRowComplete()`: All crossed, partial crossed, empty row, zero-cell edge cases ✓
- `getWaitingNumber()`: Single remaining, multiple remaining, none remaining, empty row ✓
Uses 200 trial loops on randomized generators → strong probabilistic coverage.
**settings-store.test.js (12 tests):**
- Defaults (frozen object, default color #7a4a2b) ✓
- `loadSettings()`: Empty storage, valid color, invalid color, wrong shape, corrupt JSON, 3-digit hex rejection, uppercase hex ✓
- `saveSettings()`: localStorage persistence, CSS var injection ✓
- `resetSettings()`: State + persistence reset ✓
Uses happy-dom environment for localStorage + document.documentElement.
---
## UNTESTED Code Paths (Critical Gap)
**In `game-logic.js` (5 functions/helpers — 0 tests):**
### 1. `saveGrid(grid, prefix = "loto")` [line 144–150]
- **Risk:** Grid persistence layer. No test for localStorage write, quota error handling, or serialization edge cases.
- **Behavior tested:** None.
- **Scenario:** App relies on this to save player state; failure = lost game in progress.
### 2. `loadGrid(prefix = "loto")` [line 156–162]
- **Risk:** Grid deserialization. Missing tests for corrupt JSON, wrong shape, null/undefined, migration scenarios.
- **Behavior tested:** None.
- **Scenario:** Corrupted localStorage could crash game initialization.
### 3. `saveCrossedState(crossed, prefix = "loto")` [line 168–174]
- **Risk:** Crossed-cell persistence (game progress). No test for data loss, quota exceeded, or race conditions.
- **Behavior tested:** None.
### 4. `loadCrossedState(prefix = "loto")` [line 180–186]
- **Risk:** Crossed-cell deserialization. Missing validation of boolean matrix structure.
- **Behavior tested:** None.
### 5. Helper Functions (private, but load-bearing):
- `safeParse(raw, validate)` [line 102–110]: Core JSON parse + validate guard. No direct tests.
- `isNumberMatrix(v)` [line 113–124]: Shape validator for grid. No edge case tests (e.g., missing rows, non-number values).
- `isBoolMatrix(v)` [line 127–138]: Shape validator for crossed state. Same gaps.
- `randomNumbersInCol(num, col)` [line 29–35]: Helper for grid generation. No test for edge cases (e.g., num > column size).
- `pickFilledCols()` [line 46–72]: Core quota algorithm. No test for distribution, edge rows, quota correctness.
**Net Impact:** Entire grid persistence layer is untested. If `saveGrid`/`loadGrid` fail, the app silently falls back to in-memory only, but no coverage validates that fallback or data integrity.
---
## Critical Questions (Unresolved)
1. **Is `localStorage` quota/disabled handling sufficient?** Tests use try-catch for quota in save functions, but no test validates recovery. If localStorage is disabled (private mode), the app still works in-memory — but is that intentional? Should it warn the user?
2. **Do `loadGrid` / `loadCrossedState` need stricter shape validation?** Current validators check length & type, but don't validate:
- Row/column numeric ranges (e.g., grid cells must be 0–90)
- Cross-state consistency (e.g., crossed cell at empty position should fail?)
3. **Migration path for corrupted localStorage?** If user has old/malformed data, `loadGrid` silently returns null and app starts fresh. Is this documented? Should there be a user-facing error?
4. **Flakiness under load?** Tests pass cleanly in isolation. Have they been run under simulated storage quota exceeded or in private-mode environments?
---
## Recommendations (Prioritized)
### Must Fix (blocks lint)
- **Fix eslint config** to include Svelte 5 rune globals or suppress `.svelte.js` files:
```js
// Option 1: Add globals for .svelte.js files in eslint.config.mjs
{
files: ["**/*.svelte.js"],
languageOptions: {
globals: { $state: "readonly", $derived: "readonly", ... }
}
}
```
### Should Add (medium risk — persistence)
- Test `saveGrid` / `loadGrid` with:
- Valid 9×9 grid (round-trip serialization)
- Corrupt JSON payloads
- Wrong matrix shape (e.g., 8×9, 3×9)
- localStorage quota exceeded (mock error)
- null/undefined input
- Test `saveCrossedState` / `loadCrossedState` similarly
- Test `isNumberMatrix` / `isBoolMatrix` validators with invalid inputs:
- Non-array, wrong dimensions, mixed types, null values
### Nice to Have (edge case hardening)
- Test `pickFilledCols()` distribution (e.g., all rows/cols hit exactly 5 cells after 1000 iterations)
- Test `randomNumbersInCol()` edge cases (e.g., pick 5 from col 0, which has exactly 9 options)
- Add integration test: full game workflow (generate → save → load → cross → save → load → verify)
---
## Build & Environment Notes
- **Node environment:** happy-dom for DOM mocking in settings tests (✓ working)
- **jsconfig.json:** Valid; extends SvelteKit conventions (but missing explicit tsconfig extension hint)
- **Vite/SvelteKit:** v7 + v2, fully compatible with test setup
- **Test framework:** vitest 4.1.5, ESM modules, no issues
---
## Summary Table
| Command | Status | Notes |
|---------|--------|-------|
| `npm test` | ✅ | 28/28 pass, stable across runs |
| `svelte-check` | ✅ | 0 errors, 0 warnings |
| `npm run build` | ✅ | Static export succeeds |
| `npm run lint` | ❌ | $state rune not declared in eslint globals |
---
**Status:** DONE_WITH_CONCERNS
**Summary:** Test infrastructure is functional & stable (28/28 pass, no flakiness). Lint must be fixed. **Critical gap: storage layer (saveGrid, loadGrid, saveCrossedState, loadCrossedState, and helper validators) is untested—recommend adding coverage for persistence before launch.**
**Concerns:**
1. Lint error blocks CI (eslint config missing Svelte 5 globals)
2. Persistence layer untested (risky for game state recovery)
3. No integration test covering save/load round-trip
4. Validators (isNumberMatrix, isBoolMatrix) not stress-tested with edge cases
@@ -1,124 +0,0 @@
# Phase 1 — Settings expansion (foundation)
## Context
- [plan.md](plan.md)
- All later phases consume settings from this store. Land first.
## Overview
- Priority: P0 (blocks 2, 5, 6)
- Status: TODO
- Effort: ~30 min
- Description: Extend `settings-store.svelte.js` shape, validate, persist;
swap preset palette to Office Standard Colors; default empty-cell color
to purple. Add tests.
## New settings shape
```ts
{
emptyCellColor: string, // hex, default "#7030A0" (Excel Standard Purple)
theme: "auto"|"light"|"dark", // default "auto" — implementation in Phase 2
masterMode: boolean, // default false — wired in Phase 5
autoCallEnabled: boolean, // default false — wired in Phase 6
autoCallSpeed: number, // 1..10 seconds, default 5 — wired in Phase 6
}
```
## Excel Standard Colors palette (10 swatches, in order)
| # | Name | Hex |
|---|---|---|
| 1 | Dark Red | `#C00000` |
| 2 | Red | `#FF0000` |
| 3 | Orange | `#FFC000` |
| 4 | Yellow | `#FFFF00` |
| 5 | Light Green | `#92D050` |
| 6 | Green | `#00B050` |
| 7 | Light Blue | `#00B0F0` |
| 8 | Blue | `#0070C0` |
| 9 | Dark Blue | `#002060` |
| 10 | **Purple (default)** | `#7030A0` |
Layout in modal: `grid grid-cols-5 gap-2` so all 10 fit cleanly.
## Validation
- `emptyCellColor`: same `/^#[0-9a-fA-F]{6}$/` regex.
- `theme`: must be one of the literal strings; else fall back to `"auto"`.
- `masterMode` / `autoCallEnabled`: must be boolean; else default.
- `autoCallSpeed`: must be integer in `[1, 10]`; else default 5.
Use a small per-key validator function — don't pull in a schema lib.
## Files
| File | Change |
|---|---|
| `src/lib/settings-store.svelte.js` | extend `DEFAULT_SETTINGS`; add per-key validators; update `loadSettings` to validate each key independently and fall back per-key on miss; update `saveSettings` (no shape change). Apply-to-DOM stays for `--empty-cell-bg`. |
| `src/lib/settings-store.test.js` | add tests for the 4 new keys. |
| `src/lib/SettingsButton.svelte` | replace 8 ad-hoc swatches with the 10 Excel ones; first preset auto-tracks `DEFAULT_SETTINGS.emptyCellColor`. (Theme toggle, master mode, auto-call land in later phases — keep this phase scoped to color + foundation.) |
## Implementation Steps
1. Edit `settings-store.svelte.js`:
- Update `DEFAULT_SETTINGS`:
```js
export const DEFAULT_SETTINGS = Object.freeze({
emptyCellColor: "#7030A0",
theme: "auto",
masterMode: false,
autoCallEnabled: false,
autoCallSpeed: 5,
});
```
- Add validators (one fn per key, returns coerced value or default).
- Replace `loadSettings` body to apply each validator independently:
```js
const parsed = JSON.parse(raw) ?? {};
settings.emptyCellColor = validColor(parsed.emptyCellColor) ?? DEFAULT_SETTINGS.emptyCellColor;
settings.theme = validTheme(parsed.theme) ?? DEFAULT_SETTINGS.theme;
// ... etc
```
2. Edit `settings-store.test.js`:
- Update default-color test to expect `#7030A0`.
- Add tests for each new key: default, valid load, invalid load fall-back.
3. Edit `SettingsButton.svelte`:
- Replace `PRESETS` array with the 10 Excel hex values.
- Change swatch grid to `grid-cols-5`.
## Tests to add (in `settings-store.test.js`)
```
describe("settings-store — theme")
it default is "auto"
it loads "light" / "dark" valid
it falls back on unknown string
describe("settings-store — masterMode")
it default is false
it loads true
it falls back on non-boolean
describe("settings-store — autoCallEnabled") // same shape as masterMode
describe("settings-store — autoCallSpeed")
it default 5
it loads 1..10
it rejects 0 / 11 / non-int
it rejects "5" string
```
## Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Existing users have `loto_settings` with only `emptyCellColor` | High | None — per-key fallback handles it | Per-key validation, not whole-object |
| Default purple is too saturated against white grid cells | Med | Cosmetic | Reviewed: `#7030A0` reads fine; user picked purple; iterate if needed |
## Success criteria
- `npm test` adds new passing tests; existing 38 still pass.
- Settings modal shows 10 Excel swatches, default purple is the first one (auto-bound to `DEFAULT_SETTINGS.emptyCellColor`).
- Loading a settings JSON with old `{emptyCellColor: "#1e88e5"}` keeps the user's blue choice (no key wiped).
## Next
- Phase 2 consumes `settings.theme`.
- Phase 5 consumes `settings.masterMode`.
- Phase 6 consumes `settings.autoCallEnabled` + `settings.autoCallSpeed`.
@@ -1,135 +0,0 @@
# Phase 2 — Theme system: explicit light/dark override
## Context
- [plan.md](plan.md) — depends on Phase 1.
- Currently dark mode follows `prefers-color-scheme` automatically via Tailwind v4's default.
- Goal: 3-state theme (`auto` / `light` / `dark`); user-pick lives in Settings.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~30 min
## Architecture
**Tailwind v4 dark variant.** Tailwind v4 ships with `dark:` mapped to `@media (prefers-color-scheme: dark)` by default. To honor an explicit class, declare a custom variant in `app.css`:
```css
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
```
After this, `dark:bg-slate-900` matches when ancestor `<html>` has `class="dark"`. We *replace* the OS-media behavior with class-based; OS preference is then mirrored into the class by JS, so the visual result is the same when `theme === "auto"`.
**JS apply.** `settings-store` decides:
- `"light"` → remove `dark` class from `<html>`.
- `"dark"` → add `dark` class to `<html>`.
- `"auto"` → apply class based on `window.matchMedia('(prefers-color-scheme: dark)').matches`, AND subscribe to its `change` event to re-apply.
When the user picks a non-auto theme, unsubscribe the media listener.
**CSS conversions.** `app.css` currently has 2 `@media (prefers-color-scheme: dark)` blocks (`.section-divider`, `.section-divider-vertical`, `.section-label`). Convert to `:global(.dark) .selector` instead, since the global theme system now drives the class. Without conversion these styles only kick in by OS pref, ignoring the user's explicit pick.
## Settings UI
In `SettingsButton.svelte`, add a fieldset above the color picker:
```svelte
<fieldset class="mb-5">
<legend class="text-sm font-semibold ...">Giao diện</legend>
<div class="grid grid-cols-3 gap-2">
{#each [["auto","Tự động"],["light","Sáng"],["dark","Tối"]] as [v, label]}
<button onclick={() => pickTheme(v)}
class="px-3 py-2 rounded-lg border-2 ...
{settings.theme === v ? 'border-indigo-500 ...' : 'border-slate-200 ...'}">
{label}
</button>
{/each}
</div>
</fieldset>
```
`pickTheme(v)` updates `settings.theme = v` and calls `saveSettings()`. The store's apply effect handles the DOM class.
## Files
| File | Change |
|---|---|
| `src/app.css` | Add `@variant dark (&:where(.dark, .dark *));` after `@import`. Convert 2 dark `@media` blocks to `:global(.dark) .selector { ... }`. |
| `src/lib/settings-store.svelte.js` | Add `applyTheme()` helper called from `loadSettings()` and from the effect that runs when `settings.theme` changes. Manages the `prefers-color-scheme` media listener for auto mode. |
| `src/lib/SettingsButton.svelte` | Add theme tri-state selector. |
| `src/lib/settings-store.test.js` | Add tests for theme apply (manipulate `window.matchMedia` mock, verify `<html>` class). |
| `src/routes/+layout.svelte` | No change — `loadSettings()` on mount already triggers `applyTheme()`. |
## Implementation Steps
1. `app.css`:
- Add `@variant dark (&:where(.dark, .dark *));` directly under `@import "tailwindcss";`.
- Replace `@media (prefers-color-scheme: dark) { .section-divider, .section-divider-vertical { ... } }` with `:global(.dark) .section-divider, :global(.dark) .section-divider-vertical { ... }`.
- Same conversion for `.section-label`.
2. `settings-store.svelte.js`:
- Add `applyTheme()`:
```js
/** @type {MediaQueryList | null} */
let mql = null;
/** @type {((e: MediaQueryListEvent) => void) | null} */
let mqlListener = null;
function applyTheme() {
if (typeof document === "undefined") return;
// tear down any previous auto listener
if (mql && mqlListener) {
mql.removeEventListener("change", mqlListener);
mql = null; mqlListener = null;
}
const root = document.documentElement;
const set = (dark) => root.classList.toggle("dark", dark);
if (settings.theme === "dark") set(true);
else if (settings.theme === "light") set(false);
else { // auto
mql = window.matchMedia("(prefers-color-scheme: dark)");
set(mql.matches);
mqlListener = (e) => set(e.matches);
mql.addEventListener("change", mqlListener);
}
}
```
- Call `applyTheme()` from `loadSettings()` (after settings are populated) and from `saveSettings()`.
3. `SettingsButton.svelte`:
- Add the theme fieldset above the color one.
- Wire to `settings.theme` + `saveSettings()`.
4. `settings-store.test.js`:
- Tests for `applyTheme()`. Mock `window.matchMedia`. Assert class on `<html>`. Test `auto` listener attach/detach when switching modes.
## Edge cases
- SSR: app uses adapter-static + `ssr: false` (per docs). `applyTheme` early-returns if `document` is undefined — safe.
- Multiple components calling `applyTheme()` while listener already attached: helper tears down first → idempotent.
- User flips between `auto` and `dark` and `auto` again — listener correctly re-attaches.
## Tests
```
describe("applyTheme")
beforeEach: clear classList; mock matchMedia (prefers-color-scheme: dark) → false
it("theme=light removes dark class")
it("theme=dark adds dark class")
it("theme=auto applies based on matchMedia matches")
it("theme=auto re-applies on matchMedia change event")
it("switching auto → dark detaches matchMedia listener")
```
## Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| `@variant dark (.dark &)` doesn't compile in Tailwind v4 setup | Low | High | Verify in Phase 2 dev test. Fallback: add to a `@layer` |
| Existing dark CSS in `app.css` no longer triggers from OS | High by design | Low | After conversion, OS pref drives the class via JS; visual result identical |
| User in private browsing → no localStorage → theme reset every reload | Low | Annoying | Acceptable; same as today for color setting |
## Success criteria
- Toggling theme in Settings repaints the app live, no reload.
- Set theme=light, reload — stays light despite OS dark mode.
- Set theme=auto, change OS dark mode — app flips live.
- All existing 38+ tests pass; new theme tests pass.
- `npm run build` clean (no Tailwind variant errors).
@@ -1,123 +0,0 @@
# Phase 3 — Mobile fit + page header/footer refactor
## Context
- [plan.md](plan.md). Independent of Phases 2/4/5/6 — can land any time.
- Touches PlayerBoard CSS (responsive cells), `/` page header, and adds a footer.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~30 min
## Mobile fit
### Goal
The 9-col card with `aspect-[3/5]` cells × 3 sections × 3 rows currently produces
a tall card that overflows portrait viewport on small phones. Want the player
board to **fit a typical mobile portrait viewport** (no horizontal scroll, fits
within 1 viewport height where reasonable).
### Approach (numbers worked from a 360 px portrait baseline)
- 360 px viewport − 16 px page padding (`px-2`) = 344 px card width.
- 9 cols → 38 px wide per cell.
- On mobile, switch cells from `aspect-[3/5]` (63 px tall) → `aspect-square`
(38 px tall). 3 rows × 38 = 114 px per section + 30 px label = 144 px × 3
sections + 16 px frames = ~448 px. Plus header (~120 px) + buttons + footer
≈ 700 px total — fits portrait on most phones (Pixel 7 = 915 px).
- On `sm:` breakpoint (≥640 px) cells return to the prettier `aspect-[3/5]`.
### Number text size
- Smaller cells need smaller font: `text-base` mobile, `text-2xl sm:text-3xl`
on larger.
### Page padding
- Player page wrapper: `px-3 py-8 sm:py-12` → `px-2 py-4 sm:px-3 sm:py-12`.
Tighten on mobile so the card breathes less.
## Header/footer refactor
### Current header (`src/routes/+page.svelte`)
- H1 "Lô tô"
- Tagline: "Lấy cảm hứng từ những buổi họp lớp thiếu giấy chơi lô tô / của TN1 (2014–2017)"
- "Hướng dẫn / Trang quản trò" links
- Settings gear
### After
- Settings gear (same place)
- H1 "Lô tô"
- (no tagline — moved to footer)
- (no instructions toggle — removed)
- (no "Trang quản trò" link — gated by master-mode setting in Phase 5)
- Subhead removed
### Footer (NEW component `src/lib/PageFooter.svelte`)
```
[centered tagline: "Lấy cảm hứng từ những buổi họp lớp thiếu bộ lô tô của TN1 (2014–2017)"]
[centered: Made by <a>miti99</a> with <heart-svg>]
```
Tagline corrected from "thiếu giấy chơi lô tô" → "thiếu bộ lô tô" per user spec.
Heart icon: small inline SVG (Phosphor / Lucide-style heart, 14px), filled red
(`fill-red-500`). Use the same simple SVG pattern as the SettingsButton gear.
### Bottom-section-label cleanup in PlayerBoard
The current PlayerBoard has a final `<div class="section-label">Made by miti99
with ❤️ ...</div>`. Remove that block — the attribution lives in PageFooter
now. Keep the rest of the labels above each section.
## Files
| File | Change |
|---|---|
| `src/lib/PlayerBoard.svelte` | Cells responsive `aspect-square sm:aspect-[3/5]`. Number text `text-base sm:text-2xl md:text-3xl`. Drop the bottom "Made by" section-label. |
| `src/lib/PageFooter.svelte` | NEW — tagline + made-by-with-heart-svg. ~30 LOC. |
| `src/routes/+page.svelte` | Drop tagline `<p>`, instructions toggle/panel, "Trang quản trò" link. Mount `<PageFooter />` after `<PlayerBoard />`. Tighten page padding. |
| `src/routes/master/+page.svelte` | Mount `<PageFooter />` too (Phase 5 collapses this whole route into a thin shell, so this footer mount becomes near-trivial then). |
## Implementation Steps
1. **PageFooter.svelte (new)** — write the small component:
```svelte
<footer class="mt-8 text-center text-xs text-slate-500 dark:text-slate-400 space-y-2">
<p>Lấy cảm hứng từ những buổi họp lớp thiếu bộ lô tô của TN1 (2014–2017)</p>
<p class="flex items-center justify-center gap-1">
Made by
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer"
class="text-indigo-500 hover:underline">miti99</a>
with
<svg viewBox="0 0 24 24" fill="currentColor" aria-label="trái tim"
class="inline w-3.5 h-3.5 text-red-500">
<path d="M12 21s-7-4.35-9.5-8.5C.5 8.5 3 4 7 4c2 0 3.5 1 5 3 1.5-2 3-3 5-3 4 0 6.5 4.5 4.5 8.5C19 16.65 12 21 12 21z"/>
</svg>
</p>
</footer>
```
2. **PlayerBoard.svelte**:
- Cells: replace `aspect-[3/5]` → `aspect-square sm:aspect-[3/5]` on both empty-cell div and button cell.
- Number text size: replace `text-2xl sm:text-3xl` → `text-base sm:text-2xl md:text-3xl`.
- Delete the final bottom `<div class="section-label">…Made by miti99…</div>` block.
3. **/+page.svelte**:
- Remove `let showInstructions = $state(false)`, the tagline `<p>`, the instructions toggle button, the instructions panel block, and the "Trang quản trò" link (Phase 5 will gate this differently).
- Add `import PageFooter from "$lib/PageFooter.svelte";` and mount `<PageFooter />` after `<PlayerBoard />`.
- Adjust wrapper to `px-2 py-4 sm:px-3 sm:py-12`.
4. **/master/+page.svelte**: import + mount `<PageFooter />`. (Will be massively simplified in Phase 5.)
## Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Mobile cells too small to read at `text-base` | Med | UX | Manually verify with browser devtools 360px width; bump to `text-lg` if needed |
| Heart SVG render width drift across browsers | Low | Cosmetic | Fixed `w-3.5 h-3.5` via Tailwind |
| Aspect-square mobile + tan-tan-num font feels squat | Low | Cosmetic | Acceptable trade for fit |
## Success criteria
- iPhone-width devtools (375 px): card fits without horizontal scroll, total page height ≤ ~750 px.
- Desktop (≥640 px): card looks the same as before this phase (tall cells).
- Footer shows tagline above made-by-with-heart-svg-link.
- No instructions toggle anywhere.
- All tests still pass.
## Next
- Phase 4 (extract MasterPanel) is independent.
- Phase 5 wires master mode + the missing "Trang quản trò" hook (now lives in Settings).
@@ -1,89 +0,0 @@
# Phase 4 — Extract MasterPanel component
## Context
- [plan.md](plan.md). Independent of Phase 2/3. Blocks Phase 5/6.
- Goal: pull all of `/master`'s state + UI (except page-level header) into a
reusable `MasterPanel.svelte` so both `/` (when master mode on) and
`/master` (kept for deep link) render the same logic without duplication.
## Overview
- Priority: P0 for Phase 5/6 dependency
- Status: TODO
- Effort: ~30 min (mechanical refactor + verify)
## What goes into MasterPanel
From `src/routes/master/+page.svelte`:
- `<script module>` block (BOARD constants, storage helpers).
- `<script>` reactive state (`state`, `lastCalled`, `callOrder`, effects, handlers).
- DOM:
- "Ván mới" + "Xổ số" controls.
- "Số vừa xổ" hero token.
- "Thứ tự đã xổ" history.
- 11×9 master tracking grid.
- The host's own player card (`<PlayerBoard storagePrefix="loto_master_card" />`).
## What stays out
- Page-level header (H1 "Quản trò", back-to-player link, the SettingsButton mount).
- The `<PageFooter />` mount.
- The outer `<div class="flex flex-col flex-1 items-center px-3 py-8 …">` wrapper.
## File contract
`src/lib/MasterPanel.svelte`:
- No props (mirrors current behavior).
- Self-contained — owns its own localStorage, its own draw state, etc.
- Renders top-down: controls → "Số vừa xổ" → history → master grid → host's own card.
- Uses the `loto_master` and `loto_master_card_*` storage keys (unchanged).
`src/routes/master/+page.svelte` shrinks to:
```svelte
<script>
import { base } from "$app/paths";
import MasterPanel from "$lib/MasterPanel.svelte";
import SettingsButton from "$lib/SettingsButton.svelte";
import PageFooter from "$lib/PageFooter.svelte";
</script>
<div class="flex flex-col flex-1 items-center px-2 py-4 sm:px-3 sm:py-12">
<div class="w-full max-w-2xl">
<header class="relative text-center mb-6">
<div class="absolute right-0 top-0"><SettingsButton /></div>
<h1 class="text-3xl sm:text-4xl font-extrabold ...">Quản trò</h1>
<a href="{base}/" class="...">← Về trang người chơi</a>
</header>
<MasterPanel />
<PageFooter />
</div>
</div>
```
(Phase 5 will further re-shape this route.)
## Implementation Steps
1. Create `src/lib/MasterPanel.svelte`. Copy:
- Entire `<script module>` block.
- The reactive `<script>` block (state, derived, effects, handlers — but NOT the `import { base }` since the back link stays on the route).
- The DOM from "Controls" through "Master 9x10 tracking board" through "Master's own playing card" `<PlayerBoard storagePrefix="loto_master_card" />`. Wrap in a top-level `<section aria-label="Bảng quản trò">…</section>` (since the page already has its own header).
2. In `src/routes/master/+page.svelte`, delete the moved code and replace with `<MasterPanel />`. Verify imports — keep only what's still used: `base`, `SettingsButton`, `MasterPanel`, `PageFooter` (Phase 3 added).
3. svelte-check + dev-browser verify the `/master` route renders identically to before.
## Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Module-scope `BOARD` constants accidentally duplicated when component re-mounts | None — `<script module>` is one-time per module | — | n/a |
| Storage prefix typo breaks existing user data | Low | High | Keep `loto_master` and `loto_master_card` exactly. Grep before/after. |
| Route still imports unused symbols (lint warning) | Med | Low | clean up after move |
## Success criteria
- `/master` page renders identically pre/post (visually + functionally).
- No code duplicated between MasterPanel and the route.
- `npm test`/`lint`/`svelte-check`/`build` all clean.
## Next
- Phase 5: mount `MasterPanel` conditionally on `/`.
- Phase 6: extend MasterPanel with auto-call.
@@ -1,101 +0,0 @@
# Phase 5 — Master mode integration on `/`
## Context
- [plan.md](plan.md). Depends on Phase 1 (settings.masterMode) and Phase 4 (MasterPanel).
- Goal: a single-page experience. Player board on top; master panel below
when `settings.masterMode === true`. The `/master` route stays as a deep
link.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~25 min
## UX
**Player page (`/`)**:
- Header (H1 "Lô tô" + SettingsButton).
- PlayerBoard.
- If `settings.masterMode`:
- Visual divider (e.g., `<hr>` or just margin) + small heading "Quản trò".
- `<MasterPanel />`.
- PageFooter.
**Settings**: a "Chế độ quản trò" toggle (on/off, default off). When the user flips it on, the master panel appears inline immediately (rune-reactive — no reload). When off, the panel unmounts and the master state in localStorage stays put (next toggle restores it).
**`/master` deep link**: continues to render `<MasterPanel />` standalone (Phase 4's slim shell). Doesn't mutate the masterMode setting (least surprise — user lands on /master, sees master panel, leaves; their `/` view stays the way they left it).
## Files
| File | Change |
|---|---|
| `src/routes/+page.svelte` | Import `MasterPanel`. Read `settings.masterMode`. Conditionally render `<MasterPanel />` between PlayerBoard and PageFooter. |
| `src/lib/SettingsButton.svelte` | Add a "Chế độ quản trò" toggle row (above the color fieldset, below theme). Wire to `settings.masterMode` + `saveSettings`. |
| `src/routes/master/+page.svelte` | No further change in this phase (Phase 4 already shrank it). |
## Implementation Steps
1. `+page.svelte`:
```svelte
<script>
import MasterPanel from "$lib/MasterPanel.svelte";
import { settings } from "$lib/settings-store.svelte.js";
// ... existing imports
</script>
<PlayerBoard />
{#if settings.masterMode}
<div class="mt-10">
<h2 class="text-center text-lg font-bold text-orange-500 dark:text-orange-400 mb-4">
Quản trò
</h2>
<MasterPanel />
</div>
{/if}
<PageFooter />
```
2. `SettingsButton.svelte`: add the toggle in the modal. Keep it minimal — a labelled checkbox or a 2-state pill. Suggest pill for visual consistency with theme buttons.
```svelte
<fieldset class="mb-5">
<legend class="text-sm font-semibold ...">Chế độ quản trò</legend>
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
Hiện bảng quản trò bên dưới bảng người chơi
</p>
<button onclick={() => toggleMasterMode()}
class="w-full px-3 py-2 rounded-lg border-2 ...
{settings.masterMode
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
: 'border-slate-300 dark:border-slate-600'}">
{settings.masterMode ? "Đang bật" : "Đang tắt"}
</button>
</fieldset>
```
3. Verify: toggle on/off in settings → master panel appears/disappears live on `/`. `/master` still works.
## Edge cases
- User flips master mode off mid-game: panel unmounts; `loto_master` storage stays so next-on restores. ✓ Default Svelte behavior since the component manages its own state from storage on mount.
- User has no `loto_master` data when toggling on for the first time: MasterPanel's existing render handles this (`{:else} Nhấn "Ván mới" để bắt đầu`). ✓
## Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Unmount mid-auto-call interval (Phase 6) leaks timer | Med | Memory | $effect cleanup in Phase 6 covers it |
| Navigation from `/` to `/master` with masterMode=on shows master panel twice (once on /, once on /master) | n/a | — | Different routes — only one renders at a time |
| Removing "Trang quản trò" header link in Phase 3 leaves no nav to `/master` | True by design | Acceptable | `/master` becomes deep-link-only; users discover master mode via Settings |
## Success criteria
- `settings.masterMode === false`: `/` shows player board + footer only. No "Trang quản trò" link or master content.
- `settings.masterMode === true`: `/` shows player board, then "Quản trò" subhead, then full MasterPanel, then footer.
- `/master` still works (renders MasterPanel + back link to `/`).
- Toggling the setting repaints `/` live.
## Next
- Phase 6: auto-call extends MasterPanel.
@@ -1,133 +0,0 @@
# Phase 6 — Auto-call: toggle + interval lifecycle
## Context
- [plan.md](plan.md). Depends on Phase 1 (settings.autoCallEnabled, autoCallSpeed) and Phase 4 (MasterPanel).
## Overview
- Priority: P1
- Status: TODO
- Effort: ~30 min
## UX
In MasterPanel, when `settings.autoCallEnabled === true`:
- Add a 2nd line below the controls: speed slider 1–10 sec (label: "Tốc độ tự động: {N} giây/số") wired to `settings.autoCallSpeed`.
- "Xổ số" button label changes:
- Not running → **"Bắt đầu"** (green gradient).
- Running → **"Dừng"** (red gradient).
- Clicking starts/stops a `setInterval` that calls the existing `handleDrawNext()` every N seconds.
- Auto-stop when `state.remaining.length === 0`.
When `settings.autoCallEnabled === false` (default): unchanged — manual "Xổ số" per click.
The Auto on/off toggle itself lives in **Settings** (per user spec). Keeping the speed slider inline in MasterPanel — close to the Bắt đầu/Dừng button it controls — feels more discoverable than burying it in Settings. Acceptable deviation: speed lives in MasterPanel, master-mode + auto-enabled live in Settings. (Alt: put speed in Settings too. Decided inline because it's a runtime knob, not a preference.)
Wait — re-read user spec: "When auto, Xổ số became Start/Stop". And "auto-call speed (1-10s/number, default 5)" persisted in settings. The toggle is also in settings.
So:
- **Settings**: `autoCallEnabled` (toggle), `autoCallSpeed` (slider).
- **MasterPanel**: when `autoCallEnabled`, button switches to start/stop; reads speed from settings live (so changing speed in Settings while running re-arms the interval).
Let me put the speed slider in Settings (cleaner), but display its current value as a small caption near the start/stop button so the host knows what's about to fire.
## Lifecycle (the careful bit)
```js
// Inside MasterPanel
let autoRunning = $state(false);
$effect(() => {
// Re-arm interval whenever autoRunning OR speed changes
if (!autoRunning) return;
const ms = settings.autoCallSpeed * 1000;
const id = setInterval(() => {
if (!state || state.remaining.length === 0) {
autoRunning = false; // triggers cleanup
return;
}
handleDrawNext();
}, ms);
return () => clearInterval(id);
});
// Auto-stop also if user disables the auto setting mid-run
$effect(() => {
if (!settings.autoCallEnabled && autoRunning) autoRunning = false;
});
// Auto-stop when starting a new game
function handleNewGame() { /* ... */ autoRunning = false; }
```
Race conditions handled:
- **Speed change while running**: $effect re-runs (its dependency `settings.autoCallSpeed` changed) → cleanup old `setInterval` → start new one. ✓
- **Remaining hits 0 during a tick**: tick checks first, sets `autoRunning = false`, returns without drawing. The $effect re-runs, sees `!autoRunning`, returns without setting up a new interval. ✓
- **User unmounts MasterPanel (master mode off) mid-run**: Svelte fires effect cleanup → `clearInterval`. ✓
- **User disables auto setting while running**: secondary $effect catches it, flips `autoRunning = false`, primary effect cleans up. ✓
- **Multiple intervals stacking**: impossible, primary $effect always runs cleanup before re-running. ✓
## Files
| File | Change |
|---|---|
| `src/lib/MasterPanel.svelte` | Add `autoRunning` state, the two `$effect`s above, swap "Xổ số" button label/style based on `settings.autoCallEnabled` and `autoRunning`. Display speed caption when auto on. |
| `src/lib/SettingsButton.svelte` | Add "Tự động xổ" toggle + speed slider 1–10 (visible only when masterMode is on, OR always; pick: always — toggling auto without master mode is a no-op so harmless). |
| `src/lib/settings-store.test.js` | (already covered Phase 1) — no new tests for the slider UI itself. |
| (no new tests for the $effect lifecycle — relies on browser timers; manual verify in dev) |
## Implementation Steps
1. `MasterPanel.svelte`:
- Add `import { settings } from "$lib/settings-store.svelte.js"` if not already present.
- Add `let autoRunning = $state(false)`.
- Add the two $effects (above).
- Modify the "Xổ số" button:
```svelte
{#if settings.autoCallEnabled}
<button onclick={() => (autoRunning = !autoRunning)}
disabled={!state || (state.remaining.length === 0 && !autoRunning)}
class="px-10 py-4 rounded-full font-semibold text-white text-lg
bg-gradient-to-r {autoRunning
? 'from-red-500 to-rose-500'
: 'from-emerald-500 to-teal-500'}
hover:opacity-90 active:scale-95 transition-all shadow-lg">
{autoRunning ? "Dừng" : "Bắt đầu"}
</button>
{:else if state && state.remaining.length > 0}
<button onclick={handleDrawNext} class="...">Xổ số</button>
{/if}
```
- Reset `autoRunning = false` in `handleNewGame`.
- Below the buttons, when auto on: small caption "Tự động: {settings.autoCallSpeed}s/số".
2. `SettingsButton.svelte`:
- Add a "Tự động xổ" fieldset (after Master mode) with:
- On/off toggle pill (same style as masterMode).
- When on: a `<input type="range" min="1" max="10" step="1" bind:value={settings.autoCallSpeed} oninput={saveSettings}>` plus a label showing "{N} giây/số".
3. svelte-check + manual dev verify:
- Master mode on, auto off: "Xổ số" works as before.
- Master mode on, auto on, speed=2: click "Bắt đầu" → numbers draw every 2s. Click "Dừng" → stops. Click "Bắt đầu" again → resumes. Reaching 90 numbers stops automatically.
- Change speed slider while running → interval re-arms at new speed.
## Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Tab in background throttles `setInterval` to ≥1s | Real | If speed=1, browser may still fire ~1s. UX: slightly slower in background. | Acceptable — host typically has tab focused |
| User toggles autoCallEnabled rapidly while running | Low | Stale interval | Secondary $effect catches it |
| `handleDrawNext` mutates `state` during interval tick — does Svelte 5 reactivity batch correctly? | Low | If reactive update is slow, intervals could overlap. | `setInterval` ticks are independent of render; `handleDrawNext` is sync; no overlap risk in practice. |
| Confirm dialog in `handleNewGame` (`if (state && !confirm(...))`) blocks during auto-call → interval keeps firing while modal is up | Low | Visual glitch | `handleNewGame` is user-triggered, not from interval. Safe. |
## Success criteria
- Auto setting off → manual "Xổ số" unchanged.
- Auto setting on → "Bắt đầu"/"Dừng" button. Drawing every N seconds. Stops on Dừng. Stops at 90/90. Speed change re-arms.
- Disabling auto in Settings while running stops it.
- Tests still pass (no logic regression).
- No timer leaks (verified by toggling master-mode off while running — Svelte cleanup handles).
## Done = Plan complete
- Mark plan.md status → `completed`. Run `/ck:project-management` sync if needed.
- Update docs (PDR, codebase-summary, system-architecture, dev-roadmap).
- Move "Theme switcher" from roadmap "Idea Phase" → "Currently Implemented".
@@ -1,80 +0,0 @@
---
slug: master-merge-theme-auto-mobile-fit
created: 2026-04-27
status: completed
completedAt: 2026-04-27
mode: auto
blockedBy: []
blocks: []
---
# Master mode merge + theme toggle + auto-call + mobile fit
Eight user-requested features bundled into one coordinated refactor.
Touches almost every component in the app but keeps the data model
(grid, crossed, called/remaining, storage keys) untouched.
## Decisions (locked)
- **Default color**: Excel "Standard Color: Purple" `#7030A0`. Most
saturated of the 10 standard purples; reads well on both white and
black grids; user picked "purple" as the default.
- **Color presets**: Office Standard Colors palette (10 swatches), see
Phase 1 for hex values.
- **Theme**: 3-state — `auto` (default, follow OS), `light`, `dark`.
Implemented by toggling `class="dark"` on `<html>` + Tailwind v4
`@variant dark (.dark &)` declaration.
- **Master route**: kept as a deep-link that auto-enables master mode
in settings on visit, then redirects (or just renders the same `/`).
Recommend: keep `/master` rendering the same content as `/` but with
master mode forced on for that view (no setting mutation — least
surprise). Storage prefixes preserved.
- **Master logic refactor**: extract everything in `src/routes/master/+page.svelte`
except the page-level header into `src/lib/MasterPanel.svelte`. Both
`/` (when master mode on) and `/master` mount that component.
- **Auto-call lifecycle**: `setInterval` inside an `$effect` that
depends on `(autoRunning, settings.autoCallSpeed)` so changing speed
while running re-arms cleanly. Auto-stop when remaining hits 0.
- **Mobile fit**: cell aspect ratio responsive — `aspect-square` on
mobile (default), `sm:aspect-[3/5]` on ≥640px. Number text
`text-base sm:text-2xl md:text-3xl`. Page padding tightened on
mobile.
## Phases
| # | Phase | Status | File |
|---|---|---|---|
| 1 | Settings expansion (theme, masterMode, autoCall*, Excel palette, purple default) + tests | DONE | [phase-01-settings-expansion.md](phase-01-settings-expansion.md) |
| 2 | Theme system: Tailwind v4 class-based dark + JS sync + settings UI | DONE | [phase-02-theme-system.md](phase-02-theme-system.md) |
| 3 | Mobile-fit + page header/footer refactor | DONE | [phase-03-mobile-and-header-footer.md](phase-03-mobile-and-header-footer.md) |
| 4 | Extract MasterPanel component from `/master` route | DONE | [phase-04-extract-master-panel.md](phase-04-extract-master-panel.md) |
| 5 | Master mode integration on `/` (inline render via setting) + `/master` deep-link | DONE | [phase-05-master-mode-integration.md](phase-05-master-mode-integration.md) |
| 6 | Auto-call toggle + interval lifecycle | DONE | [phase-06-auto-call.md](phase-06-auto-call.md) |
## Files Touched (summary)
| File | Phases |
|---|---|
| `src/lib/settings-store.svelte.js` | 1, 2, 5, 6 — new keys, theme apply logic |
| `src/lib/settings-store.test.js` | 1, 2, 6 — new tests |
| `src/lib/SettingsButton.svelte` | 1, 2, 5, 6 — Excel swatches, theme toggle, master mode toggle, auto-call speed |
| `src/lib/PlayerBoard.svelte` | 3 — responsive cells, drop "Made by" from bottom label, footer text simplification |
| `src/lib/MasterPanel.svelte` | 4 — NEW (extracted from master route) |
| `src/routes/+layout.svelte` | 2 — theme already loads on mount; ensure media-query listener if `auto` |
| `src/routes/+page.svelte` | 3, 5 — drop instructions, restructure header, mount MasterPanel conditionally, mount Footer |
| `src/routes/master/+page.svelte` | 4, 5 — collapse to a thin shell that mounts MasterPanel |
| `src/lib/PageFooter.svelte` | 3 — NEW small component (tagline + made-by) |
| `src/app.css` | 2, 3 — convert dark `@media` to class-based, add Tailwind dark variant declaration |
| `docs/project-overview-pdr.md` | post-impl — sync features |
| `docs/codebase-summary.md` | post-impl — new files |
| `docs/system-architecture.md` | post-impl — page-flow update |
| `docs/development-roadmap.md` | post-impl — move "Theme switcher" → Implemented |
## Out of Scope
- Per-card storage migrations
- E2E tests / component tests
- Sound effects, PWA, multiplayer sync
- Custom number range
- Card serial number badges
- Replacing PlayerBoard rendering for master grid
@@ -1,176 +0,0 @@
# Session Review — 260427-0113
Scope: 6-feature working-tree refactor (settings/theme, Tailwind v4 dark variant, settings UI, mobile fit + footer, MasterPanel extraction, auto-call). 7 modified + 2 new files, 469 / 393 LOC.
Validation run before review:
- `npx vitest run` — 53/53 pass.
- `npx svelte-check` — 0 errors, 0 warnings.
- `npx vite build` — clean (Tailwind v4 `@variant` compiled).
---
## Must-fix (block ship)
**None.** Build, tests, and types all green. Lifecycle and storage concerns below are real but each has an honest mitigation that keeps "ship today" defensible.
---
## Nice-to-fix (do before ship if you have 30 min, otherwise file)
### 1. Auto-call interval can leak on `/master` navigation when `autoCallEnabled=false`
File: `src/lib/MasterPanel.svelte:96-112`
Walk-through:
1. `autoCallEnabled=true`, user is on `/master`, clicks "Bắt đầu" → `autoRunning=true`, primary effect arms `setInterval`.
2. User opens Settings, flips `autoCallEnabled=false`. Secondary effect (line 110-112) fires synchronously, sets `autoRunning=false`. Primary effect re-runs, cleanup runs, `clearInterval` fires. Safe.
3. User unmounts `MasterPanel` mid-run (navigates `/master` → `/` with `masterMode=false`, or simply closes tab) → Svelte runs `$effect` cleanup → `clearInterval`. Safe.
4. Edge: `state.remaining.length === 0` reached inside the tick → `autoRunning=false` set inside the interval callback. Next microtask the primary `$effect` re-runs with `autoRunning=false`, hits the early `return`, the prior cleanup fires → `clearInterval`. Safe but the interval fires *one extra time* (the one that set `autoRunning=false`) before tearing down. Not a leak — already correct because the early-return inside the callback (`if (!state || state.remaining.length === 0) { autoRunning=false; return; }`) prevents `handleDrawNext()` from being called the dead tick. Good.
So no actual leak. **But** there is an ordering subtlety to flag:
The two effects both depend on `autoRunning`. If the secondary effect runs *after* the primary in the same flush, and the user disables `autoCallEnabled` while running, the primary re-arms with the current `autoRunning=true` for that flush, then the secondary flips `autoRunning=false`, then the primary re-runs again and tears down. Net result: cleanup is called, no leak. But Svelte 5 doesn't guarantee effect order across separate `$effect` calls — depends on registration order. Today registration order is primary first, secondary second, so secondary always runs after. Fine. If someone reorders the script, the leak window widens to one tick.
**Suggested fix (cheap):** Fold the secondary check into the primary effect to remove cross-effect dependency:
```js
$effect(() => {
if (!autoRunning) return;
if (!settings.autoCallEnabled) { autoRunning = false; return; }
const ms = settings.autoCallSpeed * 1000;
const id = setInterval(() => { ... }, ms);
return () => clearInterval(id);
});
```
Drop the second `$effect` entirely. Same behavior, one fewer reactive subscription, no ordering dependency.
### 2. `loadSettings()` re-entry — leak window if layout remounts
File: `src/lib/settings-store.svelte.js:66-88`
`applyTheme()` checks `if (mql && mqlListener)` and tears down before reattaching. Safe under sequential calls. **However** `loadSettings()` and `saveSettings()` both call `applyAll()` → `applyTheme()`. The teardown only runs if the *last* call left a listener attached. If something else attached a listener directly to `window.matchMedia(...)` outside this module (it doesn't today), it would leak.
Within the module: safe. The test at line 269-280 covers `auto → dark` detach. Add an analogous test for `loadSettings()` called twice with `theme=auto` to lock in the invariant. (Skipped per your "no tests" instruction — note for tester agent.)
**Real concern:** if you ever switch from `onMount` (`+layout.svelte:11`) to `$effect` for hydration, and the layout re-runs during HMR or route group switch, `applyTheme` is the only thing protecting you. The current guard is correct. Good defensive code.
### 3. Two-tab `loto_master` race — unmitigated
File: `src/lib/MasterPanel.svelte:2,45-51`
Storage key `loto_master` is shared between `/master` and `/` (when `masterMode=true`). Both tabs save state on every change with last-writer-wins. This was raised in your priority Q5; my read:
- For a single-host quiz scenario (one device, one human), this is **fine**. Acceptable for ship today.
- The deeper bug isn't write conflicts, it's that **neither instance reacts to `storage` events**, so when `/` writes, `/master` keeps showing stale called-list and vice versa until refresh. The two MasterPanels are not actually shared state — they're independent state machines persisted to the same key. Confusing but not destructive.
**Recommendation:** ship as-is. Add a `window.addEventListener("storage", ...)` reload in a follow-up if you ever support multi-screen sessions. Document the limitation in MasterPanel.svelte module-doc.
### 4. `MasterPanel` mounted twice causes double saves
File: `src/routes/+page.svelte:33`, `src/routes/master/+page.svelte:30`
If a user is on `/` with `masterMode=true` and navigates to `/master` (no link does this today, but typing the URL works), they hit two routes that each mount MasterPanel. SvelteKit unmounts `/`'s instance on navigation, so only one is active at a time. Safe.
But if `/master` is open and you toggle on `masterMode` then go back to `/`, both now use `loto_master` *but the MasterPanel `$state` is reset* on mount — it loads from localStorage on the `$effect` at line 77. Result: state persists across navigation. Good.
Edge: the `$effect` at line 77 has no dependencies — Svelte 5 runs it once on mount. ✓ Correct.
### 5. `aspect-square` for cells at 360-639px feels cramped
File: `src/lib/PlayerBoard.svelte:184,194`
You said "card is 9 cells wide × 38px = 342px, fits". At 9-col grid the cell width is `(viewport - padding) / 9`. With `px-2` (16px) it's `(360-16)/9 ≈ 38.2px`. Square cells give the user 38×38 tappable targets — under iOS HIG's 44px minimum and Android Material's 48dp. Touch target accessibility issue.
**Options:**
- Push `sm:aspect-[3/5]` to start earlier — `xs` doesn't exist in Tailwind by default, but `min-[400px]:aspect-[3/5]` works.
- Or accept it and add `min-h-[44px]` to the cell button to enforce a floor (will break aspect ratio but improves tap accuracy).
Not blocking ship — Vietnamese wedding/party venue users will tap it; works on iPad fine. Note for backlog.
### 6. Speed slider has no `aria-label`
File: `src/lib/SettingsButton.svelte:219-227`
The `<input type="range">` is wrapped by `<label>` whose visible text is `Tốc độ: <strong>{n}</strong> giây/số`. Screen readers do associate label-text with the input via wrapping `<label>`, but the `<strong>` interpolation reads as "Speed: 5 seconds per number" — fine. **However** there is no `aria-valuetext` so the SR announces "5" without unit. Minor.
Add `aria-label="Tốc độ tự động xổ"` and `aria-valuetext="{settings.autoCallSpeed} giây mỗi số"` for completeness.
### 7. Backdrop `<div>` with `role="presentation"` and `onkeydown` — contradiction
File: `src/lib/SettingsButton.svelte:122-129`
You set `aria-hidden="true"` AND `role="presentation"` AND attach `onkeydown`. Pick one model:
- Decorative backdrop: `aria-hidden="true"`, no keyboard handler (the window-level Escape handler at line 76-84 already covers it).
- Interactive close: `<button type="button" aria-label="Đóng">` with the rest stripped.
Drop the `onkeydown` and `role="presentation"` — keep `aria-hidden`. Cleaner.
---
## Noted, not blocking
### Tailwind v4 `@variant` syntax (your Q3)
`@variant dark (&:where(.dark, .dark *));` is **valid Tailwind v4 syntax** for class-based dark mode. Build passed. The `&:where(.dark, .dark *)` shape correctly handles both:
- The element itself having `.dark` (e.g., `<html class="dark">` for `:root` rules).
- Any descendant of `.dark`.
Standard pattern from Tailwind v4 docs. Compiled output works (build succeeded, dark-mode tests pass).
### `:where(.dark)` specificity (your Q4)
`:where()` reduces specificity to 0. **For CSS custom properties** (`--background`, `--foreground` at line 16-19) this is fine — custom-property cascade resolves last-declaration-wins regardless of specificity. The Tailwind utilities reading `var(--background)` always see the latest value.
**For non-custom-property rules** like `:where(.dark) .section-divider { background-color: ... }` (line 61-64): specificity 0,1,0 (the `.section-divider` is outside the `:where`). Tailwind utilities like `bg-blue-100` are 0,1,0. Could collide. **In practice** the `.section-divider` class is custom CSS not overridden by Tailwind utilities anywhere in the codebase, so safe. If you ever apply `class="section-divider bg-blue-100"` the utility would win, but that's the conventional Tailwind behavior anyway.
**Verdict:** intentional, correct, idiomatic for v4. No change needed.
### YAGNI / over-engineering (your Q8)
- Settings store is appropriately compact. Per-key validation pattern is the right call given backwards compat with old saved data.
- Two `$effect`s in MasterPanel — see (1) above, fold into one.
- `applyAll()` always calls both `applyEmptyCellColor()` and `applyTheme()` even when only one changed. Cheap operations; no need to split.
- `BOARD_FLAT` precomputation in MasterPanel module-script is correct and cheap.
- `MasterPanel.svelte` is 311 lines — over the 200-line guideline in CLAUDE.md but the layout (script, state, effects, controls, current-number, history, grid, master-card) is a single cohesive unit. Splitting would invent prop boundaries that don't help. Acceptable.
### Footer SVG heart
Inline SVG with `fill="currentColor"` and `class="text-red-500"` — correct. Renders in dark mode without inversion (red on dark is fine). `aria-label="trái tim"` — debatable; could be `aria-hidden="true"` since the surrounding text "Made by miti99 with [heart]" doesn't depend on heart for meaning. Not blocking.
### `confirm()` in handlers
`MasterPanel.svelte:115` `confirm("Bạn có muốn tạo ván mới không?")` — synchronous browser dialog. Same pattern as `PlayerBoard.svelte:111`. Consistent across codebase. Fine.
### Theme `auto` SSR
SvelteKit static adapter pre-renders without a window. `applyTheme()` correctly guards on `typeof document === "undefined"`. The `<html>` ships without `.dark`, so first paint is light. Then `onMount → loadSettings → applyTheme` adds it if needed. **Brief flash of light theme** for users with dark OS preference. Common SvelteKit static-site issue, acceptable for a quiz app, not worth a SSR theme cookie hack today.
### Stored speed validators
`validSpeed` rejects `"5"` (string), good. Rejects `5.5`, good. Tests cover. ✓
### `state` initialization in MasterPanel
`let state = $state(/** @type {...} */ (null))`. The auto-call effect (line 96) checks `!state` defensively. Good — there's a moment after mount before `loadState()` runs the first effect where state is null but autoRunning is false anyway. No race.
### Mobile fit math (your Q6)
360→640px square cells: see (5). Adding a `min-[480px]:aspect-[3/5]` breakpoint feels right but YAGNI says ship and observe. Note for backlog.
---
## Behavioral checklist
- Concurrency / async: auto-call interval lifecycle reviewed (item 1). Theme MQL listener teardown reviewed (item 2). Both correct.
- Error boundaries: `try/catch` swallow at storage boundaries with default fallback. Correct for a localStorage-only app.
- API contracts: `Props` typedef on `PlayerBoard.svelte` defaults `storagePrefix="loto"`. Settings exports stable.
- Backwards compat: per-key validators preserve old saved data shape. ✓ Tested.
- Input validation: hex, theme enum, bool, integer range — all validated before commit to settings.
- Auth/authz: N/A (client-only quiz app, no auth surface).
- N+1 / queries: N/A.
- Data leaks: no PII, no secrets. Only localStorage of game state.
---
## Recommended actions (priority order)
1. **Fold the two MasterPanel `$effect`s into one** (item 1). 5-min change, eliminates ordering dependency.
2. Drop `onkeydown` + `role="presentation"` from SettingsButton backdrop (item 7). 1-min cleanup.
3. Add `aria-label` + `aria-valuetext` to speed slider (item 6). 1-min.
4. Document `loto_master` two-tab limitation in MasterPanel module doc.
5. (Backlog) Reconsider mobile cell aspect — `min-[480px]:aspect-[3/5]` or `min-h-[44px]`.
---
## Unresolved questions
- Should `/master` route be deprecated entirely once `masterMode` toggle ships? Two paths to the same component is a maintenance smell.
- Was the choice of `confirm()` over a custom modal intentional (consistency) or just expedient? If intentional, document.
**Status:** DONE_WITH_CONCERNS
**Summary:** All 6 features ship-ready. No blockers. One real cleanup (fold two `$effect`s in MasterPanel to remove cross-effect ordering dependency) plus minor a11y polish. Build, tests, types all green.
@@ -1,283 +0,0 @@
# Test Validation Report
**Project:** SvelteKit Lô Tô App (6-phase refactor)
**Test Date:** 2026-04-27 01:13 UTC
**Scope:** Complete test suite + build validation
---
## Test Execution Results
### npm test (Run 1)
- **Status:** PASSED ✓
- **Test Files:** 2 passed (2)
- **Tests:** 53 passed (53)
- **Duration:** 4.95s (transform 3.60s, import 4.59s, tests 457ms, environment 1.12s)
### npm test (Run 2 — flakiness check)
- **Status:** PASSED ✓
- **Test Files:** 2 passed (2)
- **Tests:** 53 passed (53)
- **Duration:** 6.11s (transform 4.41s, import 5.53s, tests 666ms, environment 1.46s)
**Flakiness:** No flakiness detected. Tests are stable across runs.
---
## Test Coverage by File
### settings-store.test.js (38 tests)
**Status:** All passing ✓
Tests validate:
- **Defaults (4 tests):** `DEFAULT_SETTINGS` frozen, purple color default (#7030A0), theme="auto", masterMode/autoCallEnabled/autoCallSpeed defaults
- **loadSettings color validation (8 tests):** Valid/invalid colors, RGB range checks, uppercase/shorthand rejection, JSON corruption resilience
- **Per-key fallback validators (7 tests):** Theme validation (auto/light/dark), boolean masterMode/autoCallEnabled, integer autoCallSpeed (1..10 range)
- **saveSettings persistence (2 tests):** All 5 keys persisted atomically, CSS var application
- **resetSettings (1 test):** Full reset to defaults
- **Theme application via load/save (6 tests):**
- Light mode removes `.dark` class
- Dark mode adds `.dark` class
- Auto mode follows matchMedia.matches
- Auto mode re-applies on OS pref change event (fire tests)
- Switching auto→dark detaches listener
**Theme Test Quality:** matchMedia mocked with listener tracking. Tests verify listener add/remove on theme toggle. No issues detected. Happy-dom listenerSet properly managed via beforeEach cleanup.
### game-logic.test.js (15 tests)
**Status:** All passing ✓
Tests validate:
- **generateGrid invariants (4 tests):** 9x9 shape, 5 per row, 5 per col, no duplicates (50 trials each)
- **Column number ranges (4 tests):** Tens ranges per column, ascending order within column, col 0 (1-9), col 8 (80-90)
- **isRowComplete (4 tests):** All crossed=true, partial uncrossed, all-zero row, zero-cell ignoring
- **Roundtrip persistence (5 tests):** saveGrid/loadGrid by prefix, corruption rejection (wrong shape, non-numbers, corrupt JSON), loadCrossedState validation
**Coverage:** Core game logic fully tested. Boundary conditions covered (empty rows, shape validation, type rejection).
---
## Build Validation
### npm run lint
- **Status:** PASSED ✓
- **Errors:** 0
- **Warnings:** 0
- **Output:** Clean
### npx svelte-check (Svelte v5 component type checking)
- **Status:** PASSED ✓
- **Files checked:** 7
- **Errors:** 0
- **Warnings:** 0
### npm run build (production SSR + static export)
- **Status:** PASSED ✓
- **Duration:** 13.28s total
- **Output:** `.svelte-kit/output/` built successfully
- **Chunks:** 149 SSR modules, 162 client modules
- **Gzip sizes:** CSS 7.73 kB, JS bundles <12 kB each
- **Static export:** `build/` folder generated by adapter-static
**Build quality:** Healthy. No deprecation warnings or optimization issues.
---
## Critical Coverage Gaps (HIGH PRIORITY)
### 1. MasterPanel.svelte — Auto-call $effect lifecycle (UNTESTED)
**Severity:** HIGH — Core feature with complex interval logic
The auto-call feature at lines 96-112 has zero test coverage:
```javascript
// Auto-call interval. Re-runs when autoRunning OR speed changes
$effect(() => {
if (!autoRunning) return;
const ms = settings.autoCallSpeed * 1000;
const id = setInterval(...);
return () => clearInterval(id);
});
// Auto-stop if user disables auto-call mid-run
$effect(() => {
if (!settings.autoCallEnabled && autoRunning) autoRunning = false;
});
```
**Gaps:**
- No test for interval setup/teardown with correct timing
- No test for interval re-arming when speed changes mid-run
- No test for early cleanup when `autoRunning=false`
- No test for emergency auto-stop when `autoCallEnabled` toggles while running
- No test for game-over edge case (remaining.length === 0 → autoRunning = false)
**Why this matters:** Intervals leak memory if teardown fails. Speed mid-run swap must clear old interval before arming new one. User disables auto setting mid-game → app hangs if effect doesn't fire.
**Recommended tests:**
- Setup interval, verify clearInterval called on cleanup
- Change speed 2..10 mid-interval, verify old interval cleared before new one armed
- User disables autoCallEnabled while autoRunning=true, verify autoRunning forced to false
- Game ends (remaining.length → 0), verify autoRunning stops without error
---
### 2. SettingsButton.svelte — User interaction & modal lifecycle (UNTESTED)
**Severity:** MEDIUM — 85 lines of Svelte components with no unit tests
**Untested:**
- Escape-key-to-close listener (lines 76–84): setup/teardown, listener removal
- Modal state transitions: open=false → open=true → close() → open=false
- Theme button callbacks (pickTheme → saveSettings)
- Master toggle (toggleMaster → saveSettings)
- Auto-call toggle & speed slider (toggleAuto, onSpeedInput range validation)
- Color picker input (onPickerInput, preset buttons pickColor)
- Reset button (resetSettings)
- Backdrop click-to-close (onclick={close})
**Why this matters:** Modal dialogs are common test failure points (backdrop leaks, focus not restored, listeners not cleaned up). Escape-key listener tests would catch regressions in modal behavior.
**Recommended tests:** (E2E or integration, as component state testing is fragile in Vitest)
- Modal opens/closes, Escape key closes it
- Speed slider input validation (rejects >10, <1, non-integer)
- Color preset selection updates both settings and UI
- Reset button restores all defaults
---
### 3. MasterPanel.svelte — Game state & UI logic (UNTESTED)
**Severity:** MEDIUM — 311 lines, complex state machine
**Untested:**
- Game initialization (handleNewGame, state creation, confirmation dialog)
- Draw sequence (handleDrawNext, state update, lastCalled tracking)
- Board rendering (BOARD_FLAT grid, callOrder Map, cell highlighting)
- Auto-toggle UI (toggleAuto, button state conditions)
- Game-over detection (remaining.length === 0)
- Load/save state via localStorage (lines 53–61)
- Call history rendering (state.called display)
- Kinh! order number display (lines 282–286)
**Why this matters:** MasterPanel is the app's most complex UI. State sync, conditional rendering, and game lifecycle are hard to verify visually. No tests means regression risk on state mutations.
---
### 4. PlayerBoard.svelte — Grid rendering & interaction (UNTESTED)
**Severity:** MEDIUM — Player's card UI, cell crossing logic
**Untested:**
- Card generation (grid shape, number placement)
- Cell crossing via click (crossed[r][c] toggle)
- Waiting-number highlight (getWaitingNumber logic)
- Row-complete detection (isRowComplete visual feedback)
- Responsive sizing (aspect-square, sm: breakpoints)
- localStorage persistence by prefix (storagePrefix prop)
---
### 5. Page routes (±page.svelte, +layout.svelte) — Integration (UNTESTED)
**Severity:** LOW — Routes mount components but have no business logic
**Untested:**
- Route rendering (/`, `/master`)
- Conditional mounting of MasterPanel (settings.masterMode)
- Footer presence
- Settings button placement
---
## Asset Coverage Summary
| Layer | Test Files | Component Files | Coverage |
|-------|-----------|-----------------|----------|
| **Logic** (game-logic.js, settings-store.js) | 2 test files, 53 tests | 2 logic files | ~90% ✓ |
| **Components** (Svelte) | 0 test files | 7 components + 3 routes | 0% ✗ |
| **Integration** (API, state flow) | 0 test files | End-to-end flows | 0% ✗ |
---
## Flakiness & Environment Quality
### matchMedia Mock (happy-dom)
**Status:** Robust ✓
The mockMatchMedia helper in settings-store.test.js:
- Properly isolates listener Set
- Fires events to all listeners
- Tracks add/remove via listenerCount()
- Cleaned before each test via beforeEach
Run 1 & Run 2 both stable. No listener cleanup issues detected.
### Async Tests
**Status:** None detected
No async/await or promise-based tests in current suite. All sync, deterministic.
---
## Code Quality & Best Practices
### Strengths
✓ Per-key validators prevent silent load failures
✓ Color validation rejects shorthand (#fff), ensures 6-digit hex
✓ Comprehensive theme auto-switching with listener lifecycle
✓ Game logic tested against 200-trial randomness
✓ localStorage roundtrip tests cover JSON corruption
✓ All test files have `@vitest-environment happy-dom` comment
### Minor Issues
⚠ No coverage for component interactive flows (modals, buttons, dragging)
⚠ No performance benchmarks (interval timing, grid rendering)
⚠ No a11y tests (aria-pressed, aria-label, role attributes)
⚠ No dark mode visual regression tests (just CSS var + class toggle)
---
## Recommendations (Priority Order)
### MUST (Before shipping)
1. **Add MasterPanel auto-call $effect tests.** Current untested interval logic is a memory leak risk. Test interval setup/teardown, speed swap, emergency stop.
2. **Add integration test for theme toggle via UI.** SettingsButton theme buttons → saveSettings → dark class applied. Verify end-to-end.
### SHOULD (Next iteration)
3. **Add SettingsButton modal tests.** Escape key, backdrop click, reset button. Fragile UX patterns.
4. **Add MasterPanel game state tests.** Confirm dialog, state mutations, board rendering.
5. **Add PlayerBoard interaction tests.** Cell crossing, waiting-number highlight, row-complete feedback.
### NICE-TO-HAVE (Future)
6. Add a11y tests (axe-core or similar).
7. Add dark mode snapshot tests (visual regression).
8. Add performance benchmarks (grid generation 9x9 time, interval latency).
---
## Unresolved Questions
1. **MasterPanel auto-call: What's the expected behavior if the user disables autoCallEnabled while autoRunning=true but before the interval fires again?** Current code stops autoRunning in $effect, but timing isn't tested. Is the interrupt instantaneous or delayed to next fire?
2. **SettingsButton modal: Is the Escape-key listener re-attached every time open changes?** The $effect at line 76 depends on `open`, so yes, but no test verifies this.
3. **Theme listener: If theme="auto" and user toggles to "dark", does the old matchMedia listener get cleaned up before or after the class toggle?** Code at line 69–72 removes first, then applies. Is this order critical?
4. **Speed range validation: The slider input uses native `<input type="range" min="1" max="10" />`, but onSpeedInput re-validates. Is this belt-and-suspenders by design, or should we trust the browser constraint?**
5. **Game state persistence: MasterPanel uses localStorage key "loto_master", but PlayerBoard uses prop `storagePrefix`. Are they isolated correctly when both are on the page?**
---
## Final Assessment
**Test Suite Status:** PASSED ✓
**Build Status:** PASSED ✓
**Coverage (logic layer):** 90%+
**Coverage (UI/integration):** 0%
**Flakiness:** None detected
**Shipping readiness:** BLOCKED on MasterPanel auto-call tests
The refactor's logic (settings, game-logic) is well-tested. But the new UI features (theme toggle, auto-call, master mode) have zero test coverage. Before merging:
1. Add MasterPanel $effect tests (interval lifecycle).
2. Add SettingsButton + MasterPanel integration test (theme change → dark class).
3. Smoke-test manually: open settings, toggle theme, toggle master mode, test auto-call speed swap mid-run.
Recommend running full build + tests 1x more after fixes to ensure no regressions.
@@ -1,92 +0,0 @@
# Phase 1 — Vietnamese number words utility
## Context
- [plan.md](plan.md). No dependencies; self-contained pure module.
## Overview
- Priority: P0 (everything else depends on it)
- Status: TODO
- Effort: ~15 min
## Goal
Pure function `numberToVietnamese(n)` mapping `1..90` to spoken Vietnamese,
honoring the tonal exceptions that show up in lô tô numbers.
## Tonal rules (the parts that matter)
| Position | Rule | Example |
|---|---|---|
| Standalone unit | "không" / "một" / ... / "chín" | `5 = năm` |
| 10 alone | "mười" | `10 = mười` |
| 11–19, units 1–9 | "mười X" | `12 = mười hai` |
| 11–19, unit = 5 | **"mười lăm"** (not "năm") | `15 = mười lăm` |
| 20–90 tens place | "X mươi" (not "mười") | `30 = ba mươi` |
| 21+ unit = 1 | **"…mốt"** (not "một") | `21 = hai mươi mốt` |
| 21+ unit = 5 | **"…lăm"** (not "năm") | `25 = hai mươi lăm` |
| Tens 0 | drop unit | `40 = bốn mươi` |
(`tư` for 4 in unit position is regional — we stay with `bốn` for
consistency.)
## Files
| File | Change |
|---|---|
| `src/lib/vietnamese-number.js` | NEW — exports `numberToVietnamese(n)` |
| `src/lib/vietnamese-number.test.js` | NEW — covers edge cases below |
## Implementation sketch
```js
const ONES = [
"không", "một", "hai", "ba", "bốn",
"năm", "sáu", "bảy", "tám", "chín",
];
/** @param {number} n integer 0..90 */
export function numberToVietnamese(n) {
if (!Number.isInteger(n) || n < 0 || n > 90) return String(n);
if (n < 10) return ONES[n];
if (n === 10) return "mười";
if (n < 20) {
const u = n - 10;
return u === 5 ? "mười lăm" : `mười ${ONES[u]}`;
}
const t = Math.floor(n / 10);
const u = n % 10;
const tens = `${ONES[t]} mươi`;
if (u === 0) return tens;
if (u === 1) return `${tens} mốt`;
if (u === 5) return `${tens} lăm`;
return `${tens} ${ONES[u]}`;
}
```
## Tests (must cover)
| n | Expected |
|---|---|
| 1 | "một" |
| 5 | "năm" |
| 10 | "mười" |
| 11 | "mười một" |
| 15 | "mười lăm" |
| 19 | "mười chín" |
| 20 | "hai mươi" |
| 21 | "hai mươi mốt" |
| 25 | "hai mươi lăm" |
| 45 | "bốn mươi lăm" |
| 81 | "tám mươi mốt" |
| 90 | "chín mươi" |
Plus: out-of-range fall-through (`91 → "91"`, `0.5 → "0.5"`, `-1 → "-1"`).
## Success criteria
- All ~12 table cases pass.
- `numberToVietnamese` is pure (no side effects, idempotent, no DOM).
- Test file slots into existing Vitest setup with no config changes.
## Next
- Phase 2 imports it from the voice module.
@@ -1,204 +0,0 @@
# Phase 2 — Audio generation script (build-time, all Vietnamese voices)
## Context
- [plan.md](plan.md). Output: per-voice MP3 sets committed under
`static/audio/{voiceId}/`, ready for Phase 3's playback module.
## Overview
- Priority: P0
- Status: TODO
- Effort: ~30 min (incl. ~5-10 min generation runtime)
- Run frequency: once per voice change (rare).
## What gets generated
```
static/audio/
├── hoai-my/ (vi-VN-HoaiMyNeural — female)
│ ├── 1.mp3 "một"
│ ├── 2.mp3 "hai"
│ ├── ... (90)
│ ├── 90.mp3 "chín mươi"
│ ├── cho.mp3 "Chờ"
│ └── kinh.mp3 "Kinh"
├── nam-minh/ (vi-VN-NamMinhNeural — male)
│ ├── 1.mp3
│ ├── ... (same 92 names)
│ └── kinh.mp3
└── manifest.json (voice list, generated)
```
92 clips × N voices. As of writing, edge-tts ships 2 Vietnamese voices
(female + male) — total ~184 files, ~2.2 MB. Script auto-discovers
voices via `edge-tts.list_voices()`, so new voices added by Microsoft
get picked up on the next regen.
## Voice ID mapping
| edge-tts voice | Folder ID | Display label |
|---|---|---|
| `vi-VN-HoaiMyNeural` | `hoai-my` | "Hoài Mỹ (nữ)" |
| `vi-VN-NamMinhNeural` | `nam-minh` | "Nam Minh (nam)" |
| (future `vi-VN-XxxNeural`) | `xxx` | "Xxx" — script slug-ifies |
Folder ID = lowercase + kebab-case + drop locale prefix + drop
"Neural" suffix. Conversion is deterministic; script writes the
manifest so the JS side never has to mirror Python.
## Tooling
| Tool | Why |
|---|---|
| Python 3 | already on dev machine |
| `edge-tts` (`pip install edge-tts`) | free, no API key, MS Neural quality |
User installs `edge-tts` once on the dev machine; project doesn't
add Python deps to `package.json` (build-time only).
## File: `scripts/generate-audio.py`
```python
#!/usr/bin/env python3
"""Generate Vietnamese audio clips (1-90 + Chờ + Kinh) for every
edge-tts Vietnamese voice. Output committed to static/audio/{voiceId}/
and shipped with the app — runtime never calls TTS."""
import asyncio, json, os, re, sys
OUT_ROOT = os.path.join(os.path.dirname(__file__), "..", "static", "audio")
ONES = ["không", "một", "hai", "ba", "bốn",
"năm", "sáu", "bảy", "tám", "chín"]
def number_to_vietnamese(n: int) -> str:
if n < 10: return ONES[n]
if n == 10: return "mười"
if n < 20:
u = n - 10
return "mười lăm" if u == 5 else f"mười {ONES[u]}"
t, u = divmod(n, 10)
tens = f"{ONES[t]} mươi"
if u == 0: return tens
if u == 1: return f"{tens} mốt"
if u == 5: return f"{tens} lăm"
return f"{tens} {ONES[u]}"
def voice_id(short_name: str) -> str:
"""vi-VN-HoaiMyNeural -> hoai-my"""
name = short_name.split("-")[-1] # HoaiMyNeural
name = re.sub(r"Neural$", "", name) # HoaiMy
name = re.sub(r"(?<!^)(?=[A-Z])", "-", name) # Hoai-My
return name.lower() # hoai-my
async def synth(text: str, voice: str, out: str):
import edge_tts
await edge_tts.Communicate(text, voice).save(out)
print(f" {out} ← \"{text}\"")
async def main():
import edge_tts
all_voices = await edge_tts.list_voices()
vi_voices = [v for v in all_voices if v["Locale"].startswith("vi-")]
if not vi_voices:
sys.exit("No Vietnamese voices found in edge-tts.")
manifest = {"voices": []}
for v in vi_voices:
vid = voice_id(v["ShortName"])
out_dir = os.path.join(OUT_ROOT, vid)
os.makedirs(out_dir, exist_ok=True)
print(f"\n→ {v['ShortName']} → static/audio/{vid}/")
tasks = []
for n in range(1, 91):
tasks.append(synth(number_to_vietnamese(n),
v["ShortName"],
os.path.join(out_dir, f"{n}.mp3")))
tasks.append(synth("Chờ", v["ShortName"], os.path.join(out_dir, "cho.mp3")))
tasks.append(synth("Kinh", v["ShortName"], os.path.join(out_dir, "kinh.mp3")))
await asyncio.gather(*tasks)
# Display label: gender + given name
gender_vi = "nữ" if v["Gender"].lower() == "female" else "nam"
given = re.sub(r"(?<!^)(?=[A-Z])", " ", re.sub(r"Neural$", "",
v["ShortName"].split("-")[-1])).strip()
manifest["voices"].append({
"id": vid,
"edgeName": v["ShortName"],
"label": f"{given} ({gender_vi})",
"gender": v["Gender"].lower(),
})
with open(os.path.join(OUT_ROOT, "manifest.json"), "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
print(f"\nWrote manifest with {len(manifest['voices'])} voice(s).")
if __name__ == "__main__":
try:
asyncio.run(main())
except ImportError:
sys.exit("Install dep first: pip install edge-tts")
```
## How to run
```bash
pip install edge-tts # one-time, dev-machine only
python3 scripts/generate-audio.py
git add static/audio/
git commit -m "chore(audio): regenerate Vietnamese clips"
```
## manifest.json — the JS side reads this
```json
{
"voices": [
{ "id": "hoai-my", "edgeName": "vi-VN-HoaiMyNeural", "label": "Hoai My (nữ)", "gender": "female" },
{ "id": "nam-minh", "edgeName": "vi-VN-NamMinhNeural", "label": "Nam Minh (nam)", "gender": "male" }
]
}
```
Phase 4's settings UI fetches `manifest.json` once (or imports it via
Vite's `?json` if we want it baked into the bundle) to populate the
voice picker. Adding a new voice = re-run the script, ship the new
folder + manifest, no code change required.
## Repository policy / README addendum
```md
### Regenerating audio
Vietnamese voice clips live in `static/audio/{voiceId}/`. To regenerate
(e.g., to add a new edge-tts voice or change wording):
pip install edge-tts
python3 scripts/generate-audio.py
```
## Edge cases
| Case | Handling |
|---|---|
| Network fails mid-generation | `asyncio.gather` raises; rerun (idempotent) |
| edge-tts upstream removes a voice | Committed MP3s still play; only regen blocks. Manifest stays accurate to what's on disk. |
| New Vietnamese voice arrives | Auto-included in next regen; Phase 4 UI picks it up via manifest |
| Voice name collision after slug-ify | Defensive: script aborts with message if two `id`s collide |
## Success criteria
- `python3 scripts/generate-audio.py` writes 92 MP3s per Vietnamese
voice into `static/audio/{voiceId}/` plus `manifest.json`.
- Each `45.mp3` says "bốn mươi lăm" in the corresponding voice.
- `cho.mp3` + `45.mp3` in sequence sounds like "Chờ bốn mươi lăm".
- Repo grows by ~2.2 MB (acceptable).
## Risks
| Risk | Mitigation |
|---|---|
| edge-tts endpoint changes break regen later | MP3s already committed; runtime unaffected |
| Manifest drifts from disk (manual edit) | Script always rewrites manifest; treat as generated artifact |
| Bandwidth — 184 files lazy-loaded | Browser caches; player only ever uses one voice's 92 files at a time |
## Next
- Phase 3 reads `manifest.json` + active-voice setting to build URLs.
@@ -1,171 +0,0 @@
# Phase 3 — Audio playback module
## Context
- [plan.md](plan.md). Depends on Phase 2 (committed MP3s).
- Single module both Master and Player import from.
## Overview
- Priority: P0
- Status: TODO
- Effort: ~25 min
## Goal
Three exports both call sites use:
```
playNumber(n) // master: plays {voice}/{n}.mp3
playWaiting(n) // player: plays {voice}/cho.mp3 then {voice}/{n}.mp3
playBingo() // player: plays {voice}/kinh.mp3
cancelPlayback() // pauses any in-flight audio (used on new game / new card)
```
Active voice resolved at call time from `settings.voice`. URLs go
through `import { base } from "$app/paths"` for basePath safety.
## Files
| File | Change |
|---|---|
| `src/lib/voice.js` | NEW — playback functions, `<audio>` cache, sequencer |
## Implementation sketch
```js
import { base } from "$app/paths";
import { settings } from "$lib/settings-store.svelte.js";
/** @type {Map<string, HTMLAudioElement>} */
const cache = new Map();
/** @type {HTMLAudioElement | null} */
let activePrimary = null; // currently-playing or chained chain leader
/** @param {string} url */
function getAudio(url) {
let a = cache.get(url);
if (!a) {
a = new Audio(url);
a.preload = "auto";
cache.set(url, a);
}
return a;
}
function clipUrl(name) {
return `${base}/audio/${settings.voice}/${name}.mp3`;
}
export function cancelPlayback() {
if (activePrimary) {
activePrimary.onended = null;
activePrimary.pause();
activePrimary.currentTime = 0;
activePrimary = null;
}
}
/**
* Play a single clip; resolves when it ends (or errors / is canceled).
* @param {string} url
*/
function playClip(url) {
return new Promise((resolve) => {
const a = getAudio(url);
a.currentTime = 0;
activePrimary = a;
const done = () => {
a.onended = null;
a.onerror = null;
if (activePrimary === a) activePrimary = null;
resolve();
};
a.onended = done;
a.onerror = done;
a.play().catch(done); // autoplay blocked, etc.
});
}
/** @param {number} n */
export function playNumber(n) {
cancelPlayback();
void playClip(clipUrl(String(n)));
}
/** @param {number} n */
export function playWaiting(n) {
cancelPlayback();
// Sequence: cho → number. Re-check cancelPlayback between clips so
// a fast user click can interrupt mid-sequence.
(async () => {
const cho = getAudio(clipUrl("cho"));
const num = getAudio(clipUrl(String(n)));
activePrimary = cho;
await playClip(clipUrl("cho"));
if (activePrimary !== null && activePrimary !== num) return; // canceled
await playClip(clipUrl(String(n)));
})();
}
export function playBingo() {
cancelPlayback();
void playClip(clipUrl("kinh"));
}
```
## Manifest loading
Phase 4 imports `manifest.json` directly via Vite's `?json` query so
it lands in the JS bundle (no fetch needed):
```js
// In settings-store.svelte.js or SettingsButton.svelte
import manifest from "../../static/audio/manifest.json";
```
If `static/audio/manifest.json` doesn't exist yet (script not run on
fresh checkout), import fails at build time. Phase 7 mentions this in
the README.
Alternative if static-import is awkward: copy the manifest into `src/lib/`
during the script run. Defer that micro-optimization.
## Edge cases
| Case | Behavior |
|---|---|
| Voice clip 404 (e.g., user upgraded but didn't pull MP3s) | `audio.onerror` fires, promise resolves silently — game keeps working |
| User changes voice mid-game | Next call uses new URL; cache holds both voices' clips. Memory cost: each Audio node ~few KB; acceptable. |
| Rapid manual draws or auto-call at 1s | Each call hits `cancelPlayback` first; only the latest plays |
| iOS Safari autoplay block | All call sites trigger from a click handler (Tạo bảng / Xổ số / cell click) — autoplay policy satisfied |
| `playWaiting` interrupted mid-sequence | Activity flag check between clips bails out; prevents stale tail playing on top of a newer event |
| Browser tab backgrounded | Browser pauses Audio elements automatically; nothing for us to do |
## Tests
Skip unit tests for this module — it's a thin wrapper around the
DOM Audio element. Phase 1 covers the only branchy logic
(`numberToVietnamese`). Manual smoke test in browser is the validation.
If we ever want to test it: stub `Audio` with a mock that records
`play()` / `pause()` calls. Defer until there's a real bug to chase.
## Success criteria
- `import { playNumber, playWaiting, playBingo } from "$lib/voice.js"`
works in components.
- `playNumber(45)` plays the chosen voice's "bốn mươi lăm" clip.
- `playWaiting(45)` plays "Chờ" then "bốn mươi lăm" with no audible
gap > 200 ms.
- Rapid calls cancel prior playback (no overlap, no backlog).
- Module no-ops cleanly if a clip is missing (logs only).
## Risks
| Risk | Mitigation |
|---|---|
| Cache grows unbounded across voices | At most 92 × N voices ≈ 184 nodes after full warmup. Bounded; acceptable. |
| `base` not resolved at module-init time on SSR | We're SSR-disabled (`ssr: false` in `+layout.js`); `base` is always set when these functions run |
## Next
- Phase 4 wires settings + voice picker.
@@ -1,139 +0,0 @@
# Phase 4 — Settings: 3 keys + voice picker UI
## Context
- [plan.md](plan.md). Adds 3 keys to `settings-store` and a new
"Âm thanh" fieldset with toggles + a voice list.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~25 min
## New settings keys
| Key | Type | Default | Effect |
|---|---|---|---|
| `voiceEnabledMaster` | boolean | `true` | Speak called number when master draws |
| `voiceEnabledPlayer` | boolean | `true` | Speak "Chờ N" / "Kinh" on player events |
| `voice` | string (voiceId) | first manifest entry | Which voice to play |
All persisted under existing `loto_settings` blob, same merge-and-validate
pattern as the other keys.
## Manifest source
```js
// In settings-store.svelte.js (or a small lib/audio-manifest.js):
import manifest from "../../static/audio/manifest.json";
export const VOICES = manifest.voices;
// e.g. [{ id: "hoai-my", edgeName: "...", label: "Hoai My (nữ)", gender: "female" }, ...]
```
Vite imports JSON natively — manifest is part of the JS bundle, no
fetch needed at runtime.
## Files
| File | Change |
|---|---|
| `src/lib/audio-manifest.js` (NEW, optional) | Re-exports manifest as `VOICES` so settings-store and SettingsButton share the source |
| `src/lib/settings-store.svelte.js` | Add 3 keys to `DEFAULTS`, validate in `loadSettings` (boolean for two, string-in-VOICES-ids for `voice`) |
| `src/lib/settings-store.test.js` | 4-5 tests: defaults, persistence round-trip, corruption fallback, invalid voiceId fallback |
| `src/lib/SettingsButton.svelte` | NEW "Âm thanh" fieldset: 2 toggles + voice radio/pill list |
## settings-store.svelte.js — pattern
```js
import { VOICES } from "$lib/audio-manifest.js";
const VOICE_IDS = new Set(VOICES.map((v) => v.id));
const DEFAULT_VOICE = VOICES[0]?.id ?? "hoai-my"; // graceful fallback
const DEFAULTS = {
// ...existing 5 keys...
voiceEnabledMaster: true,
voiceEnabledPlayer: true,
voice: DEFAULT_VOICE,
};
// In loadSettings(), per-key validate:
// voiceEnabledMaster: typeof v === "boolean" → keep, else default
// voiceEnabledPlayer: typeof v === "boolean" → keep, else default
// voice: VOICE_IDS.has(v) → keep, else default
```
No CSS variables, no `<html>` class — pure values read by other modules.
## SettingsButton.svelte — UI fieldset
Place between "Tự động xổ" and "Màu ô trống" (logical grouping with
feedback features).
```svelte
<fieldset class="mb-5">
<legend class="text-sm font-semibold ...">Âm thanh</legend>
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
Đọc số bằng tiếng Việt
</p>
<button onclick={() => toggleVoiceMaster()} class="...pill...">
Quản trò: {settings.voiceEnabledMaster ? "Bật" : "Tắt"}
</button>
<button onclick={() => toggleVoicePlayer()} class="...pill mt-2...">
Người chơi (Chờ / Kinh): {settings.voiceEnabledPlayer ? "Bật" : "Tắt"}
</button>
<div class="mt-3">
<p class="text-xs text-slate-500 dark:text-slate-400 mb-1">Giọng đọc</p>
<div class="grid grid-cols-2 gap-2">
{#each VOICES as v}
<button
onclick={() => saveSettings({ voice: v.id })}
class="px-3 py-2 rounded-lg border-2 text-sm
{settings.voice === v.id
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30'
: 'border-slate-300 dark:border-slate-600'}"
>
{v.label}
</button>
{/each}
</div>
</div>
</fieldset>
```
Optional polish: tiny "▶" preview button next to each voice that calls
`playNumber(45)` so users can hear before committing. Skip in v1; add
if asked.
## Tests
In `settings-store.test.js`:
1. Defaults: `voiceEnabledMaster === true`, `voiceEnabledPlayer === true`,
`voice === DEFAULT_VOICE` (first manifest entry).
2. Round-trip: `saveSettings({ voice: "nam-minh" })` persists and reloads.
3. Corrupt boolean → falls back to default for that key, others survive.
4. Invalid voice id (`"made-up"`) → falls back to default voice.
5. Empty manifest case (theoretical — if somehow no voices) → default voice
string still set without crashing other settings.
## Success criteria
- Toggling either pill flips the boolean immediately (rune-reactive).
- Picking a voice updates `settings.voice` and next call uses new clips.
- Page reload preserves all 3 settings.
- Vitest still green; settings-store tests grow by 4-5.
## Risks
| Risk | Mitigation |
|---|---|
| Manifest absent on fresh checkout (Phase 2 not run) | Build fails fast at import — README tells contributors to run the script first |
| Manifest renamed a voice id; saved id no longer valid | `loadSettings` falls back to default voice (covered by test #4) |
| User picks a voice but its clips are missing on disk | Phase 3's `audio.onerror` keeps app functional; UI still works (silent) |
## Next
- Phase 5 reads `voiceEnabledMaster` to gate master speech.
- Phase 6 reads `voiceEnabledPlayer` to gate player speech.
@@ -1,76 +0,0 @@
# Phase 5 — Master: play called number
## Context
- [plan.md](plan.md). Depends on Phases 2–4.
- Single hook point: `handleDrawNext()` in `MasterPanel.svelte:119`.
Both manual click ("Xổ số") and auto-call interval funnel through it.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~5 min
## Files
| File | Change |
|---|---|
| `src/lib/MasterPanel.svelte` | Import `playNumber`, `cancelPlayback`. Call `playNumber(next)` after state update; `cancelPlayback()` on new game. |
## Diff (illustrative)
```diff
+ import { playNumber, cancelPlayback } from "$lib/voice.js";
// ...
function handleNewGame() {
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
+ cancelPlayback();
autoRunning = false;
state = createFreshState();
lastCalled = null;
}
function handleDrawNext() {
if (!state || state.remaining.length === 0) return;
const next = state.remaining[0];
state = {
called: [...state.called, next],
remaining: state.remaining.slice(1),
};
lastCalled = next;
+ if (settings.voiceEnabledMaster) playNumber(next);
}
```
## Why one hook covers both manual + auto
The auto-call `$effect` (line ~96 in current file) calls
`handleDrawNext()` directly. No duplicate hook needed. ✓
## Edge cases
| Case | Behavior |
|---|---|
| User mashes "Xổ số" rapidly | `playNumber` cancels prior playback (Phase 3) — only the latest plays. ✓ |
| Auto-call at 1s with ~1.5s clips | Same — cancel-then-play. Truncation is the cost of fast mode; users adjust speed if needed. ✓ |
| Toggle `voiceEnabledMaster` off mid-game | Next draw stays silent. Already-playing clip finishes (acceptable) or `cancelPlayback()` if we want immediate silence — KISS, skip. |
| `state.remaining.length === 0` | Early-return guards; `playNumber` never called. ✓ |
| Voice changed mid-game | Next draw uses new voice's clip. ✓ |
## Success criteria
- Click "Xổ số" with `voiceEnabledMaster=true` → audible Vietnamese
number in chosen voice.
- Auto-call announces every drawn number.
- Toggle off → next draws silent.
- "Ván mới" → any pending playback stops.
## Risks
| Risk | Mitigation |
|---|---|
| Clip 404 (corrupt static dir) | Phase 3's `audio.onerror` no-ops silently |
| iOS Safari autoplay block | First "Xổ số" click satisfies the user-gesture requirement |
## Next
- Phase 6 mirrors this in PlayerBoard.
@@ -1,104 +0,0 @@
# Phase 6 — Player: play "Chờ N" and "Kinh"
## Context
- [plan.md](plan.md). Depends on Phases 2–4.
- Hook into the existing waiting/celebrated `$effect` block in
`PlayerBoard.svelte` (lines 83–108). Sound is a sibling output to
the toast and bingo popup, gated by the existing `notifiedWaitingRows`
/ `celebratedRows` sets to dedupe.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~10 min
## Files
| File | Change |
|---|---|
| `src/lib/PlayerBoard.svelte` | Import `playWaiting`, `playBingo`, `cancelPlayback`. Call beside existing toast + popup triggers. Cancel on `handleGenerate` / `handleClear`. |
## Diff (illustrative)
```diff
+ import { playWaiting, playBingo, cancelPlayback } from "$lib/voice.js";
+ import { settings } from "$lib/settings-store.svelte.js";
// ...
$effect(() => {
if (!grid || crossed.length === 0) return;
// Pass 1: at most one bingo popup per render
for (let i = 0; i < grid.length; i++) {
if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
celebratedRows.add(i);
notifiedWaitingRows.add(i);
congratsRow = i + 1;
showCongrats = true;
+ if (settings.voiceEnabledPlayer) playBingo();
break;
}
}
// Pass 2: update waiting state for every non-celebrated row
for (let i = 0; i < grid.length; i++) {
if (celebratedRows.has(i)) continue;
const waitNum = getWaitingNumber(grid, crossed, i);
if (waitNum !== null && !notifiedWaitingRows.has(i)) {
notifiedWaitingRows.add(i);
showToast(`Chờ ${waitNum}`);
+ if (settings.voiceEnabledPlayer) playWaiting(waitNum);
} else if (waitNum === null && notifiedWaitingRows.has(i)) {
notifiedWaitingRows.delete(i);
}
}
});
function handleGenerate() {
if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return;
+ cancelPlayback();
// ...existing...
}
function handleClear() {
if (!grid) return;
const hasMarks = crossed.some((row) => row.some(Boolean));
if (hasMarks && !confirm("Bạn có muốn xoá tất cả đánh dấu không?")) return;
+ cancelPlayback();
// ...existing...
}
```
## De-duplication is already there
The existing `notifiedWaitingRows` and `celebratedRows` sets gate the
toast + popup; the audio call hangs on the same gates so we never
play twice for the same row. ✓
## Edge cases
| Case | Behavior |
|---|---|
| Multiple rows enter "waiting" same render | Each calls `playWaiting`, but cancel-then-play (Phase 3) means only the LAST plays. Acceptable; queueing is a future tune-up. |
| Bingo + waiting on same render | Impossible — `if (celebratedRows.has(i)) continue` short-circuits the waiting check. ✓ |
| Player crosses fast, toggles waiting on/off | `notifiedWaitingRows.delete(i)` lets re-firing happen next entry into waiting. ✓ |
| Player uncrosses winning cell | No "un-bingo" sound. Acceptable — the popup already requires explicit dismiss. |
| Master + player speech race | Latest call wins (cancel-then-play). Player events are low-frequency, so this is rarely user-visible. |
## Success criteria
- Cross 4 of 5 cells in a row → "Chờ {N}" plays.
- Cross the 5th → bingo popup + "Kinh" plays.
- "Tạo bảng mới" / "Xoá đánh dấu" stop any pending playback.
- `voiceEnabledPlayer=false` → silent on both events; toast and popup
still appear.
## Risks
| Risk | Mitigation |
|---|---|
| Master and player speech overlap (e.g., player marks a cell during master draw) | Cancel-then-play documented behavior; latest event wins |
| iOS Safari first-event no-play (no prior gesture) | First waiting state requires 4 prior cell clicks → gesture already satisfied. ✓ |
## Next
- Phase 7 docs sync.
@@ -1,92 +0,0 @@
# Phase 7 — Tests pass + docs sync
## Context
- [plan.md](plan.md). Final phase. No new features — verification +
doc drift cleanup.
## Overview
- Priority: P2
- Status: TODO
- Effort: ~15 min
## Verification
```bash
npm test
npm run build
```
Expected:
- 54 (current) + ~12 (Phase 1 number tests) + ~5 (Phase 4 settings
tests) ≈ 71 tests passing.
- Build clean. `static/audio/` carried into `build/audio/` by
adapter-static automatically.
Manual quick-check after build:
```bash
ls build/audio/hoai-my/ | head # confirm clips bundled
```
## Manual smoke test (browser)
| Step | Expected |
|---|---|
| Fresh load `/`, click "Tạo bảng mới" | grid renders, no audio |
| Cross 4 cells in a row | "Chờ {N}" plays (chosen voice) |
| Cross the 5th | "Kinh" plays + bingo popup |
| Open Settings → toggle "Người chơi" off → cross to bingo | popup, no audio |
| Toggle master mode on, click "Xổ số" | called number plays |
| Settings → switch voice (Hoài Mỹ → Nam Minh) → click "Xổ số" | new voice plays |
| Auto-call at 1s | each draw plays, no backlog |
| Click "Ván mới" mid-clip | playback stops |
| Hard refresh, settings persisted | voice selection survives |
| DevTools → Network tab on first draw | one MP3 fetch from `/audio/{voice}/{n}.mp3`; subsequent draws use cache |
## Docs to sync
| File | Change |
|---|---|
| `docs/codebase-summary.md` | Add `vietnamese-number.js`, `voice.js`, `audio-manifest.js` rows. Update `SettingsButton.svelte` row to mention 5 fieldsets (was 4) + voice picker. Update `settings-store.svelte.js` description: 8 keys (was 5). New "Static Assets" subsection mentioning `static/audio/{voiceId}/`. |
| `docs/project-overview-pdr.md` | Settings section: add "Âm thanh" fieldset (master toggle, player toggle, voice picker). Add acceptance items for voice-on-number, voice-on-Chờ/Kinh, voice picker. Tech Stack: note "Free build-time TTS via edge-tts". |
| `docs/development-roadmap.md` | Drop "Sound Effects on Bingo" idea entry — done in better form (TTS, configurable voice). |
| `docs/deployment-guide.md` | Brief note: regenerating audio = `pip install edge-tts && python3 scripts/generate-audio.py`; no impact on CF Pages build (MP3s are committed). |
| `README.md` | "Regenerating audio" subsection (one short paragraph). |
Skip changes to `system-architecture.md` and `code-standards.md` —
voice is a leaf module with no architectural surface.
## Roadmap
The roadmap currently lists "Sound Effects on Bingo" as an Idea. After
this lands, **delete that section** — it's done in the better form
(neural TTS, configurable voice).
## Cross-language `numberToVietnamese` divergence guard
`scripts/generate-audio.py` and `src/lib/vietnamese-number.js` both
implement the same Vietnamese number rules. They have to stay in sync.
Mitigations:
1. JS unit tests (Phase 1) cover all tonal exceptions; if JS drifts,
tests catch it.
2. Manual: after editing either file, re-read both side by side.
3. (Future) auto-check via a small CI script that calls Python +
loads the JS via Node and compares 1..90. Skip in v1.
## Out of scope (intentionally)
- `voice.test.js` — Phase 3 explained why (DOM Audio mock churn not
worth it; the only branchy logic is `numberToVietnamese`, already
covered by Phase 1 tests).
- Component tests for SettingsButton voice toggles + voice picker —
manual smoke test plus settings-store unit tests cover it.
- E2E tests for audio playback (requires Playwright + audio capture).
## Success criteria
- All tests pass.
- `npm run build` clean. `build/audio/` populated.
- Manual smoke checklist all green.
- Docs updated to reflect new files, new settings keys, new voice
picker.
- No stale "Sound Effects" idea in roadmap.
@@ -1,116 +0,0 @@
---
slug: vietnamese-voice-calls
created: 2026-04-27
updated: 2026-04-27
status: completed
completedAt: 2026-04-27
mode: fast
blockedBy: []
blocks: []
---
# Vietnamese voice calls — bundled MP3 clips, no runtime APIs
Speak the called number aloud (master), and "Chờ {N}" / "Kinh!"
(player) when those events fire. **Free + offline + zero runtime API
calls** — pre-generate Vietnamese audio once during dev (using the
free `edge-tts` Microsoft neural voice) and ship the MP3s as static
assets bundled with the app.
## Why this approach (vs Web Speech API)
User wants the audio bundled in the project — no runtime API or browser
TTS dependency.
| Concern | Web Speech API | Bundled MP3 (this plan) |
|---|---|---|
| Runtime cost | free | free |
| Voice quality | OS-dependent (Linux Chrome often has no `vi`) | uniform, neural, Vietnamese |
| Bundle weight | 0 KB | ~2.2 MB (92 clips × ~12 KB × 2 voices, lazy-loaded) |
| Offline | yes | yes |
| Privacy | TTS may upload text to OS service | fully self-hosted |
| Build complexity | none | one-time `python` script |
Trade-off accepted: ~1.1 MB added to repo + initial page weight, in
exchange for consistent, high-quality Vietnamese audio everywhere.
## Decisions (locked)
- **TTS engine for generation**: `edge-tts` (Python). Free, no API key,
Microsoft Neural Voice quality.
- **All Vietnamese voices generated**: script auto-discovers every
`vi-*` voice in edge-tts and produces a separate clip set per voice
under `static/audio/{voiceId}/`. As of writing: `vi-VN-HoaiMyNeural`
(female) and `vi-VN-NamMinhNeural` (male) — script picks up new
voices automatically on regen.
- **Active voice configurable in settings**: new `voice` key (string,
matches a voice's `id` from `manifest.json`). Default = first voice
in manifest (`hoai-my`). User picks in the "Âm thanh" fieldset.
- **Runtime playback**: HTML5 `<audio>` via plain `new Audio(url)`. No
Web Audio API. Files lazy-loaded on first request; browser caches
after that.
- **Asset layout**: `static/audio/{voiceId}/{1..90}.mp3` +
`static/audio/{voiceId}/cho.mp3` + `static/audio/{voiceId}/kinh.mp3`,
plus `static/audio/manifest.json` (voice list). 92 files per voice.
- **"Chờ N" composition**: play `cho.mp3` then `{N}.mp3` in sequence
via `audio.onended`. Saves 90 files vs pre-generating "Chờ 1.mp3"
through "Chờ 90.mp3".
- **Cancel-then-play**: each request stops any in-flight audio first.
Auto-call at 1s with ~1.5s clips → without cancel we'd grow a
backlog. Latest number is what matters.
- **basePath aware**: URLs go through `import { base } from "$app/paths"`
so the same code works on `loto.miti99.com`, `tiennm99.github.io/loto`,
and code-server `/absproxy/{port}`.
- **Three settings keys**:
- `voiceEnabledMaster: boolean` (default `true`) — speak called number
- `voiceEnabledPlayer: boolean` (default `true`) — speak "Chờ N" / "Kinh"
- `voice: string` (default first manifest entry) — which voice to use
- **No volume slider, no rate/pitch tuning** — device volume + a curated
voice list. KISS.
- **Hook points**:
- Master: inside `handleDrawNext()` (covers manual click + auto-call)
- Player: inside the existing waiting/celebrated `$effect` in
`PlayerBoard.svelte` (uses existing dedupe sets)
## Phases
| # | Phase | File |
|---|---|---|
| 1 | Vietnamese number utility (1–90) — provides TTS prompts + tests | DONE | [phase-01-vietnamese-number-utility.md](phase-01-vietnamese-number-utility.md) |
| 2 | Audio generation script (all `vi-*` voices via edge-tts) → commit `static/audio/{voiceId}/*.mp3` + `manifest.json` | DONE (script + placeholder manifest; user runs script to materialize MP3s) | [phase-02-audio-generation-script.md](phase-02-audio-generation-script.md) |
| 3 | `voice.js` playback module — `<audio>` lazy cache, sequencer, cancel, voice-aware URL builder | DONE | [phase-03-audio-playback-module.md](phase-03-audio-playback-module.md) |
| 4 | Settings: 3 keys (master/player toggles + voice picker) + "Âm thanh" fieldset + tests | DONE | [phase-04-settings-integration.md](phase-04-settings-integration.md) |
| 5 | MasterPanel: announce drawn number in `handleDrawNext()` | DONE | [phase-05-master-integration.md](phase-05-master-integration.md) |
| 6 | PlayerBoard: announce "Chờ N" / "Kinh" in waiting/bingo $effect | DONE | [phase-06-player-integration.md](phase-06-player-integration.md) |
| 7 | Tests pass + docs sync | DONE (98/98 tests; reviewer fixes applied) | [phase-07-tests-and-docs.md](phase-07-tests-and-docs.md) |
## Files Touched / Created
| File | Phase |
|---|---|
| `src/lib/vietnamese-number.js` (NEW) | 1 |
| `src/lib/vietnamese-number.test.js` (NEW) | 1 |
| `scripts/generate-audio.py` (NEW) | 2 |
| `scripts/README.md` (NEW, optional) | 2 |
| `static/audio/*.mp3` (NEW, 92 files, committed) | 2 |
| `src/lib/voice.js` (NEW) | 3 |
| `src/lib/settings-store.svelte.js` | 4 |
| `src/lib/settings-store.test.js` | 4 |
| `src/lib/SettingsButton.svelte` | 4 |
| `src/lib/MasterPanel.svelte` | 5 |
| `src/lib/PlayerBoard.svelte` | 6 |
| `docs/codebase-summary.md` | 7 |
| `docs/project-overview-pdr.md` | 7 |
| `docs/development-roadmap.md` | 7 (drop stale "Sound Effects" idea) |
| `.gitattributes` (optional, for LFS-free MP3 handling) | 2 |
| `README.md` (mention audio regen) | 2 |
## Out of Scope
- Audio sprite / single-file packing (HTTP/2 makes 92 small fetches fine)
- Volume slider, voice picker, alt voices, male voice, child voice
- Pre-rendered "Chờ 1..90" combo clips (sequence at runtime)
- Sound effects on bingo (chimes / confetti audio)
- Service Worker pre-caching (PWA territory; future plan)
- Lô tô fairground rhythm / chant style — flat reading only
- Runtime TTS APIs (edge-tts is build-time only; no runtime API)
@@ -1,92 +0,0 @@
# Phase 1 — Mobile legibility (cell sizing, haptic, active, cross-out)
## Context
- [plan.md](plan.md). No deps. Highest user-facing impact.
- Reviewer P0: cell text under ~14px on 360px viewport, tap target
~37px (under WCAG 44px), no active-press feedback, no haptic, static
cross-out line.
## Overview
- Priority: P0
- Status: TODO
- Effort: ~20 min
## Files
| File | Change |
|---|---|
| `src/lib/PlayerBoard.svelte` | Cell button class — taller aspect on mobile, larger text, `active:scale-90`, `navigator.vibrate(10)` in `handleCellClick` |
| `src/app.css` | `@keyframes cross-draw` + apply to `.cell-crossed::after` |
## Diffs (illustrative)
```diff
/** @param {number} row, @param {number} col */
function handleCellClick(row, col) {
+ if (typeof navigator !== "undefined" && navigator.vibrate) {
+ navigator.vibrate(10);
+ }
crossed = crossed.map((r, ri) =>
ri === row ? r.map((v, ci) => (ci === col ? !v : v)) : r,
);
}
```
```diff
<button
type="button"
aria-label="Số {num}{isCrossed ? ', đã đánh dấu' : ''}"
aria-pressed={isCrossed}
onclick={() => handleCellClick(row, col)}
class="tan-tan-num relative flex items-center justify-center
- aspect-square sm:aspect-[3/5]
- text-base sm:text-2xl md:text-3xl
+ aspect-[3/4] sm:aspect-[3/5]
+ text-lg sm:text-2xl md:text-3xl
border border-slate-400/50 dark:border-slate-600/40
- transition-all select-none cursor-pointer
+ transition-all select-none cursor-pointer active:scale-90
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
{isCrossed ...}"
```
```css
/* src/app.css — add near .cell-crossed */
@keyframes cross-draw {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0 0 0); }
}
.cell-crossed::after {
/* …existing rules… */
animation: cross-draw 200ms ease-out;
}
```
## Edge cases
| Case | Handling |
|---|---|
| iOS Safari ignores `navigator.vibrate` | Guarded `typeof navigator !== "undefined" && navigator.vibrate` — silent no-op |
| User uncrosses a cell | `cell-crossed::after` simply unmounts; no inverse animation needed |
| Reduced-motion preference | Optional follow-up: wrap `cross-draw` in `@media (prefers-reduced-motion: no-preference)`. Skip in v1 — animation is 200ms and non-essential. |
| `aspect-[3/4]` on very small screens | Cells grow ~10px taller; layout still fits because parent caps width |
## Success criteria
- On a 360px viewport, a number reads at ≥18px effective.
- Tap target ≥40px on mobile (cells are 40×53 px in 9-col grid at 360px).
- Tapping a cell on Android Chrome vibrates ~10ms.
- Press scale-down is visible on touchstart.
- Cross-out diagonal animates in over ~200ms when toggled on.
- Existing 98 unit tests still pass.
## Risks
| Risk | Mitigation |
|---|---|
| Taller cells push the bingo popup off-screen on tiny viewports | Already a `fixed inset-0` modal with internal scroll — unaffected |
| Haptic abused as a cell-bouncer | One 10ms pulse per click; user can hold-press without retrigger because Svelte uses click events, not touchstart |
## Next
- Phase 2 brand + first-run state.
@@ -1,100 +0,0 @@
# Phase 2 — Brand H1 marquee + first-run empty preview
## Context
- [plan.md](plan.md). Independent of Phase 1.
- Reviewer P0: H1 reads like a SaaS label; cold first paint shows a
gray "Nhấn 'Tạo bảng mới'" placeholder with no personality.
## Overview
- Priority: P0
- Status: TODO
- Effort: ~20 min
## Files
| File | Change |
|---|---|
| `src/routes/+page.svelte` | H1: bigger, italic, warmer gradient, drop-shadow; add subtitle line |
| `src/lib/PlayerBoard.svelte` | Replace empty-state placeholder with a faded mini-card preview + warm welcome line |
## H1 marquee
```diff
- <h1 class="text-4xl sm:text-5xl font-extrabold
- bg-gradient-to-r from-indigo-500 to-purple-500
- bg-clip-text text-transparent">
- Lô tô
- </h1>
+ <h1 class="text-5xl sm:text-7xl font-black italic tracking-tight
+ bg-gradient-to-r from-rose-500 via-amber-500 to-rose-500
+ bg-clip-text text-transparent
+ drop-shadow-[0_2px_0_rgba(0,0,0,0.15)]">
+ Lô tô
+ </h1>
+ <p class="text-xs sm:text-sm uppercase tracking-[0.3em]
+ text-slate-500 dark:text-slate-400 italic mt-1">
+ Hội chợ Tân Tân
+ </p>
```
## Empty-state preview
Replace the current placeholder block (`PlayerBoard.svelte` ~line 292):
```svelte
{:else}
<div class="text-center py-10">
<!-- Faded 3×9 preview row to set expectations -->
<div class="mx-auto max-w-xs opacity-30 pointer-events-none mb-6
rounded-md overflow-hidden border border-slate-300 dark:border-slate-600">
<div class="grid grid-cols-9 gap-px bg-slate-300 dark:bg-slate-700">
{#each Array(27) as _, i (i)}
<div
class="aspect-square text-[0.6rem] flex items-center justify-center
{i % 3 === 0 ? 'bg-white dark:bg-slate-800' : ''}"
style:background-color={i % 3 === 0 ? undefined : 'var(--empty-cell-bg)'}
>
{i % 3 === 0 ? Math.floor(Math.random() * 90) + 1 : ''}
</div>
{/each}
</div>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400 italic">
Nhấn <span class="font-semibold text-indigo-500 dark:text-indigo-400">"Tạo bảng mới"</span> để bắt đầu chơi
</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
🎫 Chúc cả nhà một ván vui vẻ
</p>
</div>
{/if}
```
(The randomness on first paint is fine — preview re-renders only on
mount; it's decorative, not gameplay.)
## Edge cases
| Case | Handling |
|---|---|
| Tailwind purges `from-rose-500 via-amber-500 to-rose-500` | These are static class names — tailwind keeps them |
| User dislikes the new gradient | Trivial revert, single block |
| Empty preview confuses screen readers | Wrap in `aria-hidden="true"` (already pointer-events-none + decorative) |
| First paint shows different random numbers on every visit | Acceptable — purely decorative |
## Success criteria
- H1 visually dominates above the fold on mobile (text-5xl ≈ 48px).
- "Hội chợ Tân Tân" subtitle reads as a marquee subline.
- Cold reload (no `loto_grid` in localStorage) shows a faded preview
card + welcome copy, not a gray placeholder.
- Dark-mode versions still readable (>=4.5:1 contrast on subtitle).
## Risks
| Risk | Mitigation |
|---|---|
| `drop-shadow-[…]` arbitrary value drops Safari support pre-15.4 | Acceptable — Safari 15.4 = March 2022; Cloudflare logs would tell us if a meaningful share is on older |
| Brand color shift (purple→rose/amber) might feel inconsistent with Excel-purple empty cells | The H1 is the wordmark, the cells are the data — different roles. Reviewer specifically flagged the purple as "generic SaaS" applied to the H1; cells stay purple by user setting. |
## Next
- Phase 3 master focal point.
@@ -1,109 +0,0 @@
# Phase 3 — Master mode focal point
## Context
- [plan.md](plan.md). Independent of Phases 1-2.
- Reviewer P0: "Số vừa xổ" hero is too small (96px), called number is
buried mid-page below the player card. Master mode toggles in
abruptly with no transition. No `aria-live` for screen readers.
## Overview
- Priority: P0
- Status: TODO
- Effort: ~25 min
## Files
| File | Change |
|---|---|
| `src/lib/MasterPanel.svelte` | Hero "Số vừa xổ" 2× larger; container has `aria-live`; auto-`scrollIntoView` after each draw |
| `src/routes/+page.svelte` | Slide transition on the master section mount |
## Hero scaling
In `MasterPanel.svelte` find the "Số vừa xổ" block (~line 184):
```diff
- <div class="...flex items-center justify-center
- w-24 h-24 sm:w-28 sm:h-28
- rounded-full ring-4 ...">
- <span class="text-5xl sm:text-6xl font-black ...">
+ <div bind:this={heroEl}
+ role="status" aria-live="assertive" aria-atomic="true"
+ class="...flex items-center justify-center
+ w-40 h-40 sm:w-56 sm:h-56
+ rounded-full ring-4 ...
+ scroll-mt-4">
+ <span class="text-7xl sm:text-8xl font-black ...">
{lastCalled}
</span>
</div>
```
Add the auto-scroll wiring (after the existing `$state` block):
```js
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
$effect(() => {
if (lastCalled !== null && heroEl) {
// microtask after DOM patch
requestAnimationFrame(() =>
heroEl?.scrollIntoView({ behavior: "smooth", block: "center" })
);
}
});
```
## Slide-in master section
In `+page.svelte`:
```diff
+ <script>
+ import { slide } from "svelte/transition";
+ // …existing imports…
+ </script>
{#if settings.masterMode}
- <div class="mt-10">
+ <div class="mt-10" transition:slide={{ duration: 250 }}>
<h2 class="text-center text-lg font-bold text-orange-500 dark:text-orange-400 mb-4">
Quản trò
</h2>
<MasterPanel />
</div>
{/if}
```
Also drop the gradient on H2 (reviewer P2): keep `text-orange-500` —
it stays visually subordinate to H1's rose-amber.
## Edge cases
| Case | Handling |
|---|---|
| Auto-scroll on every render (e.g., toggling auto-call) | `$effect` dep is `lastCalled` only; toggling auto-call doesn't change it |
| Hero `scrollIntoView` competes with toast on player side | Toast is `position: absolute` inside the player area, not affected by window scroll |
| Screen reader reads "45" out of context | `aria-live="assertive"` + Vietnamese voice announcement together — redundant for hearing users; SR users get the live region. Acceptable. |
| User has reduced motion preference | `behavior: "smooth"` ignores prefers-reduced-motion in some browsers; trade-off accepted in v1. Follow-up: branch on `matchMedia('(prefers-reduced-motion: reduce)').matches`. |
| Section unmounts during slide | Svelte's `slide` handles in/out; on toggle off the section collapses cleanly |
## Success criteria
- "Số vừa xổ" circle ~160-220px on desktop, ~160px on mobile.
- After `Xổ số`, the hero scrolls smoothly into view (block: center).
- Screen reader announces each new number.
- Toggling master mode on slides the section in over 250ms (no
layout jump).
- No regression to auto-call interval timing.
## Risks
| Risk | Mitigation |
|---|---|
| `scrollIntoView` triggers on initial load if `lastCalled` was persisted | `loadState` already sets `lastCalled` on mount; the effect runs once but the user expects to land on the hero anyway. Fine. |
| Bigger hero pushes the rest of the panel down | Intentional — the hero is the focal point |
| Slide transition causes CLS warnings | One-off, only on master toggle; not a Core Web Vitals path |
## Next
- Phase 4 settings polish.
@@ -1,140 +0,0 @@
# Phase 4 — Settings modal width + dark-mode purple + switch toggles
## Context
- [plan.md](plan.md). Independent.
- Reviewer P1: modal feels cramped on tablet+; empty cells use full-
saturation Excel purple in both themes (neon-bright in dark mode);
"Đang bật / Đang tắt" reads like state, not action.
## Overview
- Priority: P1
- Status: TODO
- Effort: ~25 min
## Files
| File | Change |
|---|---|
| `src/lib/SettingsButton.svelte` | Modal `max-w-sm` → `max-w-sm sm:max-w-md`; convert master/auto/voice toggles to switch UI |
| `src/app.css` | Dark-mode override for `--empty-cell-bg` (or filter on dark) |
## Modal width
```diff
<div
- class="relative mx-4 max-w-sm w-full max-h-[90vh] overflow-y-auto
+ class="relative mx-4 max-w-sm sm:max-w-md w-full max-h-[90vh] overflow-y-auto
rounded-3xl bg-white dark:bg-slate-800 p-6 shadow-2xl animate-pop-in"
>
```
Cell color swatch grid stays 5-col; the extra width gives breathing
room without re-flow.
## Switch UI for boolean toggles
A small reusable inline pattern (no new component file — this is a
two-line snippet replacement, KISS):
```svelte
<!-- Replace the master-mode "Đang bật / Đang tắt" full-width button -->
<label class="flex items-center justify-between gap-3 px-3 py-2 rounded-lg
border-2 border-slate-200 dark:border-slate-600 cursor-pointer
hover:border-slate-300 dark:hover:border-slate-500 transition-colors">
<span class="text-sm text-slate-700 dark:text-slate-200">
Hiện bảng quản trò
</span>
<span
role="switch"
aria-checked={settings.masterMode}
tabindex="0"
onclick={toggleMaster}
onkeydown={(e) => (e.key === " " || e.key === "Enter") && (e.preventDefault(), toggleMaster())}
class="relative inline-block w-10 h-6 rounded-full transition-colors
{settings.masterMode
? 'bg-emerald-500'
: 'bg-slate-300 dark:bg-slate-600'}"
>
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform
{settings.masterMode ? 'translate-x-4' : 'translate-x-0'}"></span>
</span>
</label>
```
Apply the same pattern to:
- Auto-call enable (`autoCallEnabled`)
- Voice master (`voiceEnabledMaster`)
- Voice player (`voiceEnabledPlayer`)
Voice picker (radio-style buttons) stays as-is — switch UI doesn't
generalize to >2 options.
## Dark-mode empty-cell desaturation
In `src/app.css`:
```diff
:root {
--empty-cell-bg: #7030A0;
}
+
+ :where(.dark) {
+ --empty-cell-bg: #5a2480;
+ }
```
Or, if user has a custom hex via the color picker, the override above
gets clobbered when they pick. Better: keep user's choice as the
source of truth, but apply a `filter: brightness(0.85)` in dark mode
on the empty-cell elements.
Decision: **respect user choice in both modes**. Skip the dark
override; user can pick their own color for dark mode if they want.
Just dim slightly via filter:
```css
:where(.dark) [style*="--empty-cell-bg"],
:where(.dark) [style*="background-color"] {
/* Too broad — second selector hits everything. Skip. */
}
```
Actually the cleanest fix that doesn't fight user choice:
```svelte
<!-- PlayerBoard.svelte empty cell -->
- style:background-color="var(--empty-cell-bg)"
+ class="dark:[filter:brightness(0.85)_saturate(0.9)]"
+ style:background-color="var(--empty-cell-bg)"
```
This keeps user's color but tones it down ~15% in dark mode.
## Edge cases
| Case | Handling |
|---|---|
| Switch keyboard activation on iOS VoiceOver | `role="switch"` + `aria-checked` + Space/Enter keydown handler — works |
| Dark-mode filter on cells with custom colors close to white | Filter is multiplicative; near-white stays near-white, near-purple goes near-deeper-purple. Acceptable. |
| Modal `max-w-md` overflows on landscape phones | `mx-4` gutter still applies; max width is a cap, not a floor |
| Hover styles on touch devices stick after tap | Existing pattern in the codebase; no regression |
## Success criteria
- On a 768px+ viewport, settings modal feels balanced (not narrow).
- Boolean toggles read as switches, not buttons.
- In dark mode, default purple `#7030A0` reads as a muted purple, not
neon. Custom colors get the same dimming.
- All settings keep persisting correctly (Phase 4 of voice plan
test-suite still green).
## Risks
| Risk | Mitigation |
|---|---|
| Switch behavior diverges across keyboard/mouse/touch | Tested keyboard handler; tap-on-label still works because the inner span is the actual switch role |
| Custom dark color picked at full saturation looks worse with filter | User can override; trade-off accepted |
| Filter on every cell adds GPU layer | 81 cells × 1 filter = no measurable cost on modern devices |
## Next
- Phase 5 celebration tiering.
@@ -1,113 +0,0 @@
# Phase 5 — Two-tier "Kinh!" celebration
## Context
- [plan.md](plan.md). Independent of earlier phases.
- Reviewer P2: bingo on row 1 looks identical to bingo on row 5. The
party should escalate.
## Overview
- Priority: P2
- Status: TODO
- Effort: ~15 min
## Goal
First completed row: current celebration (gradient text, 🎉 bounce, ✨🎊 spinners).
Third+ completed row: same modal **plus** a CSS confetti burst (8–12
emoji spans falling from the top with random `--x` and `--delay`).
No JS particle library — pure CSS keyframes.
## Files
| File | Change |
|---|---|
| `src/lib/PlayerBoard.svelte` | Track `celebratedRows.size`; pass `tier` to popup; render confetti layer when tier ≥ 2 |
| `src/app.css` | `@keyframes confetti-fall` |
## Diff (illustrative)
```diff
$effect(() => {
if (!grid || crossed.length === 0) return;
for (let i = 0; i < grid.length; i++) {
if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
celebratedRows.add(i);
notifiedWaitingRows.add(i);
congratsRow = i + 1;
showCongrats = true;
+ celebrationTier = celebratedRows.size >= 3 ? 2 : 1;
if (settings.voiceEnabledPlayer) playBingo();
break;
}
}
// …pass 2 unchanged…
});
```
```svelte
let celebrationTier = $state(/** @type {1 | 2} */ (1));
```
In the popup template, after the existing modal block:
```svelte
{#if showCongrats && celebrationTier >= 2}
<div aria-hidden="true" class="fixed inset-0 z-40 pointer-events-none overflow-hidden">
{#each Array(12) as _, i (i)}
<span
class="confetti"
style:--x="{Math.random() * 100}%"
style:--delay="{Math.random() * 400}ms"
style:--rot="{Math.random() * 360}deg"
>
{["🎊", "✨", "🎉", "🥳"][i % 4]}
</span>
{/each}
</div>
{/if}
```
```css
/* src/app.css */
@keyframes confetti-fall {
0% { transform: translate(0, -10vh) rotate(0); opacity: 0; }
10% { opacity: 1; }
100% { transform: translate(0, 110vh) rotate(var(--rot)); opacity: 0.8; }
}
.confetti {
position: absolute;
top: 0;
left: var(--x);
font-size: 2rem;
animation: confetti-fall 1.6s ease-in var(--delay) forwards;
will-change: transform, opacity;
}
```
## Edge cases
| Case | Handling |
|---|---|
| User dismisses popup before confetti finishes | `showCongrats = false` unmounts the confetti layer too — clean |
| Player generates a new card mid-celebration | `handleGenerate` already clears `celebratedRows` — tier resets to 1 next bingo |
| Reduced motion users | Wrap `confetti` block in `@media (prefers-reduced-motion: no-preference) { … }` (skip in v1, follow-up) |
| 12 emoji × random pos = browser perf | 1.6s, 12 spans, GPU layer — trivial. No issue. |
## Success criteria
- First bingo: same modal as today, no confetti.
- Third bingo (and beyond): same modal **plus** ~12 falling confetti
emoji that complete in ~1.6 s.
- Tier resets when the player generates a new card.
## Risks
| Risk | Mitigation |
|---|---|
| Confetti behind the modal looks weird | `z-40` for confetti vs `z-50` for modal — modal floats above |
| Animation lingers on slow phones | `forwards` keeps final state; opacity 0.8→0 fade still hides it |
## Next
- Phase 6 tests + docs.
@@ -1,63 +0,0 @@
# Phase 6 — Tests pass + docs sync
## Context
- [plan.md](plan.md). Final phase.
## Overview
- Priority: P2
- Status: TODO
- Effort: ~10 min
## Verification
```bash
npm test # expect 98/98 still pass — no new tests required
npm run build # clean
```
This plan ships no new tests because the changes are visual /
interaction polish; existing settings + game-logic + number-words
tests remain authoritative.
## Manual smoke (browser, mobile + desktop)
| Step | Expected |
|---|---|
| Cold reload (clear localStorage) | H1 marquee + subtitle visible; preview card faded; warm welcome line |
| Click "Tạo bảng mới" | grid renders; cells comfortably tappable on phone |
| Tap a cell on Android | 10ms vibration, scale-down press, cross-out diagonal animates in |
| Mark a row to bingo | popup + audio (existing behavior) |
| Mark second & third row to bingo | popup + confetti rain on 3rd |
| Open Settings → toggle master mode | section slides in, no jump |
| Master "Xổ số" | giant hero appears, auto-scrolls into view, screen reader announces number |
| Toggle dark mode → look at empty cells | purple is muted, not neon |
| Settings on tablet (≥640px) | modal `max-w-md`, swatches breathe |
| Switch toggles in settings | feel like switches, not buttons |
## Docs to sync
| File | Change |
|---|---|
| `docs/codebase-summary.md` | Update PlayerBoard description (haptic, animated cross-out, tiered celebration). Update SettingsButton (switch UI). Update +page.svelte (marquee H1 + subtitle). Add `@keyframes confetti-fall`, `cross-draw` to app.css description. |
| `docs/project-overview-pdr.md` | "Visual Language" section: add wordmark gradient (rose→amber→rose); confetti tier note; haptic feedback. Add 1-2 acceptance items (mobile cell legibility, tiered celebration). |
| `docs/development-roadmap.md` | No change (no new ideas, no new completed items belong here). |
Skip `system-architecture.md`, `code-standards.md`, `deployment-guide.md`.
## Out of scope (intentionally)
- New tests for visual changes (Vitest doesn't render, Playwright not
set up; visual regression deferred to a future PWA/E2E plan).
- Reduced-motion media queries for animations (one-line fix; defer
until a real accessibility complaint).
- Configurable section labels.
- Custom font for the wordmark.
## Success criteria
- All 98 unit tests still pass.
- `npm run build` clean.
- Manual smoke checklist all green.
- Docs reflect new visual behavior.
- Plan marked completed; tasks #5–#11 from voice plan stay closed,
new task chain marked done.
@@ -1,105 +0,0 @@
---
slug: ui-ux-improvements
created: 2026-04-27
status: completed
completedAt: 2026-04-27
mode: fast
blockedBy: []
blocks: []
---
# UI/UX improvements — mobile legibility, brand, master focus, dark mode
Synthesizes the 12 recommendations from the ui-ux-designer review (see
`/config/workspace/tiennm99/loto/plans/reports/` if archived; key
findings inline below). Six small phases, ~10–25 min each, no new
dependencies, no breaking changes to settings/storage.
## What this changes (in user terms)
- Numbers on the player card are bigger and easier to tap on phones.
- Cold first paint shows a friendly preview, not a gray placeholder.
- Brand wordmark feels like a fairground marquee, not a SaaS header.
- When the host enables master mode, the called number becomes a giant
hero that auto-scrolls into view; new draws are announced for screen
readers.
- Dark mode loses the "neon purple" punch on empty cells.
- Settings modal feels less cramped on tablet/desktop.
- Marking a cell has tactile feedback (haptic + crossout animation +
active-press scale).
## Decisions (locked unless flagged)
- **Hard invariants stay**: 9×9 grid, 5-per-row + 5-per-col, ascending
columns, no-3-consecutive soft rule, all storage keys, all settings
keys.
- **No new components/library**: continue plain Svelte + Tailwind.
- **No new fonts in v1** — H1 marquee uses existing system stack +
`italic tracking-tight` + warmer gradient. Adding a Google Font is
P2/follow-up.
- **No illustration assets** in this plan — open question #2 below.
- **Section labels stay personal** ("TN1 (2014-2017)",
"Độc-Đỉnh-Điên") — they're the original group's joke and part of the
app's identity. Making them configurable is a separate plan.
- **Master mode keeps stacking below player**, not full-screen replace
— open question #3 below; deferred until a host complains.
## Phases
| # | Phase | File |
|---|---|---|
| 1 | Mobile legibility — cell sizing, haptic, active state, animated cross-out | [phase-01-mobile-legibility.md](phase-01-mobile-legibility.md) |
| 2 | Brand H1 marquee + first-run empty preview | [phase-02-brand-and-empty-state.md](phase-02-brand-and-empty-state.md) |
| 3 | Master mode focal point — giant hero, auto-scroll, slide-in, aria-live | [phase-03-master-focal-point.md](phase-03-master-focal-point.md) |
| 4 | Settings modal width + dark-mode purple + switch-style toggles | [phase-04-settings-and-dark-polish.md](phase-04-settings-and-dark-polish.md) |
| 5 | Two-tier "Kinh!" celebration (first row vs nth row) | [phase-05-celebration-tiering.md](phase-05-celebration-tiering.md) |
| 6 | Tests pass + docs sync | [phase-06-tests-and-docs.md](phase-06-tests-and-docs.md) |
## Files Touched
| File | Phase(s) |
|---|---|
| `src/lib/PlayerBoard.svelte` | 1, 2, 5 |
| `src/lib/MasterPanel.svelte` | 3 |
| `src/lib/SettingsButton.svelte` | 4 |
| `src/routes/+page.svelte` | 2, 3 |
| `src/app.css` | 1, 4 |
| `docs/codebase-summary.md`, `docs/project-overview-pdr.md` | 6 |
## Open questions (deferred — answer at implementation time)
1. **Section labels** — keep "Lô tô / TN1 (2014-2017) / Độc-Đỉnh-Điên"
hard-coded? **Default: yes, keep them.** Configurable labels = a
future plan if multiple groups adopt the app.
2. **Illustration assets** — add a paper-lantern / dice motif to the
header? **Default: skip in v1.** A typographic marquee carries
enough personality; revisit if real users say it's bland.
3. **Master mode on phone** — should it replace the player view full-
screen instead of stacking? **Default: keep stacking** + giant
hero + auto-scroll. Revisit if a host complains they can't see the
hero while holding their card.
## Out of Scope
- New fonts (Playfair / Bebas Neue / etc.)
- Illustration / icon assets
- Configurable section labels
- Full-screen master mode toggle for phones
- Confetti particle library (Phase 5 uses pure CSS)
- PWA, sound effects beyond TTS, multiplayer sync
## Acceptance summary (user-visible)
- [ ] On a 360px viewport, a player number is comfortably readable
across a typical living room.
- [ ] First load shows a card preview + warm welcome line, not a gray
placeholder.
- [ ] H1 looks like a vintage carnival marquee, not a SaaS header.
- [ ] In master mode, the most recently drawn number is the
unambiguous focal point even with the player card on screen.
- [ ] Switching themes desaturates the purple empty cells in dark
mode (no more neon punch).
- [ ] Tapping a cell vibrates briefly on mobile, presses inward, and
draws the cross-out diagonal in ~200 ms.
- [ ] Bingo on row 1 ≠ bingo on row 5 visually (party gets louder).
- [ ] All 98 unit tests still pass; `npm run build` clean.
@@ -1,175 +0,0 @@
# Phase 1 — Three-mode settings (player / master / both)
## Overview
Replace `masterMode: boolean` with `mode: "player" | "master" | "both"`.
Migrate existing saved state. Update settings UI from a single switch
to a 3-button segmented picker. Conditionally render PlayerBoard
and/or MasterPanel on `/` based on the new mode.
**Status**: not started
**Priority**: P0 (foundational for phases 2 & 3)
**Effort**: ~25 min
## Files to modify
- `src/lib/settings-store.svelte.js` — replace key, add validator,
migration in `loadSettings`, update `resetSettings`
- `src/lib/settings-store.test.js` — update assertions
- `src/lib/SettingsButton.svelte` — replace switch with 3-button picker
- `src/routes/+page.svelte` — `mode === "master"` hides PlayerBoard;
`mode === "player"` hides MasterPanel; `mode === "both"` renders both
## Key design
```js
// settings-store.svelte.js
export const DEFAULT_SETTINGS = Object.freeze({
// ...existing keys...
mode: /** @type {"player"|"master"|"both"} */ ("player"),
// ...
});
const VALID_MODES = ["player", "master", "both"];
function validMode(v) {
return typeof v === "string" && VALID_MODES.includes(v) ? v : null;
}
// In loadSettings:
const parsedMode = validMode(parsed.mode);
if (parsedMode) {
settings.mode = parsedMode;
} else if (parsed.masterMode === true) {
settings.mode = "both"; // legacy migration
} else {
settings.mode = DEFAULT_SETTINGS.mode;
}
```
Drop `masterMode` from the saved JSON shape in `saveSettings` (which
spreads `{ ...settings }`, so just don't keep `masterMode` on
`settings`). Keep validator/load fallback for one release in case any
future user reverts — actually no, YAGNI. Drop cleanly.
## UI — segmented picker
In `SettingsButton.svelte`, replace the single `switchRow` for
`masterMode` with a 3-button group, mirroring the theme picker pattern
already in the file:
```svelte
<fieldset class="mb-5">
<legend class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2">
Chế độ hiển thị
</legend>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">
Chọn vai trò để chỉ hiện phần liên quan
</p>
<div class="grid grid-cols-3 gap-2">
{#each MODES as [v, label] (v)}
{@const selected = settings.mode === v}
<button
type="button"
aria-pressed={selected}
onclick={() => pickMode(v)}
class="px-2 py-2 rounded-lg border-2 text-sm font-medium transition-all
{selected
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-500'}"
>
{label}
</button>
{/each}
</div>
</fieldset>
```
```js
const MODES = /** @type {const} */ ([
["player", "Người chơi"],
["master", "Quản trò"],
["both", "Cả hai"],
]);
function pickMode(m) {
settings.mode = m;
saveSettings();
}
```
The "Tự động xổ" sub-fieldset, currently gated by `settings.masterMode`,
becomes gated by `settings.mode !== "player"` (i.e. master is visible).
## +page.svelte — conditional render
Replace the existing `{#if settings.masterMode}` MasterPanel block and
the unconditional `<PlayerBoard />` with:
```svelte
{#if settings.mode !== "master"}
<PlayerBoard />
{/if}
{#if settings.mode !== "player"}
<section class="mt-10" aria-label="Bảng quản trò"
transition:slide={{ duration: 250 }}>
<h2 class="...">Quản trò</h2>
<MasterPanel />
</section>
{/if}
```
Keep the section heading only when there's a player board above it
(otherwise it's redundant). Conditional:
```svelte
{#if settings.mode === "both"}
<h2 class="...">Quản trò</h2>
{/if}
```
In `mode === "master"`, the page still has the brand header — the
master panel becomes the main content area.
## Tests
Update `settings-store.test.js`:
- `mode` defaults to `"player"`.
- Persistence round-trip includes `mode`.
- Legacy `masterMode: true` in stored JSON migrates to `mode: "both"`.
- Legacy `masterMode: false` (or missing) → `mode: "player"`.
- Invalid `mode` value falls back to default.
- Drop the old `masterMode` assertions in the "persists ALL keys" test.
## Todo
- [ ] Add `mode` to `DEFAULT_SETTINGS`, drop `masterMode`
- [ ] Add `validMode` helper
- [ ] Implement migration in `loadSettings`
- [ ] Update `resetSettings`
- [ ] Update tests
- [ ] Replace `masterMode` switch with 3-button picker in
`SettingsButton.svelte`
- [ ] Re-gate "Tự động xổ" fieldset on `mode !== "player"`
- [ ] Update `+page.svelte` conditional rendering
- [ ] `npm test && npm run build` — green
## Success criteria
- `npm test` passes.
- `npm run build` clean.
- Manual: refresh with `localStorage.loto_settings = '{"masterMode":true}'`
→ after load, settings panel shows "Cả hai" selected, both panels
visible.
- Manual: pick "Quản trò" → PlayerBoard disappears, MasterPanel shows
alone.
- Manual: pick "Người chơi" → MasterPanel disappears.
## Risks
- **Touching shared settings store breaks other consumers**: low —
voice flags and color settings are independent.
- **Slide transition jank** when MasterPanel appears alone: the
existing `transition:slide` is on the `<section>`. Will visually
test in `master` mode where it's the only thing rendering.
@@ -1,152 +0,0 @@
# Phase 2 — Master broadcast + player auto-tick
## Overview
In `mode === "both"`, when MasterPanel draws a number, PlayerBoard
auto-marks that number on its grid (if present). Achieved with a tiny
shared store that broadcasts `lastDrawn`. PlayerBoard reacts via
`$effect`. Manual cell taps still work as before.
**Status**: not started
**Priority**: P1
**Effort**: ~35 min
**Depends on**: Phase 1 (uses `settings.mode === "both"`)
## Files to create
- `src/lib/call-bus.svelte.js` — single reactive `lastDrawn` slot
## Files to modify
- `src/lib/MasterPanel.svelte` — write to bus on draw
- `src/lib/PlayerBoard.svelte` — read bus, auto-tick if present
- `src/lib/call-bus.test.js` — new test file
## Design
### call-bus.svelte.js
```js
/**
* Tiny one-slot bus to coordinate master draws → player auto-tick.
* Each draw publishes a fresh object so even repeat numbers fire a
* fresh reactive change. Consumers read `bus.lastDrawn?.num` in an
* effect.
*
* @module lib/call-bus
*/
export const bus = $state({
/** @type {{ num: number, at: number } | null} */
lastDrawn: null,
});
/** @param {number} num */
export function broadcastDraw(num) {
bus.lastDrawn = { num, at: Date.now() };
}
export function resetBus() {
bus.lastDrawn = null;
}
```
`at` is a tiebreaker: if master draws 17, player marks; player un-marks
manually; master draws 17 again (impossible in normal play but
possible after "Ván mới") — the new object reference triggers the
effect cleanly.
Reset is called from MasterPanel's `handleNewGame` so a stale
`lastDrawn` from a previous game can't race the new player grid.
### MasterPanel changes
In `handleDrawNext`:
```js
function handleDrawNext() {
if (!state || state.remaining.length === 0) return;
const next = state.remaining[0];
state = {
called: [...state.called, next],
remaining: state.remaining.slice(1),
};
lastCalled = next;
scrollOnNextDraw = true;
broadcastDraw(next); // NEW
if (settings.voiceEnabledMaster) playNumber(next);
}
```
In `handleNewGame`, after creating fresh state, call `resetBus()`.
### PlayerBoard changes
Add a new `$effect` watching the bus:
```js
import { bus } from "$lib/call-bus.svelte.js";
import { settings } from "$lib/settings-store.svelte.js";
$effect(() => {
const drawn = bus.lastDrawn;
if (!drawn) return;
if (settings.mode !== "both") return;
if (!grid || crossed.length === 0) return;
// Find first occurrence (numbers are unique on a 9×9 lô tô card)
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[r].length; c++) {
if (grid[r][c] === drawn.num && !crossed[r][c]) {
crossed = crossed.map((row, ri) =>
ri === r ? row.map((v, ci) => (ci === c ? true : v)) : row,
);
return;
}
}
}
});
```
**Important**: only sets to `true`, never toggles off. If the cell is
already crossed (manually), the effect short-circuits — no double-toggle
bug.
Mode check is inside the effect, not a top-level guard, so the effect
remains reactive to `settings.mode` (Svelte 5 runes track reads).
### Test plan (call-bus.test.js)
- `broadcastDraw(n)` updates `bus.lastDrawn.num` to `n`.
- Calling twice with the same number yields a different object
reference (so `$effect` re-fires).
- `resetBus()` sets `bus.lastDrawn = null`.
## Todo
- [ ] Create `src/lib/call-bus.svelte.js`
- [ ] Wire `broadcastDraw` into `handleDrawNext`
- [ ] Wire `resetBus` into `handleNewGame`
- [ ] Add bus-watching `$effect` in PlayerBoard
- [ ] Confirm auto-tick fires Bingo/Chờ effects naturally (existing
`$effect` on `crossed` will pick up the change)
- [ ] Write `call-bus.test.js`
- [ ] `npm test && npm run build` — green
- [ ] Manual: in "Cả hai", draw → matching cell marks itself.
## Success criteria
- All tests pass.
- In `both` mode, drawing a number visible on the player grid
auto-marks it; the existing waiting/bingo logic fires correctly.
- In `player` or `master` mode alone, no cross-component effects.
- Manual taps still toggle cells normally (no interference).
- Auto-tick never un-marks a cell that's already crossed.
## Risks
- **Effect runs on hot-reload with stale state**: Svelte 5 effects
re-run on dep changes. The mode/grid guard makes this safe.
- **Visual surprise**: host might want "preview" before marking.
Decision: auto-tick is the point of this phase; not making it
optional in v1. Can add a `autoTick` setting later if requested.
@@ -1,125 +0,0 @@
# Phase 3 — Master speaks Chờ / Kinh
## Overview
When `voiceEnabledMaster` is on AND `mode === "both"`, the master also
speaks "Chờ" / "Kinh" announcements as the player board hits those
states. Player voice toggle continues to work in solo `player` mode.
No new setting; the existing `voiceEnabledMaster` flag covers it.
**Status**: not started
**Priority**: P1
**Effort**: ~20 min
**Depends on**: Phase 1 (mode check)
**Independent of**: Phase 2 (different code path, different effects)
## Files to modify
- `src/lib/PlayerBoard.svelte` — broaden the voice trigger condition
on the existing Bingo + Chờ effects
- (optional) `src/lib/SettingsButton.svelte` — small text update on
the "Quản trò đọc số" hint to mention Chờ/Kinh
## Design
Today's PlayerBoard runs:
```js
if (settings.voiceEnabledPlayer) playBingo();
// ...
if (settings.voiceEnabledPlayer) playWaiting(waitNum);
```
Change to:
```js
if (shouldAnnouncePlayerVoice()) playBingo();
// ...
if (shouldAnnouncePlayerVoice()) playWaiting(waitNum);
```
Where:
```js
function shouldAnnouncePlayerVoice() {
return (
settings.voiceEnabledPlayer ||
(settings.voiceEnabledMaster && settings.mode === "both")
);
}
```
Inline the boolean if the helper feels heavy — DRY-but-tiny:
```js
const announce =
settings.voiceEnabledPlayer ||
(settings.voiceEnabledMaster && settings.mode === "both");
if (announce) playBingo();
```
### Why no new setting
- `voiceEnabledMaster` already implies "the host's audio role".
- In `both` mode, the master IS the announcer; Chờ/Kinh are part of
what an announcer says.
- Adds no UX complexity; the existing toggle just covers more cases.
### Why this can ship without phase 2
The Chờ/Kinh announcements fire from the player board's existing
effect on `crossed` changes. Those changes happen whether the cell
was crossed manually OR by phase 2's auto-tick. So phase 3 works
solo: player taps a cell that completes a row → master speaks "Kinh"
in `both` mode.
### Settings hint copy
Current "Quản trò đọc số" has no description. Add a one-line note:
```svelte
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1.5 px-1">
Đọc số đã xổ + báo Chờ/Kinh khi ở "Cả hai".
</p>
```
(Only when in master-visible modes, to keep the panel uncluttered.)
## Todo
- [ ] Add `announce` derived boolean in PlayerBoard before each
voice call
- [ ] Update both `playBingo()` and `playWaiting()` call sites
- [ ] Add small clarifying hint under "Quản trò đọc số"
- [ ] `npm test && npm run build` — green
- [ ] Manual smoke:
- mode=player, voicePlayer=on, voiceMaster=off → Chờ/Kinh play
- mode=player, voicePlayer=off → silent
- mode=both, voicePlayer=off, voiceMaster=on → Chờ/Kinh play
- mode=both, voicePlayer=off, voiceMaster=off → silent
- mode=master alone → no player events to announce
## Success criteria
- Existing player-voice tests still pass.
- New behavior verified manually with above matrix.
- Voice cancellation logic in `voice.js` is unchanged; double-trigger
scenarios resolve as "last wins" (already covered by existing
cancellation pattern).
## Risks
- **Surprise users who turned off player voice on purpose**: low —
the master flag is on by default and they likely want all master
audio. If they specifically want master-number-only, they'd need a
finer-grained setting; out of scope for this iteration. Note in
follow-up.
- **Two clips overlapping**: if a draw fires `playNumber` (master) and
the player auto-tick (phase 2) fires `playWaiting` immediately,
voice.js cancels the in-flight clip. The most recent call wins.
Acceptable tradeoff; the alternative (queueing) creates speech lag.
## Follow-up (out of scope)
- A "what does master speak?" sub-panel: number / Chờ / Kinh as
individual toggles. Add only if a real user asks.
@@ -1,77 +0,0 @@
---
slug: three-mode-and-master-auto-tick
created: 2026-04-27
status: completed
completedAt: 2026-04-27
mode: fast
blockedBy: []
blocks: []
---
# Three-mode settings + master auto-tick + master Chờ/Kinh
## What this changes (in user terms)
- Settings replaces the single "Hiện bảng quản trò" switch with a 3-way
picker: **Người chơi** (player only — today's default), **Quản trò**
(master only — for projector/casting host), **Cả hai** (both inline,
same as today's `masterMode=true`).
- In **Cả hai** mode, when the master draws a number, the player board
automatically marks that number if present — host doesn't have to
tap twice.
- "Quản trò đọc số" now ALSO speaks **Chờ** / **Kinh** announcements
whenever they trigger (in addition to the called number). Player
voice toggle stays for solo-player mode.
## Decisions (locked unless flagged)
- `masterMode: boolean` → `mode: "player" | "master" | "both"`. Saved
data with `masterMode: true` migrates to `mode: "both"`; everything
else falls to `"player"`.
- Defaults: `mode = "player"`, `voiceEnabledMaster = true`,
`voiceEnabledPlayer = false`, `voiceWaitingNumber = false` (unchanged).
- Master ↔ Player wiring uses a tiny shared store (`call-bus.svelte.js`)
— one reactive `lastDrawn` slot. Avoids prop-drilling through the
page route, keeps each component self-contained.
- Auto-tick is **only** active in `mode === "both"`. In `master` alone
there is no player board, and in `player` alone there is no master.
- "Master speaks Chờ/Kinh" is a behavior change of the existing
`voiceEnabledMaster` flag — no new setting. Player voice events fire
when `voiceEnabledPlayer` OR (`voiceEnabledMaster` AND `mode === "both"`).
- No localStorage breakage: keeping all existing keys, only adding
`mode`. Old `masterMode` is migrated then ignored.
## Phases
| # | File | Status | Effort |
|---|------|--------|--------|
| 1 | `phase-01-three-mode-settings.md` | done | 25 min |
| 2 | `phase-02-master-broadcast-and-auto-tick.md` | done | 35 min |
| 3 | `phase-03-master-cho-kinh-announcements.md` | done | 20 min |
Total: ~80 min. Each phase ships independently, tests pass after each.
## Dependencies
- None external. All work in `src/lib/` and `src/routes/+page.svelte`.
- Phase 2 depends on phase 1 (mode === "both" check).
- Phase 3 depends on phase 1 (mode === "both" check) but can ship
independent of phase 2.
## Risks
- **Migration silently dropping master mode users**: anyone currently
using `masterMode: true` will get `mode: "both"` after the migration
runs once. Verified by test (phase 1).
- **Auto-tick + manual cross collision**: if a player crosses a cell,
then master draws the same number, marking the cell again would
toggle it OFF. Fix: auto-tick only sets `crossed=true` when not
already true; never toggles.
- **Voice race**: if master draws and the player board immediately
hits Chờ in `both` mode, two clips would queue. Voice module already
cancels in-flight playback on each `play*()` call — last wins. Verify.
## Rollback
Single-commit revert per phase. No DB, no migrations beyond a one-shot
read transformation in `loadSettings()`.
@@ -1,152 +0,0 @@
# Phase 1 — Vietnamese-safe font + master empty-state hero
Closes the two highest-impact deferred P0s from the audit:
1. `tan-tan-num` font stack relies on system fonts that don't ship
Vietnamese diacritics consistently on Android.
2. `mode === "master"` with no game started shows just a button + line
of text — feels broken.
**Status**: not started
**Priority**: P0
**Effort**: ~40 min
**Depends on**: nothing (independent)
## Files to add
- `static/fonts/roboto-condensed-700-vietnamese.woff2` — subset font
asset (download once, commit). Or comparable face. ~30-50KB.
- `src/lib/MasterEmptyState.svelte` (new) — small, isolated component.
Reuses `tan-tan-num` and existing styles.
## Files to modify
- `src/app.css` — add `@font-face` declaration; promote new face to
the front of the `tan-tan-num` stack; ensure `font-display: swap`.
- `src/lib/MasterPanel.svelte` — render `<MasterEmptyState />` when
`state === null`, replacing the current line of text.
## Font choice
**Recommended**: Roboto Condensed Bold (700), Vietnamese-extended
subset. Free under Apache-2.0. Already in `tan-tan-num` fallback list,
so promotion is non-breaking. 28KB gz at 700 weight only.
Alternative: Oswald — bolder feel, narrower, more "fairground sign"
mood. ~25KB gz. Pick Roboto unless we want a stronger brand shift.
Generate subset:
```bash
npx glyphhanger \
--formats=woff2 \
--subset=src/**/*.svelte,src/**/*.js \
--LATIN \
--whitelist=U+0102,U+0103,U+1EA0-1EF9 \
fonts/RobotoCondensed-Bold.ttf
```
Or use `google-webfonts-helper` with manual subset selection.
## CSS
```css
@font-face {
font-family: "Roboto Condensed";
src: url("/fonts/roboto-condensed-700-vietnamese.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
unicode-range: U+0020-007F, U+00A0-024F, U+1E00-1EFF, U+0102-0103,
U+1EA0-1EF9;
}
```
`tan-tan-num` already lists "Roboto Condensed" — promote it to the
front (or leave as-is now that the @font-face declaration registers
the family for the renderer). `font-display: swap` ensures Inter or
similar renders first while the woff2 downloads.
## MasterEmptyState component
```svelte
<script>
// Decorative 11×9 ghost board mock — same proportions as the real
// master tracking grid so the page silhouette stays consistent.
</script>
<div class="text-center py-10">
<div
aria-hidden="true"
class="mx-auto max-w-xs opacity-30 pointer-events-none mb-6
rounded-md overflow-hidden border border-slate-300 dark:border-slate-600"
>
<div class="grid grid-cols-9 gap-px bg-slate-300 dark:bg-slate-700">
{#each Array(99) as _, i (i)}
{@const filled = (i % 11) < 9 && i % 7 === 0}
<div
class="aspect-square text-[0.5rem] flex items-center justify-center
text-black {filled ? 'bg-white dark:bg-slate-800 dark:text-slate-100' : ''}"
style:background-color={filled ? undefined : "var(--empty-cell-bg)"}
>
{filled ? ((i * 13) % 90) + 1 : ""}
</div>
{/each}
</div>
</div>
<span
class="inline-block px-3 py-1 rounded-full text-xs font-semibold
tracking-wider uppercase
bg-orange-100 dark:bg-orange-950/40
text-orange-700 dark:text-orange-300"
>
Chế độ Quản trò
</span>
<p class="mt-3 text-base text-slate-600 dark:text-slate-300 italic">
Nhấn
<span class="font-semibold text-orange-500 dark:text-orange-400 not-italic">
"Ván mới"
</span>
để bắt đầu xổ số
</p>
<p class="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
🎤 Đã sẵn sàng
</p>
</div>
```
Replace the current `{:else} <div>Nhấn "Ván mới" ...</div>` block in
`MasterPanel.svelte`.
## Todo
- [ ] Subset Roboto Condensed Bold to Vietnamese, save as
`static/fonts/roboto-condensed-700-vietnamese.woff2`
- [ ] Add `@font-face` in `app.css` (with `unicode-range`, `swap`)
- [ ] Verify `tan-tan-num` resolves the new face on Android Chrome
(DevTools → Computed → Rendered fonts)
- [ ] Create `src/lib/MasterEmptyState.svelte`
- [ ] Wire into `MasterPanel.svelte` `{:else}` branch
- [ ] Update CSP `font-src` in `static/_headers` to allow `'self'` (already does, just confirm)
- [ ] `npm test && npm run build` — green
- [ ] Manual: master mode alone, no game → empty-state hero shows;
diacritics on "Chờ", "Đã" render correctly
## Success criteria
- Numbers + Vietnamese-text-bearing labels render with the new face
on Linux/Android (no fallback to default sans).
- master-only mode no longer feels broken — hero ghost grid + role
pill make the page feel intentional.
- Lighthouse perf score doesn't drop more than 2 points (font is
preloaded? Probably not needed — it's only used by the masthead.)
## Risks
- **Font license check**: confirm Apache-2.0 redistribution OK in the
static export. (Yes — Roboto family is Apache-2.0.)
- **FOUT flash on slow connection**: `font-display: swap` is the right
tradeoff. Don't switch to `block` — it's worse UX.
- **Subset miss**: if a Vietnamese diacritic combination isn't in the
subset, the renderer falls back per-codepoint to the next family
in the stack — visible but not broken. Mitigation: full
Vietnamese subset > attempting smart trimming.
@@ -1,168 +0,0 @@
# Phase 2 — Mode picker icons, color picker, brand polish
Cleans up the medium-priority audit items in `SettingsButton.svelte`
plus the header subline. Each is small and visual; the phase is one
focused commit.
**Status**: not started
**Priority**: P1
**Effort**: ~50 min
**Depends on**: nothing (independent of phase 1)
## Files to modify
- `src/lib/SettingsButton.svelte` — mode picker SVG glyphs, color
picker layout, "Mặc định" button styling
- `src/routes/+page.svelte` — header subline replacement
## Mode picker — inline SVG glyphs
Replace text-only buttons with text + tiny glyph above. No icon
library; ~15 lines of inline SVG total.
```svelte
{#snippet modeIcon(v)}
{#if v === "player"}
<!-- Card with cells -->
<svg viewBox="0 0 24 16" class="w-6 h-4 mx-auto" fill="none"
stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="22" height="14" rx="1.5" />
<path d="M1 6h22M1 11h22M8 1v14M16 1v14" />
</svg>
{:else if v === "master"}
<!-- Megaphone -->
<svg viewBox="0 0 24 24" class="w-5 h-5 mx-auto" fill="none"
stroke="currentColor" stroke-width="1.8">
<path d="M3 11l14-6v14L3 13z M3 11v2 M19 8a4 4 0 0 1 0 8" />
</svg>
{:else}
<!-- Two stacked cards -->
<svg viewBox="0 0 24 20" class="w-6 h-5 mx-auto" fill="none"
stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="18" height="11" rx="1" />
<rect x="5" y="7" width="18" height="11" rx="1" />
</svg>
{/if}
{/snippet}
```
Inside the existing `{#each MODES}` loop:
```svelte
<button ...>
{@render modeIcon(v)}
<span class="block mt-0.5">{label}</span>
</button>
```
Add `flex flex-col items-center` to the button class so glyph and
label stack cleanly. Existing `MODE_HINTS[settings.mode]` line stays.
## Color picker layout
Wrap in a single bordered card with sub-headers, native input on the
left, presets on the right. Replace the current loose flex/grid:
```svelte
<fieldset class="mb-5">
<legend class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2">
Màu ô trống
</legend>
<div class="rounded-xl border border-slate-200 dark:border-slate-700 p-3 space-y-3">
<!-- Custom -->
<div>
<p class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-1.5">
Tuỳ chỉnh
</p>
<div class="flex items-center gap-3">
<input type="color" .../>
<code class="text-sm font-mono ...">{settings.emptyCellColor}</code>
</div>
</div>
<!-- Presets -->
<div>
<p class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-1.5">
Mẫu sẵn
</p>
<div class="grid grid-cols-5 gap-2">
{#each PRESETS as hex (hex)}
...
{/each}
</div>
</div>
</div>
</fieldset>
```
Adds visual structure; no new logic.
## "Mặc định" button — clearer affordance
Currently a near-invisible footer link. Keep it understated but make
it look like a button:
```svelte
<button
type="button"
onclick={() => resetSettings()}
class="px-4 py-2 rounded-full text-sm font-medium
text-slate-600 dark:text-slate-300
border border-slate-300 dark:border-slate-600
hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
Đặt lại
</button>
```
Rename "Mặc định" → "Đặt lại" (clearer = "reset" not "default").
## Header subline — fairground mood
Current:
```svelte
<p class="text-xs sm:text-sm uppercase tracking-[0.28em] text-slate-600 dark:text-slate-300 italic mt-1.5 font-medium">
Hội chợ TN1
</p>
```
New: drop the heavy uppercase tracking; flank with hairline dashes;
add a tiny lantern emoji to anchor mood. Keep `text-sm`, drop italic.
```svelte
<p class="flex items-center justify-center gap-2 text-xs sm:text-sm
text-slate-600 dark:text-slate-300 mt-1.5 font-medium">
<span aria-hidden="true" class="block w-6 h-px bg-slate-400/60 dark:bg-slate-500/50"></span>
<span>🏮 Hội chợ TN1</span>
<span aria-hidden="true" class="block w-6 h-px bg-slate-400/60 dark:bg-slate-500/50"></span>
</p>
```
## Todo
- [ ] Add inline mode-picker SVG glyphs (snippet pattern)
- [ ] Restructure mode picker buttons for vertical icon+label layout
- [ ] Wrap color picker in a single bordered card with sub-headers
- [ ] Style "Mặc định" → "Đặt lại" with bordered chip look
- [ ] Replace header subline with dash-flanked lantern band
- [ ] `npm test && npm run build` — green
- [ ] Manual smoke: mode picker glyphs read on light + dark; color
picker visually grouped; reset button clearly clickable.
## Success criteria
- Mode picker communicates role at a glance without reading the label.
- Color picker no longer feels disconnected (single card vs loose).
- Reset button clearly affordant but not destructive-looking.
- Header subline reads as fairground decor, not SaaS subhead.
## Risks
- **Glyph stroke width on dark mode**: `currentColor` inherits from
the button text, so it follows the indigo-active vs slate-resting
states automatically. Test contrast on both.
- **Color picker height growth**: bordered card adds ~16px vertical;
the modal scrolls anyway, no real cost.
- **"Đặt lại" wording**: "Mặc định" is the current Vietnamese
convention; "Đặt lại" is clearer but slightly less polite. Both
work — defer to user if they push back.
@@ -1,216 +0,0 @@
# Phase 3 — Installable PWA with offline audio
Ship the app as a Progressive Web App so users can install it to home
screen and play without signal. Critical for fairground use where
venue Wi-Fi is unreliable.
**Status**: not started
**Priority**: P1
**Effort**: ~60 min
**Depends on**: nothing (independent of phases 1 & 2)
## Files to add
- `static/manifest.webmanifest` — PWA metadata
- `static/icons/icon-192.png`, `icon-512.png`, `icon-maskable-512.png`
— exported from a single SVG source
- `static/icons/source.svg` (optional — keeps a vectorized origin
alongside the rasters)
## Files to modify
- `package.json` — add `@vite-pwa/sveltekit` dev dependency
- `vite.config.js` — register the SvelteKitPWA plugin
- `src/app.html` — `<link rel="manifest">`, `<meta name="theme-color">`,
apple-touch-icon
- `svelte.config.js` — confirm static adapter still works with PWA
plugin (it does, but verify)
## Plugin choice — `@vite-pwa/sveltekit`
Mature, actively maintained, integrates cleanly with `adapter-static`.
Generates the service worker via Workbox under the hood with sensible
defaults.
```bash
npm install -D @vite-pwa/sveltekit
```
```js
// vite.config.js
import { sveltekit } from "@sveltejs/kit/vite";
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
import tailwindcss from "@tailwindcss/vite";
export default {
plugins: [
tailwindcss(),
sveltekit(),
SvelteKitPWA({
registerType: "autoUpdate",
includeAssets: ["favicon.ico", "icons/*.png", "audio/**/*.mp3"],
manifest: false, // we ship our own static/manifest.webmanifest
workbox: {
globPatterns: [
"**/*.{js,css,html,svg,png,woff2,webmanifest}",
"audio/**/*.mp3",
],
// 184 audio clips × ~10KB ≈ 2MB. Workbox default is 2MB so
// bump explicitly to be safe.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
navigateFallback: "/index.html",
},
devOptions: { enabled: false }, // don't pollute dev with SW
}),
],
};
```
## manifest.webmanifest
```json
{
"name": "Lô tô — Hội chợ TN1",
"short_name": "Lô tô",
"description": "Bàn số của trò chơi Lô tô",
"start_url": ".",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#1565c0",
"background_color": "#0a0f1f",
"lang": "vi",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
```
`theme_color` matches the section accent. `background_color` matches
dark-theme base (so the splash screen feels intentional).
## app.html additions
```html
<head>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#1565c0"
media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0f1f"
media="(prefers-color-scheme: dark)" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
</head>
```
## Icons
Use a single 512×512 SVG source. Export 192/512 standard + 512
maskable (with 20% safe-zone padding for Android adaptive icons).
Quick path: ImageMagick.
```bash
magick static/icons/source.svg -resize 192x192 static/icons/icon-192.png
magick static/icons/source.svg -resize 512x512 static/icons/icon-512.png
# Maskable: pad 20% transparent safe zone
magick static/icons/source.svg -background none -gravity center \
-resize 70%x70% -extent 512x512 static/icons/icon-maskable-512.png
```
Source SVG: a stylized bingo card grid + the rose/amber gradient
from the page header. Or (simpler) a centered "L" wordmark in the
brand gradient.
## CSP for the manifest
Confirm `static/_headers` allows `manifest-src 'self'` and the icon
fetches. Today's CSP has `default-src 'self'` which covers it.
## Testing
```bash
npm run build
npx serve build
# Open http://localhost:3000 in Chrome
# DevTools → Application → Manifest (verify icons + theme)
# DevTools → Application → Service Workers (verify registration)
# Network tab → check "Offline" → reload — app still works
# Audio playback: turn off network, draw a number — clip plays
```
## Update flow
`autoUpdate` mode: on each new deploy, the SW detects the new build
and swaps in. Users see the new version on next navigation. Don't
add a "new version" toast in v1 — the swap is automatic and silent.
If users complain about content jumping mid-game, layer on a manual
prompt later (out of scope).
## Todo
- [ ] `npm install -D @vite-pwa/sveltekit`
- [ ] Add SvelteKitPWA to `vite.config.js`
- [ ] Create `static/manifest.webmanifest`
- [ ] Generate three icons (192, 512, maskable-512) from source SVG
- [ ] Add manifest + theme-color meta tags + apple-touch-icon to
`src/app.html`
- [ ] Verify `static/_headers` CSP still permits manifest + SW
- [ ] `npm run build && npx serve build` — manual offline test
- [ ] Confirm `npm test` still passes (PWA shouldn't touch test paths)
- [ ] Lighthouse audit — PWA category should hit 100
## Success criteria
- Chrome shows the install prompt (≥1 visit + qualifying engagement)
- iOS Safari "Add to Home Screen" launches in standalone, no browser
chrome
- Splash screen uses the right theme color and dark background
- Offline reload renders the app and plays audio clips for previously
downloaded voices
- Lighthouse PWA score = 100, perf score doesn't drop > 5 points
- Service worker registration is silent (no console errors)
## Risks
- **Service worker stale-content trap**: `autoUpdate` registers a new
SW; old tabs keep the old build until reload. Acceptable; users
reload between rounds.
- **Cloudflare Pages SW caching headers**: Cloudflare caches `/sw.js`
by default; need `Cache-Control: no-cache` on `sw.js` so updates
propagate. Add to `static/_headers`:
```
/sw.js
Cache-Control: no-cache
```
- **Audio precache size**: 2 voices × 92 clips. Measure with
`ls -la static/audio/*/*.mp3 | awk '{s+=$5} END {print s}'`.
If > 5MB, consider runtime-cached strategy instead of precache.
- **iOS quirks**: Safari requires `apple-mobile-web-app-capable`
meta + apple-touch-icon for proper standalone launch. Both
included.
- **GitHub Pages mirror**: SW path expectations differ when served
from `/loto/` base. `@vite-pwa/sveltekit` reads SvelteKit's
`paths.base` so this works automatically. Verify in
`npm run build:gh`.
## Out of scope
- "New version available" toast (deferred — autoUpdate is silent v1)
- Push notifications
- Background sync (no backend to sync with)
- Workbox runtime caching strategies beyond defaults
- Custom install prompt UI (use browser native)
@@ -1,93 +0,0 @@
---
slug: ui-polish-and-pwa-v2
created: 2026-04-27
status: completed
completedAt: 2026-04-27
mode: fast
blockedBy: []
blocks: []
---
# UI polish v2 + installable PWA
Finishes the UI/UX audit items deferred in `ad6291e` (Vietnamese font,
master empty state, mode picker iconography, color picker, brand mood)
and ships the app as an installable PWA so it works offline at the
fairground (no signal in many venues).
## Why now
- The audit's remaining P1/P2 items are all small, concrete fixes with
sketched solutions — bundle them before the codebase grows.
- The Vietnamese font gap is the single biggest legibility risk on
Android phones, which is the primary device for fairground use.
- "Works offline at a fairground with poor signal" is the killer
feature that justifies installing it. PWA support is straightforward
with `@vite-pwa/sveltekit` (already-static export).
## Decisions (locked unless flagged)
- **Font policy**: self-host one weight (Roboto Condensed Bold or
Oswald Vietnamese subset) via `@font-face` with `font-display: swap`.
Add to `tan-tan-num` stack as the primary face. Subset to `latin` +
`latin-ext` + `vietnamese`. ~30-50KB gz'd.
- **PWA stack**: `@vite-pwa/sveltekit`. Service-worker strategy:
`precache` all 184 audio clips (2 voices × 92 names) + app shell;
`NetworkFirst` for navigation; cache-first for static assets.
- **No backend, no install hostility**: ship `manifest.webmanifest`
with icons, theme color, name. Don't show a "Install this app"
banner — let the browser native flow handle it. (Out of scope for
v2; can layer on later if data shows it's needed.)
- **Mode picker icons**: SVG inline, no icon library. 3 small glyphs
hand-drawn from primitives (rectangle + ellipse).
- **Color picker**: keep the 10-swatch + native input layout, but
group them in one card with `Tuỳ chỉnh` / `Mẫu` sub-headers.
- **Header brand polish**: drop the all-caps tracking-[0.28em] sub
and replace with decorative dashes. Keep current font stack.
- **Per-row Chờ indicator**: a subtle ring on the row label band
(`section-label`) when a Chờ row exists in that section. Optional
— fall back to today's toast if implementation stretches.
## Phases
| # | File | Status | Notes |
|---|------|--------|-------|
| 1 | Vietnamese font + MasterEmptyState | DONE | @fontsource/roboto-condensed added to tan-tan-num stack |
| 2 | Mode picker + color picker + brand polish | DONE | SVG glyphs inline, bordered card with sub-headers, header subline with decorative dashes |
| 3 | Installable PWA (offline-capable) | DONE | @vite-pwa/sveltekit + manifest + icons; audio uses runtime CacheFirst (not precache due to static adapter timing) |
All phases shipped in commit `fba3e91`. Total delivery: ~2.5 hr.
## Out of scope (parking lot)
- Multiple cards per player.
- Long-press hero to undo last call.
- Spacebar to draw next number.
- Internationalization (English UI).
- LRU on audio cache (deferred from security audit — only matters at
voices > 10).
- Bumping `@sveltejs/kit` to ship `cookie >=0.7.0` (next dependency
sweep — not exploitable in static export today).
- Tier-2 confetti threshold tweak.
- 'unsafe-inline' style → hashed CSP (requires Svelte build tweak).
## Risks
- **Font swap visible flash on slow connections**: `font-display: swap`
shows fallback first. Acceptable; alternative (`block`) blocks
rendering up to 3s.
- **Service worker stale-content trap**: `@vite-pwa/sveltekit` ships
with `autoUpdate` mode. Will use it; users see a "new version
available" reload toast. Verify the SW skipWaiting flow doesn't
drop in-flight audio.
- **Install on iOS Safari is awkward**: Safari requires "Add to
Home Screen" manually; manifest still helps standalone display.
- **Audio precache size**: 2 voices × 92 clips. If clips are ~10KB
each, total ~1.8MB. Manageable but worth measuring before locking
precache strategy.
## Rollback
Each phase is one-commit revertible. PWA can be feature-flagged
(don't register SW in dev) so reverts don't strand cached SWs in
users' browsers.
+46 -38
View File
@@ -1,28 +1,60 @@
# Next-session TODO
Hand-off list as of 2026-04-28 (commit `9f24b6d`). The
`260428-0927-implement-todo-backlog` plan shipped 8 of 9 phases — only
the manual PWA verification checklist remains. Below are residual /
new items deferred from that pass.
Hand-off list as of 2026-04-28 (commit `9f24b6d`). All prior plan
folders have been deleted; residual / new items live here directly.
## Highest leverage (start here)
- **Run Phase 9 — PWA install verification.** Manual checklist in
`plans/260428-0927-implement-todo-backlog/phase-09-pwa-verify-install.md`.
Needs production deploy on Cloudflare Pages + physical Android
Chrome + iOS Safari. Lighthouse PWA = 100/100, install flow,
airplane-mode offline, `curl -I` header check (script-src now
hashed, no longer `'unsafe-inline'`).
### PWA install verification (manual, post-deploy)
## UX polish (carried over from pass-2)
Needs production deploy on Cloudflare Pages + physical Android
Chrome + iOS Safari. No code; verification only.
**Lighthouse — Cloudflare Pages (root base)**
- Open `https://loto.miti99.com/` in incognito Chrome.
- DevTools → Lighthouse → PWA + Perf + Best Practices + a11y.
- PWA score = 100. No CSP violations. No mixed-content warnings.
**Lighthouse — GitHub Pages (`/loto/` base)**
- Open `https://tiennm99.github.io/loto/` in incognito.
- Confirm SW URL `/loto/sw.js`, manifest `/loto/manifest.webmanifest`,
icons `/loto/icons/...` resolve.
**Android Chrome (physical or emulator)**
- "Add to Home Screen" → install → launch.
- Splash uses theme color (`#1565c0` light / `#0a0f1f` dark, see
`app.html:9-10`). Status bar matches.
- Standalone display (no Chrome chrome).
- Airplane mode → reload from home → app shell + default voice work.
- Maskable icon: long-press app icon, ensure mask doesn't crop the
centered glyph. Verify in DevTools "Show maskable preview" too.
May need to drop safe-zone 70% → 65% if mask crops tight.
**iOS Safari**
- Share → Add to Home Screen.
- Icon uses `apple-touch-icon` (`/icons/icon-192.png`) — round-ish
glyph, no white bars.
- Launch standalone, fonts legible under translucent status bar.
- Airplane mode → app shell + default voice play.
**CSP + headers (production)**
- `curl -I https://loto.miti99.com/` shows `Content-Security-Policy`,
`X-Content-Type-Options: nosniff`, `Referrer-Policy`,
`Permissions-Policy`, `X-Frame-Options: DENY`.
- CSP `script-src` no longer contains `'unsafe-inline'`.
**Common gotchas**
- Manifest paths break under `/loto/` base → check `vite.config.js`
PWA `manifest: false` + `app.html` uses `%sveltekit.assets%`.
- iOS install shows wrong icon → ensure `icons/icon-192.png` is
192×192 actual size.
## UX polish (carried over)
- **`MasterEmptyState` ↔ PlayerBoard ghost-grid duplication.** Two
near-identical decorative components — extract a shared
`<GhostBoardPreview rows={N} />` only if a third use appears.
(Rule-of-three not met yet.)
- **Maskable icon at 70% safe-zone.** Verify in Chrome DevTools "Show
maskable preview" before announcing PWA. May need to drop to 65% if
Android shape masks crop too tight. Roll into Phase 9 verification.
## Tech debt
@@ -50,27 +82,3 @@ new items deferred from that pass.
- "New version available" reload toast for SW autoUpdate. Today the
swap is silent; if users complain about content jumping mid-game,
add a non-blocking notice.
## Reports archive
- Pass 1 (260427-1151): `code-reviewer-`, `ui-ux-designer-`, `security-`
- Pass 2 (260427-2047): `code-reviewer-pass2-full`,
`ui-ux-designer-pass2-full`, `security-pass2-full`
- Phase-specific: `code-reviewer-260427-1036-three-mode`,
`code-reviewer-260427-2030-polish-pwa`
All under `plans/reports/`. Pass-1 unresolved items were verified
addressed in pass-2; pass-2 items were addressed across the
`260428-0927-implement-todo-backlog` phases.
## Recently shipped (260428-0927-implement-todo-backlog)
- ✅ Phase 1 — auto-tick integration test (8 cases, helper extracted)
- ✅ Phase 2 — CI inline-script guard (`npm run verify:build`)
- ✅ Phase 3 — mode picker "Both" glyph: grid + megaphone composite
- ✅ Phase 4 — settings modal sticky title/footer on small screens
- ✅ Phase 5 — per-section Chờ ring (amber, reduced-motion aware)
- ✅ Phase 6 — confetti tier-2 threshold + 🥢🎋🏮 + size jitter
- ✅ Phase 7 — strict CSP: SHA-256 hash of inline bootstrap, no
`'unsafe-inline'` in `script-src`
- ✅ Phase 8 — audio cache 30d → 7d + purgeOnQuotaError