mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
docs(reports): record the UI/UX review of the game's layout and comfort
Three read-only reviews, one per surface: the solo turn loop, the online room, and the global layout with its tokens and accessibility. They are stateful records of what was found on 2026-09-10, not evergreen authority — the contrast tables and the drift inventory describe the palette as it was measured, before the fixes that follow.
This commit is contained in:
1 parent
b1204e270f
commit
b65b4eade1
3 files changed
+645
No files matched your search
@@ -0,0 +1,263 @@
|
||||
# UI/UX review — global layout, design system, responsiveness, accessibility
|
||||
|
||||
Date: 2026-09-10 · Scope: `web/src/app.css`, `app.html`, `+layout.svelte`, all 15 components' style blocks, route-level containers
|
||||
Method: static read of every `<style>` block + live measurement (Edge/Playwright, `vite dev`, 360/400/768/1280 × light/dark) + WCAG ratios computed from the literal hex values in `app.css`.
|
||||
|
||||
## Verdict
|
||||
|
||||
The green repaint landed cleanly and the "keep the board on screen" fix is structurally correct — zero hardcoded colours in any component, `color-scheme` set in both themes, theme applied pre-paint, no horizontal scroll and exact 16px gutters at all four widths. Three real WCAG AA failures remain in the palette itself (`--border` at 1.47:1 as a form-control boundary, `--accent` at 4.29:1 as text on its own tint), and the single biggest gap is that **turn changes are never announced** — the one state change the whole game hangs on. Spacing and type have no scale tokens at all, which is where the visible inconsistency comes from.
|
||||
|
||||
## What works (verified, not assumed)
|
||||
|
||||
- **Colour token discipline is near-perfect.** `grep -rE '#[0-9a-f]{3,8}|rgba?\(|hsla?\('` across all 15 components and 3 routes: **zero matches**. Every colour resolves through a custom property, exactly as `app.css:1-5` claims.
|
||||
- **Theme correctness.** Inline pre-paint script (`app.html:15-25`) sets `data-theme` before `%sveltekit.head%`; `color-scheme` declared in *both* blocks (`app.css:40`, `app.css:65`) — measured `getComputedStyle(html).colorScheme` = `light`/`dark` respectively. No token exists in only one theme (all 17 are declared twice). Persistence via `settings.svelte.js:117-126` with full storage guards (`safeStorage`/`read`/`write`, lines 19-51) that degrade to in-memory. No flash of wrong theme.
|
||||
- **Responsive baseline.** `document.scrollWidth - clientWidth === 0` at 360, 400, 768, 1280 in both themes on `/` and `/online`; content gutter measured exactly 16px at 360 and 400. No element crosses the viewport edge. `100dvh` alongside `100vh` (`+layout.svelte:34-35`).
|
||||
- **The board fix holds.** At 360×640 the chain gets a 263px internal scroll area while `.input-row` stays pinned at y=214 and the syllable at y=120 — the chain grows inside itself, not the page (`ChainHistory.svelte:91-96` + `:114-122`, `GameBoard.svelte:86-93`). Moving `.resign` *above* the chain (`GameBoard.svelte:74-80`) is the right call and the comment explains why.
|
||||
- **No `outline: none` anywhere.** Focus is never suppressed.
|
||||
- **Neutrals and semantics pass comfortably.** `--text` 15.35/15.39, `--text-muted` 4.88–7.64, `--danger` 6.04–7.46, `--warn` 5.11–10.11, all four seat colours 5.51–10.10 — every one passes AA body in both themes. The four-seat palette deliberately avoids the accent's green and it works.
|
||||
- **Vietnamese typography is fine.** `lang="vi"` (`app.html:2`); `system-ui` → Segoe UI on Windows, SF on macOS, Roboto on Android, all with full diacritic coverage; body `line-height: 1.5` (`app.css:84`) leaves headroom for stacked marks. Verified in render: "Kỷ lục: Chưa có", "Đấu trực tuyến", "Nối tiếp tiếng" all clean at 360px, no clipping.
|
||||
- **iOS zoom guarded.** Every text input is `font-size: 1rem` (`WordInput:140`, `ChatPanel:322`, `NicknameInput:45`, `online:406`) with the reason documented.
|
||||
- **A11y patterns that are already right:** `role="alert"` on errors (`GameBoard:45`, `WordInput:122`, `ChatPanel:162`, `online:263`), `aria-live="polite"` on the connection badge (`ConnectionBadge:19`), `role="status"` on player banners (`PlayerStatus:55,57,64`), `aria-expanded`/`aria-controls` on chain rows (`ChainHistory:41-44`), real `<input type="radio">` in `DifficultyPicker` rather than fake ARIA radios, `<header>`/`<main>`/`<footer>` landmarks present.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### P1 — broken or inaccessible for real users
|
||||
|
||||
**P1-1. Turn changes are never announced.**
|
||||
`GameBoard.svelte:57` — `<p class="who" data-testid="turn-indicator">{turnLabel}</p>`
|
||||
- **Symptom:** the single most important state change in the game ("Đến lượt bạn") reaches nobody who isn't looking at that line. A screen-reader user, or a player who tabbed away, learns nothing until the turn clock forfeits them.
|
||||
- **Cause:** no `aria-live`/`role`. The pattern exists elsewhere in the same file (`GameBoard:45` uses `role="alert"`), so this is an omission, not a decision.
|
||||
- **Fix:**
|
||||
```svelte
|
||||
<p class="who" role="status" aria-live="polite" aria-atomic="true" data-testid="turn-indicator">{turnLabel}</p>
|
||||
```
|
||||
|
||||
**P1-2. `--border` fails WCAG 1.4.11 (3:1) as the boundary of every form control and secondary button, in both themes.**
|
||||
`app.css:13` `--border: #c2d1c8` · `app.css:47` `--border: #3a4a40`
|
||||
- **Symptom:** light theme — inputs are white on `#f4f7f4` (1.05:1 fill difference) with a 1.47:1 border, so the field edge is effectively absent. Dark theme is worse in render: the room-code input at 1280 is barely findable (see measurement run). Low-vision users cannot locate the fields.
|
||||
- **Ratios:** light `#c2d1c8` vs `#ffffff` **1.59**, vs `#f4f7f4` **1.47**, vs `#e9f0ea` **1.37**. Dark `#3a4a40` vs `#17201a` **1.78**, vs `#101512` **1.96**, vs `#1f2a23` **1.58**. All fail 3:1.
|
||||
- **Cause:** one `--border` token serves both decorative card edges (where 1.4:1 is legitimate) and interactive component boundaries (where 3:1 is required). Sites: inputs `NicknameInput:42`, `WordInput:135`, `ChatPanel:318`, `online:403`; buttons `+page.svelte:48`, `Lobby:259`, `GameOverPanel:261`, `RoomCodePanel:98`, `online:418`; `ThemeToggle:26`; `Lobby:232`, `GameBoard:183`.
|
||||
- **Fix:** split the token, keep `--border` for decoration.
|
||||
```css
|
||||
:root { --border-strong: #768b7f; } /* 3.64 / 3.37 / 3.14 — all pass */
|
||||
[data-theme='dark'] { --border-strong: #647f70; } /* 4.23 / 3.82 / 3.40 */
|
||||
```
|
||||
Then swap `var(--border)` → `var(--border-strong)` at the twelve interactive sites above.
|
||||
|
||||
**P1-3. `--accent` as small text on its own tint fails 4.5:1 in the light theme.**
|
||||
`app.css:19` `--accent: #15803d`
|
||||
- **Symptom:** the points readout, the win badge and the record marker — the three things a player actually reads a score off — are under AA in the default theme.
|
||||
- **Ratios (light):** `#15803d` on `--accent-soft #dff2e5` = **4.29** (FAIL); on `--surface-alt #e9f0ea` = **4.33** (FAIL). On `--surface`/`--bg` it passes (5.02 / 4.65). Dark passes everywhere (7.13–8.54).
|
||||
- **Cause / sites:** `ChainHistory:214-218` `.points` renders inside `.rows > li.mine` whose background is `--accent-soft` (`:158-161`) at 0.8rem/600. `ScoreBoard:130-134` `.badge.win` — accent on accent-soft at 0.7rem/700. `GameOverPanel:234-241` `.record` — accent on accent-soft at 1rem/700 (not "large text": needs 18.66px bold). `ConnectionBadge:44-46` — accent on `--surface-alt` at 0.85rem.
|
||||
- **Fix:** darken the accent one step. `--accent: #12692f` gives on-accent-soft **5.82**, on-surface-alt **5.87**, white-on-accent **6.80** (up from 5.02), on-bg **6.30** — every existing pair improves, nothing regresses, dark theme untouched. If the exact hue must be preserved, add `--accent-strong: #12692f` and use it only for the four text sites above.
|
||||
|
||||
### P2 — noticeable inconsistency or friction
|
||||
|
||||
**P2-4. Chat messages are silent to assistive tech.**
|
||||
`ChatPanel.svelte:145` — `<ol bind:this={list} class:column data-testid="chat-log">`
|
||||
- Symptom: incoming messages are never announced; the unread badge (`:136-138`) only exists in the collapsed variant.
|
||||
- Fix: `<ol ... aria-live="polite" aria-relevant="additions">`.
|
||||
|
||||
**P2-5. The `/online` join screen jams against the left edge on tablet and desktop.**
|
||||
`+layout.svelte:14` `const wide = $derived(page.route.id === '/online')` + `online/+page.svelte:312-314`
|
||||
- **Symptom (measured):** at 1280 the shell widens to 1040 but the form is capped at 480 and never centred — 136px of gutter on the left, **664px on the right**. At 768: 16px left, 272px right. The header brand and theme toggle sit ~500px away from the content they belong to, and the footer is centred on a different axis than the form. Visibly broken alignment.
|
||||
- **Cause:** `wide` is keyed to the route, but the route has two states (join form vs room). Only the room needs 1040.
|
||||
- **Fix (one line):** `online/+page.svelte:312` → `.online:not(.room) { max-width: 480px; margin-inline: auto; }`. Better: derive the shell width from room state rather than route id.
|
||||
|
||||
**P2-6. The chain list can collapse to zero height on a short viewport.**
|
||||
`ChainHistory.svelte:114-122` (`.rows { overflow-y: auto }`) inside `+layout.svelte:59-64` (`main { flex: 1; min-height: 0 }`) inside `.shell { min-height: 100dvh }` (`:34-35`)
|
||||
- **Symptom:** with the on-screen keyboard open (visual viewport ~340–420px tall) the chain vanishes and the page offers no scroll to get it back.
|
||||
- **Cause:** `overflow-y: auto` sets a flex item's *automatic minimum size* to 0, so `.rows` shrinks to nothing instead of overflowing and growing the document. Measured at 360×420 the chain floors at 52px — and only because the empty-state `<p class="empty">` has a content minimum; the real `.rows` has none. The shell is pinned at exactly `100dvh` (docH === vpH at 640), so there is no page scroll to fall back on.
|
||||
- **Fix:** `ChainHistory.svelte:114` → add `min-height: 4.5rem;` to `.rows`. The page then grows and scrolls rather than swallowing the list.
|
||||
|
||||
**P2-7. All three routes share one document title.**
|
||||
`app.html:7` `<title>Nối Từ</title>`; no `<svelte:head>` anywhere in `src/`.
|
||||
- Symptom: tab strip, browser history and screen-reader page announcement are identical on `/`, `/play` and `/online`.
|
||||
- Fix: per-route `<svelte:head><title>Chơi với máy · Nối Từ</title></svelte:head>`.
|
||||
|
||||
**P2-8. Heading order starts at `<h2>` on two of three routes.**
|
||||
`+page.svelte` (landing) has no heading at all — the only title is the header `<a class="brand">` (`+layout.svelte:19`). `/play` renders `ChainHistory:22` `<h2>` and `GameOverPanel:38` `<h2>` under no `h1`. `/online` has an `<h1>` only in the join branch (`online:257`); once in a room it disappears while `ChainHistory`/`ChatPanel` still emit `h2`.
|
||||
- Fix: `<h1 class="sr-only">` per route (or promote the landing tagline to `<h1>` with `.tagline` styling), and one in `GameBoard`.
|
||||
|
||||
**P2-9. The reduced-motion escape hatch makes the connection dot flicker instead of stopping.**
|
||||
`app.css:111-118` sets `animation-duration: 0.01ms !important` · `ConnectionBadge.svelte:57-59` `animation: pulse 1.2s ease-in-out infinite`
|
||||
- Symptom: an infinite animation at 0.01ms restarts ~100 000× per frame, so `opacity` resamples arbitrarily every frame — a flicker delivered to exactly the users who asked for less motion.
|
||||
- Fix: in the same block add
|
||||
```css
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
```
|
||||
|
||||
**P2-10. Two JS smooth scrolls override `prefers-reduced-motion` entirely.**
|
||||
`ChainHistory.svelte:17` `list?.scrollTo({ top: 0, behavior: 'smooth' })` · `ChatPanel.svelte:80` `list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' })`
|
||||
- Symptom: an explicit `behavior` option beats any CSS rule, so the chain and chat still animate. The chain scrolls on *every* move.
|
||||
- Fix: `behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'` (or a tiny shared helper, used by both).
|
||||
|
||||
**P2-11. `viewport-fit=cover` is opted into with no safe-area padding anywhere.**
|
||||
`app.html:5` · `grep -rn "env(\|safe-area" src/` → **no matches**
|
||||
- Symptom: on a notched iPhone in landscape the 16px gutter (`+layout.svelte:64`) sits under the cutout, and the footer's licence text sits under the home indicator.
|
||||
- Fix: `+layout.svelte:59-65` and `:44-50` →
|
||||
```css
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
```
|
||||
and `AttributionFooter.svelte:28` → `padding: 20px 16px max(28px, env(safe-area-inset-bottom));`
|
||||
|
||||
**P2-12. Dismiss/icon targets are inconsistent, and one is below WCAG 2.2 minimum.**
|
||||
`GameBoard.svelte:152-165` does it right (44×44 with the padding negative-margined back out, comment and all). `ChatPanel.svelte:301-307` — the same `×` button with no width/height: ~18×18px, **fails 2.5.8 Target Size Minimum (24×24)**. `Lobby.svelte:228-238` `.kick` is 26×26 — passes 24, but is 40% of the 44 used elsewhere. `ThemeToggle.svelte:24-25` is 36×36, the only interactive element in the header.
|
||||
- Fix: lift the `GameBoard` recipe into `app.css` as `.icon-button { width: 44px; height: 44px; display: inline-grid; place-items: center; }` and use it in all four places.
|
||||
|
||||
**P2-13. Focus indication: one recipe, six copies, and buttons excluded.**
|
||||
`ChainHistory:149-152` (offset `-2px`), `ChatPanel:325-328`, `DifficultyPicker:75-78` (`:focus-within`), `NicknameInput:48-51`, `WordInput:143-146`, `online:411-414` — all `outline: 2px solid var(--accent)`, five with `outline-offset: 1px` and one with `-2px`. No `<button>` gets one; every button falls back to the UA ring, which is a different colour and thickness from the inputs beside it.
|
||||
- Fix: delete all six and put one rule in `app.css`:
|
||||
```css
|
||||
:where(a, button, input, summary, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
(Accent-on-bg is 4.65 light / 10.61 dark, so the ring itself passes 1.4.11.) Keep `DifficultyPicker`'s `:focus-within` override, since the real input there is `.sr-only`.
|
||||
|
||||
**P2-14. No spacing scale and no type scale exist.** See the token drift inventory below.
|
||||
|
||||
### P3 — polish
|
||||
|
||||
**P3-15. The idle countdown ring is effectively invisible.**
|
||||
`CountdownRing.svelte:64-66` `.ring.idle { color: var(--border) }` and `:81-83` `.track { stroke: var(--border) }` → `#c2d1c8` on `#f4f7f4` = **1.47:1**, failing the 3:1 that 1.4.11 asks of a meaningful graphic. Confirmed in render: the between-turns ring reads as a smudge.
|
||||
- Fix: `--border-strong` for `.track`, `--text-muted` for `.ring.idle` (5.29:1).
|
||||
|
||||
**P3-16. `/play` and `/` are a 560px column in a 1280px field.** Measured: `.board` is 528px wide with 669px of height inside an 800px viewport, and the chain's empty state leaves ~430px of dead background below; the landing leaves ~250px. The two-column pattern that would fix this already exists and works — `online/+page.svelte:334-362`. Consider `@media (min-width: 900px)` on `/play`: board + syllable + input left, chain right. Taste, not defect.
|
||||
|
||||
**P3-17. Uppercase Vietnamese micro-headings run tight.**
|
||||
`ChainHistory:98-105`, `ChatPanel:204-218`, `GameOverPanel:202-209`: `text-transform: uppercase; letter-spacing: 0.04em` at 0.8–0.85rem. Uppercase Vietnamese stacks tone marks above caps ("CHUỖI TỪ", "TRÒ CHUYỆN") and there is no explicit `line-height`, so it inherits 1.5 and just clears. Add `line-height: 1.6` for headroom, or drop the uppercase — sentence case is easier to read in Vietnamese and loses nothing here.
|
||||
|
||||
**P3-18. `--radius` is nearly dead and the pill radius is unowned.** `var(--radius)` (12px) appears **twice** (`GameOverPanel:115`, `RoomCodePanel:71`) while `--radius-sm` carries the whole UI; `border-radius: 999px` is hardcoded **8 times** (`ChainHistory:209`, `ChatPanel:227`, `ConnectionBadge:31`, `GameOverPanel:224`, `GameOverPanel:237`, `Lobby:192`, `Lobby:233`, `ScoreBoard:124`). Add `--radius-pill: 999px`; decide whether `--radius` earns its keep.
|
||||
|
||||
**P3-19. Skip link absent.** Only two tab stops precede `main`, so low impact — but a `<a class="skip" href="#main">` is four lines.
|
||||
|
||||
**P3-20. `.sr-only` (`app.css:100-109`) omits `border: 0`.** Harmless today (nothing inside it has a border) but it is the one line missing from the canonical recipe.
|
||||
|
||||
---
|
||||
|
||||
## Contrast audit
|
||||
|
||||
Computed from the literal hex values in `app.css` (sRGB relative luminance, WCAG 2.x). Thresholds: **4.5** body text, **3.0** large text (≥18.66px bold / ≥24px) and non-text UI boundaries.
|
||||
|
||||
### Light theme (`app.css:6-41`)
|
||||
|
||||
| Pair | Hex on hex | Ratio | Body | Large/UI | Where |
|
||||
|---|---|---|---|---|---|
|
||||
| `--text` / `--bg` | `#16211b` on `#f4f7f4` | 15.35 | PASS | PASS | body |
|
||||
| `--text` / `--surface` | `#16211b` on `#ffffff` | 16.57 | PASS | PASS | cards, inputs |
|
||||
| `--text` / `--surface-alt` | `#16211b` on `#e9f0ea` | 14.29 | PASS | PASS | `GameOverPanel:153` |
|
||||
| `--text` / `--accent-soft` | `#16211b` on `#dff2e5` | 14.18 | PASS | PASS | `ChainHistory:160` |
|
||||
| `--text-muted` / `--bg` | `#5b6a61` on `#f4f7f4` | 5.29 | PASS | PASS | tagline, hints |
|
||||
| `--text-muted` / `--surface` | `#5b6a61` on `#ffffff` | 5.71 | PASS | PASS | — |
|
||||
| `--text-muted` / `--surface-alt` | `#5b6a61` on `#e9f0ea` | 4.92 | PASS | PASS | `ChainHistory:207-212` |
|
||||
| `--text-muted` / `--accent-soft` | `#5b6a61` on `#dff2e5` | 4.88 | PASS | PASS | `.mine .by` |
|
||||
| `--text-muted` / `--danger-soft` | `#5b6a61` on `#fbe4e2` | 4.70 | PASS | PASS | — |
|
||||
| `--accent-text` / `--accent` | `#ffffff` on `#15803d` | 5.02 | PASS | PASS | primary buttons |
|
||||
| `--accent` / `--bg` | `#15803d` on `#f4f7f4` | 4.65 | PASS | PASS | `ChainHistory:216` (unhighlighted) |
|
||||
| `--accent` / `--surface` | `#15803d` on `#ffffff` | 5.02 | PASS | PASS | `GameOverPanel:128` |
|
||||
| **`--accent` / `--surface-alt`** | `#15803d` on `#e9f0ea` | **4.33** | **FAIL** | PASS | `ConnectionBadge:45` (0.85rem) |
|
||||
| **`--accent` / `--accent-soft`** | `#15803d` on `#dff2e5` | **4.29** | **FAIL** | PASS | `ChainHistory:214`, `ScoreBoard:131`, `GameOverPanel:238` |
|
||||
| `--danger` / `--danger-soft` | `#a3231c` on `#fbe4e2` | 6.15 | PASS | PASS | error banners |
|
||||
| `--danger` / `--surface` | `#a3231c` on `#ffffff` | 7.46 | PASS | PASS | — |
|
||||
| `--danger` / `--bg` | `#a3231c` on `#f4f7f4` | 6.91 | PASS | PASS | `.resign` |
|
||||
| `--warn` / `--surface-alt` | `#8a5a08` on `#e9f0ea` | 5.11 | PASS | PASS | `PlayerStatus:79`, `ConnectionBadge:50` |
|
||||
| **`--border` / `--surface`** | `#c2d1c8` on `#ffffff` | **1.59** | FAIL | **FAIL** | input/button edges |
|
||||
| **`--border` / `--bg`** | `#c2d1c8` on `#f4f7f4` | **1.47** | FAIL | **FAIL** | input/button edges, `CountdownRing:82` |
|
||||
| **`--border` / `--surface-alt`** | `#c2d1c8` on `#e9f0ea` | **1.37** | FAIL | **FAIL** | `RoomCodePanel:98`, `online:418` |
|
||||
| `--player-1..4` / `--surface` | `#1d5c8f`/`#7a3f8f`/`#8a4c12`/`#0e6e7a` on `#ffffff` | 7.05 / 7.15 / 6.72 / 5.95 | PASS | PASS | chat log |
|
||||
|
||||
### Dark theme (`app.css:43-66`)
|
||||
|
||||
| Pair | Hex on hex | Ratio | Body | Large/UI | Where |
|
||||
|---|---|---|---|---|---|
|
||||
| `--text` / `--bg` | `#e6ece7` on `#101512` | 15.39 | PASS | PASS | body |
|
||||
| `--text` / `--surface` | `#e6ece7` on `#17201a` | 13.92 | PASS | PASS | cards, inputs |
|
||||
| `--text` / `--surface-alt` | `#e6ece7` on `#1f2a23` | 12.39 | PASS | PASS | — |
|
||||
| `--text` / `--accent-soft` | `#e6ece7` on `#1e3a2a` | 10.34 | PASS | PASS | `.mine` rows |
|
||||
| `--text-muted` / `--bg` | `#9aab9f` on `#101512` | 7.64 | PASS | PASS | — |
|
||||
| `--text-muted` / `--surface` | `#9aab9f` on `#17201a` | 6.92 | PASS | PASS | — |
|
||||
| `--text-muted` / `--surface-alt` | `#9aab9f` on `#1f2a23` | 6.15 | PASS | PASS | — |
|
||||
| `--text-muted` / `--accent-soft` | `#9aab9f` on `#1e3a2a` | 5.14 | PASS | PASS | — |
|
||||
| `--accent-text` / `--accent` | `#0c1410` on `#5ddc8b` | 10.75 | PASS | PASS | primary buttons |
|
||||
| `--accent` / `--surface` | `#5ddc8b` on `#17201a` | 9.60 | PASS | PASS | — |
|
||||
| `--accent` / `--surface-alt` | `#5ddc8b` on `#1f2a23` | 8.54 | PASS | PASS | `ConnectionBadge:45` |
|
||||
| `--accent` / `--accent-soft` | `#5ddc8b` on `#1e3a2a` | 7.13 | PASS | PASS | `.points`, `.badge.win` |
|
||||
| `--danger` / `--danger-soft` | `#f2867d` on `#3a201e` | 6.04 | PASS | PASS | error banners |
|
||||
| `--danger` / `--surface` | `#f2867d` on `#17201a` | 6.74 | PASS | PASS | — |
|
||||
| `--warn` / `--surface-alt` | `#e9b949` on `#1f2a23` | 8.13 | PASS | PASS | `PlayerStatus:79` |
|
||||
| **`--border` / `--bg`** | `#3a4a40` on `#101512` | **1.96** | FAIL | **FAIL** | input/button edges |
|
||||
| **`--border` / `--surface`** | `#3a4a40` on `#17201a` | **1.78** | FAIL | **FAIL** | input/button edges |
|
||||
| **`--border` / `--surface-alt`** | `#3a4a40` on `#1f2a23` | **1.58** | FAIL | **FAIL** | `online:418` |
|
||||
| `--player-1..4` / `--surface` | `#79b9ec`/`#c193e0`/`#e6a563`/`#63cfdc` on `#17201a` | 7.92 / 6.77 / 7.89 / 9.13 | PASS | PASS | chat log |
|
||||
|
||||
**Summary:** 2 failing pairs in dark (both `--border`), 5 in light (3 × `--border`, 2 × `--accent`-on-tint). Everything else clears AA with margin. The palette is well constructed; the failures are both cases of one token doing two jobs.
|
||||
|
||||
---
|
||||
|
||||
## Token drift inventory
|
||||
|
||||
Colour drift: **none**. The drift is entirely in spacing, type and radii, because those tokens were never created.
|
||||
|
||||
### Missing: spacing scale
|
||||
`app.css:6-66` declares no `--space-*`. Every margin/padding/gap is a literal. Distinct values in use: `1, 2, 4, 6, 7, 8, 10, 12, 14, 16, 18, 20, 24, 26, 28` px — a near-continuous 2px ramp with no rhythm.
|
||||
- Near-duplicate pairs doing the same job: `padding: 8px 12px` (`ChainHistory:139`) vs `10px 12px` (`ChatPanel:294`, `GameBoard:145`, `WordInput:169`, `PlayerStatus:76`) vs `12px 14px` (`Lobby:165`, `NicknameInput:41`, `online:402`) — three paddings for "a card/field row".
|
||||
- Container gaps: `20px` (`+page.svelte:31`), `16px` (`online:305`), `14px` (`GameBoard:90`, `Lobby:141`), `12px` (`GameOverPanel:112`), `10px`, `8px`, `6px`, `4px` — eight gap values across six sibling-level containers.
|
||||
- Off-ramp one-offs: `26px` (`Lobby:229-230`, `.kick` size), `7px` (`ChainHistory:208`, `ScoreBoard:123`), `18px` (`online:417`), `-12px -6px` (`GameBoard:158`).
|
||||
- **Fix:** `--space-1: 4px … --space-8: 32px` (4px base), then snap the ramp. `6px`, `7px`, `10px`, `14px`, `18px`, `26px` are the values to eliminate.
|
||||
|
||||
### Missing: type scale
|
||||
**15 distinct `font-size` values** across 18 files, none tokenised:
|
||||
|
||||
| rem | count | notes |
|
||||
|---|---|---|
|
||||
| 0.85 | 13 | de facto "small" |
|
||||
| 0.9 | 11 | de facto "small" too |
|
||||
| 0.8 | 10 | third "small" |
|
||||
| 1 | 6 | inputs (deliberate, iOS) |
|
||||
| 0.7 | 4 | |
|
||||
| 0.75 | 4 | |
|
||||
| 1.4 | 3 | |
|
||||
| 1.1 | 3 | |
|
||||
| 2, 1.6, 1.5, 1.3, 1.05, 0.95, 0.78 | 1 each | **one-offs** |
|
||||
|
||||
- `0.78rem` at `ChainHistory:223` is indistinguishable from the `0.8rem` used 10 times — pure noise.
|
||||
- `0.95rem` at `ChatPanel:267` and `1.05rem` at `ChainHistory:173` are likewise single-use nudges off the ramp.
|
||||
- Three interchangeable "small" sizes (0.8/0.85/0.9) are the main source of visual inconsistency between panels: `GameBoard:104` uses 0.85, `Lobby:247` uses 0.9, `AttributionFooter:31` uses 0.8, all for the same class of secondary text.
|
||||
- **Fix:** `--text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.125rem; --text-xl: 1.5rem; --text-2xl: 2rem`. Collapse 0.78→0.8→`xs`/`sm`, 0.95→`base`, 1.05→`base`, 1.1→`lg`.
|
||||
|
||||
### Radii
|
||||
`--radius` (12px) used 2×; `--radius-sm` (8px) used ~20×; `999px` hardcoded 8×; `50%` hardcoded 2× (`ThemeToggle:27`, `ConnectionBadge:39`). Add `--radius-pill: 999px`.
|
||||
|
||||
### Duplicated component recipes (DRY, not tokens)
|
||||
- The accent primary button is redeclared **6×**: `+page.svelte:57-61`, `GameOverPanel:267-271`, `Lobby:265-269`, `WordInput:153-159`, `ChatPanel:330-337`, `online:374-381` — with four different paddings (`14px`, `12px`, `14px 20px`, `10px 16px`).
|
||||
- The bordered surface button is redeclared **5×**: `+page.svelte:46-55`, `Lobby:256-263`, `GameOverPanel:258-265`, `RoomCodePanel:96-102`, `online:416-422`.
|
||||
- The disabled state (`background: var(--surface-alt); color: var(--text-muted)`) is redeclared **4×**: `WordInput:162-165`, `ChatPanel:339-342`, `Lobby:276-280`, `WordInput:148-151`.
|
||||
- The `:focus-visible` ring **6×** (see P2-13).
|
||||
- The uppercase micro-heading **3×** (`ChainHistory:98-105`, `ChatPanel:204-218`, `GameOverPanel:202-209`).
|
||||
- **Fix:** three shared classes in `app.css` — `.btn`, `.btn-primary`, `.field-label` — plus the global focus rule. Svelte scoped styles don't prevent using global utility classes for this.
|
||||
|
||||
---
|
||||
|
||||
## Quick wins (top 5)
|
||||
|
||||
1. **Announce the turn.** One attribute set on `GameBoard.svelte:57`: `role="status" aria-live="polite" aria-atomic="true"`. Biggest accessibility gain in the app for the least code.
|
||||
2. **Add `--border-strong`** (`#768b7f` light / `#647f70` dark) and swap it in on the ~12 input/button/toggle borders. Clears the only 1.4.11 failures and makes dark-theme form fields findable.
|
||||
3. **Darken `--accent` to `#12692f`.** One line in `app.css:19`; clears both remaining AA text failures and raises white-on-accent from 5.02 to 6.80. Dark theme untouched.
|
||||
4. **Centre the join screen:** `margin-inline: auto` on `online/+page.svelte:312`. One line, fixes the most visible layout defect (664px right gutter at 1280).
|
||||
5. **One global focus ring** in `app.css` + delete the six copies, and add `animation-iteration-count: 1 !important; scroll-behavior: auto !important;` to the existing reduced-motion block (`app.css:111-118`). Consistency plus a fixed flicker in ~10 lines net negative.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **Accent hue vs. contrast.** Darkening `--accent` to `#12692f` is the KISS fix, but the green was chosen deliberately ("word-game green", `app.css:16-18`). Is a slightly darker green acceptable brand-wise, or should this be a second `--accent-strong` token used only for accent-coloured text on tints?
|
||||
2. **`/play` at desktop width.** Widening the play screen to two columns (board left, chain right) at ≥900px would use the laptop viewport properly, but it changes the game's shape and duplicates the `online` grid. Is the deliberate 560px "one column of reading" (`+layout.svelte:10-14`) a decision to preserve, or was it just never revisited for `/play`?
|
||||
3. **Footer weight on game screens.** The CC BY-SA attribution is a legal obligation and must reach the player, but it costs 86px of measured height on every screen including `/play` at 360×420. Is a compacted single-line variant (smaller padding, still visible, still linked) acceptable to whoever owns the licence decision?
|
||||
4. **`--radius` (12px)** survives at two call sites. Keep it as the "panel" radius and apply it more widely, or retire it and standardise on `--radius-sm`?
|
||||
5. Report was requested at `plans/reports/…-1045-…`; the session hook advertises `web/plans/reports/…-1046-…` (a directory that does not exist). Written to the path in the task brief — confirm which is canonical.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Online room experience — UI/UX review
|
||||
|
||||
Read-only advisory. Scope: `/online` surface, lobby, chat, connection, turn ownership, mobile.
|
||||
Files read: `web/src/routes/online/+page.svelte`, `Lobby`, `ChatPanel`, `ScoreBoard`, `PlayerStatus`, `RoomCodePanel`, `ConnectionBadge`, `NicknameInput`, `GameBoard`, `WordInput`, `CountdownRing`, `GameOverPanel`, `i18n/vi.js`, `ws/{connection.svelte.js,client.js,messages.js}`, `stores/{game,settings}.svelte.js`, `room-code.js`, `app.css`, `+layout.svelte`, plus `server/internal/wsapi/{session,room,hub,nickname}.go` for the states the UI must cover.
|
||||
|
||||
## Verdict
|
||||
|
||||
The happy path is genuinely good: invite link → seated in 1 tap, room code is designed to be read aloud, chat/board/scoreboard coexist sanely at both breakpoints, and turn ownership already carries a text cue rather than colour alone. The damage is concentrated in two places — **the pre-room screen has no connection or in-flight feedback at all** (create/join can hang forever with a live-looking button), and **the lobby has no connection badge and pushes every server refusal into the chat panel below the fold**, so `Bắt đầu`/`Sẵn sàng` can look broken. Third theme: the invite-link path never asks for a nickname, so the invited friend is seated as "Người chơi" with no in-room way to fix it.
|
||||
|
||||
## What works (do not "fix")
|
||||
|
||||
- **Room code design.** Alphabet drops 0/O/1/I/L (`room-code.js:236`), display grouped 3+3 (`RoomCodePanel.svelte:18`), `aria-label` spells it out letter by letter (`:48`), and `normalizeRoomCode` accepts a pasted invite URL, spaces, dots and hyphens in any case (`room-code.js:261-267`). Typo recovery works: malformed → local `codeError` under the field (`+page.svelte:161-163,293`), well-formed but dead → server `room_not_found` in Vietnamese with the typed code still in the field (`:262-263`).
|
||||
- **Invite link auto-joins** instead of making the guest press a button they did not ask for (`+page.svelte:99-101`). One tap from a group chat to a seat.
|
||||
- **Request latch.** `pending` survives a not-yet-open socket and flushes on handshake (`+page.svelte:151-157`), so create/join during connect is not lost, and repeated taps while connecting collapse into one request.
|
||||
- **Word input is gated on the socket, not just the turn** (`WordInput.svelte:16-18`) — a reconnecting player cannot type into a black hole.
|
||||
- **Focus etiquette.** A turn arriving does not steal focus from the chat field (`WordInput.svelte:36-59`); IME composition is respected in both fields (`WordInput.svelte:79`, `ChatPanel.svelte:108`), and both are uncontrolled so Telex/VNI accents survive.
|
||||
- **Unread chat without hijacking.** Folded panel + count badge, counted off the server's running total so the cap cannot kill the badge, seeded at mount so a fresh panel does not claim old mail (`ChatPanel.svelte:48-51,68-71`); autoscroll only when already at the bottom (`:76-81`).
|
||||
- **Grace countdown for a dropped opponent** as a top-of-board banner with `role="status"` (`PlayerStatus.svelte:197-206`) — the single best "what am I waiting for" element in the app.
|
||||
- **Chat lines coloured per seat from server-supplied seat index**, never by matching display names, `chatAuthorLeft` for a cleared seat (`ChatPanel.svelte:91-94,150`).
|
||||
- **Lobby copy already states the rule** for every role/state combination (`Lobby.svelte:90-100`) and `leave` is disabled-with-reason rather than hidden (`:128-133`).
|
||||
- Server refusals are all keyed to Vietnamese sentences in one file (`vi.js:177-211`) — no raw codes leak.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### P1 — blocks or strands a player
|
||||
|
||||
**P1-1. Create/join can hang forever with no status, no spinner, no disabled button.**
|
||||
`+page.svelte:256-297` — the pre-room branch renders title, nickname, error, `Tạo phòng`, code form, back link. Nothing renders `pending` (`:42`) and nothing renders `connection.status`. `ConnectionBadge` is mounted **only** inside `GameBoard.svelte:36`, i.e. never on this screen.
|
||||
Player experience: server down / captive wifi / cold start. Tap `Tạo phòng` → the button stays enabled, the screen does not change, forever. `client.js:198-212` retries with backoff capped at 8s and never gives up, so status sits at `reconnecting` and no error is ever produced to render at `:262`. The player has no way to tell "connecting" from "broken".
|
||||
Secondary harm: with the socket already open (after `Rời phòng`, which keeps the client alive — `+page.svelte:195-199` sends `LeaveRoom` only), each impatient tap sends a real `CreateRoom` (`:155`); `roomsPerSecond = 0.2, roomBurst = 5` (`server/internal/wsapi/session.go:54-55`) so the 6th tap answers `too_many_rooms` — "Bạn tạo phòng quá nhanh" to a player who thinks they tapped nothing.
|
||||
Fix: (a) mount `<ConnectionBadge />` in the pre-room branch, next to `h1`; (b) `disabled={!!pending}` on both `Tạo phòng` (`:266`) and the join submit (`:291`), with the label swapped to `t.connecting` while pending; (c) add copy for a stall, e.g. `connectStalled: 'Chưa kết nối được máy chủ. Kiểm tra mạng rồi thử lại.'`, shown when `pending && connection.status !== Status.OPEN` for >5s.
|
||||
|
||||
**P1-2. The lobby has no connection badge, and lobby buttons fail silently when the socket is down.**
|
||||
`Lobby.svelte` renders no connection state; `GameBoard` (the only `ConnectionBadge` host) is not mounted in the `lobby` phase (`+page.svelte:243-245`). All four lobby actions discard the send result: `ready` `:183`, `start` `:188`, `kick` `:192`, `leave` `:196` — and `send()` returns `false` when the socket is not open (`connection.svelte.js:40-42`, `client.js:305`).
|
||||
Player experience: wifi blips in the lobby. Tap `Sẵn sàng` → the button does not change (state is server-owned by design, `Lobby.svelte:117-122`), no error, no badge. Tap `Bắt đầu` → nothing. The room looks dead and the player leaves.
|
||||
Fix: mount `<ConnectionBadge />` in the lobby (top of `Lobby.svelte`'s `<section>`, or in the `.pane.game` wrapper so it covers both phases), and make the lobby buttons honour the send result: `disabled={connection.status !== Status.OPEN}` on the primary and, on a `false` return, set a local notice using existing copy `t.reconnecting`.
|
||||
|
||||
**P1-3. The invite-link path never asks for a nickname, and there is no way to set one from inside the room.**
|
||||
`+page.svelte:87-108` connects and joins on arrival; the comment at `:94-98` states the nickname is typed on this screen and carried once by `Hello` — but the invite branch bypasses that screen entirely. `settings` defaults `nickname` to `''` (`settings.svelte.js:98`), the server falls back to `"Người chơi"` (`server/internal/wsapi/nickname.go:113`) plus a numeric suffix when taken. `NicknameInput` is rendered only in the `else` branch (`:260`), so it is gone the moment you are seated.
|
||||
Player experience: a first-time invited friend is seated as "Người chơi 2", scoreboard/chat/standings all show that, and the only route to a real name is `Rời phòng` → name field → retype the code (or reopen the link). In a 3-4 player room with two anonymous joiners the chat colours are carrying identity on their own.
|
||||
Fix, cheapest: render `<NicknameInput />` inside the lobby when `!settings.state.nickname`, and on blur re-`Hello`… — but `Hello` is once-per-socket, so instead: on the invite path, if `settings.state.nickname` is empty, show the name field + a single `Vào phòng` button first (hold `pending` instead of flushing) rather than auto-joining. One extra tap only for players who have no name yet; returning players keep the 1-tap link. Alternative if a rename in-room is wanted, it needs a server message and is out of scope here.
|
||||
|
||||
### P2 — noticeable friction
|
||||
|
||||
**P2-1. Lobby refusals render in the chat panel, which on a phone is below the fold.**
|
||||
`+page.svelte:252` passes `errors={!playing}`, so `game.state.error` is drawn by `ChatPanel.svelte:159-166`. In the stacked layout the chat pane comes after the game pane (`:248`) under a divider (`:328-331`). A 360×640 lobby with 4 seats is already ~600px tall (room-code panel ~160 + count 20 + 4×48 seats + gaps + hint + actions 54 + leave 34), so the error box is off-screen.
|
||||
Player experience: owner taps `Bắt đầu` with one guest not ready → `not_everyone_ready` ("Vẫn còn người chưa sẵn sàng") appears somewhere they cannot see; the button appears broken. Same for `too_fast`, `player_is_ready`, `player_offline`, `must_unready_first`, `protocol_version_mismatch` ("Hãy tải lại trang" — the one message that must be seen).
|
||||
Fix: render the error where the action is. Add the error block to `Lobby.svelte` directly above `.actions` (reuse `GameBoard.svelte:44-49` markup incl. the 44px dismiss button), and drop `errors` for the lobby case so it is not shown twice.
|
||||
|
||||
**P2-2. During a mobile game the chat toggle is below the whole chain.**
|
||||
DOM order: `GameBoard` (`+page.svelte:229-246`) ends with `<ChainHistory />` (`GameBoard.svelte:82`), then the chat pane. So the collapsible header — the only way to read chat and the only place the unread badge lives (`ChatPanel.svelte:127-133`) — sits under a list that grows one row per turn.
|
||||
Player experience: turn 15, a teammate asks something, the badge exists but is two screens down; the player never scrolls there mid-turn. The unread mechanism is well built and effectively unreachable.
|
||||
Fix: hoist the toggle. Put the collapsible chat header (title + badge) into `GameBoard`'s `.top` row beside `ConnectionBadge` — or move `.pane.talk` before `ChainHistory` in the stacked layout via `order` on a grid/flex parent. Minimum viable: a small pill button in `.top` showing the unread count that scrolls the chat panel into view.
|
||||
|
||||
**P2-3. Clipboard failure is invisible, and the invite URL is never shown as text.**
|
||||
`RoomCodePanel.svelte:24-35` swallows every clipboard rejection; `navigator.clipboard` is undefined outside a secure context, which is exactly the self-hosted `http://<lan-ip>:port` case this single-origin Go binary invites. `navigator.share` fails the same way (`:37-43`).
|
||||
Player experience: tap `Sao chép mã` / `Sao chép liên kết mời` → the label never changes to "Đã sao chép", nothing is on the clipboard, no explanation. The 6 characters are on screen (recoverable), but the *link* is not — it exists only inside `inviteUrl` (`:12-14`).
|
||||
Fix: in the `catch`, select the code node (`getSelection().selectAllChildren(codeEl)`) and set a state that swaps the label to new copy, e.g. `copyFailed: 'Không sao chép được. Hãy chọn và sao chép thủ công.'`; and render `inviteUrl` as a selectable `<p class="link">` (or `<input readonly>`) under the buttons so a link is always shareable by hand.
|
||||
|
||||
**P2-4. "It is your turn" is the smallest, faintest text on the board, and is not announced.**
|
||||
`GameBoard.svelte:57` renders `turnLabel` in `.who` — `font-size: 0.85rem; color: var(--text-muted)` (`:117-121`) — right next to a 1.6rem syllable (`:134-137`). Same styling whether the label says "Đến lượt bạn" or "Đến lượt Lan…". No `aria-live`, so nothing is spoken on a turn change. `ScoreBoard.svelte:73-78` marks the active row with border + background only (colour-only). The disabled word field keeps the inviting placeholder "Nhập từ của bạn" (`WordInput.svelte:111`) even when it is somebody else's turn.
|
||||
Player experience: on ≥900px the player's eyes are in the right-hand chat column; the turn arriving is a small grey line and a colour change in a score row, and autofocus is deliberately declined while they type in chat (`WordInput.svelte:36-59`). Turns get burned. Conversely a player types a word into a dead input and only then notices.
|
||||
Fix (three cheap changes): (1) `class:mine={game.state.myTurn}` on `.who` → `color: var(--text); font-weight: 700; font-size: 1rem;`; (2) `aria-live="polite"` on the turn indicator `<p>`; (3) placeholder driven by ownership — `placeholder={enabled ? t.wordInputPlaceholder : fill(t.playerTurn, { name })}` so the dead input states whose turn it is. Optional non-colour cue on the scoreboard: a `▸` marker or a bottom border rule on `.side.active`.
|
||||
|
||||
**P2-5. A player who drops mid-turn watches the clock run out with a 0.85rem pill and no retry.**
|
||||
`CountdownRing.svelte:13,20-27` keeps animating off `deadlineMs` regardless of connection; `ConnectionBadge.svelte:128-138` is a muted 0.85rem pill in the corner. Reconnect delay is up to 8s and jittered (`client.js:18,199-203`), and there is no manual retry: `connection.svelte.js` exposes only `connect` (no-op if a client exists), `disconnect`, `forgetSession`.
|
||||
Player experience: tunnel/lift. The ring keeps counting, the input greys out, and a player who is back online cannot force an attempt — they wait out the backoff and lose the turn. Nothing says the seat is being held (`graceMs` is known to the client — `game.svelte.js:105,268` — but only ever spent on *other* players' banners).
|
||||
Fix: when `connection.status !== Status.OPEN`, render a banner above the input (same slot as `PlayerStatus`) with existing `t.reconnecting` plus a `Thử lại` button; add `reconnectNow()` to `connection.svelte.js` (cancel `reconnectTimer`, reset `attempt`, `open()`) — `client.js:206-212` already has all the pieces. Freeze or dim the ring while the socket is down so it stops implying a live clock.
|
||||
|
||||
**P2-6. In the lobby, a dropped player has no countdown and the hint keeps lying.**
|
||||
`PlayerStatus` is mounted only as `GameBoard`'s banner snippet (`+page.svelte:232-234`), so in the `lobby` phase the grace countdown is not rendered at all — a dropped player is just `Mất kết nối` text on their row (`Lobby.svelte:56-57`). The hint chain (`Lobby.svelte:90-100`) ignores connectivity, so a ready guest keeps reading "Đang chờ chủ phòng bắt đầu…" while the owner is gone.
|
||||
Fix: render `<PlayerStatus />` in the lobby too (it is phase-guarded internally and would just show the away countdowns), and add a branch to the hint: when the owner's row has `!connected`, show new copy `ownerAway: 'Chủ phòng đang mất kết nối. Chờ một chút hoặc rời phòng.'`.
|
||||
|
||||
**P2-7. Chat arriving in the lobby is completely silent.**
|
||||
`collapsible={playing && !wide}` (`+page.svelte:251`), so in the lobby the panel is never collapsible → `open` is always true (`ChatPanel.svelte:41`) → `unread` is forced to 0 (`:51`) and no badge exists. But in the stacked lobby the log is below the fold (see P2-1).
|
||||
Player experience: "sẵn sàng chưa?" is typed and nobody who is looking at the seat list ever knows.
|
||||
Fix: make the panel collapsible whenever `!wide` (drop the `playing &&`), which restores the badge in the lobby, and combine with the hoisted toggle from P2-2.
|
||||
|
||||
**P2-8. The ready button's label is the same string as the "not ready" *state*.**
|
||||
`Lobby.svelte:121` → `game.isReady ? t.unready : t.ready`, and `vi.js:99,101` set `unready: 'Chưa sẵn sàng'` and `notReady: 'Chưa sẵn sàng'` — identical. Styling compounds it: `.actions .primary.on` turns the ready player's button grey/muted (`Lobby.svelte:271-274`), so a ready player sees a greyed button reading "Chưa sẵn sàng" and reasonably concludes their readiness did not register.
|
||||
Fix: `unready: 'Bỏ sẵn sàng'` — which also matches the two messages that already use that verb (`unreadyToLeave` `vi.js:111`, `must_unready_first` `:189`). Keep the muted style but the label now reads as an action.
|
||||
|
||||
**P2-9. Touch targets below 44px on the three controls that matter most in a room.**
|
||||
- Chat collapsible header — the only way to open chat during a mobile game: `padding: 0`, `font-size: 0.85rem` → ~19px tall (`ChatPanel.svelte:204-223`).
|
||||
- Kick button, destructive: `26×26` (`Lobby.svelte:229-238`).
|
||||
- Room-code actions, the whole sharing story: `padding: 8px 14px` at `0.85rem` → ~34px (`RoomCodePanel.svelte:96-102`).
|
||||
- Also `Lobby.svelte:283-289` `.leave` ~34px, and the chat error dismiss `×` has no size at all (`ChatPanel.svelte:301-307`) → ~18px.
|
||||
Fix: `min-height: 44px` + `padding: 10px 0` on `.header`; kick to `36×36` with a `44px` hit area via `::after` inset expansion (or `min-width/height: 44px` and shrink the glyph); `min-height: 44px; padding: 10px 16px` on the room-code and leave buttons. For the dismiss `×`, copy the existing in-repo pattern at `GameBoard.svelte:152-165` (44×44 with negative margins so the banner keeps its height).
|
||||
|
||||
**P2-10. Refreshing mid-game from an invite URL shows a spurious red error.**
|
||||
`+page.svelte:99-107` checks `isRoomCode(code)` **before** `hasStoredSession()`, so a reload at `/online?code=ABC123` sends `Hello` **with** the stored resume token (`client.js:228`) *and* a `JoinRoom`. The resume restores the seat; the join is then refused by the room with `game_in_progress` (`server/internal/wsapi/room.go:558-562`) or `cannot_join_own_room` (`:564-568`), which lands as a red banner on the restored board (`GameBoard.svelte:44-49`). Also `resuming` is `false` on this path (`:141`), so the quiet-drop logic at `:128-136` does not apply.
|
||||
Player experience: reload to recover from a glitch → you are back in your game, with "Ván đấu đang diễn ra." accusing you of something.
|
||||
Fix: prefer resume when a token exists, and fall back to the invite code only when the resume is refused — the machinery already exists at `:128-136` (it detects a refused resume). E.g. keep `pending = { kind: 'join', code }` un-flushed while `hasStoredSession()`, clear it when the phase reaches `lobby`/`playing` with a matching `roomCode`, flush it in the refused-resume effect.
|
||||
|
||||
### P3 — polish
|
||||
|
||||
- **P3-1. Room code is unreachable and unlabelled once the game starts.** `+page.svelte:231` passes it as `modeLabel`, rendered as muted 0.85rem text with no label and no copy button (`GameBoard.svelte:37,102-105`); the post-game lobby is `compact`, which drops `RoomCodePanel` entirely (`Lobby.svelte:33-35`) — exactly when a replacement player needs inviting. Fix: prefix with `t.roomCodeLabel` and make it a copy button; keep `RoomCodePanel` in the compact lobby (or a one-line variant).
|
||||
- **P3-2. Code glyph pairs.** `.code` uses the body `system-ui` stack with `font-variant-numeric: tabular-nums` (`RoomCodePanel.svelte:81-87`), which does nothing for letters. The alphabet keeps 5/S, 2/Z, 8/B, 6/G, U/V (`room-code.js:236`) — fine spoken aloud in Vietnamese, harder to copy from a screenshot. Fix: `font-family: ui-monospace, 'SFMono-Regular', 'Cascadia Mono', Menlo, monospace` on `.code` only.
|
||||
- **P3-3. Offline marker in the scoreboard is a `title`-only `⚠`** (`ScoreBoard.svelte:31-33`) — `title` does not exist on touch and is not reliably announced. Fix: `aria-label` plus an `.sr-only` span (`app.css:100-109` already has the utility).
|
||||
- **P3-4. `yourRoomCode: 'Mã phòng của bạn'`** is shown to guests too (`Lobby.svelte:34`). Fix: neutral `'Mã phòng'`, or branch on `game.isOwner`.
|
||||
- **P3-5. Owner's seat is permanently painted as ready** — `class:ready={player.isOwner || player.ready}` (`Lobby.svelte:43`) gives the owner row accent-soft forever while showing no state label (`:58-63`). Defensible, but scanning the list the highlight reads as a readiness claim. Fix: a distinct `.seat.owner` treatment (left border in `--accent`) instead of reusing `.ready`.
|
||||
- **P3-6. Native `confirm()` for kick and resign** (`+page.svelte:192,215) blocks the JS thread, so the countdown ring freezes while the server clock keeps running — an accidental `Đầu hàng` tap can time the turn out during the dialog. Also unstyled and, in some in-app webviews, suppressed. Fix: an inline two-button confirmation row where the button is (matches the rest of the app's styling and does not block the frame loop).
|
||||
- **P3-7. Blank nickname is accepted silently** (`NicknameInput.svelte:185-193`, no `required`); the player learns their name is "Người chơi" only after being seated. Fix: `placeholder`/hint already exist — add a hint line stating the fallback, e.g. append to `nicknameHint`: "Để trống sẽ được gọi là “Người chơi”."
|
||||
- **P3-8. Stale `?code=` in the URL.** `codeInput` is seeded from the query (`:67`) and the query is never cleared after a successful join under a *different* code, so a later refresh re-joins the URL's room. Fix: `replaceState` to drop the param once `roomCode` is established.
|
||||
|
||||
---
|
||||
|
||||
## Quick wins (top 5, comfort per unit of effort)
|
||||
|
||||
1. **`unready: 'Bỏ sẵn sàng'`** (`vi.js:99`). One string. Kills the worst "did my tap register?" moment in the lobby. (P2-8)
|
||||
2. **Mount `<ConnectionBadge />` on the pre-room screen and in the lobby**, and `disabled={!!pending}` on `Tạo phòng`/`Vào phòng`. ~6 lines, closes the only true dead end in the flow. (P1-1, P1-2)
|
||||
3. **Move the error block into `Lobby.svelte` above `.actions`** (copy `GameBoard.svelte:44-49`) and stop routing lobby errors to the chat panel. ~8 lines, makes every server refusal visible where the button is. (P2-1)
|
||||
4. **Emphasise your turn**: `class:mine` on `.who` + `aria-live="polite"` + ownership-driven placeholder on the disabled word field. ~10 lines across `GameBoard.svelte` and `WordInput.svelte`, recovers turns lost while reading chat. (P2-4)
|
||||
5. **`min-height: 44px` on the chat toggle, room-code buttons and `.leave`; size the chat dismiss `×` like the board's.** Pure CSS, four rules. (P2-9)
|
||||
|
||||
Next tier, worth scheduling: hoist the chat toggle out from under the chain (P2-2), the nickname-before-invite-join gate (P1-3), clipboard fallback + visible invite URL (P2-3).
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **Nickname on the invite path** — is the extra tap acceptable for first-time guests (gate the join behind the name field), or is a rename-in-room server message preferred? The second option changes the protocol and is outside this review.
|
||||
2. **Manual reconnect** — adding `reconnectNow()` touches `ws/connection.svelte.js` and its tests. Acceptable, or should P2-5 be limited to copy plus freezing the ring?
|
||||
3. **Grace window in the lobby** — does an owner who merely disconnects (not leaves) lose the seat and hand the room on at grace expiry? `room.go:1441-1457` handles it on vacate; if lobby grace does not vacate, guests can be stranded until `room_idle_closed`, which would upgrade P2-6 to P1.
|
||||
4. **Whether the 900px chat column should ever collapse** — on a 900×600 laptop with a keyboard-less browser it is fine, but a 1024×768 tablet in portrait falls into the stacked branch. No defect found; flagging in case a mid-breakpoint layout is wanted.
|
||||
5. **`GameOverPanel` + compact lobby stacking on 360px** — not measured against a real device; the vertical budget after standings + stats + suggestions is likely over one screen, which would put the `Bắt đầu`/`Sẵn sàng` for the next game below the fold. Worth a screenshot pass.
|
||||
@@ -0,0 +1,256 @@
|
||||
# Solo play loop — comfort review
|
||||
|
||||
Read-only review. Paths relative to `web/src/`.
|
||||
|
||||
## Verdict
|
||||
|
||||
The loop is well thought through on desktop: focus returns to the field on every turn, the field is pre-seeded with the required syllable, IME composition is respected, and the chain scrolls inside itself so the input never moves. Two defects hurt real play: the countdown turns danger-red during the **bot's** turn (false panic, every turn), and `disabled` on the input blurs the field each time the turn passes, which closes the on-screen keyboard and cannot be reopened programmatically — a phone player pays one extra tap per turn. Everything else is P2 legibility/contrast/session-end polish.
|
||||
|
||||
## What works (do not change)
|
||||
|
||||
- **No layout shift under the input.** `.board` is a flex column whose only flexible child is `.chain` (ChainHistory.svelte:91-96), so a growing chain, an inserted rejection line and the error banner all take space from the chain. The ring, syllable and input row never move. The comment at GameBoard.svelte:75-77 (resign above the chain) is correct reasoning.
|
||||
- **Uncontrolled input + composition guards.** WordInput.svelte:11, 79, 93-115: `autocapitalize/autocorrect/spellcheck` off, no value write-back, submit refused mid-composition. This is exactly right for Telex/VNI and is the hardest thing here to get right.
|
||||
- **Syllable seeding with caret placement** (WordInput.svelte:65-70) and re-seeding after a rejection (:24-26, :60) removes the one keystroke sequence every turn shares.
|
||||
- **Focus is not stolen from the chat** (`typingElsewhere`, :36-43) and chain rows never grab focus (ChainHistory.svelte:36-37).
|
||||
- **`SETTLE_MS = 300`** (countdown.js:10) and `Math.ceil` on the display (:48) — the clock never shows time the server has already taken. Correct call.
|
||||
- **No decorative motion in the ring.** A continuous rAF arc, no pulse, no flash. Better for repeated play than the usual pulsing clock.
|
||||
- **The input unmounts at game over** (GameBoard.svelte:51-53), so a leftover draft cannot leak into a rematch. Verified — not a bug.
|
||||
- **Touch targets in the turn loop are fine:** input 14px padding + 16px text ≈ 52px, submit ≈ 52px, `.resign` `min-height: 44px`, error dismiss 44×44 with negative margins (GameBoard.svelte:152-165).
|
||||
- **16px input font** (WordInput.svelte:138-140) — no iOS focus zoom.
|
||||
|
||||
---
|
||||
|
||||
## P1
|
||||
|
||||
### P1-1 — The clock goes red while the bot is thinking
|
||||
|
||||
**CountdownRing.svelte:13, 29-32**
|
||||
|
||||
Player experience: mid-game the ring turns danger-red and the number counts to 1 while the player can do nothing. It reads as "you are about to time out" when it is actually the bot's clock. Repeated 20+ times a session, this is the single most stress-inducing thing on the board.
|
||||
|
||||
Cause: `running` only checks `phase === 'playing' && deadlineMs > 0`, and `game.state.deadlineMs` is rewritten by *every* `turnUpdate` including the ones handing the turn to the bot (stores/game.svelte.js:336). `urgent` (:32) never consults `game.state.myTurn`.
|
||||
|
||||
Fix:
|
||||
|
||||
```js
|
||||
const mine = $derived(game.state.myTurn);
|
||||
const urgent = $derived(running && mine && left <= URGENT_SECONDS);
|
||||
```
|
||||
|
||||
and desaturate the ring when it is not the player's clock, so the number is legible but not addressed to them:
|
||||
|
||||
```css
|
||||
.ring:not(.mine):not(.idle) { color: var(--text-muted); }
|
||||
.ring:not(.mine) .value { color: var(--text-muted); }
|
||||
```
|
||||
|
||||
(add `class:mine` on the wrapper, :35).
|
||||
|
||||
### P1-2 — On a phone the keyboard closes on every turn and does not come back
|
||||
|
||||
**WordInput.svelte:110, 118 (`disabled={!enabled}`), :89, :51-59**
|
||||
|
||||
Player experience: type a word, send, the keyboard drops. The bot answers. "Đến lượt bạn" appears, the field lights up — but there is no keyboard. Tap the field, keyboard, type, send, keyboard drops. One dead tap per turn, forever.
|
||||
|
||||
Cause, two parts:
|
||||
1. `enabled` requires `myTurn` (:16-18), so the instant the turn passes the input receives `disabled`. Setting `disabled` on the focused element blurs it, and a blurred input closes the on-screen keyboard.
|
||||
2. On turn return, `field?.focus()` (:59) runs inside an `$effect` driven by a WebSocket message — not a user gesture. iOS Safari will not open the keyboard for a programmatic `focus()`, and Android Chrome is inconsistent. So the focus ring returns and the keyboard does not.
|
||||
|
||||
Tapping "Gửi" instead of the keyboard's send key loses focus even earlier: the button click blurs the input before `handleSubmit` runs.
|
||||
|
||||
Fix (both parts needed):
|
||||
|
||||
```js
|
||||
// inside handleSubmit, after field.value = '' — still inside the user gesture,
|
||||
// so iOS honours it and the keyboard stays up.
|
||||
field.value = '';
|
||||
field.focus();
|
||||
```
|
||||
|
||||
and stop disabling the field itself. Keep the *submit* disabled (the guard at :79 already refuses out-of-turn sends) and leave the field editable so focus — and the keyboard — survive the bot's turn:
|
||||
|
||||
```svelte
|
||||
<input ... aria-disabled={!enabled} />
|
||||
<button type="submit" disabled={!enabled} ...>{t.submit}</button>
|
||||
```
|
||||
|
||||
Speculative typing during the bot's turn then becomes possible, so the seed must correct a stale draft instead of yielding to it. Replace :65:
|
||||
|
||||
```js
|
||||
// Yield to a draft only if it already starts with the syllable being asked
|
||||
// for. A draft aimed at a position that has moved on is worse than no draft.
|
||||
const draft = field.value.trim();
|
||||
if (!syllable) return;
|
||||
if (draft && draft.toLowerCase().startsWith(syllable.toLowerCase())) return;
|
||||
field.value = `${syllable} `;
|
||||
```
|
||||
|
||||
Keep the visual "not your turn" state on the field via `aria-disabled`, e.g. `input[aria-disabled='true'] { background: var(--surface-alt); color: var(--text-muted); }`, so nothing looks different.
|
||||
|
||||
Caveat worth a device check: iOS also dismisses the keyboard when an input becomes `readonly`, which is why the recommendation is fully editable rather than `readonly`.
|
||||
|
||||
---
|
||||
|
||||
## P2
|
||||
|
||||
### P2-1 — Accent text on `accent-soft` is 4.29:1 in the light theme
|
||||
|
||||
**app.css:20-21 pairing, used at ChainHistory.svelte:158-161 + :214-218, GameOverPanel.svelte:234-241, ScoreBoard.svelte:130-134**
|
||||
|
||||
Measured: `--accent #15803d` (L 0.1593) on `--accent-soft #dff2e5` (L 0.8485) = **4.29:1**. Fails WCAG AA 4.5:1 for normal text. Affected while playing: `.points` (`+3`, 0.8rem) inside every `li.mine` row, `.record` ("Kỷ lục mới!"), `.badge.win`. Dark theme measures 7.13:1 and is fine.
|
||||
|
||||
Player experience: the score gain on their own words — the thing the chain is read for — is the lowest-contrast text on screen, in the light theme they will use outdoors.
|
||||
|
||||
Fix: this is a token decision (owned elsewhere). Either darken the accent to ≥ `#0f6b32` (≈5.3:1 on `accent-soft`), or add an `--accent-ink` token for accent-coloured text sitting on `--accent-soft` and point these three rules at it. Do not solve it per-component.
|
||||
|
||||
### P2-2 — The required syllable has the tightest line-height on the board
|
||||
|
||||
**GameBoard.svelte:134-137**
|
||||
|
||||
```css
|
||||
.syllable strong { font-size: 1.6rem; line-height: 1.2; }
|
||||
```
|
||||
|
||||
Player experience: syllables with stacked diacritics — `ệ`, `ộ`, `ỗ`, `ặ`, `ừ` — have their tone mark riding into the `Nối tiếp tiếng` label 2px above (`.who` margin-bottom 2px, :118). The one glyph the player must read correctly every turn is the one with no headroom.
|
||||
|
||||
Fix:
|
||||
|
||||
```css
|
||||
.syllable .label { margin-bottom: 2px; }
|
||||
.syllable strong { font-size: 1.6rem; line-height: 1.35; }
|
||||
```
|
||||
|
||||
1.35 × 1.6rem = 34.6px, enough for a full `ộ` stack plus descender. Same treatment is worth applying to `.who` (:117-121) which sits directly above it.
|
||||
|
||||
### P2-3 — A rejected word is deleted; the player retypes it under the clock
|
||||
|
||||
**WordInput.svelte:86-89, :121-123**
|
||||
|
||||
Player experience: submit `hoa hông`, get "Không tìm thấy từ này trong từ điển", and the field is empty except the re-seeded `hoa `. In Vietnamese a rejection is very often one wrong tone mark. The player has to reconstruct the whole word from memory with the clock running, and the message never tells them what the server actually received.
|
||||
|
||||
Cause: :89 clears the field on a *successful send*, which is before the server's verdict. The store keeps the word (`state.rejection.word`, stores/game.svelte.js:347-350) but the markup at :122 renders only `.message`.
|
||||
|
||||
Fix — at minimum, show it, so the eye can spot the wrong mark without retyping:
|
||||
|
||||
```svelte
|
||||
<p class="rejection" role="alert">
|
||||
<strong>{game.state.rejection.word}</strong> — {game.state.rejection.message}
|
||||
</p>
|
||||
```
|
||||
|
||||
```css
|
||||
.rejection strong { font-weight: 600; }
|
||||
```
|
||||
|
||||
Better, for the reasons above: restore it into the field and select it, so a retype overwrites and a correction is one edit away. In the `$effect` at :51-71, when `rejection` is what changed and the field holds only the bare seed, write back `rejection.word` and `setSelectionRange` over the part after the syllable.
|
||||
|
||||
### P2-4 — `confirm()` for resign covers the board while the server clock runs
|
||||
|
||||
**routes/play/+page.svelte:89-91**
|
||||
|
||||
Player experience: press "Đầu hàng" mid-turn, hesitate over the OS dialog, dismiss it — and the turn has expired, or the clock has jumped several seconds. `confirm()` blocks the main thread, so the rAF loop in CountdownRing stops (:20-27) while the server deadline does not.
|
||||
|
||||
Fix: two-step in place, no dialog, no blocking. Local state in GameBoard: first press swaps the label to a confirm and arms a 4s timeout that reverts it.
|
||||
|
||||
```svelte
|
||||
<button type="button" class="resign" class:arming={arming} onclick={armOrResign}>
|
||||
{arming ? t.resignConfirm : t.resign}
|
||||
</button>
|
||||
```
|
||||
|
||||
Reuse `t.resignConfirm` or add a short `resignSure: 'Chắc chắn?'` to i18n/vi.js (`t.resignConfirm` is a full sentence and will wrap the button). Keeps the rAF loop alive and keeps the board visible.
|
||||
|
||||
### P2-5 — Game over: focus falls to `<body>`, and "Chơi lại" can land below the fold
|
||||
|
||||
**GameBoard.svelte:51-53; GameOverPanel.svelte:97-104**
|
||||
|
||||
Player experience:
|
||||
- Keyboard/AT: the input unmounts, focus resets to the document top. A screen-reader user is told nothing — the panel has `role="group"` + `aria-label` (:37), which is not announced on insertion. To play again they Tab through the header, the badge, then the panel.
|
||||
- Phone, after the common solo loss (`NO_LEGAL_MOVE` / timeout with `elimination.suggestions`): panel height ≈ h2 + reason + standings + stats + suggestion chips + record + export + actions + gaps + 40px padding ≈ 500-540px, on top of header 48 + `.top` 28 + scoreboard 60 + footer 65. At 360×640 the actions row is off-screen and the player scrolls to restart.
|
||||
- The full-width button immediately under the results is `.export` ("Tải chuỗi từ", :97), not the rematch. Wrong thing in the primary slot.
|
||||
|
||||
Fix:
|
||||
1. Move the `.export` button after the `.actions` div (:97 → after :104). One-line reorder; it is a keepsake, not the next action.
|
||||
2. Announce and land focus without arming Enter. Do **not** focus the rematch button — a player who just submitted with Enter may still be holding it and would restart instantly. Focus the panel wrapper:
|
||||
|
||||
```svelte
|
||||
<div class="panel" role="group" tabindex="-1" bind:this={panel} aria-label={...}>
|
||||
```
|
||||
|
||||
```js
|
||||
$effect(() => { if (result) panel?.focus(); });
|
||||
```
|
||||
|
||||
```css
|
||||
.panel:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
```
|
||||
|
||||
3. Add `aria-live="polite"` to the `h2` (:38) so the result is spoken.
|
||||
|
||||
### P2-6 — Urgency is signalled by colour alone, and by the worst colour pair for it
|
||||
|
||||
**CountdownRing.svelte:32, 55-59**
|
||||
|
||||
Green → red is the deuteranopia/protanopia confusion pair; roughly 1 in 12 men gets no urgency signal at all. Nothing else changes at 5s: the arc keeps shrinking at the same rate, the number keeps the same weight.
|
||||
|
||||
Fix — add a non-colour channel, no motion needed:
|
||||
|
||||
```css
|
||||
.ring.urgent circle { stroke-width: 9; }
|
||||
.ring.urgent .value { font-size: 1.75rem; }
|
||||
```
|
||||
|
||||
The ring thickening and the number growing are both readable in greyscale and neither animates.
|
||||
|
||||
### P2-7 — Nothing guarantees the input stays above the on-screen keyboard
|
||||
|
||||
**app.html:5; routes/+layout.svelte:31-38; ChainHistory.svelte:91-96**
|
||||
|
||||
The shell is `min-height: 100dvh` and `.chain` (`flex: 1; min-height: 0; overflow-y: auto`) absorbs all vertical slack, so the document has **no scroll overflow**. With the default `interactive-widget=resizes-visual`, `dvh` does not shrink when the keyboard opens, so keyboard avoidance depends entirely on the browser panning the visual viewport. Measured stack above the input at 360px wide: header 48 + `.top` 28 + scoreboard ~60 + `.turn` 80 + three 14px gaps = ~258px, input bottom ~310px. On a 640px-tall viewport with a ~330px keyboard the input sits right on the boundary; with a taller keyboard (Vietnamese IME suggestion strip) or a shorter viewport it goes under, and there is no document scroll to recover it.
|
||||
|
||||
Fix, one line in app.html:5:
|
||||
|
||||
```html
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
```
|
||||
|
||||
`resizes-content` makes `dvh` shrink with the keyboard; the existing flex column then does the right thing automatically — `.chain` gives up height and the ring, syllable and input stay on screen. Chrome-only, but Safari's visual-viewport panning is the case that already works. Verify on a real 360×640 device with the Vietnamese keyboard up; my numbers are computed from the CSS, not measured.
|
||||
|
||||
---
|
||||
|
||||
## P3
|
||||
|
||||
- **`.who` is the faintest text on the board** (GameBoard.svelte:117-121): `0.85rem` + `--text-muted` for "Đến lượt bạn", the single most important state. Redundant with the scoreboard's `.active` row, so not a defect — but `1rem` and `color: var(--accent); font-weight: 600` when `game.state.myTurn` would make the turn hand-off readable in peripheral vision.
|
||||
- **Chain rows are ~41px tall** (ChainHistory.svelte:134-136): 8px + 8px padding + 25px line. Under 44px. Not mid-turn critical (reading a definition is a lull activity), but `min-height: 44px` on `.row` costs nothing.
|
||||
- **The chain yanks back to top while the player is reading** (ChainHistory.svelte:15-18): every accepted word scrolls to 0, including when the player has scrolled down to read an older definition. Guard it: `if ((list?.scrollTop ?? 0) < 48) list?.scrollTo(...)`.
|
||||
- **Smooth scroll ignores `prefers-reduced-motion`** (ChainHistory.svelte:17): the global rule at app.css:111-118 only neuters CSS animations/transitions, not `scrollTo({behavior:'smooth'})`. Use `behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'`.
|
||||
- **`.latest` emphasis is invisible in the dark theme** (ChainHistory.svelte:168-170): `box-shadow: var(--shadow)` is black at 40%/30% over `--surface #17201a` — nothing. Meanwhile `li.mine` carries a full accent border + tinted background, so in solo play the *alternation* marks turns and the newest word is unmarked. Use a structural cue: `border-inline-start: 3px solid var(--accent)` on `.latest`.
|
||||
- **Standings render twice at game over**: ScoreBoard.svelte:14 swaps to `standings`, and GameOverPanel.svelte:44-58 lists them again. In solo that is the same two rows stacked. Suppress the panel's `ol.standings` when there are only two players, or drop it in bot mode.
|
||||
- **A screen-reader player gets no time warning.** `role="timer"` (CountdownRing.svelte:35) is implicitly `aria-live="off"`, which correctly avoids 60 announcements a second but means the clock is silent. Add an `sr-only` `aria-live="polite"` node that emits only when `left` crosses 10 and 5, and only on the player's own turn.
|
||||
- **The input never reports invalidity.** WordInput.svelte:101-115 has no `aria-invalid` / `aria-describedby`. Give the `.rejection` an `id` and wire both.
|
||||
- **Two error surfaces, far apart.** `game.state.error` renders above the scoreboard (GameBoard.svelte:44-49) while rejections render under the input (WordInput.svelte:121). A mid-typing server error (`too_fast`, `busy`) lands ~250px away from where the eye is. Consider routing in-play server errors to the same slot as the rejection.
|
||||
- **`viewport-fit=cover` with no `env(safe-area-inset-*)` anywhere.** `main` has `padding: 0 16px` (routes/+layout.svelte:64) — in landscape on a notched iPhone the board edge sits under the cutout. `padding-inline: max(16px, env(safe-area-inset-left)) ...`.
|
||||
- **`<ol class="rows">` with `list-style: none`** (ChainHistory.svelte:114-122) drops list semantics in Safari/VoiceOver, so "1 of 24" is lost. Add `role="list"`.
|
||||
- **DifficultyPicker is 3 fixed columns** (DifficultyPicker.svelte:50-54). At 360px each option gets ~104px minus 16px padding → "Trung bình" and "Kỷ lục: Chưa có" both wrap. Legible, just ragged. `grid-template-columns: repeat(auto-fit, minmax(96px, 1fr))` or a single column under 380px.
|
||||
- **`fill()` runs 60×/s.** CountdownRing.svelte:35 recomputes `fill(t.secondsLeft, {n: left})` on every rAF tick (a regex replace + allocation) even though the string only changes once a second. Derive the label from `left`: `const timeLabel = $derived(fill(t.secondsLeft, { n: left }))`.
|
||||
|
||||
---
|
||||
|
||||
## Quick wins (comfort gained per unit of effort)
|
||||
|
||||
1. **Gate `urgent` on `myTurn`** and mute the ring when the clock is not the player's — CountdownRing.svelte:32. Two lines; removes a false panic spike from every single turn. (P1-1)
|
||||
2. **`field.focus()` at the end of `handleSubmit`, and drop `disabled` from the `<input>`** (keep it on the button) with the syllable-corrective seed at :65 — WordInput.svelte. ~8 lines; removes one dead tap per turn on every phone. (P1-2)
|
||||
3. **`interactive-widget=resizes-content`** in app.html:5. One attribute; makes the existing flex layout keyboard-safe for free. (P2-7)
|
||||
4. **`line-height: 1.35` on `.syllable strong`** — GameBoard.svelte:136. One value; fixes the tone marks on the glyph the player reads most. (P2-2)
|
||||
5. **Move `.export` below `.actions` and render the rejected word in the rejection line** — GameOverPanel.svelte:97 and WordInput.svelte:122. Two small edits; puts "Chơi lại" in the primary slot and stops blind retyping after a near-miss. (P2-5, P2-3)
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **What is `turnLimitMs` in solo?** It is server-owned (stores/game.svelte.js:306) and I could not read a value. `URGENT_SECONDS = 5` is only sensible relative to it — 5s of 30s is a fair warning, 5s of 10s is half the turn. Also determines whether a 10s warning tier is worth adding.
|
||||
2. **How long does the bot take to answer, per difficulty?** Decides whether "the input is dead while the bot thinks" is a 400ms blink or a 3s freeze, i.e. whether P1-2's editable-field change is a convenience or essential.
|
||||
3. **iOS behaviour of the P1-2 fix.** `field.focus()` inside a click-triggered submit *should* keep the keyboard up on iOS; the `disabled`→editable change *should* stop the blur. Both need a real device (iOS Safari + Android Chrome with a Vietnamese Telex keyboard). Please confirm before adopting rather than trusting my reading.
|
||||
4. **Who owns `--accent` / `--accent-soft`?** P2-1 is a token fix (4.29:1, light theme only). Brief says another agent owns the token system — handing it over rather than patching three components.
|
||||
5. **Is `.mine` tinting wanted in solo?** Half the chain is accent-green in a two-player game where the alternation already says whose word it is. Genuinely useful with four players. Worth deciding per mode, or worth leaving alone — taste, not a defect.
|
||||
6. **Should a rejected word be restored into the field, or only displayed?** Restoring is kinder for a one-tone-mark miss but overwrites whatever the player started typing in the interim. Depends on typical rejection round-trip time, which I did not measure.
|
||||
Reference in new issue
Block a user