From db7093bbe5813d0abcf7fa0d125af678ff771256 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 10 Sep 2026 13:04:13 +0700 Subject: [PATCH] fix(web): make the game comfortable to play on a phone and in both themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the findings of the three UI/UX reviews. The two that cost a player something every single turn: - The countdown reddened during the opponent's turn. It read the deadline, which the server rewrites on every handover, and never asked whose turn it was — a panic spike, every turn, about nothing the player could act on. It now mutes when the clock is not theirs, and signals urgency by thickening as well as reddening, because green to red is the pair a red-green colour blindness cannot separate. - On a phone the keyboard closed on every turn and could not be reopened. `disabled` on the focused word field blurs it, and the recovering focus() ran on a WebSocket message, which iOS will not open a keyboard for. The field stays editable and the submit button carries the turn instead; focus is asked for inside the gesture that submitted. A draft aimed at a syllable the game has moved past is now corrected rather than kept. The board was also not holding together as the game grew. `min-height` on the shell left nothing with a definite height to shrink against, so the chain kept its full height and the page got taller every turn, walking the word field and the chat off the bottom of the screen. The shell now has a real height, the chain gives up its space first with a floor so it cannot vanish, and `main` scrolls only as a last resort. Online play had one true dead end: nothing on the pre-room screen showed the connection at all, so a server that was down left an enabled button and a screen that never changed — while each impatient tap sent a real CreateRoom until the rate limiter answered. The badge is there now, requests disable their button, a stall says so, and the lobby draws the room's refusals beside the button that produced them instead of in a chat panel below the fold. A dropped player gets a retry rather than waiting out the backoff. An invite link asks a first-time guest for a name instead of seating them as "Người chơi" with no way to fix it. Accessibility and palette: - The turn change is announced. It is the state the whole game hangs on and it reached nobody who was not looking at that line. - --accent darkened to #12692f: accent text on its own tint measured 4.29:1. Every existing pair improves, white-on-accent included. - --border-strong added for the boundary of anything you type into or press, which needs 3:1 and had 1.47:1 light, 1.78:1 dark. - One focus ring, one 44px icon-button recipe, spacing and type scales, safe-area gutters, per-route titles, a heading per screen, a skip link, and reduced motion that stops the connection dot pulsing rather than flickering it a hundred thousand times a frame. Tests follow the two interactions that changed shape: resign and kick confirm inline with a second press, because confirm() blocks the frame loop the countdown runs on and hesitating could cost the turn it was protecting. joinRoomSeated waits for the seat before anybody speaks — the server replays a seat only the conversation from when it joined, so the old helper was racing the handshake. Verified: 189 unit tests, 40 end-to-end, no type errors, and the palette and layout measured at 360/400/768/1280 in both themes. --- web/e2e/bot-game.spec.js | 28 +- web/e2e/helpers.js | 29 +- web/e2e/pvp-game.spec.js | 83 ++++-- web/e2e/reconnect.spec.js | 24 +- web/src/app.css | 99 ++++++- web/src/app.html | 13 +- .../lib/components/AttributionFooter.svelte | 14 +- web/src/lib/components/ChainHistory.svelte | 48 +++- web/src/lib/components/ChatPanel.svelte | 75 ++---- web/src/lib/components/ConnectionBadge.svelte | 4 +- web/src/lib/components/CountdownRing.svelte | 64 ++++- .../lib/components/DifficultyPicker.svelte | 16 +- web/src/lib/components/GameBoard.svelte | 158 ++++++++--- web/src/lib/components/GameOverPanel.svelte | 71 +++-- web/src/lib/components/Lobby.svelte | 219 ++++++++++++--- web/src/lib/components/NicknameInput.svelte | 15 +- web/src/lib/components/PlayerStatus.svelte | 4 +- web/src/lib/components/RoomCodePanel.svelte | 141 ++++++++-- web/src/lib/components/ScoreBoard.svelte | 30 ++- web/src/lib/components/ThemeToggle.svelte | 8 +- web/src/lib/components/WordInput.svelte | 77 ++++-- web/src/lib/i18n/vi.js | 30 ++- web/src/lib/motion.js | 18 ++ web/src/lib/ws/client.js | 23 ++ web/src/lib/ws/connection.svelte.js | 13 + web/src/routes/+layout.svelte | 37 ++- web/src/routes/+page.svelte | 19 +- web/src/routes/online/+page.svelte | 252 ++++++++++++++---- web/src/routes/play/+page.svelte | 12 +- web/tests/ws-client.test.js | 79 ++++++ 30 files changed, 1359 insertions(+), 344 deletions(-) create mode 100644 web/src/lib/motion.js 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()}