mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-11 12:19:05 +00:00
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:
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)
|
||||
-156
@@ -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).
|
||||
-227
@@ -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).
|
||||
-193
@@ -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.
|
||||
-194
@@ -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
|
||||
-124
@@ -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`.
|
||||
-135
@@ -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).
|
||||
-123
@@ -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).
|
||||
-89
@@ -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.
|
||||
-101
@@ -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.
|
||||
-133
@@ -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
|
||||
-176
@@ -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.
|
||||
-283
@@ -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.
|
||||
-92
@@ -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.
|
||||
-204
@@ -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.
|
||||
-171
@@ -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.
|
||||
-139
@@ -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.
|
||||
-140
@@ -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.
|
||||
-175
@@ -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.
|
||||
-152
@@ -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.
|
||||
-125
@@ -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()`.
|
||||
-152
@@ -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.
|
||||
-168
@@ -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.
|
||||
-216
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user