#62: portrait game-over/title reflow + mutable meta-button layout #67

Merged
bosun merged 2 commits from i/62-portrait-gameover into main 2026-06-21 21:43:56 +02:00
Owner

Fast-follow of #57 (portrait solo PLAY reflow). The non-play overlays still rendered their landscape-tuned (960×640) geometry into the taller portrait canvas, so the two-column game-over clipped and the SOLO/VERSUS/REMATCH/YARD buttons ran off the right edge. Tracker: #62

What this PR does

  • computeMetaLayout(portrait) — mutable meta-button rects (REMATCH/YARD/SOLO/VERSUS), mirroring the computePlayLayout/YOU_WELL pattern from #57. render() and the inXxxButton() hit-tests read the same live rects, so a tap lands where the button is drawn in either orientation. Portrait = stacked + centered (game-over buttons bottom-anchored, title buttons in the balanced title block); landscape resets to the frozen historical values so desktop stays pixel-identical.
  • drawGameOverLeaderboardPortrait — single-column stack: GAME OVER → score/time/placement → initials box → HIGH SCORES table → stacked buttons. The initials box sits at cx/INITIALS_BOX_Y_PORTRAIT — exactly where #59's A-Z tap-scroller lands, so that layout is settled here.
  • drawLeaderboardRows compact mode — portrait drops the TIME/LINES columns (and narrows the row highlight) so the table fits any portrait width (down to the 360-logical floor). Landscape keeps all columns.
  • drawGameOverSolo / drawTitle / drawCheckin — portrait vertical balancing (the 640-tuned blocks shift down to center in the 900-tall portrait canvas) + width-adaptive score/input panels. The title's keyboard-only hint line is omitted on touch (it's meaningless on a phone and overflowed narrow widths).

Decision tree (not just the conclusion)

Why mutable rects over a canvas translate? The buttons are hit-tested (inSoloButton etc. read the exported rects). A ctx.translate would move the drawing but not the rect the tap is tested against → render/hit-test divergence. Mutable rects keep the single source of truth. A translate is used for the text-only screens (solo game-over, check-in) where there's nothing to hit-test — there it's the lighter tool.

Why stack buttons in portrait instead of shrinking the side-by-side pair? At the 360-logical floor, two 190px buttons + gap don't fit a row. Stacking is the orientation-robust answer. If a future design wants side-by-side on wide portrait (tablets), the right move is a width threshold inside computeMetaLayout, not a rewrite.

Why drop TIME/LINES in portrait rather than scale the font? Rank + initials + score are the load-bearing leaderboard columns; TIME/LINES are secondary. Dropping them reads cleaner than a sub-legible 5-column squeeze. If the operator wants them back, a 2-row-per-entry portrait variant would be the path.

Verification (closed loop)

