diff --git a/web/e2e/bot-game.spec.js b/web/e2e/bot-game.spec.js index 58d8ff2..62db47d 100644 --- a/web/e2e/bot-game.spec.js +++ b/web/e2e/bot-game.spec.js @@ -6,6 +6,7 @@ import { chooseDifficulty, openMeanings, playLegalMove, + resign, setNickname, submitWord, waitForMyTurn @@ -116,7 +117,11 @@ test.describe('playing the bot', () => { await submitWord(page, UNKNOWN_WORD); - await expect(board(page).rejection).toHaveText('Không tìm thấy từ này trong từ điển.'); + // The refused word is shown beside the reason: it was cleared out of + // the field when it went out, and a Vietnamese rejection is usually + // one tone mark away from being right. + await expect(board(page).rejection).toContainText('Không tìm thấy từ này trong từ điển.'); + await expect(board(page).rejection).toContainText(UNKNOWN_WORD); }); test('a single syllable is refused for being too short', async ({ page }) => { @@ -125,7 +130,8 @@ test.describe('playing the bot', () => { await submitWord(page, ONE_SYLLABLE_WORD); - await expect(board(page).rejection).toHaveText('Từ phải có ít nhất 2 tiếng.'); + await expect(board(page).rejection).toContainText('Từ phải có ít nhất 2 tiếng.'); + await expect(board(page).rejection).toContainText(ONE_SYLLABLE_WORD); }); test('a real word that does not link names the syllable it should start with', async ({ @@ -138,7 +144,8 @@ test.describe('playing the bot', () => { // syllable, so this is a wrong link rather than an unknown word. await submitWord(page, 'toán học'); - await expect(board(page).rejection).toHaveText('Từ phải bắt đầu bằng tiếng “sinh”.'); + await expect(board(page).rejection).toContainText('Từ phải bắt đầu bằng tiếng “sinh”.'); + await expect(board(page).rejection).toContainText('toán học'); }); test('a refused word costs the player the attempt, not the turn', async ({ page }) => { @@ -162,8 +169,7 @@ test.describe('playing the bot', () => { await page.goto('/play?difficulty=1'); await waitForMyTurn(page); - page.on('dialog', (dialog) => dialog.accept()); - await page.getByRole('button', { name: 'Đầu hàng' }).click(); + await resign(page); await expect(page.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Chơi lại' })).toBeVisible(); @@ -175,8 +181,7 @@ test.describe('playing the bot', () => { const syllable = (await board(page).syllable.textContent())?.trim() ?? ''; - page.on('dialog', (dialog) => dialog.accept()); - await page.getByRole('button', { name: 'Đầu hàng' }).click(); + await resign(page); await expect(page.getByRole('heading', { name: 'Bạn có thể nối' })).toBeVisible(); const offered = await page.locator('.suggestions li').allTextContents(); @@ -194,8 +199,7 @@ test.describe('playing the bot', () => { const [opening] = await chainWords(page); await playLegalMove(page, new Set([opening])); - page.on('dialog', (dialog) => dialog.accept()); - await page.getByRole('button', { name: 'Đầu hàng' }).click(); + await resign(page); const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'Tải chuỗi từ' }).click(); @@ -217,8 +221,7 @@ test.describe('playing the bot', () => { await page.goto('/play?difficulty=1'); await waitForMyTurn(page); - page.on('dialog', (dialog) => dialog.accept()); - await page.getByRole('button', { name: 'Đầu hàng' }).click(); + await resign(page); await expect(page.getByRole('button', { name: 'Chơi lại' })).toBeVisible(); await page.getByRole('button', { name: 'Chơi lại' }).click(); @@ -244,8 +247,7 @@ test.describe('playing the bot', () => { await playLegalMove(page, used); await waitForMyTurn(page); - page.on('dialog', (dialog) => dialog.accept()); - await page.getByRole('button', { name: 'Đầu hàng' }).click(); + await resign(page); await expect(page.getByRole('button', { name: 'Về trang chủ' })).toBeVisible(); await page.getByRole('button', { name: 'Về trang chủ' }).click(); diff --git a/web/e2e/helpers.js b/web/e2e/helpers.js index 7886242..8430ac9 100644 --- a/web/e2e/helpers.js +++ b/web/e2e/helpers.js @@ -105,8 +105,7 @@ export function chat(page) { log: page.getByTestId('chat-log'), input: page.getByTestId('chat-input'), send: page.getByTestId('chat-send'), - unread: page.getByTestId('chat-unread'), - error: page.getByTestId('chat-error') + unread: page.getByTestId('chat-unread') }; } @@ -174,6 +173,32 @@ export async function setNickname(page, name) { await page.getByLabel('Tên của bạn').fill(name); } +/** + * Gives up the game. + * + * Two presses of the same button: the confirmation is inline now, because a + * native confirm() blocks the frame loop the countdown ring runs on and could + * cost the turn it was protecting. The accessible name still contains "Đầu + * hàng" in both states, so one locator drives both presses. + * + * @param {import('@playwright/test').Page} page + */ +export async function resign(page) { + const button = page.getByRole('button', { name: 'Đầu hàng' }); + await button.click(); + await button.click(); +} + +/** + * Puts a player out of the room. Two presses, for the same reason as resign. + * + * @param {import('@playwright/test').Locator} kick + */ +export async function confirmKick(kick) { + await kick.click(); + await kick.click(); +} + /** * Reads the words currently in the chain, opening word included. * diff --git a/web/e2e/pvp-game.spec.js b/web/e2e/pvp-game.spec.js index 2821045..6ec3b40 100644 --- a/web/e2e/pvp-game.spec.js +++ b/web/e2e/pvp-game.spec.js @@ -4,9 +4,11 @@ import { board, chainWords, chat, + confirmKick, openMeanings, playLegalMove, readyAndStart, + resign, say, seats, setNickname, @@ -50,6 +52,19 @@ async function joinRoom(page, nickname, code) { await page.getByRole('button', { name: 'Vào phòng' }).click(); } +/** + * Joins, and waits until the seat actually exists. + * + * A seat is replayed only the conversation from the moment it joined — that is + * the server's rule, not an accident — so a test where somebody speaks has to + * know the listener is in the room first. Without this the assertion races the + * handshake and passes or fails on how quickly the page hydrates. + */ +async function joinRoomSeated(page, nickname, code) { + await joinRoom(page, nickname, code); + await expect(page.getByTestId('ready')).toBeVisible(); +} + /** * Seats a pair and plays them into a game, which is where most tests start. * @@ -114,6 +129,12 @@ test.describe('playing a stranger', () => { const code = await createRoom(host, 'Minh'); + // A name is all the link needs; it is remembered from the last visit, + // which is what makes the link one tap for anybody who has played + // before. + await guest.goto('/online'); + await setNickname(guest, 'Lan'); + // No code typed, no button pressed beyond opening the link. await guest.goto(`/online?code=${code}`); @@ -127,11 +148,39 @@ test.describe('playing a stranger', () => { await close(); }); + test('an invite link asks a nameless guest for a name before seating them', async ({ + browser + }) => { + const { host, guest, close } = await twoPlayers(browser); + + const code = await createRoom(host, 'Minh'); + + // A first-time guest: nothing stored, so the link cannot introduce them. + // Seating them anyway made them "Người chơi" on the scoreboard, in the + // chat and in the standings, with no way to fix it from inside the room. + await guest.goto(`/online?code=${code}`); + + await expect(guest.getByTestId('name-needed')).toBeVisible(); + await expect(guest.getByTestId('ready')).toHaveCount(0); + // Still only the host in the room. + await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi'); + + // The code came with the link, so giving a name is the only step left. + await setNickname(guest, 'Lan'); + await guest.getByRole('button', { name: 'Vào phòng' }).click(); + + await expect(guest.getByTestId('ready')).toBeVisible(); + await expect(host.getByTestId('player-count')).toHaveText('2/4 người chơi'); + await expect(host.getByTestId('scoreboard').getByText('Lan')).toHaveCount(0); + await expect(host.locator('.seat').getByText('Lan')).toBeVisible(); + + await close(); + }); + test('resigning ends the game on both sides with the right winner', async ({ browser }) => { const { host, guest, close } = await playingPair(browser); - host.on('dialog', (dialog) => dialog.accept()); - await host.getByRole('button', { name: 'Đầu hàng' }).click(); + await resign(host); await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible(); await expect(guest.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible(); @@ -142,8 +191,7 @@ test.describe('playing a stranger', () => { test('a second game is agreed in the lobby the first one ends in', async ({ browser }) => { const { host, guest, code, close } = await playingPair(browser); - host.on('dialog', (dialog) => dialog.accept()); - await host.getByRole('button', { name: 'Đầu hàng' }).click(); + await resign(host); await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible(); // The readiness that started the first game is spent, so the owner @@ -215,8 +263,7 @@ test.describe('playing a stranger', () => { await guest.getByTestId('ready').click(); await expect(kick).toBeEnabled(); - host.on('dialog', (dialog) => dialog.accept()); - await kick.click(); + await confirmKick(kick); await expect(guest.getByTestId('join-error')).toHaveText('Bạn đã bị mời ra khỏi phòng.'); await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi'); @@ -258,7 +305,7 @@ test.describe('playing a stranger', () => { const { host, guest, close } = await twoPlayers(browser); const code = await createRoom(host, 'Minh'); - await joinRoom(guest, 'Lan', code); + await joinRoomSeated(guest, 'Lan', code); await say(host, 'chào bạn'); // Each side is shown its own words and the other's, with a name on the @@ -299,7 +346,7 @@ test.describe('playing a stranger', () => { }); const code = await createRoom(host, 'Minh'); - await joinRoom(guest, 'Lan', code); + await joinRoomSeated(guest, 'Lan', code); // A conversation both players have already read. await say(host, 'một'); @@ -349,7 +396,7 @@ test.describe('playing a stranger', () => { const { host, guest, close } = await twoPlayers(browser); const code = await createRoom(host, 'Minh'); - await joinRoom(guest, 'Lan', code); + await joinRoomSeated(guest, 'Lan', code); await say(host, 'đậm'); await expect(chat(guest).log).toContainText('đậm'); @@ -373,7 +420,7 @@ test.describe('playing a stranger', () => { await expect(chat(host).send).toBeEnabled(); }); - test('a refusal is visible in the lobby, which shows no errors of its own', async ({ + test('a refusal is shown in the lobby, beside the buttons that produce it', async ({ browser }) => { const { host, guest, close } = await twoPlayers(browser); @@ -381,14 +428,16 @@ test.describe('playing a stranger', () => { const code = await createRoom(host, 'Minh'); await joinRoom(guest, 'Lan', code); - // Past the burst the server refuses, and the panel is the only surface - // in this phase that can say so. + // Past the burst the server refuses. for (let i = 0; i < 8; i++) { await say(host, `tin ${i}`); } + // In the lobby itself. It used to land in the chat panel below it, + // which on a phone is under a four-seat list and off the screen — so + // "Bắt đầu" being refused looked like "Bắt đầu" being broken. // containText, not haveText: the box carries its own dismiss button. - await expect(chat(host).error).toContainText('Bạn thao tác quá nhanh'); + await expect(host.getByTestId('lobby-error')).toContainText('Bạn thao tác quá nhanh'); await close(); }); @@ -397,7 +446,7 @@ test.describe('playing a stranger', () => { const { host, guest, close } = await twoPlayers(browser); const code = await createRoom(host, 'Minh'); - await joinRoom(guest, 'Lan', code); + await joinRoomSeated(guest, 'Lan', code); await say(host, 'nhớ nhé'); await expect(chat(guest).log).toContainText('nhớ nhé'); @@ -527,8 +576,7 @@ test.describe('playing a stranger', () => { ]); const { lead, waits } = await awaitTurn(host, guest, third); - lead.on('dialog', (dialog) => dialog.accept()); - await lead.getByRole('button', { name: 'Đầu hàng' }).click(); + await resign(lead); // Out, but still in the room: no input, no result screen, and the game // carrying on in front of them. @@ -546,8 +594,7 @@ test.describe('playing a stranger', () => { await expect(next.getByTestId('player-out')).toContainText(names.get(lead)); // The last two settle it, and everybody sees the same table. - next.on('dialog', (dialog) => dialog.accept()); - await next.getByRole('button', { name: 'Đầu hàng' }).click(); + await resign(next); await expect(last.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible(); await expect(lead.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible(); diff --git a/web/e2e/reconnect.spec.js b/web/e2e/reconnect.spec.js index 2cf888b..4ebb41d 100644 --- a/web/e2e/reconnect.spec.js +++ b/web/e2e/reconnect.spec.js @@ -124,17 +124,31 @@ test.describe('losing the connection', () => { await socket.cut({ sustained: true }); - await expect(page.getByText('Mất kết nối, đang thử lại…')).toBeVisible({ timeout: 20_000 }); + // Two surfaces, deliberately: the badge is the status, and the banner + // over the board is the one the player can act on — it carries the + // retry that saves waiting out a backoff of up to eight seconds with a + // turn timer running. + const badge = page.locator('.badge', { hasText: 'Mất kết nối, đang thử lại…' }); + await expect(badge).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole('button', { name: 'Thử lại' })).toBeVisible(); - // Disabled rather than accepting a word that cannot go anywhere and - // leaving the player to watch their turn expire. - await expect(board(page).input).toBeDisabled(); + // The word cannot be sent, but the field itself stays alive: `disabled` + // on a focused input blurs it, and a blurred input closes the on-screen + // keyboard that nothing can then reopen without a tap. So the send is + // what refuses, and the field says whose turn it is instead of inviting + // a word it cannot carry. await expect(board(page).submit).toBeDisabled(); + await expect(board(page).input).toHaveAttribute('aria-disabled', 'true'); + await expect(board(page).input).toHaveAttribute( + 'placeholder', + 'Mất kết nối, đang thử lại…' + ); // And it comes back on its own once the connection does. socket.restore(); await expect(page.getByText('Đã kết nối')).toBeVisible({ timeout: 20_000 }); - await expect(board(page).input).toBeEnabled(); + await expect(board(page).submit).toBeEnabled(); + await expect(board(page).input).toHaveAttribute('aria-disabled', 'false'); await context.close(); }); diff --git a/web/src/app.css b/web/src/app.css index f02d5fe..14a44e7 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -11,12 +11,19 @@ --surface: #ffffff; --surface-alt: #e9f0ea; --border: #c2d1c8; + /* The same edge, drawn where it has to be found rather than merely felt. + A card's outline may whisper; the boundary of something you are meant + to type into or press has to clear 3:1 against what surrounds it, which + --border does not and is not trying to. */ + --border-strong: #768b7f; --text: #16211b; --text-muted: #5b6a61; /* Word-game green. Dark enough to carry white text and to be read as text itself, which the accent has to be: the clock, a word's points and the - connection badge are all drawn in it. */ - --accent: #15803d; + connection badge are all drawn in it. Dark enough, specifically, to stay + legible on its own tint — a score sitting on --accent-soft is the + smallest accent text in the app and the pair it has to clear. */ + --accent: #12692f; --accent-text: #ffffff; --accent-soft: #dff2e5; --danger: #a3231c; @@ -35,8 +42,33 @@ --radius: 12px; --radius-sm: 8px; + --radius-pill: 999px; --font: system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + /* Spacing steps, 4px based. Named so new work picks a step instead of + inventing another value between two existing ones. */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + /* The type ramp, numbered rather than t-shirt sized: the steps are close + together and "medium" versus "large" would say nothing useful about + 0.85rem against 0.9rem. Step 6 is body text and every input, which is + 1rem because anything smaller makes iOS Safari zoom the page on focus. */ + --text-1: 0.7rem; + --text-2: 0.75rem; + --text-3: 0.8rem; + --text-4: 0.85rem; + --text-5: 0.9rem; + --text-6: 1rem; + --text-7: 1.1rem; + --text-8: 1.4rem; + --text-9: 2rem; + color-scheme: light; } @@ -45,6 +77,7 @@ --surface: #17201a; --surface-alt: #1f2a23; --border: #3a4a40; + --border-strong: #647f70; --text: #e6ece7; --text-muted: #9aab9f; /* Lightened rather than inverted, which is what a dark surface needs from @@ -96,23 +129,85 @@ input { color: inherit; } +/* + * One focus ring for the whole app, so a button and the field beside it are + * marked the same way. Written with :where() so its specificity is zero and + * any component that genuinely needs a different ring — a control whose real + * input is visually hidden, say — simply declares one. + */ +:where(a, button, input, summary, [tabindex]):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* + * A 44px target around a glyph that is a fraction of that. The padding is + * negative-margined back out, so a dismiss button inside a banner does not + * grow the banner it sits in. + */ +.icon-button { + display: inline-grid; + place-items: center; + flex: none; + width: 44px; + height: 44px; + margin: -12px -6px; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: none; + color: inherit; + font-size: var(--text-7); + line-height: 1; +} + /* Visible only to assistive technology. */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; + border: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +/* Reaches the keyboard before the header does, and only then shows itself. */ +.skip:not(:focus) { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + border: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.skip { + position: absolute; + z-index: 1; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: var(--text-5); + font-weight: 600; +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; + /* Without this an infinite animation restarts a hundred thousand times + a frame and resamples its own opacity: a flicker delivered to exactly + the people who asked for less motion. */ + animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; + scroll-behavior: auto !important; } } diff --git a/web/src/app.html b/web/src/app.html index 1f12f29..607c790 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -2,7 +2,18 @@ - + + Nối Từ diff --git a/web/src/lib/components/AttributionFooter.svelte b/web/src/lib/components/AttributionFooter.svelte index 7ccac03..86ea2f0 100644 --- a/web/src/lib/components/AttributionFooter.svelte +++ b/web/src/lib/components/AttributionFooter.svelte @@ -24,11 +24,21 @@ diff --git a/web/src/lib/components/ChatPanel.svelte b/web/src/lib/components/ChatPanel.svelte index cb91ef7..cf1d909 100644 --- a/web/src/lib/components/ChatPanel.svelte +++ b/web/src/lib/components/ChatPanel.svelte @@ -1,5 +1,6 @@ -
+
+

+ {spoken ? fill(t.yourTimeLeft, { n: spoken }) : ''} +

+ diff --git a/web/src/lib/components/GameBoard.svelte b/web/src/lib/components/GameBoard.svelte index bb8d2e3..1324e74 100644 --- a/web/src/lib/components/GameBoard.svelte +++ b/web/src/lib/components/GameBoard.svelte @@ -6,6 +6,7 @@ import WordInput from '$lib/components/WordInput.svelte'; import { fill, t } from '$lib/i18n/vi.js'; import { game } from '$lib/stores/game.svelte.js'; + import { Status, connection, reconnectNow } from '$lib/ws/connection.svelte.js'; /** * The board itself, shared by both modes. It renders the store and nothing @@ -22,6 +23,13 @@ */ let { modeLabel = '', onsubmit, onresign, gameOver, banner } = $props(); + /** How long an armed resign button waits before it goes back to being safe. */ + const ARM_MS = 4000; + + let arming = $state(false); + /** @type {any} */ + let armTimer; + // Whose turn it is, said by name. With four people at the table "the // opponent is thinking" stops naming anybody. const turnLabel = $derived.by(() => { @@ -29,6 +37,31 @@ const name = game.nameOf(game.state.turnPlayerId); return name ? fill(t.playerTurn, { name }) : t.opponentTurn; }); + + const offline = $derived(connection.status !== Status.OPEN); + + /** + * Two presses, in place of a native confirm(). + * + * confirm() blocks the main thread, which stops the countdown's animation + * frame loop while the server's deadline keeps running: hesitating over the + * dialog can cost the turn it was protecting. This keeps the board on screen + * and the clock moving, and disarms itself so a stray tap does not lie in + * wait. + */ + function armOrResign() { + if (arming) { + clearTimeout(armTimer); + arming = false; + onresign(); + return; + } + arming = true; + clearTimeout(armTimer); + armTimer = setTimeout(() => (arming = false), ARM_MS); + } + + $effect(() => () => clearTimeout(armTimer));
@@ -41,10 +74,29 @@ {#if banner}{@render banner()}{/if} + + {#if offline && game.state.phase === 'playing'} +

+ {t.reconnecting} + +

+ {/if} + {#if game.state.error} {/if} @@ -54,7 +106,22 @@
-

{turnLabel}

+ +

+ {turnLabel} +

{t.currentSyllable} {game.state.currentSyllable || '…'} @@ -76,7 +143,9 @@ that grows, and a button under it walks off the bottom of the screen exactly as the game gets long enough to want to give up on. --> {#if game.state.phase === 'playing' && !game.iAmOut} - + {/if} @@ -89,25 +158,25 @@ flex: 1; gap: 14px; min-height: 0; - padding-bottom: 8px; + padding-bottom: var(--space-2); } .top { display: flex; align-items: center; justify-content: space-between; - gap: 8px; + gap: var(--space-2); } .mode { color: var(--text-muted); - font-size: 0.85rem; + font-size: var(--text-4); } .turn { display: flex; align-items: center; - gap: 16px; + gap: var(--space-4); } .prompt { @@ -115,9 +184,17 @@ } .who { - margin: 0 0 2px; + margin: 0 0 var(--space-1); color: var(--text-muted); - font-size: 0.85rem; + font-size: var(--text-4); + } + + /* The player's own turn, said loudly enough to catch the eye that is in the + chat column beside the board. */ + .who.mine { + color: var(--text); + font-size: var(--text-6); + font-weight: 700; } .syllable { @@ -127,46 +204,56 @@ } .syllable .label { + margin-bottom: 2px; color: var(--text-muted); - font-size: 0.75rem; + font-size: var(--text-2); } + /* The one glyph read every single turn, so it gets the headroom: a stacked + Vietnamese tone mark on ệ or ộ rides into the label above it at 1.2. */ .syllable strong { font-size: 1.6rem; - line-height: 1.2; + line-height: 1.35; } - .error { + .error, + .offline { display: flex; align-items: center; justify-content: space-between; - gap: 8px; + gap: var(--space-2); margin: 0; - padding: 10px 12px; + padding: 10px var(--space-3); border-radius: var(--radius-sm); - background: var(--danger-soft); - color: var(--danger); - font-size: 0.9rem; + font-size: var(--text-5); } - /* A 44px target on a glyph that is a fraction of that: the padding is - negative-margined back out so the banner keeps its height. */ - .error button { + .error { + background: var(--danger-soft); + color: var(--danger); + } + + .offline { + background: var(--surface-alt); + color: var(--warn); + } + + .offline button { flex: none; - width: 44px; - height: 44px; - margin: -12px -6px; - border: 0; + min-height: 44px; + padding: var(--space-2) var(--space-4); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); - background: none; - color: inherit; - font-size: 1.1rem; - line-height: 1; + margin: -8px 0; + background: var(--surface); + color: var(--text); + font-size: var(--text-4); + font-weight: 600; } .spectating { margin: 0; - padding: 12px; + padding: var(--space-3); border: 1px dashed var(--border); border-radius: var(--radius-sm); color: var(--text-muted); @@ -179,16 +266,23 @@ .resign { align-self: flex-end; min-height: 44px; - padding: 8px 16px; - border: 1px solid var(--border); + padding: var(--space-2) var(--space-4); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: transparent; color: var(--danger); - font-size: 0.85rem; + font-size: var(--text-4); transition: background-color 150ms ease-out; } .resign:hover { background: var(--danger-soft); } + + /* Armed, and saying so: the second press is the one that ends the game. */ + .resign.arming { + border-color: var(--danger); + background: var(--danger-soft); + font-weight: 600; + } diff --git a/web/src/lib/components/GameOverPanel.svelte b/web/src/lib/components/GameOverPanel.svelte index 8648fd3..7516ee2 100644 --- a/web/src/lib/components/GameOverPanel.svelte +++ b/web/src/lib/components/GameOverPanel.svelte @@ -12,6 +12,9 @@ */ let { isRecord, onrematch, onhome } = $props(); + /** @type {HTMLElement | undefined} */ + let panel = $state(); + const result = $derived(game.state.result); const standings = $derived(game.state.standings); // What the position still had when this player lost it. It arrives with @@ -20,6 +23,18 @@ // else's position. const elimination = $derived(game.state.elimination); + // The word field unmounts when the game ends, which drops focus to the top + // of the document: a keyboard player tabs through the header and the badge + // to reach "Chơi lại", and a screen reader is told nothing at all, because + // role="group" is not announced on insertion. + // + // The panel takes focus, not the rematch button. A player who just pressed + // Enter to submit a word may still be holding it, and a focused button + // under that key would start the next game before they had read this one. + $effect(() => { + if (game.state.result) panel?.focus(); + }); + /** Hands the finished chain to the player as a text file to keep. */ function exportHistory() { const at = new Date(); @@ -34,14 +49,20 @@ {#if result} -

-

{result.iWon ? t.won : t.lost}

+
+

{result.iWon ? t.won : t.lost}

{#if endReasonMessages[result.reason]}

{endReasonMessages[result.reason]}

{/if} - {#if standings.length > 0} + {#if standings.length > 2}
    @@ -94,14 +115,14 @@

    {t.newRecord}

    {/if} - -
    {#if onrematch} {/if}
    + +
{/if} @@ -109,8 +130,8 @@ .panel { display: flex; flex-direction: column; - gap: 12px; - padding: 20px; + gap: var(--space-3); + padding: var(--space-5); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); @@ -121,7 +142,7 @@ h2 { margin: 0; color: var(--danger); - font-size: 1.4rem; + font-size: var(--text-8); } h2.won { @@ -147,7 +168,7 @@ display: flex; align-items: baseline; gap: 10px; - padding: 8px 12px; + padding: var(--space-2) var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-alt); @@ -176,25 +197,25 @@ .points { margin-left: auto; color: var(--text-muted); - font-size: 0.85rem; + font-size: var(--text-4); font-variant-numeric: tabular-nums; } .stats { display: flex; justify-content: center; - gap: 28px; + gap: var(--space-8); margin: 0; } dt { color: var(--text-muted); - font-size: 0.8rem; + font-size: var(--text-3); } dd { margin: 0; - font-size: 1.4rem; + font-size: var(--text-8); font-weight: 700; font-variant-numeric: tabular-nums; } @@ -202,8 +223,10 @@ .suggestions h3 { margin: 0 0 6px; color: var(--text-muted); - font-size: 0.8rem; + font-size: var(--text-3); font-weight: 600; + /* Uppercase Vietnamese stacks a tone mark above a capital. */ + line-height: 1.6; text-transform: uppercase; letter-spacing: 0.04em; } @@ -219,9 +242,9 @@ } .suggestions li { - padding: 6px 12px; + padding: 6px var(--space-3); border: 1px solid var(--border); - border-radius: 999px; + border-radius: var(--radius-pill); background: var(--surface-alt); font-weight: 600; } @@ -233,20 +256,21 @@ .record { margin: 0; - padding: 6px 12px; - border-radius: 999px; + padding: 6px var(--space-3); + border-radius: var(--radius-pill); background: var(--accent-soft); color: var(--accent); font-weight: 700; } .export { - padding: 10px 12px; - border: 1px solid var(--border); + min-height: 44px; + padding: 10px var(--space-3); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: transparent; color: var(--text-muted); - font-size: 0.9rem; + font-size: var(--text-5); font-weight: 600; } @@ -257,8 +281,9 @@ .actions button { flex: 1; - padding: 12px; - border: 1px solid var(--border); + min-height: 44px; + padding: var(--space-3); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); font-weight: 600; diff --git a/web/src/lib/components/Lobby.svelte b/web/src/lib/components/Lobby.svelte index 89f87ca..215ae69 100644 --- a/web/src/lib/components/Lobby.svelte +++ b/web/src/lib/components/Lobby.svelte @@ -1,7 +1,10 @@
+ {#if !compact} +
+ +
+ {:else} + {/if}

@@ -40,7 +95,16 @@

    {#each s.roomPlayers as player (player.playerId)} -
  • + +
  • {player.isMe ? s.nickname || t.you : player.name} {player.isOwner ? t.owner : t.guest} @@ -63,15 +127,20 @@ {/if} + is what an owner who wants out does, and it hands the room on. + + Two presses rather than a confirm() dialog: the native one + blocks the frame loop, and this is the same control asking + again rather than a second one appearing. --> {#if game.isOwner && !player.isMe} @@ -87,8 +156,18 @@ {/each}
+ + {#if !compact} + + {/if} +

- {#if game.isOwner && shortHanded} + {#if ownerAway} + {t.ownerAway} + {:else if game.isOwner && shortHanded} {fill(t.ownerNeedsMore, { n: s.minPlayers })} {:else if game.isOwner} {t.ownerStartsHint} @@ -99,14 +178,34 @@ {/if}

+ + {#if s.error} + + {:else if unsent} + + {/if} +
{#if game.isOwner} @@ -115,8 +214,9 @@ type="button" class="primary" class:on={game.isReady} + disabled={offline} data-testid="ready" - onclick={() => onready(!game.isReady)} + onclick={() => report(onready(!game.isReady))} > {game.isReady ? t.unready : t.ready} @@ -124,7 +224,8 @@
+ a button that vanishes reads as a bug. Not gated on the connection — + giving up the seat is something the player can always do locally. --> @@ -141,17 +242,22 @@ gap: 14px; } + .top { + display: flex; + align-items: center; + } + .count { margin: 0; color: var(--text-muted); - font-size: 0.85rem; + font-size: var(--text-4); font-weight: 600; } .seats { display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); margin: 0; padding: 0; list-style: none; @@ -162,17 +268,23 @@ flex-wrap: wrap; align-items: baseline; gap: 10px; - padding: 12px 14px; + padding: var(--space-3) 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); } + /* Readiness is a tint. Being the owner is a marker down the edge: a fact + about the seat, not a state it has entered. */ .seat.ready { border-color: var(--accent); background: var(--accent-soft); } + .seat.owner { + border-inline-start: 3px solid var(--accent); + } + .seat.empty { border-style: dashed; background: var(--surface-alt); @@ -188,11 +300,11 @@ } .role { - padding: 1px 8px; - border-radius: 999px; + padding: 1px var(--space-2); + border-radius: var(--radius-pill); background: var(--surface-alt); color: var(--text-muted); - font-size: 0.75rem; + font-size: var(--text-2); } /* One right-hand group, so a row keeps its shape whether or not it has a @@ -200,63 +312,97 @@ .right { display: inline-flex; align-items: center; - gap: 8px; + gap: var(--space-2); margin-left: auto; } .wins { color: var(--text-muted); - font-size: 0.75rem; + font-size: var(--text-2); white-space: nowrap; } .wins strong { color: var(--text); - font-size: 0.9rem; + font-size: var(--text-5); font-variant-numeric: tabular-nums; } .state { color: var(--text-muted); - font-size: 0.8rem; + font-size: var(--text-3); } .state.offline { color: var(--danger); } + /* + * 36px of drawn button, because a 44px circle in every seat row would add + * a quarter of a screen to a four-seat lobby that is already long. The + * touch target is the full 44 all the same, expanded out of the flow by a + * pseudo-element so the row keeps its height. + */ .kick { - width: 26px; - height: 26px; + position: relative; + width: 36px; + height: 36px; + margin: -4px 0; padding: 0; - border: 1px solid var(--border); - border-radius: 999px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); - font-size: 1rem; + font-size: var(--text-6); line-height: 1; } + .kick::after { + content: ''; + position: absolute; + inset: -4px; + } + .kick:disabled { opacity: 0.35; } + .kick.arming { + border-color: var(--danger); + background: var(--danger-soft); + color: var(--danger); + font-weight: 700; + } + .hint { margin: 0; color: var(--text-muted); - font-size: 0.9rem; + font-size: var(--text-5); text-align: center; } + .error { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin: 0; + padding: 10px var(--space-3); + border-radius: var(--radius-sm); + background: var(--danger-soft); + color: var(--danger); + font-size: var(--text-5); + } + .actions { display: flex; - gap: 8px; + gap: var(--space-2); } .actions button { flex: 1; padding: 14px; - border: 1px solid var(--border); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); font-weight: 600; @@ -281,12 +427,13 @@ .leave { align-self: center; - padding: 8px 16px; - border: 1px solid var(--border); + min-height: 44px; + padding: 10px var(--space-4); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: transparent; color: var(--text-muted); - font-size: 0.85rem; + font-size: var(--text-4); } .leave:disabled { @@ -296,7 +443,7 @@ .note { margin: 0; color: var(--text-muted); - font-size: 0.8rem; + font-size: var(--text-3); text-align: center; } diff --git a/web/src/lib/components/NicknameInput.svelte b/web/src/lib/components/NicknameInput.svelte index 592ab6c..01d06ce 100644 --- a/web/src/lib/components/NicknameInput.svelte +++ b/web/src/lib/components/NicknameInput.svelte @@ -34,25 +34,20 @@ label { font-weight: 600; - font-size: 0.9rem; + font-size: var(--text-5); } input { - padding: 12px 14px; - border: 1px solid var(--border); + padding: var(--space-3) 14px; + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); - font-size: 1rem; - } - - input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; + font-size: var(--text-6); } .hint { margin: 0; color: var(--text-muted); - font-size: 0.8rem; + font-size: var(--text-3); } diff --git a/web/src/lib/components/PlayerStatus.svelte b/web/src/lib/components/PlayerStatus.svelte index 021914a..9afb7f1 100644 --- a/web/src/lib/components/PlayerStatus.svelte +++ b/web/src/lib/components/PlayerStatus.svelte @@ -73,11 +73,11 @@ diff --git a/web/src/lib/components/ScoreBoard.svelte b/web/src/lib/components/ScoreBoard.svelte index a2874c4..67e1703 100644 --- a/web/src/lib/components/ScoreBoard.svelte +++ b/web/src/lib/components/ScoreBoard.svelte @@ -29,7 +29,10 @@ {player.isMe ? s.nickname || t.you : player.name || t.someone} {#if !player.connected && !player.eliminated} - ⚠ + + + {t.offline} {/if} {player.score} @@ -65,18 +68,27 @@ align-items: center; flex: 1 1 0; min-width: 72px; - padding: 6px 10px; + padding: 6px var(--space-2); border: 1px solid transparent; border-radius: var(--radius-sm); } /* The active side is whose turn it is, so the board doubles as the turn - indicator rather than needing a second one. */ + indicator rather than needing a second one. Weight as well as colour: a + tint and a border are the same shade of grey to anybody who cannot + separate this green from the surface behind it. */ .side.active { border-color: var(--accent); background: var(--accent-soft); } + .side.active .score { + text-decoration: underline; + text-decoration-color: var(--accent); + text-decoration-thickness: 3px; + text-underline-offset: 3px; + } + .side.out { opacity: 0.55; } @@ -88,7 +100,7 @@ max-width: 100%; overflow: hidden; color: var(--text-muted); - font-size: 0.8rem; + font-size: var(--text-3); text-overflow: ellipsis; white-space: nowrap; } @@ -103,7 +115,7 @@ } .score { - font-size: 1.4rem; + font-size: var(--text-8); font-weight: 700; font-variant-numeric: tabular-nums; } @@ -114,17 +126,17 @@ .series { color: var(--text-muted); - font-size: 0.7rem; + font-size: var(--text-1); font-variant-numeric: tabular-nums; white-space: nowrap; } .badge { - padding: 0 7px; - border-radius: 999px; + padding: 0 var(--space-2); + border-radius: var(--radius-pill); background: var(--surface-alt); color: var(--text-muted); - font-size: 0.7rem; + font-size: var(--text-1); } .badge.win { diff --git a/web/src/lib/components/ThemeToggle.svelte b/web/src/lib/components/ThemeToggle.svelte index 13fffee..e004128 100644 --- a/web/src/lib/components/ThemeToggle.svelte +++ b/web/src/lib/components/ThemeToggle.svelte @@ -21,12 +21,12 @@ display: inline-flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; - border: 1px solid var(--border); + width: 44px; + height: 44px; + border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface); - font-size: 1rem; + font-size: var(--text-6); line-height: 1; } diff --git a/web/src/lib/components/WordInput.svelte b/web/src/lib/components/WordInput.svelte index a7d034f..0225f00 100644 --- a/web/src/lib/components/WordInput.svelte +++ b/web/src/lib/components/WordInput.svelte @@ -1,5 +1,5 @@ @@ -97,6 +118,13 @@ every keystroke cancels that composition and mangles the accent. The server normalizes the text anyway, so the client has no reason to touch it. + + Never `disabled`: setting it on the focused field blurs it, and a blurred + field closes the on-screen keyboard, which nothing can then reopen + without a tap. The submit button carries the turn instead, and the guard + in handleSubmit is what actually refuses an out-of-turn word. The + accessible name stays put while the placeholder changes, so the field is + still the same field to anybody listening. --> (composing = true)} oncompositionend={() => (composing = false)} @@ -119,39 +149,42 @@ {#if game.state.rejection} - + + {/if} diff --git a/web/src/lib/i18n/vi.js b/web/src/lib/i18n/vi.js index 5766a96..ccd551c 100644 --- a/web/src/lib/i18n/vi.js +++ b/web/src/lib/i18n/vi.js @@ -8,10 +8,20 @@ import { RejectReason, GameEndReason, Difficulty } from '$lib/proto/noitu/v1/gam export const t = { appName: 'Nối Từ', tagline: 'Trò chơi nối từ tiếng Việt', + skipToContent: 'Tới nội dung chính', + + // Document titles. One per screen, so the tab strip, the browser history + // and a screen reader's page announcement say which screen this is. + titleHome: 'Nối Từ — trò chơi nối từ tiếng Việt', + titlePlay: 'Chơi với máy · Nối Từ', + titleOnline: 'Đấu trực tuyến · Nối Từ', + titleRoom: 'Phòng {code} · Nối Từ', nicknameLabel: 'Tên của bạn', nicknamePlaceholder: 'Nhập tên hiển thị', - nicknameHint: 'Tối đa 20 ký tự. Máy chủ có thể rút gọn tên của bạn.', + nicknameHint: + 'Tối đa 20 ký tự. Máy chủ có thể rút gọn tên của bạn. Để trống sẽ được gọi là “Người chơi”.', + nicknameNeeded: 'Nhập tên của bạn trước khi vào phòng.', playBot: 'Chơi với máy', playOnline: 'Đấu trực tuyến', @@ -21,7 +31,11 @@ export const t = { home: 'Về trang chủ', rematch: 'Chơi lại', resign: 'Đầu hàng', - resignConfirm: 'Bạn chắc chắn muốn đầu hàng?', + // Short enough to sit inside the button it arms, and it still contains the + // word the first press was labelled with, so the control reads as the same + // control asking again rather than as a different one. + resignSure: 'Chắc chắn đầu hàng?', + retry: 'Thử lại', yourTurn: 'Đến lượt bạn', opponentTurn: 'Đối thủ đang suy nghĩ…', @@ -41,10 +55,12 @@ export const t = { correctedFrom: 'Bạn gõ “{typed}”, từ đúng là “{word}”.', secondsLeft: '{n} giây', + yourTimeLeft: 'Còn {n} giây cho lượt của bạn', connecting: 'Đang kết nối…', connected: 'Đã kết nối', reconnecting: 'Mất kết nối, đang thử lại…', noConnection: 'Không có kết nối', + connectStalled: 'Chưa kết nối được máy chủ. Kiểm tra mạng rồi thử lại.', won: 'Bạn thắng!', lost: 'Bạn thua.', @@ -73,11 +89,12 @@ export const t = { roomCodePlaceholder: 'VD: K7M2QP', roomCodeHint: 'Sáu ký tự. Không phân biệt hoa thường.', roomCodeInvalid: 'Mã phòng gồm sáu ký tự.', - yourRoomCode: 'Mã phòng của bạn', copyCode: 'Sao chép mã', copyLink: 'Sao chép liên kết mời', shareLink: 'Chia sẻ', copied: 'Đã sao chép', + copyFailed: 'Không sao chép được. Hãy chọn và sao chép thủ công.', + inviteLinkLabel: 'Liên kết mời', leaveRoom: 'Rời phòng', shareInvite: 'Vào chơi nối từ với tôi', @@ -96,18 +113,21 @@ export const t = { guest: 'Khách', playerCount: '{n}/{max} người chơi', ready: 'Sẵn sàng', - unready: 'Chưa sẵn sàng', + unready: 'Bỏ sẵn sàng', isReady: 'Đã sẵn sàng', notReady: 'Chưa sẵn sàng', startGame: 'Bắt đầu', kickPlayer: 'Mời ra khỏi phòng', - kickConfirm: 'Mời người chơi này ra khỏi phòng?', + // Contains the label the unarmed button carries, for the same reason + // resignSure does. + kickSure: 'Chắc chắn mời ra khỏi phòng?', emptySeat: 'Còn trống', offline: 'Mất kết nối', ownerStartsHint: 'Bạn là chủ phòng. Bắt đầu khi mọi người đã sẵn sàng.', ownerNeedsMore: 'Cần ít nhất {n} người mới bắt đầu được. Gửi mã phòng cho bạn bè!', guestReadyHint: 'Bấm sẵn sàng rồi chờ chủ phòng bắt đầu.', waitingForStart: 'Đang chờ chủ phòng bắt đầu…', + ownerAway: 'Chủ phòng đang mất kết nối. Chờ một chút hoặc rời phòng.', unreadyToLeave: 'Bỏ sẵn sàng trước khi rời phòng.', playerDisconnected: '{name} mất kết nối…', diff --git a/web/src/lib/motion.js b/web/src/lib/motion.js new file mode 100644 index 0000000..405a073 --- /dev/null +++ b/web/src/lib/motion.js @@ -0,0 +1,18 @@ +/** + * Scrolling that honours the reduced-motion setting. + * + * The CSS escape hatch in app.css cannot reach this: an explicit `behavior` + * passed to scrollTo beats any `scroll-behavior` rule, so a smooth scroll asked + * for in script animates however the reader has configured their system. The + * chain scrolls on every single move, which makes it the worst offender. + */ + +/** @returns {boolean} */ +export function prefersReducedMotion() { + return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/** @returns {ScrollBehavior} */ +export function scrollBehavior() { + return prefersReducedMotion() ? 'auto' : 'smooth'; +} diff --git a/web/src/lib/ws/client.js b/web/src/lib/ws/client.js index 390d499..19f7e54 100644 --- a/web/src/lib/ws/client.js +++ b/web/src/lib/ws/client.js @@ -313,6 +313,29 @@ export function createClient({ serverNow: () => now() + clockOffsetMs, clockOffset: () => clockOffsetMs, status: () => status, + /** + * Tries again now rather than waiting out the backoff. + * + * The delay is capped at eight seconds and jittered, which is right for + * a client nobody is watching and wrong for a player who is back on the + * train's wifi and can see their turn running out. The attempt counter + * is reset too: the player asking is new information about the network, + * so the schedule starts over rather than continuing to assume the worst. + * + * @returns {boolean} whether an attempt was actually started + */ + reconnectNow() { + // A handshake the server refused outright would be refused again, + // and a socket that already exists is either open or on its way. + if (stopReconnecting || socket) return false; + if (reconnectTimer !== null) { + cancel(reconnectTimer); + reconnectTimer = null; + } + attempt = 0; + open(); + return true; + }, /** Deliberate teardown: no reconnect follows. */ close() { stopReconnecting = true; diff --git a/web/src/lib/ws/connection.svelte.js b/web/src/lib/ws/connection.svelte.js index 624b415..eddb132 100644 --- a/web/src/lib/ws/connection.svelte.js +++ b/web/src/lib/ws/connection.svelte.js @@ -41,6 +41,19 @@ export function send(msg) { return client?.send(msg) ?? false; } +/** + * Retries the connection immediately instead of waiting out the backoff. + * + * For the player looking at a "mất kết nối" banner with a turn timer running: + * the schedule is tuned for a client nobody is watching, and this is the case + * where somebody is. + * + * @returns {boolean} whether an attempt was actually started + */ +export function reconnectNow() { + return client?.reconnectNow() ?? false; +} + /** * The server's clock as this client estimates it. The countdown is drawn * against this rather than Date.now(), so a device with a wrong clock still diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1b4c5fa..383dabd 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -15,12 +15,14 @@
+ +
{t.appName}
-
+
{@render children()}
@@ -28,11 +30,21 @@
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index cc85045..7d4a42f 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -12,8 +12,14 @@ } + + {t.titleHome} + +
-

{t.tagline}

+ +

{t.tagline}

@@ -28,13 +34,17 @@ .home { display: flex; flex-direction: column; - gap: 20px; - padding-top: 12px; + gap: var(--space-5); + padding-top: var(--space-3); } + /* A heading by role, a tagline by weight: it introduces the game rather + than titling a document the header already names. */ .tagline { margin: 0; color: var(--text-muted); + font-size: var(--text-6); + font-weight: 400; } .actions { @@ -44,8 +54,9 @@ } .actions > * { + min-height: 44px; padding: 14px; - border: 1px solid var(--border); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: inherit; diff --git a/web/src/routes/online/+page.svelte b/web/src/routes/online/+page.svelte index a2bcbbe..f4bdad2 100644 --- a/web/src/routes/online/+page.svelte +++ b/web/src/routes/online/+page.svelte @@ -2,15 +2,17 @@ import { untrack } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; + import ConnectionBadge from '$lib/components/ConnectionBadge.svelte'; import GameBoard from '$lib/components/GameBoard.svelte'; import GameOverPanel from '$lib/components/GameOverPanel.svelte'; import ChatPanel from '$lib/components/ChatPanel.svelte'; import Lobby from '$lib/components/Lobby.svelte'; import NicknameInput from '$lib/components/NicknameInput.svelte'; import PlayerStatus from '$lib/components/PlayerStatus.svelte'; - import { t } from '$lib/i18n/vi.js'; + import { fill, t } from '$lib/i18n/vi.js'; import { isRoomCode, normalizeRoomCode, ROOM_CODE_LENGTH } from '$lib/room-code.js'; import { game } from '$lib/stores/game.svelte.js'; + import { settings } from '$lib/stores/settings.svelte.js'; import { createRoom, joinRoom, @@ -41,6 +43,14 @@ */ let pending = $state(null); + /** + * How long a held request waits before the screen stops saying "connecting" + * and starts saying something the player can act on. The backoff is capped + * at eight seconds and never gives up, so without this the screen would + * claim to be connecting for as long as the player was willing to watch it. + */ + const STALL_MS = 5000; + /** * Where the two-column layout starts. The media queries in the styles * below are the same decision expressed in CSS, so the two must agree: the @@ -69,16 +79,27 @@ // True while the only reason this screen has a socket is to reclaim a game // it might no longer be able to reclaim. let resuming = $state(false); + // An invite link arrived before this player had a name. Asking is one extra + // tap, and the alternative is being seated as "Người chơi" with no way to + // fix it from inside the room. + let needName = $state(false); + let stalled = $state(false); const inviteCode = $derived(normalizeRoomCode(page.url.searchParams.get('code') ?? '')); const playing = $derived(game.state.phase === 'playing' || game.state.phase === 'over'); // A seat in a room, whichever phase it is in. Both are the same layout — // the game or the lobby on one side, the conversation on the other. const inRoom = $derived(playing || game.state.phase === 'lobby'); + const named = $derived(settings.state.nickname.trim().length > 0); - // The resume worked, so nothing that happens from here is its fault. + // The resume worked, so nothing that happens from here is its fault — and + // an invite code held behind it has been answered by arriving in a room. $effect(() => { - if (playing || game.state.phase === 'lobby') untrack(() => (resuming = false)); + if (inRoom) + untrack(() => { + resuming = false; + pending = null; + }); }); // Owns the socket while this screen is on, exactly as the bot screen does. @@ -96,14 +117,28 @@ // screen, and Hello carries it once — a socket opened on arrival // would introduce the player under whatever name was stored before // they got here. - if (isRoomCode(code)) { - request({ kind: 'join', code }); - } else if (hasStoredSession()) { + if (hasStoredSession()) { // This tab was already in a game. Reconnecting restores it, which // is what a player who refreshed mid-game is expecting; the // nickname is already settled, so there is nothing to wait for. + // + // The resume comes first even when the URL carries a code: Hello + // takes the token with it either way, so joining as well would + // have the room refuse a second seat to somebody it had just + // given their old one back to — a red banner on a board that had + // in fact been restored correctly. The code is held instead, and + // only spent if the resume is refused. resuming = true; + if (isRoomCode(code)) pending = { kind: 'join', code }; connect(); + } else if (isRoomCode(code)) { + if (named) { + request({ kind: 'join', code }); + } else { + // Held, not sent. The name field is already on this screen and + // the code is already in its field, so this is one button. + needName = true; + } } }); @@ -122,9 +157,24 @@ untrack(() => flush(open)); }); + // A request that has been waiting on a socket for longer than a player will + // believe. Timed from the request rather than from the status, because a + // backoff cycles between "reconnecting" and "connecting" indefinitely and + // neither of them is news. + $effect(() => { + const waiting = !!pending && connection.status !== Status.OPEN; + if (!waiting) { + stalled = false; + return; + } + const timer = setTimeout(() => (stalled = true), STALL_MS); + return () => clearTimeout(timer); + }); + // A resume that the server cannot honour is not something the player did. // Reporting it would open the lobby with a red banner about a game they - // have already left behind, so the token is dropped quietly instead. + // have already left behind, so the token is dropped quietly instead — and + // an invite code held behind the resume is spent now. $effect(() => { const failed = resuming && !!game.state.error; untrack(() => { @@ -132,6 +182,13 @@ resuming = false; game.clearError(); forgetSession(); + if (pending && !named) { + // The link was for somebody who has still not given a name. + needName = true; + pending = null; + return; + } + flush(connection.status === Status.OPEN); }); }); @@ -139,6 +196,8 @@ function request(req) { codeError = ''; resuming = false; + needName = false; + stalled = false; game.clearError(); pending = req; // The handshake carries the nickname as it stands now, which is why the @@ -149,7 +208,9 @@ /** @param {boolean} isOpen */ function flush(isOpen) { - if (!pending || !isOpen) return; + // Nothing goes out while a resume is in flight: the seat this tab is + // reclaiming may be in the very room the held code names. + if (!pending || !isOpen || resuming) return; // Cleared only once the socket has taken it, so a request made during a // reconnect is carried by the next open connection rather than lost. const sent = pending.kind === 'create' ? send(createRoom()) : send(joinRoom(pending.code)); @@ -162,9 +223,17 @@ codeError = t.roomCodeInvalid; return; } + if (!named) { + needName = true; + return; + } request({ kind: 'join', code }); } + function create() { + request({ kind: 'create' }); + } + function goHome() { goto('/'); } @@ -177,19 +246,29 @@ * who is no longer in the room to be told about, and the button is only * enabled when this client already knows the rule allows it. * + * Each of these reports whether the request actually reached the server, so + * the lobby can say so rather than looking like a button that does nothing. + * * @param {boolean} ready + * @returns {boolean} */ function ready(ready) { - send(setReady(ready)); + return send(setReady(ready)); } + /** @returns {boolean} */ function start() { - send(startGame()); + return send(startGame()); } - /** @param {string} playerId */ + /** + * @param {string} playerId + * @returns {boolean} + */ function kick(playerId) { - if (confirm(t.kickConfirm)) send(kickPlayer(playerId)); + // The lobby arms this with a second press of the same button; a native + // confirm() would block the frame loop the countdown runs on. + return send(kickPlayer(playerId)); } function leave() { @@ -212,12 +291,22 @@ } function giveUp() { - if (confirm(t.resignConfirm)) send(resign()); + // Armed by the board with a second press, for the same reason as kick. + send(resign()); } -
+ + {inRoom && game.state.roomCode ? fill(t.titleRoom, { code: game.state.roomCode }) : t.titleOnline} + + +
{#if inRoom} + +

{t.onlineTitle}

+
- +
{:else}

{t.onlineTitle}

{t.onlineIntro}

+ +
+ +
+ + {#if needName} + + {/if} + {#if game.state.error} {/if} -
(codeError = '')} /> - +

{codeError || t.roomCodeHint}

@@ -302,15 +413,23 @@ display: flex; flex-direction: column; flex: 1; - gap: 16px; + gap: var(--space-4); min-height: 0; - padding-top: 12px; + padding-top: var(--space-3); } /* Joining is a form, not a room: it keeps a form's width whatever the - screen the two columns were widened for. */ + screen the two columns were widened for, and sits in the middle of it + rather than against the left edge of a 1040px shell. */ .online:not(.room) { + width: 100%; max-width: 480px; + margin-inline: auto; + } + + .status { + display: flex; + align-items: center; } .pane { @@ -320,13 +439,31 @@ min-height: 0; } + /* + * Stacked, the game takes the height that is going and its chain scrolls + * inside itself — which is what keeps the conversation on screen. Without + * this the chain grew the page one row per turn and pushed the only way + * into the chat below the fold exactly as the game got long enough to talk + * about, and a four-seat lobby did the same thing with its seat list. + * + * The lobby has no scroller of its own, so it is given one here. The board + * does not want one: the chain is the part that grows and it already + * scrolls, and a second scroller around it would move the word field. + */ .pane.game { - gap: 16px; + flex: 1; + gap: var(--space-4); + min-height: 0; + } + + .online.room:not(.in-game) .pane.game { + overflow-y: auto; } /* Stacked: a divider does the work the second column's whitespace does. */ .pane.talk { - padding-top: 12px; + flex: none; + padding-top: var(--space-3); border-top: 1px solid var(--border); } @@ -336,7 +473,7 @@ display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 320px); align-items: stretch; - gap: 24px; + gap: var(--space-6); } /* Each column scrolls on its own, so a long chain does not push the @@ -346,15 +483,9 @@ overflow-y: auto; } - /* Stacked, the game is as tall as it is and the conversation follows - it directly. Given a column, it takes the height of one. */ - .pane.game { - flex: 1; - } - .pane.talk { padding-top: 0; - padding-left: 24px; + padding-left: var(--space-6); border-top: 0; border-left: 1px solid var(--border); overflow: hidden; @@ -372,6 +503,7 @@ } .primary { + min-height: 44px; padding: 14px; border: 0; border-radius: var(--radius-sm); @@ -380,6 +512,11 @@ font-weight: 600; } + .primary:disabled { + background: var(--surface-alt); + color: var(--text-muted); + } + .join { display: flex; flex-direction: column; @@ -388,61 +525,70 @@ label { font-weight: 600; - font-size: 0.9rem; + font-size: var(--text-5); } .row { display: flex; - gap: 8px; + gap: var(--space-2); } input { flex: 1; min-width: 0; - padding: 12px 14px; - border: 1px solid var(--border); + padding: var(--space-3) 14px; + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); - font-size: 1rem; + font-size: var(--text-6); letter-spacing: 0.1em; text-transform: uppercase; } - input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; - } - .row button { - padding: 12px 18px; - border: 1px solid var(--border); + min-height: 44px; + padding: var(--space-3) var(--space-4); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-alt); font-weight: 600; } + .row button:disabled { + color: var(--text-muted); + } + .hint { margin: 0; color: var(--text-muted); - font-size: 0.8rem; + font-size: var(--text-3); } .hint.invalid { color: var(--danger); } - .error { + .error, + .notice { margin: 0; - padding: 10px 12px; + padding: 10px var(--space-3); border-radius: var(--radius-sm); + font-size: var(--text-5); + } + + .error { background: var(--danger-soft); color: var(--danger); - font-size: 0.9rem; + } + + .notice { + background: var(--surface-alt); + color: var(--warn); } .back { align-self: flex-start; color: var(--text-muted); - font-size: 0.9rem; + font-size: var(--text-5); } diff --git a/web/src/routes/play/+page.svelte b/web/src/routes/play/+page.svelte index 442aa9f..ec607c3 100644 --- a/web/src/routes/play/+page.svelte +++ b/web/src/routes/play/+page.svelte @@ -87,10 +87,20 @@ } function giveUp() { - if (confirm(t.resignConfirm)) send(resign()); + // The board arms this with a second press of the same button. A native + // confirm() blocks the main thread, which stops the countdown's frame + // loop while the server's deadline keeps running — hesitating over the + // dialog could cost the turn it was meant to protect. + send(resign()); } + + {t.titlePlay} + + +

{t.titlePlay}

+ {#snippet gameOver()}