Fresh Kotlin 2.2 + Jetpack Compose app at feature parity with web/
(game logic, settings contract, voice semantics ported one-to-one with
twinned tests; 104 unit + 9 instrumentation tests). Fully offline:
VIBRATE is the only permission; Media3's ACCESS_NETWORK_STATE is
stripped. Voice clips mount from web/static/audio at build time.
Same applicationId, signing env contract, and Play alpha track;
versionCode 7 / 0.2.0. CI Android job is Gradle-only (lint, test,
debug + R8 release APK) and the release workflow fails fast on an
unbumped versionCode.
BREAKING CHANGE: WebView localStorage (settings, in-progress round)
is not migrated; wrapper users start clean.
Plans were split across three trees: plans/, web/plans/, and
android/plans/. Merge them into plans/ at the repo root.
Sweep the shipped Android experience pass and the two reports for it,
carrying the residual forward into plans/todo.md: the device QA
checklist (open only because this environment has no device or
emulator), the clipped web PWA icons defect, and the untested native
assumptions.
Merge android/plans/todo.md in the same pass, dropping items the
experience pass already delivered -- haptics, back button, safe-area
insets, launcher icon, and splash -- each verified against
MainActivity.java, web/src/app.css, and the res/ tree. Correct two
stale claims while merging: web/ is a plain directory in this monorepo,
not a git submodule, and the serialize-javascript override is held by
@rollup/plugin-terser rather than workbox-build.
The retired native Kotlin/Compose port plan moves as-is; it is
superseded by the Capacitor wrapper but not yet swept.
Replace pnpm-lock.yaml with package-lock.json and drop pnpm-workspace.yaml.
The three security overrides move to package.json#overrides, which npm reads
natively; allowBuilds for esbuild is unnecessary because npm runs dependency
build scripts by default.
android/build:web drove the web build through corepack pnpm, so it would have
broken once web/pnpm-lock.yaml was gone. It now uses npm --prefix.
Fresh resolution picks up patched versions the old lockfile had pinned below,
so npm audit reports no advisories where pnpm audit reported eight.
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.