18 KiB
Web frontend review: whole codebase
Branch dev @ d3eb13e, 2026-09-29. Read-only review. The scope was web/src (excluding lib/proto), web/tests, web/e2e (read as code only), and the web config files.
Gates at start and end: npm run lint 0/0, npm run check 0 errors / 0 warnings (385 files), npm test 270 passed (16 files).
Playwright was not run (no browser on this host).
This review builds on these reports and does not repeat what they found:
web-refactor-260928-1348-review-and-refactor.mdcode-reviewer-260921-1529-web-architecture-review.mdui-ux-designer-260921-1529-whole-game-ux-review.md
Verdict
The store and reducer are careful and well tested for the message streams they expect. The real defects are all on reconnect paths where the server's replay does not describe the state the player is actually in. In each one the UI keeps showing a room or game that the server has already moved past:
- The game ended while the player was away.
- The player was eliminated before dropping.
- The resume was refused while the player was still in a room.
- A reload of
/play. - A held lobby action flushed straight after
Hello.
None of these is covered by a unit test or an e2e test. Findings 1, 2 and 3 were confirmed against the real store with a throwaway Vitest file. The file fed the store the exact frame sequence handleResume / resumeFrom emit (server/internal/wsapi/room_presence.go:113-173, dispatch.go:267-307), all four assertions reproduced the stale state, and the file was deleted afterwards.
Findings
| # | Sev | Where | What is wrong |
|---|---|---|---|
| 1 | High | stores/game-apply.js:81-98 + server room_presence.go:159-165 |
A player who drops mid-game and comes back inside the grace window, after that game has ended, is resumed into the lobby with RoomState + ChatHistory only. No GameOver arrives and roomState deliberately does not move phase, so the client stays in phase: 'playing'. Result: a frozen board, possibly a stale myTurn: true, and no Lobby (the Lobby is only rendered for lobby/over), so there is no Ready or Leave button. A guest in that state blocks the owner's Start until they navigate away. |
| 2 | High | stores/game-apply.js:25 (LEAVES_ROOM), :252-264 |
A resume refused while the UI still shows a room is only rendered as a banner. The two cases are session_not_resumable (dropped for longer than the grace window, or the server restarted) and game_already_over (a bot room that closed on game over while the player was offline). The room or board stays on screen with connection: open. On /play the dead board has no rematch button (GameOverPanel needs phase === 'over'), and the input and resign stay enabled if myTurn was true. The /online page's own recovery (+page.svelte:272-278) only fires while session.state.resuming, which is set only on page mount, not on an in-page socket drop. |
| 3 | Med | stores/game.svelte.js:117-119 (iAmOut) |
iAmOut is phase === 'playing' && elimination !== null. A player eliminated in a game of 3 or more who refreshes or reconnects gets the replayed GameStarted. That runs reset(), which nulls elimination, even though the replayed players row has isMe && eliminated: true. So iAmOut is false, the spectator box is replaced by a permanently waiting WordInput, and a disabled claim/resign row is shown. This was confirmed in the store: gamePlayers[me].eliminated === true, iAmOut === false. |
| 4 | Med | routes/play/+page.svelte:42-52, 70-78 |
A reload mid-bot-game never runs the teardown, so the tab's resume token survives. The mount then sends Hello{resumeToken} and, as soon as the status reaches OPEN, StartBotGame on the same socket. The server resumes asynchronously (resumeFrom posts to the room goroutine). If the resume attaches first, the player sees the resumed board under a red already_in_a_game banner. If StartBotGame wins, a second bot room is opened, then displaced by the resume, and the client receives two GameStarted frames. Either outcome is wrong. The same happens if the tab holds a token from /online and the player opens /play by URL: it resumes the PvP seat on the bot screen. |
| 5 | Med | routes/online/+page.svelte:221-227 (flushAction), room-session.svelte.js:188-193 |
A held lobby action is flushed the moment the status reaches OPEN, which is right after Hello. For an in-page reconnect, Hello carries the token and the server attaches the seat asynchronously on the room goroutine, so KickPlayer/SetReady/StartGame is most likely read before the attach and answered not_in_a_room (dispatch.go:224-227). The player sees the red lobby-error "Bạn không ở trong phòng nào." in a room they are in, and the held action is discarded (the send "succeeded"). Kick is reachable because its button is not gated on offline (Lobby.svelte:113-122). flush() for join/create already guards resuming; flushAction has no equivalent. |
| 6 | Med | routes/online/+page.svelte:406-414 (leave) |
Pressing Leave while the socket is down holds leaveRoom, then clears the room and forgets the token. The reconnect therefore opens a fresh session, and the held LeaveRoom it flushes is answered not_in_a_room. That lands as a red join-error on the join form the player just returned to. The held message cannot do anything useful: without the token, the seat is released by grace expiry either way. |
| 7 | Med | components/ChatPanel.svelte:128-144, 213; online/+page.svelte:416-419 |
A chat line sent while the socket is down is lost silently. say() ignores send()'s false, and submit() clears both draft and the field unconditionally. The send button is not gated on the connection, unlike WordInput (WordInput.svelte:22-24). The player types a message during a blip, presses Gửi, the text vanishes, and nothing is ever posted or explained. |
| 8 | Med (a11y) | components/PlayerStatus.svelte:66-75 |
Each away player's banner is role="status" (implicitly aria-live="polite") and its text holds a countdown that changes every second. A screen reader announces "X mất kết nối, còn N giây" once a second for up to 30 s, once per dropped player, on both the board and the lobby. This is the same defect the UX review fixed for the quick-match counter (online/+page.svelte:522-527), but this one was missed. |
| 9 | Low | components/GameOverPanel.svelte:33-35 |
panel.focus() fires on every new result, with no guard. In the wide layout (or for a knocked-out spectator), a player typing in the chat input when the game ends has focus pulled off the chat mid-sentence, and the rest of their keystrokes go nowhere. WordInput already has the right guard (typingElsewhere, WordInput.svelte:52-59). |
| 10 | Low | ws/client.js:291-299 |
clockOffsetMs is overwritten by every pong, one sample every 5 s. On a jittery mobile link the error is up to ±RTT/2 per sample, so the ring and the seconds label jump by a few hundred ms every 5 s, and can tick back up a second. SETTLE_MS (300) only covers one side of that. |
| 11 | Low | history-export.js:62-63; ChainHistory.svelte:68-73 |
After any mid-game resume the chain holds only [opening, lastMove], because GameStarted has no chain field. The transcript numbers the last move "2." even when result.chainLength is 30, and the live chain shows two unrelated words as if they were adjacent. |
| 12 | Low (a11y) | components/ChatPanel.svelte:170-186; game-apply.js:244-250 |
chatHistory renumbers every line (++chatOrdinal), so on each reconnect all up to 20 <li> are re-keyed and re-inserted inside an aria-live="polite" aria-relevant="additions" log. A screen reader re-reads the whole conversation after every blip. |
| 13 | Low (tests) | tests/word-input.test.js:3-8 vs the rest of the file |
The header says the submit-clear is "pinned down", but no test submits. Also untested: clearing only when onsubmit returns true, refusing while composing, and the out-of-turn beforeinput guard / undoInput revert. leaves an in-progress composition alone (:103-118) would pass with the composition handling deleted, because on the player's own turn nothing else touches the value. |
| 14 | Low (tests) | none | No coverage for findings 1 to 8, which are all reconnect paths. CountdownRing, PlayerStatus, GameOverPanel (focus, export click), Lobby and ArmedButton have no component test. client.js storage guards (safeSessionStorage, storeToken throwing) have no test, unlike settings.svelte.js. No test asserts that each fill(t.x, {…}) supplies exactly t.x's placeholders. A scan run for this review found no mismatches today. |
| 15 | Nit | vite.config.js:27 |
"The two suites that need a DOM": five do (chat-panel, settings-store, ws-client, game-board, word-input). |
| 16 | Nit | routes/online/+page.svelte:679 |
h1 { font-size: 1.3rem } is still off the type ramp (deferred last time). var(--text-5) (1.5rem) or --text-4 (1.125rem) is the nearest step. |
Recommended fixes (each small)
-
Game ended while away. The client cannot detect this with the current protocol: a mid-game
RoomStateis normal. This needs a server change.- In
handleResume'sinLobby()branch, send the seat itsGameOverif a game finished while it was detached. The room would keep the last standings and reason per game. - Alternatively, add
bool in_game = 13;toRoomState, and haveroomStatesetphase = 'lobby'when it is false andphase === 'playing'. - Add a server test: 2 players, one drops, the game ends, the dropped player resumes inside grace, and it must receive
GameOver(orin_game: false). - This crosses the web/server boundary, so it is the lead's call.
- In
-
Refused resume.
- Add
'session_not_resumable'toLEAVES_ROOM. It is only ever sent in reply toHello(dispatch.go:277,room_presence.go:120), so leaving on it is always right.game.leave()then lands/onlineon the join form with the banner. Theresumingeffect still clears it on mount as today. game_already_overis shared with a droppedResign, so do not blanket-leave on it. Inclient.js, remember that the lastHellocarried a token. If the frame right afterwelcomeis anerrorwhose code issession_not_resumableorgame_already_over, report it through a newonResumeRefusedcallback.resumeFromsends the error in the same call as the Welcome, so nothing can come between them.connection.svelte.jsthen callsgame.leave().- On
/play, the resultingidlephase needs a "start again" affordance, or simplystartGame(difficulty).
- Add
-
iAmOut:return state.phase === 'playing' && (state.elimination !== null || state.gamePlayers.some((p) => p.isMe && p.eliminated));. GameBoard's spectating box already handleselimination === null: it showsyouAreOutwithout suggestions. -
/playreload: callforgetSession()(already exported) instartGame()beforeconnect(). Reload-means-new-game on the same rung is what the page's own comment describes (play/+page.svelte:22-24). Otherwise, if resuming a bot game is the intent, mirror/online: whenhasStoredSession(), holdsession.requestuntil the resume answers. That choice is a product decision (see Unresolved). -
Held action vs resume: gate
flushActionon the seat being re-established, not on the socket being OPEN. The smallest version is to flush from an effect keyed ongame.state.roomPlayers, which changes on theRoomStatethe resume broadcasts, whenheldActionis set and the status is OPEN. The alternative is to setsession.startResume()whenever the status leaves OPEN whileinRoom, and let the existingnoteRoom()clear it. -
Leave while offline: in
leave(), send when possible and never hold:if (!dispatchAction({ kind: 'leaveRoom' })) {/* token is forgotten; grace frees the seat */}, and drop theactwrapper there. -
Chat offline: make
onsendreturnboolean(say = (text) => send(sendChat(text))). Insubmit(), only clear ontrue. Adddisabled={!sendable || offline}to the button, using the sameconnection.statusread as WordInput. Add a chat-panel test withonsend: () => falsethat expects the field to still hold the text. -
Away countdown:
- Split each banner into a live sentence without the number, "X mất kết nối", inside
role="status". - Move the seconds into an
aria-hidden="true"span, as the quick-match counter already does. - Optionally announce once at 10 s.
- Split each banner into a live sentence without the number, "X mất kết nối", inside
-
GameOverPanel focus:
if (game.state.result && !typingElsewhere()) panel?.focus();. Hoist WordInput'stypingElsewhereinto a tiny$lib/focus.jsso both components use it. -
Clock offset: keep the sample with the smallest RTT among the last ~5 pongs (a ring of
{rtt, offset}) and use that offset. This is the standard NTP-style filter, about 10 lines, andtests/ws-client.test.js:278already has the harness to cover it. -
Partial chain:
- In
chainToText, number from the server:const n = chainLength - (chain.length - 1 - index), withchainLengthpassed in fromgame.state.chainLength. - Insert a
…line whenchain.length < chainLength. - Optionally render one "…" row in ChainHistory under the same condition.
- In
-
Chat re-announce: in
chatHistory, reuse the ordinal of an existing line with the same(atMs, playerId, text), or give the replacement a fresh{#key}wrapper outside the live region. The first keeps Svelte from re-inserting the nodes. -
WordInput tests: add three.
- Submit clears the field when
onsubmitreturns true and keeps it when it returns false. - Submit is refused during
compositionstart. - An out-of-turn
inputis reverted tolockedValue.
Replace or delete the vacuous composition test.
- Submit clears the field when
The deferred global .primary class: worth doing now, partially
The same accent fill, hover, press and disabled block (about 15 lines) appears in:
online/+page.svelte:699-721ChatPanel.svelte:330-351+page.svelte:84-97Lobby.svelte:383-407- WordInput and GameOverPanel
Recommendation:
- Add a global
.primarytoapp.csscarrying only colour and state: background, colour, a transparent border, the transition, and:hover/:active/:disabledwith:not(:disabled). Components keep their own sizing and padding. - Apply it to the online page, the landing page, ChatPanel (add
class="primary"to its send button), WordInput and GameOverPanel. Their local rules only restate the colours, so deleting them changes nothing visually. - Leave Lobby alone for now. Its scoped
.actions button { background: var(--surface) }compiles to (0,2,1), which beats a global (0,1,0).primary. Adopting the global class there first means moving that background into.actions button:not(.primary), which is the part that needs a visual check. - Do not use
:where(.primary): zero specificity would lose to every scoped basebuttonrule.
online/+page.svelte (808 lines). About 390 of those lines are CSS. The concrete split that is still worth doing is the join-form branch (:483-590 plus its about 120 lines of styles) into JoinPanel.svelte, which receives session, named, codeInput and the three request callbacks. It was deferred by a task decision last time, so it is not listed as a finding.
Checked and clean
game-apply.js:roomStateapplies the whole snapshot at once and never merges it with the old one.turnUpdatewithoutplayedkeeps the rejection.LEAVES_ROOMruns before the error is set.- The chat window is trimmed.
- The try/catch boundary in
apply()is intact. MoveRejected.turn_seqis ignored, but every path that movesturnSeqon the server also sends aTurnUpdateor replay on the same ordered socket. No failure scenario was found, so this is not reported.
client.js:- Backoff resets on
welcomeand not on open. - Liveness ignores throttled ticks.
reconnectNowcannot create a second socket.- The terminal-error stop works.
close()and a laterconnect()never share a client instance, becausedisconnect()discards it, so the stale-oncloseclobber is unreachable today. A lateonclosefrom a discarded client cannot touch the status, since its own status is already CLOSED.- Both storage accessors are guarded at the property and at the call.
- Backoff resets on
settings.svelte.js:- Every read and write is guarded.
- Corrupt JSON and non-finite scores degrade cleanly.
- The nickname cap counts code points.
- The theme is normalised, and matches the inline script in
app.html.
- Countdown maths (
countdown.js): clamped, rounded up, settle margin applied, and the rAF loop runs only whilerunningand is cancelled on cleanup. - Runes:
- Every
$effectthat writes state either writes state it does not read or usesuntrack. The online, play, PlayerStatus and ChatPanel effects were checked individually. - Timers and listeners are cleaned up: matchMedia, the quick-match interval, the stall, resume and arm timers, the PlayerStatus interval, the ResizeObserver, rAF, and the RoomCodePanel timer.
- No
$effectthat should be a$derivedwas found. The ones that assign are bindable props or trigger-style latches.
- Every
- i18n:
checkJs+strictmakes a mistypedt.keyasvelte-checkerror.- No Vietnamese prose outside
vi.js, except the static<meta description>/<title>inapp.html. - A throwaway scan of every
fill(t.x, {…})found no missing or extra placeholder. - Server error codes are guarded by
tests/error-codes.test.js.
- The rules page scoring constants match
server/internal/game/engine.go:28-51andvietnamese.MinSyllables. - Focus:
- A turn arriving focuses the word field unless the player is typing elsewhere.
- A reconnect re-enables the field and focuses it.
- Game over focuses the panel, apart from finding 9.
- ArmedButton announces its armed state through
aria-pressed.
- Keyboard: the difficulty picker uses native radios, the chain rows are buttons with
aria-expanded/aria-controls, and the skip link targets#main. - Configs: the Vitest alias and
browsercondition are correct, the Playwright server env is sane, and dependencies use caret ranges (moving, as the workspace rule prefers).
Unresolved questions
- Finding 4: should reloading
/playmid-game resume the bot game or start a fresh one on the same rung? The page comment implies a fresh one, whilehasStoredSession()'s doc implies a resume. - Finding 1: server replay of
GameOveror a newRoomState.in_gamefield? The first needs no proto change. The second is a smaller change on each side but bumps the contract.