Merge pull request 'feat(offline): offline-mode state, Settings toggle + blue chrome (Phase C)' (#195) from feature/offline-mode-state into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s

This commit was merged in pull request #195.
This commit is contained in:
2026-07-06 07:48:49 +00:00
7 changed files with 166 additions and 4 deletions
+31 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { app, openDebug } from '../lib/app.svelte'; import { app, openDebug } from '../lib/app.svelte';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
@@ -27,7 +28,7 @@
} }
</script> </script>
<header class="nav" class:grow> <header class="nav" class:grow class:offline={offlineMode.active}>
<div class="bar"> <div class="bar">
{#if showBack} {#if showBack}
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back"> <button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
@@ -36,15 +37,20 @@
{:else} {:else}
<span class="spacer"></span> <span class="spacer"></span>
{/if} {/if}
{#if connection.online} {#if connection.online || offlineMode.active}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<h1 onclick={onTitleTap}>{title}</h1> <h1 onclick={onTitleTap}>{title}</h1>
{:else} {:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1> <h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if} {/if}
<!-- A right-hand spacer balances the back button so the title stays centred. --> <!-- A right-hand spacer balances the back button so the title stays centred; in offline mode it
carries the "Offline" chip so the deliberate mode is always visible (not just the blue tint). -->
{#if offlineMode.active}
<span class="chip">{t('settings.offline')}</span>
{:else}
<span class="spacer"></span> <span class="spacer"></span>
{/if}
</div> </div>
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the <!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
same place on every screen — and in the game (grown nav) the spare height falls below same place on every screen — and in the game (grown nav) the spare height falls below
@@ -74,6 +80,12 @@
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
/* Deliberate offline mode: a blue-tinted nav, mixed from the accent so it tracks the light/dark
theme (and any Telegram theme override) — the offline state is unmistakable at a glance. */
.nav.offline {
background: color-mix(in srgb, var(--accent) 20%, var(--bg-elev));
border-bottom-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
.nav.grow { .nav.grow {
/* Grow into spare height (banner under the title, the board pinned to the bottom), but /* Grow into spare height (banner under the title, the board pinned to the bottom), but
never shrink: on a short viewport the banner keeps its height and the board's own never shrink: on a short viewport the banner keeps its height and the board's own
@@ -114,6 +126,22 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* The offline chip: a compact accent-tinted pill in the header's right slot. */
.chip {
min-width: 40px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
font-size: 0.72rem;
font-weight: 600;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
border-radius: 999px;
white-space: nowrap;
}
.back { .back {
background: none; background: none;
border: none; border: none;
+3
View File
@@ -214,6 +214,9 @@ export const en = {
'settings.labelsClassic': 'Classic', 'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None', 'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion', 'settings.reduceMotion': 'Reduce motion',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
'about.title': 'About', 'about.title': 'About',
'about.tab': 'Info', 'about.tab': 'Info',
+3
View File
@@ -214,6 +214,9 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsClassic': 'Классика', 'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста', 'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций', 'settings.reduceMotion': 'Меньше анимаций',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
'about.title': 'О программе', 'about.title': 'О программе',
'about.tab': 'Инфо', '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));
}
+24
View File
@@ -10,8 +10,18 @@
import type { ThemePref } from '../lib/theme'; import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels'; import type { BoardLabelMode } from '../lib/boardlabels';
import { insideTelegram } from '../lib/telegram'; import { insideTelegram } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { offlineMode, setOfflineMode } from '../lib/offline.svelte';
import { isStandalone } from '../lib/pwa';
import InstallApp from '../components/InstallApp.svelte'; import InstallApp from '../components/InstallApp.svelte';
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
const offlineEligible = $derived(
isStandalone() && !insideTelegram() && !insideVK() && !!app.profile?.email,
);
const themes: ThemePref[] = ['auto', 'light', 'dark']; const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = { const themeLabel: Record<ThemePref, MessageKey> = {
auto: 'settings.themeAuto', auto: 'settings.themeAuto',
@@ -75,6 +85,20 @@
</label> </label>
</section> </section>
{#if offlineEligible}
<section>
<h3>{t('settings.offlineMode')}</h3>
<div class="seg">
<button class="opt" class:active={!offlineMode.active} onclick={() => setOfflineMode(false)}>
{t('settings.online')}
</button>
<button class="opt" class:active={offlineMode.active} onclick={() => setOfflineMode(true)}>
{t('settings.offline')}
</button>
</div>
</section>
{/if}
<!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app <!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app
is installable here — see components/InstallApp.svelte / lib/pwa). --> is installable here — see components/InstallApp.svelte / lib/pwa). -->
<InstallApp /> <InstallApp />