diff --git a/plans/260814-1317-android-experience-pass/phase-01-tier1-defects.md b/plans/260814-1317-android-experience-pass/phase-01-tier1-defects.md
new file mode 100644
index 0000000..86422cb
--- /dev/null
+++ b/plans/260814-1317-android-experience-pass/phase-01-tier1-defects.md
@@ -0,0 +1,137 @@
+# Phase 1 — Tier 1 defects
+
+APK-only bugs. None reproduce in a desktop browser.
+
+## W1 · VIBRATE permission
+
+`android/android/app/src/main/AndroidManifest.xml` — add:
+
+```xml
+
+```
+
+Normal permission: no runtime prompt, no Play data-safety change. Caller is
+`PlayerBoard.svelte:341-346`, already guarded by `prefers-reduced-motion`.
+
+## W2 · Screen wake lock
+
+New `web/src/lib/wake-lock.js` (plain `.js` — no reactive state, matches
+`game-logic.js` / `player-auto-cross.js` convention; runes files are only those
+holding `$state`).
+
+API — single entry point so callers cannot desync:
+
+```js
+export function setWakeLock(on) // idempotent
+export function _resetForTest() // test-only teardown
+```
+
+Behaviour:
+
+- `setWakeLock(true)` → `navigator.wakeLock.request("screen")`, installs a
+ `visibilitychange` listener that re-acquires on return to visible.
+ Android silently releases the lock whenever the page hides — without the
+ re-acquire the lock is dead after the first backgrounding.
+- `setWakeLock(false)` → releases sentinel, removes listener.
+- Generation token guards the async gap: a `request()` that resolves after the
+ caller flipped to `false` must release immediately rather than leak a lock.
+ Same pattern as `activeToken` in `voice.js`.
+- No `navigator.wakeLock` → no-op (old WebViews at minSdk 24).
+- `request()` rejects on battery saver / hidden page → swallow, do not throw.
+
+Consumer — `web/src/lib/MasterPanel.svelte`, new `$effect`:
+
+```js
+$effect(() => {
+ setWakeLock(autoRunning || hasGame);
+ return () => setWakeLock(false);
+});
+```
+
+`autoRunning` (line 47) and `hasGame` (lines 55-57) already exist locally.
+MasterPanel only mounts in master/both mode, so player-only mode never holds a
+lock — intended: player taps keep their own screen awake.
+
+Tests — new `web/src/lib/wake-lock.test.js`: acquire, release, re-acquire on
+`visibilitychange`, no-op when unsupported, late-resolve after `false` releases.
+
+## W3 · Back button
+
+### Web half — `web/src/lib/overlay-history.js` (new)
+
+Each open overlay pushes one sentinel history entry. `popstate` closes the
+newest. Works in the browser too — browser back closes the modal, a real PWA win.
+
+```js
+export function pushOverlay(close) // returns dispose() for programmatic close
+```
+
+- LIFO stack of `{ id, close }`.
+- `popstate` → pop top, run its `close()`, leave history alone.
+- `dispose()` (Escape / Xong / backdrop) → remove entry, then `history.back()`
+ to drop the sentinel, with a suppression counter so the resulting `popstate`
+ does not re-run `close()`.
+- Single shared `popstate` listener, installed on first push, removed when the
+ stack empties.
+
+Consumers:
+
+- `PlayerBoard.svelte` — `showCongrats` modal.
+- `SettingsButton.svelte` — settings sheet.
+
+Both already have Escape handlers in an `$effect`; the overlay push belongs in
+the same effect so open/close stays one code path.
+
+Tests — new `web/src/lib/overlay-history.test.js`: single open/close, nested
+LIFO ordering, programmatic close does not double-fire, stack drains.
+
+### Native half — `MainActivity.java`
+
+```java
+getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
+ @Override public void handleOnBackPressed() {
+ WebView wv = getBridge().getWebView();
+ if (wv != null && wv.canGoBack()) { wv.goBack(); return; } // pops a sentinel
+ confirmExit();
+ }
+});
+```
+
+`history.pushState` adds to the WebView back-forward list, so `canGoBack()` is
+true exactly while an overlay is open, and `goBack()` fires `popstate`.
+
+Exit confirm: `androidx.appcompat.app.AlertDialog`, strings from
+`strings.xml` (`exit_title`, `exit_message`, `exit_confirm`, `exit_cancel`),
+positive button → `finish()`.
+
+Also add `android:enableOnBackInvokedCallback="true"` to ``.
+Default at targetSdk 36; explicit is self-documenting.
+
+**Unconfirmed, device-only:** whether the activity theme at dialog time is
+AppCompat-derived. Activity theme is `AppTheme.NoActionBarLaunch`
+(parent `Theme.SplashScreen`); Capacitor's splash flow normally swaps to
+`AppTheme.NoActionBar` post-splash. If the dialog throws on a non-AppCompat
+theme, pass an explicit dialog theme to the builder. Flagged in QA checklist.
+
+## Files
+
+| File | Change |
+|------|--------|
+| `android/android/app/src/main/AndroidManifest.xml` | VIBRATE, enableOnBackInvokedCallback |
+| `android/android/app/src/main/java/com/miti99/loto/MainActivity.java` | back callback + confirm dialog |
+| `android/android/app/src/main/res/values/strings.xml` | 4 exit-dialog strings |
+| `web/src/lib/wake-lock.js` | new |
+| `web/src/lib/wake-lock.test.js` | new |
+| `web/src/lib/overlay-history.js` | new |
+| `web/src/lib/overlay-history.test.js` | new |
+| `web/src/lib/MasterPanel.svelte` | wake-lock effect |
+| `web/src/lib/PlayerBoard.svelte` | overlay push for bingo modal |
+| `web/src/lib/SettingsButton.svelte` | overlay push for settings sheet |
+
+## Validation
+
+- `pnpm test` — new suites green, existing 6 suites unaffected.
+- `pnpm lint`, `pnpm build`.
+- Device QA: tap buzzes; reduced-motion silent; 3-min auto-call, screen stays
+ lit; background/return keeps it lit; back closes modal; back at root confirms;
+ confirm dialog renders (theme check).
diff --git a/plans/260814-1317-android-experience-pass/phase-02-tier2-defensive.md b/plans/260814-1317-android-experience-pass/phase-02-tier2-defensive.md
new file mode 100644
index 0000000..882d200
--- /dev/null
+++ b/plans/260814-1317-android-experience-pass/phase-02-tier2-defensive.md
@@ -0,0 +1,132 @@
+# Phase 2 — Tier 2, designed defensively
+
+Suspected but unconfirmed without a device. Every change here is correct whether
+or not the bug reproduces, and costs nothing if it does not.
+
+## W4 · Safe-area insets
+
+`web/src/app.html` — viewport meta gains `viewport-fit=cover`:
+
+```html
+
+```
+
+`web/src/app.css` — pad `body` by the insets:
+
+```css
+body {
+ padding-top: env(safe-area-inset-top);
+ padding-right: env(safe-area-inset-right);
+ padding-bottom: env(safe-area-inset-bottom);
+ padding-left: env(safe-area-inset-left);
+}
+```
+
+Why `body` and not the page container: `html` carries the background colour and
+`body` is already `transparent` + `min-h-full flex flex-col`, so padding here
+insets content while the background stays full-bleed. `fixed inset-0` overlays
+(bingo modal, settings sheet, inactive-tab curtain) are viewport-relative and
+deliberately unaffected — their backdrops should cover the whole screen; their
+content is centred and already clear of the bars.
+
+Bonus: fixes the iOS PWA notch for free.
+
+Zero effect where insets are 0 (desktop, older Android).
+
+## W5 · Font scale
+
+### Native pin
+
+`MainActivity.java`, after `super.onCreate`:
+
+```java
+getBridge().getWebView().getSettings().setTextZoom(100);
+```
+
+Stops the system font multiplier from breaking a 9-column fixed grid.
+
+### In-app replacement control
+
+Taking the system control away obliges giving one back. Both ship or neither.
+
+`web/src/lib/settings-store.svelte.js`:
+
+- `boardTextScale: 1` in `DEFAULT_SETTINGS`.
+- `validBoardTextScale(v)` — allowlist `[0.9, 1, 1.15, 1.3]`, same
+ per-key-validator pattern as the existing keys.
+- `applyBoardTextScale()` sets `--board-text-scale` on `documentElement`,
+ mirroring `applyEmptyCellColor()`; called from `applyAll()`.
+- Wire into `loadSettings()` and `resetSettings()`.
+
+`web/src/app.css` — new classes. `.tan-tan-num` sets font-family/weight only;
+sizes come from Tailwind utilities on the elements, so overriding there would be
+a specificity fight. Dedicated classes instead, replacing the utilities:
+
+```css
+/* Player card cell — was text-xl sm:text-2xl md:text-3xl */
+.board-num { font-size: calc(1.25rem * var(--board-text-scale, 1)); }
+@media (min-width: 640px) { .board-num { font-size: calc(1.5rem * var(--board-text-scale, 1)); } }
+@media (min-width: 768px) { .board-num { font-size: calc(1.875rem * var(--board-text-scale, 1)); } }
+
+/* Master tracking token — was text-xl sm:text-2xl */
+.master-num { font-size: calc(1.25rem * var(--board-text-scale, 1)); }
+@media (min-width: 640px) { .master-num { font-size: calc(1.5rem * var(--board-text-scale, 1)); } }
+```
+
+Two classes, not one: the master board has two breakpoint rungs, the player card
+three. Merging them would silently enlarge the master board on desktop.
+
+Consumers:
+
+- `PlayerBoard.svelte:451-462` — swap `text-xl sm:text-2xl md:text-3xl` → `board-num`.
+- `MasterPanel.svelte:287` — swap `text-xl sm:text-2xl` → `master-num`.
+
+Scope: grid cells only. Master hero number, called-history chips, and all UI
+chrome keep their Tailwind sizes — clipping is a grid problem.
+
+### Settings UI
+
+`web/src/lib/SettingsButton.svelte` — new fieldset "Cỡ chữ bảng", 4 buttons
+(Nhỏ / Vừa / Lớn / Rất lớn), same `aria-pressed` button-group pattern as the
+existing theme/mode pickers.
+
+Tests — extend `web/src/lib/settings-store.test.js`: default, valid values
+persist, invalid/out-of-set falls back to 1, reset restores 1.
+
+## W6 · Volume rocker → media stream
+
+`MainActivity.java`:
+
+```java
+setVolumeControlStream(AudioManager.STREAM_MUSIC);
+```
+
+One line. Volume keys adjust media rather than ringtone even before first
+playback. Unambiguously right for an app whose job is calling numbers aloud.
+
+## Deferred — audio focus / MediaSession
+
+Still unconfirmed as broken. Building a MediaSession layer around 1-second clips
+for a hypothetical problem is speculative work. Revisit with device evidence.
+
+## Files
+
+| File | Change |
+|------|--------|
+| `web/src/app.html` | `viewport-fit=cover` |
+| `web/src/app.css` | safe-area body padding, `.board-num`, `.master-num` |
+| `web/src/lib/settings-store.svelte.js` | `boardTextScale` + validator + apply |
+| `web/src/lib/settings-store.test.js` | coverage for the new key |
+| `web/src/lib/SettingsButton.svelte` | size picker fieldset |
+| `web/src/lib/PlayerBoard.svelte` | `board-num` class swap |
+| `web/src/lib/MasterPanel.svelte` | `master-num` class swap |
+| `android/.../MainActivity.java` | textZoom pin, volume stream |
+
+## Validation
+
+- `pnpm test` — settings-store suite covers the new key; existing 369 lines of
+ settings tests must stay green (this file has the most regression surface).
+- `pnpm lint`, `pnpm build`.
+- Device QA: system font at 200% → grid intact; in-app size control changes
+ numbers and survives reload; gesture-nav and status bar clear of gear/footer;
+ volume rocker shows the media slider during a call.
diff --git a/plans/260814-1317-android-experience-pass/phase-03-store-polish.md b/plans/260814-1317-android-experience-pass/phase-03-store-polish.md
new file mode 100644
index 0000000..347306b
--- /dev/null
+++ b/plans/260814-1317-android-experience-pass/phase-03-store-polish.md
@@ -0,0 +1,82 @@
+# Phase 3 — Store polish
+
+Independent of phases 1-2. Pure config/resource work.
+
+## Launcher name
+
+`android/android/app/src/main/res/values/strings.xml`:
+
+- `app_name`: `Lo To` → `Lô tô`
+- `title_activity_main`: `Lo To` → `Lô tô`
+
+Vietnamese users currently get an ASCII-mangled launcher label while the app
+itself is "Lô tô — Hội chợ TN1". Short form matches the webmanifest
+`short_name`. AAPT handles UTF-8; the file already declares
+`encoding='utf-8'`.
+
+Leave `package_name` and `custom_url_scheme` alone — identifiers, not labels.
+
+## Themed icon (Android 13+)
+
+`android/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml` — add a
+`` layer so the launcher can tint the icon to the user's theme.
+Without it Android 13+ falls back to the full-colour icon on themed home
+screens.
+
+Source glyph: `web/static/icons/source.svg`. Monochrome layers must be a flat
+silhouette on transparent — no gradients, no background plate. Add as
+`res/drawable/ic_launcher_monochrome.xml` (vector) reusing the existing
+foreground geometry.
+
+## Dark splash
+
+New `android/android/app/src/main/res/values-night/styles.xml` overriding
+`AppTheme.NoActionBarLaunch` with a dark splash drawable. The app has a full
+dark theme; the splash currently flashes light before the WebView paints.
+
+Needs a `drawable-night/splash.xml` (or a night colour the existing
+`@drawable/splash` references).
+
+## Version bump
+
+`android/android/app/build.gradle`:
+
+- `versionCode 3` → `4` (Play rejects duplicates — already documented in
+ `android/README.md`).
+- `versionName "0.0.3"` → `"0.1.0"`. Still internal track; 1.0.0 would
+ overclaim.
+
+## Docs
+
+`android/README.md`:
+
+- Permissions: document VIBRATE and why (haptic cell feedback).
+- New "Back button" note: closes overlays, confirms exit at root.
+- New "Screen wake lock" note: held while a round is active.
+- Version-bump section: reflect the new numbers.
+- **Leave the "Why no INTERNET permission?" section verbatim.** The offline
+ guarantee is unchanged and the wording is deliberate.
+
+## Reassessed, no change
+
+`allowBackup=true` was listed as a gap in the brainstorm. Stored data is grid,
+crossed cells, and UI settings in localStorage — nothing sensitive, and backup
+means a player restores their card on a new phone. Correct as-is.
+
+## Files
+
+| File | Change |
+|------|--------|
+| `android/.../res/values/strings.xml` | diacritic app name |
+| `android/.../res/mipmap-anydpi-v26/ic_launcher.xml` | monochrome layer |
+| `android/.../res/drawable/ic_launcher_monochrome.xml` | new |
+| `android/.../res/values-night/styles.xml` | new, dark splash |
+| `android/.../res/drawable-night/splash.xml` | new |
+| `android/android/app/build.gradle` | versionCode 4, versionName 0.1.0 |
+| `android/README.md` | permissions, back button, wake lock, versions |
+
+## Validation
+
+- No test surface — resources and docs only.
+- Device QA: launcher shows `Lô tô`; themed-icon home screen tints correctly;
+ cold start in dark mode does not flash light.
diff --git a/plans/260814-1317-android-experience-pass/plan.md b/plans/260814-1317-android-experience-pass/plan.md
new file mode 100644
index 0000000..be605dc
--- /dev/null
+++ b/plans/260814-1317-android-experience-pass/plan.md
@@ -0,0 +1,82 @@
+---
+status: complete
+created: 2026-08-14
+completed: 2026-08-14
+branch: main
+source: plans/reports/brainstorm-android-ux-260814-1004-android-experience-pass-report.md
+report: plans/reports/from-cook-to-review-260814-1317-android-experience-pass-implementation-report.md
+---
+
+## Deviations from plan
+
+1. **Icon scope grew.** Discovered mid-plan that the APK ships Capacitor's
+ stock logo as launcher icon *and* splash. User approved regenerating from
+ `source.svg`. Supersedes the originally-planned monochrome-layer and
+ dark-splash items, which would have themed the wrong logo.
+2. **`source.svg` is broken.** `font-size="240"` overflows the 512 canvas;
+ `web/static/icons/*.png` are visibly clipped. Android art rendered at a
+ corrected 200/160. Web PWA icons still carry the bug — not fixed, out of
+ approved scope.
+3. **Wake-lock condition tightened** from `autoRunning || hasGame` to
+ `masterState.remaining.length > 0`. `hasGame` never returns to false once a
+ round starts, which would have pinned the screen on for a finished board.
+4. **Splash rebuilt rather than duplicated.** 11 density PNGs replaced by one
+ layer-list + night variant + `windowSplashScreen*` for API 31+.
+
+# Android experience pass
+
+Close Android-only behaviour gaps in the Capacitor wrapper. Offline guarantee
+(no INTERNET permission) untouched.
+
+## Phases
+
+| # | Phase | File | Depends on |
+|---|-------|------|------------|
+| 1 | Tier 1 defects | [phase-01-tier1-defects.md](phase-01-tier1-defects.md) | — |
+| 2 | Tier 2 defensive | [phase-02-tier2-defensive.md](phase-02-tier2-defensive.md) | 1 (shares MainActivity) |
+| 3 | Store polish | [phase-03-store-polish.md](phase-03-store-polish.md) | — |
+
+## Resolved before planning
+
+Brainstorm left W3 (back button) as a fork. Resolved by research:
+
+- Android 16 / API 36 no longer calls `onBackPressed()` and no longer dispatches
+ `KEYCODE_BACK`. Project targets 36.
+- `OnBackPressedCallback` is the forward-compatible mechanism and keeps working
+ under predictive back.
+- Capacitor 8 `BridgeActivity`/`Bridge` show no back handling on `main`, so
+ Capacitor's default "back navigates WebView history" cannot be assumed to
+ survive targetSdk 36 either.
+
+Consequence: back handling is implemented natively via `OnBackPressedCallback`.
+This is not a fallback — it is the only mechanism that can be relied on. Kills
+brainstorm risks 1 and 3 (`web/` never takes a Capacitor dependency).
+
+## Acceptance criteria
+
+1. Cell tap vibrates on device; `prefers-reduced-motion` still suppresses.
+2. Auto-call runs 3+ min untouched without screen dimming; stop releases lock;
+ background → foreground re-acquires.
+3. Back closes an open overlay (bingo modal, settings sheet) instead of quitting;
+ back at root shows a Vietnamese exit confirm.
+4. Player grid legible at 200% system font; in-app board-size control resizes and
+ persists.
+5. Status bar / gesture nav do not overlap the settings gear or footer.
+6. Volume rocker controls media stream during a call.
+7. Launcher shows `Lô tô` with diacritics.
+8. `pnpm test` green, `pnpm lint` clean, `pnpm build` succeeds.
+
+## Out of scope
+
+Multi-device sync, INTERNET permission, audio focus/MediaSession, APK size,
+`allowBackup` changes, service-worker asset dedup.
+
+## Verification limits
+
+No Android device, emulator, or browser in this environment (headless ARM64, no
+Chrome — see workspace CLAUDE.md). Gradle/Android SDK not installed. So:
+
+- Verifiable here: unit tests, lint, web build, static review of native code.
+- Device-only: items 1, 2 (real screen), 3, 4 (system font), 5, 6.
+
+Phase files carry a manual QA checklist for the device-only items.
diff --git a/plans/reports/brainstorm-android-ux-260814-1004-android-experience-pass-report.md b/plans/reports/brainstorm-android-ux-260814-1004-android-experience-pass-report.md
new file mode 100644
index 0000000..f3e6002
--- /dev/null
+++ b/plans/reports/brainstorm-android-ux-260814-1004-android-experience-pass-report.md
@@ -0,0 +1,205 @@
+# Brainstorm — Android experience pass
+
+- Date: 2026-08-14
+- Branch: `main` (c6c839f)
+- Mode: brainstorm (no `--html`, no `--wiki`)
+- Status: design approved, ready for `/ck:plan`
+
+## Problem statement
+
+App ships two targets from one commit: static web (`web/`) and a Capacitor 8 APK
+(`android/`). Wrapper is thin — bare `BridgeActivity`, zero plugins, no native code.
+Consequence: several behaviours diverge between browser and APK, and none of the
+divergences reproduce on desktop where development happens.
+
+Goal: close the Android-only gaps without breaking the offline guarantee.
+
+## Locked constraints
+
+| Constraint | Decision |
+|---|---|
+| INTERNET permission | Never. Non-negotiable. Offline is a hard guarantee, not a convention. |
+| Implementation bias | Web-first. Native only where the web platform cannot reach. |
+| Multi-device sync | Out of scope this round. |
+| Play data safety | Must stay "no data collected". |
+
+## Findings (verified against source)
+
+### Tier 1 — defects that exist only in the APK
+
+1. **Haptics dead.** `PlayerBoard.svelte:341-346` calls `navigator.vibrate(10)`.
+ `AndroidManifest.xml` declares no `android.permission.VIBRATE`. Works in Chrome
+ (Chrome holds the permission), silent no-op in the WebView.
+2. **Screen sleeps mid-round.** Auto-call is `setInterval` at 1–10 s/number
+ (`MasterPanel.svelte:85-104`); a 90-number round is 7.5–15 min. Zero wake-lock
+ usage anywhere (grepped). Caller device dims, timers throttle, calls stop.
+ Worst real-world failure; invisible on desktop.
+3. **Back button exits instantly.** Bare `BridgeActivity` + single-route SPA = no
+ history, so back quits. Bingo modal and settings close on Escape
+ (`PlayerBoard.svelte:218-226`) but not on back. State survives in localStorage;
+ an in-flight auto-call run does not.
+
+### Tier 2 — suspected, no device available to confirm
+
+4. **Edge-to-edge collision.** targetSdk 36 → Android 15+ forces edge-to-edge.
+ No `viewport-fit=cover` in `app.html`, no `env(safe-area-inset-*)` in CSS.
+ Settings gear is absolutely positioned at header `top: 0`.
+5. **System font scale breaks the grid.** WebView honours system font size. Board is
+ 9 fixed columns, `text-xl…text-3xl` in `aspect-[3/4]` cells. Audience skews toward
+ users who enlarge system fonts. Note: unconfirmed whether container-relative units
+ (`cqw`/`clamp`) survive Android's `textZoom` multiplier — likely not.
+6. **Audio focus.** `new Audio()` in WebView may not take Android audio focus;
+ other apps' audio would play over number calls. Not confirmed broken.
+
+### Tier 3 — product, deferred by decision
+
+7. **Every phone is an island.** `active-tab.svelte.js` is explicitly a same-origin
+ same-device coordinator. Real lô tô = one caller, many players. Cross-device sync
+ is the single largest available UX win and is blocked by the offline vow.
+ Owner kept the vow; sync stays out.
+
+### Checked and dismissed
+
+- **APK bloat** — audio totals 2.2 MB across both voices. Non-issue.
+- **Service-worker asset duplication** — SW re-caches ~2 MB already on disk in the
+ APK. Real but not worth engineering time.
+- **`allowBackup=true`** — stored data is grid, crossed cells, UI settings. Nothing
+ sensitive; backup lets a player restore their card on a new phone. Correct as-is.
+
+## Decisions
+
+| Question | Options weighed | Chosen | Why |
+|---|---|---|---|
+| Scope | Tier 1 / Tier 2 / Tier 3 / store polish | Tier 1 + Tier 2 + polish | Tier 3 blocked by offline vow |
+| Offline vow | keep / LAN-only / server | Keep, non-negotiable | Owner decision |
+| Native code | web-only / web-first / native-free | Web-first, native where forced | Keeps wrapper thin, PWA benefits |
+| Back button | silent exit / confirm / double-back | Close overlays, confirm exit | Losing a live round warrants one first-party plugin |
+| Font scale | pin only / pin + setting / CSS only / defer | Pin `textZoom` + in-app size control | Only option that fixes grid without removing a11y |
+| Wake lock | always / active round / toggle | While round active | Caller device sits untouched; player taps self-serve |
+| Tier 2 verification | defensive / wait / checklist | Design defensively | Fixes are correct regardless, cost nothing if bug absent |
+
+## Work items
+
+### Phase 1 — Tier 1 (no device needed)
+
+**W1 · VIBRATE permission.** Add ``
+to `android/android/app/src/main/AndroidManifest.xml`. Normal permission, no runtime
+prompt, no data-safety change. Existing reduced-motion guard unaffected.
+*Accept:* cell tap buzzes on device; reduced-motion still silent.
+
+**W2 · Wake lock.** New `web/src/lib/wake-lock.svelte.js` wrapping
+`navigator.wakeLock.request("screen")`. Must re-acquire on `visibilitychange` —
+Android auto-releases when the page hides. Consumed by `$effect` in
+`MasterPanel.svelte` keyed on `autoRunning || hasGame` (both already local, lines
+47/55-57). No-ops when `navigator.wakeLock` absent (old WebView, minSdk 24).
+*Accept:* 3+ min auto-call untouched, screen stays lit; stop releases; background →
+foreground re-acquires.
+
+**W3 · Back button.**
+- Web half: new `web/src/lib/overlay-history.js`. Overlay open pushes history entry;
+ single `popstate` listener closes topmost. Careful case: close via button/Escape
+ must pop the sentinel without re-triggering close. Touches `PlayerBoard.svelte`
+ (`showCongrats`) and `SettingsButton.svelte`.
+- Native half (exit guard): **unresolved fork.** targetSdk 36 enables predictive back
+ by default and ignores legacy `onBackPressed` interception. Verify whether
+ Capacitor 8 `App.addListener('backButton')` still fires before writing code.
+ Fallback: AndroidX `OnBackPressedCallback` in `MainActivity.java` + Vietnamese
+ confirm dialog from `strings.xml`. Fallback also avoids giving `web/` a Capacitor
+ dependency.
+
+*Accept:* back closes an open modal rather than quitting; back at root during a live
+round asks first.
+
+### Phase 2 — Tier 2, defensive
+
+**W4 · Safe areas.** `viewport-fit=cover` in `web/src/app.html`;
+`env(safe-area-inset-*)` padding in `app.css` / `+page.svelte` container.
+Also fixes iOS PWA notch.
+*Accept:* gear + footer fully visible on Android 15/16 with gesture nav.
+
+**W5 · Font scale.** `MainActivity.java` pins `textZoom = 100`. Paired with new
+`boardTextScale` setting (0.9 / 1.0 / 1.15 / 1.3) in `settings-store.svelte.js`,
+own validator following the existing per-key pattern, UI in the settings sheet,
+drives a CSS var multiplier on `.tan-tan-num`.
+*Accept:* grid intact at 200% system font; in-app control resizes numbers; persists
+across reload.
+
+**W6 · Volume rocker.** `setVolumeControlStream(STREAM_MUSIC)` in `MainActivity.java`.
+One line, unambiguously correct for an audio-centric app.
+
+**Deferred: audio focus / MediaSession.** Unconfirmed problem. Building a MediaSession
+layer around 1-second clips speculatively violates YAGNI. Revisit with device evidence.
+
+### Phase 3 — Store polish
+
+- `strings.xml`: `app_name` → `Lô tô` (diacritics). Currently ASCII-mangled `Lo To`
+ in the launcher while the app itself is "Lô tô — Hội chợ TN1".
+- Monochrome layer in `mipmap-anydpi-v26/ic_launcher.xml` (Android 13+ themed icons),
+ derived from `web/static/icons/source.svg`.
+- `values-night/styles.xml` — dark splash. App has a full dark theme; splash flashes light.
+- `versionCode` + `versionName` bump per existing documented process. Suggest 0.1.0,
+ not 1.0.0 — still internal track.
+- `android/README.md`: document VIBRATE, wake lock, back-button behaviour. Leave the
+ "no INTERNET permission" section verbatim.
+
+## Touchpoints
+
+| File | Work |
+|---|---|
+| `android/android/app/src/main/AndroidManifest.xml` | W1 |
+| `android/android/app/src/main/java/com/miti99/loto/MainActivity.java` | W3 (fallback), W5, W6 |
+| `android/android/app/src/main/res/values/strings.xml` | W3 (dialog strings), P3 |
+| `android/android/app/src/main/res/values-night/styles.xml` | P3 (new) |
+| `android/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml` | P3 |
+| `android/android/app/build.gradle` | P3 version bump |
+| `web/src/lib/wake-lock.svelte.js` | W2 (new) |
+| `web/src/lib/overlay-history.js` | W3 (new) |
+| `web/src/lib/MasterPanel.svelte` | W2 |
+| `web/src/lib/PlayerBoard.svelte` | W3 |
+| `web/src/lib/SettingsButton.svelte` | W3, W5 |
+| `web/src/lib/settings-store.svelte.js` | W5 |
+| `web/src/app.html` | W4 |
+| `web/src/app.css` | W4, W5 |
+| `android/README.md` | P3 docs |
+
+## Risks
+
+1. **Predictive back vs Capacitor 8** (W3) — resolve before implementation; fallback
+ identified. Highest-uncertainty item in the plan.
+2. **`textZoom` pin overrides accessibility** — mitigated only by W5's in-app control.
+ They ship together or not at all; cutting the setting means cutting the pin.
+3. **`web/` gaining a Capacitor dependency** — if the exit guard uses `@capacitor/app`,
+ the standalone GH Pages build inherits it. Native fallback avoids this.
+4. **No device in the working environment** — W4/W5 designed on reasoning, not
+ observation. Both safe-by-default; harmless if the bug does not reproduce.
+
+## Validation
+
+- `cd web && pnpm test` — settings-store gains `boardTextScale` validator coverage.
+- New unit coverage: `overlay-history` push/pop/dismiss ordering; `wake-lock`
+ re-acquire on visibility change.
+- Manual on Android 15/16 device: haptics, 3-min auto-call with screen untouched,
+ back from modal, back at root mid-round, system font at 200%, gesture-nav insets,
+ volume rocker during a call.
+- `cd android && npm run build && npm run assemble:debug` must stay green.
+
+## Out of scope
+
+Multi-device sync, INTERNET permission, audio focus/MediaSession, APK size,
+`allowBackup` changes, service-worker asset dedup.
+
+## Open questions
+
+1. W3 native path — Capacitor 8 `backButton` under predictive back: fires or not?
+ Decides whether `web/` takes a Capacitor dependency.
+2. Confirm-on-exit trigger — only when a round is live (needs the flag plumbed to
+ native), or unconditionally at root (simpler, mildly noisier)?
+3. `boardTextScale` steps — are 0.9/1.0/1.15/1.3 the right rungs, or is a continuous
+ slider preferred alongside the existing colour picker?
+
+## Next step
+
+`/ck:plan` with this report as input. Default mode recommended over `--tdd`: work is
+mostly additive across new modules and native config, not a refactor of covered
+behaviour. Exception — W5 touches `settings-store.svelte.js`, which has 369 lines of
+existing tests worth preserving.
diff --git a/plans/reports/from-cook-to-review-260814-1317-android-experience-pass-implementation-report.md b/plans/reports/from-cook-to-review-260814-1317-android-experience-pass-implementation-report.md
new file mode 100644
index 0000000..69e0e11
--- /dev/null
+++ b/plans/reports/from-cook-to-review-260814-1317-android-experience-pass-implementation-report.md
@@ -0,0 +1,131 @@
+# Android experience pass — implementation report
+
+- Date: 2026-08-14
+- Branch: `main` (uncommitted)
+- Plan: [plans/260814-1317-android-experience-pass/plan.md](../260814-1317-android-experience-pass/plan.md)
+- Brainstorm: [brainstorm-android-ux-260814-1004](brainstorm-android-ux-260814-1004-android-experience-pass-report.md)
+
+## Verified here
+
+| Gate | Result |
+|------|--------|
+| `pnpm test` | 8 files, 121 tests pass (was 6 files / 105) |
+| `pnpm lint` | clean |
+| `pnpm build` | succeeds; `viewport-fit`, safe-area CSS, `.board-num`/`.master-num`, `--board-text-scale` confirmed present in output |
+
+## NOT verified — no device, emulator, browser, or Android SDK
+
+Gradle cannot run here (`ANDROID_HOME` unset, no SDK, no `adb`). **The Java and
+all Android resources are uncompiled.** Static review only.
+
+Device QA required for: haptics, wake lock, back button + exit dialog, safe-area
+insets, system font scale, volume rocker, launcher icon, splash.
+
+## Delivered
+
+### Phase 1 — Tier 1 defects
+
+- **W1 VIBRATE** — `AndroidManifest.xml`. Normal permission; no runtime prompt,
+ no data-safety change.
+- **W2 wake lock** — new `web/src/lib/wake-lock.js` + 8 tests. Re-acquires on
+ `visibilitychange` (Android drops the lock on hide). Generation token stops a
+ late-resolving `request()` leaking a lock after the caller turned it off.
+ Consumed by a `$effect` in `MasterPanel.svelte`.
+- **W3 back button** — native `OnBackPressedCallback` in `MainActivity.java` +
+ new `web/src/lib/overlay-history.js` (8 tests). Overlays push a history
+ sentinel; `canGoBack()` therefore means "an overlay is open". Root back shows
+ a Vietnamese confirm dialog.
+
+### Phase 2 — Tier 2, defensive
+
+- **W4 safe areas** — `viewport-fit=cover` + `env(safe-area-inset-*)` padding on
+ `body`. Also fixes the iOS PWA notch.
+- **W5 font scale** — `textZoom = 100` pinned natively; new `boardTextScale`
+ setting (0.9/1/1.15/1.3) with validator, CSS var, and a "Cỡ chữ bảng" picker
+ in the settings sheet. 7 new tests.
+- **W6 volume rocker** — `setVolumeControlStream(STREAM_MUSIC)`.
+
+### Phase 3 — Store polish
+
+- `app_name` → `Lô tô` (was ASCII `Lo To`).
+- **Launcher icon and splash rebuilt from the brand mark** (see finding below).
+ Legacy + round + adaptive foreground + monochrome at 5 densities; adaptive
+ background as a gradient vector; splash as a themed layer-list with a night
+ variant plus `windowSplashScreen*` for API 31+.
+- 11 stock splash PNGs and the template robot vector deleted.
+- `versionCode 4`, `versionName 0.1.0`.
+- `android/README.md`: permissions, Android-specific behaviour, icon pipeline.
+
+## Findings
+
+### 1. The APK was shipping Capacitor's logo (fixed)
+
+Launcher icon, round icon, adaptive foreground and splash were all the stock
+Capacitor template — the blue "X" mark on a teal grid plate. The brand icon
+existed only in `web/static/icons/`. A published Play Store app was showing
+another project's logo in the launcher and on cold start.
+
+### 2. `source.svg` overflows its canvas (NOT fixed — web scope)
+
+`font-size="240"` renders "Lô tô" at ~437px against a 400px inner panel, and the
+original PNGs were rendered without Roboto Condensed so a wider fallback pushed
+it past the 512px canvas entirely. **`web/static/icons/icon-192.png`,
+`icon-512.png` and `icon-maskable-512.png` are visibly clipped** — the `L` and
+the trailing `ô` are cut off. These are what the PWA, GH Pages install prompt,
+and the OG/Twitter card images use.
+
+Android art was rendered at a corrected size (200 legacy / 160 adaptive) with
+the real font. The web PNGs were left alone — outside approved scope.
+
+### 3. Predictive back changes the mechanism (resolved during planning)
+
+Android 16 stops calling `onBackPressed()` and stops dispatching `KEYCODE_BACK`
+at targetSdk 36. Capacitor 8 shows no back handling in `BridgeActivity`/`Bridge`
+on `main`, so its default "back navigates WebView history" cannot be assumed to
+survive either — meaning a pure-web History-API approach could not have worked
+alone. `OnBackPressedCallback` is the only reliable mechanism.
+
+### 4. `allowBackup` reassessed — no change
+
+Flagged as a gap in the brainstorm. Stored data is grid, crossed cells, and UI
+settings. Nothing sensitive; backup restores a player's card on a new phone.
+Correct as-is.
+
+## Risks carried into device QA
+
+1. **Exit dialog theme.** Activity theme is `AppTheme.NoActionBarLaunch`
+ (parent `Theme.SplashScreen`). Mitigated two ways: explicit
+ `AppTheme.ExitDialog` (`Theme.AppCompat.DayNight.Dialog.Alert`) passed to the
+ builder, and `postSplashScreenTheme` added. If the dialog still throws, the
+ activity theme is the cause.
+2. **`canGoBack()` baseline.** Assumes the WebView has no history at rest.
+ SvelteKit hydrates with `replaceState`, so it should be false — verify back
+ at the root confirms exit rather than navigating.
+3. **`textZoom` pin is an accessibility override.** Only defensible alongside
+ the in-app size control. If that setting is ever cut, cut the pin too.
+4. **Uncompiled Java.** `OnBackPressedCallback` and `Bridge` resolve
+ transitively through `appcompat` / `capacitor-android`; not proven.
+
+## Device QA checklist
+
+- [ ] Tap a cell → vibrates; enable reduced-motion → silent.
+- [ ] Auto-call 3+ min untouched → screen stays lit. Stop → sleeps normally.
+- [ ] Background during auto-call, return → still lit.
+- [ ] Finish a round (remaining 0) → lock releases.
+- [ ] Back with bingo modal open → modal closes, app stays.
+- [ ] Back with settings open → sheet closes.
+- [ ] Back at root → Vietnamese exit dialog; "Ở lại" keeps state.
+- [ ] System font 200% → grid intact; Settings → Cỡ chữ bảng resizes; survives reload.
+- [ ] Gesture nav + status bar clear of the settings gear and footer.
+- [ ] Volume rocker during a call → media slider.
+- [ ] Launcher shows `Lô tô` with the brand icon; themed-icon mode tints it.
+- [ ] Cold start in dark mode → dark splash, no white flash.
+
+## Unresolved questions
+
+1. Fix the clipped web PWA icons (finding 2)? Same root cause, different scope —
+ changes GH Pages / PWA / OG card images.
+2. `boardTextScale` rungs 0.9/1/1.15/1.3 — enough range for users who ran
+ system font at 200%?
+3. Exit confirm currently fires at root regardless of round state. Worth
+ plumbing "is a round live" to native so an idle app exits without a prompt?