chore(plans): add execution plan and review reports

Add UI/UX flow polish execution plan with phases and review reports from code audit and presentation audit.
This commit is contained in:
tiennm99 committed 2026-09-01 17:17:22 +07:00
1 parent 3cc77e2869
commit 0ce56737f3
8 files changed
+926 -1

No files matched your search

@@ -30,6 +30,12 @@ in zero tests. The stub also serves the SAME panorama URL every round, so
assertions pass without a viewer remount. On any diff touching an API response
shape or the round transition, open `helpers.js` and check it moved too.
Second instance 2026-09-01 (Geoapify tile migration): the stubs match third-party
hosts *literally* (`**/tile.openstreetmap.org/**`), so swapping a provider behind
an env var silently un-stubs the suite - `npm run dev` loads `.env`, so the moment
a real key exists the "offline" run hits the vendor and spends metered credits.
Any diff that makes an outbound host configurable must widen the matcher too.
**Still open, verified 2026-08-30 on the Phase 6 docs review:**
- React prop/state contracts. `no-undef` does not see a prop a parent forgot to
pass, a stale value left in state when a sibling is cleared, or a Leaflet
@@ -39,7 +45,19 @@ shape or the round transition, open `helpers.js` and check it moved too.
real one is `isPlayable()` via `resolvePlayableRegion()` (grep the named symbol
for production callers, not just for existence), and a doc claiming a per-point
`is_pano` field that is only a build-time filter (open the generated data and
look at an actual record).
look at an actual record). `docs/project-structure.md` is the worst offender by
construction: it enumerates EVERY `src/lib/*.js` and every page one by one, so
any new module or route silently falsifies it (missed for `src/lib/map-tiles.js`
and `src/app/credits/page.js`, 2026-09-01). Diff the new-file list against it.
Two more self-falsifying enumerations found 2026-09-01 (UI/UX polish pass):
`docs/development.md` "Styling Conventions" names CSS classes and per-file
raw-palette exceptions literally (`vn-gradient-bg`, "score bands and podium
colors in RoundResultDialog.js/LeaderboardList.js, always with a `dark:`
variant"), so any class rename or token migration turns it into instructions
for the pattern just removed; and `docs/game-flow.md` step 1 scripts the player
flow beat by beat, so moving *when* a modal fires falsifies it. On any CSS
class rename, grep `docs/` for the old name; on any flow-timing change, reread
`game-flow.md`.
- `README.md` is outside the doc-update habit. Verified 2026-08-30: the six
`/docs/` files were rewritten for the region tree while README still described
@@ -0,0 +1,76 @@
---
phase: 1
title: "Quick wins"
status: todo
priority: P1
effort: "4h"
dependencies: []
---
# Phase 1: Quick wins
## Overview
The 12 small presentation fixes from the audit — labels, tooltips, dialog a11y,
touch targets, page parity. No flow or structure changes.
## Requirements
- Functional: every item below lands exactly as scoped; no feature removed.
- Non-functional: no API/localStorage/visual-token contract change.
## Related Code Files
Modify only: `src/app/page.js`, `src/app/components/UsernameModal.js`,
`src/app/components/GameClient.js`, `src/app/components/RoundResultDialog.js`,
`src/app/components/ResultMap.js`, `src/app/components/RegionPicker.js`,
`src/app/components/DonateQRModal.js`, `src/app/components/LeaderboardModal.js`,
`src/app/components/ThemeToggle.js`, `src/app/credits/page.js`
## Implementation Steps
1. **F1** — make "Playing as X" a button that reopens `UsernameModal`; show on
mobile (chip). No stored name → "Set name" chip. `UsernameModal` accepts an
initial value prop so reopening pre-fills the current name.
2. **F2a** — relabel modal CTAs: "Start Playing" → "Save name"; "Skip" →
"Skip — random name" (wording pairs with Phase 2's random-name behavior; in
this phase Skip still just closes).
3. **F5** — Skip button: `title`/`aria-label` "Skip this location — no penalty"
(`GameClient.js:487-494`).
4. **F6** — result dialog: caption "It was in" + region path in
`font-semibold text-foreground`; caption the bands strip "This round's
scoring ladder"; reorder body score → distance → map → path → ladder →
leaderboard cards (`RoundResultDialog.js:85-212`).
5. **F7a** — captions above the two result grids: "Leaderboard points added" /
"Best-distance ranks"; `title` on `(+N)`: "each board grades your distance on
its own scale".
6. **F8** — result map: guess dot red → blue (match in-game pin), keep actual
green; legend row "● Your guess ● Actual location" in `RoundResultDialog.js`
(`ResultMap.js:42-70`).
7. **F9/F10** — align score label and message cutoffs (derive both from one
band); fix amber/orange circle contrast (darken bg or dark text).
8. **F11/F12** — pano count gets a unit ("spots" or "locations") and `title`;
badge tooltips: partial = "Street imagery covers only part of this province",
few streets = "Limited street imagery — repeats likely" (`RegionPicker.js`).
9. **F14** — every dialog: centered `text-2xl` title + `DialogDescription`
(sr-only where visually redundant). Add missing descriptions to Donate and
Leaderboard (Radix warning). Drop Donate's redundant footer Close.
10. **F16** — ThemeToggle buttons `w-9` → `w-11` (44px floor).
11. **F19** — credits page: add ThemeToggle to header; "Back to game" → "← Home".
## Todo
- [x] Steps 1-11
- [x] Update `tests/e2e/` selectors/labels touched by renames (username modal spec)
- [x] `npm test`, `npm run lint`, `npm run build`, e2e green
## Success Criteria
- [x] All 12 audit QW items verified in UI
- [x] No Radix DialogDescription warnings in console
- [x] e2e smoke green
## Risk Assessment
Low. Renamed button labels can break e2e text selectors — signal: spec failure;
response: update the spec text, not the label.
@@ -0,0 +1,99 @@
---
phase: 2
title: "Flow redesign"
status: todo
priority: P1
effort: "6h"
dependencies: [1]
---
# Phase 2: Flow redesign
## Overview
Reshape when things appear, keeping every feature: username prompt moves to the
first Play click with a random-name fallback, one-time in-game hint overlay,
session progress badge, collapsible leaderboard section in the result dialog.
## Requirements
- Functional: F2b, F3, F4, F7b from the audit + the random-username decision.
- Non-functional: purely client-side; `/api/guess` still receives a non-empty
`username`; existing stored usernames untouched.
## Architecture
**Random username.** New `generateRandomUsername()` in `src/lib/username.js`:
`Player-` + 4 random base36 chars (fits the modal's `[a-zA-Z0-9_-]`, 2-20 rule).
Generated once, persisted via `setUsername`, thereafter behaves like any chosen
name (editable via the "Playing as X" chip from Phase 1).
**Username at the moment it matters.**
- `page.js`: remove the on-mount auto-open (`:42-49` keeps reading the stored
name for the chip). Intercept the first Play navigation in `RegionPicker`
rows: if no stored username, open the modal; navigate after save/skip.
- Modal Skip: generate + persist a random name, then continue navigation.
- `GameClient.js:173`: deep-linked `/game` with no stored name — replace
`username || 'Anonymous'` with generate-and-persist on first submit, so the
leaderboard shows the player's editable random name. Existing "Anonymous"
rows in board data remain (data, not code).
**Hint overlay (F3).** Small `first-round-hint.js` client component (or ~30
lines in `GameClient`): one-time dismissible overlay on the game screen,
localStorage key `vngeoguessr_hint_seen` — "Drag to look around · Click the map
to drop your guess · Submit". Auto-dismiss on first map click. Desktop guess map
gets a transient "Click to place your guess" ghost label until `hasGuess`
(mirror of the mobile cover, `GuessMapPanel.js:76-86`).
**Session progress (F4).** Client-side counter in `GameClient` state (rounds
played, points sum from each submitted result) rendered as a small header badge
next to the region name (`GameClient.js:393-398`). Resets on page load — no API
or storage change.
**Result dialog (F7b).** Wrap bands strip + both leaderboard grids in one
collapsible "Leaderboard results" section (accordion or `<details>` styled to
match), so score → distance → map → path fits one phone viewport.
## Related Code Files
- Create: `src/app/components/first-round-hint.js` (optional; may inline)
- Modify: `src/lib/username.js`, `src/app/page.js`,
`src/app/components/RegionPicker.js`, `src/app/components/UsernameModal.js`,
`src/app/components/GameClient.js`, `src/app/components/GuessMapPanel.js`,
`src/app/components/RoundResultDialog.js`
## Implementation Steps
1. `generateRandomUsername()` + unit test in `tests/` (validates modal charset
and length rules).
2. Defer modal to first Play click; wire save/skip → persist → navigate.
3. Random-name fallback in modal Skip and in `GameClient` first submit.
4. Hint overlay + desktop ghost label, both gated and dismissible.
5. Session progress badge.
6. Collapsible leaderboard section in result dialog.
7. Update `tests/e2e/` username-modal and full-round specs for the new timing
(modal appears on Play, not on landing).
## Todo
- [x] Steps 1-7
- [x] `npm test`, `npm run lint`, `npm run build`, e2e green
## Success Criteria
- [x] Fresh profile: landing shows game intro with no modal; Play opens modal;
Skip yields "Playing as Player-xxxx" and starts the game
- [x] Deep link `/game?region=…` on fresh profile: hint overlay shows once;
first submit writes a generated name to the board
- [x] Header badge increments per round; per-round results otherwise unchanged
## Risk Assessment
- Modal timing change breaks the e2e username spec by design — update the spec
as part of step 7, not after.
- Race: two RegionPicker rows both intercepting navigation — intercept in one
shared handler. Signal: double modal or lost navigation in manual test;
response: centralize in `page.js` state.
- `RoundResultDialog.js:202` shows `username || 'Anonymous'` — after step 3 a
name always exists; leave the fallback expression as dead-safe or remove with
the phase, either is fine.
@@ -0,0 +1,78 @@
---
phase: 3
title: "Consistency refactor"
status: todo
priority: P2
effort: "4h"
dependencies: [1]
---
# Phase 3: Consistency refactor
## Overview
One color and icon language: semantic tokens replace raw Tailwind palette
classes and inline hex; Lucide replaces emoji; misleading class name renamed.
Audit items F15, F16b, F18.
## Requirements
- Functional: identical information hierarchy; only how colors/icons are
expressed changes.
- Non-functional: every semantic color defined for light + dark in
`globals.css`; no raw palette classes left at the touched call sites.
## Architecture
**Tokens (F15).** Add to `globals.css` alongside `--brand`: `--success`,
`--warning`, `--danger` (may alias `--destructive`), `--rank-gold` (+ dark
values). Map call sites:
- `RoundResultDialog.js` `getScoreBg` (green/emerald/amber/orange/red/neutral →
success/warning/danger ramp on the new tokens) and `leaderboardMessage` green;
- `LeaderboardList.js` `DISTANCE_COLORS` and `getScoreColor` — simplify the
6-step rainbow to two states: top-3 tint (`--rank-gold`) + default; amber
"YOU" highlight → brand or rank token;
- `ResultMap.js` inline hex `#ef4444/#22c55e/#da251d` → values read from the
token palette (Leaflet needs literal colors: define JS constants next to the
markers that mirror the tokens, with a comment naming the token).
**Icons (F16b).** Replace emoji with Lucide (already the majority language):
ThemeToggle sun/moon/monitor, `page.js` 🍺 → matching Lucide glyph, check
`GameClient.js` and `DonateQRModal.js` for stragglers.
**F18.** Rename `.vn-gradient-bg` → `.vn-surface` in `globals.css:212-214` and
its usages (it is a flat surface, not a gradient).
## Related Code Files
- Modify: `src/app/globals.css`, `src/app/components/RoundResultDialog.js`,
`src/app/components/LeaderboardList.js`, `src/app/components/ResultMap.js`,
`src/app/components/ThemeToggle.js`, `src/app/page.js`,
`src/app/components/GameClient.js`, `src/app/components/DonateQRModal.js`
## Implementation Steps
1. Define tokens (light + dark) in `globals.css`.
2. Migrate `RoundResultDialog`, `LeaderboardList`, `ResultMap` call sites;
simplify `getScoreColor`.
3. Lucide icon sweep; verify no emoji remains in interactive chrome.
4. `.vn-gradient-bg` → `.vn-surface` rename (grep for all usages).
5. Visual pass in light + dark themes.
## Todo
- [x] Steps 1-5
- [x] `npm test`, `npm run lint`, `npm run build`, e2e green
## Success Criteria
- [x] `grep -rE "bg-(green|emerald|amber|orange|purple)-" src/app/components`
returns nothing for the three migrated files
- [x] No emoji glyphs in buttons/toggles; Lucide throughout
- [x] Dark mode renders all new tokens correctly
## Risk Assessment
Cosmetic-only phase; the risk is silent contrast regressions in dark mode —
signal: manual theme pass; response: adjust dark token values, keep light ones.
F20 (Vietnamese font subset) stays deferred — note it in the phase close-out.
@@ -0,0 +1,79 @@
---
phase: 4
title: "Legacy cleanup"
status: todo
priority: P2
effort: "2h"
dependencies: []
---
# Phase 4: Legacy cleanup
## Overview
Remove migration-era code identified by the 2026-09-01 legacy scout. Three
tiers: one-shot migration scripts (gated on the backfill actually running),
transitional session fallbacks (safe — 30-min TTL long expired), and permanent
compat that must NOT be touched.
## Requirements
- Functional: no behavior change for any live client or stored data.
- **GATE for step 2:** operator must first run
`node --env-file=.env scripts/migrate-leaderboards.mjs --apply --confirm-prefix=vngeoguessr:`
and see "applied. Backfill verified". Dry run on 2026-09-01 proved it has NOT
run (Lam Dong/Long An missing 5/18/6/101 members from Da Lat/Duc Hoa). Keep
the produced `leaderboard-backup-*.json` until the boards are confirmed good;
do not commit it.
## Related Code Files
- Delete (step 2, gated): `scripts/migrate-leaderboards.mjs`,
`scripts/lib/leaderboard-migration.mjs`, `tests/migrate-leaderboards.test.js`
- Modify: `package.json` (drop `leaderboard:migrate` script),
`src/app/api/guess/route.js` (lines 68, 80-83),
`src/app/api/new-game/route.js` (line 125),
`tests/guess-route.test.js` (lines 106-116),
`docs/project-structure.md` (drop migration script entries)
- **Keep forever (do not touch):** `:city:` Redis key prefixes (`upstash.js`),
API legacy aliases `cityRank`/`cityDistanceRank`/`type`/`cityCode`
(`guess/route.js:136-138`, `leaderboard/route.js:35-37`,
`leaderboard.js:193-195, 341-342`), `?city=` query param
(`region-request.js:31`) — they guard 30+ months of player score history.
## Implementation Steps
1. Remove session backward-compat: `session.pickedRegion ?? session.cityCode`
and `session.regionCode ?? session.cityCode` fallbacks in `guess/route.js`
and `new-game/route.js`; delete the compat test block in
`guess-route.test.js:106-116`. (All sessions carry the new shape; TTL is
30 min and the writing release shipped long ago.)
2. **[GATED — backfill applied + verified]** delete the two migration scripts,
their test file, and the `leaderboard:migrate` npm script; update
`docs/project-structure.md` (script list + test note). Git history keeps the
restore path.
3. Grep sweep: `legacy|backfill|migration` under `src/` and `scripts/` — confirm
remaining hits are only the permanent-compat comments listed above.
## Todo
- [x] Step 1 (ungated) — fallbacks removed 2026-09-01, compat test deleted
- [ ] Operator runs backfill; verify output (dry run 2026-09-01 confirmed NOT yet applied)
- [ ] Steps 2-3
- [x] `npm test`, `npm run lint`, `npm run build` green (after step 1)
## Success Criteria
- [ ] No `cityCode` session fallback in API routes; tests reflect current shape
- [ ] Migration scripts gone only after verified backfill
- [ ] Permanent compat untouched (assert `?city=` and legacy response fields
still work via existing tests)
## Risk Assessment
- Deleting scripts before the backfill runs would strand Lam Dong/Long An
boards permanently short — hence the hard gate. Signal: dry run not showing
"both empty"/matching members; response: stop, run backfill first.
- Session-fallback removal breaks only a session created before the fan-out
release — impossible now (30-min TTL). If a stale-session 500 ever appears in
logs anyway, restore the null-coalesce (one line).
@@ -0,0 +1,61 @@
---
title: "UI/UX flow polish and legacy cleanup"
description: "Newbie-friendly flow, consistent presentation, migration-era code removal"
status: in-progress
priority: P1
effort: "2-3d"
tags: [ui-ux, cleanup]
created: 2026-09-01
---
# UI/UX flow polish and legacy cleanup
## Overview
Deliver the accepted brainstorm (2026-09-01): make the whole play flow clearer and
newbie-friendly, unify presentation, and remove migration-era legacy code. Source
audit: `plans/reports/ui-ux-review-260901-1624-presentation-audit.md` (findings
F1-F20 referenced by phases).
**Contract**
- Outcome: consistent UI, clear presentation, easy first-time play; legacy code gone.
- Constraints: keep all features; no breaking changes (API responses, Redis keys,
localStorage compat); legacy mixed board scores stay; JS only; individual function
params; only `src/`, `docs/`, `plans/` modified.
- Non-goals: new gameplay features, score re-grading, session summary screen,
TypeScript, font change (F20 deferred), mobile expanded-map peek.
- User decisions: pano counts get labels (keep visible); username prompt deferred to
first Play click; blank/skipped username → generated random name; per-round
results only.
## Goals
| # | Goal | Priority |
|---|------|----------|
| 1 | First-time player understands every step without outside help | P1 |
| 2 | One dialog/color/icon language across all screens | P2 |
| 3 | Migration-era code removed without touching player data | P2 |
## Phases
| # | Phase | Status |
|---|-------|--------|
| 1 | [Phase 1: Quick wins](./phase-01-start.md) | Pending |
| 2 | [Phase 2: Flow redesign](./phase-02-flow-redesign.md) | Pending |
| 3 | [Phase 3: Consistency refactor](./phase-03-consistency-refactor.md) | Pending |
| 4 | [Phase 4: Legacy cleanup](./phase-04-legacy-cleanup.md) | Pending |
Phases are independently shippable, in order. Phase 4 step 1 is gated on the
operator running the leaderboard backfill (verified NOT yet applied in prod on
2026-09-01; dry run showed Lam Dong/Long An boards missing 5/18/6/101 source
members).
## Success Criteria
- [ ] `npm test`, `npm run lint`, `npm run build` green after each phase
- [ ] Playwright e2e smoke (`tests/e2e/`) green, updated where flow changed
- [ ] All F-findings mapped to a phase are addressed or explicitly deferred
- [ ] No change to `/api/*` response shapes, Redis key names, or existing
localStorage values
<!-- slug: uiux-flow-polish-and-legacy-cleanup -->
@@ -0,0 +1,369 @@
# Code Review — UI/UX flow polish and legacy cleanup (phases 1-3 + 4.1)
Date: 2026-09-01
Scope: uncommitted working tree, `D:/tiennm99/vngeoguessr`
Plan: `plans/260901-1636-uiux-flow-polish-and-legacy-cleanup/`
## Scope
- 23 modified + 2 new source/test files, ~564 insertions / ~251 deletions
- Focus: pending changes only (`git diff`, no staged content)
- Verification re-run: `npx vitest run tests/username.test.js tests/guess-route.test.js` (9 pass), `npm run lint` (0 errors, 19 warnings). Build/e2e not re-run per instruction.
## Overall Assessment
The diff does what the plan says, and the risky parts are the ones the plan called
risky. The round state machine is genuinely untouched, all public contracts hold,
and the Phase 4 fallback removal is provably safe. Comment quality is high and
explains constraints rather than narrating.
Two classes of defect remain: one real interaction bug in the new hint overlay on
mobile, and a set of documentation surfaces that now describe behavior and CSS
class names that no longer exist. Neither is a data or security problem.
No CRITICAL findings.
---
## HIGH
### H1 — `FirstRoundHint` blocks the expanded guess map's controls on mobile
`src/app/components/GameClient.js:447` renders `<FirstRoundHint>` as a direct child
of the `relative flex-1` game container with `absolute left-1/2 top-3 z-[700]`
(`src/app/components/FirstRoundHint.js:47`).
`GuessMapPanel`'s root is `absolute z-[500]` on phones
(`src/app/components/GuessMapPanel.js:46`). A positioned element with a numeric
`z-index` creates a stacking context, so the panel's own `z-[1200]` children — the
search box (`GuessMapPanel.js:65`) and the collapse button (`GuessMapPanel.js:101`,
`top-2 right-2 size-11`) — are clamped inside `z-500` and paint **below** the hint.
Expanded, the mobile map occupies `inset-x-3 top-3`. The hint sits at exactly
`top-3`, centred, `w-max max-w-[calc(100%-1.5rem)]` — on a 360-390px viewport it
wraps to ~2 lines spanning nearly the full width, directly over the search box and
the collapse button. The hint wrapper is **not** `pointer-events-none`, so it
intercepts those taps.
Sequence that reproduces: fresh profile → `/game?region=…` → "Tap to guess" →
map expands → search and collapse are unreachable until the player either taps the
hint's own X or drops a pin. This is exactly the target audience (first-time,
deep-linked player) hitting a dead control.
Desktop is only a cosmetic overlap: `lg:z-auto` on the panel removes the stacking
context, so the `z-[1200]` search box paints over the hint. The hint text is
partially hidden behind the search box at the column seam, but nothing is blocked.
Fix options (any one):
- Gate the hint on the collapsed state: pass `mapExpanded` down and render `null`
when the map is expanded.
- Add `pointer-events-none` to the wrapper and `pointer-events-auto` on the X.
Fixes the blocking, not the visual collision.
- Move the hint to `bottom-` on `<lg` (the collapsed minimap already lives at
`bottom-[calc(5.25rem+…)] right-3`, so a bottom-left placement is clear).
Note the plan's own edge-case checklist asked specifically whether the hint blocks
the minimap tap target. It does not block the *collapsed* minimap — it blocks the
*expanded* map's chrome, which the check did not cover.
---
## MEDIUM
### M1 — `docs/development.md:208` documents a class that no longer exists
`- **Page background**: \`min-h-dvh vn-gradient-bg\` on the page root.`
Phase 3 renamed the class to `.vn-surface` (`src/app/globals.css:251`) and updated
every call site. The doc is the only surviving reference and now instructs future
contributors to apply a class that resolves to nothing. Docs are in the plan's
allowed-modification set (`plan.md:24`) and this is a user-visible-convention
change, so per `documentation-management.md` it should have been updated with the
rename.
### M2 — `docs/development.md:220-225` contradicts Phase 3's whole premise
> Raw palette colors are allowed only where the color itself is the meaning and
> must not follow the theme: score bands and podium colors (`RoundResultDialog.js`,
> `LeaderboardList.js`, always with a `dark:` variant) …
Both named files were migrated off raw palettes onto `--success/--warning/--danger/
--rank-*` in this diff. The doc now points a future contributor back at the exact
pattern Phase 3 removed, and the new tokens are documented nowhere. The
`bg-neutral-900` panorama surround exception is still valid and should stay.
### M3 — `docs/game-flow.md:5-8` describes the removed on-landing prompt
```
### 1. Username Setup
- Check localStorage for existing username
- Display UsernameModal if not set
```
This is precisely the behavior Phase 2 deleted (`src/app/page.js:47-50`). The flow
is now: landing renders with no modal → first Play click intercepts → save or skip
into a generated name → navigation resumes. `docs/game-flow.md` is the canonical
description of the player flow; a user-visible flow change is exactly the trigger
the docs rule names.
### M4 — Home header row overflows on small phones now that the chip is always visible
`src/app/page.js:90` — the inner `<div className="flex items-center gap-3">` has no
`flex-wrap`, and every child is `whitespace-nowrap` (shadcn `Button`). Contents:
ThemeToggle (3 × 44px = 132px, now wider after F16's `w-9`→`w-11`), the name chip
(up to `"Playing as "` + 20 chars ≈ 190px with `px-2`), "Leaderboard" (~130px),
"Buy me a beer" (~155px), plus 3 × 12px gaps ≈ 640px inside a ~328px container at
360px viewport.
The row already overflowed before this diff (~440px), but the previously
`hidden sm:inline` name span is now unconditional and adds up to ~190px, plus
+6px from the toggle widening. The chip also has no `max-w`/`truncate`, so a
20-character name renders in full.
Phase 1 explicitly required the chip on mobile, so the requirement is right; the
layout needs to absorb it. Suggested: `flex-wrap` on the inner div plus
`max-w-[10rem] truncate` on the chip, or drop the `"Playing as "` prefix below
`sm` and show just the name.
### M5 — The session badge labels the headline score as "pts", which no board receives
`src/app/components/GameClient.js:270` sums `submitted.score` and
`GameClient.js:423` renders it as `N rounds · M pts`, tooltip
`"M points in N rounds this visit"`.
`score` is `finalScore` from `src/app/api/guess/route.js:74` — graded on the
*picked* region's ladder and, per the route's own comment at lines 97-100,
credited to **no** leaderboard. The dialog's "Leaderboard points added" section
shows per-board `entry.points`, which are different numbers for the same round.
Two adjacent surfaces now both say "points" and mean different things — and this
dialog exists precisely to explain that distinction (the `(+N)` tooltip at
`RoundResultDialog.js:239` says "Each board grades your distance on its own
scale").
This matches the plan's wording ("points sum from each submitted result"), so it is
a judgment call rather than a deviation. Recommend relabelling the badge to
`N rounds · M score` (or "round score") and adjusting the tooltip, so the only
thing called "points" in the UI is what actually lands on a board.
### M6 — The new localStorage key breaks the repo's storage-key convention and is duplicated
`src/app/components/FirstRoundHint.js:6` declares `HINT_STORAGE_KEY` privately
inside a component. Every other storage key in the codebase is an exported constant
in `src/lib/`:
- `src/lib/username.js:5` `USERNAME_STORAGE_KEY`
- `src/lib/last-region.js:4` `LAST_REGION_STORAGE_KEY`
- `src/lib/theme.js:8` `THEME_STORAGE_KEY`
Because it is not exported, `tests/e2e/helpers.js:21` re-declares the literal
`'vngeoguessr_hint_seen'`. Two sources of truth for a persisted key: renaming it in
one place leaves a silently-passing e2e helper that seeds a dead key, and the specs
would then race the banner exactly as `seedHintSeen` was written to prevent.
Fix: move the constant (and ideally `getHintSeen`/`setHintSeen`) to
`src/lib/hint.js` alongside the siblings, import it in both places.
### M7 — Generated names collide and silently merge leaderboard identities
`src/lib/username.js:22-27` produces `Player-` + 4 base36 chars → 36⁴ = 1,679,616
values. Leaderboard identity is the raw username string used as the ZSET member
(`src/lib/leaderboard.js:138-143`), so two players who both skip into the same
generated name share one board row and accumulate a merged score.
By the birthday bound, ~1,530 skipping players gives a ~50% chance of at least one
collision; ~100 gives ~0.3%.
Mitigating context: typed names already collide freely (there is no uniqueness
constraint anywhere), so this is not a new *class* of defect. The difference is
that these collisions are machine-assigned and invisible to the player.
Mitigation is one character: 6 chars → 36⁶ = 2.18B, still inside the modal's 2-20
length rule, still matched by the existing regex tests (the assertion at
`tests/username.test.js:18` would need `{6}`). Worth taking given the cost.
---
## LOW
- **L1 — Modifier-click on a Play row is hijacked.** `src/app/components/RegionPicker.js:57-59`
calls `e.preventDefault()` unconditionally when intercepting. Ctrl/Cmd+click fires
`onClick`, so "open the round in a new tab" instead opens the name modal in the
current tab. Guard with `if (e.metaKey || e.ctrlKey || e.shiftKey) return;`.
- **L2 — "Skip — random name" from the header chip assigns an unrequested name.**
`src/app/components/UsernameModal.js:65-68` picks the secondary action from
`hasExistingName` alone. With no name saved, opening the modal from the "Set name"
chip (no `pendingHref`) still offers Skip, which persists a generated name and
closes. Consider keying the label off whether a navigation is pending, not just
off the stored name.
- **L3 — `leaderboardMessage` moved behind the collapsed section.**
`src/app/components/RoundResultDialog.js:269-271`. Phase 2 named the bands strip and
the two grids; the message ("Score added at 3 levels (+4, +5, +5)") was not listed
but went in too. Defensible as bookkeeping — flagging so it is a decision, not a
side effect.
- **L4 — `<summary>` has no focus-visible ring.** `RoundResultDialog.js:182` uses
`list-none` + `min-h-11` but omits the repo's `focus-visible:ring-[3px]
focus-visible:ring-ring` idiom used on every other custom control
(`page.js:98`, `FirstRoundHint.js:59`, `RegionPicker.js:60`). Keyboard toggling
works natively; only the focus indicator falls back to the UA default.
- **L5 — "YOU" row is now less distinguishable from the podium rows.**
`src/app/components/LeaderboardList.js:74-77`: `bg-brand-subtle/70 border-2 border-brand`
vs `bg-brand-subtle/40 border border-brand/20`. Same hue, differing only in opacity
and border weight, where it used to be amber vs brand. The `YOU` badge still
disambiguates, so this is cosmetic.
- **L6 — Opening `<details>` does not scroll the revealed content into view.** Inside
`overflow-y-auto max-h-[calc(100dvh-2rem)]` (`RoundResultDialog.js:75,104`), the
summary sits near the bottom on a phone; expanding may appear to do nothing.
- **L7 — The hint renders over the load-error panel and the round spinner.**
`GameClient.js:447` is outside any `loadError`/`roundLoading` guard.
- **L8 — `docs/project-structure.md:47-57` omits `FirstRoundHint.js`.** That list is
already partial (`GuessMapPanel`, `RoundResultDialog`, `ResultMap`, `MapSearchBox`,
`LeaderboardModal` are all missing), so this is consistent with the existing state
rather than a regression.
- **L9 — `role="alert"` on the failed-round block** (`RoundResultDialog.js:109`)
duplicates what the dialog already announces via `aria-describedby`
(`RoundResultDialog.js:91-97`). Screen readers hear the failure twice.
---
## Verified Clean
Recording the verification source so these are not re-litigated:
**(b) Round state machine — no regression.** Walked `applyRound`
(`GameClient.js:108-124`), `loadRound` (126-142), `handleSubmitGuess` (260-298),
`resetRoundState` (303-307), `handleNextRound` (309-340), `handleSkipGuess`
(342-368), `handleRetryLoad`, and the watchdog (233-237). `roundEpochRef` /
`appliedEpochRef` comparisons, the prefetch hand-off, the `if (roundLoading) return`
double-click guard, and the `submitting` guard are all byte-identical. The only
changes are two `setSessionRounds`/`setSessionPoints` calls added inside the
existing `if (submitted)` branch (269-270) and the name generation inside
`submitGameResult` (184-189), which runs before the fetch and touches no round
state.
**(c) Public contracts — intact.**
- `/api/guess` response body unchanged, including the permanent legacy aliases
`cityRank` / `cityDistanceRank` (`guess/route.js:132,134`).
- `/api/leaderboard` legacy `cityCode` alias untouched (`leaderboard/route.js:37`).
- No Redis key touched; `vngeoguessr:leaderboard:city:*` assertions still pass
(`tests/guess-route.test.js:58-60`).
- `vngeoguessr_username` unchanged; `vngeoguessr_hint_seen` is purely additive.
- `?region=` and legacy `?location=` both still read at `GameClient.js:170-171`.
- The anti-cheat property still holds: `session.regionCode` is server-resolved and
the request-body `regionCode`/`cityCode` are still ignored
(`tests/guess-route.test.js:63-77` passes).
**Phase 4 step 1 is provably safe.** `src/app/api/new-game/route.js:45` rejects the
round with `if (!selectedImage.regionCode)` *before* writing the session, so no
session can exist without `regionCode`. The removed `?? session.cityCode` was
unreachable for any session creatable by the current release, and TTL is 30 min.
Deleting the compat test with the code is correct — it was asserting a shape the
system can no longer produce.
**(a) Phase acceptance criteria.** All Phase 1 items (F1, F2a, F5, F6, F7a, F8,
F9/F10, F11/F12, F14, F16, F19), all Phase 2 items (random name + unit test,
deferred modal, skip fallback, deep-link fallback, hint + ghost label, progress
badge, collapsible section, e2e updates), and all Phase 3 items are present in the
diff. F9/F10 in particular is genuinely fixed: the old `getScoreLabel` cutoffs
(`>=5/4/3/2/1`) and `getResultMessage` cutoffs (`>4/>2/>0`) disagreed; both now
derive from the single `SCORE_WORDING` table (`RoundResultDialog.js:24-36`).
**Phase 3 acceptance greps, run:**
- `grep -nE "(bg|text|border)-(green|emerald|amber|orange|purple|yellow|slate|blue|red)-[0-9]"` on
`RoundResultDialog.js`, `LeaderboardList.js`, `ResultMap.js` → **no matches**.
- Emoji sweep over `src/**/*.js` → **no matches**.
- `vn-gradient-bg` in `src/` → **no matches** (only the doc, see M1).
- All four dialogs have a centred `text-2xl` title and a `DialogDescription`.
**New-token contrast, computed.** Light: `--success` L=0.55 → ~4.9:1 vs white;
`--warning` L=0.60 → ~4.0:1; `--danger` L=0.577 → ~4.3:1. All clear 3:1 for the
`text-3xl font-extrabold` score circle, and success/danger clear 4.5:1 outright.
Dark: on-colour text is `oklch(0.145 0 0)` against L=0.75-0.80 fills → 8-9:1. The
`--rank-silver` (L=0.71) tint sits on a decorative `aria-hidden` `<Medal>` icon
only (`LeaderboardList.js:83`), with the rank number rendered separately in
`text-foreground`, so the 3:1 non-text threshold is the applicable one and colour
carries no meaning alone.
**(d) Repo patterns.** JS only, no `.ts`/`.tsx`. Props-object destructuring matches
every existing component; the individual-parameter rule applies to `src/lib/`
functions and `generateRandomUsername()` takes none. Comments explain constraints
(stacking, hydration, epoch, why state must flip) rather than narrating.
**(e) Lint.** 0 errors, 19 warnings. Three are new
(`FirstRoundHint.js:23,36`, `UsernameModal.js:29`); all three are
`react-hooks/set-state-in-effect` on the same localStorage-read-in-effect pattern
already accepted at `ThemeToggle.js:29`, `RegionPicker.js:126`, `GameClient.js:172`,
`page.js:49`, `LeaderboardModal.js:64`, `MapSearchBox.js:42,52`. Acceptable — this
is the established idiom, not new debt.
**(f) Edge cases asked about:**
- `pendingHref` clears on overlay/Esc close — `page.js:191`
(`onClose={() => { setShowUsernameModal(false); setPendingHref(null); }}`) is
wired to Radix `onOpenChange`. Correct.
- `<details>` keyboard access — native `<summary>` focus + Enter/Space; `list-none`
removes only the marker. See L4 for the missing focus ring.
- Hint vs minimap tap target — the *collapsed* minimap is clear; the *expanded* map
is not. See H1.
- Dark-mode token contrast — computed above, clean.
- Random-name collisions — see M7.
---
## No security or data findings in this diff
Noting one pre-existing item found while checking the trust boundary, explicitly
**not** introduced here and **not** in scope: `/api/guess` validates only that
`username` is truthy (`guess/route.js:14`); the 2-20 char `[a-zA-Z0-9_-]` rule
lives solely in the client modal. Distance-board entries are encoded as
`username:distance:timestamp` and parsed with `.split(':')`
(`src/lib/leaderboard.js:106-107`), so a crafted request with a colon in the
username would corrupt that board's parsing. React escapes output, so there is no
XSS path. Raise separately if the operator wants it; do not bundle it into this
plan.
---
## Recommended Actions
1. Fix H1 — gate `FirstRoundHint` on the collapsed map state (or `pointer-events-none`
+ reposition below `lg`). Blocking for phase 2 sign-off.
2. Update the three doc surfaces: `docs/development.md:208` (M1),
`docs/development.md:220-225` + a short semantic-token entry (M2),
`docs/game-flow.md:5-8` (M3). Phase 3 and Phase 2 are not closeable without these.
3. Absorb the chip into the home header layout (M4).
4. Relabel the session badge away from "pts" (M5).
5. Move `HINT_STORAGE_KEY` to `src/lib/` and import it in the e2e helper (M6).
6. Widen the random suffix to 6 chars and update `tests/username.test.js:18` (M7).
7. Sweep the LOW items opportunistically; L1 and L4 are one-liners.
## Plan Status (reporting only — no plan files edited)
- Phase 1: all 11 steps present in the diff. Complete pending M1-M4.
- Phase 2: all 7 steps present. **Blocked on H1** before it can be called done.
- Phase 3: all 5 steps present, greps clean. Blocked on M1/M2 (the rename and the
token migration are only half-landed while the docs still teach the old way).
- Phase 4: step 1 done and verified safe. Steps 2-3 correctly deferred behind the
ops backfill gate; `scripts/migrate-leaderboards.mjs`,
`scripts/lib/leaderboard-migration.mjs`, `tests/migrate-leaderboards.test.js`,
the `leaderboard:migrate` npm script and the `docs/development.md` migration
section are all still present, as intended.
- `plan.md` still reads `status: pending` and every phase file `status: todo` with
unchecked Todo boxes. The lead/planner should update these — I did not.
## Unresolved Questions
1. M5: is "pts" in the header meant to read as the headline round score, or should
it track something a board actually receives? The plan says the former; the UI
wording implies the latter.
2. M7: is a machine-assigned name collision acceptable given typed names already
collide, or is the 6-char widening worth taking now?
3. M3: should `docs/game-flow.md` also gain the deep-link/generated-name path, or
is the landing flow description sufficient?
Status: DONE_WITH_CONCERNS
Summary: Phases 1-3 and phase 4 step 1 are faithfully implemented with the round
state machine and every public contract intact, but the new hint overlay blocks the
expanded guess map's search and collapse controls on phones, and three
documentation surfaces still describe the renamed CSS class, the removed raw-palette
convention, and the old on-landing username prompt.
Concerns/Blockers: H1 (mobile hint blocks map chrome) should be fixed before phase
2 is signed off; M1-M3 (stale docs) before phases 1-3 are closed.
@@ -0,0 +1,145 @@
# VNGeoGuessr UI/UX Presentation Audit
Date: 2026-09-01 · Scope: presentation/UX only, read-only. All current features stay; no breaking changes proposed.
Overall: the app is in strong shape — token-driven palette (`--brand` VN red), 44px touch floor baked into Button, mobile minimap pattern, honest error states, reduced-motion handling, careful a11y comments. Findings below are refinements, ranked by impact on a first-time player.
---
## 1. First-time journey (highest impact)
### F1. No way to change username after first save — HIGH
`UsernameModal` only opens when `getUsername()` is empty (`src/app/page.js:42-49`). A player who typos their name or skipped is stuck as-is/Anonymous forever; the "Playing as **X**" text (`page.js:68-72`) is inert and hidden below `sm`, so mobile players never even see confirmation their name saved.
- **Fix (quick win):** make "Playing as X" a button that reopens `UsernameModal`; show it on mobile too (it fits as a chip). When skipped, render "Playing as Anonymous — set name". Files: `src/app/page.js`, `src/app/components/UsernameModal.js` (accept an initial value).
### F2. Username modal ambushes on landing, and its CTA lies — HIGH
The modal opens before the newbie has seen the game (`page.js:47`). Its primary button says **"Start Playing"** but only saves the name and returns to the homepage — expectation break #1 for every new player. "Skip" gives no hint you'll play as Anonymous.
- **Fix (quick win):** relabel "Start Playing" → "Save name" (or "Let's go" if you keep it pre-game); relabel "Skip" → "Skip — play as Anonymous". File: `src/app/components/UsernameModal.js:90-104`.
- **Fix (flow, medium):** defer the prompt to the first Play click (open modal, then navigate on save/skip) so the landing page introduces the game first. Files: `page.js`, `RegionPicker.js` (intercept first navigation) — keeps the feature, changes only when it appears.
### F3. Zero in-game onboarding; deep-linked players see nothing — MEDIUM-HIGH
All instruction lives on the homepage card. A player who lands on `/game?region=…` (shared link) gets a panorama with no hint that it drags/rotates, and on desktop no hint that the right map is clickable (mobile at least says "Tap to guess"). The scoring ladder is never explained before the first result.
- **Fix (quick win):** a one-time dismissible hint overlay on the game screen, gated by localStorage (`vngg-hint-seen`): "Drag to look around · Click the map to drop your guess · Submit". Auto-dismiss on first map click. Files: `src/app/components/GameClient.js` (+ ~30 lines, or a tiny `FirstRoundHint.js`).
- Desktop guess map could also carry a transient "Click to place your guess" ghost label until `hasGuess` — mirror of the mobile cover in `GuessMapPanel.js:76-86`.
### F4. No sense of progress during a session — MEDIUM
The game header shows only region + theme + donate. Rounds played and points earned this session are invisible until the result dialog, and even there only as leaderboard totals. Newbies have no feedback loop ("am I improving?").
- **Fix (medium):** client-side session counter in `GameClient` state (rounds, points sum from each submitted result) shown as a small badge in the header next to the region. Purely additive, no API change. File: `src/app/components/GameClient.js:393-398`.
### F5. Skip is unexplained — LOW
"Skip" sits beside Submit with no hint it's penalty-free (`GameClient.js:487-494`). A cautious newbie hesitates.
- **Fix (quick win):** `title="Skip this location — no penalty"` + aria-label; or label "Skip ↻".
---
## 2. Round result readability
### F6. The reveal is buried and unlabeled — HIGH
`RoundResultDialog.js` renders the resolved region path as plain muted text (`:153-157`) — this is the emotional payoff of the round ("it was District 7!") and it reads like a footnote. The map, the second payoff, is last and below the fold on most screens. The bands strip (`:121-145`) shows `≤240m = 5` chips with no heading — cryptic on first sight.
- **Fix (quick win):**
- Give the path a label and weight: small caption "It was in" + the path in `font-semibold text-foreground`.
- Caption the bands strip: "This round's scoring ladder" in the same `text-[10px] uppercase tracking-wider` style used elsewhere.
- Reorder body: score circle → distance → **map** → path → ladder → leaderboard cards. File: `RoundResultDialog.js:85-212`.
### F7. Leaderboard level cards are unexplained and visually competing — MEDIUM
Three brand-subtle cards ("Total: 37 (+2)") followed by muted "X distance Rank #n" cards (`:162-195`). Nothing says these are *leaderboard* credits, and the `(+2)` differing from the headline score (by design — per-board ladders) will read as a bug to a newbie.
- **Fix (quick win):** captions above each grid: "Leaderboard points added" / "Best-distance ranks"; tooltip/`title` on the `(+N)` — "each board grades your distance on its own scale". File: `RoundResultDialog.js`.
- **Fix (medium):** collapse both grids into one `<details>`/accordion "Leaderboard results" section, shortening the dialog to score + map + path at a glance.
### F8. Result map markers: red/green pair with no legend — MEDIUM (a11y)
Guess = red dot, actual = green dot (`ResultMap.js:42-70`), distinguishable only via click-popups; red/green is the classic deuteranopia trap, and red doubles as the brand color. Meanwhile the in-game guess pin is Leaflet's *blue* marker — the guess changes color between screens.
- **Fix (quick win):** add a legend row above/below the map ("● Your guess ● Actual location") and switch the guess dot to blue (matching the in-game pin) or add distinct shapes (pin vs flag). Files: `ResultMap.js`, small legend in `RoundResultDialog.js`.
### F9. Tone mismatch between label and message — LOW
Score 4 shows label "Excellent" (`:20-27`) but message "Good job! Nice work!" (`:30-35` uses `> 4` / `> 2` cutoffs). Align cutoffs or derive the message from the same band. File: `RoundResultDialog.js`.
### F10. Score circle contrast — LOW
White 3xl text on `bg-amber-600` (~3.0:1) and `bg-orange-600` is borderline even for large text (`:10-17`). Darken those two steps or use dark text on amber. File: `RoundResultDialog.js`.
---
## 3. Region picker comprehension
### F11. The bare pano count is a mystery number — MEDIUM
Each PlayRow shows e.g. `225,966` with no unit (`RegionPicker.js:69`). A newbie can't tell if it's players, points, or size.
- **Fix (quick win):** append a label — `225,966 spots` / `locations` — or a `title` attribute at minimum. File: `RegionPicker.js`.
### F12. "partial" and "few streets" badges unexplained — LOW
(`RegionPicker.js:62-66, 140-144`). Add `title="Street imagery covers only part of this province"` / `"Limited street imagery — repeats are likely"`. Quick win.
### F13. Disabled rows read fine; keep them — no change
"no map data" / "no street view" honest labels + dashed border are good practice. Optionally capitalize for polish.
---
## 4. Consistency audit
### F14. Dialog patterns diverge — MEDIUM
- Titles: RoundResult/Leaderboard/Donate use `text-2xl text-center`; Username uses `text-xl` left-aligned (`UsernameModal.js:55`).
- Descriptions: RoundResult has an sr-only `DialogDescription`; Username has a visible one; **Donate and Leaderboard have none** (Radix logs a warning, screen readers get no context). Files: `DonateQRModal.js`, `LeaderboardModal.js`.
- Dismissal: Donate has an explicit "Close" button *plus* the X; others rely on X only.
- **Fix (quick win):** one convention — centered `text-2xl` title + a `DialogDescription` (sr-only where visually redundant) in every dialog; drop Donate's redundant footer Close or adopt footer actions everywhere.
### F15. Hardcoded palette classes bypass the token system — MEDIUM (refactor)
The design system comments insist on `--brand` tokens, but result/leaderboard semantics use raw Tailwind colors with hand-managed dark variants:
- `RoundResultDialog.js` `getScoreBg` (green/emerald/amber/orange/red/neutral), `leaderboardMessage` green (`:198`);
- `LeaderboardList.js` `DISTANCE_COLORS`, `getScoreColor` (a 6-step purple→red rainbow keyed to arbitrary totals), amber "YOU" highlight (`:87, 101-105`);
- `ResultMap.js` inline hex `#ef4444/#22c55e/#da251d`.
- **Fix (larger):** add semantic tokens in `globals.css` (`--success`, `--warning`, `--rank-gold` …, plus dark values) and map these call sites onto them. Also simplify `getScoreColor` — the rainbow carries no meaning; two states (top-3 tint + default) or a single neutral would read cleaner.
### F16. ThemeToggle breaks its own touch-target rule — LOW
Buttons are `h-11 w-9` = 36px wide (`ThemeToggle.js:57`), below the 44px floor `button.jsx` documents. Widen to `w-11`. Also: three emoji buttons with `bg-brand` selection is visually noisier than the rest of the chrome; a Lucide sun/moon/monitor set would match the Trophy/Wrench/ArrowLeft icon language (`page.js` uses 🍺 emoji too — pick one icon language, Lucide is already the majority).
### F17. Marker language: play vs result — covered in F8.
### F18. `vn-gradient-bg` is not a gradient — TRIVIAL
`globals.css:212-214` maps it to flat `--surface`. Rename to `.vn-surface` (or fold into `bg-surface` utility) whenever files are next touched — misleading names invite wrong reuse.
### F19. Credits page chrome differs from home — LOW
No ThemeToggle on `/credits` (`credits/page.js:38-45`), and the header drops the actions row entirely. Fine minimal page, but add ThemeToggle for parity (theme still applies, just no switch), and "Back to game" actually goes to the homepage — label it "Back to home" or "← Home".
### F20. Vietnamese glyph fallback in search results — LOW
Geist loads `subsets: ["latin"]` only (`layout.js:8-16`). All UI region names are ASCII, but Photon search results (`MapSearchBox.js`) return labels with Vietnamese diacritics (Đường, Phường…), which will render in the fallback system font — a subtle ransom-note effect inside the dropdown. If/when Geist offers a `vietnamese` subset, add it; otherwise consider Be Vietnam Pro or Inter (both support Vietnamese) if Vietnamese-language UI ever expands.
---
## 5. Flow improvements (all features preserved)
Ordered, concrete, non-breaking:
1. **Move the username prompt to the first Play click** (F2) — landing page becomes pure introduction; the modal appears exactly when the name is about to matter. Home still shows "Playing as X / set name" chip (F1).
2. **Result dialog restructure** (F6/F7): score → distance → map → reveal path → collapsible "Leaderboard results" containing bands + level cards. Shortens the payoff screen to one viewport on phones; all data stays.
3. **First-round hint overlay** on `/game` (F3) — one-time, localStorage-gated.
4. **Session progress badge** in the game header (F4).
5. **Mobile expanded-map peek** (optional, larger): when the map is expanded, keep a small panorama thumbnail in a corner (inverse of the current minimap) so players can cross-check without collapsing. `GuessMapPanel.js` already has the swap pattern; this mirrors it. Defer unless players ask.
---
## Quick wins vs larger refactors
| # | Item | Size | Files |
|---|------|------|-------|
| F1 | Clickable "Playing as X" reopens username modal, visible on mobile | QW | `page.js`, `UsernameModal.js` |
| F2a | Relabel modal buttons ("Save name" / "Skip — play as Anonymous") | QW | `UsernameModal.js` |
| F5 | Skip tooltip | QW | `GameClient.js` |
| F6 | Label the reveal path + bands caption + reorder map up | QW | `RoundResultDialog.js` |
| F7a | Captions on leaderboard grids | QW | `RoundResultDialog.js` |
| F8 | Result-map legend + blue guess dot | QW | `ResultMap.js`, `RoundResultDialog.js` |
| F9/F10 | Message-band alignment, amber contrast | QW | `RoundResultDialog.js` |
| F11/F12 | Label pano counts; badge tooltips | QW | `RegionPicker.js` |
| F14 | Unify dialog titles/descriptions | QW | `UsernameModal.js`, `DonateQRModal.js`, `LeaderboardModal.js` |
| F16 | ThemeToggle 44px width | QW | `ThemeToggle.js` |
| F19 | Credits parity | QW | `credits/page.js` |
| F2b | Defer username modal to first Play | Medium | `page.js`, `RegionPicker.js` |
| F3 | First-round hint overlay | Medium | `GameClient.js` (+ small component) |
| F4 | Session progress badge | Medium | `GameClient.js` |
| F7b | Collapsible leaderboard section in result dialog | Medium | `RoundResultDialog.js` |
| F15 | Semantic color tokens (success/warning/rank) replacing raw Tailwind colors | Larger | `globals.css`, `RoundResultDialog.js`, `LeaderboardList.js`, `ResultMap.js` |
| F16b | Emoji → Lucide icon unification | Larger (cosmetic) | `ThemeToggle.js`, `page.js`, `GameClient.js`, `DonateQRModal.js` |
| F20 | Vietnamese font subset/pairing | Larger (deferred) | `layout.js` |
## Unresolved questions
- Is the pano count on PlayRows meant as a player-facing signal (bigger = more variety) or a debug leftover? Answer decides F11's label vs removal-from-view (kept as `title` only).
- Should Anonymous play be encouraged (frictionless first round) or discouraged (leaderboard identity)? Decides how prominent the F2b deferral makes the name prompt.
- Is there interest in a session summary ("You played 8 rounds, 23 pts") when leaving via Menu? It would complete the F4 loop but adds a new surface — not proposed without a nod.