fix(ui): local-game history (hide social, keep dictionary); Enter dismisses keyboard; email-code autosubmit
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s

- History drawer in a local (offline) game: the seat plaques no longer
  show the add-friend/block controls — canAddFriend/canBlock now exclude a
  local game (hotseat seats have synthetic account ids that slipped past
  the vs_ai-only guard) — and the Dictionary entry is restored: an active
  hotseat game keeps the comms button, and CommsHub is Dictionary-only for
  a chatless vs_ai OR hotseat game (ChatScreen never mounts offline).
- Enter on any single-line <input> now dismisses the soft keyboard (blur);
  a <textarea> (feedback) keeps Enter for newlines. One global handler.
- Login email code: the friend-code spread-digit style (.codein), a
  6-char cap, auto-submit on the 6th digit, and Enter to submit.
- e2e: the local-game history has no social controls and keeps the
  dictionary entry.
This commit is contained in:
Ilia Denisov
2026-07-07 14:45:53 +02:00
parent beda6ccd3d
commit 52d0c559f9
5 changed files with 57 additions and 17 deletions
+7
View File
@@ -94,6 +94,13 @@ test.describe('offline hotseat (pass-and-play)', () => {
await typePin(page, '1234'); await typePin(page, '1234');
await expect(page.locator('.rack .tile')).toHaveCount(7); 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, // 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. // whose seat is open, so his rack shows without a lock.
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click(); await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
+12 -9
View File
@@ -16,18 +16,21 @@
// The game is rendered (and cached) before its comms open, so the cache tells us whether // 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. // it is still active without another fetch; an unknown game keeps the Dictionary offered.
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
// An honest-AI game has no chat at all, so its hub is Dictionary-only. // A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game.
const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false); // 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: // 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). // deep-link falls back to Chat).
// An honest-AI game has only the Dictionary, so start there regardless of the entry route — this // A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen
// keeps ChatScreen (which fetches chat over the network) from mounting even for a beat, which would // (which fetches chat over the network) from mounting even for a beat, which would otherwise raise
// otherwise raise an error toast in offline mode. // an error toast in offline mode.
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let tab = $state<CommsTab>(vsAi ? 'dictionary' : initialTab); let tab = $state<CommsTab>(chatless ? 'dictionary' : initialTab);
$effect(() => { $effect(() => {
if (vsAi) tab = 'dictionary'; if (chatless) tab = 'dictionary';
else if (tab === 'dictionary' && !active) tab = 'chat'; else if (tab === 'dictionary' && !active) tab = 'chat';
}); });
</script> </script>
@@ -46,7 +49,7 @@
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
{#if !vsAi} {#if !chatless}
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}> <button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span> <span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
</button> </button>
+9 -7
View File
@@ -1422,8 +1422,9 @@
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested // (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled). // opponent still shows it, but disabled).
function canAddFriend(s: { accountId: string; seat: number }): boolean { function canAddFriend(s: { accountId: string; seat: number }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player. // Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in
if (view?.game.vsAi) return false; // 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 ( return (
!!s.accountId && !!s.accountId &&
!app.profile?.isGuest && !app.profile?.isGuest &&
@@ -1437,7 +1438,7 @@
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion. // 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). // An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(s: { accountId: string; seat: number }): boolean { 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); return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
} }
</script> </script>
@@ -1546,10 +1547,11 @@
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if} {#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the <!-- The comms entry opens Chat + the word Dictionary. A local game (vs_ai or hotseat) has no
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
A hotseat game has no chat either (local players, no accounts). --> — so drop the entry only when a local game is finished; an active local game keeps it for
{#if !(gameOver && view.game.vsAi) && !view.game.hotseat} the Dictionary. An online game always keeps it (chat outlives the game). -->
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}> <button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key} {#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button> </button>
+10
View File
@@ -824,6 +824,16 @@ export async function bootstrap(): Promise<void> {
window.visualViewport.addEventListener('scroll', syncViewport); window.visualViewport.addEventListener('scroll', syncViewport);
} }
// Enter on a single-line <input> dismisses the soft keyboard (blur): the keyboard's default
// return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A <textarea> (the
// feedback form) is excluded so Enter still inserts a newline; a form's own Enter→submit still
// fires first (this listener is on the document, so it runs after the field's own handler).
if (typeof document !== 'undefined') {
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.target.blur();
});
}
// 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
// longer a render-blocking <script> in index.html, so a network that blocks telegram.org (common // longer a render-blocking <script> in index.html, so a network that blocks telegram.org (common
// where Telegram itself reaches users only over a proxy) cannot hang the page and strand the app // where Telegram itself reaches users only over a proxy) cannot hang the page and strand the app
+19 -1
View File
@@ -17,10 +17,17 @@
} }
async function signIn() { async function signIn() {
if (busy || code.trim().length === 0) return;
busy = true; busy = true;
await loginEmail(email.trim(), code.trim()); await loginEmail(email.trim(), code.trim());
busy = false; busy = false;
} }
// Track the code and auto-submit once the 6-digit code is complete (Enter submits too).
function onCode(v: string): void {
code = v;
if (code.trim().length === 6) void signIn();
}
</script> </script>
<main class="login"> <main class="login">
@@ -46,10 +53,16 @@
{:else} {:else}
<p class="muted">{t('login.codeSent', { email })}</p> <p class="muted">{t('login.codeSent', { email })}</p>
<input <input
class="codein"
inputmode="numeric" inputmode="numeric"
autocomplete="one-time-code" autocomplete="one-time-code"
maxlength="6"
placeholder={t('login.codePlaceholder')} placeholder={t('login.codePlaceholder')}
bind:value={code} value={code}
oninput={(e) => onCode(e.currentTarget.value)}
onkeydown={(e) => {
if (e.key === 'Enter') void signIn();
}}
/> />
<button class="secondary" disabled={busy || !code.trim()} onclick={signIn}> <button class="secondary" disabled={busy || !code.trim()} onclick={signIn}>
{t('login.signIn')} {t('login.signIn')}
@@ -98,6 +111,11 @@
color: var(--text); color: var(--text);
font-size: 1rem; font-size: 1rem;
} }
/* The email code: the same spread-digit look as the friend-code input elsewhere. */
.codein {
letter-spacing: 0.3em;
font-size: 1.1rem;
}
button { button {
padding: 12px; padding: 12px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);