Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.
O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.
Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.
Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.
Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
The 2-4 hotseat players now start in a random seating order (so who goes
first, and the turn order, is random) — matching the online games. The
engine starts at seat 0, so shuffling the seats at creation picks the
starter; the shuffle is seed-driven (a distinct derivation from the tile
bag), so replay reproduces it. Scoped to hotseat — vs_ai stays human-first.
- lib/roster.ts: shuffleSeeded (unit-tested); NewGame shuffles buildSeats
with the game seed before create.
- e2e: pin a seed that keeps the roster order so the lock assertions stay
deterministic.
- History drawer in a local (offline) game: the seat plaques no longer
show the add-friend/block controls — canAddFriend/canBlock now exclude a
local game (hotseat seats have synthetic account ids that slipped past
the vs_ai-only guard) — and the Dictionary entry is restored: an active
hotseat game keeps the comms button, and CommsHub is Dictionary-only for
a chatless vs_ai OR hotseat game (ChatScreen never mounts offline).
- Enter on any single-line <input> now dismisses the soft keyboard (blur);
a <textarea> (feedback) keeps Enter for newlines. One global handler.
- Login email code: the friend-code spread-digit style (.codein), a
6-char cap, auto-submit on the 6th digit, and Enter to submit.
- e2e: the local-game history has no social controls and keeps the
dictionary entry.
- NewGame: all three forms (quick / friends / hotseat) now use the
Profile sub-view's natural-flow .page (no forced height, no pinned-CTA
.grow/.fg scroll), so the screen's single .content scroll + --vvh handle
overflow and a focused name input scrolls into view cleanly — fixes the
keyboard blank-space / deep-scroll regression. One layout rule, one place.
- PinPad: pause 250ms after the 4th digit before the verdict, so the fill
(and the shake on a wrong PIN) reads as a deliberate response.
- e2e: typePin waits for empty dots before typing (robust to the pause).
Owner review of the pass-and-play PR:
- Roster delete (bug): a correct host PIN now ARMS a delete cross next to
the row (mirroring the lobby delete) instead of removing it silently;
tapping the kebab again clears the host authorisation.
- Variant picker (UX): replace the dropdown with the Quick-Match variant
plaques + the multiple-words toggle, one-to-one.
- Keyboard layout (UX): the roster sits in a scroll region with the Start
button pinned (friends-form pattern), so a name input's soft keyboard
shrinks the region instead of leaving a full-height blank spacer painted
behind the keyboard.
- e2e: variant via plaque + a row-delete (arm then cross) regression step.
PWA stale-version (pre-existing, same PR):
- sw.ts: register the network-first NavigationRoute BEFORE precacheAndRoute.
Workbox matches in registration order and the precache route (directoryIndex
index.html) shadowed the shell navigation, serving it cache-first — the old
build's __APP_VERSION__ until a second load. Fixes both the maintenance
self-reload and a cold open after a deploy. Doc updated (ARCHITECTURE).
Add the Playwright mock e2e for offline pass-and-play (Chromium+WebKit):
create a 2-seat hotseat with a PIN-locked seat, verify the rack is
withheld, unlock (wrong then right PIN), host-skip the current turn, and
terminate from the lobby behind the master PIN.
The e2e surfaced a persistence bug: the roster PINs are $state proxies,
which fail structured-clone on the IndexedDB put (silently — best-effort
store), so a created hotseat game vanished on reload. Snapshot the seats
+ host PIN to plain objects at the create() call site.