- {/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.
diff --git a/plans/archive/260427-0907-ui-ux-improvements/phase-04-settings-and-dark-polish.md b/plans/archive/260427-0907-ui-ux-improvements/phase-04-settings-and-dark-polish.md
deleted file mode 100644
index 9d91649..0000000
--- a/plans/archive/260427-0907-ui-ux-improvements/phase-04-settings-and-dark-polish.md
+++ /dev/null
@@ -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
-
-```
-
-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
-
-
-```
-
-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
-
-- 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.
diff --git a/plans/archive/260427-0907-ui-ux-improvements/phase-05-celebration-tiering.md b/plans/archive/260427-0907-ui-ux-improvements/phase-05-celebration-tiering.md
deleted file mode 100644
index e4550aa..0000000
--- a/plans/archive/260427-0907-ui-ux-improvements/phase-05-celebration-tiering.md
+++ /dev/null
@@ -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}
-
- {#each Array(12) as _, i (i)}
-
- {["🎊", "✨", "🎉", "🥳"][i % 4]}
-
- {/each}
-
-{/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.
diff --git a/plans/archive/260427-0907-ui-ux-improvements/phase-06-tests-and-docs.md b/plans/archive/260427-0907-ui-ux-improvements/phase-06-tests-and-docs.md
deleted file mode 100644
index d407538..0000000
--- a/plans/archive/260427-0907-ui-ux-improvements/phase-06-tests-and-docs.md
+++ /dev/null
@@ -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.
diff --git a/plans/archive/260427-0907-ui-ux-improvements/plan.md b/plans/archive/260427-0907-ui-ux-improvements/plan.md
deleted file mode 100644
index 496e02b..0000000
--- a/plans/archive/260427-0907-ui-ux-improvements/plan.md
+++ /dev/null
@@ -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.
diff --git a/plans/archive/260427-1036-three-mode-and-master-auto-tick/phase-01-three-mode-settings.md b/plans/archive/260427-1036-three-mode-and-master-auto-tick/phase-01-three-mode-settings.md
deleted file mode 100644
index bfa1f28..0000000
--- a/plans/archive/260427-1036-three-mode-and-master-auto-tick/phase-01-three-mode-settings.md
+++ /dev/null
@@ -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
-
-```
-
-```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 `
` with:
-
-```svelte
-{#if settings.mode !== "master"}
-
-{/if}
-
-{#if settings.mode !== "player"}
-
-{/if}
-```
-
-Keep the section heading only when there's a player board above it
-(otherwise it's redundant). Conditional:
-
-```svelte
-{#if settings.mode === "both"}
-
Quản trò
-{/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 `
`. Will visually
- test in `master` mode where it's the only thing rendering.
diff --git a/plans/archive/260427-1036-three-mode-and-master-auto-tick/phase-02-master-broadcast-and-auto-tick.md b/plans/archive/260427-1036-three-mode-and-master-auto-tick/phase-02-master-broadcast-and-auto-tick.md
deleted file mode 100644
index 985afa5..0000000
--- a/plans/archive/260427-1036-three-mode-and-master-auto-tick/phase-02-master-broadcast-and-auto-tick.md
+++ /dev/null
@@ -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.
diff --git a/plans/archive/260427-1036-three-mode-and-master-auto-tick/phase-03-master-cho-kinh-announcements.md b/plans/archive/260427-1036-three-mode-and-master-auto-tick/phase-03-master-cho-kinh-announcements.md
deleted file mode 100644
index f032cc1..0000000
--- a/plans/archive/260427-1036-three-mode-and-master-auto-tick/phase-03-master-cho-kinh-announcements.md
+++ /dev/null
@@ -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
-
- Đọc số đã xổ + báo Chờ/Kinh khi ở "Cả hai".
-
-```
-
-(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.
diff --git a/plans/archive/260427-1036-three-mode-and-master-auto-tick/plan.md b/plans/archive/260427-1036-three-mode-and-master-auto-tick/plan.md
deleted file mode 100644
index 69e7fe8..0000000
--- a/plans/archive/260427-1036-three-mode-and-master-auto-tick/plan.md
+++ /dev/null
@@ -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()`.
diff --git a/plans/archive/260427-1930-ui-polish-and-pwa-v2/phase-01-vietnamese-font-and-master-empty.md b/plans/archive/260427-1930-ui-polish-and-pwa-v2/phase-01-vietnamese-font-and-master-empty.md
deleted file mode 100644
index cac69f3..0000000
--- a/plans/archive/260427-1930-ui-polish-and-pwa-v2/phase-01-vietnamese-font-and-master-empty.md
+++ /dev/null
@@ -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 `` 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
-
-
-
-
-
- {#each Array(99) as _, i (i)}
- {@const filled = (i % 11) < 9 && i % 7 === 0}
-
- {filled ? ((i * 13) % 90) + 1 : ""}
-
- {/each}
-
-
-
- Chế độ Quản trò
-
-
- Nhấn
-
- "Ván mới"
-
- để bắt đầu xổ số
-
-
- 🎤 Đã sẵn sàng
-
-
-```
-
-Replace the current `{:else} Nhấn "Ván mới" ...
` 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.
diff --git a/plans/archive/260427-1930-ui-polish-and-pwa-v2/phase-02-mode-picker-color-picker-brand.md b/plans/archive/260427-1930-ui-polish-and-pwa-v2/phase-02-mode-picker-color-picker-brand.md
deleted file mode 100644
index e0726a6..0000000
--- a/plans/archive/260427-1930-ui-polish-and-pwa-v2/phase-02-mode-picker-color-picker-brand.md
+++ /dev/null
@@ -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"}
-
-
- {:else if v === "master"}
-
-
- {:else}
-
-
- {/if}
-{/snippet}
-```
-
-Inside the existing `{#each MODES}` loop:
-
-```svelte
-
-```
-
-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
-
-```
-
-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
-
-```
-
-Rename "Mặc định" → "Đặt lại" (clearer = "reset" not "default").
-
-## Header subline — fairground mood
-
-Current:
-
-```svelte
-
- Hội chợ TN1
-
-```
-
-New: drop the heavy uppercase tracking; flank with hairline dashes;
-add a tiny lantern emoji to anchor mood. Keep `text-sm`, drop italic.
-
-```svelte
-
-
- 🏮 Hội chợ TN1
-
-
-```
-
-## 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.
diff --git a/plans/archive/260427-1930-ui-polish-and-pwa-v2/phase-03-pwa-installable-offline.md b/plans/archive/260427-1930-ui-polish-and-pwa-v2/phase-03-pwa-installable-offline.md
deleted file mode 100644
index 6d48a29..0000000
--- a/plans/archive/260427-1930-ui-polish-and-pwa-v2/phase-03-pwa-installable-offline.md
+++ /dev/null
@@ -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` — ``, ``,
- 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
-
-
-
-
-
-
-```
-
-## 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)
diff --git a/plans/archive/260427-1930-ui-polish-and-pwa-v2/plan.md b/plans/archive/260427-1930-ui-polish-and-pwa-v2/plan.md
deleted file mode 100644
index 7f387a7..0000000
--- a/plans/archive/260427-1930-ui-polish-and-pwa-v2/plan.md
+++ /dev/null
@@ -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.
diff --git a/plans/todo.md b/plans/todo.md
index a18d995..477b49f 100644
--- a/plans/todo.md
+++ b/plans/todo.md
@@ -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
`` 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