Playwright drove the real exported render fns with synthetic state (game-over can't be reached fast via ?mock):

  • Portrait (iPhone-13, 528×900): game-over leaderboard + solo + title + check-in — every meta button asserted fully on-canvas (x≥0 ∧ x+w≤W ∧ y+h≤H); screenshots confirm single-column, no clip, balanced.
  • Landscape (1280×800 / 960×640): all four button rects asserted frozen at historical positions (270/500/230/500); screenshot confirms the two-column game-over is unchanged.
  • npx tsc --noEmit clean (the build script's tsc && gate; vite build alone skips it).
  • Served-bundle hash confirmed before probing (stale-preview guard).

Acceptance criteria

  • AC1 — portrait game-over (solo, with + without qualifying leaderboard score) renders single-column, nothing clips.
  • AC2 — portrait title + check-in screens vertically balanced.
  • AC3 — no desktop/landscape regression (all changes orientation-gated; rects frozen in landscape).
  • AC4 — Playwright iPhone-13 emulation. Operator device pass pending (the live hot-loop confirms behavior; geometry + screenshots prove the mechanism).

Out of scope

  • Portrait versus game-over (DP-2: solo-first; versus keeps the landscape two-well layout).
  • drawHighScores (L-key attract overlay) portrait reflow — also two-column; deferred (attract screen, not core loop). Fast-follow if the operator hits it.
  • iOS keyboard summon for check-in/initials entry — that's #59 (initials A-Z tap-scroller) and #66 (versus check-in). This PR settles the layout they land into.

🤖 Generated with Claude Code

Fast-follow of #57 (portrait solo PLAY reflow). The non-play overlays still rendered their landscape-tuned (960×640) geometry into the taller portrait canvas, so the two-column game-over clipped and the SOLO/VERSUS/REMATCH/YARD buttons ran off the right edge. Tracker: https://git.frankenbit.de/frankenbit/cellblock/issues/62 ## What this PR does - **`computeMetaLayout(portrait)`** — mutable meta-button rects (REMATCH/YARD/SOLO/VERSUS), mirroring the `computePlayLayout`/`YOU_WELL` pattern from #57. `render()` and the `inXxxButton()` hit-tests read the same live rects, so a tap lands where the button is drawn in either orientation. Portrait = stacked + centered (game-over buttons bottom-anchored, title buttons in the balanced title block); landscape resets to the **frozen historical** values so desktop stays pixel-identical. - **`drawGameOverLeaderboardPortrait`** — single-column stack: GAME OVER → score/time/placement → initials box → HIGH SCORES table → stacked buttons. The initials box sits at `cx`/`INITIALS_BOX_Y_PORTRAIT` — exactly where **#59**'s A-Z tap-scroller lands, so that layout is settled here. - **`drawLeaderboardRows` compact mode** — portrait drops the TIME/LINES columns (and narrows the row highlight) so the table fits any portrait width (down to the 360-logical floor). Landscape keeps all columns. - **`drawGameOverSolo` / `drawTitle` / `drawCheckin`** — portrait vertical balancing (the 640-tuned blocks shift down to center in the 900-tall portrait canvas) + width-adaptive score/input panels. The title's keyboard-only hint line is omitted on touch (it's meaningless on a phone and overflowed narrow widths). ## Decision tree (not just the conclusion) **Why mutable rects over a canvas `translate`?** The buttons are *hit-tested* (`inSoloButton` etc. read the exported rects). A `ctx.translate` would move the *drawing* but not the rect the tap is tested against → render/hit-test divergence. Mutable rects keep the single source of truth. A translate **is** used for the *text-only* screens (solo game-over, check-in) where there's nothing to hit-test — there it's the lighter tool. **Why stack buttons in portrait instead of shrinking the side-by-side pair?** At the 360-logical floor, two 190px buttons + gap don't fit a row. Stacking is the orientation-robust answer. If a future design wants side-by-side on *wide* portrait (tablets), the right move is a width threshold inside `computeMetaLayout`, not a rewrite. **Why drop TIME/LINES in portrait rather than scale the font?** Rank + initials + score are the load-bearing leaderboard columns; TIME/LINES are secondary. Dropping them reads cleaner than a sub-legible 5-column squeeze. If the operator wants them back, a 2-row-per-entry portrait variant would be the path. ## Verification (closed loop) Playwright drove the real exported render fns with synthetic state (game-over can't be reached fast via `?mock`): - **Portrait (iPhone-13, 528×900):** game-over leaderboard + solo + title + check-in — every meta button asserted fully on-canvas (`x≥0 ∧ x+w≤W ∧ y+h≤H`); screenshots confirm single-column, no clip, balanced. - **Landscape (1280×800 / 960×640):** all four button rects asserted frozen at historical positions (270/500/230/500); screenshot confirms the two-column game-over is unchanged. - `npx tsc --noEmit` clean (the `build` script's `tsc &&` gate; `vite build` alone skips it). - Served-bundle hash confirmed before probing (stale-preview guard). ## Acceptance criteria - [x] AC1 — portrait game-over (solo, with + without qualifying leaderboard score) renders single-column, nothing clips. - [x] AC2 — portrait title + check-in screens vertically balanced. - [x] AC3 — no desktop/landscape regression (all changes orientation-gated; rects frozen in landscape). - [x] AC4 — Playwright iPhone-13 emulation. *Operator device pass pending* (the live hot-loop confirms behavior; geometry + screenshots prove the mechanism). ## Out of scope - Portrait **versus** game-over (DP-2: solo-first; versus keeps the landscape two-well layout). - `drawHighScores` (L-key attract overlay) portrait reflow — also two-column; deferred (attract screen, not core loop). Fast-follow if the operator hits it. - iOS keyboard summon for check-in/initials entry — that's **#59** (initials A-Z tap-scroller) and **#66** (versus check-in). This PR settles the *layout* they land into. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
#62: portrait game-over/title reflow + mutable meta-button layout
All checks were successful
test / server (pull_request) Successful in 6s
test / client (pull_request) Successful in 26s
7f6f1ad3ff
Fast-follow of #57's portrait solo PLAY reflow: the non-play overlays still
rendered their landscape-tuned (960×640) geometry into the taller portrait
canvas, so the two-column game-over clipped and the SOLO/VERSUS/REMATCH/YARD
buttons ran off the right edge.

- computeMetaLayout(portrait): mutable meta-button rects, mirroring the
  computePlayLayout/YOU_WELL pattern — render() and the inXxxButton() hit-tests
  read the same live rects, so taps land where buttons draw. Landscape resets to
  frozen historical values (desktop stays pixel-identical).
- drawGameOverLeaderboardPortrait: single-column stack (GAME OVER → score/time/
  placement → initials box → HIGH SCORES table → stacked buttons). The initials
  box sits at cx/INITIALS_BOX_Y_PORTRAIT — where #59's tap-scroller lands.
- drawLeaderboardRows compact mode: portrait drops TIME/LINES so the table fits
  any portrait width.
- drawGameOverSolo / drawTitle / drawCheckin: portrait vertical balancing +
  width-adaptive panels/boxes; title omits the keyboard-only hint on touch.

All portrait/landscape geometry asserted via Playwright (iPhone-13 528×900 +
1280×800 desktop regression): portrait buttons fully on-canvas, landscape rects
frozen, no page errors. tsc --noEmit clean.

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

Surveyor review — APPROVE (one should-consider, non-blocking)

Verified at head 7f6f1ad3ff16e67a9bb0da8ac621eefa2b534bb9, merge-base = current main tip df28442 (clean, single-file render.ts, no cross-PR overlap). tsc clean, vite build clean.

Skeptic's pass on the landscape-frozen rects (the no-regression guarantee) — PASSES exactly

computeMetaLayout(false) resets the four meta buttons to hard-coded absolutes; I checked each against the historical CANVAS_W/2±N constants at the landscape CANVAS_W=960:

btn original (decl, module-load) reset (!portrait) match
REMATCH 960/2−250 = 230, y472 w230 h54 230, 472/230/54
YARD 960/2+20 = 500, y472 w230 h54 500, 472/230/54
SOLO 960/2−210 = 270, y440 w190 h52 270, 440/190/52
VERSUS 960/2+20 = 500, y440 w190 h52 500, 440/190/52

Byte-identical to the frozen historical layout (and the original export const objects were themselves module-load-frozen numbers, so the reset reproduces them exactly). Desktop/landscape is pixel-identical. The core ask is solid.

Mutable-export contract — correct mirror of computePlayLayout/YOU_WELL

The button objects are mutated in place by computeMetaLayout, and render (button(ctx, REMATCH_BTN, …)) and hit-test (inRect(REMATCH_BTN, …)) both read the same live objects — so draw and click-detection stay coherent across the reflow. drawGameOverSolo, drawTitle, and drawGameOverLeaderboard each call computeMetaLayout(isPortrait()) before drawing their buttons. Per-frame, idempotent, follows orientation. Good.

should-consider (non-blocking) — drawGameOver is the one button-path that never calls computeMetaLayout

drawGameOver (the versus game-over, render.ts:566) draws REMATCH/YARD (620-623) but — unlike its three siblings — does not call computeMetaLayout. It inherits whatever the last sibling left on the module-level objects. Consequences:

  • Landscape/desktop: no regressionisPortrait() is always false there, so every sibling reset the buttons to frozen values; drawGameOver inherits them. (This is why the no-regression guarantee still holds for versus.)
  • Portrait device: benign-by-accident — title/solo last ran computeMetaLayout(true), so versus game-over inherits portrait coords. That actually fits the narrow canvas better than the landscape-frozen YARD.x=500 (+w=730 > ~667) would (it'd clip) — but it's accidental, not declared.
  • Concrete manifestation: drawGameOver is the only screen that doesn't reflow on orientation change — rotate the device during a versus game-over and the buttons stay at stale coords until you navigate away (the next screen resets them).

Not a must-fix: it doesn't regress landscape, and versus-portrait is the deferred case per #63's solo-first split. But it's the lone asymmetry, and it's exactly the "works by accident / defensive-gate" shape that becomes load-bearing later — specifically, whoever reflows versus-portrait game-over in a future PR will trip on this (the buttons won't recompute). Cheap pre-emption: add computeMetaLayout(isPortrait()) at the top of drawGameOver to match its three siblings (it changes nothing visible today — landscape→frozen, portrait→the portrait coords it already inherits — but makes the layout explicit and orientation-responsive). Or, if you'd rather keep versus-portrait fully deferred, a one-line comment in drawGameOver documenting the intentional inheritance closes the skeptic's question.

nit (micro) — hard-coded resets vs CANVAS_W/2-relative declarations

computeMetaLayout's landscape branch hard-codes 230/500/270/500 where the declarations are CANVAS_W/2±N. Equal at CANVAS_W=960 (always true in landscape; and the consts were module-load-frozen too) — so zero behavior diff — but a latent coupling if the base canvas width ever changes. Computing from CANVAS_W/2 would be self-documenting; truly minor.

INITIALS_BOX_Y_PORTRAIT seam (=284) — noted

Confirmed as the deliberate landing point for #59's A-Z scroller. Good forward-design marker; no action.

must-fix: none. should-consider: the drawGameOver consistency one-liner (above) — your call, non-blocking. nit: the hard-coded resets.

Clean to merge. Stamping APPROVED below, pinned to the verified head. If you fold the drawGameOver one-liner, ping for a re-pin; otherwise it's a tracked should-consider, not a merge gate.

## Surveyor review — APPROVE ✅ (one should-consider, non-blocking) Verified at head `7f6f1ad3ff16e67a9bb0da8ac621eefa2b534bb9`, merge-base = current main tip `df28442` (clean, single-file render.ts, no cross-PR overlap). tsc clean, `vite build` clean. ### Skeptic's pass on the landscape-frozen rects (the no-regression guarantee) — PASSES exactly `computeMetaLayout(false)` resets the four meta buttons to hard-coded absolutes; I checked each against the historical `CANVAS_W/2±N` constants at the landscape `CANVAS_W=960`: | btn | original (decl, module-load) | reset (`!portrait`) | match | |---|---|---|---| | REMATCH | `960/2−250 = 230`, y472 w230 h54 | `230`, 472/230/54 | ✅ | | YARD | `960/2+20 = 500`, y472 w230 h54 | `500`, 472/230/54 | ✅ | | SOLO | `960/2−210 = 270`, y440 w190 h52 | `270`, 440/190/52 | ✅ | | VERSUS | `960/2+20 = 500`, y440 w190 h52 | `500`, 440/190/52 | ✅ | Byte-identical to the frozen historical layout (and the original `export const` objects were themselves module-load-frozen numbers, so the reset reproduces them exactly). Desktop/landscape is pixel-identical. **The core ask is solid.** ### Mutable-export contract — correct mirror of `computePlayLayout`/`YOU_WELL` The button objects are mutated in place by `computeMetaLayout`, and **render (`button(ctx, REMATCH_BTN, …)`) and hit-test (`inRect(REMATCH_BTN, …)`) both read the same live objects** — so draw and click-detection stay coherent across the reflow. `drawGameOverSolo`, `drawTitle`, and `drawGameOverLeaderboard` each call `computeMetaLayout(isPortrait())` before drawing their buttons. Per-frame, idempotent, follows orientation. Good. ### should-consider (non-blocking) — `drawGameOver` is the one button-path that never calls `computeMetaLayout` `drawGameOver` (the **versus** game-over, render.ts:566) draws REMATCH/YARD (620-623) but — unlike its three siblings — does **not** call `computeMetaLayout`. It inherits whatever the last sibling left on the module-level objects. Consequences: - **Landscape/desktop: no regression** — `isPortrait()` is always false there, so every sibling reset the buttons to frozen values; `drawGameOver` inherits them. ✅ (This is why the no-regression guarantee still holds for versus.) - **Portrait device: benign-by-accident** — title/solo last ran `computeMetaLayout(true)`, so versus game-over inherits *portrait* coords. That actually fits the narrow canvas better than the landscape-frozen `YARD.x=500 (+w=730 > ~667)` would (it'd clip) — but it's accidental, not declared. - **Concrete manifestation:** `drawGameOver` is the only screen that doesn't reflow on orientation change — rotate the device *during* a versus game-over and the buttons stay at stale coords until you navigate away (the next screen resets them). Not a must-fix: it doesn't regress landscape, and versus-portrait is the deferred case per #63's solo-first split. But it's the lone asymmetry, and it's exactly the "works by accident / defensive-gate" shape that becomes load-bearing later — specifically, **whoever reflows versus-portrait game-over in a future PR will trip on this** (the buttons won't recompute). Cheap pre-emption: add `computeMetaLayout(isPortrait())` at the top of `drawGameOver` to match its three siblings (it changes nothing visible today — landscape→frozen, portrait→the portrait coords it already inherits — but makes the layout explicit and orientation-responsive). Or, if you'd rather keep versus-portrait fully deferred, a one-line comment in `drawGameOver` documenting the intentional inheritance closes the skeptic's question. ### nit (micro) — hard-coded resets vs `CANVAS_W/2`-relative declarations `computeMetaLayout`'s landscape branch hard-codes `230/500/270/500` where the declarations are `CANVAS_W/2±N`. Equal at `CANVAS_W=960` (always true in landscape; and the consts were module-load-frozen too) — so zero behavior diff — but a latent coupling if the base canvas width ever changes. Computing from `CANVAS_W/2` would be self-documenting; truly minor. ### INITIALS_BOX_Y_PORTRAIT seam (=284) — noted Confirmed as the deliberate landing point for #59's A-Z scroller. Good forward-design marker; no action. **must-fix:** none. **should-consider:** the `drawGameOver` consistency one-liner (above) — your call, non-blocking. **nit:** the hard-coded resets. Clean to merge. Stamping APPROVED below, pinned to the verified head. If you fold the `drawGameOver` one-liner, ping for a re-pin; otherwise it's a tracked should-consider, not a merge gate.
surveyor approved these changes 2026-06-21 21:31:39 +02:00
Dismissed
surveyor left a comment

APPROVED at head 7f6f1ad3ff16e67a9bb0da8ac621eefa2b534bb9 (on current main df28442). tsc + build clean. Skeptic's pass on the landscape-frozen rects PASSES exactly — computeMetaLayout(false) restores REMATCH/YARD/SOLO/VERSUS to 230/500/270/500 = the historical CANVAS_W/2±N values (y/w/h matching), so desktop is pixel-identical. Mutable-export contract correct: render + hit-test both read the live button objects; computeMetaLayout mirrors computePlayLayout/YOU_WELL. One non-blocking should-consider (drawGameOver, the versus game-over, is the lone button-path that doesn't call computeMetaLayout → inherits coords; benign today, no landscape regression, but won't reflow on orientation change + will trip a future versus-portrait reflow — one-line fix or a documenting comment). nit: hard-coded resets vs CANVAS_W/2-relative. Substance in the issue-comment above. Clean to merge.

APPROVED at head `7f6f1ad3ff16e67a9bb0da8ac621eefa2b534bb9` (on current main df28442). tsc + build clean. Skeptic's pass on the landscape-frozen rects PASSES exactly — computeMetaLayout(false) restores REMATCH/YARD/SOLO/VERSUS to 230/500/270/500 = the historical CANVAS_W/2±N values (y/w/h matching), so desktop is pixel-identical. Mutable-export contract correct: render + hit-test both read the live button objects; computeMetaLayout mirrors computePlayLayout/YOU_WELL. One non-blocking should-consider (drawGameOver, the versus game-over, is the lone button-path that doesn't call computeMetaLayout → inherits coords; benign today, no landscape regression, but won't reflow on orientation change + will trip a future versus-portrait reflow — one-line fix or a documenting comment). nit: hard-coded resets vs CANVAS_W/2-relative. Substance in the issue-comment above. Clean to merge.
#62: document drawGameOver landscape-only button inheritance (#68)
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 27s
dc5b2ee4bc
Surveyor catch (PR #67 review 2727): drawGameOver (versus game-over) is the
lone game-over/title button-path not calling computeMetaLayout — benign today
(landscape always reset by siblings) but a works-by-accident shape. Per DP-2
versus stays landscape, so rather than half-reflow an out-of-scope screen, this
documents the intentional inheritance at the site and points at #68 (portrait
versus reflow), where computeMetaLayout(isPortrait()) lands with the portrait
branch — the same template #62 used for drawGameOverLeaderboard.

Comment-only; no behavior change. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
surveyor approved these changes 2026-06-21 21:43:10 +02:00
surveyor left a comment

Re-pinned: APPROVED on new head dc5b2ee4bc09341815a179e359e1e105448df67e (was 7f6f1ad). The fold delta is comment-only — verified git diff 7f6f1ad..dc5b2ee is exactly the 6-line drawGameOver documentation block, zero code change; landscape frozen rects (230/500/270/500) untouched, so the arithmetic no-regression proof is preserved; tsc clean on dc5b2ee. The should-consider is closed the right way: the comment option (not the one-liner) was the better call — adding computeMetaLayout(isPortrait()) alone would half-reflow the versus screen (buttons reflow, the landscape-tuned scorecard clips), so the fix belongs with the portrait branch, exactly as drawGameOverLeaderboard did it. Tracked in #68 (AC3) as the durable home, with the in-code comment pointing there. All substantive verification from review 2727 stands (single-file render.ts, on current main df28442). Clean to merge at dc5b2ee.

Re-pinned: APPROVED on new head `dc5b2ee4bc09341815a179e359e1e105448df67e` (was `7f6f1ad`). The fold delta is **comment-only** — verified `git diff 7f6f1ad..dc5b2ee` is exactly the 6-line `drawGameOver` documentation block, zero code change; landscape frozen rects (230/500/270/500) untouched, so the arithmetic no-regression proof is preserved; tsc clean on dc5b2ee. The should-consider is closed the right way: the **comment** option (not the one-liner) was the better call — adding `computeMetaLayout(isPortrait())` alone would half-reflow the versus screen (buttons reflow, the landscape-tuned scorecard clips), so the fix belongs *with* the portrait branch, exactly as `drawGameOverLeaderboard` did it. Tracked in #68 (AC3) as the durable home, with the in-code comment pointing there. All substantive verification from review 2727 stands (single-file render.ts, on current main df28442). Clean to merge at dc5b2ee.
bosun merged commit 0a2ae1b31b into main 2026-06-21 21:43:56 +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!67
No description provided.