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
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:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { offlineMode } from '../lib/offline.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app, openDebug } from '../lib/app.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
@@ -27,7 +28,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="nav" class:grow>
|
||||
<header class="nav" class:grow class:offline={offlineMode.active}>
|
||||
<div class="bar">
|
||||
{#if showBack}
|
||||
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||
@@ -36,15 +37,20 @@
|
||||
{:else}
|
||||
<span class="spacer"></span>
|
||||
{/if}
|
||||
{#if connection.online}
|
||||
{#if connection.online || offlineMode.active}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<h1 onclick={onTitleTap}>{title}</h1>
|
||||
{:else}
|
||||
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||
{/if}
|
||||
<!-- A right-hand spacer balances the back button so the title stays centred. -->
|
||||
<span class="spacer"></span>
|
||||
<!-- 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>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- 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
|
||||
@@ -74,6 +80,12 @@
|
||||
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 {
|
||||
/* 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
|
||||
@@ -114,6 +126,22 @@
|
||||
align-items: 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 {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -10,8 +10,18 @@
|
||||
import type { ThemePref } from '../lib/theme';
|
||||
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||
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';
|
||||
|
||||
// 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 themeLabel: Record<ThemePref, MessageKey> = {
|
||||
auto: 'settings.themeAuto',
|
||||
@@ -75,6 +85,20 @@
|
||||
</label>
|
||||
</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
|
||||
is installable here — see components/InstallApp.svelte / lib/pwa). -->
|
||||
<InstallApp />
|
||||
|
||||
Reference in New Issue
Block a user