Merge branch 'worktree-agent-a4990d1e576b04b80' into dev

This commit is contained in:
tiennm99 committed 2026-09-21 17:02:02 +07:00
commit 80ef632598
31 files changed
+739 -231

No files matched your search

@@ -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 `<ul>` of pill `<li>`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.
+4 -4
View File
@@ -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();
});
+26 -12
View File
@@ -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;
+4 -1
View File
@@ -14,7 +14,10 @@
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="description" content="Trò chơi nối từ tiếng Việt: chơi với máy hoặc đấu 1v1 trực tuyến." />
<meta
name="description"
content="Trò chơi nối từ tiếng Việt: chơi với máy hoặc đấu trực tuyến 2–4 người."
/>
<title>Nối Từ</title>
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<!--
@@ -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;
}
+33 -18
View File
@@ -99,24 +99,29 @@
{/if}
</span>
</button>
{#if entry.byMe && entry.typed && entry.typed !== entry.word}
{#if entry.opening}
<!-- A newcomer sees one grey word with nobody credited for it and
no reason given why it starts the chain. -->
<span class="corrected">{t.openingCaption}</span>
{:else if entry.byMe && entry.typed && entry.typed !== entry.word}
<!-- The server accepted a different spelling from the one typed.
Saying so beats silently rewriting the player's word. -->
<span class="corrected">
{fill(t.correctedFrom, { typed: entry.typed, word: entry.word })}
</span>
{/if}
{#if entry.parts.length}
{#if entry.parts.length && (index === 0 || open)}
<!-- Why the word scored what it did, not only that it did: the
client has no wordlist to re-derive this from, so the terms
travel with the total. -->
<p class="parts">
{entry.parts
.map((/** @type {import('$lib/stores/game.svelte.js').PointPart} */ p) =>
`+${p.value} ${pointKindLabels[p.kind] ?? ''}`
)
.join(' · ')}
</p>
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. -->
<ul class="parts" aria-label={t.pointsBreakdown}>
{#each entry.parts as p, partIndex (partIndex)}
<li>+{p.value} <span>{pointKindLabels[p.kind] ?? ''}</span></li>
{/each}
</ul>
{/if}
{#if open}
<!-- Plain text from the server, rendered as text: the builder
@@ -155,7 +160,7 @@
h2 {
margin: 0 0 var(--space-2);
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, which the
inherited 1.5 only just clears. */
@@ -174,7 +179,7 @@
.rows {
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--space-2);
margin: 0;
padding: 0;
/* overflow-y sets a flex item's automatic minimum size to 0, and the
@@ -266,7 +271,7 @@
}
.word {
font-size: var(--text-6);
font-size: var(--text-3);
font-weight: 600;
}
@@ -275,7 +280,7 @@
padding-left: calc(12px + 1.4em);
padding-right: 12px;
color: var(--text-muted);
font-size: var(--text-4);
font-size: var(--text-2);
line-height: 1.4;
}
@@ -292,12 +297,12 @@
display: inline-flex;
gap: 8px;
margin-left: auto;
font-size: var(--text-3);
font-size: var(--text-2);
}
.by {
color: var(--text-muted);
font-size: var(--text-3);
font-size: var(--text-2);
}
.badge {
@@ -316,14 +321,24 @@
.corrected {
padding: 0 12px 8px;
color: var(--text-muted);
font-size: var(--text-3);
font-size: var(--text-2);
}
.parts {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin: 0;
padding: 0 12px 8px;
list-style: none;
}
.parts li {
padding: 0 var(--space-2);
border-radius: var(--radius-pill);
background: var(--surface-alt);
color: var(--text-muted);
font-size: var(--text-2);
font-size: var(--text-1);
font-variant-numeric: tabular-nums;
}
</style>
+16 -7
View File
@@ -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 {
+12 -3
View File
@@ -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 @@
-->
<p class="badge" data-status={connection.status} aria-live="polite">
<span class="dot" aria-hidden="true"></span>
{label}
<span class:sr-only={compact && connection.status === Status.OPEN}>{label}</span>
</p>
<style>
@@ -27,11 +36,11 @@
align-items: center;
gap: 8px;
margin: 0;
padding: 4px 10px;
padding: 4px var(--space-3);
border-radius: var(--radius-pill);
background: var(--surface-alt);
color: var(--text-muted);
font-size: var(--text-4);
font-size: var(--text-2);
}
.dot {
+1 -1
View File
@@ -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;
}
+10 -5
View File
@@ -1,5 +1,5 @@
<script>
import { difficultyLabels, difficultyOrder, t } from '$lib/i18n/vi.js';
import { difficultyHints, difficultyLabels, difficultyOrder, t } from '$lib/i18n/vi.js';
import { settings } from '$lib/stores/settings.svelte.js';
/** @type {{ value: number, onselect?: (difficulty: number) => void }} */
@@ -28,7 +28,12 @@
}}
/>
<span class="name">{difficultyLabels[difficulty]}</span>
<span class="best">{t.bestScore}: {best > 0 ? best : t.noBestScore}</span>
<!-- What the rung does beats "chưa có" once there is no record to
show instead: a first-time player has nothing true to read in a
record line, but what the bot does at this rung always is. -->
<span class="best">
{best > 0 ? `${t.bestScore}: ${best}` : difficultyHints[difficulty]}
</span>
</label>
{/each}
</div>
@@ -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);
}
</style>
+98 -51
View File
@@ -65,12 +65,22 @@
<section class="board" data-phase={game.state.phase}>
<div class="top">
<ConnectionBadge />
<!-- Its text label is what crowded this row: "Đã kết nối" said nothing
a player needed while it stayed true, and the moment it stops
being true is exactly when the label earns its width back. -->
<ConnectionBadge compact />
<div class="meta">
{#if modeLabel}<span class="mode">{modeLabel}</span>{/if}
<!-- A new tab: this screen resigns or leaves the room when it unmounts,
so an in-page navigation to the rules would forfeit the game. -->
<a class="rules-link" href="/rules" target="_blank" rel="noopener">{t.rulesLink}</a>
so an in-page navigation to the rules would forfeit the game. A
44px glyph rather than the underlined text link used elsewhere:
at 360px "Luật chơi" was a third of the row on its own. -->
<a
class="icon-button"
href="/rules"
target="_blank"
rel="noopener"
aria-label={t.rulesLink}>?</a
>
{#if onchatopen}
<!-- Above the chain rather than below it, which is where this used
to live: the chain grows a row per turn, and a badge under it
@@ -90,9 +100,10 @@
: t.chatOpen}
data-testid="chat-pill"
>
{t.chatTitle}
<span aria-hidden="true">💬</span>
<span class="sr-only">{t.chatTitle}</span>
{#if chatUnread > 0}
<span class="pill-badge">{fill(t.chatUnread, { n: chatUnread })}</span>
<span class="pill-badge">{chatUnread}</span>
{/if}
</button>
{/if}
@@ -160,21 +171,44 @@
<span class="label">{t.currentSyllable}</span>
<strong data-testid="current-syllable">{game.state.currentSyllable || '…'}</strong>
</p>
<!-- The room code (or, in a bot game, the difficulty): a room fact
rather than a turn fact, so it sits under the prompt it used to
crowd in the header rather than beside the connection badge. -->
{#if modeLabel}<p class="mode">{modeLabel}</p>{/if}
</div>
</div>
<!-- A player who has been knocked out watches the rest of it: the chain,
the clock and the chat all keep working, and only the one thing
they can no longer do goes away. -->
they can no longer do goes away. The board said so twice before —
this box and a banner above the scoreboard — so the elimination
suggestions move in here rather than waiting for the game-over
screen, which a four-seat spectator can be minutes away from. -->
{#if game.iAmOut}
<p class="spectating">{t.spectating}</p>
<div class="spectating" role="status" data-testid="eliminated">
<p>{t.youAreOut}</p>
{#if game.state.elimination?.suggestions.length}
<p class="could">
{t.suggestionsTitle}: {game.state.elimination.suggestions.join(' · ')}
</p>
{:else if game.state.elimination}
<p class="could">
{fill(t.noSuggestions, { syllable: game.state.elimination.syllable })}
</p>
{/if}
</div>
{:else}
<WordInput {onsubmit} {onreportword} />
{#if game.state.myTurn}
<!-- Next to the input, not down by resign: a dead end is read off
the current syllable, which only means something on this
player's own turn — unlike resign, there is no "not yet" state
worth showing for it off turn. -->
{/if}
{/if}
<!-- One row, both controls always mounted: the "Bí từ" button used to
mount and unmount with every handover, shifting the input under a
player's thumb each turn — the same churn `.resign` was already
built to avoid. Disabled off-turn instead, which keeps the row's
height constant. -->
{#if game.state.phase === 'playing' && !game.iAmOut}
<div class="secondary">
<ArmedButton
class="claim-dead-end"
label={t.claimDeadEnd}
@@ -182,7 +216,14 @@
disabled={!canClaimDeadEnd}
onconfirm={onclaimdeadend}
/>
{/if}
<ArmedButton
class="resign"
label={t.resign}
confirmLabel={t.resignSure}
disabled={!canResign}
onconfirm={onresign}
/>
</div>
{#if game.state.claimError}
<p class="claim-error" role="alert">
{game.state.claimError}
@@ -195,20 +236,6 @@
</p>
{/if}
{/if}
{/if}
<!-- Above the chain, not below it: the chain is the one part of the board
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}
<ArmedButton
class="resign"
label={t.resign}
confirmLabel={t.resignSure}
disabled={!canResign}
onconfirm={onresign}
/>
{/if}
<ChainHistory />
</section>
@@ -239,20 +266,23 @@
}
.mode {
margin: var(--space-1) 0 0;
color: var(--text-muted);
font-size: var(--text-4);
font-size: var(--text-1);
}
.chat-pill {
display: inline-flex;
align-items: center;
gap: 6px;
gap: var(--space-1);
min-height: 32px;
min-width: 44px;
justify-content: center;
padding: 4px var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-pill);
background: var(--surface-alt);
font-size: var(--text-3);
font-size: var(--text-2);
font-weight: 600;
}
@@ -278,14 +308,14 @@
.who {
margin: 0 0 var(--space-1);
color: var(--text-muted);
font-size: var(--text-4);
font-size: var(--text-2);
}
/* 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-size: var(--text-3);
font-weight: 700;
}
@@ -298,13 +328,13 @@
.syllable .label {
margin-bottom: 2px;
color: var(--text-muted);
font-size: var(--text-2);
font-size: var(--text-1);
}
/* 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;
font-size: var(--text-7);
line-height: 1.35;
}
@@ -315,9 +345,9 @@
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);
font-size: var(--text-5);
font-size: var(--text-2);
}
.error {
@@ -339,11 +369,14 @@
margin: -8px 0;
background: var(--surface);
color: var(--text);
font-size: var(--text-4);
font-size: var(--text-2);
font-weight: 600;
}
.spectating {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
padding: var(--space-3);
border: 1px dashed var(--border);
@@ -352,15 +385,32 @@
text-align: center;
}
.spectating p {
margin: 0;
}
/* The one thing worth reading in this box once the news itself has sunk
in: what would have gotten this player out of the position that beat
them. */
.spectating .could {
color: var(--text);
}
/* Both controls on one row now, so alignment comes from the row rather
than from each button placing itself at an end of the column. */
.secondary {
display: flex;
justify-content: space-between;
gap: var(--space-2);
}
/* :global(): these are ArmedButton's own <button>, not one this
component's template renders directly, so Svelte's scoped-style
attribute never lands on it. */
/* Right of the board and away from the input: giving up is the one thing
here nobody should hit by accident while typing. Danger coloured because
it ends the game, subordinate because it is not the way to play it. */
/* Danger coloured because it ends the game, subordinate because it is not
the way to play it. */
:global(.resign) {
align-self: flex-end;
/* Below the 44px the rest of the controls keep, deliberately: this is
the one button here nobody is trying to hit, it takes two presses to
do anything, and at full size it read as an offer rather than as the
@@ -372,7 +422,7 @@
border-radius: var(--radius-sm);
background: transparent;
color: var(--danger);
font-size: var(--text-3);
font-size: var(--text-2);
transition: background-color 150ms ease-out;
}
@@ -395,19 +445,16 @@
font-weight: 600;
}
/* Beside the input rather than down with resign: a dead-end claim is about
the syllable on screen right now, so it reads as part of answering it
rather than as a way out of the game. Secondary weight either way — it
is not the way to play a turn, just a shortcut past an empty one. */
/* Secondary weight, same as resign: a dead-end claim is a shortcut past a
turn that cannot be answered, not the way to play one. */
:global(.claim-dead-end) {
align-self: flex-start;
min-height: 32px;
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font-size: var(--text-3);
font-size: var(--text-2);
transition: background-color 150ms ease-out;
}
@@ -432,10 +479,10 @@
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);
}
</style>
+25 -12
View File
@@ -106,8 +106,12 @@
</ul>
</div>
{:else}
<!-- The syllable this player was actually stuck on, not
whatever the game has moved on to since: by the time a
four-seat game ends, currentSyllable belongs to whoever
is playing now. -->
<p class="dead-end">
{fill(t.noSuggestions, { syllable: game.state.currentSyllable })}
{fill(t.noSuggestions, { syllable: elimination.syllable })}
</p>
{/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);
}
</style>
+24 -15
View File
@@ -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;
}
</style>
+5 -5
View File
@@ -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);
}
</style>
+3 -12
View File
@@ -59,11 +59,7 @@
}
</script>
{#if game.iAmOut}
<!-- The game carries on without this player, and saying so is the whole
difference between being knocked out and being disconnected. -->
<p class="banner gone" role="status" data-testid="eliminated">{t.youAreOut}</p>
{: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'}
<p class="banner" role="status" data-testid="player-out">{lastOutLabel}</p>
{/if}
@@ -81,16 +77,11 @@
<style>
.banner {
margin: 0;
padding: 10px var(--space-3);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-alt);
color: var(--warn);
font-size: var(--text-5);
font-size: var(--text-2);
text-align: center;
}
.banner.gone {
color: var(--danger);
background: var(--danger-soft);
}
</style>
+8 -8
View File
@@ -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 {
+4 -4
View File
@@ -1,5 +1,5 @@
<script>
import { t } from '$lib/i18n/vi.js';
import { fill, t } from '$lib/i18n/vi.js';
import { game } from '$lib/stores/game.svelte.js';
/**
@@ -38,7 +38,7 @@
<span class="score">{player.score}</span>
{#if series}
<span class="series" data-testid={`series-${player.playerId}`}>
{t.winsLabel} {game.winsOf(player.playerId)}
{fill(t.winsCompact, { n: game.winsOf(player.playerId) })}
</span>
{/if}
{#if player.rank === 1}
@@ -100,7 +100,7 @@
max-width: 100%;
overflow: hidden;
color: var(--text-muted);
font-size: var(--text-3);
font-size: var(--text-2);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -115,7 +115,7 @@
}
.score {
font-size: var(--text-7);
font-size: var(--text-4);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
+1 -1
View File
@@ -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;
}
+66 -19
View File
@@ -223,17 +223,26 @@
clock, with nothing to compare against. -->
<p class="rejection" id="word-rejection" role="alert">
<strong>{game.state.rejection.word}</strong> — {game.state.rejection.message}
</p>
{#if game.state.rejection.suggestion || game.state.rejection.reason === RejectReason.NOT_IN_DICTIONARY}
<!-- Their own line, at a real tap size: under the clock these are the
two most valuable taps on the board, and the pills they used to be
were under WCAG's 24px minimum. -->
<div class="fixes">
{#if game.state.rejection.suggestion}
<!-- Corrects typing, not vocabulary: the server only ever offers this
for a word that differs from a real one by diacritics alone. -->
<button type="button" class="suggestion" onclick={useSuggestion}>
<!-- Corrects typing, not vocabulary: the server only ever offers
this for a word that differs from a real one by diacritics
alone — primary weight because it is almost certainly the
word the player meant. -->
<button type="button" class="fix suggestion" onclick={useSuggestion}>
{fill(t.suggestionPrompt, { word: game.state.rejection.suggestion })}
</button>
{/if}
{#if game.state.rejection.reason === RejectReason.NOT_IN_DICTIONARY}
<button type="button" class="report" onclick={report}>{t.reportWord}</button>
<button type="button" class="fix report" onclick={report}>{t.reportWord}</button>
{/if}
</div>
{/if}
</p>
{/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);
}
</style>
+74 -20
View File
@@ -19,10 +19,22 @@ export const 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. Để trống sẽ được gọi là “Người chơi”.',
// The server truncating a name is an edge case it already shows by
// displaying the sanitized result — three sentences on a 360px screen was
// explaining it twice.
nicknameHint: 'Tối đa 20 ký tự. Để trống sẽ là “Người chơi”.',
nicknameNeeded: 'Nhập tên của bạn trước khi vào phòng.',
// The chain rule itself, read once on the landing screen rather than first
// learned from a rejection under a running clock. The turn length is a
// server constant not known here, so "có đồng hồ" rather than a number.
howToPlay:
'Tiếng đầu của từ bạn phải là tiếng cuối của từ trước — {example}. Mỗi lượt có đồng hồ.',
howToPlayExample: 'ngôn ngữ → ngữ pháp → pháp luật',
// The opening word's own row in the chain: a newcomer sees one grey word
// with nobody credited for it and no reason given why it starts there.
openingCaption: 'Từ mở đầu — nối tiếp bằng tiếng cuối',
playBot: 'Chơi với máy',
playOnline: 'Đấu trực tuyến',
difficultyLabel: 'Độ khó',
@@ -49,7 +61,10 @@ export const t = {
suggestionPrompt: 'Ý bạn là “{word}”?',
yourTurn: 'Đến lượt bạn',
opponentTurn: 'Đối thủ đang suy nghĩ…',
// The fallback for an online turn whose name has not arrived yet — a bot
// game always has one, so this reads as "some other player" rather than
// naming an opponent that may not be singular.
opponentTurn: 'Đang chờ người chơi khác…',
playerTurn: 'Đến lượt {name}…',
currentSyllable: 'Nối tiếp tiếng',
wordInputPlaceholder: 'Nhập từ của bạn',
@@ -90,24 +105,33 @@ export const t = {
exportHistory: 'Tải chuỗi từ',
exportOpening: 'từ mở đầu',
bestScore: 'Kỷ lục',
noBestScore: 'Chưa có',
// The chain row's score-breakdown chips, named for a screen reader since
// the chips themselves carry no heading of their own.
pointsBreakdown: 'Cách tính điểm',
theme: 'Giao diện',
themeLight: 'Sáng',
themeDark: 'Tối',
onlineTitle: 'Đấu trực tuyến',
onlineIntro: 'Tạo phòng rồi gửi mã cho bạn bè, hoặc nhập mã bạn được mời.',
onlineIntro: 'Chơi ngay với người lạ, tạo phòng cho bạn bè, hoặc nhập mã bạn được mời.',
createRoom: 'Tạo phòng',
joinRoom: 'Vào phòng',
// Sub-labels under the two ways into an online game, since "Chơi ngay" and
// "Tạo phòng" alone do not say what each actually starts.
quickMatchHint: 'Ghép với người đang chờ, ván đầu tự bắt đầu',
createRoomHint: 'Nhận mã sáu ký tự để gửi cho bạn bè',
// Quick-match: paired with the next stranger who also asked, no code
// exchanged.
quickMatch: 'Chơi ngay',
quickMatchWaiting: 'Đang tìm đối thủ… {n} giây',
// The searching state announced once; the running seconds are shown beside
// it but kept out of the live region, or a screen reader would hear "n
// giây" every second.
quickMatchWaiting: 'Đang tìm đối thủ…',
quickMatchCancel: 'Hủy tìm trận',
quickMatchNudge: 'Chưa có ai chờ…',
quickMatchNudgeLink: 'Chơi với máy trong lúc đợi?',
quickMatchNudge: 'Chưa có ai chờ.',
quickMatchNudgeLink: 'Chơi với máy trong lúc đợi (sẽ rời hàng chờ)',
roomCodeLabel: 'Mã phòng',
roomCodePlaceholder: 'VD: K7M2QP',
roomCodeHint: 'Sáu ký tự. Không phân biệt hoa thường.',
@@ -128,11 +152,17 @@ export const t = {
chatPlaceholder: 'Nhắn tin…',
chatEmpty: 'Chưa có tin nhắn nào.',
chatUnread: '{n} tin mới',
chatAuthorLeft: 'Đã rời phòng',
// Parenthesized rather than a second clause: "Đã rời phòng: hello" reads
// as a sentence about the message, not a name standing in for one.
chatAuthorLeft: '(đã rời phòng)',
// The series score: how many games each player has won since the room
// opened, which is a room fact rather than a game one.
winsLabel: 'Tỉ số',
// opened, which is a room fact rather than a game one — distinct from the
// per-game score sitting right beside it, which used to share this label.
winsLabel: 'Ván thắng',
// The same figure, compact, on the board itself: "Ván thắng 1" beside a
// per-game score reading "12" was two numbers both called scores.
winsCompact: 'Thắng {n}',
lobbyTitle: 'Phòng chờ',
owner: 'Chủ phòng',
@@ -158,8 +188,10 @@ export const t = {
playerDisconnected: '{name} mất kết nối…',
playerDisconnectedIn: '{name} mất kết nối… ({n}s)',
// Said once, in the board's own spectating box, alongside whatever the
// position that knocked this player out still had left in it — a second
// banner used to say the same thing 60px above it.
youAreOut: 'Bạn đã bị loại. Ván đấu vẫn đang tiếp tục.',
spectating: 'Bạn đang xem ván đấu.',
playerOut: '{name} đã bị loại.',
// Left rather than knocked out: the seat is gone from the room, not just
// out of the game, so the others are not waiting for anybody.
@@ -236,6 +268,18 @@ export const difficultyLabels = {
/** The three difficulties offered on the home screen, in ladder order. */
export const difficultyOrder = [Difficulty.EASY, Difficulty.MEDIUM, Difficulty.HARD];
/**
* What each rung's bot actually does, shown in place of the record line until
* there is a record to show instead — server/internal/bot/strategy_*.go is
* the source of truth this describes.
* @type {Record<number, string>}
*/
export const difficultyHints = {
[Difficulty.EASY]: 'Máy chọn từ ngẫu nhiên',
[Difficulty.MEDIUM]: 'Máy chọn từ tốt nhất trước mắt',
[Difficulty.HARD]: 'Máy tính trước vài nước, hay dồn bí từ'
};
/**
* Why a word was refused. `{syllable}` is filled from the syllable the server
* is currently asking for — the reason alone does not say which one it was.
@@ -247,7 +291,11 @@ export const rejectMessages = {
[RejectReason.WRONG_LINK]: 'Từ phải bắt đầu bằng tiếng “{syllable}”.',
[RejectReason.NOT_IN_DICTIONARY]: 'Không tìm thấy từ này trong từ điển.',
[RejectReason.ALREADY_USED]: 'Từ này đã được dùng rồi.',
[RejectReason.NOT_YOUR_TURN]: 'Chưa đến lượt bạn.',
// Both callers submit only on the player's own turn, so this reason only
// ever answers a race where the turn moved on between the tap and the
// server's reply — "chưa đến lượt" alone reads as "not yet" about a turn
// that has in fact already passed.
[RejectReason.NOT_YOUR_TURN]: 'Chưa đến lượt bạn — từ này chưa được gửi.',
[RejectReason.TIMEOUT]: 'Hết giờ!',
[RejectReason.GAME_OVER]: 'Ván đấu đã kết thúc.'
};
@@ -260,7 +308,9 @@ export const rejectMessages = {
export const pointKindLabels = {
[PointKind.BASE]: 'nền',
[PointKind.CHAIN]: 'chuỗi',
[PointKind.SYLLABLES]: 'dài',
// "dài" alone is not a phrase; "+5 dài" read as a stray adjective rather
// than a bonus for a long word.
[PointKind.SYLLABLES]: 'từ dài',
[PointKind.SPEED]: 'nhanh',
[PointKind.RARITY]: 'hiếm'
};
@@ -298,16 +348,20 @@ export const errorMessages = {
kicked: 'Bạn đã bị mời ra khỏi phòng.',
cannot_kick_self: 'Bạn không thể tự mời mình ra khỏi phòng.',
must_unready_first: 'Hãy bỏ sẵn sàng trước khi rời phòng.',
need_more_players: 'Cần ít nhất hai người chơi mới bắt đầu được.',
not_a_dead_end: 'Vẫn còn từ nối được. Hãy thử lại.',
// "Hai" was the server's own minimum leaking into prose; a room can need
// more than that, and ownerNeedsMore already says the real number.
need_more_players: 'Chưa đủ người để bắt đầu.',
not_a_dead_end: 'Vẫn còn từ nối được với tiếng này. Nghĩ thêm chút nữa!',
not_everyone_ready: 'Vẫn còn người chưa sẵn sàng.',
not_in_a_game: 'Bạn không ở trong ván đấu nào.',
not_in_a_room: 'Bạn không ở trong phòng nào.',
not_the_owner: 'Chỉ chủ phòng làm được việc này.',
not_your_seat: 'Bạn không phải người chơi trong ván này.',
// Shared by resigning and claiming a dead end: both are things only the
// player to act may spend.
not_your_turn: 'Chưa đến lượt bạn.',
// Shared by resigning and claiming a dead end: both are gated on the
// player's own turn client-side, so this can only answer a race where the
// turn moved on between the tap and the server's reply — never a plain
// "wait your turn". Shown beside the button, not in the top banner.
not_your_turn: 'Lượt vừa chuyển đi, thao tác này không còn hiệu lực.',
no_one_to_kick: 'Chưa có ai trong phòng để mời ra.',
player_offline: 'Vẫn còn người đang mất kết nối.',
owner_needs_no_ready: 'Chủ phòng không cần bấm sẵn sàng.',
@@ -320,7 +374,7 @@ export const errorMessages = {
server_full: 'Máy chủ đang quá tải. Hãy thử lại sau ít phút.',
server_restarting: 'Máy chủ đang khởi động lại. Hãy thử lại sau giây lát.',
session_not_resumable: 'Không khôi phục được ván đấu trước.',
too_fast: 'Bạn thao tác quá nhanh. Chậm lại một chút nhé.',
too_fast: 'Thao tác quá nhanh. Đợi một chút rồi thử lại.',
too_many_attempts: 'Bạn thử vào phòng quá nhiều lần. Hãy đợi một lát.',
too_many_rooms: 'Bạn tạo phòng quá nhanh. Hãy đợi một lát.',
unknown_difficulty: 'Độ khó không hợp lệ.',
+9 -5
View File
@@ -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;
}
+5 -2
View File
@@ -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.
+29 -1
View File
@@ -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<string, number>} */
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}
+1 -1
View File
@@ -71,7 +71,7 @@
.brand {
color: inherit;
font-size: var(--text-7);
font-size: var(--text-4);
font-weight: 700;
text-decoration: none;
}
+29 -6
View File
@@ -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. -->
<h1 class="tagline">{t.tagline}</h1>
<!-- The rule itself, before the nickname or the ladder: a newcomer's first
read of it used to be a rejection message under a running clock. -->
<p class="how">
{fill(t.howToPlay, { example: t.howToPlayExample })}
</p>
<NicknameInput />
<DifficultyPicker bind:value={difficulty} />
<DifficultyPicker bind:value={difficulty} onselect={selectDifficulty} />
<div class="actions">
<button type="button" class="primary" onclick={playBot}>{t.playBot}</button>
@@ -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
+55 -13
View File
@@ -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}
<!-- The wait itself. Ends on its own — RoomState replaces this whole
branch the moment a match is found — so the only button here is
the way out. -->
<div class="waiting" role="status">
<p>{fill(t.quickMatchWaiting, { n: session.state.queuedForS })}</p>
the way out. The running seconds sit outside the live region:
inside it, a screen reader announced "n giây" on every tick. -->
<div class="waiting">
<p role="status" aria-live="polite">{t.quickMatchWaiting}</p>
<p class="counter" aria-hidden="true">
{fill(t.secondsLeft, { n: session.state.queuedForS })}
</p>
<button type="button" onclick={cancelQueue}>{t.quickMatchCancel}</button>
{#if session.state.queuedForS >= QUICK_MATCH_NUDGE_S}
<p class="hint">
{t.quickMatchNudge}
<a href="/play">{t.quickMatchNudgeLink}</a>
<a href={quickMatchNudgeHref}>{t.quickMatchNudgeLink}</a>
</p>
{/if}
</div>
@@ -555,6 +565,7 @@
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. -->
<div class="choice">
<button
type="button"
class="primary"
@@ -563,10 +574,15 @@
>
{session.state.pending?.kind === 'quickMatch' ? t.connecting : t.quickMatch}
</button>
<p class="choice-hint">{t.quickMatchHint}</p>
</div>
<div class="choice">
<button type="button" class="primary" disabled={!!session.state.pending} onclick={create}>
{session.state.pending?.kind === 'create' ? t.connecting : t.createRoom}
</button>
<p class="choice-hint">{t.createRoomHint}</p>
</div>
<form
class="join"
@@ -695,14 +711,35 @@
color: var(--text-muted);
}
.choice {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.choice-hint {
margin: 0;
color: var(--text-muted);
font-size: var(--text-1);
}
.primary {
min-height: 44px;
padding: 14px;
padding: var(--space-4);
border: 0;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
transition: background-color 150ms ease-out;
}
.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.primary:active:not(:disabled) {
background: var(--accent-pressed);
}
.primary:disabled {
@@ -713,7 +750,7 @@
.join {
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--space-2);
}
.waiting {
@@ -731,6 +768,11 @@
margin: 0;
}
.waiting .counter {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.waiting button {
min-height: 44px;
padding: var(--space-3) var(--space-4);
@@ -742,7 +784,7 @@
label {
font-weight: 600;
font-size: var(--text-5);
font-size: var(--text-2);
}
.row {
@@ -753,11 +795,11 @@
input {
flex: 1;
min-width: 0;
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);
letter-spacing: 0.1em;
text-transform: uppercase;
}
@@ -778,7 +820,7 @@
.hint {
margin: 0;
color: var(--text-muted);
font-size: var(--text-3);
font-size: var(--text-2);
}
.hint.invalid {
@@ -788,9 +830,9 @@
.error,
.notice {
margin: 0;
padding: 10px var(--space-3);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--text-5);
font-size: var(--text-2);
}
.error {
@@ -806,6 +848,6 @@
.back {
align-self: flex-start;
color: var(--text-muted);
font-size: var(--text-5);
font-size: var(--text-2);
}
</style>
+4 -4
View File
@@ -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);
}
</style>
+53
View File
@@ -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);
});
});
+48
View File
@@ -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();
});
});
+9
View File
@@ -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', () => {
+23
View File
@@ -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', () => {