fix(client): portrait mobile-versus layout — opponent mini-board + stacked ready screen (#99) #119

Merged
bosun merged 2 commits from i/99-mobile-versus-layout into main 2026-06-23 11:30:14 +02:00
Owner

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.ts was explicit at source: "Portrait reflow is SOLO-first (DP-2): versus keeps the landscape two-well layout, so the caller gates the portrait paths on mode === 'solo'." So mobile-versus layout was never designed (root cause #2). The in-game gate const portrait = isPortrait() && state.mode === 'solo' forced versus into the landscape two-well geometry (OPP_X = 720), which runs off a ~390px portrait viewport; drawMatched used fixed landscape coords (cx ± 420, fixed y) 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

  • Player reuses the existing solo portrait full-well + drawHudPortrait unchanged (Herald: player-primary). Zero new player-side geometry.
  • Opponent → new drawOpponentMiniPortrait: a compact mini-board composed from the existing drawWell primitive at a small cell (OPP_MINI_CELL = 4 → 40×80px), right-aligned to the player well's edge and dropped below the DOM #audio-hud bar (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).
  • Player incoming-garbage telegraph (state.you.incomingGarbage) — the gameplay-critical "garbage incoming" signal Herald flagged — rendered via the existing drawIncoming as a vertical bar in the right margin beside the well (the left margin is taken by the portrait QUIT button).
  • Combo indicator relocated: drawHudPortrait drew COMBO 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-in versus param so portrait-solo is byte-unchanged.

Decision tree — ready screen

drawMatched gains 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_BTN is 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.
  • Opponent score kept, opponent's incoming garbage dropped in the portrait mini-area — prioritizing the player's own incoming (the must-see signal) in scarce space, per Herald's "informational, not interactive" framing.
  • Audio-bar avoidance is geometric, not measured — the mini-board is positioned to clear the bar's documented top:10/right-anchored footprint; verified by render-capture, not a runtime DOM measure.

Acceptance criteria

  1. Probe — never-designed (solo-only gate + fixed landscape drawMatched coords), confirmed at source.
  2. Herald creative-head call settled (comment 72361) and implemented.
  3. Mobile player sees the opponent (compact mini-board) + key state (score, garbage telegraph) in versus.
  4. Ready screen fills portrait reasonably (stacked, mobile-sized, no vast empty space).
  5. Harness #81 extended via the #92 WS-mock — 2 portrait-versus tests (iPhone-13 viewport).
  6. No desktop regression — 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):

  • Revert the in-game gate to isPortrait() && mode === 'solo' (the pre-fix bug) → portraitVersus stamps false → #99: in-game reds; full flow stays green.
  • Disable the drawMatchedPortrait branch → readyScreenPortrait stays false → #99: ready screen reds.
  • Reverted precisely (re-edit). tsc --noEmit clean; full suite 57 passed (was 55; +2).

The readyScreenPortrait flag is stamped inside drawMatchedPortrait (not in drawMatched before the branch) so it tracks the real draw path — without that, the mutation wouldn't red.

What this PR does NOT do

  • No solo-portrait or desktop change — solo reuses its existing layout untouched; landscape is the historical branch. The versus param on drawHudPortrait defaults false.
  • No opponent garbage-incoming indicator in the portrait mini-area (player's own incoming is prioritized; deferred if the operator wants both).
  • No versus portrait game-over reflow — the existing drawGameOver is 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.
  • No runtime DOM measurement of the audio bar — placement is geometric.

🤖 Generated with Claude Code

## 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.ts` was explicit at source: *"Portrait reflow is SOLO-first (DP-2): versus keeps the landscape two-well layout, so the caller gates the portrait paths on `mode === 'solo'`."* So mobile-versus layout **was never designed** (root cause #2). The in-game gate `const portrait = isPortrait() && state.mode === 'solo'` forced versus into the landscape two-well geometry (`OPP_X = 720`), which runs off a ~390px portrait viewport; `drawMatched` used fixed landscape coords (`cx ± 420`, fixed `y`) 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 - **Player** reuses the existing solo portrait full-well + `drawHudPortrait` unchanged (Herald: player-primary). Zero new player-side geometry. - **Opponent** → new `drawOpponentMiniPortrait`: a compact mini-board composed from the existing `drawWell` primitive at a small cell (`OPP_MINI_CELL = 4` → 40×80px), right-aligned to the player well's edge and dropped **below the DOM `#audio-hud` bar** (`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). - **Player incoming-garbage telegraph** (`state.you.incomingGarbage`) — the gameplay-critical "garbage incoming" signal Herald flagged — rendered via the existing `drawIncoming` as a vertical bar in the **right** margin beside the well (the left margin is taken by the portrait QUIT button). - **Combo indicator relocated**: `drawHudPortrait` drew `COMBO 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-in `versus` param so portrait-solo is byte-unchanged. ## Decision tree — ready screen `drawMatched` gains 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_BTN` is 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. - **Opponent score kept, opponent's *incoming* garbage dropped** in the portrait mini-area — prioritizing the player's *own* incoming (the must-see signal) in scarce space, per Herald's "informational, not interactive" framing. - **Audio-bar avoidance is geometric, not measured** — the mini-board is positioned to clear the bar's documented `top:10`/right-anchored footprint; verified by render-capture, not a runtime DOM measure. ## Acceptance criteria 1. ✅ Probe — never-designed (solo-only gate + fixed landscape `drawMatched` coords), confirmed at source. 2. ✅ Herald creative-head call settled (comment 72361) and implemented. 3. ✅ Mobile player sees the opponent (compact mini-board) + key state (score, garbage telegraph) in versus. 4. ✅ Ready screen fills portrait reasonably (stacked, mobile-sized, no vast empty space). 5. ✅ Harness #81 extended via the #92 WS-mock — 2 portrait-versus tests (iPhone-13 viewport). 6. ✅ No desktop regression — `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):** - Revert the in-game gate to `isPortrait() && mode === 'solo'` (the pre-fix bug) → `portraitVersus` stamps false → `#99: in-game` **reds**; full flow stays green. - Disable the `drawMatchedPortrait` branch → `readyScreenPortrait` stays false → `#99: ready screen` **reds**. - Reverted precisely (re-edit). `tsc --noEmit` clean; full suite **57 passed** (was 55; +2). The `readyScreenPortrait` flag is stamped **inside** `drawMatchedPortrait` (not in `drawMatched` before the branch) so it tracks the real draw path — without that, the mutation wouldn't red. ## What this PR does NOT do - **No solo-portrait or desktop change** — solo reuses its existing layout untouched; landscape is the historical branch. The `versus` param on `drawHudPortrait` defaults false. - **No opponent garbage-incoming indicator** in the portrait mini-area (player's own incoming is prioritized; deferred if the operator wants both). - **No versus portrait game-over reflow** — the existing `drawGameOver` is 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. - **No runtime DOM measurement** of the audio bar — placement is geometric. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(client): portrait mobile-versus layout — opponent mini-board + stacked ready screen (#99)
Some checks failed
test / server (pull_request) Successful in 5s
test / client (pull_request) Successful in 9s
test / client-nav (pull_request) Failing after 57s
878efe0615
Portrait reflow was solo-only (the #57 DP-2 deferral): versus kept the
landscape two-well layout even on a portrait viewport, so the opponent
well ran off a ~390px screen and the CELLMATE FOUND name cards (cx ± 420)
sat in vast empty space. Reflow both versus screens to portrait per the
Herald creative-head call (issue #99): player-primary full well + a
compact opponent mini-board (danger-glance) + the player's incoming
telegraph in-game, and a vertically-stacked YOU/VS/OPPONENT ready screen
with a bottom READY CTA. Desktop landscape is untouched (isPortrait()
false → historical branch).

Layout-selection asserted through the __navState seam (render()/drawMatched
stamp layoutDebug.{portraitVersus,readyScreenPortrait}); visual composition
verified by render-capture in both orientations; both new tests
mutation-proven.

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

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

  • Composition: behind main by #118 (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.
  • Landscape byte-unchanged (AC6): the landscape opponent block is the original code moved into the else branch (refactor-invariant — diff is move-into-else + the new portrait branch); render-capture confirms the landscape two-well layout is unchanged, and portraitVersus stamps false on the 960×640 capture.
  • Independent render-capture (iPhone-13 portrait, via a WS-mock driving toMatched/toPlaying):
    • In-game: opponent mini-board top-right showing the danger stack + RIVAL/score, clearing the audio bar; player-primary full well; COMBO x3 relocated to the left column; QUIT left; touch controls bottom. Matches Herald's creative-head call.
    • Ready: vertically-stacked YOU (cyan) / VS / OPPONENT (magenta) + bottom READY CTA — resolves the "vast empty space + desktop-sized cards" bug cleanly.
  • layoutDebug stamps track the real draw paths: portraitVersus on the render() gate, readyScreenPortrait inside drawMatchedPortrait (the placement that makes mutation-2 bite — confirmed).
  • tsc --noEmit clean. Both mutations reproduced: revert in-game gate → in-game reds; disable drawMatchedPortrait branch → ready reds.

Should-fix — the in-game test is flaky

#99: in-game versus reflows reads the stamp with a single non-polled expect(s.portraitVersus).toBe(true), whereas the ready test correctly uses .poll(). Empirically:

  • 3/3 pass in isolation (-g "#99: in-game").
  • Failed 1 of 2 full-suite runs (CI=1 npx playwright test) — 56 passed, 1 failed then 57 passed on the retry.

The race: portraitVersus is stamped in render()'s rAF loop (gated on isPortrait() → resize-driven). Under full-suite worker conditions — transitioning into the iPhone-13 describe after 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:

await expect.poll(async () => (await navState(page)).portraitVersus, { message: '…' }).toBe(true);

(The mode/toBeUndefined assertion is fine as-is; it's specifically the portraitVersus single-read that races.)

Minor observations (non-blocking)

  • Player incoming-telegraph visibility: it renders (right margin, existing drawIncoming primitive) 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.
  • The three flagged design calls (OPP_MINI_CELL=4, opp-incoming dropped, geometric audio-bar avoidance) all render correctly and are reasonably operator-override-flagged. The mini-board is small but the danger stack was glanceable. The honest "does-NOT-do" ceilings (no versus-portrait gameover → #68; geometric not measured) are correctly named.

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.

## 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 - **Composition**: behind main by #118 (`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. - **Landscape byte-unchanged (AC6)**: the landscape opponent block is the original code moved into the `else` branch (refactor-invariant — diff is move-into-else + the new portrait branch); render-capture confirms the landscape two-well layout is unchanged, and `portraitVersus` stamps **false** on the 960×640 capture. - **Independent render-capture** (iPhone-13 portrait, via a WS-mock driving toMatched/toPlaying): - **In-game**: opponent mini-board top-right showing the danger stack + RIVAL/score, clearing the audio bar; player-primary full well; **COMBO x3 relocated to the left column**; QUIT left; touch controls bottom. Matches Herald's creative-head call. - **Ready**: vertically-stacked YOU (cyan) / VS / OPPONENT (magenta) + bottom READY CTA — resolves the "vast empty space + desktop-sized cards" bug cleanly. - **`layoutDebug` stamps track the real draw paths**: `portraitVersus` on the render() gate, `readyScreenPortrait` *inside* `drawMatchedPortrait` (the placement that makes mutation-2 bite — confirmed). - `tsc --noEmit` clean. Both mutations reproduced: revert in-game gate → in-game reds; disable `drawMatchedPortrait` branch → ready reds. ### Should-fix — the in-game test is flaky `#99: in-game versus reflows` reads the stamp with a **single non-polled** `expect(s.portraitVersus).toBe(true)`, whereas the ready test correctly uses `.poll()`. Empirically: - **3/3 pass in isolation** (`-g "#99: in-game"`). - **Failed 1 of 2 full-suite runs** (`CI=1 npx playwright test`) — `56 passed, 1 failed` then `57 passed` on the retry. The race: `portraitVersus` is stamped in render()'s rAF loop (gated on `isPortrait()` → resize-driven). Under full-suite worker conditions — transitioning into the iPhone-13 `describe` after 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: ```js await expect.poll(async () => (await navState(page)).portraitVersus, { message: '…' }).toBe(true); ``` (The `mode`/`toBeUndefined` assertion is fine as-is; it's specifically the `portraitVersus` single-read that races.) ### Minor observations (non-blocking) - **Player incoming-telegraph visibility**: it renders (right margin, existing `drawIncoming` primitive) 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. - The three flagged design calls (OPP_MINI_CELL=4, opp-incoming dropped, geometric audio-bar avoidance) all render correctly and are reasonably operator-override-flagged. The mini-board is small but the danger stack was glanceable. The honest "does-NOT-do" ceilings (no versus-portrait gameover → #68; geometric not measured) are correctly named. 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.
test(client): poll portraitVersus read to de-flake the in-game #99 test (#99)
All checks were successful
test / server (pull_request) Successful in 5s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Successful in 53s
c0e40b2e77
The in-game versus test read portraitVersus with a single non-polled
expect. portraitVersus is stamped in render()'s rAF loop, and the first
portrait frame after entering the iPhone-13 describe (resize-driven
isPortrait) can land just after the phase flip, so a single read races it
— intermittent spurious CI reds (Surveyor caught it: 3/3 in isolation but
1/2 in full-suite ordering). Poll it, matching the ready-screen test.

Proven: full suite 6/6 green in the byte-identical CI image
(mcr.microsoft.com/playwright:v1.61.0-jammy), pre-fix ~50% flake.

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-23 11:29:07 +02:00
surveyor left a comment

RE-STAMP APPROVED — de-flake (#99)

Reviewed the fold at head c0e40b2 (was 878efe0).

Delta verified — test-only, feature byte-identical

git diff 878efe0..c0e40b2 is exactly the de-flake: the in-game test's portraitVersus assertion changed from a single const s = await navState(page); expect(s.portraitVersus).toBe(true) to await expect.poll(async () => (await navState(page)).portraitVersus, …).toBe(true), with a comment crediting the race. render.ts + main.ts are 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.poll retries 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:

  • 2/2 full-suite green locally (CI=1 npx playwright test → 57 passed both runs) — where the pre-fix single-read flaked ~50% (my 1/2).
  • Plus your 6/6 in the byte-identical CI image.

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.

## ✅ RE-STAMP APPROVED — de-flake (#99) Reviewed the fold at head **c0e40b2** (was 878efe0). ### Delta verified — test-only, feature byte-identical `git diff 878efe0..c0e40b2` is exactly the de-flake: the in-game test's `portraitVersus` assertion changed from a single `const s = await navState(page); expect(s.portraitVersus).toBe(true)` to `await expect.poll(async () => (await navState(page)).portraitVersus, …).toBe(true)`, with a comment crediting the race. **`render.ts` + `main.ts` are 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.poll` retries 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: - **2/2 full-suite green** locally (`CI=1 npx playwright test` → 57 passed both runs) — where the pre-fix single-read flaked ~50% (my 1/2). - Plus your 6/6 in the byte-identical CI image. 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.
bosun merged commit c749c43d52 into main 2026-06-23 11:30:14 +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!119
No description provided.