Commit Graph
92 Commits
Author SHA1 Message Date
tiennm99 ef0cd82e78 fix(settings): hide "Báo Chờ / Kinh" toggle in master mode
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.
2026-04-30 21:53:25 +07:00
tiennm99 22a117f012 revert(player): bring back the gradient cross-slash style
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
2026-04-30 21:48:50 +07:00
tiennm99 d5d72136a3 chore(plans): add waiting-cell research + brainstorm reports 2026-04-30 21:41:50 +07:00
tiennm99 c4e103cd58 feat(player): center "Chờ N" chip + pulse the awaited cell
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.
2026-04-30 21:41:39 +07:00
tiennm99 9d2699cf96 refactor(active-tab): rename tab-lock to active-tab for clarity
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.
2026-04-30 21:34:06 +07:00
tiennm99 8b30de2af9 fix(tab-lock): formalize banner copy
Drop casual loanword 'Tap', use neutral formal Vietnamese for the freeze banner shown to all users (not just internal team).
2026-04-30 21:25:30 +07:00
tiennm99 8b104b70d6 chore(plans): add voice + multi-tab brainstorm report 2026-04-30 21:22:31 +07:00
tiennm99 a944170649 feat(tab-lock): single-tab guard via BroadcastChannel
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.
2026-04-30 21:22:23 +07:00
tiennm99 79f3a819d8 fix(voice): suppress "Chờ {số}" in both mode
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.
2026-04-30 21:22:18 +07:00
tiennm99 b94f735ea7 chore(plans): add both-mode consistency plan + audit reports 2026-04-30 21:10:58 +07:00
tiennm99 761086358e refactor(state): replace call-bus with shared master-store for both-mode
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.
2026-04-30 21:10:51 +07:00
tiennm99 8e24d8047a fix(master): break effect_update_depth_exceeded loop in countdown
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).
2026-04-30 19:47:22 +07:00
tiennm99 05c96f24c3 chore(plans): add countdown indicator plan + review report 2026-04-30 19:32:09 +07:00
tiennm99 c730468d0a feat(master): add auto-call countdown indicator
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)
2026-04-30 19:32:02 +07:00
tiennm99 b0a467a7e8 chore: add Apache-2.0 license 2026-04-29 21:33:25 +07:00
tiennm99 2fb35f229a refactor(ui): drop gradients for clean solid-color palette
Single-accent system: rose-600 (player + UI), amber-600 (host),
emerald-600 (draw), sky-600 (master low-half). Replaces every text
gradient, button gradient, radial bg-glow, hatch-stripe divider, and
the diagonal cell slash (now an SVG <line> with non-scaling stroke).
Updates design-guidelines.md to match.
2026-04-29 14:53:08 +07:00
tiennm99 bfe540f537 chore(plans): delete shipped plan folders, inline residual TODOs
All implemented plan folders removed (current backlog + 8 archived).
PWA verification checklist preserved inline in todo.md as the only
remaining post-deploy work.
2026-04-28 14:11:22 +07:00
tiennm99 c4ce30a86d chore(plans): refresh todo.md after 260428-0927 plan ships
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.
2026-04-28 11:43:43 +07:00
tiennm99 9f24b6da7d chore(pwa): tighten audio cache eviction (30d → 7d, purgeOnQuotaError)
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.
2026-04-28 11:10:51 +07:00
tiennm99 7c893aa3b5 ci(csp): replace 'unsafe-inline' with sha256 hash at build time
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.
2026-04-28 11:09:32 +07:00
tiennm99 a60ea08f37 feat(player-board): persistent Chờ ring + livelier confetti
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.
2026-04-28 11:05:43 +07:00
tiennm99 2b74b2e2e9 feat(settings): clearer "both" glyph + sticky title/footer on mobile
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.
2026-04-28 11:00:23 +07:00
tiennm99 558d0c75b2 ci: add inline-script guard for built index.html
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.
2026-04-28 10:55:43 +07:00
tiennm99 99c7a900a8 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.
2026-04-28 10:03:54 +07:00
tiennm99 3e6cb90a08 refactor(player-board): extract auto-tick into pure helper + tests
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.
2026-04-28 10:03:47 +07:00
tiennm99 00fbc97a55 chore(plans): archive completed plans + add next-session todo
- Move all 8 completed plans (260426-* and 260427-*) into
  plans/archive/ to keep the active plans/ dir uncluttered.
- Add plans/todo.md as a hand-off list for next session: highest
  leverage items first (auto-tick integration test, CI inline-script
  smoke check, PWA install verification), then UX polish queued from
  pass-2 reviews, tech-debt items, and a parking-lot of new features.
