iOS Safari/WKWebView does not shrink the layout viewport for the soft
keyboard (Android Chrome does): the visual viewport shrinks AND offsets
down toward the focused field, and the values do not cleanly revert. The
pinned shell tracked only the height (--vvh), not the offset, so its
top-anchored content misaligned on iOS — empty space below the form,
worst on a repeat focus. Fix (one place; covers NewGame, Chat, Feedback):
- app.svelte.ts syncViewport: also mirror visualViewport.offsetTop into
--vv-top, and scroll the focused field into view on the keyboard-open
transition (iOS does not reliably scroll a pinned document to it).
- app.css: the pinned app-shell body follows top=--vv-top + height=--vvh
(was inset:0), so it stays on the visible area.
- e2e/viewport.spec.ts emulates the visual-viewport resize+offset (a fake
window.visualViewport) to verify the shell follows, on Chromium+WebKit.
Hotseat: returning to the lobby now re-locks the current seat (its PIN is
re-prompted) — LocalSource.relock, cleared in Game.svelte onDestroy.
Root-caused a crash it exposed: a $props() value (id) reads back undefined
during Svelte teardown, so isLocalGameId(id) threw and aborted onDestroy
(breaking navigation) — read the id from the loaded view instead.
Game.svelte drives a hotseat game:
- the seat-to-move's rack is replaced by an Unlock button when the seat
is PIN-locked (board stays visible); canMove gates the move controls
on the unlock; PinPad(verify) -> source.unlockSeat reveals it.
- the hint slot becomes a host button (hints off in hotseat), always
enabled while the game runs (acts on a locked seat too). Master-PIN ->
action sheet: skip current / exclude a player / end the game, each with
a confirm + a fading check; terminate deletes and returns to the lobby.
- per-turn advance re-fetches the next seat's (locked) state; self-resign
and chat are hidden (hotseat resign is a host action, no comms).
- gamesource: expose unlockSeat / verifyHostPin / hostAction on the local
proxy. i18n hotseat.* (en + ru).
The lobby's finished-game delete (hide) always called gateway.hideGame; for a
local (offline) game the transport kill switch refused it, so it toasted and the
game stayed. Route by id: a local game is removed from the device store + the
source cache (LocalSource.delete), an online game still hides on the backend.
In offline mode the lobby now shows only the device-local games and its
New-vs-AI entry creates one through the in-browser engine — the visible
payoff of the offline mode.
- LocalSource.list() reconstructs a lobby GameView per stored local game by
replay, exposed through the lazy gamesource proxy; unit-tested via an
in-memory store.
- Lobby.load() branches on offlineMode: lists local games and skips every
gateway call (no online games/invitations/incoming); the Stats tab is
disabled offline.
- NewGame offline: find() creates a device-local vs_ai game via
LocalSource.create using the profile's advertised dict version + a local
seed; the friends flow and the random-opponent option are hidden, and the
variant picker / Start are enabled offline (were gated on connection).
- id.ts: newLocalGameId + randomSeed (tested).
Docs: ARCHITECTURE + FUNCTIONAL(+ru) offline-mode section.
Deferred to fast-follow: the Settings Friends/Profile/Feedback affordance
gating, the flip-to-offline readiness wait, the offline mock e2e (needs
mock-dawg support), and the local-hint UI. The offline flow is verified on
the test contour — the mock e2e cannot enter offline mode (the toggle is
gated to an installed PWA).
The game screen now drives a local vs_ai game through the offline engine, dispatched by
game id — completing the playable local game (on top of the source, #193). Online play
is unchanged.
- gamesource.ts: gameSource(id) returns the local source for a `local:` id, else the
gateway (the same game-loop interface). The offline engine stays OUT of the app entry
bundle — it is dynamically imported on first use (a separate chunk), so online-only
users never pay for it (the app entry stays within its size budget).
- localgame/id.ts: the tiny id helper (no engine imports) the dispatcher branches on.
- Game.svelte: the game-loop calls (state/history/submit/pass/exchange/resign/hint/
evaluate/draft) go through gameSource(id) instead of the gateway directly; a local
game's robot-reply events route through the same app event hub the network stream
feeds, so the screen reacts to opponent_moved / game_over identically.
Behaviour-preserving for network games (gameSource returns the gateway for them). Local
verify green: check + test:unit + build + bundle-size gate + e2e (196 passed).