feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s

Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
This commit is contained in:
Ilia Denisov
2026-07-13 01:44:28 +02:00
parent 09e05eef18
commit 2d1fadb50c
55 changed files with 2065 additions and 976 deletions
-63
View File
@@ -11,22 +11,12 @@
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, requestOffline } from '../lib/offline.svelte';
import { isStandalone } from '../lib/pwa';
import InstallApp from '../components/InstallApp.svelte';
// The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit
// the whole board and never auto-zoom), so it is shown only for a coarse pointer.
const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
// 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',
@@ -41,24 +31,6 @@
none: 'settings.labelsNone',
};
// The offline toggle gates entry on dictionary readiness: flipping to offline fetches the enabled
// variants' dictionaries (cache-first) and waits at most a few seconds; if they cannot be made
// available it stays online and shows the "needs internet" note, while the fetch keeps warming the
// cache in the background so a later flip is instant. Leaving offline (-> online) is never gated.
let checking = $state(false);
let needsData = $state(false);
async function goOffline(): Promise<void> {
if (offlineMode.active || checking) return;
needsData = false;
checking = true;
try {
const ready = await requestOffline(app.profile);
if (!ready) needsData = true;
} finally {
checking = false;
}
}
</script>
<div class="page">
@@ -119,33 +91,6 @@
{/if}
</section>
{#if offlineEligible}
<section>
<h3>{t('settings.offlineMode')}</h3>
<div class="seg">
<button
class="opt"
class:active={!offlineMode.active}
disabled={checking}
onclick={() => {
needsData = false;
setOfflineMode(false);
}}
>
{t('settings.online')}
</button>
<button class="opt" class:active={offlineMode.active} disabled={checking} onclick={goOffline}>
{t('settings.offline')}
</button>
</div>
{#if checking}
<p class="onote">{t('settings.offlineChecking')}</p>
{:else if needsData}
<p class="onote onote--warn" role="alert">{t('settings.offlineNeedsData')}</p>
{/if}
</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 />
@@ -190,14 +135,6 @@
opacity: 0.55;
cursor: default;
}
.onote {
font-size: 0.85rem;
color: var(--text-muted);
margin: 8px 0 0;
}
.onote--warn {
color: var(--warn);
}
.row {
display: flex;
align-items: center;