diff --git a/web/src/lib/components/ConnectionBadge.svelte b/web/src/lib/components/ConnectionBadge.svelte index 33ad04a..8bcc1b5 100644 --- a/web/src/lib/components/ConnectionBadge.svelte +++ b/web/src/lib/components/ConnectionBadge.svelte @@ -2,6 +2,15 @@ import { t } from '$lib/i18n/vi.js'; import { Status, connection } from '$lib/ws/connection.svelte.js'; + /** + * `compact` hides the label's text while the connection is open, keeping + * only the dot — that is the state a board's header has no room to spell + * out every time, and the one that needs no announcement anyway. Any other + * status still shows its text: that is exactly when it matters. + * @type {{ compact?: boolean }} + */ + let { compact = false } = $props(); + const label = $derived( { [Status.CONNECTING]: t.connecting, @@ -18,7 +27,7 @@ -->

- {label} + {label}

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

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

{/if} {/if} @@ -146,7 +150,7 @@ h2 { margin: 0; color: var(--danger); - font-size: var(--text-7); + font-size: var(--text-4); } h2.won { @@ -171,9 +175,9 @@ .standings li { display: flex; align-items: baseline; - gap: 10px; + gap: var(--space-3); padding: var(--space-1) var(--space-3); - font-size: var(--text-5); + font-size: var(--text-2); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-alt); @@ -202,7 +206,7 @@ .points { margin-left: auto; color: var(--text-muted); - font-size: var(--text-4); + font-size: var(--text-2); font-variant-numeric: tabular-nums; } @@ -226,12 +230,12 @@ dt { color: var(--text-muted); - font-size: var(--text-4); + font-size: var(--text-2); } dd { margin: 0; - font-size: var(--text-5); + font-size: var(--text-2); font-weight: 700; font-variant-numeric: tabular-nums; } @@ -243,13 +247,13 @@ flex-wrap: wrap; align-items: baseline; justify-content: center; - gap: 6px var(--space-2); + gap: var(--space-2) var(--space-2); } .suggestions h3 { margin: 0; color: var(--text-muted); - font-size: var(--text-3); + font-size: var(--text-2); font-weight: 600; /* Uppercase Vietnamese stacks a tone mark above a capital. */ line-height: 1.6; @@ -261,7 +265,7 @@ display: flex; flex-wrap: wrap; justify-content: center; - gap: 6px; + gap: var(--space-2); margin: 0; padding: 0; list-style: none; @@ -282,7 +286,7 @@ .record { margin: 0; - padding: 6px var(--space-3); + padding: var(--space-2) var(--space-3); border-radius: var(--radius-pill); background: var(--accent-soft); color: var(--accent); @@ -312,12 +316,21 @@ border-color: transparent; background: var(--accent); color: var(--accent-text); + transition: background-color 150ms ease-out; + } + + .actions .primary:hover { + background: var(--accent-hover); + } + + .actions .primary:active { + background: var(--accent-pressed); } /* Keeping the chain is worth offering and not worth pressing first. */ .actions .export { background: transparent; color: var(--text-muted); - font-size: var(--text-5); + font-size: var(--text-2); } diff --git a/web/src/lib/components/PlayerStatus.svelte b/web/src/lib/components/PlayerStatus.svelte index a36c59e..326ba2a 100644 --- a/web/src/lib/components/PlayerStatus.svelte +++ b/web/src/lib/components/PlayerStatus.svelte @@ -59,11 +59,7 @@ } -{#if game.iAmOut} - - -{:else if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'} +{#if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'} {/if} @@ -81,16 +77,11 @@ diff --git a/web/src/lib/stores/game-apply.js b/web/src/lib/stores/game-apply.js index a954241..bf9e1bf 100644 --- a/web/src/lib/stores/game-apply.js +++ b/web/src/lib/stores/game-apply.js @@ -169,7 +169,10 @@ export function applyTo(state, msg, { reset, leave }) { playerId: value.playerId, name: value.name, reason: value.reason, - suggestions: value.suggestions ?? [] + suggestions: value.suggestions ?? [], + // Captured now: the game keeps running without this + // player, and currentSyllable moves on with it. + syllable: state.currentSyllable }; } break; @@ -230,10 +233,11 @@ export function applyTo(state, msg, { reset, leave }) { // the model has to stop describing one. Set after, because // leaving clears everything including the message. if (value.code === 'kicked' || value.code === 'room_idle_closed') leave(); - // A false dead-end claim is answered next to the input, not in - // the top banner: it is about the move just attempted, not a - // room-wide condition every screen has to show. - if (value.code === 'not_a_dead_end') { + // A false dead-end claim, and a resign or claim that raced the + // turn moving on, are both answered next to the button that sent + // them, not in the top banner: each is about the move just + // attempted, not a room-wide condition every screen has to show. + if (value.code === 'not_a_dead_end' || value.code === 'not_your_turn') { state.claimError = errorMessage(value.code); break; } diff --git a/web/src/lib/stores/game-shape.js b/web/src/lib/stores/game-shape.js index d326883..8835189 100644 --- a/web/src/lib/stores/game-shape.js +++ b/web/src/lib/stores/game-shape.js @@ -63,7 +63,7 @@ * @property {number} rank - final placing, 1 for the winner; 0 while in play */ -/** @typedef {{ playerId: string, name: string, reason: number, suggestions: string[] }} Elimination */ +/** @typedef {{ playerId: string, name: string, reason: number, suggestions: string[], syllable: string }} Elimination */ /** @typedef {{ playerId: string, name: string, isMe: boolean, reason: number }} LastOut */ /** @typedef {{ n: number, fromMe: boolean, playerId: string, author: string, text: string, atMs: number }} ChatLine */ /** @typedef {{ word: string, message: string, reason: number, suggestion: string }} Rejection */ @@ -110,7 +110,10 @@ * player left standing, then the rest in reverse order of elimination. * @property {Elimination | null} elimination - This player's own knockout, * and nobody else's. Empty `suggestions` means it was a dead end, which is - * a different thing to say than "here is what you missed". + * a different thing to say than "here is what you missed". `syllable` is + * the one this player was stuck on, captured at the moment of knockout — + * `currentSyllable` moves on with the game that keeps running without + * them, so reading it later would name somebody else's syllable. * @property {LastOut | null} lastOut - The last player to go out, whoever * they were: what a spectator is shown. The client's own knockout is * `elimination` above. diff --git a/web/tests/game-board.test.js b/web/tests/game-board.test.js index f75214d..5b7921c 100644 --- a/web/tests/game-board.test.js +++ b/web/tests/game-board.test.js @@ -101,3 +101,56 @@ describe('the chat pill', () => { unmount(component); }); }); + +describe('the persistent claim/resign row', () => { + // "Bí từ" used to mount only on the player's own turn, shifting everything + // under the input each handover. Both controls now stay mounted for the + // whole game and are disabled off-turn instead, so the row's height never + // changes turn to turn. + it('keeps both buttons mounted, enabled, on the player\'s own turn', () => { + const { target, component } = renderGameBoard(); + + /** @type {HTMLButtonElement | null} */ + const claim = target.querySelector('.claim-dead-end'); + /** @type {HTMLButtonElement | null} */ + const resign = target.querySelector('.resign'); + + expect(claim).not.toBeNull(); + expect(resign).not.toBeNull(); + expect(claim?.disabled).toBe(false); + expect(resign?.disabled).toBe(false); + unmount(component); + }); + + it('disables rather than unmounts both once the turn moves on', () => { + const { target, component } = renderGameBoard(); + + game.apply( + create(ServerMessageSchema, { + payload: { + case: 'turnUpdate', + value: { + currentSyllable: 'yên', + myTurn: false, + turnSeq: 2, + chainLength: 1, + players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }], + turnPlayerId: 'p2' + } + } + }) + ); + flushSync(); + + /** @type {HTMLButtonElement | null} */ + const claim = target.querySelector('.claim-dead-end'); + /** @type {HTMLButtonElement | null} */ + const resign = target.querySelector('.resign'); + + expect(claim).not.toBeNull(); + expect(resign).not.toBeNull(); + expect(claim?.disabled).toBe(true); + expect(resign?.disabled).toBe(true); + unmount(component); + }); +}); diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js index 74228a5..d34b0b3 100644 --- a/web/tests/game-store.test.js +++ b/web/tests/game-store.test.js @@ -404,6 +404,38 @@ describe('playerEliminated', () => { expect(store.state.myTurn).toBe(false); }); + it('captures the syllable this player was stuck on, not whatever the game moves on to', () => { + // currentSyllable keeps changing for the players still in the game; + // the elimination has to freeze its own copy at the moment it happens + // or a later reader (the game-over screen, in a four-seat room) would + // name somebody else's syllable. + const store = createGameStore(); + store.apply(started()); + store.apply( + msg('playerEliminated', { + playerId: 'p1', + isMe: true, + reason: GameEndReason.NO_LEGAL_MOVE + }) + ); + expect(store.state.elimination?.syllable).toBe(store.state.currentSyllable); + + const stuckOn = store.state.currentSyllable; + store.apply( + msg('turnUpdate', { + currentSyllable: 'khác', + myTurn: false, + turnSeq: 2, + chainLength: 1, + players: table(), + turnPlayerId: 'p2' + }) + ); + + expect(store.state.currentSyllable).toBe('khác'); + expect(store.state.elimination?.syllable).toBe(stuckOn); + }); + it('reads an absent list as a position that had nothing left', () => { // A dead end arrives as an empty list, which means "no words to offer" // rather than undefined. @@ -933,3 +965,19 @@ describe('dead-end claim', () => { expect(store.state.claimError).toBeNull(); }); }); + +describe('a resign or claim that raced the turn moving on', () => { + // The server can only answer resign or claim-dead-end with not_your_turn + // when the turn already left before the request arrived — both are + // gated on the player's own turn client-side. That is the same kind of + // news as a false dead-end claim: about the move just attempted, not the + // room, so it answers beside the button rather than in the top banner. + it('answers inline rather than in the general error banner', () => { + const store = createGameStore(); + store.apply(started()); + store.apply(msg('error', { code: 'not_your_turn', message: '' })); + + expect(store.state.claimError).not.toBeNull(); + expect(store.state.error).toBeNull(); + }); +});