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.
- source: a finished hotseat game is never locked (its final rack is
shown for review, not an Unlock button); guard locked on !isOver.
- bundle-size: raise the app-entry budget 115 -> 120 KB for the hotseat
UI (PIN pad, roster, host menu), which lives in the always-loaded New
Game / Game / Lobby screens; the offline engine + PIN hashing stay lazy.
Extend the local game to offline pass-and-play (hotseat):
- serialize.ts: Seat.pin, record hotseat + hostPin (all optional; old
vs_ai records read hotseat as false).
- source.ts: create() takes hotseat/hostPin + per-seat pins; stateView
reveals the seat-to-move's rack and withholds it (locked) when that
seat is PIN-locked, until unlockSeat; ephemeral per-turn unlock
re-locks on advance; new unlockSeat / verifyHostPin / hostAction
(skip/resign/terminate); gameView sets vsAi=!hotseat + the hotseat flag.
- model.ts: GameView.hotseat, StateView.locked (offline-only, optional).
Tests: source.hotseat (lock/unlock/re-lock, host skip/resign/terminate,
master-PIN gate, natural-end persistence) + serialize hotseat shape.