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
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:
+115
-51
@@ -167,6 +167,10 @@
|
||||
| { kind: 'seat-change'; index: number }
|
||||
| { kind: 'row-delete'; index: number };
|
||||
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' =>
|
||||
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')}</button>
|
||||
{:else if offlineMode.active}
|
||||
<!-- 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="hostpin">
|
||||
<span class="ftitle">{t('hotseat.hostPin')}</span>
|
||||
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
|
||||
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>{t('new.gameType')}</span>
|
||||
<select bind:value={inviteVariant} class:placeholder={!inviteVariant} disabled={variants.length === 1}>
|
||||
<option value="" disabled>—</option>
|
||||
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)}
|
||||
<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}
|
||||
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')}
|
||||
<div class="hs-scroll">
|
||||
<div class="hostpin">
|
||||
<span class="ftitle">{t('hotseat.hostPin')}</span>
|
||||
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
|
||||
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Variant plaques + the multiple-words toggle, identical to Quick Match. -->
|
||||
<div class="variants">
|
||||
{#each variants as v (v.id)}
|
||||
<button class="variant" class:selected={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>
|
||||
<span class="vmain">
|
||||
<span class="vname">{t(v.label)}</span>
|
||||
{#if VARIANT_FLAG[v.id]}
|
||||
<span class="vflag">{VARIANT_FLAG[v.id]}</span>
|
||||
{:else}
|
||||
<img class="vflag-img" src="flag-ussr.svg" alt="" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="vrules">{t(VARIANT_RULES[v.id])}</span>
|
||||
</button>
|
||||
{#if rows.length > 2}
|
||||
<button
|
||||
class="pkebab"
|
||||
{/each}
|
||||
</div>
|
||||
{#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}
|
||||
aria-label={t('hotseat.removePlayer')}
|
||||
onclick={() => (pad = { kind: 'row-delete', index: i })}
|
||||
>⋮</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
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>
|
||||
{#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>
|
||||
{#if rows.length < 4}
|
||||
<button class="addrow" disabled={!hostPin} onclick={() => (rows = [...rows, { name: '', committed: '' }])}>
|
||||
+ {t('hotseat.addPlayer')}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="grow"></div>
|
||||
<button
|
||||
class="invite"
|
||||
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
|
||||
@@ -639,6 +680,17 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* --- 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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -696,6 +748,18 @@
|
||||
font-size: 1.1rem;
|
||||
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 {
|
||||
align-self: flex-start;
|
||||
padding: 8px 12px;
|
||||
|
||||
+13
-5
@@ -24,17 +24,19 @@ clientsClaim();
|
||||
// Drop precaches left by a previous SW revision (including the old install-only shell cache).
|
||||
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
|
||||
// (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
|
||||
// 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
|
||||
// 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;
|
||||
async function freshShell({ request }: { request: Request }): Promise<Response> {
|
||||
const ctrl = new AbortController();
|
||||
@@ -50,3 +52,9 @@ async function freshShell({ request }: { request: Request }): Promise<Response>
|
||||
return (await matchPrecache('index.html')) ?? Response.error();
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user