diff --git a/ui/e2e/hotseat.spec.ts b/ui/e2e/hotseat.spec.ts index 4f92a54..875beb6 100644 --- a/ui/e2e/hotseat.spec.ts +++ b/ui/e2e/hotseat.spec.ts @@ -94,6 +94,13 @@ test.describe('offline hotseat (pass-and-play)', () => { await typePin(page, '1234'); await expect(page.locator('.rack .tile')).toHaveCount(7); + // Opening the history reveals NO social controls on the seat plaques (a local game is + // account-less), and the Dictionary entry (the comms button) is kept for the active game. + await page.locator('.scoreboard').click(); + await expect(page.locator('.fico')).toHaveCount(0); + await expect(page.locator('.chat-ico')).toBeVisible(); + await page.locator('.scoreboard').click(); + // Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob, // whose seat is open, so his rack shows without a lock. await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click(); diff --git a/ui/src/game/CommsHub.svelte b/ui/src/game/CommsHub.svelte index b2f311f..a3b442a 100644 --- a/ui/src/game/CommsHub.svelte +++ b/ui/src/game/CommsHub.svelte @@ -16,18 +16,21 @@ // The game is rendered (and cached) before its comms open, so the cache tells us whether // it is still active without another fetch; an unknown game keeps the Dictionary offered. const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); - // An honest-AI game has no chat at all, so its hub is Dictionary-only. - const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false); + // A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game. + // Its hub is Dictionary-only. + const chatless = $derived( + (getCachedGame(id)?.view.game.vsAi ?? false) || (getCachedGame(id)?.view.game.hotseat ?? false), + ); // Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid: - // an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary + // a chatless game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary // deep-link falls back to Chat). - // An honest-AI game has only the Dictionary, so start there regardless of the entry route — this - // keeps ChatScreen (which fetches chat over the network) from mounting even for a beat, which would - // otherwise raise an error toast in offline mode. + // A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen + // (which fetches chat over the network) from mounting even for a beat, which would otherwise raise + // an error toast in offline mode. // svelte-ignore state_referenced_locally - let tab = $state(vsAi ? 'dictionary' : initialTab); + let tab = $state(chatless ? 'dictionary' : initialTab); $effect(() => { - if (vsAi) tab = 'dictionary'; + if (chatless) tab = 'dictionary'; else if (tab === 'dictionary' && !active) tab = 'chat'; }); @@ -46,7 +49,7 @@ {#snippet tabbar()} - {#if !vsAi} + {#if !chatless} diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index e2136ce..14ee04a 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1422,8 +1422,9 @@ // (not the still-empty seat of an open game) who is not yet a friend (an already-requested // opponent still shows it, but disabled). function canAddFriend(s: { accountId: string; seat: number }): boolean { - // Never offer add-friend against an AI opponent, an existing friend, or a blocked player. - if (view?.game.vsAi) return false; + // Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in + // a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids. + if (view?.game.vsAi || isLocalGameId(id)) return false; return ( !!s.accountId && !app.profile?.isGuest && @@ -1437,7 +1438,7 @@ // may still be blocked (the block overrides the friendship), so it omits the friend exclusion. // An already-blocked opponent hides it (both controls go, and the name is struck). function canBlock(s: { accountId: string; seat: number }): boolean { - if (view?.game.vsAi) return false; + if (view?.game.vsAi || isLocalGameId(id)) return false; return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s); } @@ -1546,10 +1547,11 @@ {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} - - {#if !(gameOver && view.game.vsAi) && !view.game.hotseat} + + {#if !(gameOver && (view.game.vsAi || view.game.hotseat))} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 7ae8848..173f59d 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -824,6 +824,16 @@ export async function bootstrap(): Promise { window.visualViewport.addEventListener('scroll', syncViewport); } + // Enter on a single-line dismisses the soft keyboard (blur): the keyboard's default + // return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A