fix(offline): unify NewGame layout to natural-flow + PinPad verdict pause
CI / changes (pull_request) Successful in 1s
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 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s

- NewGame: all three forms (quick / friends / hotseat) now use the
  Profile sub-view's natural-flow .page (no forced height, no pinned-CTA
  .grow/.fg scroll), so the screen's single .content scroll + --vvh handle
  overflow and a focused name input scrolls into view cleanly — fixes the
  keyboard blank-space / deep-scroll regression. One layout rule, one place.
- PinPad: pause 250ms after the 4th digit before the verdict, so the fill
  (and the shake on a wrong PIN) reads as a deliberate response.
- e2e: typePin waits for empty dots before typing (robust to the pause).
This commit is contained in:
Ilia Denisov
2026-07-07 13:19:22 +02:00
parent cafe67e9c8
commit c1ac77bc6a
3 changed files with 10 additions and 31 deletions
+3
View File
@@ -27,7 +27,10 @@ async function goOffline(page: Page): Promise<void> {
} }
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action). // typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
// It first waits for the dots to be empty, so a call made right after a previous entry does not lose
// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance).
async function typePin(page: Page, digits: string): Promise<void> { async function typePin(page: Page, digits: string): Promise<void> {
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click(); for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
} }
+3
View File
@@ -72,6 +72,9 @@
async function commit(): Promise<void> { async function commit(): Promise<void> {
busy = true; busy = true;
try { try {
// Let the 4th dot register before the verdict: a beat so the fill (and, on failure, the shake)
// reads as a deliberate response rather than an instant flicker.
await new Promise((r) => setTimeout(r, 250));
if (phase === 'old') { if (phase === 'old') {
if (verify && (await verify(buffer))) { if (verify && (await verify(buffer))) {
buffer = ''; buffer = '';
+4 -31
View File
@@ -232,14 +232,6 @@
deleteRow = null; 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 { function setHostPlays(yes: boolean): void {
askHostPlays = false; askHostPlays = false;
if (!yes) return; if (!yes) return;
@@ -286,10 +278,7 @@
</script> </script>
<Screen title={t('new.title')} back="/"> <Screen title={t('new.title')} back="/">
<!-- The hotseat form flows naturally and scrolls with the screen's own scroll (scrollform: no <div class="page">
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 <!-- 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 it is always shown: "quick" is the local vs_ai flow, "with friends" is the local
pass-and-play (hotseat) flow. --> pass-and-play (hotseat) flow. -->
@@ -333,7 +322,6 @@
<input type="checkbox" bind:checked={multipleWords} /> <input type="checkbox" bind:checked={multipleWords} />
</label> </label>
{/if} {/if}
<div class="grow"></div>
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p> <p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if} {#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button <button
@@ -382,7 +370,6 @@
disabled={!hostPin} disabled={!hostPin}
placeholder={t('hotseat.playerName')} placeholder={t('hotseat.playerName')}
maxlength="40" maxlength="40"
onfocus={bringIntoView}
oninput={() => (row.committed = commitName(row.name, row.committed))} oninput={() => (row.committed = commitName(row.name, row.committed))}
onblur={() => (row.name = row.committed)} onblur={() => (row.name = row.committed)}
/> />
@@ -484,20 +471,16 @@
</Screen> </Screen>
<style> <style>
/* Natural-flow content (matching the Profile sub-view): no forced height and no pinned CTA, so the
screen's own scroll (Screen .content + --vvh) handles overflow and a focused input scrolls into
view above the soft keyboard with a single, clean scroll container. */
.page { .page {
padding: var(--pad); padding: var(--pad);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
height: 100%;
box-sizing: border-box; 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 { .subtitle {
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
@@ -580,8 +563,6 @@
border-color: var(--accent); border-color: var(--accent);
} }
.fg { .fg {
flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -604,9 +585,6 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.friends-scroll { .friends-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
@@ -667,11 +645,6 @@
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
} }
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
mirroring the friend-game invite button pinned by the .fg scroll area. */
.grow {
flex: 1;
}
.invite { .invite {
flex: 0 0 auto; flex: 0 0 auto;
padding: 14px; padding: 14px;