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
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:
@@ -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',
|
||||
|
||||
@@ -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': 'Инфо',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user