mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
Merge branch 'worktree-agent-a4990d1e576b04b80' into dev
This commit is contained in:
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.
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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ệ.',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
.brand {
|
||||
color: inherit;
|
||||
font-size: var(--text-7);
|
||||
font-size: var(--text-4);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user