refactor(web): share alert banner and turn actions, fix restyle leaks

Add AlertBanner and turnActions to replace duplicated markup and
wrappers, and share component-test setup.

Fixes: oversized dismiss, help, rules and skip-link text after the type
ramp collapse; report button hover styled as submit; chat draft lost
when the panel folds; resign/claim/kick styles leaking app-wide.
This commit is contained in:
tiennm99 committed 2026-09-28 15:19:59 +07:00
1 parent 55abdb36c1
commit 9be86a3cad
18 files changed
+481 -470

No files matched your search

+6 -5
View File
@@ -171,19 +171,20 @@ input {
border-radius: var(--radius-sm);
background: none;
color: inherit;
font-size: var(--text-7);
font-size: var(--text-4);
line-height: 1;
}
/*
* A small link to the rules, used wherever a screen has room for one but not
* for a button that would compete with the primary action beside it: the
* landing page, the game header and the lobby all point at the same route
* through the same class, so it reads as one control rather than three.
* landing page and the lobby point at the same route through the same class,
* so it reads as one control. The game header, with no room for the words,
* uses a "?" icon-button instead.
*/
.rules-link {
color: var(--text-muted);
font-size: var(--text-4);
font-size: var(--text-2);
text-decoration: underline;
}
@@ -224,7 +225,7 @@ input {
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: var(--text-5);
font-size: var(--text-2);
font-weight: 600;
}
+52
View File
@@ -0,0 +1,52 @@
<script>
import { t } from '$lib/i18n/vi.js';
/**
* One line of news the player has to see, announced as it appears: a
* refusal from the server, a request that has stalled, a name still
* missing. The board, the lobby and the join form all draw it the same
* way, so a refusal reads as a refusal wherever it lands.
*
* `tone` is `error` for something that went wrong and `notice` for
* something the player has to do or know. `ondismiss`, when given, adds
* the close button — for news that stays true until somebody acts on it,
* the caller omits it.
* @type {{
* tone?: 'error' | 'notice',
* testid?: string,
* ondismiss?: () => void,
* children: import('svelte').Snippet
* }}
*/
let { tone = 'error', testid, ondismiss, children } = $props();
</script>
<p class="alert {tone}" role="alert" data-testid={testid}>
{@render children()}
{#if ondismiss}
<button type="button" class="icon-button" onclick={ondismiss} aria-label={t.dismiss}>×</button>
{/if}
</p>
<style>
.alert {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin: 0;
padding: var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--text-2);
}
.error {
background: var(--danger-soft);
color: var(--danger);
}
.notice {
background: var(--surface-alt);
color: var(--warn);
}
</style>
+7 -6
View File
@@ -44,19 +44,20 @@
/** @type {ReturnType<typeof setTimeout>} */
let timer;
function disarm() {
clearTimeout(timer);
armed = false;
}
$effect(() => {
if (disabled) {
clearTimeout(timer);
armed = false;
}
if (disabled) disarm();
});
$effect(() => () => clearTimeout(timer));
function press() {
if (armed) {
clearTimeout(timer);
armed = false;
disarm();
onconfirm();
return;
}
+21 -10
View File
@@ -1,4 +1,5 @@
<script>
import { untrack } from 'svelte';
import { fill, t } from '$lib/i18n/vi.js';
import { scrollBehavior } from '$lib/motion.js';
import { game } from '$lib/stores/game.svelte.js';
@@ -8,7 +9,7 @@
* client never appends its own copy of a message, so there is one ordering
* rather than a guessed one.
*
* Three facts about where it is, because each is a different question and
* Two facts about where it is, because each is a different question and
* the caller is the only one that can answer them:
*
* - `collapsible` folds the panel behind an unread count. That is for the
@@ -16,9 +17,10 @@
* and an open log would crowd the board off it.
* - `column` says the panel has a column of its own, so the log grows into
* the height it is given instead of stopping at a phone's worth.
*
* The room's refusals are not shown here. They belong beside the button
* that produced them, which is where the lobby now draws them: down here
* they were below the fold on a phone, and "Bắt đầu" looked broken.
* that produced them, which is where the lobby draws them: down here they
* were below the fold on a phone, and "Bắt đầu" looked broken.
*
* `folded` and `unread` are bindable so a control drawn outside this panel
* — the pill hoisted into the board's top row — can unfold it and read its
@@ -69,7 +71,6 @@
// the invisible characters that survive a trim stripped out. Matching the
// server's own emptiness test is what keeps a blank message from being
// sent and silently dropped with nothing on screen to explain it.
//
/** @param {string} text */
function hasContent(text) {
return text.replace(/[\p{Cf}\p{Cc}]/gu, '').trim().length > 0;
@@ -77,6 +78,16 @@
const sendable = $derived(hasContent(draft));
// The field unmounts while the panel is folded and comes back empty, so
// the draft is written back into it on the way in: what was typed survives
// a fold, and the send button — which reads the draft — never offers to
// send text the field no longer holds. Untracked, so this runs once per
// mount and never writes into a composition in progress.
$effect(() => {
const el = field;
if (el) untrack(() => (el.value = draft));
});
// Everything is read while the panel is open. A count that went backwards
// is a resynchronised panel — a reconnect, or an opponent leaving — and
// whatever it now holds is all there is to have missed.
@@ -208,7 +219,7 @@
.chat {
display: flex;
flex-direction: column;
gap: 8px;
gap: var(--space-2);
min-height: 0;
}
@@ -216,7 +227,7 @@
.header {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
margin: 0;
padding: 0;
border: 0;
@@ -255,7 +266,7 @@
ol {
display: flex;
flex-direction: column;
gap: 4px;
gap: var(--space-1);
/* Stacked under the game, so bounded: the field it scrolls above has to
stay on screen. */
max-height: 180px;
@@ -302,13 +313,13 @@
.row {
display: flex;
gap: 8px;
gap: var(--space-2);
}
input {
flex: 1;
min-width: 0;
padding: var(--space-3) 12px;
padding: var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
@@ -317,7 +328,7 @@
}
.row button {
padding: var(--space-3) 16px;
padding: var(--space-3) var(--space-4);
border: 0;
border-radius: var(--radius-sm);
background: var(--accent);
+44 -91
View File
@@ -1,4 +1,5 @@
<script>
import AlertBanner from '$lib/components/AlertBanner.svelte';
import ArmedButton from '$lib/components/ArmedButton.svelte';
import ChainHistory from '$lib/components/ChainHistory.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
@@ -53,14 +54,11 @@
const offline = $derived(connection.status !== Status.OPEN);
// Giving up is a move: it is what a player plays instead of a word, so it
// is offered on their turn and no other. Out of turn the way out of a game
// is to leave the room, which the lobby's own button does.
const canResign = $derived(game.state.myTurn && !offline);
// A claim is the same offer resign is: made only on the player's own turn,
// on the same reasoning canResign already states.
const canClaimDeadEnd = $derived(game.state.myTurn && !offline);
// Giving up and claiming a dead end are both moves: each is what a player
// plays instead of a word, so they are offered on their turn and no other.
// Out of turn the way out of a game is to leave the room, which the
// lobby's own button does.
const canPlayInsteadOfAWord = $derived(game.state.myTurn && !offline);
</script>
<section class="board" data-phase={game.state.phase}>
@@ -134,15 +132,7 @@
{/if}
{#if game.state.error}
<p class="error" role="alert">
{game.state.error}
<button
type="button"
class="icon-button"
onclick={() => game.clearError()}
aria-label={t.dismiss}>×</button
>
</p>
<AlertBanner ondismiss={() => game.clearError()}>{game.state.error}</AlertBanner>
{/if}
{#if game.state.phase === 'over'}
@@ -213,27 +203,19 @@
class="claim-dead-end"
label={t.claimDeadEnd}
confirmLabel={t.claimDeadEndSure}
disabled={!canClaimDeadEnd}
disabled={!canPlayInsteadOfAWord}
onconfirm={onclaimdeadend}
/>
<ArmedButton
class="resign"
label={t.resign}
confirmLabel={t.resignSure}
disabled={!canResign}
disabled={!canPlayInsteadOfAWord}
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>
<AlertBanner ondismiss={() => game.clearClaimError()}>{game.state.claimError}</AlertBanner>
{/if}
{/if}
@@ -338,26 +320,19 @@
line-height: 1.35;
}
.error,
/* Not an AlertBanner: it is not announced (see the markup), and it
carries the one control that can do anything about it. */
.offline {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin: 0;
padding: var(--space-3) var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--text-2);
}
.error {
background: var(--danger-soft);
color: var(--danger);
}
.offline {
background: var(--surface-alt);
color: var(--warn);
font-size: var(--text-2);
}
.offline button {
@@ -404,85 +379,63 @@
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. */
/* :global() under .secondary: these are ArmedButton's own <button>, which
Svelte's scoped-style attribute never lands on, so the rules are scoped
by the row they sit in instead — a bare :global(.resign) would reach
any element with that class anywhere in the app.
/* Danger coloured because it ends the game, subordinate because it is not
the way to play it. */
:global(.resign) {
/* 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
way out. Still its own outlined block in danger colour, so it is
plainly findable rather than hidden. */
Both are secondary weight: resigning ends the game and claiming skips
a turn that cannot be answered, and neither is the way to play one.
Below the 44px the rest of the controls keep, deliberately: nobody is
trying to hit these, each takes two presses to do anything, and at
full size they read as offers rather than as ways out. Still outlined
blocks, so they are plainly findable rather than hidden. */
.secondary :global(button) {
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(--danger);
font-size: var(--text-2);
transition: background-color 150ms ease-out;
}
:global(.resign:hover:enabled) {
background: var(--danger-soft);
}
/* Off turn: still there, so the way out of the game does not appear and
disappear under the player's thumb every handover, but plainly not the
thing to press yet. */
:global(.resign:disabled) {
/* Off turn: still there, so neither appears and disappears under the
player's thumb every handover, but plainly not the thing to press yet. */
.secondary :global(button:disabled) {
border-color: var(--border);
color: var(--text-muted);
}
/* Armed, and saying so: the second press is the one that ends the game. */
:global(.resign.arming) {
border-color: var(--danger);
background: var(--danger-soft);
.secondary :global(.arming) {
font-weight: 600;
}
/* 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) {
min-height: 32px;
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: transparent;
.secondary :global(.claim-dead-end) {
color: var(--text);
font-size: var(--text-2);
transition: background-color 150ms ease-out;
}
:global(.claim-dead-end:hover:enabled) {
.secondary :global(.claim-dead-end:hover:enabled) {
background: var(--surface-alt);
}
:global(.claim-dead-end:disabled) {
border-color: var(--border);
color: var(--text-muted);
}
:global(.claim-dead-end.arming) {
.secondary :global(.claim-dead-end.arming) {
border-color: var(--accent);
background: var(--accent-soft);
font-weight: 600;
}
.claim-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin: 0;
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-sm);
background: var(--danger-soft);
/* Danger coloured because it ends the game. */
.secondary :global(.resign) {
color: var(--danger);
font-size: var(--text-2);
}
.secondary :global(.resign:hover:enabled) {
background: var(--danger-soft);
}
/* Armed, and saying so: the second press is the one that ends the game. */
.secondary :global(.resign.arming) {
border-color: var(--danger);
background: var(--danger-soft);
}
</style>
+11 -35
View File
@@ -1,4 +1,5 @@
<script>
import AlertBanner from '$lib/components/AlertBanner.svelte';
import ArmedButton from '$lib/components/ArmedButton.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
@@ -47,12 +48,7 @@
const offline = $derived(connection.status !== Status.OPEN);
// The owner is who everybody else is waiting on, so the hint has to stop
// telling a ready guest to keep waiting once the owner has dropped.
const ownerAway = $derived(
s.roomPlayers.some((/** @type {{ isOwner: boolean, connected: boolean }} */ p) => {
return p.isOwner && !p.connected;
})
);
const ownerAway = $derived(s.roomPlayers.some((p) => p.isOwner && !p.connected));
</script>
<section class="lobby" class:compact aria-label={t.lobbyTitle}>
@@ -165,17 +161,9 @@
player had already scrolled past, which made "Bắt đầu" look broken.
-->
{#if s.error}
<p class="error" role="alert" data-testid="lobby-error">
{s.error}
<button
type="button"
class="icon-button"
onclick={() => game.clearError()}
aria-label={t.dismiss}>×</button
>
</p>
<AlertBanner testid="lobby-error" ondismiss={() => game.clearError()}>{s.error}</AlertBanner>
{:else if actionHeld}
<p class="error" role="alert" data-testid="lobby-unsent">{t.reconnecting}</p>
<AlertBanner testid="lobby-unsent">{t.reconnecting}</AlertBanner>
{/if}
<div class="actions">
@@ -333,10 +321,11 @@
* touch target is the full 44 all the same, expanded out of the flow by a
* pseudo-element so the row keeps its height.
*
* :global(): ArmedButton renders its own <button>, which this component's
* scoped-style attribute never reaches.
* :global() under .seats: ArmedButton renders its own <button>, which this
* component's scoped-style attribute never reaches, so the rules are
* scoped by the list instead of leaking to every .kick in the app.
*/
:global(.kick) {
.seats :global(.kick) {
position: relative;
width: 36px;
height: 36px;
@@ -350,17 +339,17 @@
line-height: 1;
}
:global(.kick::after) {
.seats :global(.kick::after) {
content: '';
position: absolute;
inset: -4px;
}
:global(.kick:disabled) {
.seats :global(.kick:disabled) {
opacity: 0.35;
}
:global(.kick.arming) {
.seats :global(.kick.arming) {
border-color: var(--danger);
background: var(--danger-soft);
color: var(--danger);
@@ -374,19 +363,6 @@
text-align: center;
}
.error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin: 0;
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-sm);
background: var(--danger-soft);
color: var(--danger);
font-size: var(--text-2);
}
.actions {
display: flex;
gap: var(--space-2);
+18 -8
View File
@@ -126,11 +126,18 @@
if (draft && draft.toLowerCase().startsWith(syllable.toLowerCase())) return;
field.value = `${syllable} `;
// Caret after the seed, so typing continues the word instead of
// landing in front of it.
field.setSelectionRange(field.value.length, field.value.length);
caretToEnd(field);
});
/**
* Puts the caret after whatever the field holds, so typing continues the
* word instead of landing in front of it.
* @param {HTMLInputElement} input
*/
function caretToEnd(input) {
input.setSelectionRange(input.value.length, input.value.length);
}
/** @param {SubmitEvent} event */
function handleSubmit(event) {
event.preventDefault();
@@ -167,7 +174,7 @@
if (!suggestion || !field) return;
field.value = suggestion;
field.focus();
field.setSelectionRange(field.value.length, field.value.length);
caretToEnd(field);
}
function report() {
@@ -283,7 +290,10 @@
color: var(--text-muted);
}
button {
/* Scoped to the row: a bare `button` rule also reached the fix buttons
below, and its hover and press states — more specific than
`.fix:hover` — turned the report button solid accent under dark text. */
.input-row button {
padding: var(--space-4) var(--space-5);
border: 0;
border-radius: var(--radius-sm);
@@ -293,15 +303,15 @@
transition: background-color 150ms ease-out;
}
button:hover:not(:disabled) {
.input-row button:hover:not(:disabled) {
background: var(--accent-hover);
}
button:active:not(:disabled) {
.input-row button:active:not(:disabled) {
background: var(--accent-pressed);
}
button:disabled {
.input-row button:disabled {
background: var(--surface-alt);
color: var(--text-muted);
}
+58 -49
View File
@@ -3,7 +3,9 @@ import { toParts, toSenses, toScore, toSlot } from './game-shape.js';
/**
* @typedef {import('$lib/proto/noitu/v1/game_pb.js').ServerMessage} ServerMessage
* @typedef {import('$lib/proto/noitu/v1/game_pb.js').ChatMessage} WireChatMessage
* @typedef {import('./game-shape.js').GameState} GameState
* @typedef {import('./game-shape.js').ChatLine} ChatLine
*/
// Ordinal handed to each chat line as it arrives, for list keys. Never reset:
@@ -16,6 +18,44 @@ let chatOrdinal = 0;
*/
export const CHAT_WINDOW = 20;
/**
* Error codes that also end this connection's membership of the room, so the
* model has to stop describing one.
*/
const LEAVES_ROOM = new Set(['kicked', 'room_idle_closed']);
/**
* 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.
*/
const ANSWERED_BY_THE_BUTTON = new Set(['not_a_dead_end', 'not_your_turn']);
/**
* A match the server could not open leaves nobody queued, and the only frame
* that says so is one of these refusals.
*/
const ENDS_THE_QUEUE = new Set(['server_full', 'room_start_failed', 'server_restarting']);
/**
* Reads one chat line off the wire, numbering it for list keys.
* @param {WireChatMessage} m
* @returns {ChatLine}
*/
function toChatLine(m) {
return {
n: ++chatOrdinal,
fromMe: m.fromMe,
playerId: m.playerId,
author: m.author,
text: m.text,
// int64 on the wire, which the runtime hands over as a bigint.
// Nothing downstream expects one.
atMs: Number(m.sentUnixMs)
};
}
/**
* Applies one ServerMessage to a GameState in place.
*
@@ -113,6 +153,14 @@ export function applyTo(state, msg, { reset, leave }) {
});
state.expanded = state.expanded.filter((w) => w !== previous);
if (!state.expanded.includes(played.word)) state.expanded.push(played.word);
// An accepted move answers the previous rejection — and only
// an accepted move does. A wordless update is somebody being
// eliminated, which says nothing about the word this player
// was just refused, and wiping the reason off their screen is
// one player's exit costing another the only explanation they
// had.
state.rejection = null;
state.reportConfirmation = null;
}
state.currentSyllable = value.currentSyllable;
state.myTurn = value.myTurn;
@@ -121,16 +169,6 @@ export function applyTo(state, msg, { reset, leave }) {
state.chainLength = value.chainLength;
state.gamePlayers = value.players.map(toScore);
state.turnPlayerId = value.turnPlayerId;
// An accepted move answers the previous rejection — and only
// an accepted move does. A wordless update is somebody being
// eliminated, which says nothing about the word this player
// was just refused, and wiping the reason off their screen is
// one player's exit costing another the only explanation they
// had.
if (played) {
state.rejection = null;
state.reportConfirmation = null;
}
// A false dead-end claim is about the position this update
// just moved past, however the turn moved.
state.claimError = null;
@@ -194,16 +232,7 @@ export function applyTo(state, msg, { reset, leave }) {
}
case 'chatMessage':
state.chat.push({
n: ++chatOrdinal,
fromMe: payload.value.fromMe,
playerId: payload.value.playerId,
author: payload.value.author,
text: payload.value.text,
// int64 on the wire, which the runtime hands over as a
// bigint. Nothing downstream expects one.
atMs: Number(payload.value.sentUnixMs)
});
state.chat.push(toChatLine(payload.value));
state.chatCount++;
// Trimmed to the server's window, so a long conversation and a
// replayed one are the same list.
@@ -216,41 +245,21 @@ export function applyTo(state, msg, { reset, leave }) {
// A snapshot replaces; it never merges. It is also what a
// client arriving in a new room is given, so a conversation
// cannot outlive the room it was had in.
state.chat = payload.value.messages.map((m) => ({
n: ++chatOrdinal,
fromMe: m.fromMe,
playerId: m.playerId,
author: m.author,
text: m.text,
atMs: Number(m.sentUnixMs)
}));
state.chat = payload.value.messages.map(toChatLine);
state.chatCount = state.chat.length;
break;
case 'error': {
const value = payload.value;
// Two of them also end this player's membership of the room, so
// 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, 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);
const code = payload.value.code;
// Before anything else, because leaving clears everything
// including the message about to be set.
if (LEAVES_ROOM.has(code)) leave();
if (ANSWERED_BY_THE_BUTTON.has(code)) {
state.claimError = errorMessage(code);
break;
}
// A match the server could not open leaves nobody queued, and
// the only frame that says so is this refusal.
if (
value.code === 'server_full' ||
value.code === 'room_start_failed' ||
value.code === 'server_restarting'
) {
state.queued = false;
}
state.error = errorMessage(value.code);
if (ENDS_THE_QUEUE.has(code)) state.queued = false;
state.error = errorMessage(code);
break;
}
+4 -1
View File
@@ -115,7 +115,10 @@ export function createRoomSession() {
return sent;
},
/** Marks a resume attempt as starting, optionally behind a held join. */
/**
* Marks a resume attempt as starting. A join the page wants to make
* once it is settled is held separately, with `holdPendingJoin()`.
*/
startResume() {
state.resuming = true;
},
+22
View File
@@ -1,4 +1,5 @@
import { Status, createClient, hasStoredSession } from './client.js';
import { claimDeadEnd, reportWord, resign, submitWord } from './messages.js';
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
@@ -88,5 +89,26 @@ export function disconnect() {
state.status = Status.CLOSED;
}
/**
* The moves a board offers, sent as the server expects them. Both game
* screens hand these to GameBoard unchanged: what a turn sends does not
* depend on whether the other side is a bot or a room.
*
* Resigning and claiming are armed by the board with a second press rather
* than a native confirm(), which would block the countdown's frame loop
* while the server's deadline kept running.
*/
export const turnActions = {
/**
* @param {string} word
* @returns {boolean} whether the word reached the server
*/
submit: (word) => send(submitWord(word, game.state.turnSeq)),
resign: () => send(resign()),
claimDeadEnd: () => send(claimDeadEnd()),
/** @param {string} word */
reportWord: (word) => send(reportWord(word))
};
export const connection = state;
export { Status, hasStoredSession };
+67 -112
View File
@@ -2,6 +2,7 @@
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import AlertBanner from '$lib/components/AlertBanner.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
import GameBoard from '$lib/components/GameBoard.svelte';
import GameOverPanel from '$lib/components/GameOverPanel.svelte';
@@ -17,18 +18,14 @@
import { settings } from '$lib/stores/settings.svelte.js';
import {
cancelQuickMatch,
claimDeadEnd,
createRoom,
joinRoom,
kickPlayer,
leaveRoom,
quickMatch,
reportWord,
resign,
sendChat,
setReady,
startGame,
submitWord
startGame
} from '$lib/ws/messages.js';
import {
Status,
@@ -37,7 +34,8 @@
disconnect,
forgetSession,
hasStoredSession,
send
send,
turnActions
} from '$lib/ws/connection.svelte.js';
/**
@@ -233,11 +231,8 @@
// found and then a later, separate wait never inherits the first one's
// clock.
$effect(() => {
if (!game.state.queued) {
session.resetQueued();
return;
}
session.resetQueued();
if (!game.state.queued) return;
const id = setInterval(() => session.tickQueued(), 1000);
return () => clearInterval(id);
});
@@ -264,13 +259,7 @@
// not a handshake that opened and then went quiet.
$effect(() => {
if (!(session.state.resuming && connection.status === Status.OPEN)) return;
const timer = setTimeout(() => {
untrack(() => {
session.noteResumeFailed(named);
forgetSession();
if (!session.state.needName) flush(connection.status === Status.OPEN);
});
}, RESUME_TIMEOUT_MS);
const timer = setTimeout(abandonResume, RESUME_TIMEOUT_MS);
return () => clearTimeout(timer);
});
@@ -281,16 +270,24 @@
// to answer a stale token with `session_not_resumable` lands here, ahead
// of the time-box above.
$effect(() => {
const failed = session.state.resuming && !!game.state.error;
if (!(session.state.resuming && game.state.error)) return;
untrack(() => {
if (!failed) return;
game.clearError();
forgetSession();
session.noteResumeFailed(named);
if (!session.state.needName) flush(connection.status === Status.OPEN);
abandonResume();
});
});
/**
* Gives up on a resume, whichever of the two paths above noticed first:
* the token is spent, and an invite code held behind it either goes out
* now or waits on the name the player has not typed yet.
*/
function abandonResume() {
session.noteResumeFailed(named);
forgetSession();
if (!session.state.needName) flush(connection.status === Status.OPEN);
}
/** @param {import('$lib/stores/room-session.svelte.js').RoomRequest} req */
function request(req) {
codeError = '';
@@ -312,11 +309,12 @@
}
/**
* Resends a lobby action the socket refused the first time. Every one of
* these is safe to resend regardless of what happened in between: the
* server refuses whichever no longer apply rather than misapplying them.
* Sends a lobby action. Every one of these is safe to resend regardless of
* what happened in between — the server refuses whichever no longer apply
* rather than misapplying them — which is why this is also what a held
* action is retried through once the socket reopens.
* @param {import('$lib/stores/room-session.svelte.js').RoomAction} action
* @returns {boolean}
* @returns {boolean} whether it reached the server
*/
function dispatchAction(action) {
switch (action.kind) {
@@ -335,6 +333,19 @@
}
}
/**
* Sends a lobby action now, or holds it for the socket to carry once it
* reopens. Reports which, so the lobby can say a request is waiting
* rather than looking like a button that does nothing.
* @param {import('$lib/stores/room-session.svelte.js').RoomAction} action
* @returns {boolean} whether it reached the server
*/
function act(action) {
const sent = dispatchAction(action);
if (!sent) session.holdAction(action);
return sent;
}
function join() {
const code = normalizeRoomCode(codeInput);
if (!isRoomCode(code)) {
@@ -358,8 +369,7 @@
/** Withdraws from the pairing queue without leaving the page. */
function cancelQueue() {
const sent = send(cancelQuickMatch());
if (!sent) session.holdAction({ kind: 'cancelQueue' });
act({ kind: 'cancelQueue' });
session.clearPending();
}
@@ -367,47 +377,34 @@
goto('/');
}
/** @param {boolean} value */
function ready(value) {
return act({ kind: 'setReady', ready: value });
}
function start() {
return act({ kind: 'startGame' });
}
/**
* Armed by the lobby with a second press of the same button; a native
* confirm() would block the frame loop the countdown runs on.
* @param {string} playerId
*/
function kick(playerId) {
return act({ kind: 'kickPlayer', playerId });
}
/**
* Gives up the seat without leaving the page: the room may still be there
* to rejoin, and the lobby list is the natural place to land.
* to rejoin, and the join form is the natural place to land.
*
* The local state goes with it. The server sends nothing back to somebody
* who is no longer in the room to be told about, and the button is only
* enabled when this client already knows the rule allows it.
*
* Each of these reports whether the request actually reached the server, so
* the lobby can say so rather than looking like a button that does nothing.
* @param {boolean} ready
* @returns {boolean}
*/
function ready(ready) {
const sent = send(setReady(ready));
if (!sent) session.holdAction({ kind: 'setReady', ready });
return sent;
}
/** @returns {boolean} */
function start() {
const sent = send(startGame());
if (!sent) session.holdAction({ kind: 'startGame' });
return sent;
}
/**
* @param {string} playerId
* @returns {boolean}
*/
function kick(playerId) {
// The lobby arms this with a second press of the same button; a native
// confirm() would block the frame loop the countdown runs on.
const sent = send(kickPlayer(playerId));
if (!sent) session.holdAction({ kind: 'kickPlayer', playerId });
return sent;
}
function leave() {
const sent = send(leaveRoom());
if (!sent) session.holdAction({ kind: 'leaveRoom' });
act({ kind: 'leaveRoom' });
game.leave();
session.clearPending();
// Matches the page-teardown path: leaving deliberately must not leave
@@ -420,30 +417,6 @@
function say(text) {
send(sendChat(text));
}
/**
* @param {string} word
* @returns {boolean}
*/
function play(word) {
return send(submitWord(word, game.state.turnSeq));
}
function giveUp() {
// Armed by the board with a second press, for the same reason as kick.
send(resign());
}
function claim() {
// Armed by the board the same way giving up is: a second press, so a
// stray tap cannot spend it.
send(claimDeadEnd());
}
/** @param {string} word */
function report(word) {
send(reportWord(word));
}
</script>
<svelte:head>
@@ -469,10 +442,10 @@
{#if playing}
<GameBoard
modeLabel={game.state.roomCode}
onsubmit={play}
onresign={giveUp}
onclaimdeadend={claim}
onreportword={report}
onsubmit={turnActions.submit}
onresign={turnActions.resign}
onclaimdeadend={turnActions.claimDeadEnd}
onreportword={turnActions.reportWord}
chatUnread={wide ? 0 : chatUnread}
onchatopen={wide ? undefined : openChat}
>
@@ -521,11 +494,11 @@
<NicknameInput />
{#if session.state.needName}
<p class="notice" role="alert" data-testid="name-needed">{t.nicknameNeeded}</p>
<AlertBanner tone="notice" testid="name-needed">{t.nicknameNeeded}</AlertBanner>
{/if}
{#if game.state.error}
<p class="error" role="alert" data-testid="join-error">{game.state.error}</p>
<AlertBanner testid="join-error">{game.state.error}</AlertBanner>
{/if}
{#if session.state.resumeFailed}
@@ -533,13 +506,13 @@
ServerMessage the page decides how to react to in the ordinary
way, and the message is the same whether the server actually said
`session_not_resumable` or simply never answered. -->
<p class="notice" role="alert" data-testid="resume-failed">
<AlertBanner tone="notice" testid="resume-failed">
{errorMessage('session_not_resumable')}
</p>
</AlertBanner>
{/if}
{#if session.state.stalled}
<p class="error" role="alert" data-testid="connect-stalled">{t.connectStalled}</p>
<AlertBanner testid="connect-stalled">{t.connectStalled}</AlertBanner>
{/if}
{#if game.state.queued}
@@ -827,24 +800,6 @@
color: var(--danger);
}
.error,
.notice {
margin: 0;
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--text-2);
}
.error {
background: var(--danger-soft);
color: var(--danger);
}
.notice {
background: var(--surface-alt);
color: var(--warn);
}
.back {
align-self: flex-start;
color: var(--text-muted);
+13 -31
View File
@@ -9,8 +9,15 @@
import { createBotSession } from '$lib/stores/bot-session.svelte.js';
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
import { claimDeadEnd, reportWord, resign, startBotGame, submitWord } from '$lib/ws/messages.js';
import { Status, connect, connection, disconnect, send } from '$lib/ws/connection.svelte.js';
import { resign, startBotGame } from '$lib/ws/messages.js';
import {
Status,
connect,
connection,
disconnect,
send,
turnActions
} from '$lib/ws/connection.svelte.js';
/**
* The difficulty travels in the URL so a reload resumes the same ladder rung
@@ -77,31 +84,6 @@
function goHome() {
goto('/');
}
/**
* @param {string} word
* @returns {boolean} whether the word reached the server
*/
function play(word) {
return send(submitWord(word, game.state.turnSeq));
}
function giveUp() {
// The board arms this with a second press of the same button. A native
// confirm() blocks the main thread, which stops the countdown's frame
// loop while the server's deadline keeps running — hesitating over the
// dialog could cost the turn it was meant to protect.
send(resign());
}
function claim() {
send(claimDeadEnd());
}
/** @param {string} word */
function report(word) {
send(reportWord(word));
}
</script>
<svelte:head>
@@ -112,10 +94,10 @@
<GameBoard
modeLabel={difficultyLabels[difficulty]}
onsubmit={play}
onresign={giveUp}
onclaimdeadend={claim}
onreportword={report}
onsubmit={turnActions.submit}
onresign={turnActions.resign}
onclaimdeadend={turnActions.claimDeadEnd}
onreportword={turnActions.reportWord}
>
{#snippet gameOver()}
<!-- A bot always plays again, so there is nothing to negotiate: the
+40 -21
View File
@@ -1,37 +1,23 @@
// @vitest-environment jsdom
// ChatPanel's unread accounting is what broke CI once already (the review
// that asked for this file cites it by name), and until now nothing mounted
// the component to prove it. jsdom is already a devDependency; Svelte 5
// components compiled by the vite plugin mount directly under it with no
// extra library.
// ChatPanel's unread accounting has broken CI once already, and nothing
// else mounts the component to prove it. Svelte 5 components compiled by the
// vite plugin mount directly under jsdom with no extra library.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mount, unmount, flushSync } from 'svelte';
import { create } from '@bufbuild/protobuf';
import { unmount, flushSync } from 'svelte';
import ChatPanel from '../src/lib/components/ChatPanel.svelte';
import { ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
import { game } from '../src/lib/stores/game.svelte.js';
import { receive, render } from './component-support.js';
/** @param {{ author?: string, text?: string, fromMe?: boolean }} [fields] */
function receiveLine({ author = 'Lan', text = 'chào', fromMe = false } = {}) {
game.apply(
create(ServerMessageSchema, {
payload: {
case: 'chatMessage',
value: { fromMe, author, text, playerId: 'p2', sentUnixMs: 1n }
}
})
);
receive('chatMessage', { fromMe, author, text, playerId: 'p2', sentUnixMs: 1n });
}
/** @param {ConstructorParameters<typeof ChatPanel>[0]['props']} props */
function renderChatPanel(props) {
const target = document.createElement('div');
document.body.appendChild(target);
const component = mount(ChatPanel, { target, props });
flushSync();
return { target, component };
return render(ChatPanel, props);
}
beforeEach(() => {
@@ -168,3 +154,36 @@ describe('sending', () => {
expect(sent).toEqual([]);
});
});
describe('a draft across a fold', () => {
it('comes back in the field, and still sendable, once the panel reopens', () => {
/** @type {string[]} */
const sent = [];
const { target, component } = renderChatPanel({
collapsible: true,
folded: false,
onsend: (text) => sent.push(text)
});
/** @type {HTMLButtonElement | null} */
const toggle = target.querySelector('[data-testid="chat-toggle"]');
/** @type {HTMLInputElement | null} */
let input = target.querySelector('[data-testid="chat-input"]');
if (input) {
input.value = 'nửa câu';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
toggle?.click(); // folds: the field unmounts
flushSync();
toggle?.click(); // unfolds: a new field mounts
flushSync();
input = target.querySelector('[data-testid="chat-input"]');
expect(input?.value).toBe('nửa câu');
target.querySelector('form')?.requestSubmit();
flushSync();
expect(sent).toEqual(['nửa câu']);
unmount(component);
});
});
+49
View File
@@ -0,0 +1,49 @@
// Shared set-up for the suites that mount a component under jsdom. Not a
// test file itself: Vitest only collects `*.test.js`.
import { flushSync, mount } from 'svelte';
import { create } from '@bufbuild/protobuf';
import { ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
import { game } from '../src/lib/stores/game.svelte.js';
/**
* Feeds the shared store one decoded server message, as the socket would.
* @param {string} kind - the payload oneof case
* @param {object} value
*/
export function receive(kind, value) {
game.apply(
create(ServerMessageSchema, { payload: { case: kind, value } })
);
}
/**
* Starts a one-seat game with this player on turn.
* @param {{ turnSeq?: number, currentSyllable?: string }} [fields]
*/
export function startGame({ turnSeq = 1, currentSyllable = 'yên' } = {}) {
receive('gameStarted', {
openingWord: 'bình yên',
currentSyllable,
myTurn: true,
deadlineUnixMs: 1_700_000_020_000n,
turnSeq,
turnLimitMs: 20_000,
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
turnPlayerId: 'p1'
});
}
/**
* Mounts a component into a fresh element on the page and settles it.
* @template {Record<string, unknown>} Props
* @param {import('svelte').Component<Props>} component
* @param {Props} props
*/
export function render(component, props) {
const target = document.createElement('div');
document.body.appendChild(target);
const instance = mount(component, { target, props });
flushSync();
return { target, component: instance };
}
+50 -51
View File
@@ -6,49 +6,21 @@
// something only the online route happens to exercise.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mount, unmount, flushSync } from 'svelte';
import { create } from '@bufbuild/protobuf';
import { unmount, flushSync } from 'svelte';
import GameBoard from '../src/lib/components/GameBoard.svelte';
import { ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
import { game } from '../src/lib/stores/game.svelte.js';
import { Status, connection } from '../src/lib/ws/connection.svelte.js';
function startGame() {
game.apply(
create(ServerMessageSchema, {
payload: {
case: 'gameStarted',
value: {
openingWord: 'bình yên',
currentSyllable: 'yên',
myTurn: true,
deadlineUnixMs: 1_700_000_020_000n,
turnSeq: 1,
turnLimitMs: 20_000,
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
turnPlayerId: 'p1'
}
}
})
);
}
import { receive, render, startGame } from './component-support.js';
/** @param {Partial<ConstructorParameters<typeof GameBoard>[0]['props']>} [extra] */
function renderGameBoard(extra = {}) {
const target = document.createElement('div');
document.body.appendChild(target);
const component = mount(GameBoard, {
target,
props: {
onsubmit: () => true,
onresign: () => {},
onclaimdeadend: () => {},
onreportword: () => {},
...extra
}
return render(GameBoard, {
onsubmit: () => true,
onresign: () => {},
onclaimdeadend: () => {},
onreportword: () => {},
...extra
});
flushSync();
return { target, component };
}
beforeEach(() => {
@@ -125,21 +97,14 @@ describe('the persistent claim/resign row', () => {
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'
}
}
})
);
receive('turnUpdate', {
currentSyllable: 'yên',
myTurn: false,
turnSeq: 2,
chainLength: 1,
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
turnPlayerId: 'p2'
});
flushSync();
/** @type {HTMLButtonElement | null} */
@@ -154,3 +119,37 @@ describe('the persistent claim/resign row', () => {
unmount(component);
});
});
describe('refusals', () => {
it('shows a room-wide error with a button that dismisses it', () => {
const { target, component } = renderGameBoard();
receive('error', { code: 'too_fast' });
flushSync();
/** @type {HTMLButtonElement | null} */
const dismiss = target.querySelector('[role="alert"] button');
expect(target.querySelector('[role="alert"]')?.textContent).toContain('Thao tác quá nhanh');
dismiss?.click();
flushSync();
expect(game.state.error).toBeNull();
expect(target.querySelector('[role="alert"]')).toBeNull();
unmount(component);
});
it('answers a refused claim beside the button rather than in the top banner', () => {
const { target, component } = renderGameBoard();
receive('error', { code: 'not_a_dead_end' });
flushSync();
expect(game.state.error).toBeNull();
const alert = target.querySelector('[role="alert"]');
expect(alert?.previousElementSibling?.classList.contains('secondary')).toBe(true);
alert?.querySelector('button')?.click();
flushSync();
expect(game.state.claimError).toBeNull();
unmount(component);
});
});
+1 -1
View File
@@ -74,7 +74,7 @@ describe('normalizeRoomCode', () => {
it('handles nothing at all', () => {
expect(normalizeRoomCode('')).toBe('');
expect(normalizeRoomCode(/** @type {any} */ (undefined))).toBe('');
expect(normalizeRoomCode(/** @type {string} */ (/** @type {unknown} */ (undefined)))).toBe('');
});
});
+2 -2
View File
@@ -116,8 +116,8 @@ describe('resuming a stored session', () => {
});
it('times out to the join form instead of staying stuck forever', () => {
// The scenario C1 in the review describes: an older server answers a
// stale token with silence, never with an error.
// An older server answers a stale token with silence, never with an
// error.
const session = createRoomSession();
session.startResume();
+16 -47
View File
@@ -4,63 +4,32 @@
// and the submit-clear — are the exceptions to an otherwise fully
// uncontrolled field, so each is worth pinning down: the seed fires once per
// turn and never during the player's own composition, the suggestion lands
// on click, and the seed itself is gated on the connection (C6) as well as
// the turn.
// on click, and the seed itself is gated on the connection as well as the
// turn.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mount, unmount, flushSync } from 'svelte';
import { create } from '@bufbuild/protobuf';
import { unmount, flushSync } from 'svelte';
import WordInput from '../src/lib/components/WordInput.svelte';
import { RejectReason, ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
import { RejectReason } from '../src/lib/proto/noitu/v1/game_pb.js';
import { game } from '../src/lib/stores/game.svelte.js';
import { Status, connection } from '../src/lib/ws/connection.svelte.js';
/** @param {{ turnSeq?: number, currentSyllable?: string }} [fields] */
function startTurn({ turnSeq = 1, currentSyllable = 'an' } = {}) {
game.apply(
create(ServerMessageSchema, {
payload: {
case: 'gameStarted',
value: {
openingWord: 'bình yên',
currentSyllable,
myTurn: true,
deadlineUnixMs: 1_700_000_020_000n,
turnSeq,
turnLimitMs: 20_000,
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
turnPlayerId: 'p1'
}
}
})
);
}
import { receive, render, startGame as startTurn } from './component-support.js';
/** @param {string} suggestion */
function rejectWith(suggestion) {
game.apply(
create(ServerMessageSchema, {
payload: {
case: 'moveRejected',
value: {
reason: RejectReason.NOT_IN_DICTIONARY,
word: 'binh yen',
turnSeq: 1,
suggestion
}
}
})
);
receive('moveRejected', {
reason: RejectReason.NOT_IN_DICTIONARY,
word: 'binh yen',
turnSeq: 1,
suggestion
});
}
function renderWordInput() {
const target = document.createElement('div');
document.body.appendChild(target);
const component = mount(WordInput, {
target,
props: { onsubmit: () => true, onreportword: () => {} }
const { target, component } = render(WordInput, {
onsubmit: () => true,
onreportword: () => {}
});
flushSync();
/** @type {HTMLInputElement} */
const input = target.querySelector('input');
return { target, component, input };
@@ -86,8 +55,8 @@ describe('seeding the turn', () => {
});
it('does not seed, or focus, while the connection is down', () => {
// C6: seeding during a reconnect wrote into a field the player could
// not submit from, and the first composition event then undid it.
// Seeding during a reconnect wrote into a field the player could not
// submit from, and the first composition event then undid it.
connection.status = Status.CLOSED;
startTurn({ currentSyllable: 'an' });
const { input, component } = renderWordInput();