chore(plans): add todo-backlog implementation plan

9-phase plan covering the highest-leverage items from `plans/todo.md`
plus tech-debt and UX polish. YAGNI cuts: GhostBoardPreview (rule of
three not met), upstream-blocked override removals, parking-lot
features. Phase 1 (auto-tick test) marked completed.
This commit is contained in:
tiennm99 committed 2026-04-28 10:03:54 +07:00
1 parent e745631ea7
commit 36142de6c5
10 files changed
+919

No files matched your search

@@ -0,0 +1,92 @@
---
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).
@@ -0,0 +1,96 @@
---
name: CI inline-script guard
phase: 2
status: todo
priority: high
effort: 30m
---
# 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 `[^>]*`.
@@ -0,0 +1,68 @@
---
name: Mode picker glyph redesign
phase: 3
status: todo
priority: medium
effort: 45m
---
# 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.
@@ -0,0 +1,80 @@
---
name: Settings modal sticky on small screens
phase: 4
status: todo
priority: medium
effort: 30m
---
# 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.
@@ -0,0 +1,130 @@
---
name: Per-row "Chờ" indicator
phase: 5
status: todo
priority: medium
effort: 1h
---
# 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)
: []
);
```
@@ -0,0 +1,99 @@
---
name: Confetti polish
phase: 6
status: todo
priority: low
effort: 30m
---
# 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.
@@ -0,0 +1,128 @@
---
name: Strict CSP via hashed inline script
phase: 7
status: todo
priority: medium
effort: 1.5h
---
# 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.
@@ -0,0 +1,85 @@
---
name: Audio cache LRU rule
phase: 8
status: todo
priority: low
effort: 30m
---
# 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.
@@ -0,0 +1,92 @@
---
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.
@@ -0,0 +1,49 @@
---
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)