fix(offline): hotseat review fixes + PWA stale-shell (SW route order)
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 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s

Owner review of the pass-and-play PR:
- Roster delete (bug): a correct host PIN now ARMS a delete cross next to
  the row (mirroring the lobby delete) instead of removing it silently;
  tapping the kebab again clears the host authorisation.
- Variant picker (UX): replace the dropdown with the Quick-Match variant
  plaques + the multiple-words toggle, one-to-one.
- Keyboard layout (UX): the roster sits in a scroll region with the Start
  button pinned (friends-form pattern), so a name input's soft keyboard
  shrinks the region instead of leaving a full-height blank spacer painted
  behind the keyboard.
- e2e: variant via plaque + a row-delete (arm then cross) regression step.

PWA stale-version (pre-existing, same PR):
- sw.ts: register the network-first NavigationRoute BEFORE precacheAndRoute.
  Workbox matches in registration order and the precache route (directoryIndex
  index.html) shadowed the shell navigation, serving it cache-first — the old
  build's __APP_VERSION__ until a second load. Fixes both the maintenance
  self-reload and a cold open after a deploy. Doc updated (ARCHITECTURE).
This commit is contained in:
Ilia Denisov
2026-07-07 12:35:33 +02:00
parent 903de7d41d
commit 3adc4e32c9
4 changed files with 153 additions and 64 deletions
+9 -4
View File
@@ -1242,10 +1242,15 @@ browser-language detection — crawlers render with arbitrary languages). The fa
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It **web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with **precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
no network, and falls every in-scope navigation back to the precached shell (the hash router no network. Shell **navigations are network-first** the fresh `no-cache` shell is fetched from the
resolves the route client-side); the landing page and the conditional polyfill bundle are excluded, server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
and the Connect stream and runtime API POSTs are never precached nor intercepted, so the live app is precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
never served stale. This satisfies Chromium's installability requirement (a registered SW, needed cache-first, and the hash router resolves the route client-side. This navigation route is registered
**before** the precache route on purpose: Workbox matches in registration order, and the precache
route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve the shell
cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
+16 -4
View File
@@ -54,14 +54,26 @@ test.describe('offline hotseat (pass-and-play)', () => {
await typePin(page, '9999'); await typePin(page, '9999');
await page.getByRole('button', { name: /^(No|Нет)$/ }).click(); await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
// Pick the English variant if the picker offers a choice. // Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
const variant = page.locator('.fg select').first(); await page.locator('.variant', { hasText: 'Scrabble' }).click();
if (await variant.isEnabled()) await variant.selectOption('scrabble_en');
// Two players: Ann (PIN-locked) and Bob (open). // Two players: Ann (PIN-locked) and Bob (open).
await page.locator('.pname').nth(0).fill('Ann'); await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob'); await page.locator('.pname').nth(1).fill('Bob');
await page.locator('.prow').nth(0).locator('.plink').click(); // Ann's seat PIN
// Row-delete: a 3rd player's kebab asks the host PIN, which ARMS a ❌ (not a silent delete);
// tapping the ❌ removes the row.
await page.getByRole('button', { name: /Add player|Добавить/i }).click();
await expect(page.locator('.prow')).toHaveCount(3);
await page.locator('.prow').nth(2).locator('.pkebab').click();
await typePin(page, '9999');
const rowDel = page.locator('.prow').nth(2).locator('.prow-del');
await expect(rowDel).toBeVisible();
await rowDel.click();
await expect(page.locator('.prow')).toHaveCount(2);
// Lock Ann's seat with a PIN.
await page.locator('.prow').nth(0).locator('.plink').click();
await typePin(page, '1234'); await typePin(page, '1234');
await typePin(page, '1234'); await typePin(page, '1234');
+115 -51
View File
@@ -167,6 +167,10 @@
| { kind: 'seat-change'; index: number } | { kind: 'seat-change'; index: number }
| { kind: 'row-delete'; index: number }; | { kind: 'row-delete'; index: number };
let pad = $state<PadTarget | null>(null); let pad = $state<PadTarget | null>(null);
// The row whose delete cross is armed: tapping a row's kebab asks the host PIN, and a correct PIN
// arms its ❌ (mirroring the lobby delete) rather than removing it silently. Tapping the kebab again
// (or arming another row) clears the authorisation.
let deleteRow = $state<number | null>(null);
const padMode = (p: PadTarget): 'set' | 'verify' | 'change' => const padMode = (p: PadTarget): 'set' | 'verify' | 'change' =>
p.kind === 'host-set' || p.kind === 'seat-set' ? 'set' : p.kind === 'row-delete' ? 'verify' : 'change'; p.kind === 'host-set' || p.kind === 'seat-set' ? 'set' : p.kind === 'row-delete' ? 'verify' : 'change';
@@ -202,11 +206,32 @@
else if (r.kind === 'removed') rows[target.index].pin = undefined; else if (r.kind === 'removed') rows[target.index].pin = undefined;
break; break;
case 'row-delete': case 'row-delete':
if (r.kind === 'verified') rows = rows.filter((_, j) => j !== target.index); if (r.kind === 'verified') deleteRow = target.index; // arm the ❌; the actual delete is a tap on it
break; break;
} }
} }
// onKebab toggles a row's delete authorisation: a second tap (already armed) closes it and resets
// the host authorisation; otherwise it asks the host PIN (which, on success, arms the ❌).
function onKebab(i: number): void {
if (deleteRow === i) {
deleteRow = null;
return;
}
deleteRow = null; // only one row armed at a time
pad = { kind: 'row-delete', index: i };
}
function removeRow(i: number): void {
rows = rows.filter((_, j) => j !== i);
deleteRow = null;
}
function addRow(): void {
rows = [...rows, { name: '', committed: '' }];
deleteRow = null;
}
function setHostPlays(yes: boolean): void { function setHostPlays(yes: boolean): void {
askHostPlays = false; askHostPlays = false;
if (!yes) return; if (!yes) return;
@@ -307,60 +332,76 @@
>{t('new.start')}</button> >{t('new.start')}</button>
{:else if offlineMode.active} {:else if offlineMode.active}
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master <!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
PIN first (it gates the roster); each seat may add its own PIN. --> PIN first (it gates the roster); each seat may add its own PIN. The scrollable region +
the pinned Start button mirror the friends form, so a name input's soft keyboard shrinks
the region (no full-height spacer that would paint a blank block behind the keyboard). -->
<div class="fg"> <div class="fg">
<div class="hostpin"> <div class="hs-scroll">
<span class="ftitle">{t('hotseat.hostPin')}</span> <div class="hostpin">
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}> <span class="ftitle">{t('hotseat.hostPin')}</span>
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')} <button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
</button> {hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
</div> </button>
<label class="field"> </div>
<span>{t('new.gameType')}</span> <!-- Variant plaques + the multiple-words toggle, identical to Quick Match. -->
<select bind:value={inviteVariant} class:placeholder={!inviteVariant} disabled={variants.length === 1}> <div class="variants">
<option value="" disabled></option> {#each variants as v (v.id)}
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each} <button class="variant" class:selected={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>
</select> <span class="vmain">
</label> <span class="vname">{t(v.label)}</span>
{#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)} {#if VARIANT_FLAG[v.id]}
<label class="toggle"> <span class="vflag">{VARIANT_FLAG[v.id]}</span>
<span>{t('new.multipleWordsPerTurn')}</span> {:else}
<input type="checkbox" bind:checked={multipleWords} /> <img class="vflag-img" src="flag-ussr.svg" alt="" />
</label> {/if}
{/if} </span>
<div class="roster"> <span class="vrules">{t(VARIANT_RULES[v.id])}</span>
{#each rows as row, i (i)}
<div class="prow">
<input
class="pname"
class:invalid={row.name.trim() !== '' && !validDisplayName(row.name)}
bind:value={row.name}
disabled={!hostPin}
placeholder={t('hotseat.playerName')}
maxlength="40"
oninput={() => (row.committed = commitName(row.name, row.committed))}
onblur={() => (row.name = row.committed)}
/>
<button class="plink" disabled={!hostPin || row.committed === ''} onclick={() => openSeatPin(i)}>
{row.pin ? t('hotseat.changePin') : t('hotseat.setPin')}
</button> </button>
{#if rows.length > 2} {/each}
<button </div>
class="pkebab" {#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
<label class="toggle">
<span>{t('new.multipleWordsPerTurn')}</span>
<input type="checkbox" bind:checked={multipleWords} />
</label>
{/if}
<div class="roster">
{#each rows as row, i (i)}
<div class="prow">
<input
class="pname"
class:invalid={row.name.trim() !== '' && !validDisplayName(row.name)}
bind:value={row.name}
disabled={!hostPin} disabled={!hostPin}
aria-label={t('hotseat.removePlayer')} placeholder={t('hotseat.playerName')}
onclick={() => (pad = { kind: 'row-delete', index: i })} maxlength="40"
>⋮</button> oninput={() => (row.committed = commitName(row.name, row.committed))}
{/if} onblur={() => (row.name = row.committed)}
</div> />
{/each} <button class="plink" disabled={!hostPin || row.committed === ''} onclick={() => openSeatPin(i)}>
{row.pin ? t('hotseat.changePin') : t('hotseat.setPin')}
</button>
{#if rows.length > 2}
{#if deleteRow === i}
<button class="prow-del" aria-label={t('hotseat.removePlayer')} onclick={() => removeRow(i)}></button>
{/if}
<button
class="pkebab"
class:armed={deleteRow === i}
disabled={!hostPin}
aria-label={t('hotseat.removePlayer')}
onclick={() => onKebab(i)}
></button>
{/if}
</div>
{/each}
</div>
{#if rows.length < 4}
<button class="addrow" disabled={!hostPin} onclick={addRow}>
+ {t('hotseat.addPlayer')}
</button>
{/if}
</div> </div>
{#if rows.length < 4}
<button class="addrow" disabled={!hostPin} onclick={() => (rows = [...rows, { name: '', committed: '' }])}>
+ {t('hotseat.addPlayer')}
</button>
{/if}
<div class="grow"></div>
<button <button
class="invite" class="invite"
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting} disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
@@ -639,6 +680,17 @@
line-height: 1.4; line-height: 1.4;
} }
/* --- offline hotseat roster --- */ /* --- offline hotseat roster --- */
/* The scrollable region (host PIN + variants + roster), pinned above the Start button — mirrors
the friends form so a focused name input's soft keyboard shrinks this region (via --vvh on the
screen) instead of leaving a full-height blank spacer painted behind the keyboard. */
.hs-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
}
.hostpin { .hostpin {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -696,6 +748,18 @@
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1; line-height: 1;
} }
.pkebab.armed {
color: var(--accent);
}
/* The delete cross a correct host PIN arms next to a player row (mirrors the lobby game delete). */
.prow-del {
flex: 0 0 auto;
padding: 6px 8px;
border: none;
background: none;
font-size: 1rem;
line-height: 1;
}
.addrow { .addrow {
align-self: flex-start; align-self: flex-start;
padding: 8px 12px; padding: 8px 12px;
+13 -5
View File
@@ -24,17 +24,19 @@ clientsClaim();
// Drop precaches left by a previous SW revision (including the old install-only shell cache). // Drop precaches left by a previous SW revision (including the old install-only shell cache).
cleanupOutdatedCaches(); cleanupOutdatedCaches();
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
// needs. The landing page and the old-engine polyfill bundle are excluded via the build globs.
precacheAndRoute(self.__WB_MANIFEST);
// Navigations are network-first so a new deploy loads immediately when online: fetch the fresh // Navigations are network-first so a new deploy loads immediately when online: fetch the fresh
// (no-cache) shell from the server — it references the new hashed assets, which are then fetched // (no-cache) shell from the server — it references the new hashed assets, which are then fetched
// fresh — and only fall back to the precached shell when the network is unreachable or too slow (a // fresh — and only fall back to the precached shell when the network is unreachable or too slow (a
// short timeout). This keeps the app up to date online while still cold-launching offline. Only the // short timeout). This keeps the app up to date online while still cold-launching offline. Only the
// tiny HTML is re-fetched; the immutable hashed assets stay cache-first (precacheAndRoute above) and // tiny HTML is re-fetched; the immutable hashed assets stay cache-first (precacheAndRoute below) and
// re-download only when their hash — i.e. the version — changes. Deny-list the RPC path and the // re-download only when their hash — i.e. the version — changes. Deny-list the RPC path and the
// admin console so those are never resolved to the app shell. // admin console so those are never resolved to the app shell.
//
// This route MUST be registered BEFORE precacheAndRoute: Workbox matches routes in registration
// order, and the precache route matches shell navigations too (its directoryIndex is index.html), so
// registering it first would shadow this handler and serve the shell CACHE-FIRST — i.e. the old
// build's version until the next load. NavigationRoute matches only request.mode === 'navigate', so
// hashed-asset requests still fall through to the cache-first precache route below.
const NAV_TIMEOUT_MS = 3000; const NAV_TIMEOUT_MS = 3000;
async function freshShell({ request }: { request: Request }): Promise<Response> { async function freshShell({ request }: { request: Request }): Promise<Response> {
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -50,3 +52,9 @@ async function freshShell({ request }: { request: Request }): Promise<Response>
return (await matchPrecache('index.html')) ?? Response.error(); return (await matchPrecache('index.html')) ?? Response.error();
} }
registerRoute(new NavigationRoute(freshShell, { denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//] })); registerRoute(new NavigationRoute(freshShell, { denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//] }));
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
// needs (the precached index.html is also the offline fallback matchPrecache serves above). The
// immutable hashed assets stay cache-first here; the landing page and the old-engine polyfill bundle
// are excluded via the build globs.
precacheAndRoute(self.__WB_MANIFEST);