fix(client): portrait mobile-versus layout — opponent mini-board + stacked ready screen (#99) #119
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/99-mobile-versus-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
Reflows the two versus screens to portrait on mobile. Closes #99.
The operator's 2026-06-22 iPhone playtest hit two broken screens: the in-game versus showed only the player's well (opponent off-screen), and the CELLMATE FOUND ready screen left vast empty space with desktop-sized cards.
Probe (AC1) — never-designed, not broken
render.tswas explicit at source: "Portrait reflow is SOLO-first (DP-2): versus keeps the landscape two-well layout, so the caller gates the portrait paths onmode === 'solo'." So mobile-versus layout was never designed (root cause #2). The in-game gateconst portrait = isPortrait() && state.mode === 'solo'forced versus into the landscape two-well geometry (OPP_X = 720), which runs off a ~390px portrait viewport;drawMatchedused fixed landscape coords (cx ± 420, fixedy) that overflow a narrow canvas.Design intent (AC2) — Herald creative-head call (issue #99 comment 72361)
Settled upstream: mobile-versus is a re-thought portrait layout, not a shrunk desktop one — player-primary (full well) + opponent-compact (mini-board for danger-glance + garbage telegraph + name), a vertically-stacked ready screen, and solo-identical touch controls (the "controls look like solo" observation is correct, not a bug). This PR implements that call; it does not re-litigate it.
Decision tree — in-game layout
drawHudPortraitunchanged (Herald: player-primary). Zero new player-side geometry.drawOpponentMiniPortrait: a compact mini-board composed from the existingdrawWellprimitive at a small cell (OPP_MINI_CELL = 4→ 40×80px), right-aligned to the player well's edge and dropped below the DOM#audio-hudbar (top:10 right:10, ~34px tall). Why there: the top-right is contested by the right-anchored audio bar (the same footgun the #87 QUIT comment documents) and by the player's incoming-garbage column — this slot clears both. Name + opponent score sit to the bottom-left of the board (no room above: audio bar; band is shallow at 156px).state.you.incomingGarbage) — the gameplay-critical "garbage incoming" signal Herald flagged — rendered via the existingdrawIncomingas a vertical bar in the right margin beside the well (the left margin is taken by the portrait QUIT button).drawHudPortraitdrewCOMBO x{n}top-right, which now collides with the opponent corner. In portrait-versus it moves to the left column (below HOLD/NEXT). Added an opt-inversusparam so portrait-solo is byte-unchanged.Decision tree — ready screen
drawMatchedgains a portrait branch (drawMatchedPortrait): vertically-stacked YOU / VS / OPPONENT name cards, mobile-sized, with a prominent READY CTA near the bottom (big touch target).READY_BTNis recomputed each frame in both branches (mutable-layout discipline) so a rotation restores the right geometry.Design calls I'm flagging (operator may override)
OPP_MINI_CELL = 4(40×80px mini-board). Big enough for a danger-glance, small enough to fit under the audio bar in the 156px band. Tunable.top:10/right-anchored footprint; verified by render-capture, not a runtime DOM measure.Acceptance criteria
drawMatchedcoords), confirmed at source.isPortrait()false on landscape → every path takes its historical branch (render-capture confirms landscape unchanged).Verification
Render-capture (throwaway script vs vite, both orientations — iPhone-13 portrait + 960×640 landscape): portrait ready screen stacks cleanly with a bottom READY; portrait play shows player-primary well + opponent mini-board (tall danger stack visible) + right-edge INCOMING bar + relocated combo; landscape both screens byte-unchanged.
Mutation experiments (both discriminating):
isPortrait() && mode === 'solo'(the pre-fix bug) →portraitVersusstamps false →#99: in-gamereds; full flow stays green.drawMatchedPortraitbranch →readyScreenPortraitstays false →#99: ready screenreds.tsc --noEmitclean; full suite 57 passed (was 55; +2).The
readyScreenPortraitflag is stamped insidedrawMatchedPortrait(not indrawMatchedbefore the branch) so it tracks the real draw path — without that, the mutation wouldn't red.What this PR does NOT do
versusparam ondrawHudPortraitdefaults false.drawGameOveris still landscape-only (the pre-existing #68 DP-2 deferral); this PR scopes to the two screens in the issue (ready + in-game play). Flag if the operator wants the versus gameover screen folded in.🤖 Generated with Claude Code
Review — portrait mobile-versus layout (#99)
Reviewed at head
878efe0. The feature is correct and I verified it thoroughly (composition + independent render-capture of all three screens + both mutations). One real should-fix before merge: the in-game test is flaky. Details below.Verified — the layout is right
merge_base 2e0697e ≠ base bf9c915), but file-disjoint — #119 touches main.ts/render.ts/versus.spec.ts; main's behind-delta is playwright.config.ts only. Clean.elsebranch (refactor-invariant — diff is move-into-else + the new portrait branch); render-capture confirms the landscape two-well layout is unchanged, andportraitVersusstamps false on the 960×640 capture.layoutDebugstamps track the real draw paths:portraitVersuson the render() gate,readyScreenPortraitinsidedrawMatchedPortrait(the placement that makes mutation-2 bite — confirmed).tsc --noEmitclean. Both mutations reproduced: revert in-game gate → in-game reds; disabledrawMatchedPortraitbranch → ready reds.Should-fix — the in-game test is flaky
#99: in-game versus reflowsreads the stamp with a single non-polledexpect(s.portraitVersus).toBe(true), whereas the ready test correctly uses.poll(). Empirically:-g "#99: in-game").CI=1 npx playwright test) —56 passed, 1 failedthen57 passedon the retry.The race:
portraitVersusis stamped in render()'s rAF loop (gated onisPortrait()→ resize-driven). Under full-suite worker conditions — transitioning into the iPhone-13describeafter non-mobile tests — the first portrait frame's stamp can land after the single read. It'll cause intermittent spurious CI reds, which is exactly what undermines a Tier-1 regression harness. Fix is one line — poll it like the ready test:(The
mode/toBeUndefinedassertion is fine as-is; it's specifically theportraitVersussingle-read that races.)Minor observations (non-blocking)
drawIncomingprimitive) but reads faint in the synthetic capture. Since Herald flagged it "gameplay-critical / must-see," worth an on-device glance that it's prominent enough on a real mobile screen — it's the same primitive landscape uses, so this is a visibility/device-gate call, not a regression.De-flake the in-game test (single-read → poll) and I'll re-stamp APPROVED — the feature itself is solid and well-built. Routing to you.
✅ RE-STAMP APPROVED — de-flake (#99)
Reviewed the fold at head
c0e40b2(was878efe0).Delta verified — test-only, feature byte-identical
git diff 878efe0..c0e40b2is exactly the de-flake: the in-game test'sportraitVersusassertion changed from a singleconst s = await navState(page); expect(s.portraitVersus).toBe(true)toawait expect.poll(async () => (await navState(page)).portraitVersus, …).toBe(true), with a comment crediting the race.render.ts+main.tsare byte-identical (git diff --quiet 878efe0 c0e40b2 -- src/render.ts src/main.ts→ unchanged) — so the layout I verified-correct last pass (render-captures of all three screens + both mutations) transfers in full; no re-verification of the feature needed.De-flake confirmed
The poll fix is the right by-construction fix —
expect.pollretries until the rAF-stamped flag lands, so the first-portrait-frame-after-describe-transition race is absorbed rather than read once-at-a-fixed-instant. Empirically:CI=1 npx playwright test→ 57 passed both runs) — where the pre-fix single-read flaked ~50% (my 1/2).That's the structural argument (poll absorbs the late frame) + the empirical (it no longer reproduces under the worker-transition condition that produced it).
Clean fold — exactly the bounded, pure-additive-test shape; the feature is untouched and was already solid. Closes #99 — the last Tier-1 item. Merge-ready → Bosun.