feat(web): slim the board header and stop the claim/resign row from churning

At 360px the header used to open on two rows of chrome: the connection badge
said "Đã kết nối" in full while it was true, "Luật chơi" was a full-width
text link, and the room code sat crowded in between. The badge now shows
only its dot once open, the rules link is a 44px icon with an aria-label,
and the room code moves under the turn prompt it used to sit beside. "Bí từ"
used to mount and unmount every handover, shifting the input under a
player's thumb each turn; it now sits in one persistent row with "Đầu hàng",
both built on ArmedButton and disabled off-turn rather than unmounted.

A resign or claim raced by the turn moving on now answers beside the button
that sent it instead of the top banner, matching how a false dead-end claim
was already handled. Knockout stops saying "you're out" twice — once in a
banner above the scoreboard, once in the spectating box below it — and the
elimination suggestions a spectator used to wait for the game-over screen to
see now show up the moment they're eliminated, keyed to the syllable they
were actually stuck on rather than whatever the game has moved on to since.
This commit is contained in:
tiennm99 committed 2026-09-21 16:59:13 +07:00
1 parent 81d99bb0cf
commit 4fe7ef4f11
8 files changed
+267 -99

No files matched your search

+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 {
+112 -65
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,54 +171,70 @@
<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. -->
<ArmedButton
class="claim-dead-end"
label={t.claimDeadEnd}
confirmLabel={t.claimDeadEndSure}
disabled={!canClaimDeadEnd}
onconfirm={onclaimdeadend}
/>
{/if}
{#if game.state.claimError}
<p class="claim-error" role="alert">
{game.state.claimError}
<button
type="button"
class="icon-button"
onclick={() => game.clearClaimError()}
aria-label={t.dismiss}>×</button
>
</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. -->
<!-- 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}
<ArmedButton
class="resign"
label={t.resign}
confirmLabel={t.resignSure}
disabled={!canResign}
onconfirm={onresign}
/>
<div class="secondary">
<ArmedButton
class="claim-dead-end"
label={t.claimDeadEnd}
confirmLabel={t.claimDeadEndSure}
disabled={!canClaimDeadEnd}
onconfirm={onclaimdeadend}
/>
<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}
<button
type="button"
class="icon-button"
onclick={() => game.clearClaimError()}
aria-label={t.dismiss}>×</button
>
</p>
{/if}
{/if}
<ChainHistory />
@@ -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>
+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>
+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.
+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();
});
});