release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212

Merged
developer merged 79 commits from development into master 2026-07-07 14:40:43 +00:00
Showing only changes of commit cafe67e9c8 - Show all commits
+88 -87
View File
@@ -232,6 +232,14 @@
deleteRow = null;
}
// Centre a focused name input above the soft keyboard: the keyboard shrinks the screen (--vvh),
// so wait a beat for it to open + the layout to settle, then scroll the field into view (the
// default focus-scroll leaves a bottom row hidden behind the keyboard).
function bringIntoView(e: FocusEvent): void {
const el = e.currentTarget as HTMLElement;
setTimeout(() => el.scrollIntoView({ block: 'center' }), 300);
}
function setHostPlays(yes: boolean): void {
askHostPlays = false;
if (!yes) return;
@@ -278,7 +286,10 @@
</script>
<Screen title={t('new.title')} back="/">
<div class="page">
<!-- The hotseat form flows naturally and scrolls with the screen's own scroll (scrollform: no
forced full height, no pinned button) so a focused name input's soft keyboard just scrolls the
page rather than fighting a nested scroll region. -->
<div class="page" class:scrollform={mode === 'friends' && offlineMode.active}>
<!-- The auto/friends selector. Online it is hidden for guests (no friends to invite). Offline
it is always shown: "quick" is the local vs_ai flow, "with friends" is the local
pass-and-play (hotseat) flow. -->
@@ -332,82 +343,77 @@
>{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. 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="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>
{/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}
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>
<button
class="invite"
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
onclick={startHotseat}
>{t('new.start')}</button>
PIN first (it gates the roster); each seat may add its own PIN. -->
<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>
{/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}
placeholder={t('hotseat.playerName')}
maxlength="40"
onfocus={bringIntoView}
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}
<button
class="invite"
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
onclick={startHotseat}
>{t('new.start')}</button>
{:else if friends.length === 0}
<p class="subtitle">{t('new.noFriends')}</p>
{:else}
@@ -486,6 +492,12 @@
height: 100%;
box-sizing: border-box;
}
/* The hotseat form: natural height (grows past the viewport → the screen's own scroll takes over)
with no full-height pinning, so a focused name input just scrolls the page above the keyboard. */
.page.scrollform {
height: auto;
min-height: 100%;
}
.subtitle {
color: var(--text-muted);
margin: 0;
@@ -680,17 +692,6 @@
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;