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 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();
|
||||
|
||||
@@ -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<CommsTab>(vsAi ? 'dictionary' : initialTab);
|
||||
let tab = $state<CommsTab>(chatless ? 'dictionary' : initialTab);
|
||||
$effect(() => {
|
||||
if (vsAi) tab = 'dictionary';
|
||||
if (chatless) tab = 'dictionary';
|
||||
else if (tab === 'dictionary' && !active) tab = 'chat';
|
||||
});
|
||||
</script>
|
||||
@@ -46,7 +49,7 @@
|
||||
|
||||
{#snippet tabbar()}
|
||||
<TabBar>
|
||||
{#if !vsAi}
|
||||
{#if !chatless}
|
||||
<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>
|
||||
</button>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
@@ -1546,10 +1547,11 @@
|
||||
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/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
|
||||
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary).
|
||||
A hotseat game has no chat either (local players, no accounts). -->
|
||||
{#if !(gameOver && view.game.vsAi) && !view.game.hotseat}
|
||||
<!-- The comms entry opens Chat + the word Dictionary. A local game (vs_ai or hotseat) has no
|
||||
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
|
||||
— so drop the entry only when a local game is finished; an active local game keeps it for
|
||||
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')}>
|
||||
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||
</button>
|
||||
|
||||
@@ -824,6 +824,16 @@ export async function bootstrap(): Promise<void> {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -17,10 +17,17 @@
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
if (busy || code.trim().length === 0) return;
|
||||
busy = true;
|
||||
await loginEmail(email.trim(), code.trim());
|
||||
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>
|
||||
|
||||
<main class="login">
|
||||
@@ -46,10 +53,16 @@
|
||||
{:else}
|
||||
<p class="muted">{t('login.codeSent', { email })}</p>
|
||||
<input
|
||||
class="codein"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
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}>
|
||||
{t('login.signIn')}
|
||||
@@ -98,6 +111,11 @@
|
||||
color: var(--text);
|
||||
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 {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
Reference in New Issue
Block a user