2026-04-27 21:13:33 +07:00
tiennm99 ee71bf041d fix: address pass-2 review findings (PWA + CSP + copy)
P0:
- CSP `script-src` was 'self' only, but SvelteKit's static export
  emits a small inline bootstrap script. Without 'unsafe-inline' the
  entire app silently fails under Cloudflare Pages CSP enforcement.
  Verified by inspecting the built index.html.
- manifest `background_color` was the dark base (#0a0f1f); for the
  ~50% of users on light mode that gave a dark splash flash on every
  install/launch. Switch to #f8fafc to match the default light theme.
- <title> bare "Lô tô" mismatched manifest name "Lô tô — Hội chợ TN1";
  align both to the same string so OS install prompt + browser tab
  match.

Medium:
- Audio runtime cache `cacheableResponse.statuses` was [0, 200].
  Audio is same-origin, so opaque (0) responses can never legitimately
  appear; tightening to [200] removes a CDN-poisoning replay window.
- Voice hint copy: "Đọc số đã xổ + báo Chờ/Kinh khi ở Cả hai" was
  shown in master-only mode too, where the hint is wrong (no player
  board → no Chờ/Kinh). Split copy per mode.

Cosmetic:
- Drop `includeAssets: ["icons/*.png", "audio/**/*.mp3"]` — both are
  already in static/, so the option was a no-op.
- Replace `defaultVoiceId` fallback `"hoai-my"` with a hard read; the
  manifest is committed and authoritative — duplicate fallbacks just
  invite drift if the manifest ever rotates.

Verified: npm test 115/115; npm run build clean (305 precache entries,
no glob warnings); npm audit 0 vulnerabilities.

Reports: plans/reports/{code-reviewer,ui-ux-designer,security}-260427-2047-pass2-full.md
2026-04-27 20:53:53 +07:00
tiennm99 d94294d83b chore(deps): override transitive vulns — serialize-javascript + cookie
`@vite-pwa/sveltekit` pulled in `serialize-javascript@6.0.2` via
workbox-build → @rollup/plugin-terser, which is flagged for High RCE
(GHSA-5c6j-r48x-rmvq) and Medium DoS (GHSA-qj8w-gfj5-8c6v). Both fix
to >=7.0.5.

Also opportunistically bump `cookie` past 0.7.0 (Low GHSA-pxg6-pf52-xh8x)
via @sveltejs/kit — was the last-remaining Dependabot Low.

Build-time tooling only — neither package ships to the browser. Pinned
via npm `overrides` rather than `audit fix --force` because the latter
would downgrade @vite-pwa/sveltekit to 0.0.1 (breaking).

npm audit: 0 vulnerabilities. Tests 115/115. Build clean.
2026-04-27 20:43:32 +07:00
tiennm99 f7db20c13a feat: UI polish v2 + installable PWA with offline audio
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.
2026-04-27 20:35:59 +07:00
tiennm99 ad6291e924 fix: address full-project review findings (P0/P1/P2 + security)
P0:
- PlayerBoard auto-tick: track lastDrawn.at non-reactively so the
  effect re-firing on crossed/grid changes (manual untick, clear,
  regen) no longer re-marks the latest drawn number.
- Toast moved above the grid — was overlaying middle cells for 5s.
- Bingo modal Escape now uses a window listener (matches settings
  modal pattern); the inline onkeydown rarely fired.
- Dark winning-row contrast: bg-emerald-900/60 + text-emerald-200
  to clear WCAG AA. New .cell-crossed-win class flips the slash to
  emerald so completed rows read as "win" not "marked off".
- master-only mode: keep "Quản trò" h2 visible so the page has
  context when no player board sits above.

P1:
- Reset bus on PlayerBoard handleClear/handleGenerate.
- Master mode picker now shows a per-mode hint line.
- Hide "Quản trò đọc số" toggle entirely in mode=player (no effect
  there). Indent auto-call slider with the same nested-border
  treatment as voice waiting.
- Hero number scales smaller on ≤375px (w-32 / border-6).
- aria-live=polite on hero (was assertive — informational not urgent).
- MasterPanel auto-stop early-return without redundant write.
- Drop in-card "Made by miti99" — duplicates the page footer.
- Replace biased Math.random sort shuffle with Fisher-Yates in
  randomNumbersInCol.
- Reduced-motion gates: vibrate, smooth scroll, all keyframe
  animations short-circuit when prefers-reduced-motion: reduce.
- History pill border-2 (was border-3, cramped 88s).

Security:
- static/_headers: CSP, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy, X-Frame-Options for Cloudflare Pages.
- safeParse / loadSettings / MasterPanel.loadState: payload-size cap
  before JSON.parse + reviver strips __proto__ and constructor keys
  as defense-in-depth.
- voice.clipUrl: encodeURIComponent on voice id and clip name.

Tests: +findUncrossedCell unit tests (covers the auto-tick path
that exposed the P0), +voice.js cancellation/chaining tests
(playWaiting honours voiceWaitingNumber). 115/115 pass.

Skipped (need new assets / deeper scope):
- Vietnamese-supporting condensed @font-face (needs font upload).
- Full master empty-state hero illustration.
- Confetti emoji variety, color picker redesign, mode picker icons,
  brand-mood overhaul of the header subline.
2026-04-27 18:57:14 +07:00
tiennm99 c9b6c52bc0 feat(ui): readability polish and voice settings refinement
- Improve section accent/divider color theming in app.css
- Enhance dark mode glow and body→html background layering
- Increase footer text size for better mobile legibility (text-xs → text-sm)
- Gate voiceWaitingNumber announcements behind new setting flag
2026-04-27 10:51:12 +07:00
tiennm99 f28279b663 feat: three-mode display (player/master/both) with master auto-tick
- 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
2026-04-27 10:51:08 +07:00
tiennm99 fba3e9183b fix(ui): four small bugs surfaced after the UI/UX merge
1. Settings gear was unclickable because H1's `drop-shadow` filter
   created a stacking context that painted the H1 over the absolutely-
   positioned trigger. Bump gear wrapper to z-10.
   (src/routes/+page.svelte)

2. Switch rows in settings had a dead zone — `<label>` wrapped a
   `<span role=switch>` (not a labelable element), so taps on the
   text fell through. Move role/click/keydown to the wrapping div
   so the whole row toggles. Pill stays decorative.
   (src/lib/SettingsButton.svelte)

3. Empty cells used `dark:[filter:brightness(...)]` which creates a
   per-cell stacking context — same hazard class as bug #1. Replace
   with a pointer-events-none overlay span (`hidden dark:block`).
   (src/lib/PlayerBoard.svelte)

4. Audio cache never evicted on voice swap, leaking ~92 Audio
   elements per change over a session. New `clearAudioCache()` in
   voice.js, called from `pickVoice` when the id actually changes.
   (src/lib/voice.js, src/lib/SettingsButton.svelte)

Tests 98/98 pass; build clean.
2026-04-27 09:51:49 +07:00
tiennm99 d77d4a5652 feat(ui): mobile legibility, brand marquee, master focal, dark polish
Six small UI/UX phases shipped together:

1. Mobile legibility — cell aspect 3:4 on mobile (was 1:1), text-lg
   number, active:scale-90 press, navigator.vibrate(10) on tap,
   200ms cross-draw clip-path animation on cell mark.
2. Brand — H1 italic rose-amber gradient with drop-shadow + "Hội chợ
   Tân Tân" subtitle. First-run state shows a faded preview card +
   warm welcome line instead of a gray placeholder.
3. Master focal point — "Số vừa xổ" hero scaled w-40/56 with
   text-7xl/8xl, role=status aria-live=assertive, scrollIntoView on
   each user-driven draw (gated by scrollOnNextDraw flag so reload
   doesn't yank the page). Master section uses transition:slide.
4. Settings polish — modal max-w-md on sm+; switchRow snippet
   replaces 4 boolean buttons with role=switch UI (focus ring,
   keyboard space/enter); empty cells get
   dark:[filter:brightness(0.85)_saturate(0.9)] so user's chosen
   purple isn't neon in dark mode.
5. Celebration tiering — 3rd+ bingo per card triggers a 12-emoji
   CSS confetti rain at z-[60] above the popup.
6. Docs sync — codebase-summary + pdr.

Reviewer fixes applied: confetti z-index above popup; scroll-on-load
guarded by interaction flag; switch focus ring.

Tests 98/98 pass; build clean.
2026-04-27 09:38:18 +07:00
tiennm99 0b6c41faad chore(audio): generate Vietnamese clips for both voices
- Run scripts/generate-audio.py to produce 184 MP3 clips (92 each
  for vi-VN-HoaiMyNeural and vi-VN-NamMinhNeural), ~2.2 MB total.
- Cap edge-tts concurrency at 4 with 4-attempt exponential retry on
  NoAudioReceived — earlier all-at-once gather() hit the upstream
  rate limit and bailed mid-voice.
- .gitignore: add .venv/ + __pycache__/ for the local generator venv.
2026-04-27 09:11:13 +07:00
tiennm99 ad537ee4a6 feat(voice): bundled Vietnamese voice calls (master + player)
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).
2026-04-27 09:06:48 +07:00
tiennm99 fb0ef9f783 feat(card): avoid 3 consecutive filled columns in any row
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
2026-04-27 07:57:10 +07:00
tiennm99 3c5fa5a0f2 feat(player): add "Xoá đánh dấu" button to clear marks without regen
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
2026-04-27 07:52:05 +07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bee22ee8d6 chore(deps-dev): bump postcss from 8.5.8 to 8.5.12 (#2)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.12.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.12)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 07:48:10 +07:00
tiennm99 0adc4777b0 chore: sync docs with single-page app, fix jsconfig
- docs: drop stale /master route refs (system-architecture page flow,
  client-only file list, basePath profiles; pdr architecture section
  + acceptance items; codebase-summary mounted-on notes; code-standards
  link example; deployment-guide redirect explanation)
- roadmap: drop "Currently Implemented Features" section (lives in git
  log + plans + pdr/codebase-summary)
- jsconfig: extend .svelte-kit/tsconfig.json (clears SvelteKit warning,
  picks up generated paths/aliases)
2026-04-27 07:44:19 +07:00
tiennm99 6d482354c1 fix(deploy): break the / -> / redirect loop on Cloudflare Pages
The previous rule "/* / 301" matched / itself even though docs say
static assets take precedence — empirically CF still bounced and
caused ERR_TOO_MANY_REDIRECTS on loto.miti99.com.

Replace with two safer rules:
  /master*  /             301   (true URL redirect for the known orphan)
  /*        /index.html   200   (SPA fallback — URL stays, homepage shows)

The 200 rewrite cannot create a redirect loop. Static assets at /,
/_app/*, /favicon.* are served before rules fire.
2026-04-27 01:35:51 +07:00
tiennm99 e058ff6636 refactor: single-page app — restore master tracking grid, drop /master route
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).
2026-04-27 01:31:26 +07:00
tiennm99 1a47435873 feat(ui): big bundle — settings (theme/master/auto), purple default, mobile fit, master extraction
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.
2026-04-27 01:26:21 +07:00
tiennm99 00c66c46ae style(player): move "Made with ❤️ by miti99" into the footer section label
The attribution lives inside the closing section-label band now (with
hatch flanks on both sides) instead of as a separate page footer.
Link to miti99.com kept clickable, with hover styling to differentiate
it from surrounding label text. Drops the duplicated standalone
<footer> from / and /master.
2026-04-27 00:50:10 +07:00
tiennm99 a064a6774a style(player): cross-hatch ✚✚✚ flanks around section label text
Each label now renders as: [hatch fill] [padding] [LABEL] [padding]
[hatch fill]. Flex layout with ::before/::after pseudo-elements
filling the remaining width. Matches the BAMBOORAFT physical card's
✚✚✚✚ TÂN TÂN ✚✚✚✚ band; 12px horizontal padding around the text
keeps the label readable.
2026-04-27 00:48:07 +07:00
tiennm99 6b8fad90e4 style(player): drop top/bottom hatch frames, add footer section label
Card now framed only on left + right by the cross-hatch. Top edge is
the first "Tân Tân" section label and the bottom edge gets a closing
"Tân Tân tốt nhất" label, mirroring the physical sheet's printed
header / footer slogans.
2026-04-27 00:47:03 +07:00
tiennm99 69820b636a style(player): drop cross-hatch dividers between sections
Section labels alone separate the 3 mini-cards now. The cross-hatch
frame stays only around the whole card perimeter — cleaner, less busy.
2026-04-27 00:44:30 +07:00
tiennm99 95bdcf0971 style(player): bring back per-cell borders so the grid actually reads
Each cell now has a thin slate border on all 4 sides (slate-400/50 in
light, slate-600/40 in dark). Adjacent cells double up to a slightly
heavier 1-2px line — matches the visible grid lines on the physical
BAMBOORAFT card.
2026-04-27 00:43:06 +07:00
tiennm99 47fcdef506 style(player): wrap card in cross-hatch frame on all 4 sides
The BAMBOORAFT physical sheet has the ✚✚✚ blue border running
around the entire card edge, not just between mini-cards. Adds
matching top/bottom/left/right frames using the existing
.section-divider plus a new .section-divider-vertical. Drops the
thin slate outer border (replaced by the hatch frame) and the
per-cell border-r/border-b lines (the white-on-blue contrast already
delineates cells, matching the flat physical-paper look). Outer
wrapper rounded-md instead of rounded-2xl to feel more like
a printed card edge.
2026-04-27 00:41:18 +07:00
tiennm99 baccbf2dbd style(master): number text matches ring color
All three places where called numbers appear (hero, tracking grid
token, history list) — digit color now follows the ring: pink for
1–49, emerald for 50–90. Drops the rose-700 fallback that was
mismatched against both rings.
2026-04-27 00:39:02 +07:00