From 3adc4e32c9bf60226d9b25e90db06453a506d6d3 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 12:35:33 +0200 Subject: [PATCH] fix(offline): hotseat review fixes + PWA stale-shell (SW route order) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/ARCHITECTURE.md | 13 ++- ui/e2e/hotseat.spec.ts | 20 +++- ui/src/screens/NewGame.svelte | 166 +++++++++++++++++++++++----------- ui/src/sw.ts | 18 +++- 4 files changed, 153 insertions(+), 64 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 796ff5e..1e6ae33 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 **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 -no network, and falls every in-scope navigation back to the precached shell (the hash router -resolves the route client-side); 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 +no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the +server (so a new deploy is picked up on the very next launch, not the one after), falling back to the +precached shell only when the network is unreachable or too slow; the immutable hashed assets stay +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 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 diff --git a/ui/e2e/hotseat.spec.ts b/ui/e2e/hotseat.spec.ts index d89e43c..1ac8c6a 100644 --- a/ui/e2e/hotseat.spec.ts +++ b/ui/e2e/hotseat.spec.ts @@ -54,14 +54,26 @@ test.describe('offline hotseat (pass-and-play)', () => { await typePin(page, '9999'); await page.getByRole('button', { name: /^(No|Нет)$/ }).click(); - // Pick the English variant if the picker offers a choice. - const variant = page.locator('.fg select').first(); - if (await variant.isEnabled()) await variant.selectOption('scrabble_en'); + // Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name). + await page.locator('.variant', { hasText: 'Scrabble' }).click(); // Two players: Ann (PIN-locked) and Bob (open). await page.locator('.pname').nth(0).fill('Ann'); 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'); diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 78b92aa..64fdd0f 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -167,6 +167,10 @@ | { kind: 'seat-change'; index: number } | { kind: 'row-delete'; index: number }; let pad = $state(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(null); const padMode = (p: PadTarget): 'set' | '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; break; 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; } } + // 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 { askHostPlays = false; if (!yes) return; @@ -307,60 +332,76 @@ >{t('new.start')} {:else if offlineMode.active} + 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). -->
-
- {t('hotseat.hostPin')} - -
- - {#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)} - - {/if} -
- {#each rows as row, i (i)} -
- (row.committed = commitName(row.name, row.committed))} - onblur={() => (row.name = row.committed)} - /> - +
+ +
+ {#each variants as v (v.id)} + - {#if rows.length > 2} - - {/if} -
- {/each} + placeholder={t('hotseat.playerName')} + maxlength="40" + oninput={() => (row.committed = commitName(row.name, row.committed))} + onblur={() => (row.name = row.committed)} + /> + + {#if rows.length > 2} + {#if deleteRow === i} + + {/if} + + {/if} +
+ {/each} +
+ {#if rows.length < 4} + + {/if} - {#if rows.length < 4} - - {/if} -