mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-15 08:17:12 +00:00
docs(plans): whole-project server, web and UX reviews
This commit is contained in:
1 parent
3ef9f48df0
commit
e4f9917312
3 files changed
+1137
No files matched your search
@@ -0,0 +1,429 @@
|
||||
# Server architecture review — noitu Go server
|
||||
|
||||
Branch `dev` @ 5178a97 · 2026-09-21 · read-only · scope `server/`, `proto/`, `Makefile`, `Dockerfile`, `.github/`
|
||||
|
||||
## Verdict
|
||||
|
||||
The server is in good shape and the design is sound. `go vet`, `golangci-lint`
|
||||
(defaults) and `go test ./... -race -count=1` are all clean; coverage is
|
||||
88–100% everywhere except `cmd/noitu-server` at **0.0%**. No data race, no
|
||||
goroutine leak (`TestGoroutinesReturnToBaseline`), no auth bypass, no injection
|
||||
surface, no PII in logs.
|
||||
|
||||
The one-goroutine-per-room actor with a single `inputs chan any` is **the right
|
||||
shape and should stay**. `room.go`'s 1858 lines are a *file* problem, not an
|
||||
architecture problem: every method is on `*room`, called only from `run()`, so
|
||||
the engine-ownership invariant holds by construction and a pure file split
|
||||
preserves it at zero risk. Do not convert it to typed handlers or a state
|
||||
machine, and do not split lobby from game — see §1 for why each would cost more
|
||||
than it buys.
|
||||
|
||||
Real defects are small and few. The most valuable work is not defect-fixing: it
|
||||
is (a) the per-IP join limiter, which is simultaneously the CI flake and a
|
||||
CGNAT availability bug, (b) removing `State.History` from the hot broadcast
|
||||
path, (c) splitting the four oversized files, (d) closing the 0% coverage hole.
|
||||
|
||||
## Top 10 ranked actions
|
||||
|
||||
| # | Action | Kind | Size | Risk | Why now |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Per-IP join limiter is shared by every client behind one NAT egress (1/s, burst 5) — raise it, key it better, or make it configurable | fix | S | low | Explains today's CI flake **and** breaks a whole café/CGNAT of real players (§4 C2, §5) |
|
||||
| 2 | Add a per-IP concurrent-connection cap (F6) | fix | S | low | One host can hold all 2000 sockets; the only remaining unbounded-per-peer resource |
|
||||
| 3 | Split `room.go` into 7 files along existing seams | refactor | M | ~nil | 1858 lines is past the point where a reviewer can hold it; pure moves, invariants preserved by construction (§1) |
|
||||
| 4 | Drop `History` from `game.State`; add `LastMove()` + `UsedWords()` | refactor | S | low | O(chain) copy on **every** TurnUpdate and every bot move, for two call sites (§2) |
|
||||
| 5 | Refuse `StartGame` / quick-match auto-start while draining (F7) | fix | S | low | `beginGame` can raise `liveGames` after the drain decision; drain then waits its full timeout (§4 C3) |
|
||||
| 6 | Guard `attach` against a session already torn down | fix | S | low | Quick match can auto-start a game against a dead socket, burning a 30 s turn clock in front of a real player (§4 C1) |
|
||||
| 7 | `cmd/noitu-server` at 0% — add `main_test.go` for the env parsers + drain loop | fix | S | nil | Only real coverage hole; all pure functions; config parsing is where a prod misconfig hides |
|
||||
| 8 | Split `wsapi_test.go` (2534 lines, 75 tests) by topic; delete the 3-line `hub_test.go` | refactor | M | nil | Mirrors #3; a test file nobody can navigate stops being extended (§5) |
|
||||
| 9 | Split `session.go` into socket half / protocol half | refactor | S | nil | Two unrelated concerns in one 746-line file (§1) |
|
||||
| 10 | Dockerfile: `golang:1.25-alpine` → `golang:1-alpine`, `alpine:3.22` → `alpine:3` | fix | S | low | House rule is moving-major; dependabot is currently generating churn the rule exists to avoid (§6) |
|
||||
|
||||
Deliberately **not** in the list, with reasons in-section: typed handler
|
||||
dispatch (§1), lobby/game actor split (§1), `Engine` API consolidation (§2),
|
||||
bot `Board` changes (§2), committing word data (§3).
|
||||
|
||||
---
|
||||
|
||||
## 1. Structural assessment
|
||||
|
||||
### `wsapi/room.go` — keep the actor, split the file
|
||||
|
||||
The actor is doing real work that a redesign would have to re-earn:
|
||||
|
||||
- `room.go:387` `run()` is the only place the engine is touched; every handler
|
||||
is a `*room` method reachable only from the type switch at `room.go:474-511`.
|
||||
- `room.go:540-543` — one `RoomState` per input, driven by the `lobbyChanged`
|
||||
flag, from the single place that knows the input finished. No handler has to
|
||||
remember to broadcast.
|
||||
- `room.go:498-511` — timers are recomputed after *every* input rather than per
|
||||
arm, so a new input type cannot silently forget one.
|
||||
- `room.go:362` `send()` never blocks a session goroutine, and checks
|
||||
`ctx.Done()` on its own before the `select` so a dead room cannot report a
|
||||
false success.
|
||||
|
||||
**Proposal (recommended): 7 files, pure moves, no signature changes.**
|
||||
|
||||
| File | Contents | ~LOC |
|
||||
|---|---|---|
|
||||
| `room.go` | struct, `newRoom`, `send`, `run` + timer closures, `inLobby`/`occupied`/`freeSeat`/`seatedCount`/`allConnected`/`seatOf`/`sendTo`/`broadcastError` | 350 |
|
||||
| `room_inputs.go` | every `*Input` struct + `lobbyAction` consts (`room.go:94-204`) | 120 |
|
||||
| `room_lobby.go` | `handleCreate`, `handleJoin`, `handleLobby`, `canStart`, `guestsReady`, `takenNicknames`, `vacate`, `promote`, `playerSlots`, `broadcastRoomState`, `seatIDs` | 380 |
|
||||
| `room_game.go` | `beginGame`, `handleSubmit`, `handleResign`, `handleClaimDeadEnd`, `handleBotMove`, `maybeScheduleBot`, `mark`/`inputMark`, `applyEliminations`, `broadcastTurn`/`sendTurnUpdate`/`sendGameStarted`/`broadcastElimination`/`broadcastGameOver`, `scoreRows`, `wireEndReason`, `nearMissFor`, `recordRejection`, `handleReportWord` | 420 |
|
||||
| `room_presence.go` | `handleDisconnect`, `nextGraceExpiry`, `handleGraceExpiry`, `eliminateAbsent`, `handleResume`, `detachAll` | 200 |
|
||||
| `room_chat.go` | `chatEntry`, `handleChat`, `sendChatHistory`, `chatMessageFor` | 150 |
|
||||
| `bot_board.go` | `frozenBoard`, `freezeBoard` (`room.go:1824-1858`) | 50 |
|
||||
|
||||
Invariants: **all preserved, by construction.** Same package, same receiver,
|
||||
same call graph — the engine stays on the room goroutine because nothing else
|
||||
can reach a `*room` method; the one-broadcast-per-input site does not move; the
|
||||
per-recipient rendering loops are untouched. Risk is limited to a bad
|
||||
copy-paste, which `go build` catches.
|
||||
|
||||
**Rejected — typed handlers (`interface{ apply(*room) }` or a handler map).**
|
||||
The type switch is 30 lines and is the *only* dispatch in the package. Moving it
|
||||
onto the input types scatters the two exceptions that currently read at a glance
|
||||
(`chatInput` and `reportWordInput` set `idleActivity = false`, `room.go:489`,
|
||||
`room.go:497`) into the types themselves, and buys no exhaustiveness — Go gives
|
||||
none for either shape. Revisit only past ~20 arms.
|
||||
|
||||
**Rejected — a phase state machine.** The lobby/playing/over phase is already
|
||||
derived in exactly one place (`room.go:1595` `inLobby()`), and every handler's
|
||||
guard asks a *different* question — seat ownership (`occupies`), turn ownership,
|
||||
owner role, readiness. A phase enum answers none of them and adds a field that
|
||||
can disagree with `r.engine`.
|
||||
|
||||
**Rejected — separate lobby and game actors.** The state that spans both is
|
||||
exactly the state that makes it hard: `seats` (with `wins`, `chatFrom`,
|
||||
`graceUntil`), `chat`, `chatSeq`, `owner`, `turnSeq`. Two actors would need a
|
||||
handoff protocol for all of it and would reintroduce the race the single
|
||||
goroutine removes. `seat.wins` (`room.go:222-226`) and the chat log explicitly
|
||||
outlive games; `handleGraceExpiry` (`room.go:1402`) must work identically in and
|
||||
out of a game.
|
||||
|
||||
### `wsapi/hub.go` (402) — one split worth doing
|
||||
|
||||
Size is fine and the mutex discipline is right (registries only, never engine
|
||||
state — `hub.go:46-51`). One concern has its own policy, its own metrics and the
|
||||
most obvious growth path: extract `quickmatch.go` (~90 lines: `waiting`,
|
||||
`quickMatch`, `cancelQuickMatch`, `errAlreadyQueued`). While there, decide
|
||||
whether the queue needs `hub.mu` at all — it is never mutated atomically with
|
||||
`rooms` or `sessions`, so its own mutex would document that. Optional second
|
||||
split: `roomcode.go` for `roomCodeAlphabet`/`randomCode`/`reserveCode` (~60).
|
||||
|
||||
### `wsapi/session.go` (746) — split by concern, keep the switch
|
||||
|
||||
Two unrelated halves: the socket (`run`, `readLoop`, `writeLoop`, `write`,
|
||||
`drain`, `keepalive`, `close`, `send`, `trySend`, `attach`/`release` — ~330) and
|
||||
the protocol (`dispatch` and its handlers, `handleHello`, `resumeFrom`,
|
||||
`handleSubmit`, `handleReportWord`, `toRoom`, `roomCreateError` — ~350). Split
|
||||
into `session.go` and `dispatch.go`.
|
||||
|
||||
The 16-arm `switch` at `session.go:441` is fine as a switch — it is the wire
|
||||
contract read top to bottom, and each arm's error code is deliberately
|
||||
different. The duplication worth removing is smaller: `Resign`
|
||||
(`session.go:513`), `ClaimDeadEnd` (`session.go:524`) and `SendChat`
|
||||
(`session.go:560`) each re-implement `toRoom`'s currentRoom-then-send shape with
|
||||
their own codes. One helper taking `(limiter, input, missingCode, droppedCode)`
|
||||
collapses ~30 lines and turns "which limiter pays for which action" into a
|
||||
table. Do it with #9, not before.
|
||||
|
||||
**Missing arm:** `dispatch` has no `default`. A `ClientMessage` with an unset or
|
||||
future payload is silently accepted and ignored. Add
|
||||
`default: s.send(errorMsg("unknown_message"))` plus a counter — see §4 C5.
|
||||
|
||||
---
|
||||
|
||||
## 2. Engine and bot
|
||||
|
||||
**`State.History` is copied on every broadcast for two readers.**
|
||||
`engine.go:576` does `append([]Move{}, e.history...)` inside `Snapshot()`, and
|
||||
`Snapshot()` is called at `room.go:908` (beginGame), `room.go:1142`
|
||||
(broadcastTurn — **every turn**), `room.go:1207` (applyEliminations) and
|
||||
`room.go:1831` (freezeBoard — **every bot move**). `History` is read at exactly
|
||||
two of those: `room.go:1487-1488` wants only the last move, and `room.go:1836`
|
||||
wants only the played-word set.
|
||||
|
||||
Fix: drop `History` from `State`; add `Engine.LastMove() (Move, bool)` and
|
||||
`Engine.UsedWords() iter.Seq[string]` (or a copied map). This removes an
|
||||
O(chain) allocation from the hot path entirely. `State` is internal and `wsapi`
|
||||
is its only non-test consumer, so the blast radius is `engine_test.go` +
|
||||
`wsapi_test.go` assertions. Size S.
|
||||
|
||||
**`Standings()` runs on every `Snapshot()`** (`engine.go:587`) even mid-game,
|
||||
where its own doc says it is meaningless (`engine.go:539-540`). Guard it:
|
||||
`if e.over`. Trivial, same commit as above.
|
||||
|
||||
**`Engine` API surface: leave it.** `Submit`/`Timeout`/`NoMove`/`Resign` are
|
||||
four genuinely different rules, not four spellings of one, and the caller-side
|
||||
pairing with `mark()`/`applyEliminations` is already centralized in exactly one
|
||||
function (`room.go:1203`). `pointsFor` returning `(int, []PointPart)` with
|
||||
`capParts` trimming from the end (`engine.go:298-322`) is correct and
|
||||
well-reasoned — a client with no wordlist cannot re-derive the breakdown, so it
|
||||
has to travel. No change.
|
||||
|
||||
**Rule duplication engine↔room: essentially none.** One near-miss:
|
||||
`room.go:1053-1056` (`nearMissFor`) re-applies the link and used-word checks
|
||||
that `Submit` would apply. That is deliberate and commented — offering a
|
||||
suggestion that would immediately be refused is worse than offering none. Leave.
|
||||
`handleClaimDeadEnd` (`room.go:641`) calls `HasLegalMove()` then `NoMove()`,
|
||||
which re-walks; a wasted scan on a rare input. Leave.
|
||||
|
||||
**Bot `Board` (4 methods, `bot/bot.go:45-51`) is minimal and correctly narrow** —
|
||||
a strategy can read the position and cannot play a move. `freezeBoard`
|
||||
(`room.go:1830`) is the right boundary: frozen on the room goroutine before the
|
||||
worker exists, so `Used` cannot race a concurrent resign. Cost is O(chain) per
|
||||
bot move → O(chain²) per bot game; chains are tens of words, so this is not
|
||||
worth restructuring. It gets cheaper for free once `UsedWords()` lands. The
|
||||
worker's leak guard (`room.go:1123-1129`, `select` on `r.ctx.Done()` around the
|
||||
thinking delay) is correct. No change.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dictionary
|
||||
|
||||
`Store` is load-once, read-only, never hands out a mutable reference
|
||||
(`store.go:60-64`), so unsynchronized concurrent use is genuinely safe. The
|
||||
`validate()` pass (`store.go:289`) checks declared-vs-loaded counts, orphan
|
||||
meanings, out-degree consistency and dangling aliases — that is the right set,
|
||||
and it is what makes a truncated file fail at boot rather than mid-game.
|
||||
|
||||
`NearMiss` (`store.go:421`) and its `stripped` index (`store.go:376`) are a
|
||||
clean addition: built once at `Open`, ambiguity refused rather than guessed,
|
||||
and `stripDiacritics` correctly folds `đ/Đ` by hand since they have no canonical
|
||||
decomposition. Cost: one extra map plus one string per word. On a 30 k-word
|
||||
corpus the `count != 1` rule will suppress most hits (common stems have many
|
||||
candidates) — worth a metric on suggestion hit rate before tuning it.
|
||||
|
||||
**Builder version drift risk:** `dictionary/store.go:41`
|
||||
`requiredBuilderVersion = "5"` and `cmd/build-dictionary/main.go:40`
|
||||
`builderVer = "5"` are two constants in two packages that must agree, with no
|
||||
compile-time link and (unlike the dump URL, per the `Makefile` comment) I found
|
||||
no test asserting they do. Either have the builder import the store's constant
|
||||
or add a one-line test. Size S.
|
||||
|
||||
**Community allowlist overlay — design only, no data.** Plug point is
|
||||
`store.go`, between `loadAliases` and `buildStrippedIndex`: a `mergeOverlay()`
|
||||
step, after which `buildStrippedIndex()` and `validate()` run over the *merged*
|
||||
result unchanged. Shape:
|
||||
|
||||
- `Open(path string, opts ...Option)` with `WithOverlay(path)`. The overlay is a
|
||||
second database built by the **same** `build-dictionary --words` path, so
|
||||
there is one schema and one validator.
|
||||
- Merge rules to settle before writing code: (a) an overlay word colliding with
|
||||
a canonical word is a no-op, not an error; (b) `outDegree` and `openers` must
|
||||
be recomputed after merge, not merged — merging them is what would break the
|
||||
`validate()` out-degree invariant; (c) a *suppression* list needs its own
|
||||
table, because removing a word can orphan an `opener` and lower an out-degree
|
||||
below `minOpeningOutDegree`.
|
||||
- Licensing: the overlay is a **separate artifact with its own LICENSE and
|
||||
ATTRIBUTION**, copied as its own layer exactly the way `Dockerfile:74-77`
|
||||
already does for `data/`. The licence question is open and out of scope here;
|
||||
no word data should be committed to this repo either way.
|
||||
|
||||
---
|
||||
|
||||
## 4. Correctness and robustness sweep, ranked
|
||||
|
||||
**Confirmed (path traced)**
|
||||
|
||||
- **C1 · medium · ghost seat from an attach-after-teardown race.**
|
||||
`session.run` defers `leaveRoom()` (`session.go:280`), which sends
|
||||
`disconnectInput` only if `currentRoom()` is non-nil. A session that dies
|
||||
between `hub.createRoom`/`quickMatch` handing the room an input and the room
|
||||
goroutine draining it is never in a room at that moment, so no disconnect is
|
||||
ever sent — and then `handleCreate`/`handleJoin` calls `attach` on it
|
||||
(`room.go:578`, `room.go:715`), leaving a seat with a non-nil dead `sess`. No
|
||||
grace window opens (that needs `disconnectInput`), so `allConnected()` reports
|
||||
true. Worst case is quick match: `handleJoin`'s auto-start (`room.go:726`)
|
||||
gates on `allConnected()`, so a real player is auto-started into a game
|
||||
against a dead socket and waits out the 30 s turn clock. Fix: check
|
||||
`m.sess.ctx.Err() != nil` at the top of `handleCreate`/`handleJoin` and refuse
|
||||
(for join) or `r.cancel()` (for create). Bounded elsewhere: a PvP lobby closes
|
||||
on the 10-min idle timer, a bot room on the turn clock.
|
||||
- **C2 · medium · per-IP join limiter is far too tight for shared egress.**
|
||||
`joinsPerSecond = 1, joinBurst = 5` (`session.go:50-51`), keyed on
|
||||
`clientIP` (`session.go:475`). Every player behind one NAT/CGNAT egress — a
|
||||
café, a school, a Vietnamese mobile carrier — shares five room joins and then
|
||||
one per second, refused with `too_many_attempts`. This is also the CI flake
|
||||
(§5). The limiter is right in kind (it protects the 6-char room-code secret
|
||||
from brute force at `31^6 ≈ 8.9e8`); the *rate* is wrong. At 5/s it still
|
||||
takes centuries to walk the space. Raise the burst and rate substantially, and
|
||||
make them configurable so the e2e suite and a CGNAT deployment can tune them.
|
||||
- **C3 · low-medium · F7, games can start after the drain decision.**
|
||||
`beginGame` (`room.go:855`) has no `isDraining()` check, so `hub.gameStarted()`
|
||||
(`room.go:907`) can raise `liveGames` after `startDraining()`. `newRegisteredRoom`
|
||||
refuses *new rooms* (`hub.go:261`) but an existing lobby is untouched.
|
||||
Consequence is bounded by `NOITU_DRAIN_TIMEOUT` — the drain waits its full
|
||||
timeout instead of exiting early — and the game is then killed mid-play
|
||||
anyway, which is the outcome the drain existed to avoid. Fix: in
|
||||
`handleLobby`'s `lobbyStart` arm and in `handleJoin`'s auto-start, refuse with
|
||||
`server_restarting` when `r.hub.isDraining()`.
|
||||
- **C4 · low · player-driven log volume.** `recordRejection` (`room.go:1021`)
|
||||
emits `slog.Info("word_rejected", …)` for *every* rejected submission, up to
|
||||
5/s per connection. The corpus feedback loop it serves is real, but
|
||||
`noitu_words_rejected` already carries the reason breakdown. Sample it, or
|
||||
drop the line to Debug and keep the counter. The word is normalized and capped
|
||||
before logging and no player identity is attached — that part is right.
|
||||
- **C5 · low · silent unknown-payload handling.** No `default` in `dispatch`
|
||||
(`session.go:441-575`). A future or malformed `ClientMessage` costs a decode
|
||||
and gets no answer. Add the arm plus a counter; the frame limiter
|
||||
(`session.go:328`) already bounds the flood case.
|
||||
- **C6 · low · `Config.IdleFor` is a dead knob in production.**
|
||||
`server.go:27` is documented as operator-facing but `cmd/noitu-server`
|
||||
never sets it (`main.go:88-98`) and no `NOITU_*` variable exists — `docs/`
|
||||
and `README` correctly do not claim one. Rooms are fixed at
|
||||
`defaultIdleWindow` (10 min, `room.go:83`). Either wire `NOITU_IDLE_TIMEOUT`
|
||||
and document it, or delete the field and let the constant be the only source.
|
||||
- **C7 · low · `/debug/vars` publishes `cmdline` and `memstats`.**
|
||||
`expvar.Handler()` (`main.go:166`) always exposes the full argv and heap
|
||||
stats. It is opt-in and on a separate listener (`main.go:158-162`), which is
|
||||
the right design — but `docs/deployment.md` should say explicitly: never bind
|
||||
`NOITU_DEBUG_ADDR` to a public interface.
|
||||
- **C8 · informational · resume token has no liveness check on the prior
|
||||
connection.** `handleResume` (`room.go:1448`) accepts the takeover even when
|
||||
`s.sess != nil`, then closes the old connection (`room.go:1471`). With the
|
||||
token in `localStorage`, a second tab evicts the first. That reads as a
|
||||
deliberate "one seat, latest tab wins" — record it as a decision rather than
|
||||
fix it. The token itself is 128 bits from `crypto/rand` (`session.go:742`) and
|
||||
is registered/expired correctly (`hub.go:122`), so it is not guessable.
|
||||
|
||||
**Plausible (not traced to a failure)**
|
||||
|
||||
- **C9 · `reserveCode` before the ceiling check.** `newRegisteredRoom`
|
||||
(`hub.go:268`) draws a code — up to 10 `crypto/rand` rounds and 10 lock
|
||||
acquisitions (`hub.go:332-341`) — before checking `maxRooms` at
|
||||
`hub.go:277`. At the ceiling every refused create pays that. Cheap reorder:
|
||||
an advisory count check first, keeping the authoritative one under the
|
||||
registration lock where it correctly is today.
|
||||
|
||||
**Checked and clean**
|
||||
|
||||
Trust boundaries: `sanitizeText` is applied to nicknames, chat **and** the
|
||||
echoed `typed` word (`room.go:966`, `nickname.go:52`), with NFC first so the
|
||||
rune cap sees what the browser renders — correct order. `clientIP`
|
||||
(`server.go:242`) walks XFF from the right and only when the peer is a
|
||||
configured proxy; malformed hops fall back to the socket address rather than
|
||||
keying a limiter on garbage. Static file serving is root-confined
|
||||
(`server.go:225` `underRoot`). Seat authority is always the seat, never the
|
||||
claimed id (`room.go:849` `occupies`, used by every handler that acts on a
|
||||
game). `liveGames` accounting is exactly-once via `CompareAndSwap` on both the
|
||||
normal path (`room.go:1289`) and the cancelled-mid-game path (`room.go:392`).
|
||||
Limiter maps are swept (`ratelimit.go:81`, `server.go:316`). `errorMsg` carries
|
||||
UI keys, never prose or internal errors — asserted by
|
||||
`TestErrorMessagesAreUIKeysNotProse`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tests
|
||||
|
||||
**Structure.** 13 files, 143 tests. `wsapi_test.go` alone is 2534 lines / 75
|
||||
tests spanning ~9 topics; `hub_test.go` is 3 lines of comment and should be
|
||||
deleted (fold the note into `hub.go`). Split `wsapi_test.go` along the §1
|
||||
seams — `lobby_test.go`, `chat_test.go`, `resume_test.go`, `deadend_test.go`,
|
||||
`report_test.go`, `ratelimit_test.go`, `nickname_test.go` (already exists) —
|
||||
keeping `wsapi_test.go` as the harness only (`newTestServer`, client helpers,
|
||||
fixture dictionaries, ~350 lines).
|
||||
|
||||
**The browser-suite join flake — answer: no, there is no server-side ordering
|
||||
that makes `can_start` late.** Traced: `SetReady` → `toRoom`
|
||||
(`session.go:603`) → `handleLobby` sets `mine.ready` (`room.go:764-766`) → the
|
||||
run loop broadcasts exactly one `RoomState` in the *same* iteration
|
||||
(`room.go:540-543`), with `can_start` computed in that same frame
|
||||
(`room.go:1755`, `room.go:1671`). One RTT, no timer, no second input. The client
|
||||
derives both `isReady` and `canStart` straight from that frame
|
||||
(`game.svelte.js:537`, `Lobby.svelte:211`), so there is no local optimism to
|
||||
drift either.
|
||||
|
||||
What can make the frame **missing** rather than late, in likelihood order:
|
||||
|
||||
1. **The join was refused and the test never noticed.** `playingPair`
|
||||
(`pvp-game.spec.js:74-78`) uses `joinRoom`, **not** `joinRoomSeated` — it
|
||||
clicks "Vào phòng" and goes straight to `readyAndStart` with no assertion
|
||||
that the seat exists. The suite runs `workers: 1` against one server process
|
||||
with every page on `127.0.0.1`, so **all pages share one join bucket**
|
||||
(C2: 1/s, burst 5). A test that joins twice in quick succession after a
|
||||
previous test drained the bucket gets `too_many_attempts`, the guest never
|
||||
reaches the lobby, the `ready` click is a no-op, and the failure surfaces
|
||||
10 s later on the owner's `start-game` — exactly the observed symptom at
|
||||
`helpers.js:130`.
|
||||
2. `Lobby.svelte:211` also gates on `offline`; a socket blip keeps the button
|
||||
disabled independently of `can_start`.
|
||||
|
||||
Fixes, in order: raise/expose the join limiter (C2, which is a production fix
|
||||
first and a test fix second); use `joinRoomSeated` in `playingPair`; and have
|
||||
`readyAndStart` assert each guest's own ready state landed before waiting on the
|
||||
owner's button, so a dropped `SetReady` fails at the guest with a clear message
|
||||
instead of timing out on a third party.
|
||||
|
||||
**What to move from Playwright into Go.** The server-rule assertions in
|
||||
`pvp-game.spec.js` already have named in-process equivalents —
|
||||
`TestStartIsRefusedUntilTheGuestIsReady` (wsapi_test.go:1332),
|
||||
`TestOnlyTheOwnerStartsAndKicks` (:1373), `TestKickFreesAnUnreadySeatOnly`
|
||||
(:1515), `TestOwnerLeavingPromotesTheOtherPlayer` (:1548),
|
||||
`TestResumeWithinGraceRestoresGame` (:607). The browser suite should keep only
|
||||
what a browser proves: rendering, focus, the unread badge, deep links, the
|
||||
bundle carrying no wordlist. Concrete task: walk `pvp-game.spec.js` and delete
|
||||
every assertion whose rule is already named by one of those tests.
|
||||
|
||||
**Coverage floors.** Current: `vietnamese` 100, `game` 95.5, `bot` 91.2,
|
||||
`wsapi` 91.1, `dictionary` 89.0, `build-dictionary` 88.6, **`cmd/noitu-server`
|
||||
0.0**. Propose an 85% per-package floor in CI (`go test -coverprofile` + a
|
||||
small check script), excluding `gen/`. That floor is already met everywhere
|
||||
except the one package that has never been tested — `loadConfig`, `envInt`,
|
||||
`envDuration`, `envNonNegDuration`, `envList` and `waitForGamesToFinish` are all
|
||||
pure and all trivially testable, and config parsing is precisely where a
|
||||
production misconfiguration hides.
|
||||
|
||||
**Fuzz.** Three targets today: `FuzzNormalize`, `FuzzSanitizeText`,
|
||||
`FuzzDecode` — all at the right boundaries. The gap is the *composition*: add
|
||||
`FuzzSubmit` over the fixture dictionary asserting the property that any input
|
||||
`Engine.Submit` accepts resolves to a canonical word whose `FirstSyllable`
|
||||
equals the link and whose `LastSyllable` becomes the next link. A second cheap
|
||||
one: `FuzzStripDiacritics` for idempotence
|
||||
(`strip(strip(x)) == strip(x)`), which is what `NearMiss` silently relies on.
|
||||
|
||||
---
|
||||
|
||||
## 6. Build and ops
|
||||
|
||||
- **Dockerfile version pins.** `Dockerfile:19` `golang:1.25-alpine` and
|
||||
`Dockerfile:37` `alpine:3.22` are exact-minor pins; the house rule prefers a
|
||||
moving major, and `node:24-alpine` (`:4`) and
|
||||
`distroless/static-debian12:nonroot` already follow it. As written, the
|
||||
dependabot `docker` ecosystem generates exactly the churn the rule exists to
|
||||
avoid. Change to `golang:1-alpine` and `alpine:3`, or record in the README why
|
||||
these two are pinned harder. `go.mod` says `go 1.25.0`, so the Go pin is
|
||||
currently *consistent* — just needlessly tight.
|
||||
- **`make test-go` does not mirror CI.** It runs `go vet && go test -race`
|
||||
(`Makefile`), while CI additionally enforces `gofmt -l` and
|
||||
`golangci-lint` (`ci.yml`). A contributor can pass `make test` and fail CI.
|
||||
Add a `lint-go` target running both, and call it from `test-go`.
|
||||
- **Duplicated vet.** `golangci-lint` runs `govet` by default, so the separate
|
||||
`go vet` step in `ci.yml` is redundant. It is ~free and it fails faster with a
|
||||
clearer message — leave it.
|
||||
- **Everything else is clean.** The image job's licence assertion (`ci.yml`,
|
||||
"The licence travels with the data") is a genuinely good check, as is the
|
||||
negative assertion that the upstream dump never reaches the final image. The
|
||||
`proto.yml` breaking-change gate with `--against '.git#ref=origin/main'` and
|
||||
the generated-tree sync check (with `--intent-to-add` for newly emitted files)
|
||||
are both correct. `dependabot.yml` covers all four manifests with sensible
|
||||
grouping. The `build-dictionary` binary is built once in the `build` stage and
|
||||
copied into `dict` — no duplicated toolchain. Nothing stale found.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **C2 rate.** What join rate and burst do you want? The security argument
|
||||
caps nothing below ~5/s; the availability argument (Vietnamese mobile CGNAT)
|
||||
wants much more. This is a product call, not a review call — I have not
|
||||
changed it.
|
||||
2. **C8 two-tab takeover.** Is "second tab evicts the first" the intended
|
||||
behaviour? If yes it should be a line in `docs/`; if no it needs a liveness
|
||||
check in `handleResume`.
|
||||
3. **C6 `IdleFor`.** Wire it as `NOITU_IDLE_TIMEOUT`, or delete the field?
|
||||
4. **Coverage floor.** Is 85% the number you want, and should `cmd/` be held to
|
||||
it or exempted with a documented reason?
|
||||
5. **Overlay (§3).** Does the suppression case (removing a shipped word) need to
|
||||
exist at all in v1? It is the only part of the overlay design that touches
|
||||
`openers` and `outDegree` and therefore the only part that is not additive.
|
||||
@@ -0,0 +1,505 @@
|
||||
# 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 `$effect`s; 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 `$effect`s, 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`) — owns `pending`,
|
||||
`resuming`, `needName`, `stalled`, `waitedS`; exposes `request(req)`, `flush(isOpen)`, `noteRoom()`,
|
||||
`noteError(code)`, `noteResumeTimeout()`, `leave()`. Unit-testable in Vitest with a fake clock, exactly as
|
||||
`ws/client.js` already 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` / `stalled` notices.
|
||||
- `lib/components/RoomLayout.svelte` (~90) — the two-column shell (408-464) plus `wide` (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:
|
||||
|
||||
1. 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 `RoomState` is precisely that, with the atomicity now spread across
|
||||
module boundaries.
|
||||
2. Components read across the proposed domains. `ScoreBoard.svelte:15-17` reads `gamePlayers` +
|
||||
`standings` + `roomPlayers` + `nickname`; `nameOf()` (593-601) falls back across game → room; `myScore`
|
||||
(566-569) picks its table by `phase`.
|
||||
3. There is no performance motive. 61 store tests run in 36ms.
|
||||
|
||||
**Proposal — mechanical file split, one `$state`:**
|
||||
|
||||
- `stores/game-shape.js` (~210) — the `ChainEntry`/`Sense`/`PointPart`/`PlayerSlot`/`PlayerScore` typedefs,
|
||||
the new `GameState` typedef (#3), `initialState()`, and the three wire decoders `toSenses`/`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) — the `top` row 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:
|
||||
|
||||
```js
|
||||
* @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:
|
||||
|
||||
```js
|
||||
/** @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 `any`s
|
||||
(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:
|
||||
|
||||
```js
|
||||
/** @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:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @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 from `object|null` to `{ myScore: number }|null`, drop the cast.
|
||||
- `connection.svelte.js:36` → `ClientMessage`.
|
||||
- `tests/game-store.test.js:624/635/661` dissolve once `GameState` exists; `tests/ws-client.test.js` (6)
|
||||
needs the same `ServerMessage` typedef; `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.** `readyAndStart` clicks the guest's ready button and
|
||||
immediately asserts the owner's Start is enabled (10s `expect` timeout). `Lobby.svelte` line ~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 `false` if the socket is not `readyState === 1` (`client.js:299`) and **nothing retries**:
|
||||
`unsent` (60) draws a banner and the request is gone. Meanwhile the button itself is only disabled on
|
||||
`offline`, which is `connection.status`, and status reaches `OPEN` inside `ws.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 latch `pending` already 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 on `running` (`CountdownRing.svelte:30-37`), `SETTLE_MS` erring early included.
|
||||
- **Chat unread accounting** — the two effects at `ChatPanel.svelte:64-66` and 83-86 are correct under fold,
|
||||
resync (`count < seenAt`), the `CHAT_WINDOW` cap (counted against `chatCount`, not `chat.length`), and the
|
||||
single-mount-across-phases arrangement the page comment at 417-422 depends on.
|
||||
- **Storage failures** — `settings.svelte.js:389-421` and `client.js:68-90` guard the property access itself,
|
||||
not just the call; `readBestScores` (428-445) validates shape and value; `app.html`'s pre-paint theme
|
||||
script has its own `try`.
|
||||
- **Uncontrolled-input invariant** — `beforeinput` guard + `input` undo + 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.
|
||||
- **`untrack` usage** — load-bearing at `+page.svelte` 139/150/218/255 and `play/+page.svelte` 37/51/58; each
|
||||
prevents a self-retriggering effect, none hides a dependency that should be tracked.
|
||||
- **`PlayerStatus.svelte:36-48`** grace 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:**
|
||||
|
||||
1. **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. Fold `aria-pressed` (or a discreet live region) into `ArmedButton` (#9).
|
||||
2. **Chat log live region.** `ChatPanel.svelte` (the `<ol aria-live="polite" aria-relevant="additions">`)
|
||||
announces the reader's *own* messages back to them, and `aria-relevant` is inconsistently implemented.
|
||||
Prefer a dedicated `sr-only` `role="log"` carrying only the newest line where `fromMe` is false — the
|
||||
pattern `CountdownRing` already uses.
|
||||
3. **Rejection alert doubles as a description.** `WordInput.svelte:200-202` points `aria-describedby` at the
|
||||
same element that carries `role="alert"` (220), so the text is announced twice, and the alert contains two
|
||||
focusable buttons. Split: an `sr-only` alert for the announcement, a plain `<p id="word-rejection">` for
|
||||
the description and the buttons.
|
||||
4. **Focus after suggestion-fill is right, state is not.** `useSuggestion()` (161-167) focuses the field and
|
||||
places the caret correctly, but leaves `game.state.rejection` set — so `aria-invalid` stays true and the
|
||||
banner still offers the suggestion the player just took.
|
||||
5. **`ScoreBoard` turn row has no `aria-current`.** `class:active` (24) is the turn indicator for sighted
|
||||
users only; the row is otherwise indistinguishable.
|
||||
6. **Colour contrast is fine.** Spot-checked the risky pairs: `--warn #8a5a08` on `--surface-alt #e9f0ea`
|
||||
≈ 5.1:1; `--text-muted #5b6a61` on `--bg #f4f7f4` ≈ 5.2:1; `--player-1 #1d5c8f` on white ≈ 7.1:1;
|
||||
`--player-3 #8a4c12` on white ≈ 6.7:1; dark-theme `--warn #e9b949` on `#1f2a23` ≈ 8.1:1. All ≥ 4.5:1 at
|
||||
the sizes used. `--border-strong` exists precisely because `--border` does 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 `ServerMessage`s — 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:
|
||||
|
||||
```js
|
||||
// @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
|
||||
|
||||
1. **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 → /play` can leave `/play`
|
||||
transport-less.
|
||||
2. **C1's server half:** should `handleHello` answer 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 and `resumeFrom`'s own comment already argues for it.
|
||||
3. 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.
|
||||
4. Is the `chatFolded` behaviour in C5 intended (fold once, stay folded) or an unnoticed consequence of
|
||||
5178a97? This is a product call, not a defect call.
|
||||
5. 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.
|
||||
@@ -0,0 +1,203 @@
|
||||
# noitu — whole-game UX review (code-based, read-only)
|
||||
|
||||
Tree: `web/` at `dev` 5178a97, 2026-09-21. No browser on this host: every claim below is read off Svelte markup, CSS tokens and `vi.js`, with ratios computed from the hex in `app.css`. Prior 260910 reports checked against the tree first — their P1/P2 items (turn live region, `--border-strong`, accent darkened to `#12692f`, spacing/type ramps, `min-height` on the chain, focus on game-over, two-press resign/kick, seat colours, owner marker, monospace code, reduced-motion scroll) are all shipped and are not repeated.
|
||||
|
||||
## Verdict
|
||||
|
||||
Structurally one of the better-reasoned small game UIs I have reviewed: the board keeps syllable/clock/input fixed and lets only the chain scroll, the IME handling is right, the colour tokens hold AA in both themes (measured, table in §3), and nearly every control explains itself in `vi.js`. Two things are wrong at a level above polish: (1) the in-game "Luật chơi" link forfeits the game — the route change unmounts the board and its cleanup resigns/leaves; (2) a newcomer reaches the first running clock without ever being told the chain rule. Below those, today's additions (chips, pill, claim, suggestion/report) each work alone but together crowd the 360px board and hand the rejection row three sub-24px targets. The token set is disciplined but looks like nothing in particular — a single display face for the four hero numbers and a flatter type ramp would give it a face without a redesign.
|
||||
|
||||
## Top-10 ranked changes
|
||||
|
||||
| # | Change | Size | Screen | Why |
|
||||
|---|---|---|---|---|
|
||||
| 1 | In-room "Luật chơi" opens in a new tab (or `<dialog>` summary); never a same-tab route | S | board, lobby | `GameBoard.svelte:133`, `Lobby.svelte:90` are plain `<a href="/rules">`; `play/+page.svelte:39-44` cleanup sends `resign()`, `online/+page.svelte:185-212` sends `leaveRoom()` + `forgetSession()`. Reading the rules mid-game = forfeit. |
|
||||
| 2 | State the chain rule before the first clock: one line + example on the landing, caption on the opening row | S | landing, board | `+page.svelte:387` tagline is the only rule text; `rulesLink` sits below the CTAs (`:397`). Opening row `ChainHistory.svelte:249-252` is dashed but unlabelled. |
|
||||
| 3 | Slim the board header: badge text `sr-only` while open, rules as 44px "?" icon, room code moves under the turn prompt | M | board 360px | `GameBoard.svelte:129-154` puts badge (~105px) + code + "Luật chơi" + chat pill (~150px w/ badge) in 328px → two wrapped rows above the scoreboard. |
|
||||
| 4 | Rejection row: suggestion and report become 36px-tall buttons on their own line; suggestion styled primary | S | board | `WordInput.svelte:304-313` pills are `2px` padding at 0.8rem ≈ 23px tall — under WCAG 2.2 24px minimum, and the one control that saves the turn. |
|
||||
| 5 | "Bí từ" stops appearing/vanishing each handover; sits in one secondary row with "Đầu hàng" | S | board | `GameBoard.svelte:227-241` mounts the button only on `myTurn`, shifting everything under the input every turn — the exact thing `.resign`'s comment (`:437-443`) avoids. |
|
||||
| 6 | Show elimination suggestions in the spectating box at knockout, not only at game over | M | board (out) | `elimination.suggestions` arrive with `playerEliminated` (`game.svelte.js:414-431`) but render only in `GameOverPanel.svelte:426-445`; a 4-seat spectator waits minutes. Also `.spectating` (`GameBoard:224`) and the `youAreOut` banner (`PlayerStatus:535`) say the same thing twice. |
|
||||
| 7 | Fix `not_your_turn` for the race it actually describes; route it beside the button | S | board | Both callers are gated on `myTurn` (`GameBoard:69,82`), so the server can only say this when the turn moved mid-press. "Chưa đến lượt bạn" says "not yet" about a turn that has *passed*, in the top banner (`game.svelte.js:498`) ~250px from the button. |
|
||||
| 8 | Quick-match wait: take the second counter out of the live region; nudge says it leaves the queue | S | online entry | `online/+page.svelte:494-503` `role="status"` wraps `{n} giây` → screen reader announces every second. `/play` link at default difficulty; cleanup cancels queue silently (`:207`). |
|
||||
| 9 | Breakdown line: chips, only on the latest row / when a row is open; rename `dài` | M | board | `ChainHistory.svelte:113-118` prints `+10 nền · +4 chuỗi · +5 dài` at 0.75rem muted under *every* row in a 9rem-high list — three rows of history become three rows of arithmetic. |
|
||||
| 10 | Visual identity: one display face for syllable/clock/score/code, ramp collapsed 9→7 steps, hover token, lifted `--bg` | M | all | §6. Nothing here is broken; it is `system-ui` at 600/700 in 8px boxes and reads as a default. |
|
||||
|
||||
## 1. First-run flow
|
||||
|
||||
Path: `/` → nickname (`NicknameInput`) → `DifficultyPicker` → "Chơi với máy" → `/play?difficulty=` → socket → `StartBotGame` → `TurnUpdate` with clock running.
|
||||
|
||||
- **No rule before the clock.** `+page.svelte:387` `tagline` = "Trò chơi nối từ tiếng Việt"; nothing says *what* nối từ requires. `rulesLink` (`:397`) is a muted underline under the CTAs — right that it is not a button, wrong that the *rule* itself needs a click. Newcomer's first read of the rule is `rejectMessages.WRONG_LINK` under a running clock.
|
||||
Sketch (landing, between tagline and nickname):
|
||||
```svelte
|
||||
<p class="how">
|
||||
Tiếng đầu của từ bạn phải là tiếng cuối của từ trước —
|
||||
<span class="ex">ngôn ngữ → ngữ pháp → pháp luật</span>. Mỗi lượt có đồng hồ.
|
||||
</p>
|
||||
```
|
||||
```css
|
||||
.how { margin:0; font-size: var(--text-5); color: var(--text-muted); }
|
||||
.ex { color: var(--text); font-weight: 600; white-space: nowrap; }
|
||||
```
|
||||
Copy: `howToPlay: 'Tiếng đầu của từ bạn phải là tiếng cuối của từ trước — {example}. Mỗi lượt có đồng hồ.'`, `howToPlayExample: 'ngôn ngữ → ngữ pháp → pháp luật'`. Turn length is a server constant not known on the landing, so "có đồng hồ", not "30 giây".
|
||||
- **Opening row unlabelled.** `ChainHistory.svelte:73,249-252` marks `.opening` dashed; a newcomer sees one grey word and a syllable. Add a caption on the opening row only: `openingCaption: 'Từ mở đầu — nối tiếp bằng tiếng cuối'` rendered like `.corrected` (`:310-314`). Stateless, helps every game, costs one 0.8rem line.
|
||||
- **Rules discoverability is right on landing, absent on `/online` entry.** The join form (`online/+page.svelte:466-546`) has no `.rules-link`; a stranger arriving by invite link never sees one before being seated. Add the same link under `.back` (`:546`).
|
||||
- **Difficulty picker: clear as a ladder, opaque as a choice.** `difficultyLabels` = Dễ/Trung bình/Khó + "Kỷ lục". Server: easy = random legal move, medium = 1-ply greedy, hard = multi-ply + 85% kill rate (`server/internal/bot/strategy_*.go`). One truthful caption each, replacing the `.best` line when best = 0:
|
||||
`difficultyHints: { EASY: 'Máy chọn từ ngẫu nhiên', MEDIUM: 'Máy chọn từ tốt nhất trước mắt', HARD: 'Máy tính trước vài nước, hay dồn bí từ' }`. Fits the 96px min column at 0.75rem over two lines.
|
||||
- **Nickname hint is three sentences on a 360px screen** (`vi.js:22-23`, rendered `NicknameInput:411`). Collapse: `'Tối đa 20 ký tự. Để trống sẽ là “Người chơi”.'` — server truncation is an edge case the server already reports by displaying the sanitized name.
|
||||
- **Rules page itself** (`rules/+page.svelte`): good — one anchored page, `scroll-margin-top`, live scoring constants. Two nits: `.toc a` (`:248-256`) is ~27px tall (`4px` padding, 0.8rem) — raise to `padding: var(--space-2) var(--space-3); min-height: 32px`; `.back` (`:220`) always goes `/` — when opened from a game in a new tab (fix #1) the natural action is "close", so hide `.back` when `window.opener || history.length <= 1`, or label it "Đóng" via `history.back()` fallback.
|
||||
|
||||
## 2. In-game board
|
||||
|
||||
**Hierarchy (360–420px).** Reading order in `GameBoard.svelte:128-272`: header row (badge, code, rules, pill) → `ScoreBoard` → banners → error → `.turn` (80px ring + "Đến lượt bạn" + syllable 1.6rem) → `WordInput` → rejection → "Bí từ" → claim error → "Đầu hàng" (right) → chain (`min-height: 9rem`). Priority is right: syllable and clock share a row, input beneath. Problems are density above the ring and churn beneath the input.
|
||||
|
||||
- **Header crowding (#3).** At 360 the inner width is 328px. "Đã kết nối" badge ≈ 105px, `K7M 2QP` ≈ 60px, "Luật chơi" ≈ 60px, chat pill with "3 tin mới" ≈ 150px. `.meta` wraps (`:291-297`) so nothing breaks, but the board opens on two rows of chrome. Sketch:
|
||||
```svelte
|
||||
<div class="top">
|
||||
<ConnectionBadge compact /> <!-- dot only while OPEN; label sr-only -->
|
||||
<div class="meta">
|
||||
<a class="icon-button rules" href="/rules" target="_blank" rel="noopener" aria-label={t.rulesLink}>?</a>
|
||||
{#if onchatopen}<button class="chat-pill" …>💬 <span class="sr-only">{t.chatTitle}</span>{#if chatUnread}<span class="pill-badge">{chatUnread}</span>{/if}</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
…
|
||||
<p class="syllable"><span class="label">{t.currentSyllable}{#if modeLabel} · {modeLabel}{/if}</span><strong>…</strong></p>
|
||||
```
|
||||
`ConnectionBadge` gains `compact` → when `status==='open'` render `.dot` + `.sr-only` label; any other status shows the text (that is when it matters). Pill count drops the word: `chatUnread` becomes `'{n}'` visually with `aria-label={fill(t.chatUnread,{n})}`. Header then fits one row at 320px.
|
||||
- **Input row is not crowded — what follows it is.** `WordInput` row = input + "Gửi" (`:175-213`), fine. Below it, in order and each conditional: `.rejection` (word — message + up to two pills, `:220-232`), `.report-confirmation` (`:235-239`), "Bí từ" (`GameBoard:232-240`), `.claim-error` (`:243-251`), then "Đầu hàng" right-aligned (`:260-268`). Worst case is five stacked blocks between input and chain, four of them pop in and out. Sketch — one persistent secondary row, everything else inline in a single message slot:
|
||||
```svelte
|
||||
<WordInput … />
|
||||
<div class="secondary">
|
||||
<button class="claim-dead-end" disabled={!canClaimDeadEnd} …>{claimArming ? t.claimDeadEndSure : t.claimDeadEnd}</button>
|
||||
<button class="resign" disabled={!canResign} …>{arming ? t.resignSure : t.resign}</button>
|
||||
</div>
|
||||
{#if message}<p class="notice" class:danger role="alert">…</p>{/if} <!-- rejection | claimError | reportConfirmation, one at a time -->
|
||||
```
|
||||
```css
|
||||
.secondary { display:flex; justify-content:space-between; gap:var(--space-2); }
|
||||
```
|
||||
Both buttons keep their existing arming styles and disabled looks; both stay mounted so the row height is constant (what `.resign` already argues for at `:437-443`).
|
||||
- **Rejection affordances (#4).** `.suggestion, .report` (`WordInput:304-313`): `padding: 2px 8px`, 0.8rem → ≈23×90px. Under the clock these are the two most valuable taps on the board. Sketch:
|
||||
```svelte
|
||||
<p class="rejection" id="word-rejection" role="alert">
|
||||
<strong>{word}</strong> — {message}
|
||||
</p>
|
||||
{#if suggestion || reason === NOT_IN_DICTIONARY}
|
||||
<div class="fixes">
|
||||
{#if suggestion}<button class="fix primary" onclick={useSuggestion}>{fill(t.suggestionPrompt,{word:suggestion})}</button>{/if}
|
||||
{#if reason === NOT_IN_DICTIONARY}<button class="fix" onclick={report}>{t.reportWord}</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
```css
|
||||
.fixes { display:flex; flex-wrap:wrap; gap:var(--space-2); margin-top:var(--space-2); }
|
||||
.fix { min-height:36px; padding:var(--space-1) var(--space-3); border:1px solid var(--border-strong); border-radius:var(--radius-pill); background:var(--surface); color:var(--text); font-size:var(--text-4); font-weight:600; }
|
||||
.fix.primary { border-color:var(--accent); background:var(--accent-soft); }
|
||||
```
|
||||
Consider `useSuggestion()` also submitting — one tap, not tap + Enter — since the server already vouched the word exists; only ambiguity is the player may want to change it. Keep as fill-only if unsure; then label `'Dùng “{word}”'` is more honest than a question.
|
||||
- **Breakdown chips (#9).** `.parts` (`ChainHistory:113-118, 316-322`) is a joined string, not chips, under every row. In a 9rem list that is ~3 rows, each now ~64px with parts. Show parts only on `.latest` and on an `open` row (row is already a toggle, `:79-86`), and render as chips:
|
||||
```svelte
|
||||
{#if entry.parts.length && (index === 0 || open)}
|
||||
<ul class="parts" aria-label={t.pointsBreakdown}>{#each entry.parts as p}<li>+{p.value} <span>{pointKindLabels[p.kind]}</span></li>{/each}</ul>
|
||||
{/if}
|
||||
```
|
||||
```css
|
||||
.parts { display:flex; flex-wrap:wrap; gap:var(--space-1); margin:0; padding:0 12px 8px; list-style:none; }
|
||||
.parts li { padding:0 var(--space-2); border-radius:var(--radius-pill); background:var(--surface-alt); color:var(--text-muted); font-size:var(--text-2); font-variant-numeric:tabular-nums; }
|
||||
```
|
||||
Labels: keep `nền/chuỗi/nhanh/hiếm` (they match `/rules`); change `SYLLABLES: 'dài'` → `'từ dài'` — "+5 dài" is not a phrase; "+5 từ dài" is. Add `pointsBreakdown: 'Cách tính điểm'`.
|
||||
- **Wide screen.** Bot game uses the 560px shell — fine, it is a reading column. Online room at ≥900px is `1fr / 320px` (`online/+page.svelte:610-616`): game pane can reach 700px, so `.turn` (ring left, prompt right) leaves the right half empty and the chain rows stretch to 700px with `.meta` pushed far right of `.word`. Cap the board: `.pane.game { max-width: 560px; }` or centre `.turn`. Cheap, and it keeps the syllable near the input on a 1440 monitor.
|
||||
- **Motion / reduced motion.** Inventory: `pulse` on the reconnecting dot (`ConnectionBadge:58`), two 150ms background transitions (`GameBoard:430,465`), rAF ring, smooth scroll via `motion.js`. Global kill switch `app.css:217-229` is correct (iteration-count fix noted). Motion is nearly absent, which is a defensible choice; two micro-interactions would earn their place and are auto-covered by the global rule: accepted word entering (`li.latest { animation: rise 160ms ease-out }` with `@keyframes rise { from { opacity:0; transform: translateY(4px) } }`) and a 2-frame horizontal nudge on `.rejection` insertion (`translateX(-3px)→3px→0`, 180ms). Nothing loops.
|
||||
- **5 seconds left.** `CountdownRing:365,418-420,450-456`: `urgent` = `mine && !stalled && left ≤ 5` → `--danger`, stroke 6→9, value 1.5→1.75rem, spoken at 10 and 5. Colour-independent, doesn't move, opponent's clock stays neutral — correct. Gap: the eye is on the caret, 80px to the right. Lift `urgent` to the board (`data-urgent` on `.board`, or a store derived) and add `.board[data-urgent] .input-row input { border-color: var(--danger); box-shadow: inset 0 0 0 1px var(--danger); }`. Small, no motion.
|
||||
- **Elimination / spectating (#6).** `game.iAmOut` → `PlayerStatus:535` banner "Bạn đã bị loại. Ván đấu vẫn đang tiếp tục." *and* `GameBoard:224` box "Bạn đang xem ván đấu." Same news twice, 60px apart. Merge into the spectating box and put the suggestions there — the player has minutes to look at them:
|
||||
```svelte
|
||||
<div class="spectating" role="status">
|
||||
<p>{t.youAreOut}</p>
|
||||
{#if game.state.elimination?.suggestions.length}
|
||||
<p class="could">{t.suggestionsTitle}: {game.state.elimination.suggestions.join(' · ')}</p>
|
||||
{:else if game.state.elimination}
|
||||
<p class="could">{fill(t.noSuggestions,{syllable})}</p> <!-- capture syllable at knockout; currentSyllable moves on -->
|
||||
{/if}
|
||||
</div>
|
||||
```
|
||||
Note the existing `GameOverPanel:442` fills `noSuggestions` with `game.state.currentSyllable` — by game over in a 4-seat room that is somebody else's syllable. Store the syllable in `elimination` at `game.svelte.js:425-430`.
|
||||
- **Game-over standings.** `GameOverPanel:396-424`: standings list *then* `.stats` "Điểm cuối cùng / Số từ trong chuỗi" — the score is already on the player's own standings row (`:408`); drop `finalScore` from `.stats`, keep `chainLength`. `🏆` on a bare `<span aria-label>` (`:409`) is not reliably announced — `role="img"`. `h2` gets `aria-live="polite"` on insertion (`:390`) plus panel focus (`:365-367`); either is enough, keep the focus, drop the live attribute to avoid a double announcement.
|
||||
|
||||
## 3. Online
|
||||
|
||||
- **Entry choice architecture.** `online/+page.svelte:509-543`: two identical `.primary` filled buttons ("Chơi ngay", "Tạo phòng") stacked, then the join form. Two primaries = no primary. `onlineIntro` (`vi.js:100`) still describes only create/join. Sketch: "Chơi ngay" stays filled; "Tạo phòng" becomes outlined (`border:1px solid var(--border-strong); background:var(--surface); color:inherit`); join form unchanged. Copy: `onlineIntro: 'Chơi ngay với người lạ, tạo phòng cho bạn bè, hoặc nhập mã bạn được mời.'` Add sub-labels under the two buttons at `--text-3` muted: `quickMatchHint: 'Ghép với người đang chờ, ván đầu tự bắt đầu'`, `createRoomHint: 'Nhận mã sáu ký tự để gửi cho bạn bè'`.
|
||||
- **Waiting panel (#8).** `:494-503`. Split: `<p aria-live="polite">{t.quickMatchSearching}</p>` announced once, and `<p aria-hidden="true">{n} giây</p>` for the eye; or keep one line with `aria-live="off"` and a separate `sr-only` status announcing at 0s and 20s. Nudge copy: `quickMatchNudge: 'Chưa có ai chờ.'`, `quickMatchNudgeLink: 'Chơi với máy trong lúc đợi (sẽ rời hàng chờ)'`; link to `/play?difficulty={settings' last or MEDIUM}`. Also `.waiting` has no `.cancel` styling differentiation — fine as is.
|
||||
- **Lobby (2–4 seats).** `Lobby.svelte:97-162`: count line, seat rows with role pill, wins, state text, owner-only 36px `×` (44px hit via `::after`, `:361-379`). Readiness = tint + text label (`:294-297,129-131`) — colour-independent. Owner = 3px accent left border (`:299-301`). Hints cover every role/state (`:172-184`). Two additions: (a) **seat colour appears nowhere except chat.** Move the owner marker to the role pill (it already says "Chủ phòng") and use the left border for the seat colour: `.seat { border-inline-start: 3px solid var(--seat) }` with `style:--seat={`var(--player-${game.seatIndexOf(player.playerId)})`}`; the chat log's colours then mean something the first time they are seen. (b) `kick:disabled` at `opacity .35` with no reason — `title` is not enough; when `player.ready` add `.sr-only` "{t.player_is_ready}" text or drop the button for ready seats and keep the row height (`visibility:hidden`).
|
||||
- **Chat log contrast (computed from `app.css:38-41, 93-96`).** Light, on `--surface`/`--surface-alt`: p1 7.05/6.08, p2 7.15/6.17, p3 6.72/5.80, p4 5.95/**5.13**. Dark: p1 7.92/7.05, p2 6.77/6.02, p3 7.89/7.02, p4 9.13/8.13. All pass AA; p4 light is the tight one — `--player-4: #0b5f6a` lifts it to ~5.8 without touching hue. Distinguishability between seats is low for CVD (p1 vs p4 1.18, p1 vs p2 1.02 luminance ratio) — acceptable only because every line also carries the name (`ChatPanel:170`); keep that invariant. Timestamp `.at` at `--text-1` (0.7rem = 11.2px) muted on every line is noise: show it only when the minute changes from the previous line, or on hover/focus of the line.
|
||||
- **Chat as log.** `ChatPanel:159-176` one line per message, `aria-live="polite" aria-relevant="additions"` — reads author, text *and* timestamp for every message; move `.at` outside the live text or `aria-hidden` it. `chatAuthorLeft: 'Đã rời phòng'` renders as `Đã rời phòng: hello` — reads as a sentence, not a name. Use `'(đã rời phòng)'`. Folded header button (`:142-147`) is 44px, good; the board pill (#3) and this header are two ways to the same thing on a phone — acceptable since the pill is above the chain.
|
||||
- **Reconnect surfaces.** Three layers, no conflict: `ConnectionBadge` live label; `.offline` line with 44px "Thử lại" (`GameBoard:172-177`, warn on surface-alt 5.11 light / 8.13 dark); per-player grace banners with countdown (`PlayerStatus:540-549`). Lobby `ownerAway` hint (`Lobby:173-174`) is the right override. One gap: the ring at `stalled` goes to `opacity .4` (`CountdownRing:428-430`) but the value keeps counting — the number should read "–" or the label should say offline, else the player reads a live clock they cannot beat.
|
||||
- **Invite link.** `RoomCodePanel`: 3+3 grouping, letter-by-letter `aria-label`, monospace, copy code / copy link / native share, clipboard-failure fallback selects the code, full URL visible and `user-select: all`. Nothing to add; the compact variant correctly keeps only "Sao chép mã".
|
||||
|
||||
## 4. Copy
|
||||
|
||||
Tone: consistent second-person "bạn", imperative "Hãy …" for recovery, no exclamation except `won`, `newRecord`, `TIMEOUT`. Explains rather than blames in almost every rejection (`NOT_IN_DICTIONARY`, `WRONG_LINK` with the syllable filled). Issues:
|
||||
|
||||
- `not_your_turn` / `NOT_YOUR_TURN` "Chưa đến lượt bạn." — for resign and claim this can only fire on a race (both buttons are turn-gated), where the truth is the turn *just left*. Propose for the server-error key: `not_your_turn: 'Lượt vừa chuyển đi, thao tác này không còn hiệu lực.'`; keep the reject-reason string for a raced word submit but extend it: `'Chưa đến lượt bạn — từ này chưa được gửi.'`. Route the error to the same slot as `claimError` (#7).
|
||||
- `not_a_dead_end: 'Vẫn còn từ nối được. Hãy thử lại.'` — "thử lại" invites re-pressing "Bí từ". → `'Vẫn còn từ nối được với tiếng này. Nghĩ thêm chút nữa!'`
|
||||
- `winsLabel: 'Tỉ số'` beside a per-game `score` (`ScoreBoard:361-365`) — two numbers, one called "điểm", one "tỉ số", both look like scores. → `'Ván thắng'` (lobby) / `'Thắng {n}'` compact on the board.
|
||||
- `need_more_players` hardcodes "hai" while `ownerNeedsMore` interpolates `{n}` — server constant leaks in prose. → `'Chưa đủ người để bắt đầu.'`
|
||||
- `opponentTurn: 'Đối thủ đang suy nghĩ…'` fine for bot; online it is the fallback when `nameOf` is empty — reword neutral: `'Đang chờ người chơi khác…'`.
|
||||
- `nicknameHint` — see §1. `app.html:246` meta description says "đấu 1v1"; the game is 2–4: `'Trò chơi nối từ tiếng Việt: chơi với máy hoặc đấu trực tuyến 2–4 người.'`
|
||||
- `too_fast: 'Bạn thao tác quá nhanh. Chậm lại một chút nhé.'` — the only "nhé" in the file; either adopt the warmer register elsewhere (`not_a_dead_end` above does) or drop it: `'Thao tác quá nhanh. Đợi một chút rồi thử lại.'`
|
||||
- Length at 360: `claimDeadEndSure 'Chắc chắn bí từ?'` and `resignSure` fit the 32px buttons; `reportWord 'Báo từ này là từ thật'` (~150px at 0.8rem) wraps the rejection row — with #4 it moves to its own line. `quickMatchNudgeLink` + `quickMatchNudge` on one line wrap at 360; the sketch in §3 accepts that.
|
||||
- Rules body paragraphs are single 90–120-word sentences (`rulesDeadEndBody`, `rulesRoomBody`). Split each at the first "—" into two sentences; content is fine.
|
||||
|
||||
## 5. Accessibility
|
||||
|
||||
- **Focus order**: header (brand, theme) → skip link target `#main` → badge (not focusable) → pill → scoreboard (static) → ring (static) → input → Gửi → suggestion/report → Bí từ → Đầu hàng → chain rows. Logical. Game over: panel takes focus (`GameOverPanel:365`), good. The input keeps focus off-turn with hidden caret (`WordInput:266-271`) and `aria-disabled` — correct trade for the keyboard; the placeholder swap tells a screen reader nothing because placeholder isn't re-announced. Add `aria-describedby` to a `sr-only` "Chưa đến lượt bạn" status when `!enabled`, or announce via the existing turn `role="status"` (already does — fine, no change).
|
||||
- **Labels**: word input `aria-label` stable (`:204`); chat input labelled; kick has state-aware `aria-label` (`Lobby:146`); theme toggle `aria-pressed` + sr text. `.chat-pill` reads "Trò chuyện 3 tin mới" — good. `🏆` span — `role="img"` (§2).
|
||||
- **Live regions**: turn (`GameBoard:207-209` polite, atomic), badge (polite), spoken clock at 10/5 (`CountdownRing:400-402`), errors `role="alert"`, chat `aria-live` additions, quick-match counter every second (**fix**, #8), game-over `h2` + focus (double, §2). Count of simultaneously-live nodes on the board is five; acceptable because each fires on a distinct event.
|
||||
- **Keyboard-only play**: fully possible. Two-press arming for resign/claim/kick works with Enter/Space; the 4s disarm (`GameBoard:46`) is short for a switch-access user — 8s costs nothing.
|
||||
- **Colour-independent state**: ring thickness+size at urgent; scoreboard active row = border + tint + 3px underline (`ScoreBoard:403-413`); ready seat = tint + text; chain `.mine` = border + tint; `.latest` = 3px bar; connection = text label. Pass. `li.mine` tint vs surface is 1.17 light — the accent border carries it.
|
||||
- **Targets**: pass — inputs ≈52px, Gửi, Ready/Start/Copy 44px, kick 44 via `::after`, icon dismiss 44. Fail/tight — `.suggestion/.report` ≈23px (#4), `.toc a` ≈27px (§1), `ThemeToggle` 40px (`:86-97`; make 44), `.chat-pill` 32px and `.claim-dead-end`/`.resign` 32px by stated intent (`GameBoard:418-423`) — WCAG 2.2 2.5.8 minimum is 24, so compliant; I would still take `.claim-dead-end` to 40 since it is an in-turn action, unlike resign.
|
||||
- **Contrast** (computed): all text pairs in use ≥4.88 light / ≥5.14 dark; `--border-strong` on surface 3.64/3.82 and on surface-alt 3.14/3.40 (≥3:1 for UI boundaries, pass); ring track vs arc 1.87/2.51 — non-text but the arc is the information; the thickness/number carry it, and the arc is `currentColor` on accent so it is legible against the track by lightness in dark mode only. Consider `.track { stroke: var(--border); }` (fainter track = arc reads higher).
|
||||
|
||||
## 6. Visual identity
|
||||
|
||||
What `app.css` adds up to: a coherent *palette* (paper with a green cast, one accent that is also the "text green", four seat hues) and disciplined *tokens*, drawn in `system-ui` with 600/700 weights inside 8px-radius outlined boxes. It is clean and reads as "well-built default". Verified drift: 34 spacing declarations still use 6/10/14px literals (17×10px, 8×14px, 9×6px) beside the 4px ramp; four font-size literals off the ramp (`GameBoard:365` 1.6rem, `CountdownRing:132,149` 1.75/1.5rem, `online:636` 1.3rem); the ramp's first five steps span 0.7–0.9rem (11.2→14.4px in 0.8px increments) — too fine to be a scale. Light theme surfaces are near-flat: `--surface-alt`/`--surface` 1.16, `--bg`/`--surface` 1.08.
|
||||
|
||||
Restrained direction — one face, one accent, fewer steps:
|
||||
|
||||
- **Display face for the four hero glyphs only** (syllable, clock value, score, room code): `Be Vietnam Pro` 700 — designed for Vietnamese, full diacritic stacking, Google Fonts, self-hostable as two `woff2` (vietnamese+latin subset ≈ 45KB total). Body stays `system-ui`. Fallback chain keeps the current look if the font fails.
|
||||
```css
|
||||
--font-display: 'Be Vietnam Pro', var(--font);
|
||||
/* GameBoard .syllable strong, CountdownRing .value, ScoreBoard .score, RoomCodePanel .code (keep monospace there? — no: the display face's 5/S 2/Z are distinct enough; if in doubt keep ui-monospace) */
|
||||
```
|
||||
Alternative at zero bytes: keep `system-ui`, but give the syllable the room a hero deserves — `font-size: var(--text-10); letter-spacing: -0.01em; line-height: 1.25`.
|
||||
- **Type ramp 9 → 7**, mapped:
|
||||
```css
|
||||
--text-1: 0.75rem; /* was text-1/text-2 (0.7, 0.75): badges, series, timestamps */
|
||||
--text-2: 0.875rem; /* was text-3/text-4/text-5 (0.8, 0.85, 0.9): meta, hints, labels */
|
||||
--text-3: 1rem; /* was text-6: body, inputs (iOS floor) */
|
||||
--text-4: 1.125rem; /* was text-7 (1.1): scoreboard score, h2 */
|
||||
--text-5: 1.5rem; /* was text-8 (1.4) + ring value 1.5 */
|
||||
--text-6: 2rem; /* was text-9: room code */
|
||||
--text-7: 2.25rem; /* new: the syllable (was 1.6 literal) */
|
||||
```
|
||||
Losing 0.7rem (11.2px) removes the smallest text in the app (pill badge, series, chat time) — all of which are currently at the edge of legible on a phone.
|
||||
- **Accent hover / pressed tokens** — hovers today borrow `--surface-alt`, which is grey, so the accent never darkens under the pointer:
|
||||
```css
|
||||
:root { --accent-hover: #0e5626; --accent-pressed: #0a4520; }
|
||||
[data-theme='dark'] { --accent-hover: #7ae6a1; --accent-pressed: #93edb3; }
|
||||
.primary:hover { background: var(--accent-hover); } .primary:active { background: var(--accent-pressed); }
|
||||
```
|
||||
Both keep white/dark ink ≥ 7:1.
|
||||
- **Lift the page from the cards** (light only): `--bg: #eef3ef` (surface/bg 1.25, cards read as cards; text-muted on bg stays 5.0+). Dark is already layered (1.35/1.49).
|
||||
- **Spacing**: replace the 34 literals with the ramp (`6→--space-2` or `--space-1`, `10→--space-3` or `--space-2`, `14→--space-4`), then the ramp is the rhythm rather than a suggestion. Radius: keep 12/8/pill.
|
||||
- **Where the identity will actually show**: the `.turn` block. With the syllable at 2.25rem in the display face, the ring beside it, and the chain in body face below, the board has one obvious focal point — which is also the one glyph read every turn (`GameBoard:362-367` already argues this).
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Fix #1: new tab vs in-page `<dialog>` summary of the rules? A dialog keeps the clock visible but needs a second copy of the rules or an iframe of `/rules`; a new tab is one attribute.
|
||||
2. Bot game: does the human take the first turn after the opening word (engine `players[0]`)? Determines whether the landing one-liner should mention the clock at all.
|
||||
3. Are the two micro-interactions (word entrance, rejection nudge) wanted, given the current near-zero-motion stance reads as deliberate?
|
||||
4. Font: is a ~45KB self-hosted display face acceptable for a single-binary deploy, or is the zero-byte `system-ui` variant preferred?
|
||||
5. `useSuggestion` — fill only (current) or fill-and-submit?
|
||||
6. Storing the knockout syllable in `elimination` is a store change beyond CSS/markup — in scope for the owner's "accept redesigns", or should it be filed separately?
|
||||
|
||||
Status: DONE
|
||||
Summary: Full read-only review written to `plans/reports/ui-ux-designer-260921-1529-whole-game-ux-review.md`; two structural findings (rules link forfeits the game; no rule before first clock) plus a ranked top-10 with markup/CSS/copy sketches, contrast figures computed from tokens.
|
||||
Concerns/Blockers: Contrast and layout widths are computed/estimated from source, not rendered; §2 header-width estimates assume system-ui metrics.
|
||||
Reference in new issue
Block a user