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.
Play rejects duplicate versionCodes.
First release since the workflow was pointed at the closed testing
track, so this tag doubles as the check that "alpha" is the right
track id.
Internal testing is a separate track and does not count toward the
12-tester, 14-day requirement for production access -- only a closed
test does. Releases were landing on internal, so reaching the testers
meant promoting each build by hand in the Console.
Point tracks: at alpha, the closed track, and update the publishing
guide and android README to match. An unknown track id fails the step
with the list of valid tracks rather than publishing somewhere
unintended, so the id is checked at upload time.
Builds up to v0.1.1 remain on the internal track.
Play rejects duplicate versionCodes. Still an internal-track build, so
0.1.1 rather than 1.0.0.
Nothing in the app itself changed since v0.1.0 -- the release carries
the toolchain migration from pnpm to npm plus the plans and publishing
docs consolidation.
The consolidation commit moved todo.md but carried its pre-merge
contents; the merged body was left unstaged. Land it here.
Covers both targets now: the device QA checklist carried off the
shipped Android experience pass, the Play production access window,
the clipped web PWA icons defect, the surviving Android wrapper items,
and the Capacitor decision record.
The publishing guide covered the CI pipeline but never mentioned that
production access is gated behind a closed test: 12 testers opted in
continuously for the preceding 14 days, for personal developer accounts
created after 13 Nov 2023.
Record what the window actually requires -- install under the matching
account, real devices, an unbroken 14 days, and the feature-usage and
feedback evidence the application form asks for.
Flag the track mismatch: android-release.yml uploads to the internal
track, which does not count toward the requirement. Builds have to
reach the closed track, either by promoting each release in the Console
or by repointing tracks: in the workflow.
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.
XML forbids "--" inside a comment, and both comments referenced the CSS
custom property by its "--background" name, so mergeDebugResources
failed to parse them.
Keeps the reasoning next to the change: the brainstorm that scoped it,
the phase plan, and an implementation report carrying the device QA
checklist for everything that cannot be verified without a phone.
Records what a maintainer cannot infer from the wrapper: why VIBRATE is
declared when the web build needs no equivalent, when the wake lock is
held and why it re-acquires on visibilitychange, how back maps to
overlay history, and why the textZoom pin and the in-app size setting
ship together.
Also documents where launcher art comes from, that regenerating needs
Roboto Condensed converted out of the .woff fontsource ships, and the
clipping bug still present in source.svg.
The "Why no INTERNET permission?" section is unchanged — the offline
guarantee still holds.
The APK shipped the Capacitor template's artwork: the launcher icon,
round icon, adaptive foreground and splash were all the stock blue mark
on a teal grid plate, and the adaptive background was the template's
white. The brand icon existed only under web/static/icons, so the
published app showed another project's logo in the launcher and on cold
start.
Regenerated from web/static/icons/source.svg — legacy, round, adaptive
foreground and a monochrome layer for Android 13+ themed icons, at all
five densities. The adaptive background becomes a vector carrying the
rose → amber gradient.
source.svg sets font-size="240", which renders "Lô tô" wider than its
512px canvas, and the committed PNGs under web/static/icons were
rendered without Roboto Condensed so a wider fallback pushed the text
past the edge — they are visibly clipped on both sides. The Android art
is rendered at a corrected size with the real font. The web PWA icons
still carry the original bug.
The splash becomes a layer-list over a themed colour, with a night
variant, replacing eleven density-specific PNGs; API 31+ uses
windowSplashScreenBackground / windowSplashScreenAnimatedIcon instead.
The app has a full dark theme but the splash always flashed light.
Drops the now-unused template robot vector and the white
@color/ic_launcher_background.
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.
web/ and android/ now live in one repository, so the pinned submodule at
android/loto is redundant. Point Capacitor's webDir and the build script at
../web directly, and drop the pin-bumping workflow from the docs.
Adds a gated step in release.yml that uploads the signed AAB to the
Google Play Console internal track when PLAY_SERVICE_ACCOUNT_JSON is
configured. No-op if the secret is missing, so existing tag releases
keep working unchanged.
Default track is 'internal' for safety; change to alpha/beta/production
once trusted. Promotion can also be done via Play Console UI.
First Play Store upload must still be manual (Google policy).
- Declare touchscreen/faketouch/screen.portrait/screen.landscape as
uses-feature required=false so emulators (BlueStacks reports faketouch)
and Play Store device filters do not exclude the app.
- Drop explicit webContentsDebuggingEnabled override so Capacitor
auto-enables WebView inspection in debug builds (chrome://inspect over
ADB) and disables in release.
- Document the install + troubleshoot path in README.
Replace the native Kotlin/Compose port with a Capacitor 8 wrapper around
the upstream loto SvelteKit PWA. Loto is consumed as a git submodule and
its static build is bundled into the APK at sync time. The wrapper has
no INTERNET permission, so fully offline is enforced rather than
conventional.
- loto/ submodule pinned to dfb1c1e
- android/ scaffolded by cap add android; signing wired via env vars
- All 184 voice MP3s + service worker bundled in assets/public/
- CI checkouts submodule, runs npm build, then Gradle inside android/
- Native port preserved in git history at e7fb3d0 and 8b8d46e
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).
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)
- Remove gradient brush from "Kinh!" title, use solid BrandAmber600.
- Align with solid-color palette refactor (phase 03).
- Update button and text colors to new palette.
Upstream: a60ea08
- Minor UI alignment and spacing adjustments across master panel and
player board screens.
- Improve code consistency and readability in Compose components.
- Update project todo list with implementation notes.
Upstream: a60ea08 (sync completion)
- Add mode-specific voice hint messages (player vs master mode).
- Update VoiceToggles to show contextual hint based on current mode.
- Improve UX clarity in settings sheet.
Upstream: a60ea08
- Add sectionHasWaiting state to track which sections have rows one cell
from bingo (in Chờ state).
- Render amber ring + 1.5s pulse on section labels when section has waiting.
- Skip animation if accessibility motion is disabled.
- Add BrandAmberLight color for the ring.
Upstream: a60ea08
- Fire confetti on 2nd bingo OR 1st bingo when another row is in Chờ
(waiting for one number). Replaces old ≥3 threshold that rarely fired.
- Add Vietnamese hội-chợ emoji: 🥢🎋🏮 (chopsticks, bamboo, lantern)
- Per-particle size jitter (1.5–2.4x) for visual interest
Upstream: a60ea08
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.
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.
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.
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.
- 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.
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
`@vite-pwa/sveltekit` pulled in `[email protected]` 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.
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.
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.