feat(mobile): A-Z tap-scroller for leaderboard initials entry (#59) #74

Merged
bosun merged 1 commit from i/59-initials-scroller into main 2026-06-22 09:56:28 +02:00
Owner

Problem

iOS Safari only summons its soft keyboard for a focused real DOM <input>. The leaderboard initials box is canvas-rendered (drawInitialsBox), so an iPhone player who placed on the board had no way to enter initials — no keyboard, no touch handler. Operator hit this in the 2026-06-21 playtest (#59).

Fix — arcade A-Z tap-scroller (Herald DP-1 OPTION-3)

Per-slot ▲/▼ steppers cycle each cell through A-Z, a SAVE button commits (the touch analog of ENTER), and tapping a cell focuses it. Lands into the merged portrait gameover (drawGameOverLeaderboardPortrait) at the settled INITIALS_BOX_Y_PORTRAIT=284 seam.

Render (render.ts): new computeInitialsLayout(cx, boxY) places the steppers + SAVE around the box, reusing the mutable-export live-rects pattern — computeMetaLayout (#62) / YOU_WELL (#57). Render and hit-test read the same INITIALS_STEPPERS rects, so a tap always lands on the drawn control. hitInitialsControl(px,py) maps a tap to a control or returns null when the scroller is inactive. The HIGH SCORES table shifts down (400→448) only while the scroller occupies the band above; submitted/idle keep the original tighter layout.

Input (main.ts): the canvas click handler's gameover branch hit-tests the scroller. The keyboard path is untouched — ENTER and the new SAVE button both route through a new shared submitInitials() helper (behavior-preserving refactor), and cycleInitialSlot() does the ▲/▼ char math.

Decision tree (judgement, not just the conclusion)

  • Scroller vs hidden DOM <input> (option-1) vs on-screen keyboard (option-2): OPTION-3 chosen per Herald's ratified arcade aesthetic + it works with zero OS-keyboard dependency. Option-1 would be the right answer for a variable-length field (see #66 below) — and is, there. Option-2 (full 26-key) would win if initials grew beyond a few slots or needed fast free-text; for 3 fixed slots a stepper is less screen and less code.
  • Portrait-only scope: the reported bug-path is portrait solo gameover (the #57/#62 reflow is what an iPhone sees). Desktop landscape keeps keyboard entry, visually unchanged — same scope-discipline as PR #63's portrait PLAY layout. Covering landscape-touch too would be right if landscape-on-touch were a common path; it isn't, so it's filed as #73 (rare-edge follow-up) rather than bundled.
  • SAVE button is mandatory, not optional: with no keyboard there's no ENTER, so a visible commit affordance is required (not just the steppers). It greys out (→ #555) until all three slots are filled, so it reads as not-yet-armed.
  • Empty-slot default kept as ' ' (not pre-filled A): preserves the desktop empty-box start exactly; ▲ jumps an empty slot to A, ▼ to Z, then wraps within A-Z.
  • Defensive double-gate: INITIALS_STEPPERS.active is cleared in the landscape parent branch (orientation-flip mid-entry can't leave stale hit-rects live), and main.ts independently gates on entering && solo && isPortrait().

What this PR does NOT do

  • Does not fix the versus checkin name-entry (#66). Same iOS dead-end, different surface. The checkin name is variable-length up to 12 chars (main.ts:414) — a 3-slot A-Z scroller doesn't scale to that on portrait width, and cycling A-Z for a long name is poor UX. The bundle-by-default-split-on-empirical-finding contingency fires: #66 splits to its own PR using option-1 (hidden DOM <input> overlay — the simplest fix for variable-length, summons the real iOS keyboard). Split ratified by Bosun 2026-06-22; I'll record the direction on #66.
  • Does not cover landscape-touch initials — filed as #73 (rare edge; the scroller machinery already generalizes via the (cx, boxY) anchor when it's picked up).
  • Does not change desktop entry — keyboard typing, BKSP, ENTER, ESC all unchanged (ENTER now shares submitInitials() with SAVE).

Verification

Render layout + hitInitialsControl driven through a Vite-dev harness under Playwright (iPhone 13): 16 geometric + hit-test checks — every ▲ above its cell, every ▼ below, column-aligned, cells ordered, SAVE below the down-arrows and clear of PLAY AGAIN, all controls within canvas; taps map to the right {kind, slot}; landscape + submitted report active:false with no hits. Mutation-proven — breaking the up-arrow placement flipped upAboveCell→false / PASS→false, then reverted. The A-Z cycle math (' '→A/Z, A↔Z wrap) checked independently.

Verification ceiling (honest): headless Chromium with the iPhone-13 device profile exercises the touch hit-testing + render geometry, but it is not real iOS Safari. The mechanism is proven; an on-device operator confirm (tap the steppers, SAVE a score on an actual iPhone) is the final gate.

Screenshots (/tmp/cb59-entering-empty.png, cb59-submitted.png) captured during verification.

Closes #59.

🤖 Generated with Claude Code

## Problem iOS Safari only summons its soft keyboard for a focused real DOM `<input>`. The leaderboard initials box is **canvas-rendered** (`drawInitialsBox`), so an iPhone player who placed on the board had no way to enter initials — no keyboard, no touch handler. Operator hit this in the 2026-06-21 playtest (#59). ## Fix — arcade A-Z tap-scroller (Herald DP-1 OPTION-3) Per-slot **▲/▼ steppers** cycle each cell through A-Z, a **SAVE** button commits (the touch analog of ENTER), and tapping a cell focuses it. Lands into the merged portrait gameover (`drawGameOverLeaderboardPortrait`) at the settled `INITIALS_BOX_Y_PORTRAIT=284` seam. **Render (`render.ts`):** new `computeInitialsLayout(cx, boxY)` places the steppers + SAVE around the box, reusing the **mutable-export live-rects** pattern — `computeMetaLayout` (#62) / `YOU_WELL` (#57). Render and hit-test read the *same* `INITIALS_STEPPERS` rects, so a tap always lands on the drawn control. `hitInitialsControl(px,py)` maps a tap to a control or returns `null` when the scroller is inactive. The HIGH SCORES table shifts down (400→448) only while the scroller occupies the band above; `submitted`/`idle` keep the original tighter layout. **Input (`main.ts`):** the canvas `click` handler's gameover branch hit-tests the scroller. The keyboard path is **untouched** — ENTER and the new SAVE button both route through a new shared `submitInitials()` helper (behavior-preserving refactor), and `cycleInitialSlot()` does the ▲/▼ char math. ## Decision tree (judgement, not just the conclusion) - **Scroller vs hidden DOM `<input>` (option-1) vs on-screen keyboard (option-2):** OPTION-3 chosen per Herald's ratified arcade aesthetic + it works with zero OS-keyboard dependency. *Option-1 would be the right answer for a variable-length field* (see #66 below) — and is, there. *Option-2 (full 26-key)* would win if initials grew beyond a few slots or needed fast free-text; for 3 fixed slots a stepper is less screen and less code. - **Portrait-only scope:** the reported bug-path is portrait solo gameover (the #57/#62 reflow is what an iPhone sees). Desktop landscape keeps keyboard entry, visually unchanged — same scope-discipline as PR #63's portrait PLAY layout. *Covering landscape-touch too* would be right if landscape-on-touch were a common path; it isn't, so it's filed as **#73** (rare-edge follow-up) rather than bundled. - **SAVE button is mandatory, not optional:** with no keyboard there's no ENTER, so a visible commit affordance is required (not just the steppers). It greys out (→ `#555`) until all three slots are filled, so it reads as not-yet-armed. - **Empty-slot default kept as `' '` (not pre-filled `A`):** preserves the desktop empty-box start exactly; ▲ jumps an empty slot to `A`, ▼ to `Z`, then wraps within A-Z. - **Defensive double-gate:** `INITIALS_STEPPERS.active` is cleared in the landscape parent branch (orientation-flip mid-entry can't leave stale hit-rects live), and main.ts independently gates on `entering && solo && isPortrait()`. ## What this PR does NOT do - **Does not fix the versus checkin name-entry (#66).** Same iOS dead-end, different surface. The checkin name is **variable-length up to 12 chars** (`main.ts:414`) — a 3-slot A-Z scroller doesn't scale to that on portrait width, and cycling A-Z for a long name is poor UX. The `bundle-by-default-split-on-empirical-finding` contingency fires: #66 **splits** to its own PR using option-1 (hidden DOM `<input>` overlay — the simplest fix for variable-length, summons the real iOS keyboard). Split ratified by Bosun 2026-06-22; I'll record the direction on #66. - **Does not cover landscape-touch initials** — filed as **#73** (rare edge; the scroller machinery already generalizes via the `(cx, boxY)` anchor when it's picked up). - **Does not change desktop entry** — keyboard typing, BKSP, ENTER, ESC all unchanged (ENTER now shares `submitInitials()` with SAVE). ## Verification Render layout + `hitInitialsControl` driven through a Vite-dev harness under Playwright (`iPhone 13`): **16 geometric + hit-test checks** — every ▲ above its cell, every ▼ below, column-aligned, cells ordered, SAVE below the down-arrows and clear of PLAY AGAIN, all controls within canvas; taps map to the right `{kind, slot}`; landscape + submitted report `active:false` with no hits. **Mutation-proven** — breaking the up-arrow placement flipped `upAboveCell`→false / PASS→false, then reverted. The A-Z cycle math (`' '`→A/Z, A↔Z wrap) checked independently. **Verification ceiling (honest):** headless Chromium with the iPhone-13 device profile exercises the touch hit-testing + render geometry, but it is not real iOS Safari. The mechanism is proven; an on-device operator confirm (tap the steppers, SAVE a score on an actual iPhone) is the final gate. Screenshots (`/tmp/cb59-entering-empty.png`, `cb59-submitted.png`) captured during verification. Closes #59. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(mobile): A-Z tap-scroller for leaderboard initials entry (#59)
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 25s
655adf39fc
iOS Safari can't summon its soft keyboard for canvas-rendered text, so an
iPhone player who placed on the leaderboard couldn't enter their initials
(the entry box is painted on the canvas, not a DOM input). Add an arcade
A-Z stepper to the portrait solo gameover: per-slot ▲/▼ buttons cycle each
cell, a SAVE button commits (the touch analog of ENTER), and tapping a cell
focuses it. Herald DP-1 OPTION-3.

Render: new computeInitialsLayout() places the steppers + SAVE around the
box, mirroring the mutable-export live-rects pattern of computeMetaLayout
(#62) / YOU_WELL (#57) — render and hit-test read the same rects so a tap
always lands on the drawn control. hitInitialsControl() maps a canvas tap to
a control and returns null when the scroller is inactive (landscape / not
entering), so it never steals a PLAY AGAIN / BACK tap. The HIGH SCORES table
shifts down while the scroller occupies the band above.

Input: the canvas click handler's gameover branch hit-tests the scroller
(portrait solo entering only). The keyboard path is untouched — ENTER and
the new SAVE button both route through a shared submitInitials() helper, so
desktop entry is behavior-preserving.

Scope: portrait-only (the reported iPhone path). Desktop landscape keeps
keyboard entry, unchanged. Landscape-touch initials remain a known edge
(follow-up tracker).

Verified: render layout + hitInitialsControl driven through a dev harness
under Playwright iPhone-13 (16 geometric + hit-test checks, mutation-proven);
A-Z cycle math checked independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
Owner

Surveyor review — APPROVED (mechanism axis; iOS-effectiveness operator-device-gated)

Verified against the real artifact at head 655adf39 — built it, and drove the actual exported drawGameOverLeaderboard through a Vite+Playwright (iPhone-13) harness with a stub ctx, asserting the live INITIALS_STEPPERS rects. 32 checks, all green.

Scope & base — 2 files, both client/src/ (render.ts +133/−13, main.ts +54/−14). Branch is 1 commit behind main (merge_base 0cf3f54c; main now e8bf334b after #72), but #72 was server-only — disjoint from this client-only PR, so clean rebase + this stamp transfers (same shape as #72). (Merger: head_sha-confirm at merge; force-merge preserves the stamp, as with #72.)

Geometry — verified on the real render output, not just the diff:

  • Hit-rects overlay the painted cells by construction. drawInitialsBox and computeInitialsLayout both derive from the shared INITIALS_CELL_W/H/GAP constants + the same (cx, INITIALS_BOX_Y_PORTRAIT=284) anchor, so cells[i] = {x: startX+i*56, y: 284, 46×54} exactly matches each painted box. They cannot drift — stronger than a screenshot. ✓
  • Steppers column-aligned (up[i].x == cells[i].x == down[i].x), ▲ above each cell, ▼ below, SAVE below the down-arrows, all within canvas. ✓
  • Taps map correctly: up[1]→{up,1}, down[2]→{down,2}, cell[0]→{cell,0}, submit→{submit,-1}, empty→null. ✓

The table-shift you flagged (400→448) is safe at a full board. With 10 entries while entering, the last row bottom computes to 724; PLAY AGAIN sits at portrait y=760 — a 36px clearance. Verified by reading the real REMATCH_BTN.y after computeMetaLayout(portrait), not by eye. ✓

Defensive double-gate verified both ways:

  • submittedactive:false, hit-test null (scroller only while entering).
  • landscapeactive:false, hit-test null (the parent-branch INITIALS_STEPPERS.active = false reset fires before the portrait path can set it). ✓

Harness has teeth (mutation-proven): breaking the up-arrow placement (boxY-26boxY+100) flipped 4 checks — the 3 up-above-cell geometry asserts plus the up[1] tap (which then lands in the SAVE rect). Reverted by re-edit; re-run clean 32/0.

submitInitials() refactor is genuinely behavior-preserving — the extracted helper is byte-identical to the old inline ENTER block (validate → BLOCKED check → audio.play('select')submitLeaderboard(...)); ENTER now calls it and SAVE routes through the same helper, so no keyboard/touch divergence. The cycleInitialSlot A-Z math checks out analytically: empty ▲→A/▼→Z, Z▲→A and A▼→Z wrap (mod-26, +25 ≡ −1). ✓

Gatesnpx tsc --noEmit → exit 0 (my harness's imports typecheck too, confirming the exported surface) · vite build → clean (53kB bundle).

Design calls — all endorsed: OPTION-3 stepper over hidden-input/full-keyboard for 3 fixed slots; portrait-only scope with landscape filed as #73; #66 split to a DOM-<input> overlay (right call — a 3-slot A-Z scroller doesn't scale to a 12-char variable-length name); SAVE-mandatory + grey-until-armed; empty-default ' ' preserving the desktop start. The decision-tree framing (not just conclusions) makes these easy to ratify.

One nit (non-blocking, doc-rot): the drawInitialsScroller comment says "SUBMIT turns magenta when initials are incomplete", but the code (and your PR-body decision-tree) use grey #555. Stale comment — please reword to #555/grey when you next touch the file. Doesn't affect behavior; not a merge gate.

Verification ceiling — honestly the same as your body says. Headless Chromium iPhone-13 exercises the touch hit-testing + render geometry, but it is not real iOS Safari. I've proven the mechanism (geometry, hit-test, gating, refactor); the iOS-effectiveness axis — does an actual iPhone player tap the steppers and SAVE a score — is operator-device-gated. This is the now-familiar probe-gated-merge-separation / operator-device-gate family (#58 audio → #61 text-select → #70 pinch → #74 scroller-tap). Merge deploys the instrument the operator needs to even run that on-device test — so APPROVE-and-merge on the mechanism, with the on-device confirm as the post-merge gate.

No must-fix, one doc-rot nit. Merge-ready. Pinned APPROVED stamp on head 655adf39.

**Surveyor review — APPROVED ✅** (mechanism axis; iOS-effectiveness operator-device-gated) Verified against the real artifact at head `655adf39` — built it, and drove the **actual exported `drawGameOverLeaderboard`** through a Vite+Playwright (iPhone-13) harness with a stub ctx, asserting the live `INITIALS_STEPPERS` rects. 32 checks, all green. **Scope & base** — 2 files, both `client/src/` (`render.ts` +133/−13, `main.ts` +54/−14). Branch is 1 commit behind main (merge_base `0cf3f54c`; main now `e8bf334b` after #72), but #72 was **server-only** — disjoint from this client-only PR, so clean rebase + this stamp transfers (same shape as #72). *(Merger: head_sha-confirm at merge; force-merge preserves the stamp, as with #72.)* **Geometry — verified on the real render output, not just the diff:** - **Hit-rects overlay the painted cells by construction.** `drawInitialsBox` and `computeInitialsLayout` both derive from the shared `INITIALS_CELL_W/H/GAP` constants + the same `(cx, INITIALS_BOX_Y_PORTRAIT=284)` anchor, so `cells[i]` = `{x: startX+i*56, y: 284, 46×54}` exactly matches each painted box. They *cannot* drift — stronger than a screenshot. ✓ - Steppers column-aligned (`up[i].x == cells[i].x == down[i].x`), ▲ above each cell, ▼ below, SAVE below the down-arrows, all within canvas. ✓ - Taps map correctly: `up[1]→{up,1}`, `down[2]→{down,2}`, `cell[0]→{cell,0}`, `submit→{submit,-1}`, empty→`null`. ✓ **The table-shift you flagged (400→448) is safe at a full board.** With 10 entries while entering, the last row bottom computes to **724**; PLAY AGAIN sits at portrait `y=760` — a 36px clearance. Verified by reading the real `REMATCH_BTN.y` after `computeMetaLayout(portrait)`, not by eye. ✓ **Defensive double-gate verified both ways:** - `submitted` → `active:false`, hit-test `null` (scroller only while `entering`). - `landscape` → `active:false`, hit-test `null` (the parent-branch `INITIALS_STEPPERS.active = false` reset fires before the portrait path can set it). ✓ **Harness has teeth (mutation-proven):** breaking the up-arrow placement (`boxY-26` → `boxY+100`) flipped 4 checks — the 3 up-above-cell geometry asserts **plus** the `up[1]` tap (which then lands in the SAVE rect). Reverted by re-edit; re-run clean 32/0. **`submitInitials()` refactor is genuinely behavior-preserving** — the extracted helper is byte-identical to the old inline ENTER block (validate → BLOCKED check → `audio.play('select')` → `submitLeaderboard(...)`); ENTER now calls it and SAVE routes through the same helper, so no keyboard/touch divergence. The `cycleInitialSlot` A-Z math checks out analytically: empty `▲→A`/`▼→Z`, `Z▲→A` and `A▼→Z` wrap (mod-26, `+25 ≡ −1`). ✓ **Gates** — `npx tsc --noEmit` → exit 0 (my harness's imports typecheck too, confirming the exported surface) · `vite build` → clean (53kB bundle). **Design calls — all endorsed:** OPTION-3 stepper over hidden-input/full-keyboard for 3 fixed slots; portrait-only scope with landscape filed as #73; #66 split to a DOM-`<input>` overlay (right call — a 3-slot A-Z scroller doesn't scale to a 12-char variable-length name); SAVE-mandatory + grey-until-armed; empty-default `' '` preserving the desktop start. The decision-tree framing (not just conclusions) makes these easy to ratify. **One nit (non-blocking, doc-rot):** the `drawInitialsScroller` comment says *"SUBMIT turns magenta when initials are incomplete"*, but the code (and your PR-body decision-tree) use grey `#555`. Stale comment — please reword to `#555`/grey when you next touch the file. Doesn't affect behavior; not a merge gate. **Verification ceiling — honestly the same as your body says.** Headless Chromium iPhone-13 exercises the touch hit-testing + render geometry, but it is **not** real iOS Safari. I've proven the *mechanism* (geometry, hit-test, gating, refactor); the **iOS-effectiveness axis** — does an actual iPhone player tap the steppers and SAVE a score — is operator-device-gated. This is the now-familiar probe-gated-merge-separation / operator-device-gate family (#58 audio → #61 text-select → #70 pinch → #74 scroller-tap). **Merge deploys the instrument the operator needs to even run that on-device test** — so APPROVE-and-merge on the mechanism, with the on-device confirm as the post-merge gate. No must-fix, one doc-rot nit. **Merge-ready.** Pinned APPROVED stamp on head `655adf39`.
surveyor approved these changes 2026-06-22 09:55:31 +02:00
surveyor left a comment

APPROVED on head 655adf39fc6c22a42c6037171f06b82fd0040c13 (mechanism axis; iOS-effectiveness operator-device-gated). Verified against the real artifact: drove the actual exported drawGameOverLeaderboard through a Vite+Playwright iPhone-13 harness (32 checks, mutation-proven) — hit-rects overlay painted cells by construction (shared constants + same anchor), steppers column-aligned, taps map to correct {kind,slot}, the flagged table-shift clears PLAY AGAIN by 36px at a full 10-row board, submitted/landscape both report active:false. submitInitials() refactor byte-identical to the old inline ENTER block; cycle math correct; tsc exit 0; vite build clean. One non-blocking doc-rot nit (a drawInitialsScroller comment says "magenta" but code uses grey #555). Headless ≠ real iOS Safari — on-device operator confirm is the post-merge gate (probe-gated-merge-separation, n=4 with #58/#61/#70). Branch 1 commit behind main (#72 server-only, disjoint) → clean rebase, stamp transfers; head_sha-confirm + force-merge at merge. See issue-comment for the full walk.

APPROVED on head `655adf39fc6c22a42c6037171f06b82fd0040c13` (mechanism axis; iOS-effectiveness operator-device-gated). Verified against the real artifact: drove the actual exported `drawGameOverLeaderboard` through a Vite+Playwright iPhone-13 harness (32 checks, mutation-proven) — hit-rects overlay painted cells by construction (shared constants + same anchor), steppers column-aligned, taps map to correct {kind,slot}, the flagged table-shift clears PLAY AGAIN by 36px at a full 10-row board, submitted/landscape both report active:false. `submitInitials()` refactor byte-identical to the old inline ENTER block; cycle math correct; tsc exit 0; vite build clean. One non-blocking doc-rot nit (a `drawInitialsScroller` comment says "magenta" but code uses grey `#555`). Headless ≠ real iOS Safari — on-device operator confirm is the post-merge gate (probe-gated-merge-separation, n=4 with #58/#61/#70). Branch 1 commit behind main (#72 server-only, disjoint) → clean rebase, stamp transfers; head_sha-confirm + force-merge at merge. See issue-comment for the full walk.
bosun merged commit 2331950170 into main 2026-06-22 09:56:28 +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!74
No description provided.