Files
noitu/plans/reports/fullstack-developer-260929-1939-web-fixes.md
T

14 KiB

Web fixes from the review

Branch dev, 2026-09-29. Not committed. Only files under web/ changed. Playwright was not run (no browser), and no e2e spec was added or changed.

Gates

cd web && npm run check && npm run lint && npm test: check 0 errors and 0 warnings (386 files), lint clean, 326 tests passed in 18 files (was 270 in 16). tests/bundle.test.js is in that run and passes.

Fail-without-fix check: I reverted each behavioural fix one at a time and ran its test file. Every revert made at least one test fail, and each file was restored afterwards. The reverts were:

  • session_not_resumable in LEAVES_ROOM
  • the iAmOut row check
  • the ordinal reuse in the chat history
  • the numbering in chainToText
  • freshSession
  • the game.leave() on a refused resume
  • the onResumeRefused() call in client.js (4 tests fail)
  • the shortest-RTT clock filter
  • the GameOverPanel focus guard
  • the ChatPanel refused-send guard
  • the ChatPanel offline disable
  • the away banner's role="status"

Finding 1 is the exception: it needed no client change, so there was nothing to revert.

Per finding

  1. Game ended while away. No client change was needed. The reducer already handles the replay sequence from playing: RoomState fills in the room and leaves the phase alone, ChatHistory replaces the chat, and GameOver then moves the phase to over with the result and standings. The lobby actions appear because over renders the Lobby. Three store tests pin it: the exact sequence lands on over with standings, the room snapshot survives (so Ready and Leave are available), and a mid-game RoomState alone does not end the game. These tests pass on the unmodified reducer, so they are regression pins and not fail-without proofs.
  2. Refused resume.
    • session_not_resumable is now in LEAVES_ROOM (game-apply.js).
    • client.js tracks whether the current Hello carried a token. After the Welcome, an error that arrives before any other non-Pong frame is a refused resume. The client then drops the spent token and calls the new onResumeRefused option. Pongs are skipped because the client pings right after Hello. connection.svelte.js wires the option to game.leave(). It runs before the error is forwarded, so the board clears and the error banner still shows.
    • Tests in ws-client.test.js cover: both codes, a Pong in between, an error after the restored room (no report), a Hello without a token (no report), a spent token, and error forwarding. Two tests in connection.test.js and one in game-store.test.js cover the rest.
    • On /play, an in-game refusal now lands on an idle board with the error banner. I did not add a "start again" button.
  3. iAmOut. It is now true when elimination !== null or the player's own row in gamePlayers is eliminated. Two tests: out from the row alone, and another player's elimination does not count.
  4. /play reload.
    • connect({ freshSession: true }) forgets the stored token before a new client is created. startGame() uses it.
    • I did not call forgetSession() unconditionally before connect(). A rematch calls startGame() again on the socket that already exists. Forgetting there would delete the token this game's Welcome had just stored, and a later socket drop would open a fresh session instead of resuming the game. The flag only acts when a new client is created, so an existing socket keeps its token.
    • forgetStoredSession() is now a module-level export in client.js, and the client's forgetSession is the same function. It is needed because there is no client yet on the first mount.
    • connection.test.js checks the first Hello carries no token with the flag, carries the stored token without it, and that a second connect({freshSession}) on a live socket keeps the Welcome's token.
    • The +page.svelte call site itself is not covered, because there is no page-level test harness.
  5. Held action vs resume. In online/+page.svelte the flush effect now waits for the resume's RoomState before flushing a held action, if the socket dropped while in a room.
    • It notes the roster when the socket drops. After reopening, it releases the action once the roster object changes, since each RoomState replaces it.
    • If the resume is refused and the room is gone, the held action is cleared instead of sent.
    • An action held outside a room (cancelling the queue) is still flushed as soon as the socket opens.
    • This is page logic and has no unit test. It needs a human or e2e check.
  6. Leave while offline. leave() now sends LeaveRoom if the socket can carry it and otherwise drops it, never holds it. It also clears any other held action. The seat is freed by grace expiry either way.
  7. Chat offline. onsend returns a boolean and say() returns send()'s result. The draft is cleared only when the send went out. The send button is disabled while connection.status !== OPEN, and submit() also refuses offline. New tests: a refused send keeps the text, and the button is disabled and nothing is sent while reconnecting. The existing chat tests now set the connection to open.
  8. Away countdown. Each banner's role="status" span holds only "X mất kết nối…". The seconds sit in a sibling aria-hidden span. data-testid="away-…" is on the outer <p>, so the e2e toContainText('… mất kết nối') still matches. vi.js swaps playerDisconnectedIn for playerDisconnectedSeconds: '({n}s)'.
  9. GameOverPanel focus. New src/lib/focus.js exports typingElsewhere(own). WordInput imports it instead of its private copy, and GameOverPanel only focuses when the player is not typing elsewhere. Tested with a focused external input.
  10. Clock offset. The client keeps the last 5 pong samples {rtt, offset} and uses the offset of the one with the smallest RTT. The existing single-sample test is unchanged. New tests: a slow pong does not move the offset, a faster pong takes over, and an old fast sample ages out.
  11. Partial chain.
    • chainToText numbers from result.chainLength: the opening word is always 1, and later words are numbered back from the total. It inserts a … line after the opening when words are missing (new string exportGap). Two tests cover a partial and a complete chain.
    • Skipped: the optional "…" row in ChainHistory on screen. It was marked optional.
  12. Chat re-announce. A chatHistory line matching an on-screen line on (atMs, playerId, text) keeps that line's ordinal, so its keyed row is not re-inserted into the live region. Identical duplicate lines each claim one old line. Two tests.
  13. WordInput tests.
    • Added: submit clears the field only when onsubmit returns true, and submit is refused during composition (and works after compositionend).
    • Added: beforeinput is cancelled out of turn and not on the player's turn, and a composition's text is reverted out of turn.
    • The vacuous composition test is rewritten as "leaves the player's own text alone on their turn". It fails if the revert ignores enabled, but its purpose is to pin the on-turn behaviour.
  14. Tests.
    • New tests/status-components.test.js covers CountdownRing, PlayerStatus, GameOverPanel and ArmedButton. Details are below.
    • ws-client.test.js now covers storage that throws on read, on write, on remove, and a sessionStorage property that throws.
    • New tests/reactive-props.svelte.js is a helper only, so a test can change a mounted component's props (the $state rune needs a .svelte.js module).
    • i18n.test.js has a new test that scans src/** for fill(t.key, {…}) calls and checks the object's keys equal the template's placeholders. It asserts more than 10 calls were matched, and it cannot see calls whose template is chosen by a conditional, which the test comment notes.
    • Skipped: a Lobby component test. It was marked "if time allows", and Lobby is large and mostly wiring.
  15. vite.config.js. The comment no longer says "two suites".
  16. h1 on /online. Now var(--text-5), which is what the rules page h1 uses. The size goes from 1.3rem to 1.5rem, a small visible change.

status-components.test.js contents:

  • CountdownRing: idle dash, rounded-up seconds and label, urgent only on the player's own turn, stalled instead of urgent offline, and the spoken 10 s mark.
  • PlayerStatus: banner structure, countdown to zero, banner removed on return.
  • GameOverPanel: standings and rank classes, no table for a one-row result, focus taken, focus refused while typing elsewhere, rematch button only when supplied, and export producing a .txt download.
  • ArmedButton: arm, confirm, timeout disarm, disarm on disabled.

Other changes

  • Footer link (security review L5, footer part). data/ATTRIBUTION.md (the nine-item modification list) is not served over HTTP by the server or the web build. AttributionFooter now links "xem danh sách thay đổi" to https://github.com/tiennm99/noitu/blob/main/data/ATTRIBUTION.md. I confirmed the file exists on main. Strings attributionModified and attributionChanges are in vi.js. The rules page was not changed.
  • New error message. errcodes.go now has unknown_message, added concurrently by the server agent. It made tests/error-codes.test.js fail, so I added a Vietnamese message for it in vi.js: "Máy chủ không hiểu yêu cầu này. Hãy tải lại trang." Please confirm the wording.

.primary consolidation

app.css now has a global .primary: border-color: transparent, background: var(--accent), color: var(--accent-text), transition: background-color 150ms ease-out, plus :hover:not(:disabled) to --accent-hover, :active:not(:disabled) to --accent-pressed, and :disabled to --surface-alt background and --text-muted colour. It does not use :where(). Focus-visible is unchanged, since the global :where(...) focus rule was never touched. Sizing, padding, radius, weight and border width stay local.

Rules removed:

  • online/+page.svelte:
    • .primary lost background, color and transition, keeping min-height, padding, border: 0, border-radius and font-weight.
    • Removed .primary:hover:not(:disabled), .primary:active:not(:disabled) and .primary:disabled.
    • These were identical to the global ones.
  • ChatPanel.svelte:
    • .row button lost background, color and transition.
    • Removed .row button:hover:not(:disabled), .row button:active:not(:disabled) and .row button:disabled.
    • The send button gained class="primary".
  • WordInput.svelte:
    • .input-row button lost background, color and transition.
    • Removed .input-row button:hover:not(:disabled), :active:not(:disabled) and :disabled.
    • The submit button gained class="primary".
    • The .fix.suggestion rules are untouched.
  • +page.svelte (landing):
    • Removed .actions .primary, .actions .primary:hover and .actions .primary:active.
    • The old hover and active had no :not(:disabled), but that button is never disabled.
    • .actions > * kept size, padding, radius and weight. It no longer sets the border colour, background or color: inherit.
    • A new .actions > :where(:not(.primary)) sets border-color, background: var(--surface) and color: inherit.
    • border was split into border-width: 1px; border-style: solid, so the shorthand's currentcolor cannot beat the global transparent.
  • GameOverPanel.svelte:
    • Removed .actions .primary, .actions .primary:hover and .actions .primary:active.
    • .actions button kept its sizing and border-width/border-style. The border colour and background: var(--surface) moved to .actions button:where(:not(.primary)).
    • :where() keeps that rule's specificity at (0,1,1), so .actions .export still overrides it as before.

I checked the compiled selectors of the landing page and GameOverPanel with the Svelte compiler.

The report claimed the local rules only restated the colours. For the landing page and GameOverPanel that was wrong: .actions > * and .actions button set a background that would have beaten the global class. The :where(:not(.primary)) split above is the fix. Lobby is untouched.

Needs a human visual check (no browser here)

  • The four .primary surfaces: landing "Chơi với máy", /online quick match and create buttons, the chat send button, and the word submit button. Check rest, hover, press, disabled and the dark theme.
  • The GameOverPanel action row: the rematch button is accent-filled, home is a bordered button, and the export button is transparent and muted.
  • The /online h1 at 1.5rem.
  • The away banner: the seconds should sit inline after the sentence, with a space between them.
  • The footer: the extra sentence and link should still fit on a phone width.
  • Behaviour that needs a real reconnect, ideally an e2e run when a browser exists:
    • Finding 5: kick or ready right after a socket cut.
    • Finding 6: leave while offline.
    • Finding 7: chat button disabled during a cut.
    • Finding 4: reload /play mid-game.
    • Finding 1 end to end, once the server change lands.

Notes

  • After a refused resume on /play the player sees an idle board with a banner and must use Home or reload. An automatic restart would be a product call.
  • The server's replay for Finding 1 was not verified against the wsapi change, which is being written concurrently. The client relies only on the frame order given in the task.

Status: DONE_WITH_CONCERNS Summary: All 16 findings, the .primary consolidation and the footer link are done in web/; check, lint and 326 tests pass, and each behavioural fix has a test that fails when reverted (Finding 1 needed no client change). Concerns: The Finding 5 page logic, the /play freshSession call site and all CSS changes are unverified without a browser (list above). I added a Vietnamese string for the new server code unknown_message. I did not add an e2e spec or a Lobby component test.