feat(offline): offline-mode state, Settings toggle + blue chrome (Phase C)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s

The deliberate offline mode is now visible: an installed web-PWA user with a confirmed
email can switch to offline in Settings, and the header turns blue with an "Offline"
chip. (Network gating, the dict preload, the offline lobby + local-game creation, and the
service-worker precache follow in later Phase C steps.)

- offline.ts / offline.svelte.ts: a sticky, device-scoped reactive offline flag
  (offlineMode), distinct from connection.svelte's transient reachability signal, with the
  pure persistence + readiness helpers (offlineReady / missingDicts) unit-tested.
- Settings.svelte: an Online / Offline toggle, shown only for an installed web PWA with a
  confirmed email (the SW-launch + durable-account preconditions).
- Header.svelte: a blue-tinted nav (color-mix from the accent, so it tracks light/dark and
  a Telegram theme override) + an "Offline" chip while offline; the deliberate mode
  suppresses the transient "Connecting…" indicator.

Behaviour-preserving online (the toggle is hidden and offlineMode is false there; e2e 196
green). App entry stays within its size budget (111.6 / 112 KB gzip).
This commit is contained in:
Ilia Denisov
2026-07-06 09:44:05 +02:00
parent d80d28a402
commit 68ecd881d9
7 changed files with 166 additions and 4 deletions
+3
View File
@@ -214,6 +214,9 @@ export const en = {
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
'about.title': 'About',
'about.tab': 'Info',
+3
View File
@@ -214,6 +214,9 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
'about.title': 'О программе',
'about.tab': 'Инфо',
+23
View File
@@ -0,0 +1,23 @@
// The deliberate offline MODE: a sticky, device-scoped reactive flag the app reads to gate the
// network, tint the chrome blue and show only local games. It is the player's own choice (the
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
import { loadOfflinePref, saveOfflinePref } from './offline';
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
let active = $state(loadOfflinePref());
/** offlineMode exposes the reactive deliberate-offline flag; read it in markup / $derived. */
export const offlineMode = {
/** active is true while the app is in deliberate offline mode. */
get active(): boolean {
return active;
},
};
/** setOfflineMode enters or leaves offline mode and persists the choice (device-scoped). */
export function setOfflineMode(on: boolean): void {
active = on;
saveOfflinePref(on);
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts } from './offline';
import type { Variant } from './model';
// A minimal in-memory localStorage for the persistence tests (node has none).
beforeEach(() => {
const store = new Map<string, string>();
(globalThis as unknown as { localStorage: Storage }).localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: () => null,
length: 0,
} as Storage;
});
describe('offline mode helpers', () => {
it('persists and reads the device-scoped offline flag', () => {
expect(loadOfflinePref()).toBe(false);
saveOfflinePref(true);
expect(loadOfflinePref()).toBe(true);
saveOfflinePref(false);
expect(loadOfflinePref()).toBe(false);
});
it('offlineReady requires every enabled variant to have a dictionary', () => {
const has = (v: Variant): boolean => v !== 'erudit_ru';
expect(offlineReady(['scrabble_en', 'scrabble_ru'], has)).toBe(true);
expect(offlineReady(['scrabble_en', 'erudit_ru'], has)).toBe(false);
expect(offlineReady([], has)).toBe(false);
});
it('missingDicts lists the enabled variants without a dictionary', () => {
const has = (v: Variant): boolean => v === 'scrabble_en';
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
});
});
+43
View File
@@ -0,0 +1,43 @@
// Pure helpers for the deliberate offline MODE — its device-scoped persistence and the readiness
// decision — kept out of the reactive module (offline.svelte.ts) so they unit-test in the node env.
// The deliberate offline mode is distinct from connection.svelte.ts's transient "can we reach the
// gateway" signal: it is the player's own sticky choice, and it gates the network, tints the chrome
// and shows only local games.
import type { Variant } from './model';
const STORAGE_KEY = 'scrabble.offlineMode';
/** loadOfflinePref reads the persisted offline-mode flag (device-scoped); false when unset or when
* storage is unavailable, so a device that cannot persist simply starts online. */
export function loadOfflinePref(): boolean {
try {
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
}
/** saveOfflinePref persists the offline-mode flag (best-effort). */
export function saveOfflinePref(on: boolean): void {
try {
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
} catch {
/* best-effort — a failed persist just reverts to online on the next launch */
}
}
/**
* offlineReady reports whether the device can play offline right now: at least one variant is
* enabled and every enabled variant's dictionary is already available on the device (hasDict). The
* offline toggle uses it to decide whether flipping to offline can succeed immediately.
*/
export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): boolean {
return enabled.length > 0 && enabled.every((v) => hasDict(v));
}
/** missingDicts lists the enabled variants whose dictionary is not yet available — the ones the
* toggle must fetch (or wait on) before offline mode can be entered. */
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
return enabled.filter((v) => !hasDict(v));
}