From 54d701fd8a0c0dd181330e7b2492afb5b3196360 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 14:54:09 +0200 Subject: [PATCH 1/5] fix(offline): cold-boot offline from the persisted session + profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/ARCHITECTURE.md | 6 +++++- ui/src/lib/app.svelte.ts | 28 ++++++++++++++++++++++++++++ ui/src/lib/offline.test.ts | 9 ++++++++- ui/src/lib/offline.ts | 10 ++++++++++ ui/src/lib/session.ts | 21 ++++++++++++++++++++- 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 18ca0e9..c7d8d85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1259,7 +1259,11 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads* — on lobby entry and on a variant-preference change — through the same three-tier loader, retried with backoff and honouring the session miss-breaker; the move generator, the loader and the preload orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in -the ad-banner slot. The gateway registers the `.webmanifest` MIME type +the ad-banner slot. A **cold launch already in offline mode** boots from the persisted session and +profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session +adoption and profile fetch that would otherwise hang with no network, and lands straight in the +offline lobby; without a cached profile (an install that was never online) it drops the sticky flag +and boots online instead. The gateway registers the `.webmanifest` MIME type in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `no-cache` so a new deploy is picked up — both containers apply the same caching. An diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 1c0a784..a4041fe 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -43,6 +43,9 @@ import { parseStartParam } from './deeplink'; import { clearOnboarding, clearSession, + clearProfile, + loadProfile, + saveProfile, loadOnboarding, loadPrefs, loadSession, @@ -53,6 +56,8 @@ import { } from './session'; import type { CoachSeries } from './coachmark'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; +import { offlineMode, setOfflineMode } from './offline.svelte'; +import { shouldBootOffline } from './offline'; import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; import { advanceCached } from './gamedelta'; @@ -460,6 +465,7 @@ export async function refreshProfile(): Promise { if (!app.session) return; try { app.profile = await gateway.profileGet(); + void saveProfile(app.profile); // keep the offline-boot cache fresh } catch { // Best-effort; the banner just stays as it was until the next fetch. } @@ -517,6 +523,9 @@ async function adoptSession(s: Session): Promise { await saveSession(s); try { app.profile = await gateway.profileGet(); + // Persist the profile so a later offline cold start can launch from it (its variant + // preferences, dictionary versions and display name) without a network fetch. + void saveProfile(app.profile); // The live interface language follows the device — the explicit local choice (saved in // prefs) or the system guess made at bootstrap — and is no longer overridden from the // account here: the Telegram bot a user signs in through must not dictate the UI, so a @@ -790,6 +799,24 @@ export async function bootstrap(): Promise { // and skipped in the mock build; see lib/pwa.svelte. registerServiceWorker(); + // Deliberate offline mode carried over from a prior session: with no network the session adoption + // + profile fetch below would hang the splash, so skip them and launch straight from the cached + // session + profile into the offline lobby. Without both (e.g. cleared storage, or an install that + // was never online), drop the sticky flag and fall through to the normal online flow — the first + // launch must be online. + if (offlineMode.active) { + const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]); + if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) { + gateway.setToken(offlineSession!.token); + app.session = offlineSession!; + app.profile = offlineProfile!; + if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/'); + app.ready = true; + return; + } + setOfflineMode(false); + } + const saved = await loadSession(); // A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back // from VK; capture and clear it here (it needs the restored session to link against). @@ -1023,6 +1050,7 @@ export async function logout(): Promise { app.splashDone = false; gateway.setToken(null); await clearSession(); + await clearProfile(); app.session = null; app.profile = null; navigate('/login'); diff --git a/ui/src/lib/offline.test.ts b/ui/src/lib/offline.test.ts index 172fbf3..00c48ab 100644 --- a/ui/src/lib/offline.test.ts +++ b/ui/src/lib/offline.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible } from './offline'; +import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline } from './offline'; import type { Variant } from './model'; // A minimal in-memory localStorage for the persistence tests (node has none). @@ -45,4 +45,11 @@ describe('offline mode helpers', () => { expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false); expect(offlinePreloadEligible({ ...base, online: false })).toBe(false); }); + + it('shouldBootOffline requires the offline flag plus a cached session and profile', () => { + expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true); + expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false); + expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false); + expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false); + }); }); diff --git a/ui/src/lib/offline.ts b/ui/src/lib/offline.ts index 6506243..e461e6e 100644 --- a/ui/src/lib/offline.ts +++ b/ui/src/lib/offline.ts @@ -58,3 +58,13 @@ export function offlinePreloadEligible(opts: { }): boolean { return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online; } + +/** + * shouldBootOffline decides whether a cold start skips the network and launches straight into + * offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached + * session and profile to start from. Without both (e.g. cleared storage, or a never-online install), + * the app must boot online once first — the caller then drops the sticky flag and continues online. + */ +export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean { + return opts.offlineActive && opts.hasSession && opts.hasProfile; +} diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts index 229aa34..734da34 100644 --- a/ui/src/lib/session.ts +++ b/ui/src/lib/session.ts @@ -3,7 +3,7 @@ // re-login), with a localStorage fallback. Losing the store just means re-login — // acceptable, and for a guest it simply mints a fresh guest. -import type { Session } from './model'; +import type { Session, Profile } from './model'; import type { ThemePref } from './theme'; import type { Locale } from './i18n/catalog'; import type { BoardLabelMode } from './boardlabels'; @@ -119,6 +119,25 @@ export function clearSession(): Promise { return kvDel(SESSION_KEY); } +const PROFILE_KEY = 'profile'; + +/** loadProfile reads the last persisted profile (or null). It backs the offline cold-boot: with no + * network, bootstrap uses it instead of fetching a fresh one. */ +export function loadProfile(): Promise { + return kvGet(PROFILE_KEY); +} + +/** saveProfile persists the caller's own profile so a later offline launch can start from it (its + * variant preferences, dictionary versions and display name), best-effort. */ +export function saveProfile(p: Profile): Promise { + return kvSet(PROFILE_KEY, p); +} + +/** clearProfile drops the persisted profile (on logout, with the session). */ +export function clearProfile(): Promise { + return kvDel(PROFILE_KEY); +} + export interface Prefs { theme: ThemePref; locale: Locale; -- 2.52.0 From 19e7ea5da052cc8f5774869d3457d7f49fc4a3f0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 15:20:15 +0200 Subject: [PATCH 2/5] fix(offline): persist a plain profile snapshot; keep the sticky offline flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui/src/lib/app.svelte.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index a4041fe..58ed777 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -56,7 +56,7 @@ import { } from './session'; import type { CoachSeries } from './coachmark'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; -import { offlineMode, setOfflineMode } from './offline.svelte'; +import { offlineMode } from './offline.svelte'; import { shouldBootOffline } from './offline'; import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; @@ -465,7 +465,7 @@ export async function refreshProfile(): Promise { if (!app.session) return; try { app.profile = await gateway.profileGet(); - void saveProfile(app.profile); // keep the offline-boot cache fresh + void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot) } catch { // Best-effort; the banner just stays as it was until the next fetch. } @@ -524,8 +524,10 @@ async function adoptSession(s: Session): Promise { try { app.profile = await gateway.profileGet(); // Persist the profile so a later offline cold start can launch from it (its variant - // preferences, dictionary versions and display name) without a network fetch. - void saveProfile(app.profile); + // preferences, dictionary versions and display name) without a network fetch. Snapshot to a + // plain object first — the reactive $state proxy is not structured-cloneable, so it would fail + // the IndexedDB write and fall back to a localStorage entry that loadProfile never reads. + void saveProfile($state.snapshot(app.profile)); // The live interface language follows the device — the explicit local choice (saved in // prefs) or the system guess made at bootstrap — and is no longer overridden from the // account here: the Telegram bot a user signs in through must not dictate the UI, so a @@ -801,9 +803,10 @@ export async function bootstrap(): Promise { // Deliberate offline mode carried over from a prior session: with no network the session adoption // + profile fetch below would hang the splash, so skip them and launch straight from the cached - // session + profile into the offline lobby. Without both (e.g. cleared storage, or an install that - // was never online), drop the sticky flag and fall through to the normal online flow — the first - // launch must be online. + // session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall + // through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and + // 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 requires a prior online session.) if (offlineMode.active) { const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]); if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) { @@ -814,7 +817,6 @@ export async function bootstrap(): Promise { app.ready = true; return; } - setOfflineMode(false); } const saved = await loadSession(); -- 2.52.0 From 2a045a5b37ead1689dfbd93e3ed0aca27963d0f5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 15:36:51 +0200 Subject: [PATCH 3/5] fix(offline): give the local human seat the real account id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui/src/lib/localgame/serialize.ts | 3 +++ ui/src/lib/localgame/source.list.test.ts | 14 ++++++++++++++ ui/src/lib/localgame/source.ts | 4 ++-- ui/src/screens/NewGame.svelte | 4 +++- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/ui/src/lib/localgame/serialize.ts b/ui/src/lib/localgame/serialize.ts index 9471362..3be0d60 100644 --- a/ui/src/lib/localgame/serialize.ts +++ b/ui/src/lib/localgame/serialize.ts @@ -17,6 +17,9 @@ import type { Variant } from '../model'; export interface Seat { kind: 'human' | 'robot'; name: string; + /** The player's real account id for a human seat, so the client identifies "you" and the correct + * turn exactly as in an online game; absent for the robot (a synthetic id is derived). */ + accountId?: string; } /** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */ diff --git a/ui/src/lib/localgame/source.list.test.ts b/ui/src/lib/localgame/source.list.test.ts index 6d8fd46..df620b0 100644 --- a/ui/src/lib/localgame/source.list.test.ts +++ b/ui/src/lib/localgame/source.list.test.ts @@ -51,6 +51,20 @@ describe('LocalSource.list', () => { expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true); }); + it('carries the human seat account id into the GameView so the client identifies "you"', async () => { + const src = new LocalSource(); + await src.create({ + ...opts('local:me', 3n), + seats: [ + { kind: 'human', name: 'You', accountId: 'acct-42' }, + { kind: 'robot', name: 'Robot' }, + ], + }); + const [g] = await src.list(); + expect(g.seats[0].accountId).toBe('acct-42'); // the human's real id, matched against the session + expect(g.seats[1].accountId).not.toBe('acct-42'); // the robot keeps a synthetic id + }); + it('reconstructs a game persisted by a previous session (not in the source cache)', async () => { // Seed the store via one source, then list from a fresh source whose in-memory cache is empty. await new LocalSource().create(opts('local:old', 7n)); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 35ef2e0..3c8f848 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -120,7 +120,7 @@ export class LocalSource implements GameLoopSource { const now = nowUnix(); const record = serializeGame(game, { id: opts.id, - seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name })), + seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })), createdAtUnix: now, updatedAtUnix: now, robotLastMoveAtUnix: now, @@ -341,7 +341,7 @@ export class LocalSource implements GameLoopSource { const { game, record } = entry; const seats: Seat[] = record.seats.map((s, i) => ({ seat: i, - accountId: `${LOCAL_ID_PREFIX}${s.kind}:${i}`, + accountId: s.accountId ?? `${LOCAL_ID_PREFIX}${s.kind}:${i}`, displayName: s.name, score: game.scoreOf(i), hintsUsed: 0, diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 9e962d3..e3597c9 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -74,7 +74,9 @@ seed: randomSeed(), multipleWords: multipleWordsForRequest(v, multipleWords), seats: [ - { kind: 'human', name: app.profile?.displayName ?? 'You' }, + // The human seat carries the real account id so the game screen and the lobby identify + // "you" and the correct turn exactly as for an online game (the robot uses a synthetic id). + { kind: 'human', name: app.profile?.displayName ?? 'You', accountId: app.session?.userId }, { kind: 'robot', name: 'Robot' }, ], }); -- 2.52.0 From 1a456c484715a35aeb00c03b495d2fd4f19eccb1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 15:46:22 +0200 Subject: [PATCH 4/5] feat(offline): transport kill switch + gate the network-requiring UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui/src/lib/transport.ts | 14 ++++++++++++++ ui/src/screens/About.svelte | 3 ++- ui/src/screens/SettingsHub.svelte | 11 +++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 51bf052..f868d7d 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -12,6 +12,7 @@ import { GatewayError, type GatewayClient } from './client'; import * as codec from './codec'; import { browserOffset } from './profileValidation'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; +import { offlineMode } from './offline.svelte'; import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; import { maintenanceRetryMs } from './maintenance'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; @@ -19,6 +20,13 @@ import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry' const MAX_RETRIES = 6; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +// The offline kill switch: in deliberate offline mode every network operation is refused before it +// leaves the device, so offline truly means offline regardless of any UI affordance that slips +// through. The local (device-only) game path never uses this transport, so it is unaffected. +function assertOnline(): void { + if (offlineMode.active) throw new GatewayError('offline'); +} + export function createTransport(baseUrl: string): GatewayClient { const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : ''); const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true }); @@ -32,6 +40,7 @@ export function createTransport(baseUrl: string): GatewayClient { // (it must reject when there is no session, so the watcher keeps waiting rather than reporting up). const reachabilityProbe = async (): Promise => { if (!token) throw new Error('no session'); + assertOnline(); await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() }); }; registerProbe(reachabilityProbe); @@ -43,6 +52,7 @@ export function createTransport(baseUrl: string): GatewayClient { // dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting // indicator. A successful round-trip marks the gateway reachable; a domain result_code is final. async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise { + assertOnline(); for (let attempt = 0; ; attempt++) { let res; try { @@ -82,6 +92,7 @@ export function createTransport(baseUrl: string): GatewayClient { async fetchDict(variant, version, opts) { if (!token) throw new Error('fetchDict: no session'); + assertOnline(); // Low priority so the browser schedules the (large) dictionary behind the game's own // requests — the move eval, state and live events — on a slow link (EDGE). Ignored // where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The @@ -99,6 +110,7 @@ export function createTransport(baseUrl: string): GatewayClient { async reportLocalEval(counts) { if (!token) throw new Error('reportLocalEval: no session'); + assertOnline(); // keepalive so a batch flushed as the app is backgrounded still reaches the edge. const res = await fetch(`${origin}/metrics/local-eval`, { method: 'POST', @@ -319,6 +331,8 @@ export function createTransport(baseUrl: string): GatewayClient { }, subscribe(onEvent, onError) { + // No live stream in offline mode (the kill switch); return an inert unsubscribe. + if (offlineMode.active) return () => {}; const ctrl = new AbortController(); void (async () => { try { diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index 62f3abc..fd653b6 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -2,6 +2,7 @@ import { t } from '../lib/i18n/index.svelte'; import { app } from '../lib/app.svelte'; import { navigate } from '../lib/router.svelte'; + import { offlineMode } from '../lib/offline.svelte'; import { aboutContent } from '../lib/aboutContent'; import { onExternalLinkClick } from '../lib/links'; @@ -35,7 +36,7 @@

{t('about.version', { v: version })}

{#if !(app.profile?.isGuest ?? true)} - {/if} diff --git a/ui/src/screens/SettingsHub.svelte b/ui/src/screens/SettingsHub.svelte index b570fb2..a2934dc 100644 --- a/ui/src/screens/SettingsHub.svelte +++ b/ui/src/screens/SettingsHub.svelte @@ -6,6 +6,7 @@ import Friends from './Friends.svelte'; import About from './About.svelte'; import { app } from '../lib/app.svelte'; + import { offlineMode } from '../lib/offline.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; // The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile / @@ -23,6 +24,12 @@ $effect(() => { if (guest && tab === 'friends') tab = 'settings'; }); + // Offline mode has no network, so the Profile and Friends surfaces (which read/save over the + // network) are disabled — fall back to Settings if the hub is on one (a deep-link or a live flip). + // Settings stays reachable: it holds the online/offline toggle. + $effect(() => { + if (offlineMode.active && (tab === 'profile' || tab === 'friends')) tab = 'settings'; + }); const titleKey: Record = { settings: 'settings.title', @@ -48,11 +55,11 @@ - {#if !guest} - {/if} -- 2.52.0 From fdf14d589749e2918913c54eb6f3610129c165b5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 16:12:50 +0200 Subject: [PATCH 5/5] fix(offline): skip the NewGame friend fetch offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/src/screens/NewGame.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index e3597c9..751b244 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -112,7 +112,9 @@ ); onMount(async () => { - if (guest) return; + // Offline mode offers only the local vs_ai flow (the friends section is hidden), and the network + // is off, so skip the friend fetch — it would be refused by the transport and toast an error. + if (guest || offlineMode.active) return; try { friends = await gateway.friendsList(); } catch (e) { -- 2.52.0