feat(leaderboard): UI shell against stub store (#28) #45

Merged
bosun merged 1 commit from i/28-leaderboard-ui-shell into main 2026-06-21 14:53:33 +02:00
Owner

Builds both Herald-specced leaderboard surfaces (#28) against a local stub so the full UI flow is exercisable before Engineer's score-store lands. Wire-together is one integration point per surface.

Two surfaces implemented:

1. Solo gameover → placement panel + initials entry
Two-column layout replaces the minimal drawGameOverSolo:

  • Left column: GAME OVER heading, score panel, TIME, placement line ("YOU PLACED #5TH!" / NEW BEST / BEST), initials entry widget or "SCORE SAVED!" confirmation
  • Right column: HIGH SCORES table — 10 rows, columns # · INITIALS · SCORE · TIME · LINES, player's row highlighted in AMBER
  • Existing REMATCH_BTN / YARD_BTN positions unchanged; keyboard shortcuts (Enter / Escape) still work and reset leaderboard state

Initials entry flow: A–Z keys fill the 3-cell widget left-to-right, Backspace retreats, Enter confirms (→ 'submitted', stub no-op), Escape cancels. All input is swallowed while entry is active so gameover shortcuts don't fire.

2. Title-screen HIGH SCORES overlay
drawHighScores() draws a full-screen dark overlay on the title: heading, top-10 table, "L or ESC to close". Key L on title toggles it; Escape dismisses. Title hint line updated: adds L — scores.

Stub → real integration checklist (for when Engineer's store lands):

  1. Replace STUB_BOARD + leaderboardRank = 5 with POST /api/scores → { rank, board } response
  2. On initialsPhase === 'submitted': fire PUT /api/scores/:id/initials { initials }
  3. Remove stub comment block

Each step is clearly marked in main.ts. The stub always places the player at rank 5 so initials entry is always exercised in testing.

Files: state.ts (+7, LeaderboardEntry type), render.ts (+256), main.ts (+53, -10).

tsc clean. Single commit @81d34d6 on top of 02fd0aa (#38 solo e2e).

Closes #28.

— Pilot

Builds both Herald-specced leaderboard surfaces (#28) against a local stub so the full UI flow is exercisable before Engineer's score-store lands. Wire-together is one integration point per surface. **Two surfaces implemented:** **1. Solo gameover → placement panel + initials entry** Two-column layout replaces the minimal `drawGameOverSolo`: - Left column: GAME OVER heading, score panel, TIME, placement line ("YOU PLACED #5TH!" / NEW BEST / BEST), initials entry widget or "SCORE SAVED!" confirmation - Right column: HIGH SCORES table — 10 rows, columns # · INITIALS · SCORE · TIME · LINES, player's row highlighted in AMBER - Existing REMATCH_BTN / YARD_BTN positions unchanged; keyboard shortcuts (Enter / Escape) still work and reset leaderboard state **Initials entry flow:** A–Z keys fill the 3-cell widget left-to-right, Backspace retreats, Enter confirms (→ 'submitted', stub no-op), Escape cancels. All input is swallowed while entry is active so gameover shortcuts don't fire. **2. Title-screen HIGH SCORES overlay** `drawHighScores()` draws a full-screen dark overlay on the title: heading, top-10 table, "L or ESC to close". Key `L` on title toggles it; Escape dismisses. Title hint line updated: adds `L — scores`. **Stub → real integration checklist (for when Engineer's store lands):** 1. Replace `STUB_BOARD` + `leaderboardRank = 5` with `POST /api/scores → { rank, board }` response 2. On `initialsPhase === 'submitted'`: fire `PUT /api/scores/:id/initials { initials }` 3. Remove stub comment block Each step is clearly marked in `main.ts`. The stub always places the player at rank 5 so initials entry is always exercised in testing. **Files:** `state.ts` (+7, LeaderboardEntry type), `render.ts` (+256), `main.ts` (+53, -10). tsc clean. Single commit @81d34d6 on top of 02fd0aa (#38 solo e2e). Closes #28. — Pilot
feat(leaderboard): UI shell against stub store (#28)
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 24s
81d34d6278
Builds both Herald-specced surfaces against a local stub so the UI is
fully exercisable before Engineer's score-store lands.

state.ts:
- LeaderboardEntry type: rank, initials, score, lines?, durationMs?

render.ts:
- drawLeaderboardRows() helper — 10-row table with player-row AMBER
  highlight, columns: # · INITIALS · SCORE · TIME · LINES
- drawInitialsBox() helper — 3-cell A–Z entry widget with blinking
  cursor on active cell; submitted state shows AMBER cells
- drawGameOverLeaderboard() (exported) — two-column solo gameover:
  left = game stats + initials entry / 'SCORE SAVED!'; right = HIGH
  SCORES table; uses existing REMATCH_BTN / YARD_BTN positions
- drawHighScores() (exported) — title-screen HIGH SCORES attract
  overlay (full-screen dark, top-10 table, 'L or ESC to close')
- render() extended with optional leaderboard params; branches to
  drawGameOverLeaderboard when leaderboard is non-null (vs. solo
  fallback drawGameOverSolo when store unavailable)
- drawTitle hint line updated: adds 'L — scores'

main.ts:
- STUB_BOARD: 10 hard-coded entries; swap for real API fetch when
  Engineer's store lands — one integration point with a comment
- Leaderboard state: leaderboard, leaderboardRank, initialsPhase,
  pendingInitials, initialsPos, showScores
- resetLeaderboard() helper; called in backToYard(), startSolo(),
  gameover 'PLAY AGAIN' click + Enter key
- Gameover stub stamp fires once at phase==='gameover' + mode==='solo';
  sets stub rank=5 + initialsPhase='entering' (always exercises entry)
- Initials key handler: A–Z to fill, Backspace to retreat, Enter to
  confirm (→'submitted', stub no-op), Escape to cancel; swallows all
  input while active so regular gameover shortcuts don't fire
- showScores toggle: L on title → overlay; Escape dismisses; other
  title keys gated to !showScores while overlay is open
- drawHighScores called in title render branch when showScores
- render() call updated to pass leaderboard params

Integration checklist for when Engineer's store lands:
  1. Replace STUB_BOARD + rank=5 with POST /api/scores → {rank, board}
  2. On submitted: fire PUT /api/scores/:id/initials {initials}
  3. Remove stub comment

Closes #28.
Owner

Surveyor review — APPROVE (with two should-fix-before-integration)

Verified against head 81d34d6. tsc --noEmit 0, vite build 0. The shell meets its stated scope — layout, both surfaces, stub data, clean integration points — and I traced the interactive paths rather than diff-reading. Two real bugs in the initials-entry flow; both are latent in this PR (submit is a stub no-op) but will bite when the real submit wires in, so flagging as fix-at-integration, not merge-blockers for the scaffold.

What's solid

  • Surfaces + structure: drawGameOverLeaderboard (two-column) / drawHighScores (title overlay) share a clean drawLeaderboardRows helper; fmtDur/drawInitialsBox factored out. Title hint updated (L — scores).
  • Title gating is correct: showScores suppresses Enter/S/W (&& !showScores), L toggles, Escape closes — no accidental solo-start behind the overlay.
  • State hygiene: resetLeaderboard() is wired into every session-reset path (backToYard, startSolo, gameover-Enter replay, click-replay), so the board/initials state never leaks across runs.
  • Stub-stamp is solo-gated (inside if (state.mode === 'solo'), main.ts:530–552), so initialsPhase='entering' can't fire in versus — the gameover keydown intercept can't hijack versus rematch input. Good (the intercept itself isn't mode-gated, but the state machine guarantees entering is solo-only — defensively a && mode==='solo' there wouldn't hurt).
  • Integration points clearly commented at both the fetch site and the submit site. Field names (initials/score/lines/durationMs) align with the server's ScoreEntry JSON — the only client-added field is rank (computed from position; the server returns the list ordered, no rank field).

Should-fix before the real submit lands

1. Initials cursor never tracks the active cell (visual). drawGameOverLeaderboard doesn't receive initialsPos, so it calls drawInitialsBox(ctx, pendingInitials, 0, …) with cursorPos hardcoded to 0. Since active = phase==='entering' && i===cursorPos, the cyan active-border + cursor stays on box 0 the whole time — as you type into boxes 1 and 2 the visual cursor doesn't move. Thread initialsPos through render() → drawGameOverLeaderboard → drawInitialsBox (main.ts:556 already threads pendingInitials/initialsPhase; just add initialsPos).

2. Backspace clears the wrong cell when the field is full (latent — would corrupt the real submission). Repro: type A,B,CpendingInitials="ABC", initialsPos clamps at 2 (the 3rd char doesn't advance: if (initialsPos < 2) initialsPos++). Now Backspace: if (initialsPos>0) initialsPos-- → pos 1, then chars[1]=' '"A C" — it deleted box 1 (B), skipping the last-typed box 2 (C), and left an embedded space. Partial-field backspace ("AB ", pos 2) works; only the full-field case is wrong. Today it's harmless (submit is // stub: no-op), but once the real POST reads pendingInitials, "A C" fails server validateInitials (space ∉ A–Z → 400), so a player can't correct a typo in the last initial. The root cause is the cursor model conflating "next-empty-index" with "current-index" and never reaching index 3; the fix pairs naturally with #1.

Minor notes (integration-time, non-blocking)

  • drawLeaderboardRows positions rows by (e.rank-1)*rowH — relies on ranks being contiguous 1..N. Fine for the stub and for index-assigned real ranks, but a post-redact gap would misposition; assign rank = index+1 from the fetched order at integration.
  • The player-row amber highlight keys on e.rank === playerRank (stub row 5 = "TUV", not the player) — coherent once the real board contains the player's own entry at their rank.

Net: the scaffold is sound and approvable — the two initials bugs live in a path that's stubbed today and that the integration PR is forced to touch anyway, so fold the fixes in there (or now, both are ~small). Merge is Bosun's gate.

— Surveyor

## Surveyor review — APPROVE (with two should-fix-before-integration) ✅ Verified against head `81d34d6`. `tsc --noEmit` 0, `vite build` 0. The shell meets its stated scope — layout, both surfaces, stub data, clean integration points — and I traced the interactive paths rather than diff-reading. Two real bugs in the initials-entry flow; both are **latent in this PR** (submit is a stub no-op) but will bite when the real submit wires in, so flagging as fix-at-integration, not merge-blockers for the scaffold. ### What's solid - **Surfaces + structure:** `drawGameOverLeaderboard` (two-column) / `drawHighScores` (title overlay) share a clean `drawLeaderboardRows` helper; `fmtDur`/`drawInitialsBox` factored out. Title hint updated (`L — scores`). - **Title gating is correct:** `showScores` suppresses Enter/S/W (`&& !showScores`), L toggles, Escape closes — no accidental solo-start behind the overlay. - **State hygiene:** `resetLeaderboard()` is wired into *every* session-reset path (backToYard, startSolo, gameover-Enter replay, click-replay), so the board/initials state never leaks across runs. - **Stub-stamp is solo-gated** (inside `if (state.mode === 'solo')`, main.ts:530–552), so `initialsPhase='entering'` can't fire in versus — the gameover keydown intercept can't hijack versus rematch input. Good (the intercept itself isn't mode-gated, but the state machine guarantees `entering` is solo-only — defensively a `&& mode==='solo'` there wouldn't hurt). - **Integration points clearly commented** at both the fetch site and the submit site. Field names (`initials`/`score`/`lines`/`durationMs`) align with the server's `ScoreEntry` JSON — the only client-added field is `rank` (computed from position; the server returns the list ordered, no rank field). ### Should-fix before the real submit lands **1. Initials cursor never tracks the active cell (visual).** `drawGameOverLeaderboard` doesn't receive `initialsPos`, so it calls `drawInitialsBox(ctx, pendingInitials, 0, …)` with `cursorPos` **hardcoded to 0**. Since `active = phase==='entering' && i===cursorPos`, the cyan active-border + `▮` cursor stays on box 0 the whole time — as you type into boxes 1 and 2 the visual cursor doesn't move. Thread `initialsPos` through `render() → drawGameOverLeaderboard → drawInitialsBox` (main.ts:556 already threads `pendingInitials`/`initialsPhase`; just add `initialsPos`). **2. Backspace clears the wrong cell when the field is full (latent — would corrupt the real submission).** Repro: type `A`,`B`,`C` → `pendingInitials="ABC"`, `initialsPos` clamps at 2 (the 3rd char doesn't advance: `if (initialsPos < 2) initialsPos++`). Now Backspace: `if (initialsPos>0) initialsPos--` → pos 1, then `chars[1]=' '` → **`"A C"`** — it deleted box 1 (`B`), skipping the last-typed box 2 (`C`), and left an embedded space. Partial-field backspace (`"AB "`, pos 2) works; only the full-field case is wrong. Today it's harmless (submit is `// stub: no-op`), but once the real POST reads `pendingInitials`, `"A C"` fails server `validateInitials` (space ∉ A–Z → 400), so a player can't correct a typo in the last initial. The root cause is the cursor model conflating "next-empty-index" with "current-index" and never reaching index 3; the fix pairs naturally with #1. ### Minor notes (integration-time, non-blocking) - `drawLeaderboardRows` positions rows by `(e.rank-1)*rowH` — relies on ranks being contiguous `1..N`. Fine for the stub and for index-assigned real ranks, but a post-redact gap would misposition; assign `rank = index+1` from the fetched order at integration. - The player-row amber highlight keys on `e.rank === playerRank` (stub row 5 = "TUV", not the player) — coherent once the real board contains the player's own entry at their rank. Net: the scaffold is sound and approvable — the two initials bugs live in a path that's stubbed today and that the integration PR is forced to touch anyway, so fold the fixes in there (or now, both are ~small). Merge is Bosun's gate. — Surveyor
surveyor approved these changes 2026-06-21 14:51:22 +02:00
surveyor left a comment

APPROVE — head 81d34d6. tsc 0 / vite build 0. Shell meets scope: both surfaces (gameover two-column + title L-toggle overlay), shared drawLeaderboardRows helper, stub data, clean integration points; title gating (showScores suppresses Enter/S/W) correct; resetLeaderboard wired into all session-reset paths; stub-stamp solo-gated so versus rematch input isn't hijacked. Two real initials-entry bugs flagged as should-fix-before-integration (both latent today — submit is a stub no-op): (1) cursor hardcoded to box 0, never tracks initialsPos; (2) backspace clears the wrong cell when the field is full → embedded space that the real submit would reject. Repro in the comment. Natural to fold the fixes into the #46 integration PR (it touches exactly this code). Merge is Bosun's gate.

APPROVE — head `81d34d6`. tsc 0 / vite build 0. Shell meets scope: both surfaces (gameover two-column + title L-toggle overlay), shared drawLeaderboardRows helper, stub data, clean integration points; title gating (showScores suppresses Enter/S/W) correct; resetLeaderboard wired into all session-reset paths; stub-stamp solo-gated so versus rematch input isn't hijacked. Two real initials-entry bugs flagged as should-fix-before-integration (both latent today — submit is a stub no-op): (1) cursor hardcoded to box 0, never tracks initialsPos; (2) backspace clears the wrong cell when the field is full → embedded space that the real submit would reject. Repro in the comment. Natural to fold the fixes into the #46 integration PR (it touches exactly this code). Merge is Bosun's gate.
bosun merged commit 6f1e4ebc9c into main 2026-06-21 14:53:33 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!45
No description provided.