From beda6ccd3d89911af61e46101d1c15e27bf20028 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 14:10:57 +0200 Subject: [PATCH] fix(ui): iOS soft-keyboard shell alignment + hotseat relock on return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Safari/WKWebView does not shrink the layout viewport for the soft keyboard (Android Chrome does): the visual viewport shrinks AND offsets down toward the focused field, and the values do not cleanly revert. The pinned shell tracked only the height (--vvh), not the offset, so its top-anchored content misaligned on iOS — empty space below the form, worst on a repeat focus. Fix (one place; covers NewGame, Chat, Feedback): - app.svelte.ts syncViewport: also mirror visualViewport.offsetTop into --vv-top, and scroll the focused field into view on the keyboard-open transition (iOS does not reliably scroll a pinned document to it). - app.css: the pinned app-shell body follows top=--vv-top + height=--vvh (was inset:0), so it stays on the visible area. - e2e/viewport.spec.ts emulates the visual-viewport resize+offset (a fake window.visualViewport) to verify the shell follows, on Chromium+WebKit. Hotseat: returning to the lobby now re-locks the current seat (its PIN is re-prompted) — LocalSource.relock, cleared in Game.svelte onDestroy. Root-caused a crash it exposed: a $props() value (id) reads back undefined during Svelte teardown, so isLocalGameId(id) threw and aborted onDestroy (breaking navigation) — read the id from the loaded view instead. --- ui/e2e/viewport.spec.ts | 69 +++++++++++++++++++++ ui/src/app.css | 10 ++- ui/src/game/Game.svelte | 4 ++ ui/src/lib/app.svelte.ts | 44 +++++++++---- ui/src/lib/gamesource.ts | 5 +- ui/src/lib/localgame/source.hotseat.test.ts | 13 ++++ ui/src/lib/localgame/source.ts | 8 +++ 7 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 ui/e2e/viewport.spec.ts diff --git a/ui/e2e/viewport.spec.ts b/ui/e2e/viewport.spec.ts new file mode 100644 index 0000000..46e02b8 --- /dev/null +++ b/ui/e2e/viewport.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, type Page } from './fixtures'; + +// Playwright's WebKit has no real soft keyboard, so emulate iOS's visual-viewport behaviour: replace +// window.visualViewport with a controllable fake BEFORE the app boots. The app's syncViewport +// (app.svelte.ts) reads visualViewport.height/offsetTop and mirrors them into --vvh / --vv-top, which +// position the pinned app-shell (app.css html.app-shell body). Driving the fake exercises the exact +// code path the real keyboard triggers on iOS — where the layout viewport does NOT shrink and the +// visual viewport instead offsets down toward the focused field. +async function installFakeViewport(page: Page): Promise { + await page.addInitScript(() => { + const fake = new EventTarget() as EventTarget & { height: number; offsetTop: number; width: number }; + fake.height = window.innerHeight; + fake.offsetTop = 0; + fake.width = window.innerWidth; + Object.defineProperty(window, 'visualViewport', { configurable: true, value: fake }); + (window as unknown as { __vv: typeof fake }).__vv = fake; + }); +} + +async function setViewport(page: Page, height: number, offsetTop: number): Promise { + await page.evaluate( + ([h, t]) => { + const vv = (window as unknown as { __vv: { height: number; offsetTop: number; dispatchEvent(e: Event): boolean } }).__vv; + vv.height = h; + vv.offsetTop = t; + vv.dispatchEvent(new Event('resize')); + vv.dispatchEvent(new Event('scroll')); + }, + [height, offsetTop], + ); +} + +async function shell(page: Page): Promise<{ vvh: string; top: string; bodyTop: string }> { + return page.evaluate(() => ({ + vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(), + top: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(), + bodyTop: getComputedStyle(document.body).top, + })); +} + +test.describe('visual-viewport shell (soft-keyboard alignment)', () => { + test('the pinned shell follows the visual viewport height AND offset', async ({ page }) => { + await installFakeViewport(page); + await page.goto('/'); + await expect(page.locator('html.app-shell')).toBeAttached(); + const innerH = await page.evaluate(() => window.innerHeight); + + // Keyboard closed: full height, no offset. + await setViewport(page, innerH, 0); + let s = await shell(page); + expect(s.top).toBe('0px'); + expect(s.bodyTop).toBe('0px'); + + // Keyboard open + iOS offset: the visual viewport shrinks AND offsets down. The pinned shell must + // follow BOTH — the body's top must equal the offset (not stay at 0), else the top-anchored + // content shows empty space below (the iOS bug this fixes). + await setViewport(page, innerH - 300, 180); + s = await shell(page); + expect(s.vvh).toBe(`${innerH - 300}px`); + expect(s.top).toBe('180px'); + expect(s.bodyTop).toBe('180px'); + + // Keyboard closed again: the offset reverts to 0 — no stale shift left behind. + await setViewport(page, innerH, 0); + s = await shell(page); + expect(s.top).toBe('0px'); + expect(s.bodyTop).toBe('0px'); + }); +}); diff --git a/ui/src/app.css b/ui/src/app.css index 193cabe..2e573a7 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -162,7 +162,15 @@ html.app-shell { } html.app-shell body { position: fixed; - inset: 0; + /* Follow the visual viewport (top + height), not merely fill the layout viewport: iOS Safari / + WKWebView does not shrink the layout viewport for the soft keyboard — it offsets the visual + viewport down toward the focused field — so a top-anchored (inset:0) shell would misalign, showing + empty space below the content (worst on a repeat focus). --vv-top / --vvh are kept in sync from + app.svelte.ts (syncViewport). The fallbacks equal inset:0 before the first sync / on the landing. */ + top: var(--vv-top, 0px); + left: 0; + right: 0; + height: var(--vvh, 100%); overflow: hidden; } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index a9bf0e1..e2136ce 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -676,6 +676,10 @@ void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); } localUnsub?.(); + // Leaving a hotseat game re-locks the current seat, so returning from the lobby re-prompts its + // PIN. Read the id from the loaded view, NOT the `id` prop: a $props() value reads back undefined + // during Svelte teardown, and isLocalGameId(undefined) would throw and abort this cleanup. + if (view?.game.hotseat) localSource.relock(view.game.id); }); function onCell(row: number, col: number) { diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 7e33aaf..7ae8848 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -725,17 +725,37 @@ function syncTelegramSafeArea(): void { root.classList.toggle('tg-fullscreen', top > 0); } +// The soft-keyboard height threshold (px) that distinguishes an open keyboard from browser chrome +// (an address bar shrinks the visual viewport by far less), used to gate the focus-into-view scroll. +const KEYBOARD_MIN_PX = 120; + /** - * syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can - * fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard). - * On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible - * without the page scrolling, so the layout no longer jumps when the keyboard appears. + * syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css + * `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft + * keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android + * Chrome does); instead the visual viewport shrinks AND offsets down to reveal the focused field, and + * those values do not always cleanly revert. Tracking only the height left the top-anchored shell + * misaligned on iOS — empty space below the content, worst on a repeat focus; tracking offsetTop too + * keeps the shell on the visible area on every platform. On the keyboard-OPEN transition it also + * scrolls the focused field into view, since iOS does not reliably scroll a pinned document to it. */ -function syncViewportHeight(): void { +let keyboardOpen = false; +function syncViewport(): void { if (typeof document === 'undefined') return; - const vv = typeof window !== 'undefined' ? window.visualViewport : null; - const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0; + const win = typeof window !== 'undefined' ? window : null; + const vv = win ? win.visualViewport : null; + const h = vv ? vv.height : win ? win.innerHeight : 0; + const top = vv ? vv.offsetTop : 0; if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); + document.documentElement.style.setProperty('--vv-top', `${top}px`); + const open = !!vv && !!win && win.innerHeight - h > KEYBOARD_MIN_PX; + if (open && !keyboardOpen) { + const el = document.activeElement; + if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) { + requestAnimationFrame(() => el.scrollIntoView({ block: 'center' })); + } + } + keyboardOpen = open; } /** @@ -795,11 +815,13 @@ export async function bootstrap(): Promise { setLocale(guess); } - // Track the visual-viewport height so screens fit above an open soft keyboard (--vvh). - syncViewportHeight(); + // Track the visual viewport (height + offset) so screens fit and follow the visible area above an + // open soft keyboard (--vvh / --vv-top). Both resize AND scroll fire on iOS when the keyboard moves + // the visual viewport. + syncViewport(); if (typeof window !== 'undefined' && window.visualViewport) { - window.visualViewport.addEventListener('resize', syncViewportHeight); - window.visualViewport.addEventListener('scroll', syncViewportHeight); + window.visualViewport.addEventListener('resize', syncViewport); + window.visualViewport.addEventListener('scroll', syncViewport); } // Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no diff --git a/ui/src/lib/gamesource.ts b/ui/src/lib/gamesource.ts index 9031aac..ed87fc6 100644 --- a/ui/src/lib/gamesource.ts +++ b/ui/src/lib/gamesource.ts @@ -46,6 +46,9 @@ export const localSource = { unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)), verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(id, pin)), hostAction: (id, pin, action, targetSeat) => load().then((s) => s.hostAction(id, pin, action, targetSeat)), + // relock is fire-and-forget: the engine is already loaded (the game was open), and re-locking a + // cached game before the screen is left needs no result. + relock: (id) => void load().then((s) => s.relock(id)), // events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires // the real subscription once the engine loads and, until then, cancels a pending subscribe. events: (id, onEvent) => { @@ -60,7 +63,7 @@ export const localSource = { }; }, } satisfies GameLoopSource & - Pick; + Pick; /** * gameSource returns the source that runs the game with the given id: the local engine for a local diff --git a/ui/src/lib/localgame/source.hotseat.test.ts b/ui/src/lib/localgame/source.hotseat.test.ts index e8a5a9f..39f28ba 100644 --- a/ui/src/lib/localgame/source.hotseat.test.ts +++ b/ui/src/lib/localgame/source.hotseat.test.ts @@ -111,6 +111,19 @@ describe('LocalSource hotseat', () => { await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' }); }); + it('re-locks an unlocked seat on relock (returning to the lobby)', async () => { + const src = await hotseat('local:h9', [ + { kind: 'human', name: 'Ann', pin: await newLock('1234') }, + { kind: 'human', name: 'Bob' }, + ]); + const opened = await src.unlockSeat('local:h9', '1234'); + expect(opened.locked).toBe(false); + src.relock('local:h9'); + const st = await src.gameState('local:h9'); + expect(st.locked).toBe(true); // returning to the game re-prompts the seat PIN + expect(st.rack).toEqual([]); + }); + it('verifies the master PIN', async () => { const src = await hotseat('local:h7', [ { kind: 'human', name: 'A' }, diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 1fcd8d7..37d56de 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -214,6 +214,14 @@ export class LocalSource implements GameLoopSource { return this.stateView(entry); } + /** relock drops any per-turn seat unlock, so re-opening a hotseat game re-prompts the current + * seat's PIN. Called when the game screen is left (returning to the lobby). No-op when the game is + * not cached, or a seat with no PIN (which is never withheld anyway). */ + relock(gameId: string): void { + const entry = this.live.get(gameId); + if (entry) entry.unlockedSeat = null; + } + /** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */ async verifyHostPin(gameId: string, pin: string): Promise { const entry = await this.load(gameId);