release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212
@@ -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
|
||||||
<span class="spacer"></span>
|
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>
|
</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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'Инфо',
|
||||||
|
|||||||
@@ -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 { 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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user