From 18170aad15318259f36df3a67b90cdb61e87bcac Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:58:38 +0700 Subject: [PATCH 1/9] style(web): collapse the type ramp, add accent hover tokens, and lift the page background Nine font-size steps down to seven, mapped so no component reads smaller or larger than it did before except the two the review named on purpose: the syllable grows into the room a hero glyph deserves, and the ring's resting value moves onto the ramp. Hover and pressed accent tokens replace the grey --surface-alt every primary button used to borrow, and the light page background lifts a step so a card reads as a card again rather than the same paper as the page under it. --- web/src/app.css | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/web/src/app.css b/web/src/app.css index 59faab3..b245ae0 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -7,7 +7,10 @@ /* Paper and ink with a faint green cast: a word game reads as a page, and the cast ties the neutrals to the accent instead of leaving them beside it. */ - --bg: #f4f7f4; + /* Lifted a step above the cards it sits under: at the old #f4f7f4 the page + and a card on --surface were 1.08 apart, close enough that a card read + as the same paper as the page behind it rather than as a card on it. */ + --bg: #eef3ef; --surface: #ffffff; --surface-alt: #e9f0ea; --border: #c2d1c8; @@ -26,6 +29,12 @@ --accent: #12692f; --accent-text: #ffffff; --accent-soft: #dff2e5; + /* Hover and press need their own step: the background a button already + borrows for its off states (--surface-alt) is grey, so without these the + accent never visibly darkens under the pointer. Both keep the white ink + above ≥7:1. */ + --accent-hover: #0e5626; + --accent-pressed: #0a4520; --danger: #a3231c; --danger-soft: #fbe4e2; --warn: #8a5a08; @@ -57,17 +66,18 @@ /* 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; + 1rem against 1.125rem. Collapsed from nine steps to seven — the old + 0.7-0.9rem band was five steps in 0.2rem, too fine to read as a scale, + and 0.7rem (11.2px) was the smallest text in the app, at the edge of + legible on a phone. Step 3 is body text and every input, which is 1rem + because anything smaller makes iOS Safari zoom the page on focus. */ + --text-1: 0.75rem; /* badges, series, timestamps */ + --text-2: 0.875rem; /* meta, hints, labels */ + --text-3: 1rem; /* body, inputs */ + --text-4: 1.125rem; /* scoreboard score, h2 */ + --text-5: 1.5rem; /* the clock, standings rows */ + --text-6: 2rem; /* the room code */ + --text-7: 2.25rem; /* the syllable — the one glyph read every turn */ color-scheme: light; } @@ -85,6 +95,10 @@ --accent: #5ddc8b; --accent-text: #0c1410; --accent-soft: #1e3a2a; + /* Lightened further rather than darkened: a dark accent already reads as + "pressed" on a dark surface, so the pointer states go the other way. */ + --accent-hover: #7ae6a1; + --accent-pressed: #93edb3; --danger: #f2867d; --danger-soft: #3a201e; --warn: #e9b949; From d94c2a105c655eee2d1692459bd04772af10040f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:58:50 +0700 Subject: [PATCH 2/9] feat(web): apply the whole-game UX review's copy proposals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the strings the review flagged as unclear or misleading: the not_your_turn and not_a_dead_end messages now describe the race or the position they actually answer, need_more_players drops a server constant that had leaked into prose, the per-game score and the room's series score get separate labels instead of sharing "Tỉ số", the opponent-turn fallback reads neutral rather than assuming one opponent, a chat author who left reads as a name rather than a sentence, too_fast drops its odd-one-out "nhé", and the meta description stops promising 1v1 for a 2-4 person game. --- web/e2e/pvp-game.spec.js | 8 +- web/src/app.html | 5 +- web/src/lib/components/ScoreBoard.svelte | 8 +- web/src/lib/i18n/vi.js | 94 +++++++++++++++++++----- 4 files changed, 86 insertions(+), 29 deletions(-) diff --git a/web/e2e/pvp-game.spec.js b/web/e2e/pvp-game.spec.js index 2a13fa6..459c267 100644 --- a/web/e2e/pvp-game.spec.js +++ b/web/e2e/pvp-game.spec.js @@ -322,8 +322,8 @@ test.describe('playing a stranger', () => { // The series score of the room, which the finished game has just moved: // the guest took it, so the owner's lobby shows 1 against their seat. - await expect(host.getByTestId('wins-p2')).toContainText('Tỉ số 1'); - await expect(host.getByTestId('wins-p1')).toContainText('Tỉ số 0'); + await expect(host.getByTestId('wins-p2')).toContainText('Ván thắng 1'); + await expect(host.getByTestId('wins-p1')).toContainText('Ván thắng 0'); await readyAndStart(host, guest); @@ -335,7 +335,7 @@ test.describe('playing a stranger', () => { // And the tally is carried into it, where the board shows it beside // each player's score for this game. - await expect(host.getByTestId('series-p2')).toContainText('Tỉ số 1'); + await expect(host.getByTestId('series-p2')).toContainText('Thắng 1'); await close(); }); @@ -559,7 +559,7 @@ test.describe('playing a stranger', () => { // 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(host.getByTestId('lobby-error')).toContainText('Bạn thao tác quá nhanh'); + await expect(host.getByTestId('lobby-error')).toContainText('Thao tác quá nhanh'); await close(); }); diff --git a/web/src/app.html b/web/src/app.html index 607c790..d94513a 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -14,7 +14,10 @@ name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" /> - + Nối Từ + {t.openingCaption} + {:else if entry.byMe && entry.typed && entry.typed !== entry.word} {fill(t.correctedFrom, { typed: entry.typed, word: entry.word })} {/if} - {#if entry.parts.length} + {#if entry.parts.length && (index === 0 || open)} -

- {entry.parts - .map((/** @type {import('$lib/stores/game.svelte.js').PointPart} */ p) => - `+${p.value} ${pointKindLabels[p.kind] ?? ''}` - ) - .join(' · ')} -

+ travel with the total. Shown only on the newest row and on + one whose meaning panel is open — every row, in a 9rem list, + used to turn three rows of history into three rows of + arithmetic. --> + {/if} {#if open} + + {best > 0 ? `${t.bestScore}: ${best}` : difficultyHints[difficulty]} + {/each} @@ -42,9 +47,9 @@ } legend { - padding: 0 0 6px; + padding: 0 0 var(--space-2); font-weight: 600; - font-size: var(--text-5); + font-size: var(--text-2); } /* auto-fit rather than three fixed columns: at 360px each of three got @@ -87,6 +92,6 @@ .best { color: var(--text-muted); - font-size: var(--text-2); + font-size: var(--text-1); } diff --git a/web/src/lib/stores/settings.svelte.js b/web/src/lib/stores/settings.svelte.js index de2fea4..b9bf430 100644 --- a/web/src/lib/stores/settings.svelte.js +++ b/web/src/lib/stores/settings.svelte.js @@ -11,6 +11,7 @@ const NICKNAME_KEY = 'noitu.nickname'; const THEME_KEY = 'noitu.theme'; const BEST_KEY = 'noitu.bestScores'; +const DIFFICULTY_KEY = 'noitu.lastDifficulty'; /** Mirrors the server's own cap so the input cannot promise a name it will lose. */ export const MAX_NICKNAME_LENGTH = 20; @@ -82,6 +83,20 @@ function normalizeTheme(value) { return value === 'dark' ? 'dark' : 'light'; } +/** + * The bot difficulty last picked, from the landing screen or the quick-match + * queue's own nudge. `null` when nothing has been picked yet in this browser + * — a caller falls back to its own default rather than this store guessing + * one on its behalf. + * @returns {number | null} + */ +function initialDifficulty() { + const raw = read(DIFFICULTY_KEY, ''); + if (raw === '') return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + /** Reads the theme the inline script in app.html already applied, if any. */ function initialTheme() { const saved = read(THEME_KEY, ''); @@ -99,7 +114,9 @@ export function createSettingsStore() { /** @type {'light' | 'dark'} */ theme: initialTheme(), /** @type {Record} */ - bestScores: readBestScores() + bestScores: readBestScores(), + /** @type {number | null} */ + lastDifficulty: initialDifficulty() }); return { @@ -128,6 +145,17 @@ export function createSettingsStore() { this.setTheme(state.theme === 'dark' ? 'light' : 'dark'); }, + /** + * Remembers the ladder rung the player last picked, so a quick-match + * wait offering the bot as a nudge sends them to the rung they already + * chose rather than back to the ladder's default. + * @param {number} difficulty - a Difficulty enum value + */ + setLastDifficulty(difficulty) { + state.lastDifficulty = difficulty; + write(DIFFICULTY_KEY, String(difficulty)); + }, + /** * @param {number|string} difficulty - a Difficulty enum value * @returns {number} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 7e4cd1c..928a3ae 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -3,9 +3,17 @@ import DifficultyPicker from '$lib/components/DifficultyPicker.svelte'; import NicknameInput from '$lib/components/NicknameInput.svelte'; import { Difficulty } from '$lib/proto/noitu/v1/game_pb.js'; - import { t } from '$lib/i18n/vi.js'; + import { fill, t } from '$lib/i18n/vi.js'; + import { settings } from '$lib/stores/settings.svelte.js'; - let difficulty = $state(Difficulty.MEDIUM); + // Resumes the rung last played rather than always opening on Medium, so + // choosing a difficulty is not a step repeated every visit. + let difficulty = $state(settings.state.lastDifficulty ?? Difficulty.MEDIUM); + + /** @param {number} value */ + function selectDifficulty(value) { + settings.setLastDifficulty(value); + } function playBot() { goto(`/play?difficulty=${difficulty}`); @@ -21,8 +29,14 @@ screen reader has something to land on. -->

{t.tagline}

+ +

+ {fill(t.howToPlay, { example: t.howToPlayExample })} +

+ - +
@@ -45,19 +59,19 @@ .tagline { margin: 0; color: var(--text-muted); - font-size: var(--text-6); + font-size: var(--text-3); font-weight: 400; } .actions { display: flex; flex-direction: column; - gap: 10px; + gap: var(--space-3); } .actions > * { min-height: 44px; - padding: 14px; + padding: var(--space-4); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); @@ -71,6 +85,15 @@ border-color: transparent; background: var(--accent); color: var(--accent-text); + transition: background-color 150ms ease-out; + } + + .actions .primary:hover { + background: var(--accent-hover); + } + + .actions .primary:active { + background: var(--accent-pressed); } /* Its own line under the two big taps, not one more of them: this is a From 4fe7ef4f115b8a0c2433055760fad7436a113a1c Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:59:13 +0700 Subject: [PATCH 4/9] feat(web): slim the board header and stop the claim/resign row from churning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 360px the header used to open on two rows of chrome: the connection badge said "Đã kết nối" in full while it was true, "Luật chơi" was a full-width text link, and the room code sat crowded in between. The badge now shows only its dot once open, the rules link is a 44px icon with an aria-label, and the room code moves under the turn prompt it used to sit beside. "Bí từ" used to mount and unmount every handover, shifting the input under a player's thumb each turn; it now sits in one persistent row with "Đầu hàng", both built on ArmedButton and disabled off-turn rather than unmounted. A resign or claim raced by the turn moving on now answers beside the button that sent it instead of the top banner, matching how a false dead-end claim was already handled. Knockout stops saying "you're out" twice — once in a banner above the scoreboard, once in the spectating box below it — and the elimination suggestions a spectator used to wait for the game-over screen to see now show up the moment they're eliminated, keyed to the syllable they were actually stuck on rather than whatever the game has moved on to since. --- web/src/lib/components/ConnectionBadge.svelte | 15 +- web/src/lib/components/GameBoard.svelte | 177 +++++++++++------- web/src/lib/components/GameOverPanel.svelte | 37 ++-- web/src/lib/components/PlayerStatus.svelte | 15 +- web/src/lib/stores/game-apply.js | 14 +- web/src/lib/stores/game-shape.js | 7 +- web/tests/game-board.test.js | 53 ++++++ web/tests/game-store.test.js | 48 +++++ 8 files changed, 267 insertions(+), 99 deletions(-) diff --git a/web/src/lib/components/ConnectionBadge.svelte b/web/src/lib/components/ConnectionBadge.svelte index 33ad04a..8bcc1b5 100644 --- a/web/src/lib/components/ConnectionBadge.svelte +++ b/web/src/lib/components/ConnectionBadge.svelte @@ -2,6 +2,15 @@ import { t } from '$lib/i18n/vi.js'; import { Status, connection } from '$lib/ws/connection.svelte.js'; + /** + * `compact` hides the label's text while the connection is open, keeping + * only the dot — that is the state a board's header has no room to spell + * out every time, and the one that needs no announcement anyway. Any other + * status still shows its text: that is exactly when it matters. + * @type {{ compact?: boolean }} + */ + let { compact = false } = $props(); + const label = $derived( { [Status.CONNECTING]: t.connecting, @@ -18,7 +27,7 @@ -->

- {label} + {label}

diff --git a/web/src/lib/components/GameOverPanel.svelte b/web/src/lib/components/GameOverPanel.svelte index 3441747..b682c50 100644 --- a/web/src/lib/components/GameOverPanel.svelte +++ b/web/src/lib/components/GameOverPanel.svelte @@ -106,8 +106,12 @@
{:else} +

- {fill(t.noSuggestions, { syllable: game.state.currentSyllable })} + {fill(t.noSuggestions, { syllable: elimination.syllable })}

{/if} {/if} @@ -146,7 +150,7 @@ h2 { margin: 0; color: var(--danger); - font-size: var(--text-7); + font-size: var(--text-4); } h2.won { @@ -171,9 +175,9 @@ .standings li { display: flex; align-items: baseline; - gap: 10px; + gap: var(--space-3); padding: var(--space-1) var(--space-3); - font-size: var(--text-5); + font-size: var(--text-2); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-alt); @@ -202,7 +206,7 @@ .points { margin-left: auto; color: var(--text-muted); - font-size: var(--text-4); + font-size: var(--text-2); font-variant-numeric: tabular-nums; } @@ -226,12 +230,12 @@ dt { color: var(--text-muted); - font-size: var(--text-4); + font-size: var(--text-2); } dd { margin: 0; - font-size: var(--text-5); + font-size: var(--text-2); font-weight: 700; font-variant-numeric: tabular-nums; } @@ -243,13 +247,13 @@ flex-wrap: wrap; align-items: baseline; justify-content: center; - gap: 6px var(--space-2); + gap: var(--space-2) var(--space-2); } .suggestions h3 { margin: 0; color: var(--text-muted); - font-size: var(--text-3); + font-size: var(--text-2); font-weight: 600; /* Uppercase Vietnamese stacks a tone mark above a capital. */ line-height: 1.6; @@ -261,7 +265,7 @@ display: flex; flex-wrap: wrap; justify-content: center; - gap: 6px; + gap: var(--space-2); margin: 0; padding: 0; list-style: none; @@ -282,7 +286,7 @@ .record { margin: 0; - padding: 6px var(--space-3); + padding: var(--space-2) var(--space-3); border-radius: var(--radius-pill); background: var(--accent-soft); color: var(--accent); @@ -312,12 +316,21 @@ border-color: transparent; background: var(--accent); color: var(--accent-text); + transition: background-color 150ms ease-out; + } + + .actions .primary:hover { + background: var(--accent-hover); + } + + .actions .primary:active { + background: var(--accent-pressed); } /* Keeping the chain is worth offering and not worth pressing first. */ .actions .export { background: transparent; color: var(--text-muted); - font-size: var(--text-5); + font-size: var(--text-2); } diff --git a/web/src/lib/components/PlayerStatus.svelte b/web/src/lib/components/PlayerStatus.svelte index a36c59e..326ba2a 100644 --- a/web/src/lib/components/PlayerStatus.svelte +++ b/web/src/lib/components/PlayerStatus.svelte @@ -59,11 +59,7 @@ } -{#if game.iAmOut} - - -{:else if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'} +{#if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'} {/if} @@ -81,16 +77,11 @@ diff --git a/web/src/lib/stores/game-apply.js b/web/src/lib/stores/game-apply.js index a954241..bf9e1bf 100644 --- a/web/src/lib/stores/game-apply.js +++ b/web/src/lib/stores/game-apply.js @@ -169,7 +169,10 @@ export function applyTo(state, msg, { reset, leave }) { playerId: value.playerId, name: value.name, reason: value.reason, - suggestions: value.suggestions ?? [] + suggestions: value.suggestions ?? [], + // Captured now: the game keeps running without this + // player, and currentSyllable moves on with it. + syllable: state.currentSyllable }; } break; @@ -230,10 +233,11 @@ export function applyTo(state, msg, { reset, leave }) { // the model has to stop describing one. Set after, because // leaving clears everything including the message. if (value.code === 'kicked' || value.code === 'room_idle_closed') leave(); - // A false dead-end claim is answered next to the input, not in - // the top banner: it is about the move just attempted, not a - // room-wide condition every screen has to show. - if (value.code === 'not_a_dead_end') { + // A false dead-end claim, and a resign or claim that raced the + // turn moving on, are both answered next to the button that sent + // them, not in the top banner: each is about the move just + // attempted, not a room-wide condition every screen has to show. + if (value.code === 'not_a_dead_end' || value.code === 'not_your_turn') { state.claimError = errorMessage(value.code); break; } diff --git a/web/src/lib/stores/game-shape.js b/web/src/lib/stores/game-shape.js index d326883..8835189 100644 --- a/web/src/lib/stores/game-shape.js +++ b/web/src/lib/stores/game-shape.js @@ -63,7 +63,7 @@ * @property {number} rank - final placing, 1 for the winner; 0 while in play */ -/** @typedef {{ playerId: string, name: string, reason: number, suggestions: string[] }} Elimination */ +/** @typedef {{ playerId: string, name: string, reason: number, suggestions: string[], syllable: string }} Elimination */ /** @typedef {{ playerId: string, name: string, isMe: boolean, reason: number }} LastOut */ /** @typedef {{ n: number, fromMe: boolean, playerId: string, author: string, text: string, atMs: number }} ChatLine */ /** @typedef {{ word: string, message: string, reason: number, suggestion: string }} Rejection */ @@ -110,7 +110,10 @@ * player left standing, then the rest in reverse order of elimination. * @property {Elimination | null} elimination - This player's own knockout, * and nobody else's. Empty `suggestions` means it was a dead end, which is - * a different thing to say than "here is what you missed". + * a different thing to say than "here is what you missed". `syllable` is + * the one this player was stuck on, captured at the moment of knockout — + * `currentSyllable` moves on with the game that keeps running without + * them, so reading it later would name somebody else's syllable. * @property {LastOut | null} lastOut - The last player to go out, whoever * they were: what a spectator is shown. The client's own knockout is * `elimination` above. diff --git a/web/tests/game-board.test.js b/web/tests/game-board.test.js index f75214d..5b7921c 100644 --- a/web/tests/game-board.test.js +++ b/web/tests/game-board.test.js @@ -101,3 +101,56 @@ describe('the chat pill', () => { unmount(component); }); }); + +describe('the persistent claim/resign row', () => { + // "Bí từ" used to mount only on the player's own turn, shifting everything + // under the input each handover. Both controls now stay mounted for the + // whole game and are disabled off-turn instead, so the row's height never + // changes turn to turn. + it('keeps both buttons mounted, enabled, on the player\'s own turn', () => { + const { target, component } = renderGameBoard(); + + /** @type {HTMLButtonElement | null} */ + const claim = target.querySelector('.claim-dead-end'); + /** @type {HTMLButtonElement | null} */ + const resign = target.querySelector('.resign'); + + expect(claim).not.toBeNull(); + expect(resign).not.toBeNull(); + expect(claim?.disabled).toBe(false); + expect(resign?.disabled).toBe(false); + unmount(component); + }); + + it('disables rather than unmounts both once the turn moves on', () => { + const { target, component } = renderGameBoard(); + + game.apply( + create(ServerMessageSchema, { + payload: { + case: 'turnUpdate', + value: { + currentSyllable: 'yên', + myTurn: false, + turnSeq: 2, + chainLength: 1, + players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }], + turnPlayerId: 'p2' + } + } + }) + ); + flushSync(); + + /** @type {HTMLButtonElement | null} */ + const claim = target.querySelector('.claim-dead-end'); + /** @type {HTMLButtonElement | null} */ + const resign = target.querySelector('.resign'); + + expect(claim).not.toBeNull(); + expect(resign).not.toBeNull(); + expect(claim?.disabled).toBe(true); + expect(resign?.disabled).toBe(true); + unmount(component); + }); +}); diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js index 74228a5..d34b0b3 100644 --- a/web/tests/game-store.test.js +++ b/web/tests/game-store.test.js @@ -404,6 +404,38 @@ describe('playerEliminated', () => { expect(store.state.myTurn).toBe(false); }); + it('captures the syllable this player was stuck on, not whatever the game moves on to', () => { + // currentSyllable keeps changing for the players still in the game; + // the elimination has to freeze its own copy at the moment it happens + // or a later reader (the game-over screen, in a four-seat room) would + // name somebody else's syllable. + const store = createGameStore(); + store.apply(started()); + store.apply( + msg('playerEliminated', { + playerId: 'p1', + isMe: true, + reason: GameEndReason.NO_LEGAL_MOVE + }) + ); + expect(store.state.elimination?.syllable).toBe(store.state.currentSyllable); + + const stuckOn = store.state.currentSyllable; + store.apply( + msg('turnUpdate', { + currentSyllable: 'khác', + myTurn: false, + turnSeq: 2, + chainLength: 1, + players: table(), + turnPlayerId: 'p2' + }) + ); + + expect(store.state.currentSyllable).toBe('khác'); + expect(store.state.elimination?.syllable).toBe(stuckOn); + }); + it('reads an absent list as a position that had nothing left', () => { // A dead end arrives as an empty list, which means "no words to offer" // rather than undefined. @@ -933,3 +965,19 @@ describe('dead-end claim', () => { expect(store.state.claimError).toBeNull(); }); }); + +describe('a resign or claim that raced the turn moving on', () => { + // The server can only answer resign or claim-dead-end with not_your_turn + // when the turn already left before the request arrived — both are + // gated on the player's own turn client-side. That is the same kind of + // news as a false dead-end claim: about the move just attempted, not the + // room, so it answers beside the button rather than in the top banner. + it('answers inline rather than in the general error banner', () => { + const store = createGameStore(); + store.apply(started()); + store.apply(msg('error', { code: 'not_your_turn', message: '' })); + + expect(store.state.claimError).not.toBeNull(); + expect(store.state.error).toBeNull(); + }); +}); From a95938d611c9cbce9ad1152270a050f50de67b74 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:59:19 +0700 Subject: [PATCH 5/9] feat(web): make the rejection row's suggestion and report real touch targets The suggestion and report pills sat at 2px padding on 0.8rem text, under WCAG's 24px minimum, on the two taps most worth making under a running clock. They move onto their own line at a 36px floor, and the suggestion reads as primary weight since it is almost certainly the word the player meant. The fill-only behaviour and the field's uncontrolled-input invariant are unchanged. --- web/src/lib/components/WordInput.svelte | 95 ++++++++++++++++++------- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/web/src/lib/components/WordInput.svelte b/web/src/lib/components/WordInput.svelte index f2a3938..b959d43 100644 --- a/web/src/lib/components/WordInput.svelte +++ b/web/src/lib/components/WordInput.svelte @@ -223,17 +223,26 @@ clock, with nothing to compare against. --> + {#if game.state.rejection.suggestion || game.state.rejection.reason === RejectReason.NOT_IN_DICTIONARY} + +
+ {#if game.state.rejection.suggestion} + + + {/if} + {#if game.state.rejection.reason === RejectReason.NOT_IN_DICTIONARY} + + {/if} +
+ {/if} {/if} {#if game.state.reportConfirmation} @@ -251,13 +260,13 @@ input { flex: 1; min-width: 0; - padding: 14px; + padding: var(--space-4); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); /* 16px or larger stops iOS Safari zooming the page on focus, which on a phone hides half the board behind the keyboard. */ - font-size: var(--text-6); + font-size: var(--text-3); /* Whatever does not fit ends in an ellipsis rather than against the edge of the box. */ text-overflow: ellipsis; @@ -275,12 +284,21 @@ } button { - padding: 14px var(--space-5); + padding: var(--space-4) var(--space-5); border: 0; border-radius: var(--radius-sm); background: var(--accent); color: var(--accent-text); font-weight: 600; + transition: background-color 150ms ease-out; + } + + button:hover:not(:disabled) { + background: var(--accent-hover); + } + + button:active:not(:disabled) { + background: var(--accent-pressed); } button:disabled { @@ -294,34 +312,63 @@ align-items: baseline; gap: 4px var(--space-2); margin: var(--space-2) 0 0; - padding: 10px var(--space-3); + padding: var(--space-3) var(--space-3); border-radius: var(--radius-sm); background: var(--danger-soft); color: var(--danger); - font-size: var(--text-5); + font-size: var(--text-2); } .rejection strong { font-weight: 600; } - .suggestion, - .report { - padding: 2px var(--space-2); - border: 1px solid currentColor; + .fixes { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-top: var(--space-2); + } + + .fix { + min-height: 36px; + padding: var(--space-1) var(--space-3); + border: 1px solid var(--border-strong); border-radius: var(--radius-pill); - background: none; - color: inherit; - font-size: var(--text-3); + background: var(--surface); + color: var(--text); + font-size: var(--text-2); font-weight: 600; + text-align: left; + transition: background-color 150ms ease-out; + } + + .fix:hover { + background: var(--surface-alt); + } + + /* Primary weight: of the two, this is the one tap that almost certainly + finishes the turn. */ + .fix.suggestion { + border-color: transparent; + background: var(--accent); + color: var(--accent-text); + } + + .fix.suggestion:hover { + background: var(--accent-hover); + } + + .fix.suggestion:active { + background: var(--accent-pressed); } .report-confirmation { margin: var(--space-2) 0 0; - padding: 10px var(--space-3); + padding: var(--space-3) var(--space-3); border-radius: var(--radius-sm); background: var(--accent-soft); color: var(--text); - font-size: var(--text-5); + font-size: var(--text-2); } From 2a7a2a7d53a8d34ebf5574228cd0014cbabdaeff Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:59:26 +0700 Subject: [PATCH 6/9] feat(web): take the quick-match wait's seconds out of the live region The waiting panel's whole role="status" region re-announced itself every second because the seconds counter lived inside it; the counter now sits outside the live text, which only changes (and is only spoken) once. The nudge that offers a bot game while waiting now says it leaves the queue, and its link keeps the difficulty last picked rather than resetting to the ladder's default. The two ways into an online game get sub-labels saying what each actually starts. --- web/src/routes/online/+page.svelte | 90 ++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/web/src/routes/online/+page.svelte b/web/src/routes/online/+page.svelte index 731361d..927286b 100644 --- a/web/src/routes/online/+page.svelte +++ b/web/src/routes/online/+page.svelte @@ -54,6 +54,12 @@ */ const QUICK_MATCH_NUDGE_S = 20; + // The rung last played, if this browser has one, so the nudge's bot game + // resumes it rather than opening back on the ladder's own default. + const quickMatchNudgeHref = $derived( + settings.state.lastDifficulty != null ? `/play?difficulty=${settings.state.lastDifficulty}` : '/play' + ); + /** * 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 @@ -539,14 +545,18 @@ {#if game.state.queued} -
-

{fill(t.quickMatchWaiting, { n: session.state.queuedForS })}

+ the way out. The running seconds sit outside the live region: + inside it, a screen reader announced "n giây" on every tick. --> +
+

{t.quickMatchWaiting}

+ {#if session.state.queuedForS >= QUICK_MATCH_NUDGE_S}

{t.quickMatchNudge} - {t.quickMatchNudgeLink} + {t.quickMatchNudgeLink}

{/if}
@@ -555,18 +565,24 @@ send a real CreateRoom, and the fifth one came back as "you are creating rooms too quickly" to a player who thought they had tapped nothing at all. --> - +
+ +

{t.quickMatchHint}

+
- +
+ +

{t.createRoomHint}

+
From 2a94e057525d5e10cc60e9251e6cb4ed4dbdcab0 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:59:33 +0700 Subject: [PATCH 7/9] style(web): remap the remaining components onto the collapsed type ramp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical follow-through on the app.css ramp collapse: every var(--text-N) reference in these files moves to the step that carries the same size it did under the old nine-step scale, and the remaining 6/10/14px spacing literals become --space tokens. Accent hover/pressed tokens land on the two primary buttons here (Lobby's "Bắt đầu"/"Sẵn sàng", the chat panel's "Gửi") for the same reason the other primary buttons got them. --- .../lib/components/AttributionFooter.svelte | 2 +- web/src/lib/components/ChatPanel.svelte | 23 +++++++---- web/src/lib/components/CountdownRing.svelte | 2 +- web/src/lib/components/Lobby.svelte | 39 ++++++++++++------- web/src/lib/components/NicknameInput.svelte | 10 ++--- web/src/lib/components/RoomCodePanel.svelte | 16 ++++---- web/src/lib/components/ThemeToggle.svelte | 2 +- web/src/routes/+layout.svelte | 2 +- web/src/routes/rules/+page.svelte | 8 ++-- 9 files changed, 61 insertions(+), 43 deletions(-) diff --git a/web/src/lib/components/AttributionFooter.svelte b/web/src/lib/components/AttributionFooter.svelte index de77c5c..57dde68 100644 --- a/web/src/lib/components/AttributionFooter.svelte +++ b/web/src/lib/components/AttributionFooter.svelte @@ -37,7 +37,7 @@ padding-bottom: max(var(--space-2), env(safe-area-inset-bottom)); text-align: center; color: var(--text-muted); - font-size: var(--text-2); + font-size: var(--text-1); line-height: 1.4; } diff --git a/web/src/lib/components/ChatPanel.svelte b/web/src/lib/components/ChatPanel.svelte index df14383..71ba1ec 100644 --- a/web/src/lib/components/ChatPanel.svelte +++ b/web/src/lib/components/ChatPanel.svelte @@ -222,7 +222,7 @@ border: 0; background: none; color: var(--text-muted); - font-size: var(--text-4); + font-size: var(--text-2); font-weight: 600; /* Uppercase Vietnamese stacks a tone mark above a capital. */ line-height: 1.6; @@ -233,7 +233,7 @@ .header { width: 100%; min-height: 44px; - padding: 10px 0; + padding: var(--space-3) 0; justify-content: space-between; } @@ -276,10 +276,10 @@ display: flex; flex-wrap: wrap; align-items: baseline; - gap: 6px; + gap: var(--space-2); /* Colour is set per line, from the author's seat. Everything else about a line is the same for everybody. */ - font-size: var(--text-6); + font-size: var(--text-3); } .author { @@ -308,21 +308,30 @@ input { flex: 1; min-width: 0; - padding: 10px 12px; + padding: var(--space-3) 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); /* 16px or larger stops iOS Safari zooming the page on focus. */ - font-size: var(--text-6); + font-size: var(--text-3); } .row button { - padding: 10px 16px; + padding: var(--space-3) 16px; border: 0; border-radius: var(--radius-sm); background: var(--accent); color: var(--accent-text); font-weight: 600; + transition: background-color 150ms ease-out; + } + + .row button:hover:not(:disabled) { + background: var(--accent-hover); + } + + .row button:active:not(:disabled) { + background: var(--accent-pressed); } .row button:disabled { diff --git a/web/src/lib/components/CountdownRing.svelte b/web/src/lib/components/CountdownRing.svelte index 3c452aa..8a97b93 100644 --- a/web/src/lib/components/CountdownRing.svelte +++ b/web/src/lib/components/CountdownRing.svelte @@ -146,7 +146,7 @@ display: grid; place-items: center; color: var(--text); - font-size: 1.5rem; + font-size: var(--text-5); font-weight: 700; font-variant-numeric: tabular-nums; } diff --git a/web/src/lib/components/Lobby.svelte b/web/src/lib/components/Lobby.svelte index ab84413..13365f6 100644 --- a/web/src/lib/components/Lobby.svelte +++ b/web/src/lib/components/Lobby.svelte @@ -231,7 +231,7 @@ .count { margin: 0; color: var(--text-muted); - font-size: var(--text-4); + font-size: var(--text-2); font-weight: 600; } @@ -248,11 +248,11 @@ display: flex; flex-wrap: wrap; align-items: baseline; - gap: 10px; + gap: var(--space-3); /* A seat is a row to read, not a target to hit, so it keeps the type and gives up the padding: four of them decide whether the buttons under the list are on screen. */ - padding: var(--space-2) 14px; + padding: var(--space-2) var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); @@ -261,7 +261,7 @@ /* Tighter still under a finished game, where the list is competing with a result panel for the same screen. */ .compact .seat { - padding: var(--space-1) 14px; + padding: var(--space-1) var(--space-4); } /* Readiness is a tint. Being the owner is a marker down the edge: a fact @@ -294,7 +294,7 @@ border-radius: var(--radius-pill); background: var(--surface-alt); color: var(--text-muted); - font-size: var(--text-2); + font-size: var(--text-1); } /* One right-hand group, so a row keeps its shape whether or not it has a @@ -308,19 +308,19 @@ .wins { color: var(--text-muted); - font-size: var(--text-2); + font-size: var(--text-1); white-space: nowrap; } .wins strong { color: var(--text); - font-size: var(--text-5); + font-size: var(--text-2); font-variant-numeric: tabular-nums; } .state { color: var(--text-muted); - font-size: var(--text-3); + font-size: var(--text-2); } .state.offline { @@ -346,7 +346,7 @@ border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); - font-size: var(--text-6); + font-size: var(--text-3); line-height: 1; } @@ -370,7 +370,7 @@ .hint { margin: 0; color: var(--text-muted); - font-size: var(--text-5); + font-size: var(--text-2); text-align: center; } @@ -380,11 +380,11 @@ justify-content: space-between; gap: var(--space-2); margin: 0; - padding: 10px var(--space-3); + padding: var(--space-3) var(--space-3); border-radius: var(--radius-sm); background: var(--danger-soft); color: var(--danger); - font-size: var(--text-5); + font-size: var(--text-2); } .actions { @@ -397,7 +397,7 @@ /* Still a full 44px: readying and starting are what a player came here to press. */ min-height: 44px; - padding: 10px var(--space-3); + padding: var(--space-3) var(--space-3); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); @@ -408,6 +408,15 @@ border-color: transparent; background: var(--accent); color: var(--accent-text); + transition: background-color 150ms ease-out; + } + + .actions .primary:hover:not(:disabled) { + background: var(--accent-hover); + } + + .actions .primary:active:not(:disabled) { + background: var(--accent-pressed); } .actions .primary.on { @@ -429,7 +438,7 @@ border-radius: var(--radius-sm); background: transparent; color: var(--text-muted); - font-size: var(--text-4); + font-size: var(--text-2); } .leave:disabled { @@ -439,7 +448,7 @@ .note { margin: 0; color: var(--text-muted); - font-size: var(--text-3); + font-size: var(--text-2); text-align: center; } diff --git a/web/src/lib/components/NicknameInput.svelte b/web/src/lib/components/NicknameInput.svelte index 01d06ce..aa7ceeb 100644 --- a/web/src/lib/components/NicknameInput.svelte +++ b/web/src/lib/components/NicknameInput.svelte @@ -29,25 +29,25 @@ .field { display: flex; flex-direction: column; - gap: 6px; + gap: var(--space-2); } label { font-weight: 600; - font-size: var(--text-5); + font-size: var(--text-2); } input { - padding: var(--space-3) 14px; + padding: var(--space-3) var(--space-4); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); - font-size: var(--text-6); + font-size: var(--text-3); } .hint { margin: 0; color: var(--text-muted); - font-size: var(--text-3); + font-size: var(--text-2); } diff --git a/web/src/lib/components/RoomCodePanel.svelte b/web/src/lib/components/RoomCodePanel.svelte index b5fe29f..5659708 100644 --- a/web/src/lib/components/RoomCodePanel.svelte +++ b/web/src/lib/components/RoomCodePanel.svelte @@ -110,7 +110,7 @@ display: flex; flex-direction: column; align-items: center; - gap: 10px; + gap: var(--space-3); padding: var(--space-5); border: 1px solid var(--border); border-radius: var(--radius); @@ -135,7 +135,7 @@ .label { margin: 0; color: var(--text-muted); - font-size: var(--text-4); + font-size: var(--text-2); } /* @@ -146,13 +146,13 @@ .code { margin: 0; font-family: ui-monospace, 'SFMono-Regular', 'Cascadia Mono', Menlo, monospace; - font-size: var(--text-9); + font-size: var(--text-6); font-weight: 700; letter-spacing: 0.12em; } .panel.compact .code { - font-size: var(--text-7); + font-size: var(--text-4); } .actions { @@ -164,18 +164,18 @@ .actions button { min-height: 44px; - padding: 10px var(--space-4); + padding: var(--space-3) var(--space-4); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-alt); - font-size: var(--text-4); + font-size: var(--text-2); font-weight: 600; } .failed { margin: 0; color: var(--danger); - font-size: var(--text-3); + font-size: var(--text-2); text-align: center; } @@ -187,7 +187,7 @@ margin: 0; max-width: 100%; color: var(--text-muted); - font-size: var(--text-3); + font-size: var(--text-2); } .link-label { diff --git a/web/src/lib/components/ThemeToggle.svelte b/web/src/lib/components/ThemeToggle.svelte index 38642e3..b333e26 100644 --- a/web/src/lib/components/ThemeToggle.svelte +++ b/web/src/lib/components/ThemeToggle.svelte @@ -26,7 +26,7 @@ border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface); - font-size: var(--text-6); + font-size: var(--text-3); line-height: 1; } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 2aec6c6..d0bac8e 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -71,7 +71,7 @@ .brand { color: inherit; - font-size: var(--text-7); + font-size: var(--text-4); font-weight: 700; text-decoration: none; } diff --git a/web/src/routes/rules/+page.svelte b/web/src/routes/rules/+page.svelte index 7be8f80..aee6049 100644 --- a/web/src/routes/rules/+page.svelte +++ b/web/src/routes/rules/+page.svelte @@ -106,7 +106,7 @@ h1 { margin: 0; - font-size: var(--text-8); + font-size: var(--text-5); } .intro { @@ -126,7 +126,7 @@ border-radius: var(--radius-pill); background: var(--surface-alt); color: var(--text); - font-size: var(--text-3); + font-size: var(--text-2); text-decoration: none; } @@ -141,7 +141,7 @@ h2 { margin: 0; - font-size: var(--text-7); + font-size: var(--text-4); } p { @@ -164,6 +164,6 @@ .back { align-self: flex-start; color: var(--text-muted); - font-size: var(--text-5); + font-size: var(--text-2); } From d5f89665d8f516c40d4e2e14e6d921e66e1316b6 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:59:39 +0700 Subject: [PATCH 8/9] test(web): cover the difficulty-hint table and the last-difficulty setting difficultyHints is walked against the Difficulty schema the same way difficultyLabels already is, and the settings store gets coverage for lastDifficulty: starts unset, persists across a reload, ignores a corrupt stored value, and stays usable in memory when storage is hostile. --- web/tests/i18n.test.js | 9 +++++++++ web/tests/settings-store.test.js | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/web/tests/i18n.test.js b/web/tests/i18n.test.js index a19d1dc..2a1d133 100644 --- a/web/tests/i18n.test.js +++ b/web/tests/i18n.test.js @@ -14,6 +14,7 @@ import { PointKindSchema } from '../src/lib/proto/noitu/v1/game_pb.js'; import { + difficultyHints, difficultyLabels, endReasonMessages, errorFallback, @@ -105,6 +106,14 @@ describe('difficulty labels', () => { expect(difficultyLabels[value], `Difficulty ${value} has no label`).toBeTypeOf('string'); } }); + + it('gives every playable difficulty a hint for the record line to fall back on', () => { + const playable = valuesOf(DifficultySchema).filter((v) => v !== Difficulty.UNSPECIFIED); + for (const value of playable) { + expect(difficultyHints[value], `Difficulty ${value} has no hint`).toBeTypeOf('string'); + expect(difficultyHints[value].length).toBeGreaterThan(0); + } + }); }); describe('server error codes', () => { diff --git a/web/tests/settings-store.test.js b/web/tests/settings-store.test.js index 47c41e9..422e4d7 100644 --- a/web/tests/settings-store.test.js +++ b/web/tests/settings-store.test.js @@ -138,6 +138,26 @@ describe('best scores', () => { }); }); +describe('last difficulty', () => { + it('starts with none picked, so a caller falls back to its own default', () => { + const store = createSettingsStore(); + expect(store.state.lastDifficulty).toBeNull(); + }); + + it('remembers the rung last picked', () => { + const store = createSettingsStore(); + store.setLastDifficulty(Difficulty.HARD); + + expect(store.state.lastDifficulty).toBe(Difficulty.HARD); + expect(createSettingsStore().state.lastDifficulty).toBe(Difficulty.HARD); + }); + + it('ignores a stored value that is not a number', () => { + localStorage.setItem('noitu.lastDifficulty', 'not-a-number'); + expect(createSettingsStore().state.lastDifficulty).toBeNull(); + }); +}); + describe('when storage is unavailable', () => { it('constructs with working defaults instead of throwing', () => { useStorage(hostileStorage()); @@ -145,6 +165,7 @@ describe('when storage is unavailable', () => { expect(store.state.nickname).toBe(''); expect(store.state.bestScores).toEqual({}); + expect(store.state.lastDifficulty).toBeNull(); expect(['light', 'dark']).toContain(store.state.theme); }); @@ -155,10 +176,12 @@ describe('when storage is unavailable', () => { expect(() => store.setNickname('Minh')).not.toThrow(); expect(() => store.setTheme('dark')).not.toThrow(); expect(store.recordScore(Difficulty.HARD, 7)).toBe(true); + expect(() => store.setLastDifficulty(Difficulty.EASY)).not.toThrow(); expect(store.state.nickname).toBe('Minh'); expect(store.state.theme).toBe('dark'); expect(store.bestScore(Difficulty.HARD)).toBe(7); + expect(store.state.lastDifficulty).toBe(Difficulty.EASY); }); it('survives a browser that throws on the property itself', () => { From 007f50cbba1544d8c880af087b83ed3bbe90280b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 17:01:24 +0700 Subject: [PATCH 9/9] docs(reports): record the whole-game UX pass implementation --- ...fullstack-developer-260921-1632-ux-pass.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 plans/reports/fullstack-developer-260921-1632-ux-pass.md diff --git a/plans/reports/fullstack-developer-260921-1632-ux-pass.md b/plans/reports/fullstack-developer-260921-1632-ux-pass.md new file mode 100644 index 0000000..00fa4ac --- /dev/null +++ b/plans/reports/fullstack-developer-260921-1632-ux-pass.md @@ -0,0 +1,59 @@ +# Whole-game UX pass — implementation report + +Branch: `worktree-agent-a4990d1e576b04b80` +Worktree: `/workspace/tiennm99/noitu/.claude/worktrees/agent-a4990d1e576b04b80` +Base: fast-forwarded to `dev` `8c5bb8b` before starting (had `room-session.svelte.js`, `ArmedButton.svelte`, `chat-panel.test.js`). +Source: `plans/reports/ui-ux-designer-260921-1529-whole-game-ux-review.md`. + +## Per-item summary + +- **#1** Already done on `dev` (in-room rules links are `target="_blank"`). No change. +- **#2** Landing: `howToPlay`/`howToPlayExample` line added between the tagline and the nickname field (`routes/+page.svelte`). `ChainHistory.svelte`'s opening row gets an `openingCaption` line styled like `.corrected`. +- **#3** `GameBoard.svelte` header: `ConnectionBadge` gained a `compact` prop (dot-only, `sr-only` label, while `status === 'open'`); "Luật chơi" is now a 44px `.icon-button` anchor with `aria-label`; the room-code/difficulty `.mode` line moved from the header into `.prompt`, under the syllable. Chat pill kept its `data-testid="chat-pill"`/`chatOpen` `aria-label`; visible text dropped to an emoji + `sr-only` title + bare unread number (aria-label unchanged, still spells it out). `room-code`, `player-count`, `scoreboard`, `chat-toggle` testids untouched (they live in `RoomCodePanel`/`Lobby`/`ScoreBoard`/`ChatPanel`, not this file). +- **#4** `WordInput.svelte`: suggestion/report moved onto their own `.fixes` row at a 36px floor; suggestion is accent-filled ("primary"), report stays outlined. Classes kept as `suggestion`/`report` (plus `fix`) so `word-input.test.js`'s selectors still match. `useSuggestion`/`report` logic untouched — still fill-only, field still uncontrolled. +- **#5** "Bí từ" and "Đầu hàng" now share one `.secondary` row, both `ArmedButton`, both always mounted while `phase === 'playing' && !iAmOut`, disabled off-turn via `ArmedButton`'s own disarm-on-disable behaviour rather than being mounted/unmounted. +- **#6** Spectating box (`.spectating`, `data-testid="eliminated"`, moved here from `PlayerStatus.svelte`) now shows `youAreOut` plus the elimination suggestions or `noSuggestions`; the duplicate banner in `PlayerStatus.svelte` (and its `.banner.gone` style) is removed. `game-shape.js`'s `Elimination` typedef gained `syllable`; `game-apply.js` captures `state.currentSyllable` into it at knockout. `GameOverPanel.svelte` now reads `elimination.syllable` instead of the (possibly-moved-on) `game.state.currentSyllable`. +- **#7** `rejectMessages[NOT_YOUR_TURN]` extended to name the race for a submitted word. `errorMessages.not_your_turn` rewritten for the resign/claim race and routed (`game-apply.js`) into `state.claimError` alongside `not_a_dead_end`, so it renders beside the secondary row rather than the top banner. +- **#8** `online/+page.svelte`: the seconds counter moved out of the `role="status"` paragraph into a sibling `aria-hidden` one; `quickMatchWaiting` copy dropped its embedded `{n}`. `quickMatchNudge`/`quickMatchNudgeLink` reworded to say the nudge leaves the queue. The nudge's `/play` link now reads `settings.state.lastDifficulty` (new store field, persisted to `localStorage`, set from the landing `DifficultyPicker`'s `onselect`), falling back to plain `/play` when unset. Added `quickMatchHint`/`createRoomHint` sub-labels under the two entry buttons. +- **#9** `ChainHistory.svelte`: `.parts` is now a `
    ` of pill `
  • `s (`aria-label={t.pointsBreakdown}`), rendered only when `index === 0 || open`. `pointKindLabels[SYLLABLES]` renamed `dài` → `từ dài`. +- **#10** `app.css`: ramp collapsed 9→7 with the review's exact values; `--accent-hover`/`--accent-pressed` added (light + dark) and wired into every accent-filled button (`WordInput`, `ChatPanel`, `Lobby`, `GameOverPanel`, both `routes/+page.svelte` and `routes/online/+page.svelte` primaries); light `--bg` lifted to `#eef3ef`. No webfont added — system stack kept; the syllable's old `1.6rem` literal and the countdown ring's `1.5rem` literal now read `var(--text-7)`/`var(--text-5)` per the review's table. Every existing `var(--text-N)` reference across all components was remapped in one pass (old 1/2→new 1, old 3/4/5→new 2, old 6→3, 7→4, 8→5, 9→6) so nothing changed size except the two literals above. The 34 counted `6px`/`10px`/`14px` spacing literals became `--space-2`/`--space-3`/`--space-4`. Left un-tokenized: the countdown ring's `1.75rem` urgent-value literal and `online/+page.svelte`'s `1.3rem` `h1` (no ramp step given for either in the review's table). +- **Copy** (`vi.js` + `app.html`): `howToPlay`/`howToPlayExample`, `openingCaption`, `difficultyHints` (new table, tested), `nicknameHint` (collapsed to one sentence), `onlineIntro` + `quickMatchHint`/`createRoomHint`, `quickMatchNudge`/`quickMatchNudgeLink`, `not_a_dead_end`, `winsLabel`/new `winsCompact` (split lobby vs. board), `need_more_players`, `opponentTurn`, `chatAuthorLeft`, meta description, `too_fast`, `pointKindLabels.SYLLABLES`, `pointsBreakdown`. `noBestScore` removed (superseded by `difficultyHints`). `spectating` key removed (superseded by the merged `youAreOut`). + +## Files touched + +`web/src/app.css`, `web/src/app.html`, `web/src/lib/i18n/vi.js`, `web/src/lib/stores/{game-apply,game-shape,settings.svelte}.js`, `web/src/lib/components/{AttributionFooter,ChainHistory,ChatPanel,ConnectionBadge,CountdownRing,DifficultyPicker,GameBoard,GameOverPanel,Lobby,NicknameInput,PlayerStatus,RoomCodePanel,ScoreBoard,ThemeToggle,WordInput}.svelte`, `web/src/routes/{+layout,+page,online/+page,rules/+page}.svelte`, `web/tests/{game-board,game-store,i18n,settings-store}.test.js`, `web/e2e/pvp-game.spec.js`. + +Not touched: `server/`, `proto/`, `web/src/lib/proto/`, `word-input.test.js`, `chat-panel.test.js` (no markup change needed in either — verified their selectors/assertions still hold against the new markup). + +## Tests added + +- `game-board.test.js`: "the persistent claim/resign row" — both buttons mounted and enabled on-turn; both stay mounted and become `disabled` (not removed) once the turn moves on. +- `game-store.test.js`: `not_your_turn` routes to `claimError`, not the top banner (mirrors the existing `not_a_dead_end` test); elimination captures the syllable the player was stuck on and keeps it after `currentSyllable` moves on. +- `i18n.test.js`: `difficultyHints` walked against `DifficultySchema` the same way `difficultyLabels` already is. +- `settings-store.test.js`: `lastDifficulty` starts `null`, persists across a reload, ignores a non-numeric stored value, survives hostile storage. + +## e2e (unverified — no browser on this host) + +Two `pvp-game.spec.js` text assertions changed to match the new copy and are **unverified**: +- `wins-p1`/`wins-p2` (Lobby): `'Tỉ số 1'` → `'Ván thắng 1'` (and `0`). +- `series-p2` (ScoreBoard): `'Tỉ số 1'` → `'Thắng 1'`. +- `lobby-error` too-fast check: `'Bạn thao tác quá nhanh'` → `'Thao tác quá nhanh'`. + +No other e2e-referenced testid was renamed (`room-code`, `player-count`, `scoreboard`, `chat-toggle`, `chat-pill`, `eliminated`, `player-out`, `turn-indicator`, `current-syllable`, `word-submit`, `standings`, `ready`/`wins-*`/`series-*` all kept). `eliminated` moved from `PlayerStatus.svelte`'s banner onto `GameBoard.svelte`'s merged spectating box — same attribute, same "is it visible" assertion in `pvp-game.spec.js:705`, not expected to need a spec change, but unverified since it cannot be run here. + +## Verification tail + +``` +npm run lint → 0 errors, 1 pre-existing warning (tests/room-code.test.js, unrelated) +npm run check → 384 files, 0 errors, 0 warnings +npm test → vite build succeeds; 16 test files, 266 tests passed +``` + +## Deferred (out of the requested scope) + +Everything in the review not named in the deliverable list was left alone by design (KISS — the review's other findings are recorded there for a future pass): the online entry's two-primary-button emphasis, `/online`'s missing rules link, rules-page `.toc`/`.back` sizing, wide-screen board width cap, urgent-clock board glow, game-over `finalScore`/🏆 `role="img"`, lobby seat-colour/kick-disabled-reason, chat timestamp/contrast tweaks, reconnect-stalled ring dash, and the two motion micro-interactions. + +## Unresolved questions + +1. `.ring.urgent .value`'s `1.75rem` and `online/+page.svelte`'s `h1` `1.3rem` are the two font-size literals the review flagged that have no explicit new-ramp mapping in its table (unlike the syllable's `1.6rem` and the ring's base `1.5rem`, which do). Left as literals rather than guessing a step. +2. `eliminated`'s e2e assertion (`pvp-game.spec.js:705`, `toBeVisible()` only, no text check) should still pass since the testid moved intact to the new merged box, but this is unverified without a browser.