diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9588de4..88f7760 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -28,6 +28,57 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file - **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/`. +## Native Android build (Capacitor) + +The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under +`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code: + +- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle + run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula** + `brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable + non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so + `/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`. +- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0** + (`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer + `android-36.1` does NOT satisfy compileSdk 36. +- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install + --cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`). + Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific). +- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android && + ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug` + → `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap` + (dodges the corepack flake). +- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`; + `emulator -avd `, `adb install -r `, `adb shell monkey -p ru.eruditgame.app -c + android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep` + → run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a + full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour — + each tap advances one step — then Hint places a suggested word; commit → the robot replies). +- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces + edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom). + On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws + under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre** + button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav). + Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`, + `insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as + `--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer + that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`). + (2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the + **header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on + EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }` + (`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure + per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but + is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the + BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP: + `adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*' + | head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each + element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`). +- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets` + uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`. +- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-.tar.gz`, + keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT + `scrabble-solver/dawg` (those are the solver's pinned test fixtures). + ## Wire / schema evolution (client ↔ gateway ↔ backend) - **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never @@ -84,6 +135,20 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file eyeball per-variant tiles. - **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a plugin-config gotcha in wiring them). +- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies + with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in + **CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP** + browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are + already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN + fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit), + its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback. +- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.** + `@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import) + **replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from + `window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected + `Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject + `window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`); + `initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener). - **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env). Use **testcontainers** for container-backed tests. - **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600) diff --git a/.gitea/workflows/android-build.yaml.disabled b/.gitea/workflows/android-build.yaml.disabled new file mode 100644 index 0000000..8eb9463 --- /dev/null +++ b/.gitea/workflows/android-build.yaml.disabled @@ -0,0 +1,172 @@ +# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on +# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on +# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and +# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP). +# +# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without +# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline). +# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and +# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the +# runner host needs nothing pre-installed. +name: android-build +run-name: "android build ${{ github.sha }}" + +on: + workflow_dispatch: + inputs: + confirm: + description: 'Type "build" to confirm an APK build from master.' + required: true + default: "" + +permissions: + contents: read + +env: + NO_COLOR: "1" + # The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It + # both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__). + DICT_VERSION: ${{ vars.DICT_VERSION }} + VITE_DICT_VERSION: ${{ vars.DICT_VERSION }} + # Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset). + VITE_PAYMENTS_DISABLED: "1" + # The update overlay's store target; empty until publication (the button no-ops, and the version gate + # is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live. + VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }} + # Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it + # lives elsewhere (the default matches deploy/README.md's install path). + ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }} + ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }} + +jobs: + build: + if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }} + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java + # below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is + # missing — this doubles as the runner-access check (deploy/README.md). + - name: Verify the host Android SDK + run: | + sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$sm" ]; then + echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1 + fi + for pkg in "platforms/android-36" "build-tools"; do + if [ ! -d "$ANDROID_HOME/$pkg" ]; then + echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1 + fi + done + echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version + + - name: Compute version + native build env + id: prep + env: + PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} + run: | + # A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the + # versionCode is deterministic and strictly increasing across uploads. Refuse anything else + # rather than derive a versionCode from a "-N-gSHA" describe. + desc="$(git describe --tags --exact-match 2>/dev/null || true)" + case "$desc" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;; + esac + v="${desc#v}" + IFS=. read -r MA MI PA <<< "$v" + # 10# forces base-10 so a zero-padded part is never read as octal. + code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA )) + # The native SPA talks to the production origin (reuse the prod public base URL); strip any + # trailing slash so the Connect endpoint never doubles it. + gateway="${PUBLIC_BASE_URL%/}" + { + echo "tag=$desc" + echo "name=$v" + echo "code=$code" + echo "gateway=$gateway" + } >> "$GITHUB_OUTPUT" + echo "release $desc -> versionName $v versionCode $code, gateway $gateway" + + # Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball, + # NOT the scrabble-solver sibling (ui is a Node project outside go.work). + - name: Fetch dictionary DAWGs + run: | + mkdir -p "${GITHUB_WORKSPACE}/dawg" + curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" + tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg" + ls -la "${GITHUB_WORKSPACE}/dawg" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm@11.0.9 + + - name: Install deps + working-directory: ui + run: pnpm install --frozen-lockfile + + - name: Build the SPA (native flavour) + working-directory: ui + env: + VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }} + VITE_APP_VERSION: ${{ steps.prep.outputs.tag }} + run: pnpm run build + + # Copy the release DAWGs into dist/dict/@.dawg for the offline-first bundled tier + # (after the build, before cap sync copies dist/ into the native assets). + - name: Bundle the dictionaries + working-directory: ui + env: + DICT_DIR: ${{ github.workspace }}/dawg + run: node scripts/bundle-dicts.mjs + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + # Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps. + - name: Sync the native project + working-directory: ui + run: node_modules/.bin/cap sync android + + - name: Decode the release keystore + id: keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks" + echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT" + echo "keystore decoded -> signed release build" + else + echo "file=" >> "$GITHUB_OUTPUT" + echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)" + fi + + - name: Assemble the release APK + working-directory: ui/android + env: + ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain + + - name: Upload the APK artifact + uses: actions/upload-artifact@v4 + with: + name: erudit-${{ steps.prep.outputs.name }}-apk + path: ui/android/app/build/outputs/apk/release/*.apk + if-no-files-found: error diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 32eda42..425f51f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -353,6 +353,11 @@ jobs: # for the server-side confidential code exchange — a SEPARATE VK app from the Mini # App above. One VK ID "Web" app serves every contour -> unprefixed secret. GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} + # Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every + # contour. In the test contour the stamped client version is a commit hash (unparseable ⇒ + # fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant. + GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }} + GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }} # Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on # test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off. GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }} diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index af01215..b45b4bb 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -112,6 +112,12 @@ jobs: # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} + # Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours + # (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces + # here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking + # wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly. + GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }} + GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }} # Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm. # Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh. GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index 959ea6f..a4ec135 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -45,6 +45,7 @@ installs away cleanly, and (c) opens offline-first as a soft guest with local pl | Payments in MVP | Hidden (deferred; reuse the distribution flag) | | appId (permanent) | **`ru.eruditgame.app`** | | App display name | **`Эрудит`** (Cyrillic) | +| Toolchain (locked) | **Capacitor 8** (`@capacitor/*` `^8`); **compileSdk/targetSdk 36, minSdk 24**; **JDK 21** (required — `@capacitor/android` compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK `platforms;android-36` + `build-tools;36.x` | **Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code, commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo @@ -57,23 +58,166 @@ client-only (no server change). --- +## Progress (as-built) + +Kept current as parts land so a fresh session resumes without re-deriving. Verify against code. + +- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated + under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in + `ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build` → `cap sync` → `./gradlew assembleDebug` + builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator. + `svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in + `.claude/CLAUDE.md` → "Native Android build (Capacitor)". + **Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э» + brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive + icons + splashes. Superseded by the icon rebrand below. +- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts` + `gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL, + `screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left + as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP + via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus + a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note + (`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in + `vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean; + `?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch + browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs). +- **C. Client-version gate — ✅ DONE & verified (2026-07-12).** Server: new `gateway/internal/clientver` + (parse + compare), `GATEWAY_MIN_CLIENT_VERSION` config (empty ⇒ dormant, validated at load), and the + gate in `connectsrv` — `Execute` returns `result_code="update_required"` before the registry lookup, + `Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client: + `X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store + + `UpdateOverlay.svelte` (native → `VITE_RUSTORE_URL`, web → reload), `retry.ts` maps + `FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go + `clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl. + `update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app + store-update SDK noted Out of scope. +- **D. Offline-first — ✅ CODE-COMPLETE, e2e-verified & on-device-smoke-verified (D.1–D.6 done, 2026-07-12).** + The offline path is proven on-device (Pixel_10 / API 37, airplane mode): cold-boot → offline guest lobby, a + full local vs_ai turn (bundled dawg — Hint placed "FEZ", the robot replied "NEEDFIRE"), New Game offering + both modes; the smoke also surfaced + fixed a native-chrome edge-to-edge safe-area bug (below). Remaining for + D: the **reconcile-online leg on-device** (deferred — it hits prod, minting a guest) and the **deferred + local-game-visibility decision** (below). D.5 is also covered by `e2e/native.spec.ts` (vs_ai move + hotseat). + - **D.1 + D.2 (foundations) — ✅ DONE & committed `bcd5a1d` (2026-07-12).** `ui/scripts/bundle-dicts.mjs` + (release DAWGs → `dist/dict/@.dawg`, keyed on `VITE_DICT_VERSION`, `OUT_DIR` override + for the e2e); the `dict/loader.ts` **bundled tier** (between IndexedDB and network, **native-gated** — + see the §D.1 correction); `__DICT_VERSION__` vite define + declaration; `lib/localguest.ts` (persisted + device-local guest id, no DB row) + `common.guest` i18n; `NewGame.svelte` offline vs_ai/hotseat creates + fall back to `__DICT_VERSION__` and `localGuestId()`/`t('common.guest')` when there is no session. + - **D.3 (cold boot) + D.4 (reconciliation + silent seam) — ✅ DONE & verified (2026-07-12).** The native + no-session cold boot lands in the offline lobby as a device-local guest (`app.svelte.ts` bootstrap + `else if (native)` branch — `localGuestId()` + `setOfflineMode(true,false)` + `scheduleRecovery(0)`); + lazy `reconcileServerGuest()` mints + adopts a server guest and clears the auto-offline when the gateway + is reachable (kicked at boot + by the recovery poll + the online event). Silent seam: + `transport.ts` `exec` gained `{ silent, allowOffline }` (suppress the update overlay **and** bypass the + kill switch), plus `authGuestSilent(locale)` on the `GatewayClient` interface, the real transport and the + mock. `native.ts` `initNativeShell` made bridge-tolerant. New `e2e/native.spec.ts` (Capacitor injected; + boot→offline-lobby, local vs_ai move, hotseat start, reconcile→online) + `playwright.config.ts` bundles + the dawgs into `dist-e2e/dict/`. `svelte-check` 0, `vitest` 591, web + native builds clean; e2e runs in CI. + See §D.3/§D.4 for the as-built corrections (shouldBootOffline **not** changed; `allowOffline` + + checkReachable-token findings; the `__native.reconcile` e2e hook). + - **D.6 (Profile tg/vk hide on native) — ✅ DONE & verified (2026-07-12).** `Profile.svelte` gates + `telegramLinkable`/`vkLinkable` on `!nativeShell` (`clientChannel()` android/ios), so the Telegram + VK + LINK buttons are hidden on the native build; email + account management (incl. any existing link's UNLINK, + a redirect-free gateway call) stay. Covered by the `e2e/native.spec.ts` reconcile test (Profile → no Link + Telegram / Link VK buttons, email present). + - **Native chrome: Android 15+ edge-to-edge safe-area — ✅ FIXED & on-device-verified (2026-07-12, owner-confirmed).** + targetSdk 36 forces edge-to-edge, so the WebView draws behind the system bars; the app chrome overlapped the + status bar (top nav, untappable) and the gesture-nav home indicator (the game's centre Hint button — side + buttons fine). Two-part CSS fix consuming the SystemBars core plugin's injected `--safe-area-inset-*`: + the **bottom** via the `--tg-safe-*` token (`app.css`), the **header top** via a native-only `.bar` rule + (`Header.svelte` — its top inset had been Telegram-fullscreen-scoped only, so the token alone didn't reach + it). No dep/config (SystemBars ships in `@capacitor/core` v8). Verified on Pixel_10 / API 37 by live-WebView + CDP measurement (header title y=11→65, `--safe-area-inset-*` = 54/24px) **and** owner re-test on their device. + Full gotcha + CDP recipe in `.claude/CLAUDE.md`. Commits `4a0689a` (bottom) + `49c5379` (top). + - **DEFERRED DECISION (owner-agreed 2026-07-12) — native local-game visibility.** The online lobby lists + only server games (`Lobby.svelte:42/54`), so a native guest's **device-local vs_ai/hotseat games hide from + the lobby once reconciled online** (the pre-existing offline↔online split). For native this IS the primary + flow, so the behaviour **must change** — a native guest should still see / resume their local games when + online. Owner agreed it needs changing but wants to design it **at the very end of the Android work (before + release — see §G)**; out of D.3/D.4 scope, tracked here so it is not lost. +- **E. CI — signed APK artifact — ✅ CODE-COMPLETE & locally-proven (2026-07-12).** New manual workflow + `.gitea/workflows/android-build.yaml` (`workflow_dispatch` + `confirm=build`, `if` gated to `master` — + mirrors `prod-deploy.yaml`; never on a PR) builds the native-flavoured SPA, bundles the offline dicts, and + assembles a release APK uploaded as a run artifact. Like `prod-deploy`, it is **provable only by a dispatch + from `master`** (that is §G), so E was verified as far as possible now: locally via `assembleDebug` + a + **keyless** `assembleRelease` (→ `app-release-unsigned.apk`, `versionCode 1017000` / `versionName 1.17.0` + for a `v1.17.0` tag — confirmed in `output-metadata.json`), `svelte-check` 0, YAML structure checked. + As-built: + - **`ui/android/app/build.gradle`:** `versionCode`/`versionName` read `-PversionCode`/`-PversionName` (defaults + keep local `assembleDebug` building). **CORRECTION vs the plan snippet — use `=` assignment** + (`versionCode = (…).toInteger()`), NOT the command form `versionCode (…).toInteger()`: the latter binds + `.toInteger()` to the DSL setter's null return (`> Value is null`). A guarded `signingConfigs.release` reads + the keystore from env and is attached **only when the keystore file exists** — so **no keystore ⇒ an UNSIGNED + release APK, not a failure** (a dispatch proves the pipeline before the keystore exists). + - **Toolchain:** the **CI runner is a host-executor on a remote Debian host**. JDK 21 comes from + `actions/setup-java`; the **Android SDK is host-provisioned** (pre-installed at `ANDROID_SDK_DIR`, default + `/opt/android-sdk`), with a fail-fast **Verify the host Android SDK** pre-flight that also checks the `runner` + user's read/exec access (`platforms;android-36` + `build-tools`). Unit tests need no new tooling (they ride + the existing `unit`/`ui` jobs). + - **Env:** `VITE_GATEWAY_URL` reuses `vars.PROD_PUBLIC_BASE_URL` (trailing slash stripped); `VITE_DICT_VERSION` / + `DICT_VERSION` = the `DICT_VERSION` var; `VITE_PAYMENTS_DISABLED=1`. **`VITE_STORE_URL` was renamed to + `VITE_RUSTORE_URL`** (owner) — left **empty** via the (unset) `vars.ANDROID_RUSTORE_URL`, so the update button + no-ops until publication (`UpdateOverlay.svelte` already guards `if (url)`; the gate is dormant in the MVP). + - **versionCode from an exact tag:** the workflow refuses anything but a clean `git describe --exact-match` + `vX.Y.Z` (deterministic + monotonic store versionCode); §G tags before dispatch. + - **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch). + Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`, + `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. +- **F. Docs — ✅ DONE (2026-07-12).** Baked the Android work into the live docs: `docs/ARCHITECTURE.md` §2 + (the client-version gate + the frozen wire contract + the gate × offline rule), §3 (the local-guest / + server-guest / reconciliation identity model) and §13 (the native Capacitor build — bundle model, bundled + dicts, `versionCode` scheme, `file://` origin, RuStore); `docs/FUNCTIONAL.md` (+ `_ru` mirror) a new **Native + app (Android)** domain (offline-first guest launch, email soft-registration, no TG/VK login, hidden + purchases, update-required); `docs/TESTING.md` (the `clientver`/gate Go tests, the `native`/`update` e2e + + the `retry` mapping, and the manual **on-device Android smoke checklist**); `deploy/README.md` (the + `GATEWAY_MIN_CLIENT_VERSION` var + the wire-break bump discipline + the Android build/release runbook — + keystore, secrets, dispatch, RuStore upload); `ui/README.md` (the native `VITE_*` build vars). Every + referenced test file was confirmed to exist. +- **G — pending; gated on the offline-model redesign (was G-step-0).** The deferred native + local-game-visibility decision grew into an owner-approved cross-cutting change (web + native + a small + additive backend/wire bit): remove the explicit offline toggle → one `netState` machine, unify the lobby, + two-tier version gate. **Designed + staged (O1–O7)** in the "Offline-model redesign" section below. + **➡ Next actionable work: implement O1** (the pure `netState` reducer, test-first) via `stage-implementation`, + then O2–O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of + O1–O7 is started. Branch `feature/android-native`, local/unpushed. + +Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate +*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today +the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed +square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable +(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present. +RuStore publication prerequisites gate release only. + +--- + ## Prerequisites to start DEVELOPMENT (on the other device) Have all of these before writing code: -- **JDK 17** (Temurin/OpenJDK). +- **JDK 21** (Temurin/OpenJDK) — **required** by Capacitor 8: `@capacitor/android` sets + `sourceCompatibility/targetCompatibility = VERSION_21`, so a JDK 17 Gradle run fails with + `invalid source release: 21`. (Homebrew: `brew install openjdk@21`.) - **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or* - cmdline-tools. Install: `platform-tools`, `platforms;android-34`, `build-tools;34.0.0`. + cmdline-tools. Capacitor 8 targets **compileSdk/targetSdk 36, minSdk 24**, so install: + `platform-tools`, `platforms;android-36`, `build-tools;36.0.0` (a newer build-tools such as + `36.1.0` is also accepted — AGP treats it as a minimum). Gradle 8.14.3 / AGP 8.13 arrive via the + wrapper `cap add android` generates. - **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain. - **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login and the CI watcher exactly as the owner's global setup (`~/.claude` tooling: `python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`). - **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the - sibling `scrabble-solver` next to it. The sibling holds the committed dictionaries - (`scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`) that offline-first bundles, and - the solver library the backend/CI use. -- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. Pixel, - API 34). + sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the + `go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned, + production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact** + `scrabble-dawg-.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI + job already `curl` exactly that tarball, keyed on the shared Gitea variable `DICT_VERSION`. + Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needs + `DICT_VERSION` + network access to the release, **not** `scrabble-solver`. (`scrabble-solver/dawg/*.dawg` + are the solver's committed test fixtures — byte-identical to a build but pinned to the solver + commit, not the versioned production set.) +- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. a + Pixel AVD; any recent API image). - **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway is only needed to exercise the version gate locally (optional). @@ -152,7 +296,7 @@ it and stay a local guest (do not overlay). Implementation seam: the reconciliat Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence is: scaffold → native-correct → gate → offline-first → CI → docs → release. -### A. Capacitor scaffolding +### A. Capacitor scaffolding — ✅ DONE - `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps `@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`, @@ -196,7 +340,7 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r **Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`) `./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen. -### B. Native web-build correctness (file:// origin) +### B. Native web-build correctness (file:// origin) — ✅ DONE 1. **One origin helper** — new `ui/src/lib/origin.ts`: ```ts @@ -228,15 +372,20 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r } ``` Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they - hide the buy actions (grep `isGooglePlayBuild` under `ui/src`). Keep the "go to RuStore" stub - `isGooglePlayBuild()`-only. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`, + hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub + `isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() && + !isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key + `wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the + same note can replace the RuStore stub in the later Google Play anti-steering variant. + `purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the + state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_RUSTORE_URL?: string`, `VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`. 4. **Native env matrix** — see Build & env. **Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the purchase actions are absent; `pnpm check` + `pnpm test:unit` pass. -### C. The client-version gate +### C. The client-version gate — ✅ DONE #### C1. Backend (gateway) @@ -313,96 +462,194 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass. before the throw. - catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is `UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`. - - **Reconciliation exception:** provide a variant used by background guest-reconciliation that does - NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean - on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the - seam. Foreground `auth.*` keep the overlay behaviour. + - **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant + (swallows `update_required` without raising the overlay) has no caller until D.4, so it is added + there with its caller rather than speculatively in C. In C every foreground call that gets + `update_required` raises the overlay; offline play never trips it (the network kill switch). - **`ui/src/lib/retry.ts` `toGatewayError()`**: add `case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`. - **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable; shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) → - `window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys + `window.open(import.meta.env.VITE_RUSTORE_URL, '_system')`; web → `location.reload()`. i18n keys `update.title/body/action` (add siblings to the maintenance keys). - **`ui/src/App.svelte`**: import + place `` right after `` (line 139). - **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`. -- **Tests:** `update.svelte.test.ts`; extend `retry.test.ts` (`FailedPrecondition → 'update_required'`); - Playwright `update.spec.ts` driving `__update.on()`. +- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via + Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module, + which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other + `*.svelte.ts` store it is covered by the e2e, not a unit test. **Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged. -### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) +### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — ✅ CODE-COMPLETE Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts` and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch with no network**. -1. **Bundle dictionaries in the APK.** - - New build step `ui/scripts/bundle-dicts.mjs`: copy `../scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg` - → `ui/dist/dict/@.dawg` (dictKey naming). Run only in the native pipeline - (after `pnpm build`, before `cap sync`). Web builds stay slim. - - Vite `define`: add `__DICT_VERSION__` from `VITE_DICT_VERSION` (mirrors `__APP_VERSION__`); - declare it in `vite-env.d.ts`. - - **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: between the IndexedDB tier (line 73-85) - and the network tier (line 87), add a **bundled tier** that `fetch('./dict/'+key+'.dawg')` - (relative — served from app assets on native, 404 on web → falls through). On a hit, build the - `Dawg`, cache in memory, seed IndexedDB (`idbPutDawg`), and return. This is the only change that - lets a never-online device obtain a dictionary. - - Offline local-game creation must request the **bundled version**: where `NewGame.svelte` - offline-create and `localgame` resolve the dict version, use `__DICT_VERSION__` so the requested - `(variant, version)` matches the bundled file (grep the offline `localSource.create` calls in - `NewGame.svelte:83-96,289-296` and the dict-version resolution in `gamesource.ts`/`localgame`). -2. **Local-guest identity** — new `ui/src/lib/localguest.ts`: a device-local id + default display - name (e.g. localized "Гость"), persisted (IndexedDB `scrabble` DB / localStorage). Used as the - human seat's account id in a local vs_ai game when there is no server session (today that id comes - from the session — see `localgame/source.list.test.ts:64`). Hotseat seats stay independent local - identities (already so). -3. **Cold offline-first boot** — `ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`: - - Extend `shouldBootOffline` (offline.ts:85) so that on a **native** channel, no session may still - boot offline as the local guest (dicts are bundled). Keep the current web rule intact (web still - needs a prior online session). - - In `bootstrap()` (app.svelte.ts): when native and (no cached session) and (offline preference on - OR the network is unreachable at boot), set `setOfflineMode(true, …)`, establish the local guest, - preload the bundled dicts, and land in the **lobby** (not `Login`). Preserve the existing online - path (guest/email) when reachable. - - `offlinePreloadEligible` (offline.ts:69): treat a native channel as eligible (drop the - `standalone && hasEmail` web gate for native), or bypass preload entirely on native since the - dicts are bundled. -4. **Lazy server-guest reconciliation** — when native and online and no server session: background - `auth.guest` (the silent variant from C2), cache the session, adopt it; unlock online features. - Guard against duplicates (only when no cached session). Local games remain device-only. On - `update_required`, stay offline silently (no overlay). -5. **Ensure both offline modes render for the local guest.** `NewGame.svelte` gates the offline - flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both - the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the - local-guest id. -6. **Soft registration** stays: the local/server guest can register by email → upgrade (existing - flow), adopting the server guest first if present. +**Status:** D.1–D.6 are **done & verified** (foundations committed `bcd5a1d`; boot + reconciliation + +Profile + the Android edge-to-edge safe-area fix committed on `feature/android-native`, 2026-07-12), with +`e2e/native.spec.ts` green (chromium + webkit) AND an **on-device emulator smoke** (Pixel_10 / API 37, +airplane mode): cold-boot → offline guest lobby, offline vs_ai played end-to-end (bundled dawg — Hint +placed "FEZ", the robot replied "NEEDFIRE"), New Game offers both modes. The smoke surfaced a native-chrome +bug on **Android 15+ edge-to-edge** (WebView < 140 reports `env(safe-area-inset-*)`=0 → the top nav draws +under the status bar and the game's centre Hint button under the gesture-nav home indicator) — **fixed** by +consuming the SystemBars core plugin's injected `--safe-area-inset-*`: the bottom via the `--tg-safe-*` token +(`app.css`), and the header's top inset via a native-only `.bar` rule (`Header.svelte` — its top inset was +Telegram-fullscreen-scoped only, so the token alone did not reach it). No dep/config (the plugin ships in +`@capacitor/core` v8); full story in `.claude/CLAUDE.md`. Remaining for D: the reconcile-online leg +on-device (hits prod — a guest mint) and the deferred local-game-visibility decision (§G). **Verify every +line ref below against current code.** -**Tests:** extend the local-game/offline unit tests for the bundled-dict tier (a `fetch` mock -returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a -Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and -starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the -gate's server path in Go tests.) +**Decisions locked this session (owner-approved) — bake these before implementing:** +- **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current + online-session rule (they still need a prior session). The native channel always lands the user in the + lobby, online or offline. +- **Soft registration reuses the existing `Profile` screen** as the guest sign-in / account surface (it + already shows the email / Telegram / VK upgrade options for a guest). No new sign-in UI is built in D. +- **Hide the Telegram + VK link buttons on the native build** on Profile: VK ID web-login is a full-page + redirect to `id.vk.com` that cannot return into the Capacitor app (it strands on the web redirect URI), + and the Telegram Login Widget is unreliable in a WebView. **Email works** (pure gateway calls, no + redirect). Native Telegram/VK login (native SDKs / deep-link OAuth — "the pretty native popups") is a + **separate later stage**, consistent with the locked "guest+email" surface and "Out of scope: VK/Telegram + login in the native build". +- **Local-guest display name** = localized `common.guest` ("Гость" / "Guest"). + +1. **Bundle dictionaries in the APK. ✅ DONE.** + - `ui/scripts/bundle-dicts.mjs`: copies the DAWGs from the unpacked **`scrabble-dictionary` release** + (dir via **`DICT_DIR`**; **NOT** `../scrabble-solver/dawg`) → `/dict/@.dawg`. + `dictKey(variant,version)` = `` `${variant}@${version}` `` (`lib/dict/store.ts:31`). `dawgFor` maps + `scrabble_en→en_sowpods`, `scrabble_ru→ru_scrabble`, `erudit_ru→ru_erudit` (mirrors `e2e-dict.mjs`). + Version = `VITE_DICT_VERSION` (default `dev`); `OUT_DIR` overrides the root (the e2e points it at + `dist-e2e`). Run only in the native pipeline (after `pnpm build`, before `cap sync`); web builds skip + it and stay slim. + - Vite `define` `__DICT_VERSION__` (from `VITE_DICT_VERSION`, default `dev`; mirrors `__APP_VERSION__`) + + declared in `vite-env.d.ts`. + - **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: a **bundled tier** sits between the IndexedDB + tier (now tier 1) and the network tier (now tier 3). **CORRECTION vs the original plan:** it is + **native-gated** (`clientChannel()` android/ios), NOT "fetch always, 404 on web". A relative + `fetch('./dict/'+key+'.dawg')` on the web would hit the **gateway's own session-gated `/dict/` route** + (a real server route), not a clean 404 — so the bundled fetch is skipped off-native and only tried in + a packaged app (its own assets). On a hit: build the `Dawg`, cache in memory, seed IndexedDB, return + (no network metric — a bundled hit is a local asset). The e2e simulates native (see Tests). + - Offline creates request the **bundled version**: `NewGame.svelte` offline vs_ai (`~line 84`) and + hotseat (`~line 277`) use `app.profile?.dictVersions?.[v] ?? __DICT_VERSION__`, so a profile-less + local guest gets the bundled `(variant, version)`. +2. **Local-guest identity. ✅ DONE.** `ui/src/lib/localguest.ts`: `localGuestId()` mints + persists a + device-local id in `localStorage` (`scrabble.localGuestId`, prefix `localguest:`, no crypto API for the + old engines) + `isLocalGuestId()`. The **display name is not in the module** — it is `t('common.guest')` + at the call site, so the module stays i18n-free and node-testable. `NewGame.svelte` vs_ai human seat + (`~line 99`) now uses `accountId: app.session?.userId ?? localGuestId()` and + `name: app.profile?.displayName ?? t('common.guest')`. Hotseat seats stay independent local identities + (`buildSeats` — unchanged). +3. **Cold offline-first boot — ✅ DONE & verified (2026-07-12)** (`ui/src/lib/app.svelte.ts`). **High + blast-radius: app startup for every platform — every change is native-gated; web / PWA / TG / VK stay + byte-for-byte.** As built: + - The blocking Login was **`bootstrap()`'s `else` of `if (saved)`** (no cached session, formerly + `navigate('/login')`). On native it is now the offline-first entry: `localGuestId()` + + `setOfflineMode(true, false)` (**non-sticky** auto-offline, so reconciliation may clear it) + land in + the lobby (`navigate('/')` only if on login/confirm) + `scheduleRecovery(0)` to kick reconciliation. + Web keeps the `navigate('/login')` else. `app.ready = true`. + - **CORRECTION — `shouldBootOffline` was NOT changed.** The native-no-session case is fully caught in + that `else` branch (there `hasSession` is provably false and `saved`'s `if`-block — the web + canOffline logic — is skipped), so a `native` param would be unused in the load-bearing path. The + plan's "add a native-no-session path to `shouldBootOffline`" is dropped, and the matching unit test + with it (the boot decision is exercised by `e2e/native.spec.ts`). + - **`offlinePreloadEligible` was NOT changed either** — it already returns false off a standalone web + PWA (native is not `isStandalone()`), so the server preload is already skipped on native; no edit needed. + - The lobby renders without a session/profile: `Lobby.svelte`/`NewGame.svelte` use optional chaining + and the offline branch loads only device-local games (no session-gated `gamesList`). `NewGame` gates + its offline flows on `guest || offlineMode.active`, both true for the local guest. +4. **Lazy server-guest reconciliation + the silent seam — ✅ DONE & verified (2026-07-12).** As built: + - **Silent seam:** `exec` gained `opts?: { silent?: boolean; allowOffline?: boolean }`. + **CORRECTION — `silent` alone was not enough:** reconciliation runs while the app is still in + auto-offline, so `exec`'s `assertOnline()` kill switch would refuse it — hence **`allowOffline`** + (bypass the kill switch, like the reachability probe). `silent` gates the two `exec` + `reportUpdateRequired()` sites (result-code + catch); the `subscribe` catch keeps the overlay + (reconciliation never subscribes). New `authGuestSilent(locale)` on the `GatewayClient` interface + (`lib/client.ts`), the real transport (`{ silent: true, allowOffline: true }`) and the mock. + - **Reconciliation — `reconcileServerGuest()` (`app.svelte.ts`):** native + no session → `authGuestSilent`, + `setOfflineMode(false)` **before** `adoptSession` (so adopt's `profileGet`/stream ride the online + transport, not the kill switch), then adopt. Guarded by `reconcileInFlight` + `app.session` (never a + second guest). `update_required`/unreachable are swallowed (stay a local guest). Local games stay + device-only. Fired at boot and by the recovery poll + the online event. + - **CORRECTION — `checkReachable` cannot gate a session-less guest:** its authenticated probe rejects + with no token, so it never reports "reachable" for a local guest. The recovery poll (`scheduleRecovery`) + therefore routes the **no-session** case straight through `reconcileServerGuest` (the mint attempt IS + the reachability check); the session case keeps `checkReachable`. + - **`scheduleRecovery` is now mock-guarded** (like `initNetworkReactivity`) so the e2e's boot does not + race a mock reconcile to online before the spec observes the offline lobby; the e2e drives + reconciliation via a new **`window.__native.reconcile()`** hook (mirrors `__conn`/`__maint`/`__update`). +5. **Both offline modes render for the local guest — ✅ DONE & verified (2026-07-12).** `NewGame` gates the + offline flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both + "quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id + (D.2). Verified: `e2e/native.spec.ts` plays a vs_ai move + starts a hotseat game, and the on-device smoke + (Pixel_10 / API 37) played a full offline vs_ai turn (Hint "FEZ", robot "NEEDFIRE") and reached New Game + offering both modes. +6. **Soft registration = reuse the `Profile` screen — ✅ DONE & verified (2026-07-12).** As built: + - **Reaching Profile needed no change:** guests keep the Profile tab (`SettingsHub.svelte:27` only hides + friends/wallet for guests; `:33` hides profile/friends/wallet in **offline** mode), so once the native + guest is online and reconciled (D.4 clears the auto-offline) Profile shows and email sign-in works. + Offline you cannot register anyway (every method needs the server), so the offline-hidden behaviour is + correct. + - **Hid tg/vk on native:** `Profile.svelte` gates `telegramLinkable = loginWidgetAvailable() && !nativeShell` + and `vkLinkable = vkWebLinkAvailable() && !nativeShell` (`nativeShell = clientChannel()` android/ios), so + both LINK buttons are hidden on native while email + account management (and an existing link's UNLINK — a + redirect-free `link.unlink` call) stay. Verified in `e2e/native.spec.ts` (the reconcile test opens Profile + and asserts no Link Telegram / Link VK, email present; `loginWidgetAvailable`/`vkWebLinkAvailable` are true + under the mock, so the buttons show on web — the native count-0 proves the gate). + +**Tests — ✅ DONE for D.3/D.4 (2026-07-12):** +- **Unit (vitest, node):** no NEW unit test was added for D.3/D.4 — the boot decision, reconciliation and + silent seam live in `$state`/transport modules the plugin-less vitest cannot import (cf. `update.svelte.ts`), + and the `shouldBootOffline` native path was dropped (§D.3). `localguest` + the bundled-dict tier are D.1/D.2 + coverage (a bundled-tier `fetch`-mock unit test remains a D-close nicety if wanted). `vitest` stays 591. +- **Playwright — new `e2e/native.spec.ts`** (NOT an extension of `offline.spec`): **GOTCHA — inject + `window.androidBridge`, NOT `window.Capacitor.getPlatform`.** `@capacitor/core` (loaded during boot by + `initNativeShell`) derives the platform from `window.androidBridge` / `window.webkit` and **replaces** any + pre-set `window.Capacitor` with its own shim whose `getPlatform()` is `web` — so a bare injected + `window.Capacitor = { getPlatform: () => 'android' }` is clobbered and the boot falls to `/login`. + `simulateNative` injects `window.androidBridge = { postMessage(){} }` (+ a Capacitor stub for the pre-core + window), which makes `clientChannel()` resolve to `android`. Two tests: (1) boot → **assert the offline guest + lobby (not login)**, play a local vs_ai move (bundled dawg), Back, `window.__native.reconcile()` → assert + **online lights up** (offline tint gone, the seeded online game appears, Stats re-enabled); (2) boot → start a + 2-player hotseat game. The `playwright.config.ts` webServer now also runs `DICT_DIR="${E2E_DICT_DIR:-…}" + OUT_DIR=dist-e2e node scripts/bundle-dicts.mjs` → `dist-e2e/dict/@.dawg` (version flows from + `VITE_DICT_VERSION`, default `dev`, matching `__DICT_VERSION__`). **Ran locally green (chromium + webkit, 4/4) + against the installed browsers + the sibling `scrabble-solver/dawg`; also runs in CI.** +- **GOTCHA resolved:** `initNativeShell` was made **bridge-tolerant** (`try/catch` around the `@capacitor/app` + import + `addListener`) rather than stubbing a fake Capacitor bridge — cleaner and defensive for a real WebView + that lacks the App plugin. **Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on -silently establishes a server guest and lights up online play. +silently establishes a server guest (Profile + online play light up). Emulator smoke recipe: `.claude/CLAUDE.md` +→ "Native Android build", with the extra `DICT_DIR= VITE_DICT_VERSION= node +scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`. -### E. CI — signed APK artifact +### E. CI — signed APK artifact — ✅ CODE-COMPLETE (workflow written + `build.gradle` wired + locally proven; the signed dispatch is §G) New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s `workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps: -1. Checkout this repo **and the sibling `scrabble-solver`** — copy the checkout + Node/pnpm setup - block from `.gitea/workflows/ci.yaml` (the `ui` job). The sibling is needed for the bundled dicts. +1. Checkout this repo; copy the Node/pnpm setup block from `.gitea/workflows/ci.yaml` (the `ui` job). + Add the **"Fetch dictionary DAWGs"** step verbatim from `ci.yaml` (the `curl … + scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz` + + untar into `${GITHUB_WORKSPACE}/dawg`), keyed on the `DICT_VERSION` Gitea variable — **this**, not + the `scrabble-solver` sibling, is the source of the bundled dicts. (`scrabble-solver` is not needed + by the Android build: `ui` is a Node project outside `go.work`.) 2. `pnpm install --frozen-lockfile`. 3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`, `VITE_DICT_VERSION` = the release dict version. -4. `node ui/scripts/bundle-dicts.mjs` (copy the `.dawg` into `ui/dist/dict/`). -5. Setup JDK 17 (`actions/setup-java@v4`, temurin 17). -6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-34`, - `build-tools;34.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual. +4. `DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs` (copy the release `.dawg` + files into `ui/dist/dict/`). +5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21). +6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`, + `build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual. + *As built:* the runner is a **host-executor** on a remote Debian host, so the **SDK is host-provisioned** + (`ANDROID_SDK_DIR` var, default `/opt/android-sdk`) and a **Verify the host Android SDK** step fail-fasts on a + missing package / no runner-user access; JDK 21 still comes from `actions/setup-java`. 7. `npx cap sync android`. 8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the tag (scheme below). @@ -417,11 +664,14 @@ for the MVP. **`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` → `versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`. Wire in `ui/android/app/build.gradle` `defaultConfig`: -`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`, -`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release` -reading env/props (guarded so a local `assembleDebug` still builds unsigned). +`versionCode = (project.findProperty('versionCode') ?: '1').toInteger()`, +`versionName = (project.findProperty('versionName') ?: '0.0.0').toString()`, plus a guarded +`signingConfigs.release` reading env/props. **Use `=` assignment, not the command form** +`versionCode (…).toInteger()` — the latter binds `.toInteger()` to the DSL setter's null return +(`> Value is null`). The signing block attaches only when `ANDROID_KEYSTORE_FILE` exists, so a keyless +`assembleRelease` (and every `assembleDebug`) builds UNSIGNED instead of failing. -### F. Docs (bake in the same PR) +### F. Docs (bake in the same PR) — ✅ DONE - `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** + the gate×offline rule. New sections — the **identity model** (local guest / server guest / @@ -434,14 +684,23 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned). test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway; network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped). -- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`, - `VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. +- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_RUSTORE_URL`, + `VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. Gitea CI vars/secrets + for the APK build: `ANDROID_RUSTORE_URL` (var, empty until publish), `PROD_PUBLIC_BASE_URL` (reused as the + gateway origin), and the release-signing secrets `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / + `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`. - `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that ships an incompatible wire change. ### G. Release + owner handoff +0. **Land the offline-model redesign — ✅ DONE (O1–O7, 2026-07-13; staged detail below).** Resolved the deferred + native local-game-visibility decision — a native guest now sees / resumes their device-local games once online + (the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline + is an implicit **net-state machine** (O1/O2), the lobby is unified (O5), and the **two-tier** version gate is in + (O4). Cross-cutting (web + native + a small additive backend/wire bit); **contour-safe** (both version vars empty + ⇒ dormant; the wire add is additive). The remaining G steps below are the release chain. 1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant). 2. Promote `development` → `master`; tag `vX.Y.0`. 3. Dispatch `android-build`; watch green; retrieve the signed APK. @@ -450,6 +709,324 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned). --- +## Offline-model redesign (design — resolves G-step-0) + +> Owner-approved design (brainstormed 2026-07-12). Cross-cutting web + native + a small **additive** +> backend/wire bit. This is the Android release's G-step-0 and its own staged work; `writing-plans` turns it +> into the implementation plan. English throughout; the FBS change is additive-only (§frozen contract). + +### Goal & locked decisions + +Remove the explicit online/offline toggle. Offline becomes an **implicit, detected** state driven by a single +**net-state machine**. vs_ai stays as-is (offline → local, online → server; the app decides by the detected +state). The lobby becomes **unified**. The version gate gains a **soft** tier and its hard tier degrades to +offline instead of a terminal lockout. TG/VK stay **always-online**. Client-only except the additive +soft-tier threshold + signal. + +| Decision | Choice | +|---|---| +| Offline trigger | Implicit — detected connectivity + version, **no user toggle** | +| State model | **Single net-state machine** (replaces the two-layer `connection` / `offlineMode` split) | +| Switch UX | **Auto + a toast**, no dialog; hysteresis against flap; self-heal to online | +| Server vs_ai | **Kept** — offline → local, online → server; identical create flow, app decides by state | +| With-friends create | Explicit online/offline choice; offline **disabled (greyed)** when no network; create always works (→ local hotseat) | +| Lobby offline | Unified; server games **greyed from the last cache**, local games active | +| Version — critical (hard) | Terminal lockout → now a **notice "Update / Play offline"**, then forced offline | +| Version — recommended (soft) | **Built now** — notify but still play online (`GATEWAY_RECOMMENDED_CLIENT_VERSION` + an additive signal) | +| TG/VK | **Always online** (exempt); no local lobby / offline states | +| Native net signal | Add **`@capacitor/network`** as a hint; web keeps `navigator` + gateway probes | +| Migration | Clear the persisted `offlinePref` (nobody stuck without a toggle) | + +### The net-state machine + +One reactive `netState` absorbs `connection.svelte.ts` + `offline.svelte.ts`; the ~14 readers of +`offlineMode.active` migrate to a derived `offline` boolean and the "Connecting…" readers to `connecting`. +Written as a pure reducer (reusable for a future iOS shell). + +**States** +- `online` — gateway reachable, version accepted. Full features. May carry an orthogonal dismissible + **`updateRecommended`** nudge (soft tier); play continues. +- `connecting` — a call/probe is currently failing but the hysteresis window has not elapsed. "Connecting…"; + chrome stays online. Transient — the anti-flap buffer. **Not** offline (no kill-switch). +- `offlineNoNetwork` — sustained unreachable (hysteresis exceeded). Offline mode (blue chrome, local-only + active lobby, transport kill-switch). Self-heals. +- `offlineVersionLocked` — gateway reachable but version < min (critical). Offline mode + the + "Update / Play offline" notice. **Sticky** — exits only via an app update (fresh version next boot). + +`offline` (kill-switch / blue chrome / local play) = `offlineNoNetwork || offlineVersionLocked`. + +**Events:** `callFailed` · `callOk`/`probeOk` · `probeFailed` · `osOffline`/`osOnline` (navigator / +`@capacitor/network` hints) · `versionRejected` (update_required) · `versionRecommended` (soft) · `boot`. + +**Transitions** +| From | Event | To | Side-effect | +|---|---|---|---| +| online | callFailed / osOffline | connecting | start hysteresis (probe + timer) | +| connecting | probeOk / callOk | online | — | +| connecting | hysteresis exceeded (K fails / `OFFLINE_DEBOUNCE_MS`) | offlineNoNetwork | toast "offline" | +| offlineNoNetwork | osOnline | (probe) | trigger a probe; stay until it wins | +| offlineNoNetwork | probeOk / callOk | online | toast "back online" | +| **any** | versionRejected | offlineVersionLocked | show the Update/Offline notice (supersedes the soft nudge) | +| offlineVersionLocked | probe (still rejected) | offlineVersionLocked | stays; only an app update exits | +| online | versionRecommended | online (+ nudge) | dismissible "update available" banner | +| boot (no net) | — | connecting → offlineNoNetwork | offline-first | +| boot (version too old) | versionRejected | offlineVersionLocked | notice | + +**Hysteresis / anti-flap (explicit):** `online → offline` only after the probe fails **and** (K consecutive +failures **or** `connecting` held ≥ `OFFLINE_DEBOUNCE_MS`); a single blip lives entirely in `connecting` +(spinner only). Recovery to `online` is immediate on the first `probeOk`. `osOnline` never flips to online by +itself — it only **triggers** a probe (navigator / `@capacitor/network` can lie; the probe decides). Reuses +the existing probe watcher + backoff. + +**Session-less guest (from D):** the probe is authenticated, so a not-yet-reconciled local guest cannot probe; +there the **reconcile attempt (`auth.guest`) IS the connectivity signal** — success → online + adopt; +failure / `update_required` → stays offline (swallowed, as D already does). + +### Edge cases (each gets a test) +1. Rapid flap (fail→ok inside the window) → never leaves `connecting`; no chrome thrash. +2. Cold boot, no network → `offlineNoNetwork`, offline-first lobby. +3. Cold boot, gateway up but version < min → `offlineVersionLocked` + notice. +4. Cold boot, session-less guest → reconcile = probe; ok→online, fail→offline. +5. Mid-session min-version bump → next call `versionRejected` → `offlineVersionLocked` mid-play. +6. Captive portal: `osOnline` fires but the gateway is down → probe fails → stays offline. +7. Recovery race: `probeOk` + a queued `callOk` together → one idempotent transition to online. +8. Soft nudge, then a hard reject → escalate to `offlineVersionLocked` (notice supersedes the banner). +9. `offlineVersionLocked` on plain web with no cached dicts → offline but create disabled → the notice is the only action. +10. Offline → create local vs_ai → back online → the local game persists and shows in the unified lobby. +11. Persisted stale `offlinePref` on upgrade → ignored/cleared; never stuck offline. +12. TG/VK → the machine is inert (always `online`); no offline states, no local lobby. + +### Version gate — two tiers (backend + wire, additive) +- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`** (exists): `version < min` → `update_required` (Execute + result_code / Subscribe `FailedPrecondition`). Client reaction **changes**: no terminal overlay → + `offlineVersionLocked` + a **notice** with "Update" (native → `VITE_RUSTORE_URL`, web → reload) and + "Play offline" (dismiss → stay offline). Background reconcile stays silent (D). +- **Soft (recommended) — NEW `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` → a + **non-blocking** signal. Wire: **additive** — a **response header** `X-Update-Recommended: 1` on gated + responses (headers are the version-tolerant layer, like `X-Client-Version`); the call still succeeds and + never carries a breaking payload change. Client shows a **dismissible** "update available" banner and plays + online. Empty ⇒ soft tier off. +- Config: `recommended` must be ≥ `min` (validated at load); both empty ⇒ the gate is fully dormant (web + unchanged). Frozen-contract compliance: the soft signal is additive/trailing; the `update_required` + sentinel is unchanged. + +### Unified lobby +Merge `localSource.list()` + `gateway.gamesList()`. Offline: local games active + **server games greyed from +the last `lobbycache` snapshot** (un-openable, an "offline" hint). Identity: recognise **both** the +`server user id` and the `localGuestId` as "you" (they differ after reconciliation) for turn / medal logic. +One `lobbysort`; ids never collide. Removes the `loadSeq` mode-exclusive branch — both sources feed one list. +TG/VK: server-only (unchanged). + +### Create flows +- **vs_ai (quick):** unchanged logic — offline → `localSource.create`, online → `lobbyEnqueue` — driven by + `netState`. **Dict guard:** if the variant's dawg is unavailable offline, disable create with a reason. +- **With-friends:** a new **online/offline segmented control** (online = friend invite, offline = hotseat; + both forms exist). No network → the online segment is disabled ("needs network"), offline preselected. + +### Settings / chrome / migration / platform +Remove the offline-toggle section from `Settings.svelte` (+ `requestOffline`, the `offlineEligible` gate); +`SettingsHub` keeps disabling Profile/Friends/Wallet while offline (now from `netState`). On upgrade, +ignore/clear the persisted `offlinePref` (never strand a deliberate-offline user). Implicit offline applies to +**native (always) + web (plain tab + PWA)**; **TG/VK exempt**. Plain-web offline is best-effort (local play +only for variants whose dawg is cached). No server / data migration (server vs_ai + all server games +untouched). + +### Test matrix (exhaustive — owner requirement) +- **vitest (pure):** the net-state machine as a pure reducer — **every** transition, the hysteresis/debounce + (blip vs sustained), the session-less-guest reconcile-as-probe branch, the two-tier version decision + (min / recommended ordering + all three bands), the dict guard, the identity "self" test (both ids). Every + edge case #1–#12 gets a case. +- **Go:** `clientver`/config extended for `GATEWAY_RECOMMENDED_CLIENT_VERSION` (parse + ordering validation); + `connectsrv` for the soft signal (`v < min` ⇒ hard, `min ≤ v < recommended` ⇒ soft, `v ≥ recommended` ⇒ + none, absent/garbled ⇒ fail-open). +- **Playwright (mock):** auto online→offline (toast) + self-heal; `offlineVersionLocked` notice → "Play + offline" → local play; unified lobby (greyed server games offline; both active online); with-friends toggle + (online disabled offline); the soft nudge banner; update `native.spec.ts`. +- **Docs (revise the F bake):** ARCHITECTURE §2 gate → two tiers + graceful degrade; §3 offline model; + FUNCTIONAL (+`_ru`) offline + "update required"; TESTING; deploy/README the two version vars. + +### Scope / sequencing +Client refactor + a small **additive** backend/wire bit; **contour-safe** (both version vars empty ⇒ dormant; +the wire add is additive). It is **G-step-0** and gates the Android release. After it lands: the normal G +chain (PR → development → contour verify → master → tag → dispatch `android-build`). + +### Out of scope +Dropping server vs_ai (kept). TG/VK offline. In-app store-update SDK. The iOS shell (the machine is written to +be reusable for it; iOS is not built here). + +### Implementation stages + +Executed via `stage-implementation` (interview on every fork, tests at each layer, bake docs). Ordered so each +stage leaves the app working and independently testable. **TDD:** the pure machine (O1) is written test-first. +Standing constraints (from the design): one `netState` machine; hysteresis explicit; **server vs_ai +untouched**; wire changes **additive-only**; **TG/VK inert** (always online); **contour-safe** (both version +vars empty ⇒ dormant). Minimal-diff: keep `connection`/`offline` module paths as thin **derived shims** over +`netState` so the ~14 `offlineMode.active` consumers are not mass-renamed (single source of truth underneath). + +- **O1 — Pure net-state reducer + exhaustive unit tests (test-first). — ✅ DONE (2026-07-12).** + - Files — Create: `ui/src/lib/netstate.ts`, `ui/src/lib/netstate.test.ts`. + - Interfaces (as built) — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`; + `type NetEvent` (`boot`/`callOk`/`callFailed`/`probeOk`/`probeFailed`/`osOnline`/`osOffline`/`versionRejected`/ + `versionRecommended`); `reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig) => NetResult` where + `NetInput = { type: NetEvent; at: number }` (**time-on-the-event** — owner-chosen option A: the reducer stays + a pure function of `(state, event)` and decides *both* hysteresis branches; O2 stamps `Date.now()`), + `NetSnapshot = { state; fails; connectingSince }` (the hysteresis bookkeeping is carried in the reduced value + so `reduce` is pure yet the whole anti-flap is unit-tested), `NetResult = { next: NetSnapshot; effects: Effect[] }`, + `Effect = {kind:'toast',toast:'offline'|'online'} | {kind:'startProbe'} | {kind:'showVersionNotice'} | {kind:'setNudge'}`, + `NetConfig = { k; debounceMs }` (K + `OFFLINE_DEBOUNCE_MS`), plus `INITIAL` (optimistic pre-boot `online`). + Consumes: nothing (pure). **As-built semantics:** entry via `callFailed`/`probeFailed` counts as `fails 1`; + `osOffline` is a hint (enters `connecting` with `fails 0`, only `startProbe`) — so a single blip stays `connecting` + for K≥2; `boot` resets from any state (the sole exit from the sticky lock) → `connecting` + `startProbe`; + `offlineVersionLocked` is sticky (no connectivity event exits it, notice shown once on entry); the soft nudge is + **effect-only** (`setNudge` from `online`), latched later in O4, not stored in the snapshot. + - Acceptance: every transition-table row + all 12 edge cases pass as pure `reduce` assertions; the blip vs + sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected` + from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`. + - Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). **31 assertions, green** (transition table, + hysteresis blip/K/debounce, two-tier version gate, edge cases #1–#12, purity). + +- **O2 — Runtime store + wire the events; migrate the two modules. — ✅ DONE (2026-07-12).** + - Files (as built) — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`; the derived + `state`/`online`/`connecting`/`offline`/`versionLocked` getters; one self-rescheduling probe/recovery watcher — + backoff while `connecting`, a 4 s poll while `offlineNoNetwork`, idle otherwise; the OS-signal wiring). Modify: + `ui/src/lib/connection.svelte.ts` + `ui/src/lib/offline.svelte.ts` (thin shims over `netState` — `connection.online` + = the online state, `offlineMode.active` = the machine `offline`; `reportOffline`→`callFailed` **once** from online, + `reportOnline`→`callOk`), `ui/src/lib/update.svelte.ts` (`reportUpdateRequired` also emits `versionRejected`), + `ui/src/lib/native.ts` (+`initNativeNetwork` — `@capacitor/network` → `osOnline`/`osOffline`), + `ui/src/lib/app.svelte.ts` (the boot/recovery-seam rewrite), `ui/src/lib/gateway.ts` (the mock `__net` hook), + `ui/src/App.svelte` (dialog removal), `ui/src/lib/i18n/{en,ru}.ts` (`net.offline`/`net.online` toast), `ui/package.json` + (+`@capacitor/network@8.0.1`). **Owner-confirmed scope A (full migration):** the store subsumes the old + `scheduleRecovery` poll + `initNetworkReactivity` listeners + the cold-boot `promptOfflineChoice` **dialog** (removed — + "auto + toast, no dialog"); the native offline-first boot + the web unreachable-cached boot now enter via `bootOffline()`. + - Interfaces (as built) — Produces: `netState` (`.state`/`.online`/`.connecting`/`.offline`/`.versionLocked`), + `emit(type)` (stamps `Date.now()`), `bootOffline(kickNow?)`, `checkReachable`, `resetNetState`, `initNetSignals`, and a + **register-hook inversion** so the store stays a leaf module (no cycle with `app.svelte.ts`): `registerProbe` + (transport's reachability read), `registerRecovery` (the app's smart reconcile-or-reachability), `registerNetToast` + (i18n toast). `NetConfig = { k: 3, debounceMs: 15000 }` (owner-confirmed). **Wiring:** `reportOffline` enters + `connecting` once (only from online) and the **probe decides** offline via K/debounce; the session-less native + reconcile IS the probe (`recover()` → `reconcileServerGuest` → `emit('callOk')` before adopt). **`boot` is not + emitted** — a real relaunch reloads the bundle to `INITIAL` online, which also clears the sticky version lock; O1's + `boot` handling stays valid but unused here. **TG/VK inertness falls out for free:** the registrations run only past + the Telegram/VK early-returns in `bootstrap`, so those channels are never fed an offline signal (they can show + `connecting` but never reach `offline`). + - Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses `@capacitor/network`, + web `navigator`; the derived `offline` matches the old `offlineMode.active` for every consumer (no regression). **Verified.** + - Targeted tests: `e2e/offline.spec.ts` **rewritten** (implicit offline via the mock `__net` hook — no toggle — + the + toast + local play + persist + self-heal), `e2e/hotseat.spec.ts` migrated off the toggle, `e2e/native.spec.ts` green + (the native boot/offline/reconcile IS the store's e2e). **Full suite 119/119 on chromium + webkit; svelte-check 0/0; + vitest 622; `cap sync` registers `@capacitor/network`.** + +- **O3 — Remove the explicit offline toggle + migrate the pref. — ✅ DONE (2026-07-12).** + - Files (as built) — Modify: `ui/src/screens/Settings.svelte` (deleted the whole "Play mode" Online/Offline segment + + `offlineEligible` + `goOffline` + the `checking`/`needsData` state + the now-unused `isStandalone`/`insideVK`/offline + imports + the `.onote` CSS), `ui/src/lib/offline.svelte.ts` (dropped the inert `setOfflineMode`/`requestOffline`/ + `TOGGLE_READY_BUDGET_MS` stubs + the dead `offlineMode.auto` getter — `offlineMode.active` = `netState.offline` stays), + `ui/src/lib/offline.ts` (dropped `loadOfflinePref`/`saveOfflinePref`/`shouldBootOffline`/`offlineReady`/`missingDicts`/ + `raceOfflineReady`; **new** `clearOfflinePref`; only `offlinePreloadEligible` + the cleanup remain), `ui/src/lib/app.svelte.ts` + (calls `clearOfflinePref()` once at boot, before the Mini-App branches), `ui/src/lib/i18n/{en,ru}.ts` (pruned 7 orphaned + keys each: `settings.{offlineMode,online,offlineChecking,offlineNeedsData}` + `offline.prompt{Title,Yes,No}`). **Deleted** + `ui/src/lib/dict/offlineready.ts` (orphaned once `requestOffline` went). Tests: `ui/src/lib/offline.test.ts` slimmed to + `offlinePreloadEligible` + `clearOfflinePref`; `ui/e2e/offline.spec.ts` +1 case. + - **Kept, not changed:** `settings.offline` (the Header offline chip still uses it) + `offline.preloadWarning` (the lobby + dict-preload notice). **`SettingsHub.svelte` needed no change** — its tab-disable already reads `offlineMode.active`, + which is `netState.offline` (the O2 shim); the plan's "tab-disable from `netState`" is satisfied without a mass-rename. + - Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck) and is cleared; offline + chrome/tab-gating still driven from `netState`. **Verified.** + - Targeted tests: `e2e/offline.spec.ts` new case (a stale `scrabble.offlineMode` → boots online, key cleared, Settings has + no `Offline` toggle even in the eligible context); `offline.test.ts` (`clearOfflinePref`). **Full suite 120/120 on + chromium + webkit; svelte-check 0/0; vitest 616.** + +- **O4 — Two-tier version gate. — ✅ DONE (2026-07-13).** + - Files (as built) — Backend: `gateway/internal/config/config.go` (+`RecommendedClientVersion` from + `GATEWAY_RECOMMENDED_CLIENT_VERSION`; validate parseable + `≥ MinClientVersion`, an empty min imposing no lower bound), + `gateway/internal/connectsrv/server.go` (soft-tier fields `recClient`/`recOn`; `clientUpdateRecommended` = `min ≤ v < + recommended`, fail-open; Execute sets `X-Update-Recommended: 1` on any served response in the band via a named-return + `defer`; hard band + Subscribe unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (a + Connect-ES **interceptor** reads `x-update-recommended` on unary responses ⇒ `reportUpdateRecommended`), + `ui/src/lib/update.svelte.ts` (dropped the terminal `updateRequired` latch — `reportUpdateRequired` now only emits + `versionRejected`; new `updateRecommended` store + `latchUpdateNudge`/`dismissUpdateNudge`/`reportUpdateRecommended` + + shared `openUpdate`), `ui/src/lib/netstate.svelte.ts` (`registerNetNudge`; the `setNudge` effect fires it), + `ui/src/lib/app.svelte.ts` (`registerNetNudge(latchUpdateNudge)`), `ui/src/lib/gateway.ts` (mock `__update.recommend`), + `ui/src/lib/i18n/{en,ru}.ts` (`update.playOffline` + `update.available`). Rework: `ui/src/components/UpdateOverlay.svelte` + (terminal → dismissable notice reading `netState.versionLocked`, "Update" + "Play offline"). New: + `ui/src/components/UpdateNudge.svelte` (the soft banner, rendered **bottom of the lobby, above the tab bar** — shown only + while `online && updateRecommended.active && !dismissed`). + - Owner-chosen (interview): the hard notice keeps the **full-screen** form (now dismissable); the soft nudge is a + **bottom-of-lobby** banner (not in-game); the header rides **Execute only** (Subscribe untouched); read via a transport + interceptor; nudge dismiss is **per-session**; the nudge latches via `setNudge → registerNetNudge` (machine is the source, + "only from online"). `update.body` copy kept as-is (owner). + - Acceptance: `v