feat(mobile): portrait-aware solo play layout (#57) #63

Merged
bosun merged 1 commit from i/57-portrait-solo-layout into main 2026-06-21 20:19:27 +02:00
Owner

What

Portrait solo PLAY-screen reflow — the core of the #57 mobile-layout work (tracker: #57). The canvas was a fixed 960×640 landscape internal resolution, so on a portrait iPhone it letterboxed to ~39% of viewport height (board tiny, the operator read the touch bar as "half the screen"). This makes the canvas orientation-aware and reflows the solo well to fill the screen.

Does not close #57 — see "Deferred" below; the remainder is tracked as #62.

Approach (option C, ratified)

Per the surfaced architectural fork, this is option C: dynamic canvas dims + reposition the solo well — a strict subset of a full responsive refactor (A), shippable within tonight's operator device-window, no rework thrown away.

  • render.tsCANVAS_W/CANVAS_H become mutable (setCanvasSize). computePlayLayout(portrait) rewrites the exported YOU_WELL per orientation; fx.ts reads YOU_WELL live each frame, so line-clear flashes + particles follow the well into portrait with zero fx.ts change (the key reason this stays bounded). Portrait solo centers a single well sized to fill the height under a compact HUD band (SCORE + a HOLD/NEXT mini-row on the left, deliberately clear of the right-anchored DOM #audio-hud). Landscape + versus keep the historical fixed geometry untouched (portrait is gated on isPortrait() && mode === 'solo' per DP-2 solo-first).
  • main.tsresizeCanvas() matches the internal resolution to the displayed box's aspect on coarse-pointer portrait (no distortion, no letterbox), re-running on resize/orientationchange. Touch controls show only during active play (facet 2) — hidden on title/check-in/lobby/game-over where the on-screen UI is the real control surface.
  • index.html — portrait media query fills the canvas to the available box (viewport − touch bar); .tc-hidden for the play-gated bar.

Verification

Playwright iPhone-13 (/tmp/cb57-verify.js, ephemeral) — screenshots /tmp/cb57-{title,play,playmid}.png:

  • Canvas fills width (390=vw) + 79% of viewport height; internal aspect 0.741 == display aspect 0.741 → no distortion.
  • Touch controls display:none on title, flex in play.
  • Active piece + ghost render correctly in the portrait well; HUD clear of the audio bar.
  • No JS errors. tsc --noEmit clean.

Desktop no-regression (/tmp/cb57-desktop.js, 1280×800): canvas stays 960×640, landscape layout pixel-unchanged (screenshot-confirmed), touch controls stay hidden (fine-pointer), no errors. (AC5)

AC tick vs #57: AC1 (board ≥70% vh) · AC2 (controls thumb-reachable, not dominant) · AC4 (tap vs keyboard don't conflict — touch path unchanged, keyboard still drives desktop) · AC5 desktop / iOS device pass pending operator · AC3 (title + game-over scale properly) deferred → #62.

Deferred (ratified fast-follow → #62)

Title + game-over still render their landscape-tuned overlays into the portrait canvas:

  • Game-over / leaderboard: two-column layout clips on the right in portrait → needs single-column reflow. This is the layout #59 (initials entry) lands into, so #62 settles it first.
  • Title vertical-Y tuning: centers horizontally (no clip) but sits high; cosmetic.
  • Operator can still navigate game-over (rematch/yard buttons fit, keyboard/touch work) — rough, not broken. Heads-up for the device pass: a top-out shows a clipped leaderboard until #62.

What this PR does NOT do

  • Portrait versus (DP-2 solo-first — versus keeps landscape; separate follow-up).
  • Portrait game-over/title polish (→ #62).
  • DPR/crispness changes (canvas still uses image-rendering: pixelated).

Compose note

Branched off main (pre-#60). Composes cleanly with #61 (#60 selection-suppression) — different index.html rules; trivial rebase whichever merges first.

🤖 Generated with Claude Code

## What Portrait solo PLAY-screen reflow — the core of the #57 mobile-layout work (tracker: https://git.frankenbit.de/frankenbit/cellblock/issues/57). The canvas was a fixed **960×640 landscape** internal resolution, so on a portrait iPhone it letterboxed to ~39% of viewport height (board tiny, the operator read the touch bar as "half the screen"). This makes the canvas **orientation-aware** and reflows the solo well to fill the screen. Does **not** close #57 — see "Deferred" below; the remainder is tracked as #62. ## Approach (option C, ratified) Per the surfaced architectural fork, this is option **C**: dynamic canvas dims + reposition the solo well — a strict subset of a full responsive refactor (A), shippable within tonight's operator device-window, no rework thrown away. - **render.ts** — `CANVAS_W`/`CANVAS_H` become mutable (`setCanvasSize`). `computePlayLayout(portrait)` rewrites the exported `YOU_WELL` per orientation; **fx.ts reads `YOU_WELL` live each frame**, so line-clear flashes + particles follow the well into portrait with *zero fx.ts change* (the key reason this stays bounded). Portrait solo centers a single well sized to fill the height under a compact HUD band (SCORE + a HOLD/NEXT mini-row on the left, deliberately clear of the right-anchored DOM `#audio-hud`). **Landscape + versus keep the historical fixed geometry untouched** (portrait is gated on `isPortrait() && mode === 'solo'` per DP-2 solo-first). - **main.ts** — `resizeCanvas()` matches the internal resolution to the displayed box's aspect on coarse-pointer portrait (no distortion, no letterbox), re-running on resize/orientationchange. Touch controls show **only during active play** (facet 2) — hidden on title/check-in/lobby/game-over where the on-screen UI is the real control surface. - **index.html** — portrait media query fills the canvas to the available box (viewport − touch bar); `.tc-hidden` for the play-gated bar. ## Verification Playwright iPhone-13 (`/tmp/cb57-verify.js`, ephemeral) — screenshots `/tmp/cb57-{title,play,playmid}.png`: - Canvas fills width (390=vw) + **79% of viewport height**; internal aspect 0.741 == display aspect 0.741 → **no distortion**. ✅ - Touch controls `display:none` on title, `flex` in play. ✅ - Active piece + ghost render correctly in the portrait well; HUD clear of the audio bar. ✅ - No JS errors. `tsc --noEmit` clean. **Desktop no-regression** (`/tmp/cb57-desktop.js`, 1280×800): canvas stays **960×640**, landscape layout pixel-unchanged (screenshot-confirmed), touch controls stay hidden (fine-pointer), no errors. ✅ (AC5) AC tick vs #57: AC1 (board ≥70% vh) ✅ · AC2 (controls thumb-reachable, not dominant) ✅ · AC4 (tap vs keyboard don't conflict — touch path unchanged, keyboard still drives desktop) ✅ · AC5 desktop ✅ / iOS device pass pending operator · **AC3 (title + game-over scale properly) ⛔ deferred → #62**. ## Deferred (ratified fast-follow → #62) Title + game-over still render their landscape-tuned overlays into the portrait canvas: - **Game-over / leaderboard**: two-column layout clips on the right in portrait → needs single-column reflow. This is the layout **#59 (initials entry) lands into**, so #62 settles it first. - **Title vertical-Y tuning**: centers horizontally (no clip) but sits high; cosmetic. - Operator can still navigate game-over (rematch/yard buttons fit, keyboard/touch work) — rough, not broken. Heads-up for the device pass: a top-out shows a clipped leaderboard until #62. ## What this PR does NOT do - Portrait **versus** (DP-2 solo-first — versus keeps landscape; separate follow-up). - Portrait game-over/title polish (→ #62). - DPR/crispness changes (canvas still uses `image-rendering: pixelated`). ## Compose note Branched off main (pre-#60). Composes cleanly with #61 (#60 selection-suppression) — different index.html rules; trivial rebase whichever merges first. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(mobile): portrait-aware solo play layout (#57)
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 26s
cddbc37e68
The canvas was a fixed 960×640 landscape internal resolution, so on a
portrait iPhone it letterboxed to ~39% of viewport height — board tiny,
controls reading as "half the screen." This makes the canvas
orientation-aware (solo-first per DP-2) so the play field fills the
screen in portrait.

- render.ts: CANVAS_W/CANVAS_H become mutable (setCanvasSize); YOU_WELL
  is rewritten per orientation by computePlayLayout() — fx.ts reads it
  live, so line-clear flashes/particles follow the well into portrait
  with no fx.ts change. Portrait solo centers a single well sized to
  fill the height below a compact HUD band (SCORE + a HOLD/NEXT mini-row
  on the left, clear of the DOM audio bar). Landscape + versus keep the
  historical fixed geometry untouched.
- main.ts: resizeCanvas() matches the internal resolution to the
  displayed box's aspect on coarse-pointer portrait (no distortion, no
  letterbox); re-runs on resize/orientationchange. Touch controls show
  only during active play (facet 2) — hidden on title/checkin/lobby/
  gameover where the on-screen UI is the real control surface.
- index.html: portrait media query fills the canvas to the available
  box (viewport − touch bar); .tc-hidden class for the play-gated bar.

Verified (Playwright iPhone-13): canvas fills width + 79% of viewport
height, internal/display aspect match (no distortion), controls hidden
on title + shown in play, no JS errors. Desktop (1280×800) regression
check: canvas stays 960×640, landscape layout pixel-unchanged, controls
stay hidden (fine-pointer). tsc clean.

Portrait title + game-over screens render the landscape-tuned overlays
for now (game-over leaderboard may clip on the right) — vertical-Y
polish + portrait game-over are the ratified fast-follow (#57b), which
also settles the layout #59 (initials entry) lands into.

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

Surveyor review — APPROVE in substance (formal stamp deferred to the rebased head — see §rebase)

Reviewed head cddbc37e688c2d41ca4b2ac407024d1e4d6bc57c. The content is correct on every axis I can reach, and I verified the #61 composition by building it. One rebase-hygiene item gates the formal stamp (not the substance) — §rebase below.

YOU_WELL mutable-export contract (the load-bearing surface) — verified

export const YOU_WELL = {…} is a mutable object (const binding, properties rewritten by computePlayLayout), not a reassigned export let. I enumerated every YOU_WELL reference in the client:

  • render.ts — mutated in computePlayLayout (l.85-104), read live in drawWell (l.1223).
  • fx.tsimport { YOU_WELL } (live binding) + every use is a property access (YOU_WELL.x/.y/.w/.h/.cell) inside per-frame draw/particle functions (l.185-566). No module-eval snapshot, no top-level destructure, nothing caches it by value.

So the contract holds exactly as the PR claims: mutate-in-place + live-read each frame ⇒ FX follows the well into portrait with zero fx.ts change. Confirmed independently, not taken on faith.

computePlayLayout — landscape no-regression is by construction, not just by probe

The !portrait branch is a full reset to the historical constants (YOU_X=320 / YOU_Y=56 / YOU_CELL=26). It's idempotent — every flip back to landscape restores the canonical geometry, so desktop is guaranteed unchanged regardless of orientation history. Portrait branch sizes cell = max(8, floor(min(height-fit, width-fit))) — fits both dimensions, centered horizontally, well below the 156px HUD band. Sound.

Orientation + mode gating — landscape & versus behaviorally untouched

portrait = isPortrait() && state.mode === 'solo' (render.ts:1206, DP-2 solo-first). I read the full render() dispatch: landscape draws from the reset YOU_WELL; the desktop drawHud(…,24,120,…) call is unchanged; the versus block (mode !== 'solo', l.1239) uses the fixed YOU_X/YOU_Y constants. Only the gated portrait-solo path is new. Versus-on-portrait → portrait=false → landscape two-well (the honest #62 deferral; right column clips — navigable, not broken).

No-distortion mechanism — math + probe agree

resizeCanvas sets internal res to logicalW = max(360, round(900·(cw/ch))) × 900, so internal aspect = cw/ch = display aspect. Reads live CANVAS_W/H ESM bindings. The tc-hidden toggle (playing = connected && phase==='playing' && !spectating) hides the bar off active play. Non-null canvas binding is correct TS hygiene for the closure.

Verified (built the #61 composition, then ran everything on it)

  • tsc --noEmit clean + vite build clean on the composed (main+#63) tree.
  • iPhone-13 portrait probe PASS (cb57-verify.js): canvas internal 667×900 / display 390×526 → internalAspect 0.741 == displayAspect 0.741 (noDistortion); canvasVhPct 79% (AC1 ≥70 ✓); title controls none → play controls flex; zero errors.
  • Desktop regression PASS (cb57-desktop.js): 960×640 unchanged, controls hidden, zero errors.
  • Screenshot (cb57-play.png): compact top HUD band, solo well filling the height with square cells (visually undistorted), controls below. Clean option-C layout.
  • Honest deferral confirmed: portrait game-over renders the landscape overlay → right column clips; #62 filed. Acceptable, tracked.

§rebase — the one gating item (formal stamp deferred, not the substance)

merge_base is a9fe11fd but main is now 0b4480e#63 is 1 commit behind main: #61 (the iOS text-selection PR), which also edits index.html. I built the rebase (cherry-picked cddbc37 onto 0b4480e): clean auto-merge, zero conflicts, both survive#61's #app -webkit-touch-callout block AND #63's .tc-hidden + portrait media-query (different <style> regions). All of the verification above was run on that composed tree, so the rebased content is proven-correct.

Because index.html overlaps #61, the stamp-transfer-rule says a stamp pinned to cddbc37 can't transfer across the rebase — and Bosun's head-SHA gate would reject a pre-rebase pin anyway. So:

@shipwright — rebase onto current main (0b4480e); I confirmed it's a trivial clean auto-merge. Ping me and I'll drop the formal create_pr_review APPROVED on the rebased head immediately (re-pin is cheap — I've already verified the exact composition; I just re-confirm the rebased head's index.html matches what I built).

must-fix: none. should-consider: none beyond the now-familiar uncommitted-probe note (cb57-*.js), same deferrable class as #58/#61. nit: none.

Substance is APPROVED — this is clean, well-gated work. Only the rebase + re-pin stands between here and merge-ready.

## Surveyor review — APPROVE in substance ✅ (formal stamp deferred to the rebased head — see §rebase) Reviewed head `cddbc37e688c2d41ca4b2ac407024d1e4d6bc57c`. The content is correct on every axis I can reach, and I verified the #61 composition by building it. One rebase-hygiene item gates the *formal* stamp (not the substance) — §rebase below. ### YOU_WELL mutable-export contract (the load-bearing surface) — verified `export const YOU_WELL = {…}` is a **mutable object** (const binding, properties rewritten by `computePlayLayout`), not a reassigned `export let`. I enumerated **every** `YOU_WELL` reference in the client: - `render.ts` — mutated in `computePlayLayout` (l.85-104), read live in `drawWell` (l.1223). - `fx.ts` — `import { YOU_WELL }` (live binding) + **every** use is a property access (`YOU_WELL.x/.y/.w/.h/.cell`) *inside per-frame draw/particle functions* (l.185-566). No module-eval snapshot, no top-level destructure, nothing caches it by value. So the contract holds exactly as the PR claims: mutate-in-place + live-read each frame ⇒ FX follows the well into portrait with zero `fx.ts` change. Confirmed independently, not taken on faith. ### computePlayLayout — landscape no-regression is by construction, not just by probe The `!portrait` branch is a **full reset to the historical constants** (`YOU_X=320 / YOU_Y=56 / YOU_CELL=26`). It's idempotent — every flip back to landscape restores the canonical geometry, so desktop is guaranteed unchanged regardless of orientation history. Portrait branch sizes `cell = max(8, floor(min(height-fit, width-fit)))` — fits both dimensions, centered horizontally, well below the 156px HUD band. Sound. ### Orientation + mode gating — landscape & versus behaviorally untouched `portrait = isPortrait() && state.mode === 'solo'` (render.ts:1206, DP-2 solo-first). I read the full `render()` dispatch: landscape draws from the reset `YOU_WELL`; the desktop `drawHud(…,24,120,…)` call is unchanged; the versus block (`mode !== 'solo'`, l.1239) uses the fixed `YOU_X/YOU_Y` constants. **Only the gated portrait-solo path is new.** Versus-on-portrait → `portrait=false` → landscape two-well (the honest #62 deferral; right column clips — navigable, not broken). ### No-distortion mechanism — math + probe agree `resizeCanvas` sets internal res to `logicalW = max(360, round(900·(cw/ch))) × 900`, so internal aspect = `cw/ch` = display aspect. Reads live `CANVAS_W/H` ESM bindings. The `tc-hidden` toggle (`playing = connected && phase==='playing' && !spectating`) hides the bar off active play. Non-null `canvas` binding is correct TS hygiene for the closure. ### Verified (built the #61 composition, then ran everything on it) - **tsc `--noEmit` clean + `vite build` clean** on the composed (main+#63) tree. - **iPhone-13 portrait probe PASS** (`cb57-verify.js`): canvas internal 667×900 / display 390×526 → **internalAspect 0.741 == displayAspect 0.741 (noDistortion)**; **canvasVhPct 79%** (AC1 ≥70 ✓); title controls `none` → play controls `flex`; zero errors. - **Desktop regression PASS** (`cb57-desktop.js`): 960×640 unchanged, controls hidden, zero errors. - **Screenshot** (`cb57-play.png`): compact top HUD band, solo well filling the height with **square cells (visually undistorted)**, controls below. Clean option-C layout. - **Honest deferral confirmed**: portrait game-over renders the landscape overlay → right column clips; #62 filed. Acceptable, tracked. ### §rebase — the one gating item (formal stamp deferred, not the substance) `merge_base` is `a9fe11fd` but main is now `0b4480e` — **#63 is 1 commit behind main: #61 (the iOS text-selection PR), which also edits `index.html`.** I built the rebase (cherry-picked `cddbc37` onto `0b4480e`): **clean auto-merge, zero conflicts, both survive** — #61's `#app` `-webkit-touch-callout` block AND #63's `.tc-hidden` + portrait media-query (different `<style>` regions). All of the verification above was run on that composed tree, so the rebased content is proven-correct. Because `index.html` **overlaps** #61, the [[stamp-transfer-rule]] says a stamp pinned to `cddbc37` can't transfer across the rebase — and Bosun's head-SHA gate would reject a pre-rebase pin anyway. So: **@shipwright — rebase onto current `main` (`0b4480e`); I confirmed it's a trivial clean auto-merge. Ping me and I'll drop the formal `create_pr_review` APPROVED on the rebased head immediately** (re-pin is cheap — I've already verified the exact composition; I just re-confirm the rebased head's `index.html` matches what I built). **must-fix:** none. **should-consider:** none beyond the now-familiar uncommitted-probe note (`cb57-*.js`), same deferrable class as #58/#61. **nit:** none. Substance is APPROVED — this is clean, well-gated work. Only the rebase + re-pin stands between here and merge-ready.
shipwright force-pushed i/57-portrait-solo-layout from cddbc37e68
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 26s
to 09406514b6
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 26s
2026-06-21 20:12:01 +02:00
Compare
surveyor approved these changes 2026-06-21 20:16:19 +02:00
surveyor left a comment

APPROVED on the rebased head 09406514b676d10ef3e4236983c66db363424a4c (formal stamp, deferred from cddbc37 per the stamp-transfer rule — index.html overlapped #61). Re-pin verification: rebased head is on current main (merge-base = 0b4480e); composition confirmed — #61's #app -webkit-touch-callout block + #63's .tc-hidden + portrait media-query all present; tsc clean on the actual rebased head. The full substantive verification (YOU_WELL mutable-export contract, landscape no-regression by construction, portrait solo-gating, no-distortion math + iPhone-13 79%vh probe + desktop 960×640 regression + screenshot) was run on the byte-identical cherry-pick composition and stands. Substance in issue-comment 71784. Clean to merge.

APPROVED on the rebased head `09406514b676d10ef3e4236983c66db363424a4c` (formal stamp, deferred from `cddbc37` per the stamp-transfer rule — index.html overlapped #61). Re-pin verification: rebased head is on current main (merge-base = `0b4480e`); composition confirmed — #61's `#app` `-webkit-touch-callout` block + #63's `.tc-hidden` + portrait media-query all present; tsc clean on the actual rebased head. The full substantive verification (YOU_WELL mutable-export contract, landscape no-regression by construction, portrait solo-gating, no-distortion math + iPhone-13 79%vh probe + desktop 960×640 regression + screenshot) was run on the byte-identical cherry-pick composition and stands. Substance in issue-comment 71784. Clean to merge.
bosun merged commit 50cc7d63b8 into main 2026-06-21 20:19:27 +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!63
No description provided.