NewGame's onMount fetched the friend list (gateway.friendsList) for a non-guest;
offline the transport kill switch refuses it, so it toasted on entering the New
Game screen (the local game still created fine). The friends section is hidden
offline anyway — skip the fetch when offline.
Offline mode was 'fiction': the UI only disabled the Stats tab, but Profile was
reachable and a profile save actually hit the server and reported 'saved'.
Two layers now:
- Transport kill switch (transport.ts): in offline mode every network op — each
unary RPC (exec), the live stream (subscribe), the dict/metrics fetches and
the reachability probe — is refused before it leaves the device. Offline
truly means offline regardless of any UI that slips through. The local
(device-only) game path never uses this transport, so it is unaffected.
- UI gating: the Profile and Friends tabs (SettingsHub) and the Feedback entry
(About) are disabled offline, and the hub falls back to Settings (which holds
the online/offline toggle); the lobby Stats tab was already disabled. In-game
social/export are already hidden for a vs_ai game.
Online unaffected (offlineMode is off): e2e 196. Offline gating is
contour-verified (the mock e2e cannot enter offline).
In a local game the human seat's account id was a synthetic 'local:human:0',
so seatName's `accountId === session.userId` check never matched: the game
header rendered BOTH seats as 🤖 (the vs_ai fallback), and the lobby's
groupGames could not find the viewer's seat, so a human's turn read as
'Their turn' with the hourglass. Carry the real account id on the human seat
(create -> record -> GameView); the robot keeps its synthetic id.
Local games created before this fix keep the old display (no migration).
The offline cold-boot never engaged: it persisted app.profile — a Svelte
$state proxy — which is not structured-cloneable, so the IndexedDB write threw
and fell back to a localStorage entry that loadProfile (IDB-only on a
successful-empty read) never reads. loadProfile returned null, the boot
short-circuit missed, and it even cleared the sticky offline flag — so offline
mode stopped persisting across relaunches (owner-observed on the contour).
- Persist $state.snapshot(app.profile) (a plain object) at both persist sites,
so the IndexedDB write succeeds and loadProfile round-trips.
- Drop the setOfflineMode(false) fallback: keep the deliberate offline flag on a
cache miss (the mode is the player's choice; an online boot re-persists the
profile so the next launch goes offline). A truly offline launch with no
cached profile is unreachable (enabling offline needs a prior online session).
An installed PWA relaunched with the network off hung on the splash: C1's
precache served the shell, but bootstrap() adopted the session and fetched the
profile over the network, which hung. Persist the profile (on every online
adopt + refresh, cleared on logout) and short-circuit bootstrap when the
deliberate offline flag is sticky-on: with a cached session + profile, skip the
network and land straight in the offline lobby. Without a cached profile (never
online), drop the sticky flag and boot online — the first launch must be online.
- session.ts: saveProfile/loadProfile/clearProfile (mirror saveSession).
- offline.ts: shouldBootOffline decision (unit-tested).
- app.svelte.ts: persist in adoptSession + refreshProfile, clear on logout,
the offline boot short-circuit in bootstrap.
Docs: ARCHITECTURE. Online cold-start unaffected (e2e 196); the offline boot is
contour-verified (the mock e2e cannot enter sticky-offline).