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:
@@ -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' },
|
||||
|
||||
@@ -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<boolean> {
|
||||
const entry = await this.load(gameId);
|
||||
|
||||
Reference in New Issue
Block a user