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
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:
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user