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 @@
- + ++ {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));+ {t.reconnecting} + +
+ {/if} + {#if game.state.error}{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}
{endReasonMessages[result.reason]}
{/if} - {#if standings.length > 0} + {#if standings.length > 2}{t.newRecord}
{/if} - -@@ -40,7 +95,16 @@
- {#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} ++ {s.error} + +
+ {:else if unsent} +{t.reconnecting}
+ {/if} +{game.state.rejection.message}
+ ++ {game.state.rejection.word} — {game.state.rejection.message} +
{/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 @@ 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.tagline}
+ +{t.onlineIntro}
+ +{t.nicknameNeeded}
+ {/if} + {#if game.state.error}{game.state.error}
{/if} -{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()); } +