fix(ui): iOS soft-keyboard shell alignment + hotseat relock on return
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 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
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 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
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.
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+9
-1
@@ -162,7 +162,15 @@ html.app-shell {
|
|||||||
}
|
}
|
||||||
html.app-shell body {
|
html.app-shell body {
|
||||||
position: fixed;
|
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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -676,6 +676,10 @@
|
|||||||
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
||||||
}
|
}
|
||||||
localUnsub?.();
|
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) {
|
function onCell(row: number, col: number) {
|
||||||
|
|||||||
+33
-11
@@ -725,17 +725,37 @@ function syncTelegramSafeArea(): void {
|
|||||||
root.classList.toggle('tg-fullscreen', top > 0);
|
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
|
* syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css
|
||||||
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
|
* `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft
|
||||||
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
|
* keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android
|
||||||
* without the page scrolling, so the layout no longer jumps when the keyboard appears.
|
* 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;
|
if (typeof document === 'undefined') return;
|
||||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
const win = typeof window !== 'undefined' ? window : null;
|
||||||
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0;
|
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`);
|
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<void> {
|
|||||||
setLocale(guess);
|
setLocale(guess);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh).
|
// Track the visual viewport (height + offset) so screens fit and follow the visible area above an
|
||||||
syncViewportHeight();
|
// 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) {
|
if (typeof window !== 'undefined' && window.visualViewport) {
|
||||||
window.visualViewport.addEventListener('resize', syncViewportHeight);
|
window.visualViewport.addEventListener('resize', syncViewport);
|
||||||
window.visualViewport.addEventListener('scroll', syncViewportHeight);
|
window.visualViewport.addEventListener('scroll', syncViewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
|
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export const localSource = {
|
|||||||
unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)),
|
unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)),
|
||||||
verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(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)),
|
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
|
// 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.
|
// the real subscription once the engine loads and, until then, cancels a pending subscribe.
|
||||||
events: (id, onEvent) => {
|
events: (id, onEvent) => {
|
||||||
@@ -60,7 +63,7 @@ export const localSource = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
} satisfies GameLoopSource &
|
} satisfies GameLoopSource &
|
||||||
Pick<LocalSource, 'events' | 'create' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction'>;
|
Pick<LocalSource, 'events' | 'create' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction' | 'relock'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* gameSource returns the source that runs the game with the given id: the local engine for a local
|
* gameSource returns the source that runs the game with the given id: the local engine for a local
|
||||||
|
|||||||
@@ -111,6 +111,19 @@ describe('LocalSource hotseat', () => {
|
|||||||
await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' });
|
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 () => {
|
it('verifies the master PIN', async () => {
|
||||||
const src = await hotseat('local:h7', [
|
const src = await hotseat('local:h7', [
|
||||||
{ kind: 'human', name: 'A' },
|
{ kind: 'human', name: 'A' },
|
||||||
|
|||||||
@@ -214,6 +214,14 @@ export class LocalSource implements GameLoopSource {
|
|||||||
return this.stateView(entry);
|
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. */
|
/** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */
|
||||||
async verifyHostPin(gameId: string, pin: string): Promise<boolean> {
|
async verifyHostPin(gameId: string, pin: string): Promise<boolean> {
|
||||||
const entry = await this.load(gameId);
|
const entry = await this.load(gameId);
|
||||||
|
|||||||
Reference in New Issue
Block a user