Several behaviours diverged between the web build and the Capacitor
wrapper, none of them reproducible on a desktop browser.
- Declare VIBRATE. PlayerBoard calls navigator.vibrate() on every cell
tap; a WebView app must hold the permission itself, where a browser
holds it on the page's behalf. Taps were silent in the APK.
- Hold a screen wake lock while numbers remain to be called. Auto-call
advances on a timer with no touch input, so a round could run 15
minutes untouched — long enough for the display to sleep and the
WebView to throttle the interval. Android drops the lock whenever the
page hides, so wake-lock.js re-acquires on visibilitychange.
- Handle back through OnBackPressedCallback. Android 16 (targetSdk 36)
no longer calls onBackPressed() nor dispatches KEYCODE_BACK. Each open
overlay pushes one history entry, so "the WebView can go back" means
"an overlay is open": back closes the bingo modal or settings sheet,
and only at the root does it confirm before quitting. Browsers get the
overlay behaviour too.
- Inset content by env(safe-area-inset-*) with viewport-fit=cover.
targetSdk 36 forces edge-to-edge, so the WebView painted under the
status bar and gesture nav. Also fixes the iOS PWA notch.
- Pin the WebView's textZoom to 100 and add a "Cỡ chữ bảng" setting.
The player card is a fixed 9-column grid that clips at large system
font scales. Pinning the zoom overrides an accessibility control, so
the app now owns an equivalent one; the two belong together.
- Point the volume rocker at the media stream, so it works before the
first clip plays.
strings.xml also gains the exit-dialog strings and picks up the
diacritics in app_name.
Both subprojects ship from the same commit, so ordinary CI is now a single
ci.yml; only the tag-driven release stands apart. The web app is built twice
per run — once per base path — and every consumer downloads the artifact
instead of rebuilding, replacing three redundant base-"" builds on main.
Nothing deploys unless the test job is green, and android-release runs the
suite before signing (ci.yml does not fire on tags, so it was the only gap).
Shared toolchain setup moves into composite actions, which puts the web build
and the APK on the same Node version for the first time. The Firebase PR path
was still on npm ci against a stale web/package-lock.json that could resolve a
different tree than pnpm-lock.yaml; drop the lockfile and the npm path with it.
Also: least-privilege permissions widened per job, persist-credentials off on
every checkout, concurrency groups that cancel superseded PRs but never a live
deploy, npm caching for android, and the Firebase action pinned by commit SHA
to match how the release actions were already pinned.
Adds a root README covering both subprojects and the CI matrix, folds the two
identical Apache-2.0 copies into a single root LICENSE, and updates the web
docs that named workflow files by their pre-move paths.
GitHub only runs workflows from .github/workflows at the root, so the six
workflows inherited from the two projects have to live there rather than under
web/ and android/. Each one gets a path filter so web-only changes do not
trigger Android builds, a working directory for its subproject, and a prefixed
filename to keep the two sets apart.
Gradle now sits at android/android, the Firebase action reads its config from
web/ via entryPoint, and the Android checkout no longer needs submodules.
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
635a4c3) 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).