Files
noitu/web/src/lib/components/GameOverPanel.svelte
T
tiennm99 db7093bbe5 fix(web): make the game comfortable to play on a phone and in both themes
Applies the findings of the three UI/UX reviews. The two that cost a
player something every single turn:

- The countdown reddened during the opponent's turn. It read the deadline,
  which the server rewrites on every handover, and never asked whose turn
  it was — a panic spike, every turn, about nothing the player could act
  on. It now mutes when the clock is not theirs, and signals urgency by
  thickening as well as reddening, because green to red is the pair a
  red-green colour blindness cannot separate.

- On a phone the keyboard closed on every turn and could not be reopened.
  `disabled` on the focused word field blurs it, and the recovering
  focus() ran on a WebSocket message, which iOS will not open a keyboard
  for. The field stays editable and the submit button carries the turn
  instead; focus is asked for inside the gesture that submitted. A draft
  aimed at a syllable the game has moved past is now corrected rather
  than kept.

The board was also not holding together as the game grew. `min-height`
on the shell left nothing with a definite height to shrink against, so
the chain kept its full height and the page got taller every turn,
walking the word field and the chat off the bottom of the screen. The
shell now has a real height, the chain gives up its space first with a
floor so it cannot vanish, and `main` scrolls only as a last resort.

Online play had one true dead end: nothing on the pre-room screen showed
the connection at all, so a server that was down left an enabled button
and a screen that never changed — while each impatient tap sent a real
CreateRoom until the rate limiter answered. The badge is there now,
requests disable their button, a stall says so, and the lobby draws the
room's refusals beside the button that produced them instead of in a
chat panel below the fold. A dropped player gets a retry rather than
waiting out the backoff. An invite link asks a first-time guest for a
name instead of seating them as "Người chơi" with no way to fix it.

Accessibility and palette:

- The turn change is announced. It is the state the whole game hangs on
  and it reached nobody who was not looking at that line.
- --accent darkened to #12692f: accent text on its own tint measured
  4.29:1. Every existing pair improves, white-on-accent included.
- --border-strong added for the boundary of anything you type into or
  press, which needs 3:1 and had 1.47:1 light, 1.78:1 dark.
- One focus ring, one 44px icon-button recipe, spacing and type scales,
  safe-area gutters, per-route titles, a heading per screen, a skip
  link, and reduced motion that stops the connection dot pulsing rather
  than flickering it a hundred thousand times a frame.

Tests follow the two interactions that changed shape: resign and kick
confirm inline with a second press, because confirm() blocks the frame
loop the countdown runs on and hesitating could cost the turn it was
protecting. joinRoomSeated waits for the seat before anybody speaks —
the server replays a seat only the conversation from when it joined, so
the old helper was racing the handshake.

Verified: 189 unit tests, 40 end-to-end, no type errors, and the
palette and layout measured at 360/400/768/1280 in both themes.
2026-09-10 13:04:13 +07:00

298 lines
6.9 KiB
Svelte

