Add AlertBanner and turnActions to replace duplicated markup and
wrappers, and share component-test setup.
Fixes: oversized dismiss, help, rules and skip-link text after the type
ramp collapse; report button hover styled as submit; chat draft lost
when the panel folds; resign/claim/kick styles leaking app-wide.
The suggestion and report pills sat at 2px padding on 0.8rem text, under
WCAG's 24px minimum, on the two taps most worth making under a running
clock. They move onto their own line at a 36px floor, and the suggestion
reads as primary weight since it is almost certainly the word the player
meant. The fill-only behaviour and the field's uncontrolled-input invariant
are unchanged.
Type GameState for real instead of returning any from initialState(), which
made every game.state.* read in every component unchecked. apply() now
switches on payload.case so the oneof narrows, and is wrapped in try/catch so
a throw partway through a case cannot leave a half-mutated snapshot on
screen. Also key chain meanings by position instead of gloss, since the
dictionary gives no gloss-uniqueness guarantee, and gate the word field's
turn-seed effect on the same connection check `enabled` already uses so a
reconnect cannot seed a field the player cannot submit from.
The chain shows each word's score breakdown as small chips beside the total.
A "Bí từ" button next to the input claims a dead end on the player's own
turn, armed the same way resign is; a false claim is answered inline near
the input rather than in the general error banner. A rejection for a word
not in the dictionary offers the one real word a diacritic typo differs
from, filling and focusing the field on click, and a button to report the
word as real, acknowledged with a confirmation once the server has heard it.
A /rules page says, in one place, what nothing in the app said before:
the chain rule, the clock, what a dead end costs, elimination and the last
player standing, how a word is scored and the reconnect window. Linked
from the landing page and from the board and lobby headers.
The chat control moves above the chain as a pill with the unread count,
where it can be reached on a phone mid-game, and the lobby's folded chat
now carries an unread badge too.
ESLint with the Svelte and JSDoc plugins, run in CI; the real findings
it turned up (missing each keys, untyped timer handles) are fixed.
It keeps focus through the handover so a phone's keyboard survives it,
which left a caret blinking in a field that takes no text and an I-beam
over it inviting one. The caret is transparent and the pointer is a
plain arrow while the turn is somebody else's.
Its placeholder was a sentence — "Đến lượt Minh…", "Mất kết nối, đang
thử lại…" — in a box that shares its row with the send button, so the
end of it was cut off against the edge. The board's turn line above says
who is thinking and the badge says the connection is down; the field
only has to say that it is not yours yet, so it says that, and anything
that still does not fit ends in an ellipsis.
A turn belonged to one player, but the screen and the room both let
somebody else act inside it.
The word field took text out of turn — never disabled, so the on-screen
keyboard survives the handover — so a player could spend an opponent's
turn typing a word the submit button was never going to send. It now
refuses the text instead: beforeinput is cancelled, and the composed
character an input method lands past it is taken straight back out.
Giving up was open to any seat at any time. It is a move, played instead
of a word, so only the player to act may spend one; the board offers the
button on their turn and no other, and the room answers not_your_turn.
Somebody who wants out of a game they are not on turn in leaves the
room, which is now allowed mid-game: the seat is freed and the others
are told somebody left, exactly as a reconnect window running out
reports it. Going back to the homepage does that, taking the stored
session with it, so the player arrives back a stranger who can join
again rather than being resumed into the room they walked out of — and
a ready player's readiness is taken back first, or the seat would sit
in the lobby ready and empty until its window ran out.
Two things one player's exit used to cost the player still thinking: the
turn sequence moved even when the turn had not, refusing the word
already in flight, and a wordless turn update wiped the rejection they
were reading. The sequence now moves only when the turn does, and only
an accepted move answers a rejection.
Applies the findings of the three UI/UX reviews. The two that cost a
player something every single turn:
- The countdown reddened during the opponent's turn. It read the deadline,
which the server rewrites on every handover, and never asked whose turn
it was — a panic spike, every turn, about nothing the player could act
on. It now mutes when the clock is not theirs, and signals urgency by
thickening as well as reddening, because green to red is the pair a
red-green colour blindness cannot separate.
- On a phone the keyboard closed on every turn and could not be reopened.
`disabled` on the focused word field blurs it, and the recovering
focus() ran on a WebSocket message, which iOS will not open a keyboard
for. The field stays editable and the submit button carries the turn
instead; focus is asked for inside the gesture that submitted. A draft
aimed at a syllable the game has moved past is now corrected rather
than kept.
The board was also not holding together as the game grew. `min-height`
on the shell left nothing with a definite height to shrink against, so
the chain kept its full height and the page got taller every turn,
walking the word field and the chat off the bottom of the screen. The
shell now has a real height, the chain gives up its space first with a
floor so it cannot vanish, and `main` scrolls only as a last resort.
Online play had one true dead end: nothing on the pre-room screen showed
the connection at all, so a server that was down left an enabled button
and a screen that never changed — while each impatient tap sent a real
CreateRoom until the rate limiter answered. The badge is there now,
requests disable their button, a stall says so, and the lobby draws the
room's refusals beside the button that produced them instead of in a
chat panel below the fold. A dropped player gets a retry rather than
waiting out the backoff. An invite link asks a first-time guest for a
name instead of seating them as "Người chơi" with no way to fix it.
Accessibility and palette:
- The turn change is announced. It is the state the whole game hangs on
and it reached nobody who was not looking at that line.
- --accent darkened to #12692f: accent text on its own tint measured
4.29:1. Every existing pair improves, white-on-accent included.
- --border-strong added for the boundary of anything you type into or
press, which needs 3:1 and had 1.47:1 light, 1.78:1 dark.
- One focus ring, one 44px icon-button recipe, spacing and type scales,
safe-area gutters, per-route titles, a heading per screen, a skip
link, and reduced motion that stops the connection dot pulsing rather
than flickering it a hundred thousand times a frame.
Tests follow the two interactions that changed shape: resign and kick
confirm inline with a second press, because confirm() blocks the frame
loop the countdown runs on and hesitating could cost the turn it was
protecting. joinRoomSeated waits for the seat before anybody speaks —
the server replays a seat only the conversation from when it joined, so
the old helper was racing the handshake.
Verified: 189 unit tests, 40 end-to-end, no type errors, and the
palette and layout measured at 360/400/768/1280 in both themes.
A turn arriving pulled focus into the word field even mid-sentence in the
chat, dropping the rest of the message into the game. Focus is now only
claimed when no other text field holds it; syllable seeding is unchanged.
A room screen wide enough for it is two columns now: the game or the
lobby on one side and the room's conversation on the other, so neither
has to be scrolled past to reach the other. A phone keeps one column,
the game first, with the chat folded behind an unread count while a game
is on. One chat panel spans both phases by staying in the same place in
the markup — a remounted panel reopened having read nothing, and handed
the reader the lobby's conversation back as unread mail.
The room also keeps the score of the series. PlayerSlot carries the
games a seat has won since the room opened, credited before the lobby is
broadcast so the players see it the moment a game ends, and shown in the
lobby and beside each player's score on the board. It belongs to the
seat rather than to the room, because vacating one is exactly when the
name on it stops meaning the same person.
The conversation reads as a log — one line per message, "name: text",
each seat in its own colour — rather than as a stack of bubbles, which
stops being legible once four people are talking. ChatMessage carries
the seat that spoke, so a line is coloured by what the server said
rather than by matching display names, and a line whose seat has been
vacated is in nobody's colour.
The board's word button gets a test id of its own: it and the chat's
send button both read "Gửi", and the two are now on screen together.
Three things a player asked for after playing:
The field now arrives holding the syllable the next word has to start with,
so only the half that is still in question gets typed. It is written once per
turn and never over an existing draft, which keeps the field uncontrolled and
leaves Telex and VNI composition alone.
The chain lists the newest word first. The move that decides what to play
next is the last one, and it belongs where the eye lands rather than at the
end of a list that has to be scrolled.
A finished game can be downloaded as a plain-text transcript: the chain in
playing order, who played each word, and the final score. The export keeps
playing order because a chain read on its own only makes sense from the
opening word forward — newest-first is a reading aid for the live board, not
the shape of the game.
The board, the home screen and the game-over panel, in Vietnamese, served by
the Go binary as a static single-page app.
The store is a reducer over ServerMessage and computes nothing. Validity, turn
order, scores and the result are read from the wire, which is what lets one
screen serve the bot now and online play later. Every Vietnamese string lives in
one module, including the map from RejectReason to a message, so the server can
send UI keys instead of prose.
The word field is uncontrolled. A Telex or VNI input method composes a diacritic
across several keystrokes, and writing the value back on each one cancels the
composition. It is read on submit and cleared only there.
The countdown is drawn against the server's clock, estimated from the ping round
trip, and settles 300ms early so the ring never claims more time than the server
allows.
The screen owns the socket and the game while it is mounted, and gives the
current game up on the way out. Asking for a game is stored intent rather than a
condition inferred from the board being empty: clearing the board for a rematch
is that same condition, so the inference sent a second StartBotGame and the
server built two rooms that then destroyed each other.
A test greps the built bundle for dictionary words and holds a size budget, so
the wordlist cannot reach the browser unnoticed. Another reads the error codes
out of the Go transport and fails when one has no Vietnamese message.