Apply the policy stated in plans/todo.md ("all prior plan folders
have been deleted") that had drifted: remove three completed plan
folders (auto-call-countdown, both-mode-state-consistency,
switch-deploy-to-github-pages) and the reports tied to them.
Also remove pre-shipping audits that referenced the now-deleted
Cloudflare _headers / CSP setup, plus the just-actioned
cloudflare-legacy cleanup audit. Keep the evergreen Lô Tô rules
researcher report.
Refresh todo.md hand-off header to reflect the GitHub Pages target.
- Remove vietnamese-number.js + test: build-time Python script
(generate-audio.py) is the sole spec for pre-baked MP3s; runtime
no longer reads the JS module.
- Remove resetMaster() export: only ever called from tests; inline
the two-line body at test sites.
- Update Python docstring to drop the JS-mirror reference.
- Rewrite stale "CF Pages root" comment in svelte.config.js.
Migrate build and deployment pipeline from Cloudflare Pages to GitHub Pages.
Adds production-ready deploy workflow in deploy-github-pages.yml with proper
artifact handling. Removes Cloudflare-specific tooling: wrangler config, _headers,
_redirects, and CSP hash injection scripts (no longer needed with GitHub Pages
static hosting). Updates package.json build scripts and all project documentation
to reflect new deployment target and simplified architecture.
Master-only mode never mounts PlayerBoard, so no Chờ/Kinh detection
fires — the toggle does nothing. Hide both it and its child
"Đọc thêm số đang chờ" sub-toggle in master mode to avoid the dead
control. Visible in player and both modes as before.
The SVG-line replacement from the gradient-cleanup refactor (commit
2fb35f2) drew the slash on the main diagonal (top-left → bottom-right)
and stopped at the inset:6px box edge — visually disconnected from the
cell vertices. The original CSS gradient stripe goes corner-to-corner
of the inset box on the anti-diagonal (the natural slash direction
for Vietnamese lô tô), with a clip-path reveal animation that reads
as "drawing" the slash.
- Restore .cell-crossed / .cell-crossed-win pseudo classes
- Replace the inline <svg> markup with a single <span> + class
- Restore cross-draw keyframes (clip-path inset reveal)
- prefers-reduced-motion: keep slash drawn, drop the reveal animation
The toast above the card was disconnected from where the user actually
looks (the 9×9 grid). Two-part redesign per /ck:research + /ck:brainstorm:
- Chip moved to the vertical center of the card with bg-amber-500/75 +
backdrop-blur-sm + pointer-events-none on the wrapper. Taps still
pass through to the cells underneath; auto-hide stays at 5s.
- Persistent 1.6s breathing pulse on the cell holding the awaited
number — amber inset ring + soft outer glow, dimmed in
prefers-reduced-motion to a static ring. Material 3-style cadence
picked over scale-bounce because the grid is dense and bouncing
cells would smudge neighbours.
Pulses scale to multiple simultaneous waiting rows (Set-keyed by
"row,col"). aria-label gains "đang chờ" so screen readers pick it up.
The "lock" framing was misleading — there's no OS-level mutex; this is
a soft coordinator that decides which tab is the active one. Rename
makes the role obvious at the import site.
- file: tab-lock.svelte.js → active-tab.svelte.js (+ test)
- export: tabLock → activeTab
- field: .frozen → .inactive (positive form: this tab is inactive)
- fn: startTabLock → watchActiveTab
- fn: reclaimTab → claimActiveTab
- BroadcastChannel name: loto_tab_lock → loto_active_tab
No behavior change. Banner copy already updated separately.
Two tabs of the app on the same origin both running auto-call would
double-draw, double-write `loto_master`, and overlap audio. New tab
now broadcasts a claim; old tab freezes itself with a fullscreen
overlay ("Loto đã mở ở tab khác. Tap để chuyển về tab này.") and the
user can tap to take it back — handover broadcasts a fresh claim,
freezing the other tab in turn.
Mounted in +layout.svelte's onMount so the cleanup closes the channel
on HMR / route changes. No-op in legacy iOS Safari (≤15.4) without
BroadcastChannel — silently falls through to the prior behavior.
Master is the audio owner in both mode and is already calling numbers
aloud, so the player-side "Chờ N" announcement that fires right after
a master call confused listeners — they couldn't tell which number was
the active draw vs the awaited row.
- voice.js playWaiting: gate `speakNumber` on `mode !== "both"`
- SettingsButton: hide the "Đọc thêm số đang chờ" toggle in both mode
The bare-word "Chờ" still plays so the host knows a row is one cell
away. Setting persists across mode toggles — the gate is at the call
site, not in storage.
Single-slot bus carried only the latest draw, so any state event off-bus
(player regen, master "Ván mới", reload, mode toggle, throttled tab)
silently lost history. Symptom the host hit: regenerating the player
board mid-game wiped all prior auto-crosses.
- master-store.svelte.js: lifted {called, remaining} out of MasterPanel
into shared reactive $state, persisted to loto_master, hydrated once
in +layout.svelte's onMount so panels mount with consistent state
- player-auto-cross.js: new applyMasterCalls helper using cursor-by-index
(vs the retired Date.now-based at timestamp), so callers can pass
lastHandledIndex: 0 to replay master's full history on demand
- PlayerBoard:
- Reads masterState.called directly; cursor advances strictly
- manualUnticks Set tracks user-initiated unticks of called numbers,
suppressing re-cross on replay; persisted to loto_manualUnticks
- "Tạo bảng mới" replays masterState.called onto fresh grid (in both)
- "Xoá đánh dấu" clears + immediately replays in both mode
- Master "Ván mới" detected by called length transitioning >0 → 0,
force-clears player crossed + manualUnticks (locked product call)
- Killed call-bus.svelte.js + auto-tick.js and their tests; helper
surface is fully covered by master-store.test.js (9) and
player-auto-cross.test.js (10), plus 6 new manualUnticks cases in
game-logic.test.js (134 tests passing, was 123 before this refactor)
Targets findings F1, F2, F4, F6, F7, F8, F10 from the 2026-04-30
both-mode consistency audit. F9 (voice ownership) and #20 (multi-tab)
remain out of scope — separate plans to follow.
Two $effects were reading state they also wrote, turning each into its
own dependency:
- AutoCountdown reset effect: `now = tickStart` read tickStart after
writing it. Use a local snapshot so neither rune is read post-write.
- MasterPanel auto-call effect: `tickCount++` read tickCount. Drop the
bump here — AutoCountdown's reset effect already re-baselines on the
rising edge of `running` and on `duration` change, so re-arms remain
covered. handleDrawNext keeps its per-draw bump (not in effect scope).
Visible countdown ring + seconds number above the hero token while
auto-call runs. Host now sees exactly when the next number fires
instead of staring at a static caption.
- AutoCountdown.svelte: pure visual component, props-driven, rAF loop,
SVG ring via stroke-dashoffset, prefers-reduced-motion fallback
- MasterPanel.svelte: tickCount $state bumped per draw + on every
(re-)arm of the auto-call $effect (covers speed-slider mid-run)
All implemented plan folders removed (current backlog + 8 archived).
PWA verification checklist preserved inline in todo.md as the only
remaining post-deploy work.
8 of 9 phases shipped (only manual PWA verification remains). Rewrite
the hand-off list to reflect current state: residual UX polish,
upstream-blocked tech debt, parking-lot features, and a "Recently
shipped" tail listing the 8 phases delivered today.
Workbox's CacheFirst rule for /audio/*.mp3 used a 30-day age-based
TTL. Approximate LRU by dropping to 7d — a voice clip stays cached as
long as it's played at least once a week, otherwise it falls out and
re-fetches on next play. Each clip is <200KB and same-origin so the
re-fetch cost is negligible. Also flip purgeOnQuotaError so the
runtime cache yields first under storage pressure.
Default voice precache (vite.config.js additionalManifestEntries)
unaffected — it's a separate workbox flow with its own revision.
Postbuild script computes SHA-256 of every inline <script> in
build/index.html and rewrites build/_headers — replacing the
script-src 'unsafe-inline' relaxation with the matching hashes. The
hash regenerates per build (SvelteKit bootstrap embeds a per-build
registration call) so the script must run on every build; chain it
into both `npm run build` and `build:gh`.
verify-build extended to assert build/_headers script-src no longer
contains 'unsafe-inline', so the inject step's output is enforced in
CI. style-src 'unsafe-inline' stays — Svelte's `style:` directives
emit inline attributes that hashes can't cover.
Three player-board polish tweaks:
1. Per-section Chờ indicator. Each section's label band glows amber
while any of its 3 rows is one cell from bingo. Uses the same amber
as the toast so colour stays consistent. Animation respects
prefers-reduced-motion.
2. Confetti threshold drops from 3+ bingos to: 2nd bingo OR 1st bingo
while another row is in Chờ. The old threshold rarely fired on a
9-row card so most wins felt under-celebrated.
3. Confetti emoji set adds 🥢🎋🏮 (chopsticks, bamboo, lantern) for
hội-chợ flavour, plus per-piece size jitter (1.5–2.4rem) via a
--size CSS variable.
Two small UX fixes in the settings modal:
1. Mode picker "Cả hai" glyph was two stacked rectangles — read as
"windows" rather than the two roles being combined. Replace with a
mini player-grid + mini megaphone side-by-side so the glyph mirrors
the two single-mode glyphs concretely.
2. On iPhone SE the modal body pushes the title and the action row off
screen, so the close button needs scrolling-back to reach. Make the
title block sticky at top and the action row sticky at bottom
inside the existing scroll container; move the panel padding from
the outer wrapper into per-section padding so the sticky bands can
span edge-to-edge with matching backgrounds.