<script>
import { chainToText, downloadText, historyFilename } from '$lib/history-export.js';
import { endReasonMessages, fill, t } from '$lib/i18n/vi.js';
import { game } from '$lib/stores/game.svelte.js';
/**
* onrematch is optional because the two modes differ: a bot always plays
* again, so the button starts the next game, while online play agrees the
* next one in the lobby this panel appears above.
*
* @type {{ isRecord: boolean, onrematch?: () => void, onhome: () => void }}
*/
let { isRecord, onrematch, onhome } = $props();
/** @type {HTMLElement | undefined} */
let panel = $state();
const result = $derived(game.state.result);
const standings = $derived(game.state.standings);
// What the position still had when this player lost it. It arrives with
// their knockout rather than with the result, because by the time a game
// with four people in it ends, the position that beat them is nobody
// else's position.
const elimination = $derived(game.state.elimination);
// The word field unmounts when the game ends, which drops focus to the top
// of the document: a keyboard player tabs through the header and the badge
// to reach "Chơi lại", and a screen reader is told nothing at all, because
// role="group" is not announced on insertion.
//
// The panel takes focus, not the rematch button. A player who just pressed
// Enter to submit a word may still be holding it, and a focused button
// under that key would start the next game before they had read this one.
$effect(() => {
if (game.state.result) panel?.focus();
});
/** Hands the finished chain to the player as a text file to keep. */
function exportHistory() {
const at = new Date();
const text = chainToText({
chain: game.state.chain,
result,
nameOf: (id) => game.nameOf(id),
at
});
downloadText(historyFilename(at), text);
}
</script>
{#if result}
<div
class="panel"
bind:this={panel}
role="group"
tabindex="-1"
aria-label={result.iWon ? t.won : t.lost}
>
<h2 class:won={result.iWon} aria-live="polite">{result.iWon ? t.won : t.lost}</h2>
{#if endReasonMessages[result.reason]}
<p class="reason">{endReasonMessages[result.reason]}</p>
{/if}
{#if standings.length > 2}
<!-- Ranked by who outlasted whom, which is what the game is decided
on. The score sits beside the place rather than setting it. -->
<ol class="standings" aria-label={t.standingsTitle} data-testid="standings">
{#each standings as player (player.playerId)}
<li class:me={player.isMe} class:winner={player.rank === 1}>
<span class="rank">{player.rank}</span>
<span class="name">
{player.isMe ? game.state.nickname || t.you : player.name || t.someone}
</span>
<span class="points">{player.score} {t.pointsUnit}</span>
{#if player.rank === 1}<span class="trophy" aria-label={t.winnerBadge}>🏆</span>{/if}
</li>
{/each}
</ol>
{/if}
<dl class="stats">
<div>
<dt>{t.finalScore}</dt>
<dd>{result.myScore}</dd>
</div>
<div>
<dt>{t.chainLength}</dt>
<dd>{result.chainLength}</dd>
</div>
</dl>
{#if elimination}
<!-- Only the player who was stuck is sent this, and only they were
stuck. Losing without ever learning what the position wanted is
the part that stings; an empty list says the position had
nothing, which is worth hearing too. -->
{#if elimination.suggestions.length > 0}
<div class="suggestions">
<h3>{t.suggestionsTitle}</h3>
<ul>
{#each elimination.suggestions as word}
<li>{word}</li>
{/each}
</ul>
</div>
{:else}
<p class="dead-end">
{fill(t.noSuggestions, { syllable: game.state.currentSyllable })}
</p>
{/if}
{/if}
{#if isRecord}
<p class="record">{t.newRecord}</p>
{/if}
<div class="actions">
{#if onrematch}
<button type="button" class="primary" onclick={onrematch}>{t.rematch}</button>
{/if}
<button type="button" onclick={onhome}>{t.home}</button>
</div>
<button type="button" class="export" onclick={exportHistory}>{t.exportHistory}</button>
</div>
{/if}
<style>
.panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow);
text-align: center;
}
h2 {
margin: 0;
color: var(--danger);
font-size: var(--text-8);
}
h2.won {
color: var(--accent);
}
.reason {
margin: 0;
color: var(--text-muted);
}
.standings {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
text-align: left;
}
.standings li {
display: flex;
align-items: baseline;
gap: 10px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-alt);
}
.standings li.me {
border-color: var(--accent);
}
.standings li.winner {
background: var(--accent-soft);
}
.rank {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.standings .name {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.points {
margin-left: auto;
color: var(--text-muted);
font-size: var(--text-4);
font-variant-numeric: tabular-nums;
}
.stats {
display: flex;
justify-content: center;
gap: var(--space-8);
margin: 0;
}
dt {
color: var(--text-muted);
font-size: var(--text-3);
}
dd {
margin: 0;
font-size: var(--text-8);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.suggestions h3 {
margin: 0 0 6px;
color: var(--text-muted);
font-size: var(--text-3);
font-weight: 600;
/* Uppercase Vietnamese stacks a tone mark above a capital. */
line-height: 1.6;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.suggestions ul {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.suggestions li {
padding: 6px var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
background: var(--surface-alt);
font-weight: 600;
}
.dead-end {
margin: 0;
color: var(--text-muted);
}
.record {
margin: 0;
padding: 6px var(--space-3);
border-radius: var(--radius-pill);
background: var(--accent-soft);
color: var(--accent);
font-weight: 700;
}
.export {
min-height: 44px;
padding: 10px var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: var(--text-5);
font-weight: 600;
}
.actions {
display: flex;
gap: 8px;
}
.actions button {
flex: 1;
min-height: 44px;
padding: var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
font-weight: 600;
}
.actions .primary {
border-color: transparent;
background: var(--accent);
color: var(--accent-text);
}
</style>