Type GameState for real instead of returning any from initialState(), which made every game.state.* read in every component unchecked. apply() now switches on payload.case so the oneof narrows, and is wrapped in try/catch so a throw partway through a case cannot leave a half-mutated snapshot on screen. Also key chain meanings by position instead of gloss, since the dictionary gives no gloss-uniqueness guarantee, and gate the word field's turn-seed effect on the same connection check `enabled` already uses so a reconnect cannot seed a field the player cannot submit from.
31 KiB
Web frontend — whole-project architecture review
Branch dev @ 5178a97 · 2026-09-21 · scope /workspace/tiennm99/noitu/web (src 4,392 LOC, tests 2,537, e2e 1,472)
Checks run (read-only)
| Command | Result |
|---|---|
npm run lint |
0 errors, 33 warnings (32 × jsdoc/reject-any-type, 1 × check-param-names in e2e/helpers.js:123) |
npm run check |
380 files, 0 errors, 0 warnings — see A4: this number is mostly meaningless today |
npm test |
build OK, 221 passed / 12 files, 3.8s |
| Playwright | not run (no browser on this host, per workspace rules) |
Verdict
Structurally sound and unusually well-reasoned — the "store is a projection" invariant holds everywhere I
checked, the uncontrolled-input invariant is respected, and the comments explain why rather than what.
Three things are genuinely wrong and one of them is a stuck-UI dead end reachable after any deploy. The
bigger problem is not a defect: svelte-check's clean run is an illusion — initialState() returns
any (stores/game.svelte.js:54), so every game.state.* read in every component is unchecked. Fix that
before any refactor, or the refactor lands blind.
Do not slice the store into per-domain stores. Do extract the page's request state machine.
Top 10 ranked actions
| # | Action | Kind | Size | Risk | Why now |
|---|---|---|---|---|---|
| 1 | Time-box the resume latch; a stale token gets silence from the server, not an error → ?code= + stale token = permanently disabled join form |
fix | S | L | Confirmed against server/internal/wsapi/session.go:643 + hub.go:127. Reachable after every deploy |
| 2 | leave() omits forgetSession() → next reload resumes into the room just left |
fix | S | L | Confirmed; one-line asymmetry vs. the page-teardown path |
| 3 | Type GameState; delete @returns {any} on initialState() |
fix | M | M | Unblocks every other item. Expect real errors to surface |
| 4 | Extract stores/room-session.svelte.js (join/resume/quick-match machine) from online/+page.svelte |
refactor | M | M | Removes 5 of 7 $effects; makes #1 unit-testable; bot-session is the precedent |
| 5 | Type the wire from game_pb.d.ts; switch (payload.case) for oneof narrowing |
refactor | M | L | Clears 18 src lint warnings; makes the oneof exhaustive at build time |
| 6 | Fire-and-forget sends (cancelQueue, leave, lobby report) silently drop requests |
fix | S | L | Best explanation for the toBeEnabled flake; user-visible dead buttons |
| 7 | Split game.svelte.js → game-shape.js / game-apply.js / store; wrap apply in try/catch |
refactor | M | L | A throw mid-apply leaves a half-applied snapshot on screen |
| 8 | Component tests under jsdom via mount(); then cut Playwright 47 → ~12 |
refactor | M | L | jsdom is already a devDependency; today 0 component tests exist |
| 9 | ArmedButton.svelte (3 duplicated arm/disarm blocks) + announce the armed state |
refactor | S | L | DRY + the only a11y gap that loses information |
| 10 | {#each entry.meanings as sense (sense.gloss)} — duplicate gloss = Svelte duplicate-key throw |
fix | S | L | Dictionary data is not guaranteed gloss-unique |
Leave alone: per-domain store slices (§1), vi.js namespacing (§5), manual chunking / font strategy (§4),
the uncontrolled-field design (§3), the Set + eslint-disable in reset() (dissolves under #3).
1. Structure
1.1 routes/online/+page.svelte (757) — extract the request machine
Seven $effects, five of which are one state machine wearing a costume: pending (48), resuming (121),
needName (125), stalled (126), queuedForS (56), and the latch-clearing effect at 137-143, the flush at
216-219, the queue timer at 225-233, the stall timer at 239-247, the resume-failure handler at 253-268.
Proposal (mirrors stores/bot-session.svelte.js exactly):
lib/stores/room-session.svelte.js(~130 LOC, no DOM, no runes beyond$state) — ownspending,resuming,needName,stalled,waitedS; exposesrequest(req),flush(isOpen),noteRoom(),noteError(code),noteResumeTimeout(),leave(). Unit-testable in Vitest with a fake clock, exactly asws/client.jsalready is (630 lines of tests prove the pattern works).lib/components/JoinPanel.svelte(~160) — the{:else}branch at 465-547: nickname, quick match, create, join form,needName/error/stallednotices.lib/components/RoomLayout.svelte(~90) — the two-column shell (408-464) pluswide(85),chatFolded(103),chatUnread(104),talkPane(106) and the media-query effect (87-94).- Page drops to ~130: store wiring + the 11 one-line message senders (349-401).
Invariant impact: none. room-session holds client intent (what the player asked for), never server
state. game stays the sole projection. This is the boundary bot-session.svelte.js:1-12 already argues for
in prose.
Size M, risk M — the risk is entirely in the teardown effect (185-212), which is load-bearing for seat
release. Port it verbatim; cover it with the existing pvp-game.spec.js:235 spec before and after.
Secondary: the teardown at 185 is coupled to inviteCode ($derived on page.url, 128). Any future
in-app URL mutation on /online — a replaceState to drop the used ?code=, say — would fire a full
leave-room-and-disconnect. Move teardown to onDestroy so it is not a reactive dependency of a query
parameter.
1.2 stores/game.svelte.js (646) — split the file, keep one state object
Do not make this four stores with a dispatcher. Three reasons:
- The store's own comment at 300-302 states the failure mode a slice design invites: "Merging fields
selectively is how a client ends up believing a mixture of two states the server was never in." Four
reducers each handling part of a
RoomStateis precisely that, with the atomicity now spread across module boundaries. - Components read across the proposed domains.
ScoreBoard.svelte:15-17readsgamePlayers+standings+roomPlayers+nickname;nameOf()(593-601) falls back across game → room;myScore(566-569) picks its table byphase. - There is no performance motive. 61 store tests run in 36ms.
Proposal — mechanical file split, one $state:
stores/game-shape.js(~210) — theChainEntry/Sense/PointPart/PlayerSlot/PlayerScoretypedefs, the newGameStatetypedef (#3),initialState(), and the three wire decoderstoSenses/toParts/toScore(200-230). Pure, zero reactivity, the natural home for the generated-type imports.stores/game-apply.js(~230) —applyTo(state, msg): the switch at 286-518 as a pure function over a plain object. Testable without the runes compiler.stores/game.svelte.js(~200) —$state,reset,leave, the 12 derived accessors, the singleton.
Size M, risk L (mechanical). Sequence it after #3 and #5 so the decoders land typed.
While splitting, wrap the call site: apply() has no error boundary and is invoked from
ws/client.js:245-246 inside ws.onmessage. A throw anywhere in the switch aborts mid-mutation — e.g.
roomState sets queued/roomCode/canStart (296-306) before mapping players (308), so a throw there
leaves a room on screen with no seats. Protobuf-es v2 always materialises repeated fields, so this is
plausible rather than confirmed, but the cost of try { applyTo(...) } catch { /* report */ } is one line.
1.3 components/GameBoard.svelte (495)
Two near-identical arm/disarm blocks: arming/armTimer/armOrResign (48-51, 99-109, 124) and
claimArming/claimArmTimer/armOrClaim (52-54, 112-122, 125), plus their disarm-on-turn-loss effects
(74-78, 84-88). Lobby.svelte:55-81 has a third copy for kick.
components/ArmedButton.svelte(~45) — props{ label, confirmLabel, disabled, onconfirm }; owns the timer, the disarm-on-disable effect, and (see §5) the announcement the armed state currently lacks. Three call sites, ~70 LOC deleted. S / L.components/BoardHeader.svelte(~55) — thetoprow at 129-154 (badge, mode label, rules link, chat pill). Board falls to ~330.
1.4 components/Lobby.svelte (467)
Extract components/SeatList.svelte (~140): the <ul class="seats"> at 101-162 plus the kick arming, which
becomes an ArmedButton. Lobby → ~300. S / L. Lowest priority of the four.
1.5 Total
~700 LOC moved, ~150 new, two new pure modules Vitest can reach. No abstraction without a domain anchor: every extracted unit is an existing repeated pattern (armed button, seat list) or an existing named concept (the room session, the wire decoders).
2. Typing without TypeScript
18 any sites in src/ (+ 12 in tests/+e2e/, 9 of which dissolve for free). game_pb.d.ts already
carries everything needed: ServerMessage.payload is a proper discriminated union
(game_pb.d.ts:1185+), and moduleResolution: "bundler" resolves import('…/game_pb.js').ServerMessage
through the sibling .d.ts.
Site A — ws/client.js:100, 269, 297 (the oneof)
Before:
* @param {(msg: any) => void} options.onMessage
…
/** @param {any} msg */
function intercept(msg) {
const payload = msg.payload;
if (payload.case === 'welcome') {
attempt = 0;
if (payload.value.resumeToken) storeToken(payload.value.resumeToken);
After:
/** @typedef {import('$lib/proto/noitu/v1/game_pb.js').ServerMessage} ServerMessage */
/** @typedef {import('$lib/proto/noitu/v1/game_pb.js').ClientMessage} ClientMessage */
…
* @param {(msg: ServerMessage) => void} options.onMessage
…
/** @param {ServerMessage} msg */
function intercept(msg) {
const payload = msg.payload; // NOT destructured — see below
if (payload.case === 'welcome') {
attempt = 0;
if (payload.value.resumeToken) storeToken(payload.value.resumeToken); // value: Welcome
payload.case === 'pong' then narrows payload.value to Pong, whose clientTimeMs is bigint — which
makes the Number(...) at 287-288 a checked conversion rather than a hopeful one. send (297) takes
ClientMessage; messages.js builders already return it, so no change there. The three timer anys
(107, 126, 128) become ReturnType<typeof setTimeout>, which matches the injected schedule: typeof setTimeout exactly.
Site B — stores/game.svelte.js:54 (the one that matters)
@returns {any} on initialState() makes const state = $state(initialState()) any, therefore
game.state is any, therefore every game.state.foo in all 16 components is unchecked. A typo
(chainLenght) compiles, ships, and renders undefined. This is why svelte-check reports 0 errors.
Before:
/** @returns {any} */
function initialState() { return { phase: 'idle', chain: [], … }; }
…
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const kept = new Set(['nickname', 'roomCode', …]);
function reset() {
const fresh = initialState();
for (const key of Object.keys(fresh)) {
if (kept.has(key)) continue;
state[key] = fresh[key]; // untyped index write
}
}
After:
/**
* @typedef {object} GameState
* @property {'idle'|'lobby'|'playing'|'over'} phase
* @property {ChainEntry[]} chain
* @property {string[]} expanded
* …one @property per field, ~30 lines, prose comments unchanged…
* @property {string|null} error
*/
/** @returns {GameState} */
function initialState() { … }
/** Fields a game ending does not change: they describe the room, not the game. */
const KEPT = /** @type {const} */ ([
'nickname', 'roomCode', 'roomPlayers', 'canStart',
'maxPlayers', 'minPlayers', 'graceMs', 'chat', 'chatCount'
]);
/**
* @template {keyof GameState} K
* @param {GameState} into
* @param {GameState} from
* @param {readonly K[]} keys
*/
function carry(into, from, keys) {
for (const k of keys) into[k] = from[k]; // both sides are GameState[K]
}
function reset() {
const fresh = initialState();
carry(fresh, state, KEPT);
Object.assign(state, fresh);
}
function leave() { Object.assign(state, initialState()); }
The generic carry typechecks without a suppression, and the Set plus its eslint-disable disappear.
The one non-obvious step
apply() currently destructures: const { case: kind, value } = msg.payload; (287). TypeScript loses
discriminated-union narrowing across a destructure. The switch must become switch (payload.case) with
payload.value read inside each arm. That is the only mechanical change to the 230-line switch; everything
else is annotations.
Remaining sites
game.svelte.js:200/204/209/213/218/308/471→ the wire types, renamed on import to avoid colliding with the store's own same-named typedefs:@typedef {import('…').PlayerScore} WirePlayerScore. The?? []guards at 204/213 are dead under protobuf-es v2 (repeated fields always materialise).ChainHistory.svelte:115→@param {import('$lib/stores/game.svelte.js').PointPart} p(already exported).bot-session.svelte.js:68/74→ widen fromobject|nullto{ myScore: number }|null, drop the cast.connection.svelte.js:36→ClientMessage.tests/game-store.test.js:624/635/661dissolve onceGameStateexists;tests/ws-client.test.js(6) needs the sameServerMessagetypedef;e2e/helpers.js:123→@param {...Page} guests.
Net: 33 warnings → 0, and svelte-check starts earning its exit code. Budget for a handful of real errors
surfacing in components on the first run — that is the point.
3. Runtime correctness sweep
Confirmed
C1 — Resume latch has no timeout; the server answers an unknown token with silence.
online/+page.svelte:160-173 sets resuming = true and, when the URL carries a code, pending = {kind: 'join', code}. flush() refuses to send anything while resuming (288). resuming is cleared only by
arriving in a room (137-143) or by an error (253-268). Server side: session.go:643
if prior, ok := s.hub.resumable(token); ok && … { s.resumeFrom(prior) } — and hub.go:127-135 returns
ok == false for an unknown token, with no message sent. So a stale token (server restarted, or the
session was GC'd after grace) plus an invite link leaves the screen with an OPEN socket, resuming stuck
true, pending stuck set → all three buttons disabled reading "Đang kết nối…" (509/513/538), and the
stalled banner never fires because it requires connection.status !== OPEN (240). Permanent dead end;
only a manual reload escapes, which reproduces it.
Fix: a RESUME_TIMEOUT_MS timer armed alongside resuming, running the same path the error branch does.
Belongs in room-session (#4). Secondary: ask the server to answer a non-resumable token explicitly — the
resumeFrom comment at 651-656 already commits to "every failing branch has to say so", and this branch
does not.
C2 — leave() does not forget the session. 368-372 sends LeaveRoom, wipes the store, clears pending
— but omits forgetSession(), which the page-teardown path at 203 does call. The token stays in
sessionStorage, so the next load of /online in that tab takes the hasStoredSession() branch (160) and
tries to resume into the room the player deliberately left. Best case the server refuses and C1's stuck
state is one step away; worst case the seat is still live and they are put back.
C3 — Fire-and-forget sends. send() returns a boolean everywhere else in this file and is checked at
302 and in Lobby.report() (62-66). It is ignored at cancelQueue (328) and leave (369). Cancelling a
quick match while the socket is reconnecting therefore clears pending locally while game.state.queued
stays true — the waiting panel (490-503) stays up with its elapsed clock running and its Cancel button
doing nothing on every subsequent press. Same class as the flake below.
C4 — Duplicate-key throw on meanings. ChainHistory.svelte:124
{#each entry.meanings as sense (sense.gloss)}. Two senses with different pos and identical gloss are
not excluded by the wire contract; Svelte throws each_key_duplicate. Key by index — the list is neither
reordered nor filtered. Same fragility, lower probability, at ChainHistory.svelte:68
{#each entries as entry, index (entry.word)} (safe only because the server refuses repeats).
C5 — The lobby chat never reopens after the first game. chatFolded starts false (103) and is set
true by the effect at 108-110 when phase === 'playing'. Nothing ever sets it back. After a game ends the
phase goes over → (next game) playing; it never returns to lobby while the player stays in the room
(game.svelte.js:322 only promotes idle → lobby). So on narrow screens the between-games lobby has a
folded chat forever after the first game — the regression that 5178a97's fix does not cover. The pill in the
board header keeps it reachable, so this is UX, not a trap. Fix: fold on the lobby → playing transition
only, or unfold on over.
C6 — Seeding writes into a field the connection has disabled. WordInput.svelte:103-128 depends on
myTurn/phase/turnSeq/currentSyllable/rejection but not on connection.status, while enabled
(22-24) does. During a reconnect on the player's own turn the effect still calls field.focus() and writes
field.value = "${syllable} ", after lockedValue was captured pre-seed at 70-73. The first composition
event then calls undoInput (92-95) and yanks the seed back out. Cosmetic, but it makes the seed
non-deterministic exactly when the player is anxious. Add enabled to the guard at 104.
C7 — console.warn ships to production. game.svelte.js:514. Intentional per the comment, and I agree
with the intent — but it prints internal oneof case names to the console of every player on a version skew.
Route it through a one-shot dev-only guard or leave it; low.
Plausible
**P1 — the toBeEnabled flake in e2e/helpers.js:130.** readyAndStartclicks the guest's ready button and immediately asserts the owner's Start is enabled (10sexpecttimeout).Lobby.svelteline ~213 gates it on!s.canStart || offline, both server-owned, so the assertion is correct — but nothing between the click and the assertion proves the SetReady*left the client*.onready→report(onready(...))(224) →send(), which returns falseif the socket is notreadyState === 1 (client.js:299) and **nothing retries**: unsent(60) draws a banner and the request is gone. Meanwhile the button itself is only disabled onoffline, which is connection.status, and status reaches OPENinsidews.onopen— a window exists where Playwright sees an enabled button over a socket in an ambiguous state. *Two fixes, both worth it:* (a) give lobby actions the same held-request latchpendingalready gives join/create (lands free with #4); (b) in the helper, assert the guest's own state before starting —await expect(guest.getByTestId('my-ready')).toHaveText('Sẵn sàng')` — which converts a mystery timeout into
a precise failure on the right page.
P2 — route-swap socket race. /online teardown calls disconnect() (209) which nulls the module
singleton; /play mount calls connect() (69). If SvelteKit ever creates the new page component before
destroying the old one, connect() runs first and disconnect() then kills the socket the new screen owns,
leaving /play with pending set and no transport and no retry (session.flush is only driven by a status
change, play/+page.svelte:49-52). I could not prove the ordering without a browser. A connect() that is
idempotent-by-generation, or an owner token on the singleton, removes the question.
P3 — resume-failure handler swallows unrelated errors. online/+page.svelte:253-268 treats any
game.state.error arriving while resuming as a failed resume: it clears the error silently and spends the
held code. A server_full or too_fast landing in that window is erased with no trace. Narrow it to the
codes the resume path can actually produce (game_already_over).
Verified correct (do not "fix")
- Countdown vs. server clock —
countdown.js, the midpoint offset estimate (client.js:293), and the rAF loop gated onrunning(CountdownRing.svelte:30-37),SETTLE_MSerring early included. - Chat unread accounting — the two effects at
ChatPanel.svelte:64-66and 83-86 are correct under fold, resync (count < seenAt), theCHAT_WINDOWcap (counted againstchatCount, notchat.length), and the single-mount-across-phases arrangement the page comment at 417-422 depends on. - Storage failures —
settings.svelte.js:389-421andclient.js:68-90guard the property access itself, not just the call;readBestScores(428-445) validates shape and value;app.html's pre-paint theme script has its owntry. - Uncontrolled-input invariant —
beforeinputguard +inputundo + composition flags (WordInput.svelte:83-95, 205-208) and the three direct-write exceptions (seed 124, suggestion 164, clear 146) are each correct at their call site. Only C6 breaks the pattern. untrackusage — load-bearing at+page.svelte139/150/218/255 andplay/+page.svelte37/51/58; each prevents a self-retriggering effect, none hides a dependency that should be tracked.PlayerStatus.svelte:36-48grace bookkeeping handles the same-length-different-ids case correctly.
4. Performance and bundle
Client build (vite build): largest chunk 83.13 kB / 25.98 kB gz (Svelte runtime + @bufbuild/protobuf),
then 33.37 / 26.18 / 21.91 kB; /online route node 20.61 kB; CSS 13.61 kB (GameOverPanel) + 9.70 kB
(online route) + 3.98 kB (layout). Whole build comfortably under the 400 kB budget.
tests/bundle.test.js guards, well: dictionary words absent after unicode-escape decoding; no
.db/.sqlite/.csv/.tsv; total < 400 kB; build newer than src, so the assertions cannot go vacuous — that
last one is the good idea here, most bundle tests are silently stale. Gaps: no per-chunk ceiling (a
single 300 kB chunk passes), no gzip assertion, no sourcemap check. A largest-chunk budget is ~6 lines and
is the one that would catch an accidental dependency.
Chunking / fonts / CSS: nothing to do. No web fonts (app.css:45 — correct where system fonts already
carry the diacritics). No manualChunks, and none is warranted: every screen needs the socket and the proto
on first interaction. preload-data="hover" over five routes is cheap. ssr = false + prerender = false
means the SSR output under .svelte-kit/output/server/ is built but never copied into build/.
Svelte 5 idioms: current throughout — runes, snippets instead of slots, page from $app/state not the
deprecated $app/stores. No export let, no $:, no createEventDispatcher. Two nits: the bare
messages.length; dependency read at ChatPanel.svelte:92 is obscure (assign it to a const), and
$effect(() => () => clearTimeout(t)) (GameBoard.svelte:124-125, Lobby.svelte:81) is a dependency-free
effect used purely for teardown — onDestroy states that plainly and cannot later acquire a dependency.
5. Accessibility and i18n
Good already, unusually so: skip link; sr-only <h1> on the room screen (413); zero-specificity
:focus-visible ring via :where() (app.css:132-138); 44px .icon-button; prefers-reduced-motion
honoured in CSS and script (motion.js — necessary, since an explicit behavior beats the CSS rule);
turn indicator as role="status" aria-live="polite" aria-atomic (GameBoard.svelte:203-210); role="timer"
plus a separate two-mark spoken region rather than 60 announcements/second (CountdownRing.svelte:48-51, 77-79); deliberate non-duplication of the connection announcement (165-171); focus moved to the result
panel rather than to its button (GameOverPanel.svelte:25-34).
Gaps, ranked:
- Armed buttons announce nothing. Resign/claim/kick change their own label on the first press
(
GameBoard.svelte:239,Lobby.svelte:146). A screen reader announces a control's name on focus, not on in-place mutation — so a non-sighted player presses once, hears nothing, and either presses again blind or walks away. Foldaria-pressed(or a discreet live region) intoArmedButton(#9). - Chat log live region.
ChatPanel.svelte(the<ol aria-live="polite" aria-relevant="additions">) announces the reader's own messages back to them, andaria-relevantis inconsistently implemented. Prefer a dedicatedsr-onlyrole="log"carrying only the newest line wherefromMeis false — the patternCountdownRingalready uses. - Rejection alert doubles as a description.
WordInput.svelte:200-202pointsaria-describedbyat the same element that carriesrole="alert"(220), so the text is announced twice, and the alert contains two focusable buttons. Split: ansr-onlyalert for the announcement, a plain<p id="word-rejection">for the description and the buttons. - Focus after suggestion-fill is right, state is not.
useSuggestion()(161-167) focuses the field and places the caret correctly, but leavesgame.state.rejectionset — soaria-invalidstays true and the banner still offers the suggestion the player just took. ScoreBoardturn row has noaria-current.class:active(24) is the turn indicator for sighted users only; the row is otherwise indistinguishable.- Colour contrast is fine. Spot-checked the risky pairs:
--warn #8a5a08on--surface-alt #e9f0ea≈ 5.1:1;--text-muted #5b6a61on--bg #f4f7f4≈ 5.2:1;--player-1 #1d5c8fon white ≈ 7.1:1;--player-3 #8a4c12on white ≈ 6.7:1; dark-theme--warn #e9b949on#1f2a23≈ 8.1:1. All ≥ 4.5:1 at the sizes used.--border-strongexists precisely because--borderdoes not clear 3:1, and says so.
vi.js (359 lines) — leave it flat. Namespacing buys nothing here: one locale, no runtime loader, no
key collisions, and t.foo is already checked by svelte-check (it is a typed object literal, so t.subimt
is a compile error today — this is one of the few places typing currently works). The existing
comment-grouped sections are the right amount of structure. The real gap is that tests/i18n.test.js covers
the enum-derived maps exhaustively (reject reasons, end reasons, point kinds, difficulties, error codes)
but nothing asserts that fill() placeholders in the flat t keys match their call sites — a {name} typo
in playerTurn renders literally. A ~15-line test extracting {…} tokens from each t value and checking
them against fill() call sites would close it.
6. Tests
Vitest, by module. Covered: countdown (10), room-code (14), history-export (8), i18n (14),
settings store (16), ws/client (32 — genuinely good: injected clock, socket factory, scheduler),
game store (61, fed real decoded ServerMessages — the right call), bot-session (12), wire round-trip
(43), error codes (3), dictionary source (4), bundle (4).
Uncovered: every .svelte file. There are zero component tests and no component-test harness. Nothing
in tests/ imports a component. So all of this is browser-only today:
| Behaviour | Where it lives |
|---|---|
| Chat unread across fold / phase / resync | ChatPanel.svelte:58-96 — this is what broke CI today |
| Word field seed, guard, undo, suggestion-fill, submit-clear | WordInput.svelte:70-167 |
| Arm/disarm and the disabled matrix | GameBoard.svelte:74-125, Lobby.svelte:55-81 |
| Ring states (mine / stalled / urgent / idle) | CountdownRing.svelte:15-51 |
| Grace countdown bookkeeping | PlayerStatus.svelte:36-54 |
Recommendation: mount components under jsdom. jsdom is already a devDependency and two suites
already opt in per-file; Svelte 5 components compiled by the vite plugin mount directly with no extra
library:
// @vitest-environment jsdom
import { mount, unmount, flushSync } from 'svelte';
import ChatPanel from '../src/lib/components/ChatPanel.svelte';
No new dependency, no browser, runs on this ARM64 host. Start with ChatPanel (unread) and WordInput
(seed/guard) — between them they cover the two most-regressed behaviours in the tree.
Playwright: 47 specs across 3 files, workers: 1, fullyParallel: false, 60s timeout.
Delete — the Go suite already proves the rule, and the Vietnamese string is proven by i18n.test.js:
bot-game.spec.js:114 (word not in dictionary), :127 (too short), :137 (does not link),
pvp-game.spec.js:588 (unknown room code), :608 (latecomer refused), :624 (fifth player turned away),
:394 (room outlives its owner).
Replace with a unit or component test: bot-game.spec.js:242 (personal best — settings-store.test.js
owns the logic), :264 (attribution footer — static markup), :40 (syllable pre-seeded — a WordInput
component test), pvp-game.spec.js:460 and :528 (chat unread / send gating — a ChatPanel component
test). bot-game.spec.js:272 (deep link served by the binary, not a 404) is a server concern and belongs
in the Go suite.
Keep — genuinely browser-only: all of reconnect.spec.js (socket cut, resume, forfeit, dead-connection
typing — multi-context and transport-level); pvp-game.spec.js:100 (turns alternate), :140/:164 (invite
link, nameless guest), :193 (uncontrolled field refuses text out of turn — IME behaviour no jsdom test can
prove), :497 (a turn does not steal the chat field), :515 (rendered as text, never markup — the XSS
assertion), :674 (spectator after knockout); bot-game.spec.js:16 (one end-to-end game), :195 (download
plumbing), :217 (rematch starts exactly one game).
47 → ~14, multi-player and reconnect coverage untouched. Sequence after the component tests land.
Unresolved questions
- P2 (route-swap socket race): does SvelteKit destroy the outgoing page component before creating the
incoming one? Unverifiable here without a browser. If it does not,
/online → /playcan leave/playtransport-less. - C1's server half: should
handleHelloanswer a non-resumable token explicitly? That is a protocol change and a server decision — the client-side timeout (#1) is sufficient and should land regardless, but the explicit answer is the better contract andresumeFrom's own comment already argues for it. - Does the dictionary builder guarantee gloss-unique senses per word (C4)? If it does, the duplicate-key risk is theoretical — but the key should still not depend on a guarantee nothing in this repo states.
- Is the
chatFoldedbehaviour in C5 intended (fold once, stay folded) or an unnoticed consequence of 5178a97? This is a product call, not a defect call. - Playwright wall-clock today is unmeasured here (no browser). The 47 → 14 recommendation assumes the suite is a meaningful part of CI time; if it runs in under three minutes, prioritise #8's component-test half and defer the deletions.