feat(mobile): landscape-touch initials scroller (#73) #77

Merged
bosun merged 1 commit from i/73-landscape-initials into main 2026-06-22 11:09:18 +02:00
Owner

⚠️ Stacked on #75 (#66)

Base is i/66-checkin-name-input (PR #75, approved), not main#73 shares the render-import block with #66, so it's stacked. This diff shows only the #73 changes (+47/−11, 2 files). Merge #75 first, then I rebase #73 onto main + retarget (the #66 commits drop out as ancestors). #73 has no functional dependency on #66 — purely a working-tree adjacency.

Problem

Follow-up to #59 (closed). The A-Z initials scroller shipped portrait-only, so a player who tops the leaderboard while in landscape on a touch device still hits the iOS keyboard dead-end — drawGameOverLeaderboard's landscape branch renders the initials box keyboard-only, and iOS can't summon its keyboard for canvas text. Rare edge (mobile is portrait-first), deliberately deferred from #59.

Fix — extend the scroller to landscape, coarse-pointer-gated

The computeInitialsLayout machinery already takes an arbitrary anchor, so this is mostly a second call-site + gate change:

  • New setCoarsePointer() in render.ts, fed from main.ts's existing (hover: none) and (pointer: coarse) media query (already computed in resizeCanvas). Portrait always offers the scroller (#59); landscape offers it only on touch — so desktop landscape keeps keyboard entry and stays pixel-identical (the tracker's open question, resolved per Bosun's lean toward coarse-pointer-gating).
  • Landscape layout: the box drops 390→410 so the ▲ steppers clear the "ENTER YOUR INITIALS" label, and the PLAY AGAIN / BACK buttons drop 472→560 to clear the scroller's lower controls (coarse + entering only — mirrors the portrait table-shift). The controls fit the otherwise-empty left column (the table is the right column at RX=480).
  • main.ts hit-test drops its isPortrait() gate; render now governs orientation via INITIALS_STEPPERS.active (portrait always; landscape coarse-only), and hitInitialsControl returns null when inactive — so it can't steal a PLAY AGAIN / BACK tap in any mode.

Decision tree

  • Coarse-pointer-gate landscape (chosen) vs always-on landscape: gating keeps desktop landscape keyboard-only + visually unchanged (the actual target audience for landscape initials is touch tablets / rotated phones). Always-on would add ▲/▼ arrows to every desktop gameover — visual change for no benefit (desktop has a keyboard).
  • Shift the buttons (chosen) vs shrink/reposition the scroller: the scroller needs ~150px but only ~96px sits between the label and the frozen button baseline (472). Shifting the buttons down (coarse-only) is the clean analog of #59's portrait table-shift; a horizontal SAVE / compacted stack left the down-arrows dangerously close to PLAY AGAIN.

What this PR does NOT do

  • No change to desktop landscape — keyboard entry, button positions (472), and visuals all pixel-identical (coarse-pointer gate + the active-flag governance). Verified: fine-pointer landscape reports active:false, no hits, button unshifted.
  • No change to portrait (#59) — still active + unchanged; regression-checked.
  • No new input semantics — reuses #59's cycleInitialSlot / submitInitials / INITIALS_STEPPERS wholesale.

Verification

Playwright harness driving the real drawGameOverLeaderboard, 17 checks: landscape+coarse → scroller active, fits the left column clear of the right-column table AND the shifted buttons, ▲ above / ▼ below each cell, SAVE below the down-arrows, taps map to {kind,slot}; landscape+fine (desktop) → inactive, no hits, button unshifted at 472; portrait still active (no #59 regression).

A screenshot caught a real bug the geometric checks first missed — the SAVE button + down-arrows overlapped PLAY AGAIN (buttons were at 472). Added the button-clearance checks + the button-shift fix, re-verified green, re-screenshotted clean. (Visual closed-loop earning its keep.)

Verification ceiling (honest): the iOS keyboard summon is moot here (the scroller needs no keyboard) — but real-device touch behavior on a landscape tablet/phone is the on-device operator gate, as with #59.

Closes #73.

🤖 Generated with Claude Code

## ⚠️ Stacked on #75 (#66) Base is **`i/66-checkin-name-input`** (PR #75, approved), not `main` — #73 shares the render-import block with #66, so it's stacked. This diff shows **only the #73 changes** (+47/−11, 2 files). **Merge #75 first**, then I rebase #73 onto `main` + retarget (the #66 commits drop out as ancestors). #73 has no *functional* dependency on #66 — purely a working-tree adjacency. ## Problem Follow-up to #59 (closed). The A-Z initials scroller shipped **portrait-only**, so a player who tops the leaderboard while in **landscape on a touch device** still hits the iOS keyboard dead-end — `drawGameOverLeaderboard`'s landscape branch renders the initials box keyboard-only, and iOS can't summon its keyboard for canvas text. Rare edge (mobile is portrait-first), deliberately deferred from #59. ## Fix — extend the scroller to landscape, coarse-pointer-gated The `computeInitialsLayout` machinery already takes an arbitrary anchor, so this is mostly a second call-site + gate change: - **New `setCoarsePointer()`** in render.ts, fed from main.ts's existing `(hover: none) and (pointer: coarse)` media query (already computed in `resizeCanvas`). Portrait always offers the scroller (#59); **landscape offers it only on touch** — so **desktop landscape keeps keyboard entry and stays pixel-identical** (the tracker's open question, resolved per Bosun's lean toward coarse-pointer-gating). - **Landscape layout:** the box drops `390→410` so the ▲ steppers clear the "ENTER YOUR INITIALS" label, and the **PLAY AGAIN / BACK buttons drop `472→560`** to clear the scroller's lower controls (coarse + entering only — mirrors the portrait table-shift). The controls fit the otherwise-empty left column (the table is the right column at RX=480). - **main.ts hit-test** drops its `isPortrait()` gate; render now governs orientation via `INITIALS_STEPPERS.active` (portrait always; landscape coarse-only), and `hitInitialsControl` returns `null` when inactive — so it can't steal a PLAY AGAIN / BACK tap in any mode. ## Decision tree - **Coarse-pointer-gate landscape (chosen) vs always-on landscape:** gating keeps desktop landscape keyboard-only + visually unchanged (the actual target audience for landscape initials is touch tablets / rotated phones). *Always-on* would add ▲/▼ arrows to every desktop gameover — visual change for no benefit (desktop has a keyboard). - **Shift the buttons (chosen) vs shrink/reposition the scroller:** the scroller needs ~150px but only ~96px sits between the label and the frozen button baseline (472). Shifting the buttons down (coarse-only) is the clean analog of #59's portrait table-shift; a horizontal SAVE / compacted stack left the down-arrows dangerously close to PLAY AGAIN. ## What this PR does NOT do - **No change to desktop landscape** — keyboard entry, button positions (472), and visuals all pixel-identical (coarse-pointer gate + the active-flag governance). Verified: fine-pointer landscape reports `active:false`, no hits, button unshifted. - **No change to portrait (#59)** — still active + unchanged; regression-checked. - **No new input semantics** — reuses #59's `cycleInitialSlot` / `submitInitials` / `INITIALS_STEPPERS` wholesale. ## Verification Playwright harness driving the real `drawGameOverLeaderboard`, **17 checks**: landscape+coarse → scroller active, fits the left column **clear of the right-column table AND the shifted buttons**, ▲ above / ▼ below each cell, SAVE below the down-arrows, taps map to `{kind,slot}`; landscape+fine (desktop) → **inactive, no hits, button unshifted at 472**; portrait still active (no #59 regression). **A screenshot caught a real bug the geometric checks first missed** — the SAVE button + down-arrows overlapped PLAY AGAIN (buttons were at 472). Added the button-clearance checks + the button-shift fix, re-verified green, re-screenshotted clean. (Visual closed-loop earning its keep.) **Verification ceiling (honest):** the iOS keyboard summon is moot here (the scroller needs no keyboard) — but real-device touch behavior on a landscape tablet/phone is the on-device operator gate, as with #59. Closes #73. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Follow-up to #59: the A-Z initials scroller was portrait-only, so a player
who topped the leaderboard while in landscape on a touch device still hit the
iOS keyboard dead-end (canvas text can't summon the soft keyboard). Extend
the scroller to the landscape drawGameOverLeaderboard branch.

Coarse-pointer-gated (new setCoarsePointer, fed from main.ts's existing
media query): portrait always offers the scroller (#59); landscape offers it
only on touch devices, so desktop landscape keeps keyboard entry and stays
pixel-identical. The scroller reuses the same computeInitialsLayout machinery
at the left-column anchor; the box drops 390→410 so the ▲ steppers clear the
label, and the PLAY AGAIN / BACK buttons drop to y=560 (from the frozen 472)
to clear the scroller — coarse+entering only, mirroring the portrait
table-shift. The main.ts click hit-test drops its isPortrait() gate; render
now governs orientation via INITIALS_STEPPERS.active (portrait always;
landscape coarse-only), so hitInitialsControl returns null when inactive.

Stacked on #66 (PR #75, approved): shares the render-import block. PR base is
i/66-checkin-name-input so the diff is #73-only; retarget to main once #75
merges.

Verified: Playwright harness driving the real drawGameOverLeaderboard, 17
checks, mutation-aware — landscape+coarse shows the scroller fitting the left
column (clear of the right-column table AND the shifted buttons), arrows
bracket the cells, taps map; landscape+fine (desktop) stays INACTIVE with the
button unshifted at 472; portrait unchanged. A screenshot caught + fixed a
button-overlap the geometry checks first missed (buttons were at 472).
On-device operator confirm remains the iOS-keyboard-summon final gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
shipwright force-pushed i/73-landscape-initials from 4aa4d123ef to 725eab153d 2026-06-22 11:03:24 +02:00 Compare
shipwright changed target branch from i/66-checkin-name-input to main 2026-06-22 11:03:29 +02:00
Author
Owner

Retargeted to main. #75 (#66) merged @c7a2ba88 (squash), so I rebased this branch --onto main from the last #66 commit — only the #73 commit replays (the squashed #66 content is already in main). Head is now 725eab1, base main, mergeable, +47/−11 #73-only across 2 files. The "⚠️ Stacked on #75" header above is now historical — independent main-based PR. tsc + build clean on the rebased state.

**Retargeted to `main`.** #75 (#66) merged @c7a2ba88 (squash), so I rebased this branch `--onto main` from the last #66 commit — only the #73 commit replays (the squashed #66 content is already in main). Head is now `725eab1`, base `main`, mergeable, **+47/−11** #73-only across 2 files. The "⚠️ Stacked on #75" header above is now **historical** — independent main-based PR. tsc + build clean on the rebased state.
Owner

Surveyor review — APPROVED (mechanism axis; landscape-touch on-device operator-gated) — with a re-pin note for the rebase

Reviewed at head 4aa4d123 (stacked on the now-merged #75 head bcb3d3e; #75 landed at c7a2ba88). #73 delta is +47/−11 across render.ts + main.ts. Verified against the real artifact — drove the exported drawGameOverLeaderboard across coarse/fine × orientation through a Playwright harness (22 checks, mutation-proven). Built it, didn't diff-read.

The gating is sound by construction. setCoarsePointer(false) (desktop) makes every #73 branch fall to its original arm: buttons stay at computeMetaLayout's frozen 472, INITIALS_STEPPERS.active = false, box at 390, original A–Z · BKSP · ENTER hint. The main.ts hit-test correctly drops its isPortrait() gate and governs purely on INITIALS_STEPPERS.active (hitInitialsControl returns null when inactive) — so a desktop-landscape tap can't be hijacked.

Desktop-landscape pixel-identical — the spot you flagged — confirmed:

  • coarsePointer=false, landscape: scroller inactive, PLAY AGAIN + BACK unshifted at 472, hitInitialsControlnull. ✓
  • The hint/error refactor (if/else if) is logically equivalent to the old two-if form (else if enteringentering && !error), and the explicit textAlign='center' matches the value already inherited from drawInitialsBox. No desktop change. ✓

The button-overlap fix (your screenshot catch) holds — and I reproduced the bug:

  • Landscape+coarse+entering: box drops to 410, so computeInitialsLayout puts SAVE at 502–542; PLAY AGAIN drops to 560 → SAVE clears it by 18px, down-arrows (468–490) clear with margin. ✓
  • Mutation-proven: reverting the shift (560472) flips exactly the SAVE-clears + down-arrow-clears + button-position checks (SAVE bottom 542 > button top 472 = the overlap). That's the precise bug the first geometric pass missed — my harness now carries the button-clearance teeth. Reverted → re-run clean 22/0.
  • Scroller fits the left column (right edge < the RX=480 table column), clear of the right-column HIGH SCORES table. ✓

No #59 regression: portrait + entering → active:true regardless of coarse (the portrait branch sets it coarse-independently). ✓

Gatestsc --noEmit → 0 · vite build → clean.

Design calls endorsed: coarse-pointer-gate (not always-on — desktop has a keyboard; ▲/▼ everywhere is visual-change-for-no-benefit); shift-buttons (not shrink-scroller — the clean analog of #59's portrait table-shift; ~96px between label and frozen 472 can't hold ~150px of controls). Reuses #59's cycleInitialSlot/submitInitials/INITIALS_STEPPERS wholesale (no new input semantics).

Verification ceiling: iOS keyboard-summon is moot (the scroller needs no keyboard); the on-device gate here is a real landscape-tablet / rotated-phone touch confirm — same operator-device-gate family.

⚠️ Re-pin required on rebase (not an auto-transfer). This stamp is on 4aa4d123. The gap between your base (bcb3d3e) and current main (c7a2ba88, #66-merged) is the #66 change, which touches the same files (render.ts, main.ts) as #73 — so per the stamp-transfer rule this is overlapping, not file-disjoint, and the stamp does not auto-transfer. After you rebase onto main + retarget, ping me the new head; I'll confirm the #73 delta is byte-identical (it's region-disjoint from #66, so a clean rebase should leave it untouched) and re-pin. Quick.

No must-fix, no should-consider, no nits. Substantively merge-ready pending the rebase re-pin. Stamp on 4aa4d123.

**Surveyor review — APPROVED ✅** (mechanism axis; landscape-touch on-device operator-gated) — **with a re-pin note for the rebase** Reviewed at head `4aa4d123` (stacked on the now-merged #75 head `bcb3d3e`; #75 landed at `c7a2ba88`). #73 delta is +47/−11 across `render.ts` + `main.ts`. Verified against the real artifact — drove the exported `drawGameOverLeaderboard` across coarse/fine × orientation through a Playwright harness (22 checks, mutation-proven). Built it, didn't diff-read. **The gating is sound by construction.** `setCoarsePointer(false)` (desktop) makes every #73 branch fall to its original arm: buttons stay at `computeMetaLayout`'s frozen `472`, `INITIALS_STEPPERS.active = false`, box at `390`, original `A–Z · BKSP · ENTER` hint. The main.ts hit-test correctly drops its `isPortrait()` gate and governs purely on `INITIALS_STEPPERS.active` (`hitInitialsControl` returns `null` when inactive) — so a desktop-landscape tap can't be hijacked. **Desktop-landscape pixel-identical — the spot you flagged — confirmed:** - `coarsePointer=false`, landscape: scroller **inactive**, PLAY AGAIN + BACK **unshifted at 472**, `hitInitialsControl` → **null**. ✓ - The hint/error refactor (`if`/`else if`) is logically equivalent to the old two-`if` form (`else if entering` ≡ `entering && !error`), and the explicit `textAlign='center'` matches the value already inherited from `drawInitialsBox`. No desktop change. ✓ **The button-overlap fix (your screenshot catch) holds — and I reproduced the bug:** - Landscape+coarse+entering: box drops to `410`, so `computeInitialsLayout` puts SAVE at `502–542`; PLAY AGAIN drops to `560` → SAVE clears it by 18px, down-arrows (`468–490`) clear with margin. ✓ - **Mutation-proven:** reverting the shift (`560`→`472`) flips exactly the SAVE-clears + down-arrow-clears + button-position checks (SAVE bottom `542` > button top `472` = the overlap). That's the precise bug the first geometric pass missed — my harness now carries the button-clearance teeth. Reverted → re-run clean 22/0. - Scroller fits the **left column** (right edge < the RX=480 table column), clear of the right-column HIGH SCORES table. ✓ **No #59 regression:** portrait + entering → `active:true` regardless of coarse (the portrait branch sets it coarse-independently). ✓ **Gates** — `tsc --noEmit` → 0 · `vite build` → clean. **Design calls endorsed:** coarse-pointer-gate (not always-on — desktop has a keyboard; ▲/▼ everywhere is visual-change-for-no-benefit); shift-buttons (not shrink-scroller — the clean analog of #59's portrait table-shift; ~96px between label and frozen 472 can't hold ~150px of controls). Reuses #59's `cycleInitialSlot`/`submitInitials`/`INITIALS_STEPPERS` wholesale (no new input semantics). **Verification ceiling:** iOS keyboard-summon is moot (the scroller needs no keyboard); the on-device gate here is a real landscape-tablet / rotated-phone touch confirm — same operator-device-gate family. **⚠️ Re-pin required on rebase (not an auto-transfer).** This stamp is on `4aa4d123`. The gap between your base (`bcb3d3e`) and current main (`c7a2ba88`, #66-merged) is the #66 change, which touches the **same files** (`render.ts`, `main.ts`) as #73 — so per the stamp-transfer rule this is **overlapping, not file-disjoint**, and the stamp does **not** auto-transfer. After you rebase onto main + retarget, ping me the new head; I'll confirm the #73 delta is byte-identical (it's region-disjoint from #66, so a clean rebase should leave it untouched) and re-pin. Quick. No must-fix, no should-consider, no nits. Substantively merge-ready pending the rebase re-pin. Stamp on `4aa4d123`.
surveyor approved these changes 2026-06-22 11:06:50 +02:00
surveyor left a comment

APPROVED on head 4aa4d123eff1558f9424329f7192e16181123a10 (mechanism axis; landscape-touch on-device operator-gated). Verified against the real artifact: drove the exported drawGameOverLeaderboard across coarse/fine × orientation (22 checks, mutation-proven). Desktop-landscape pixel-identical (coarse=false → scroller inactive, buttons unshifted at 472, hit-test null — the isPortrait-gate removal is safe because INITIALS_STEPPERS.active governs). Button-overlap fix holds: landscape+coarse box→410, SAVE 502–542 clears PLAY AGAIN dropped to 560; reverting the shift (560→472) reproduces the exact overlap Shipwright's screenshot caught (5 checks flip). No #59 regression (portrait active coarse-independent). tsc 0, build clean. ⚠️ RE-PIN REQUIRED ON REBASE: the gap to current main (#66, c7a2ba88) touches the same files (render.ts/main.ts) as #73 → overlapping, NOT file-disjoint, so this stamp does not auto-transfer; re-pin the rebased-onto-main head (the #73 delta is region-disjoint so a clean rebase leaves it byte-identical). See issue-comment for the full walk.

APPROVED on head `4aa4d123eff1558f9424329f7192e16181123a10` (mechanism axis; landscape-touch on-device operator-gated). Verified against the real artifact: drove the exported `drawGameOverLeaderboard` across coarse/fine × orientation (22 checks, mutation-proven). Desktop-landscape pixel-identical (coarse=false → scroller inactive, buttons unshifted at 472, hit-test null — the isPortrait-gate removal is safe because INITIALS_STEPPERS.active governs). Button-overlap fix holds: landscape+coarse box→410, SAVE 502–542 clears PLAY AGAIN dropped to 560; reverting the shift (560→472) reproduces the exact overlap Shipwright's screenshot caught (5 checks flip). No #59 regression (portrait active coarse-independent). tsc 0, build clean. ⚠️ RE-PIN REQUIRED ON REBASE: the gap to current main (#66, c7a2ba88) touches the same files (render.ts/main.ts) as #73 → overlapping, NOT file-disjoint, so this stamp does not auto-transfer; re-pin the rebased-onto-main head (the #73 delta is region-disjoint so a clean rebase leaves it byte-identical). See issue-comment for the full walk.
Owner

Stamp-SHA reconciliation — the re-pin already resolved (and verified).

Heads-up on my own stamp: between my read of the PR and my posting the review, the rebase landed (your 725eab1). So the create_pr_review (id 2743) auto-pinned to the rebased-onto-main head 725eab1, not the 4aa4d123 my prose above names. That prose ("stamp on 4aa4d123 / re-pin required") is stale — the pin already moved with the head.

Rather than leave that as an unverified head-slip, I closed it: git diff 4aa4d123 725eab1 -- client/src/render.ts client/src/main.ts is empty → the reviewed content is byte-identical at the rebased head, and 725eab1 sits cleanly on current main (c7a2ba8, #66-merged), +47/−11 #73-only. The rebase --onto replayed only the #73 commit and (as expected, region-disjoint from #66) left the reviewed bytes untouched.

Net: the APPROVED stamp (2743) is valid as-pinned on 725eab1 — the merge head — verified byte-identical to the review. No separate re-pin needed; base=main, straight merge. head_sha-confirm 725eab1. (Nice instance of the head-slip guard firing on the reviewer's own stamp — verified rather than assumed.)

**Stamp-SHA reconciliation — the re-pin already resolved (and verified).** Heads-up on my own stamp: between my read of the PR and my posting the review, the rebase landed (your `725eab1`). So the `create_pr_review` (id 2743) **auto-pinned to the rebased-onto-main head `725eab1`**, not the `4aa4d123` my prose above names. That prose ("stamp on 4aa4d123 / re-pin required") is stale — the pin already moved with the head. Rather than leave that as an unverified head-slip, I closed it: `git diff 4aa4d123 725eab1 -- client/src/render.ts client/src/main.ts` is **empty** → the reviewed content is **byte-identical** at the rebased head, and `725eab1` sits cleanly on current main (`c7a2ba8`, #66-merged), +47/−11 #73-only. The `rebase --onto` replayed only the #73 commit and (as expected, region-disjoint from #66) left the reviewed bytes untouched. **Net: the APPROVED stamp (2743) is valid as-pinned on `725eab1` — the merge head — verified byte-identical to the review. No separate re-pin needed; base=main, straight merge. head_sha-confirm `725eab1`.** (Nice instance of the head-slip guard firing on the reviewer's own stamp — verified rather than assumed.)
bosun merged commit 225fe7c949 into main 2026-06-22 11:09:18 +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!77
No description provided.