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.
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.
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)
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.
SvelteKit emits one inline bootstrap <script> in build/index.html and
the CSP in static/_headers is relaxed to `script-src 'unsafe-inline'`
to admit it. If a SvelteKit upgrade adds another inline block, the
relaxation no longer matches reality and the new block could ship
unhashed.
`npm run verify:build` reads build/index.html, counts inline scripts
(no `src=`), and fails when count > EXPECTED_INLINE (1). New GH
Actions workflow runs test + build + verify on push/PR to main.
Mutation-tested locally: setting EXPECTED_INLINE=0 fails as expected,
restored to 1 passes.
Pull the bus-driven auto-tick effect body out of PlayerBoard.svelte
into `src/lib/auto-tick.js` so the dedup-by-`at` invariant — the one
that already caught a P0 — is unit-testable without mounting Svelte.
The effect is now a thin wrapper that calls `processAutoTick()` and
applies the returned `{crossed, lastHandledAt, changed}`.
8 vitest cases cover NEW draw, dedup on same `at`, re-cross after
manual untick, mode=master/player ignored (timestamp still advances),
off-board number, null lastDraw, and null grid.
Phase 1 — Vietnamese-safe font + master empty state:
- Add @fontsource/roboto-condensed (700 weight, all subsets including
Vietnamese). font-display:swap. tan-tan-num now resolves the bundled
face on Android instead of system Arial Narrow.
- New MasterEmptyState.svelte: ghost 11×9 grid + "Chế độ Quản trò"
pill + readiness microcopy. Replaces the bare line of text in
MasterPanel's no-game state.
Phase 2 — Mode picker icons, color picker layout, header polish:
- Inline SVG glyphs above each mode button (player card / megaphone /
two stacked cards). Communicates role at a glance.
- Color picker wrapped in a single bordered card with "Tuỳ chỉnh"
and "Mẫu sẵn" sub-headers.
- "Mặc định" → "Đặt lại" with a bordered chip style — clearer
affordance than the previous near-invisible footer link.
- Header subline: drop tracking-[0.28em] all-caps SaaS look; replace
with dash-flanked lantern band ("— 🏮 Hội chợ TN1 —") for
fairground mood.
Phase 3 — Installable PWA + offline audio:
- @vite-pwa/sveltekit with autoUpdate. Precache app shell (~353 KB
→ 213 entries) PLUS the default voice's 92 clips so first-install
is fully offline-capable. Alternate voices fall through to a
CacheFirst runtime rule (cached on first play, 30-day TTL,
maxEntries: 400 for future-proofing).
- New static/manifest.webmanifest (Lô tô — Hội chợ TN1, theme #1565c0,
background #0a0f1f, standalone, vi).
- Icons: 192/512 standard + 512 maskable, generated from a single
rose-amber-gradient SVG source.
- app.html: manifest link, dual theme-color meta (light + dark),
apple-touch-icon, apple-mobile-web-app-capable for proper
standalone launch on iOS Safari.
- _headers: add manifest-src + worker-src to CSP; no-cache on /sw.js
and /manifest.webmanifest so deploys propagate.
Tests: 115/115 pass. Build clean (305 precache entries, 0 glob
warnings).
Reviewer concerns (addressed):
- maxEntries bumped 200 → 400 (was barely enough for 2 voices).
- Default voice precached so offline-first promise holds without
requiring users to play every clip online first.
- "do NOT add skipWaiting" comment added next to autoUpdate.
- Add `mode` setting to replace legacy `masterMode` with migration path
- Implement 3-button display mode picker (player-only, master-only, both)
- Auto-increment master when card drawn via call-bus event system
- Add voice hint on settings button, gate auto-call on mode changes
- Broadcast draw events from MasterPanel, reset call-bus on new game
- Broadened announce condition to cover both modes
- New call-bus.svelte.js module for event coordination
- Update settings-store tests to cover mode migration
- Update codebase docs for mode setting and call-bus architecture
Speak the called number on master draw, "Chờ N" when a row is one
away, and "Kinh" on bingo. No runtime TTS API — clips are
pre-generated by `scripts/generate-audio.py` (free edge-tts) and
shipped as static MP3s under `static/audio/{voiceId}/`.
- src/lib/vietnamese-number.js + test (40 cases): tonal exceptions
mười lăm / hai mươi mốt / hai mươi lăm
- src/lib/voice.js: lazy <audio> cache, token-based cancellation,
cho+number sequencer, on-unmount cleanup
- src/lib/audio-manifest.js: re-exports static/audio/manifest.json
- scripts/generate-audio.py: discovers every vi-* edge-tts voice,
writes 92 clips per voice + manifest.json
- static/audio/manifest.json: placeholder until user runs the script
- src/lib/settings-store: +voiceEnabledMaster/voiceEnabledPlayer/voice
with per-key validators
- src/lib/SettingsButton: new "Âm thanh" fieldset (toggles + voice
picker rendered from manifest)
- MasterPanel.handleDrawNext: playNumber(next) + cancel on new game
- PlayerBoard $effect: playWaiting/playBingo beside toast/popup;
cancel on regenerate / clear
To materialize the MP3s on first install:
pip install edge-tts
python3 scripts/generate-audio.py
Tests: 98 pass (40 number + 31 settings + 27 game-logic).
Soft visual constraint: no row has cols n, n+1, n+2 all filled.
Implementation = constraint-aware per-row picker (uniformly samples
triple-free completions of the forced+candidate set) + whole-grid
rejection sampling (up to 200 attempts). Hard invariants (5 per row,
5 per col, ascending column values) are never sacrificed; if the soft
constraint can't be met, the generator returns the best attempt.
- src/lib/game-logic.js: hasThreeInARow, combinations,
pickFilledColsOnce, pickFilledCols rejection wrapper
- src/lib/game-logic.test.js: 300-trial strict assertion
- docs/codebase-summary.md, project-overview-pdr.md: note the rule
Lets the player wipe all crossed cells on the current card without
generating a new grid. Shown next to "Tạo bảng mới" only when a grid
exists. Confirms before clearing if any cell is marked.
- src/lib/PlayerBoard.svelte: handleClear + secondary outline button
- docs/codebase-summary.md, project-overview-pdr.md: reflect new action
Previous commit misread "remove master board" — restore the 11x9
ones-digit-aligned tracking grid (with circular tokens + draw-order
overlay) inside MasterPanel; remove the host's own player card
("Bảng của quản trò") instead. The host can use the player board
above it; no need for a duplicate card.
Delete /master route entirely (single-page app now). Cloudflare
Pages redirect added via static/_redirects: any unknown path
301-redirects to /, so old /master bookmarks land on the homepage.
Static assets are served first so the rule only fires on misses.
Storage keys loto_master_card_grid / loto_master_card_crossed are
no longer written but old saved data stays in users' localStorage
(harmless leftover).
Settings expansion: 4 new keys (theme auto/light/dark, masterMode,
autoCallEnabled, autoCallSpeed 1-10) with per-key validators that
preserve old saved data. Default empty-cell color flipped from Tân
Tân blue to Excel Standard Purple #7030A0; preset palette swapped
to Office's 10 standard colors (5x2 grid). 15 new tests, 53 total.
Theme system: Tailwind v4 @variant dark (.dark *); applyTheme()
toggles <html class="dark"> based on settings.theme; auto mode
mirrors prefers-color-scheme via matchMedia listener (cleanly torn
down when switching modes). Existing dark-mode CSS converted from
@media to :where(.dark) selectors.
Mobile fit: PlayerBoard cells aspect-square on mobile, sm:aspect-[3/5]
desktop. Number text scales text-base sm:text-2xl md:text-3xl. Page
padding tightened (px-2 py-4 sm:px-3 sm:py-12). Container bumped
max-w-lg to max-w-2xl.
Header / footer: removed instructions toggle and "Trang quản trò"
link from player page. New PageFooter.svelte (tagline + Made by
miti99 with [SVG heart] link); also duplicated in PlayerBoard's
closing section-label band per request. Heart is inline SVG (red),
not emoji.
Master mode: extracted everything from /master route into reusable
MasterPanel.svelte. /master route slimmed to header + MasterPanel
+ footer. / mounts MasterPanel conditionally when settings.masterMode.
Storage prefixes unchanged.
Master tracking grid removed: per request, the 11x9 ones-digit
master board is gone. Host still gets controls, "Số vừa xổ" hero,
draw history list, and their own player card. Player card is enough.
Auto-call: single $effect lifecycle keyed on (autoRunning,
settings.autoCallSpeed, settings.autoCallEnabled). Setup / clear
setInterval cleanly across speed changes, master-mode toggle off,
component unmount, and "user disabled auto" mid-run. Button toggles
"Xổ số" -> "Bắt đầu / Dừng". Speed slider in Settings (only visible
when master mode is on). aria-label / aria-valuetext on slider.
Code-review nice-to-fixes applied: folded the two MasterPanel $effects
into one, removed muddled onkeydown on the SettingsButton modal
backdrop, added slider a11y attrs.
Docs: PDR / codebase-summary / system-architecture / development-roadmap
/ code-standards all synced to the new state.
Match the BAMBOORAFT physical sheet:
- Cell aspect changed from square to 3:5 (taller than wide), matching
the printed paper proportions
- Number cells rendered with .tan-tan-num: condensed system-font stack
(Arial Narrow / Avenir Next Condensed / Roboto Condensed), font-weight
900, negative letter-spacing for tight fall-back on platforms without
a true condensed face. Sized text-2xl / sm:text-3xl to fill the
taller cells. Black number on white cell.
- Default empty-cell color flipped from Minh Tân brown (#7a4a2b) to
BAMBOORAFT blue (#1e88e5). Settings store + test updated. Users who
picked a different color in settings keep their choice.
- Section dividers and labels recolored from orange to matching blue.
- Section labels relabelled to the BAMBOORAFT trio:
"Tân Tân" / "An khang thịnh vượng" / "Tân Tân tốt nhất"
Replaces the orange/red filled-square cells on the master tracking
board with a circular token treatment inspired by the Hội chợ "Các
số đã ra" panel. Each numbered cell now renders a centered circle
with a colored ring — pink for 1–49, green for 50–90 — over a
cream-yellow fill when the number has been called, or dimmed/empty
when it has not. The most recent draw keeps its red accent via a
ring-offset highlight + slight scale, so the host can still find
"vừa xổ" at a glance. Draw-order superscript stays at the cell's
top-right corner, now in muted slate (visible against both the
cream-filled and dim-empty token states).
Adds vitest + happy-dom as devDependencies and npm scripts test
(one-shot) and test:watch. No SvelteKit/vite reconfig needed —
vitest auto-picks up the existing vite plugin chain.
game-logic.test.js (26 tests): generateGrid shape invariants
(9x9, exact 5/row, exact 5/col, no duplicates) over 200 random
trials; per-column number ranges (col 0 = 1-9, col 8 = 80-90);
ascending-within-column rule; isRowComplete/getWaitingNumber edge
cases; full save/load roundtrip for the persistence layer
including corrupt-JSON and wrong-shape rejection.
settings-store.test.js (12 tests): defaults frozen, load with
valid/invalid/corrupt payloads, hex regex rejects shorthand,
save persists to localStorage and pushes CSS var, reset returns
to brown default. Uses happy-dom env for localStorage and
documentElement.
code-standards.md: documents the rune globals declaration and the
.svelte.js convention for rune-using non-component modules.
Adds a gear button + modal on /, /master with a native color picker
and 8 preset swatches for the empty-cell background. The selected
color repaints both the player card AND the master tracking grid via
the --empty-cell-bg CSS variable. Default brown matches the physical
Minh Tân paper card.
settings-store.svelte.js: Svelte 5 rune-based reactive store, state
hydrated on layout mount, persisted to localStorage key loto_settings.
Validates hex (#rrggbb regex) before applying — keeps the CSS sink
safe and ignores corrupt or shorthand-3-digit payloads.
SettingsButton.svelte: gear icon, modal with picker + presets + reset
+ close, escape-to-close via window keydown listener (the trigger
button retains focus on open, so a dialog-scoped handler would miss).
eslint.config.mjs: declares Svelte 5 rune globals so .svelte.js stores
lint clean.
Docs synced: PDR, codebase-summary, system-architecture, roadmap.
Replace the full build+publish flow with a tiny inline shell step that
emits two static HTML pages (out/index.html and out/master/index.html).
Each one carries:
<meta http-equiv="refresh" content="0; url=https://loto.miti99.com/">
<script>location.replace("https://loto.miti99.com" + ...)</script>
The script preserves path / query / hash, so
/loto/ → loto.miti99.com/
/loto/master → loto.miti99.com/master
/loto/?x=1 → loto.miti99.com/?x=1
Cloudflare Pages stays canonical; this change just stops GH Pages from
serving a stale duplicate of the app and points old links at the live
domain instead.
The build:gh npm script is kept as a manual escape hatch for the rare
case where someone wants to deploy a real GH Pages copy by hand.
.github/workflows/deploy-github-pages.yml runs `npm run build:gh` on push
to main and uploads build/ via actions/upload-pages-artifact@v3 +
actions/deploy-pages@v4.
The build:gh script sets BUILD_PROFILE=gh, which switches svelte.config.js
basePath to /loto so assets resolve under tiennm99.github.io/loto.
Cloudflare Pages keeps deploying in parallel via the CF dashboard
(npm run build, root basePath, loto.miti99.com). Two URLs, no conflict.
- Default `npm run build` now produces a root-relative build (CF Pages
custom domain at loto.miti99.com). The /loto basePath is opt-in via
`npm run build:gh` for the rare manual GH Pages export.
- Removed both GitHub Actions deploy workflows (.github/workflows/) and
the dangling `build:cf` script (it was identical to `build` after the
default flip).
- next.config.mjs: simplified basePath logic — only `BUILD_PROFILE=gh`
toggles a non-empty path; everything else (CF, local dev) is root.
Two named build scripts replace the prior CF_PAGES auto-detect:
npm run build:gh → BUILD_PROFILE=gh, basePath /loto
target: https://tiennm99.github.io/loto
npm run build:cf → BUILD_PROFILE=cf, basePath ""
target: https://loto.miti99.com (CF Pages custom domain)
Both deploy workflows now use the matching profile script. The legacy
`npm run build` keeps its prior behaviour (defaults to /loto basePath) so
nothing else in the toolchain breaks. NEXT_BASE_PATH still wins for any
one-off custom-domain build.
CF_PAGES auto-detection removed — explicit profiles are clearer than
relying on the host injecting a magic env var, and dashboard users should
just set the build command to `npm run build:cf`.
next.config.mjs now detects CF_PAGES=1 (auto-injected by Cloudflare during
build) and switches basePath from /loto to "" — assets resolve at the
project root on loto.pages.dev. GH Pages keeps /loto unchanged.
New workflow .github/workflows/deploy-cloudflare-pages.yml mirrors the GH
Pages flow but publishes via cloudflare/wrangler-action@v3. Requires repo
secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID; project name "loto"
is hardcoded — adjust --project-name= in the workflow if your CF Pages
project uses a different name.
Dashboard-based deploys also work without code changes thanks to the
CF_PAGES detection. See docs/deployment-guide.md for both paths.
After the JS+JSDoc conversion, some TS-flavored bits lingered. Removed:
- // @ts-check directives (TS-specific pragma)
- JSDoc annotations referencing TS-defined types: import('next').NextConfig,
React.MutableRefObject, React.Dispatch, React.SetStateAction
- jsconfig.json (TS-server-flavored config; only kept it for the @/* alias)
@/* imports replaced with relative paths so jsconfig is no longer needed.
Remaining JSDoc is plain @param / @returns — vanilla JS, no TS dependency.
Build, lint, dev profiles unchanged.
`app/` should hold route segments only. Game logic and the player-card
component are imported by both routes, so they belong outside:
app/loto-game-logic.ts -> lib/game-logic.ts
app/loto-player-board.tsx -> components/player-board.tsx
Imports use the existing @/* alias. Drops the redundant `loto-` prefix.
Also fixes the package name from the scaffold default.
Adds the standard ./docs/ structure (overview, codebase summary,
architecture, code standards, design guidelines, deployment guide,
roadmap) and the code-review report under ./plans/reports/.
README now points at the docs and covers the codeserver dev profile.