feat(mobile): portrait-aware solo play layout (#57) #63
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/57-portrait-solo-layout"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.
CANVAS_W/CANVAS_Hbecome mutable (setCanvasSize).computePlayLayout(portrait)rewrites the exportedYOU_WELLper orientation; fx.ts readsYOU_WELLlive 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 onisPortrait() && mode === 'solo'per DP-2 solo-first).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..tc-hiddenfor the play-gated bar.Verification
Playwright iPhone-13 (
/tmp/cb57-verify.js, ephemeral) — screenshots/tmp/cb57-{title,play,playmid}.png:display:noneon title,flexin play. ✅tsc --noEmitclean.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:
What this PR does NOT do
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
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 bycomputePlayLayout), not a reassignedexport let. I enumerated everyYOU_WELLreference in the client:render.ts— mutated incomputePlayLayout(l.85-104), read live indrawWell(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.tschange. Confirmed independently, not taken on faith.computePlayLayout — landscape no-regression is by construction, not just by probe
The
!portraitbranch 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 sizescell = 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 fullrender()dispatch: landscape draws from the resetYOU_WELL; the desktopdrawHud(…,24,120,…)call is unchanged; the versus block (mode !== 'solo', l.1239) uses the fixedYOU_X/YOU_Yconstants. 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
resizeCanvassets internal res tologicalW = max(360, round(900·(cw/ch))) × 900, so internal aspect =cw/ch= display aspect. Reads liveCANVAS_W/HESM bindings. Thetc-hiddentoggle (playing = connected && phase==='playing' && !spectating) hides the bar off active play. Non-nullcanvasbinding is correct TS hygiene for the closure.Verified (built the #61 composition, then ran everything on it)
--noEmitclean +vite buildclean on the composed (main+#63) tree.cb57-verify.js): canvas internal 667×900 / display 390×526 → internalAspect 0.741 == displayAspect 0.741 (noDistortion); canvasVhPct 79% (AC1 ≥70 ✓); title controlsnone→ play controlsflex; zero errors.cb57-desktop.js): 960×640 unchanged, controls hidden, zero errors.cb57-play.png): compact top HUD band, solo well filling the height with square cells (visually undistorted), controls below. Clean option-C layout.§rebase — the one gating item (formal stamp deferred, not the substance)
merge_baseisa9fe11fdbut main is now0b4480e— #63 is 1 commit behind main: #61 (the iOS text-selection PR), which also editsindex.html. I built the rebase (cherry-pickedcddbc37onto0b4480e): clean auto-merge, zero conflicts, both survive — #61's#app-webkit-touch-calloutblock 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.htmloverlaps #61, the stamp-transfer-rule says a stamp pinned tocddbc37can'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 formalcreate_pr_reviewAPPROVED on the rebased head immediately (re-pin is cheap — I've already verified the exact composition; I just re-confirm the rebased head'sindex.htmlmatches 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.
cddbc37e6809406514b6APPROVED on the rebased head
09406514b676d10ef3e4236983c66db363424a4c(formal stamp, deferred fromcddbc37per 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-calloutblock + #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.