mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-15 02:17:11 +00:00
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:
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)
|
||||
Reference in new issue
Block a user