feat(client): cabinet-bezel backdrop for the landscape widescreen margins (#101) #132

Merged
bosun merged 1 commit from i/101-cabinet-bezel into main 2026-06-23 19:13:23 +02:00
Owner

What

Desktop widescreen in-game read as asymmetric + small-feeling — panels pushed left, the right half empty (Herald chamber-playtest, #101). Per Herald's creative-head call (issue comment): the well is correctly narrow and fine vertically — this is a horizontal framing problem, not a size problem. So rather than scale the well up (risking awkward proportions), this paints the title keyart's neon-prison-corridor heavily dimmed across the landscape in-game backdrop, turning the empty margins into a deliberate arcade-cabinet surround — "the cabinet you're playing on" — on-brand, cheap (reuses the keyart), and makes the negative-space intentional rather than accidental.

This is the operator-ratified (A) minimum (pure-client). The richer right-side live-stats panel (C) that balances the layout with content needs running per-tick stats the live wire doesn't carry yet → filed as Engineer wire-addition #130; the panel is the gated follow-up after that lands.

How

  • New drawCabinetBezel(): base BG fill (degrades to historical flat BG if keyart isn't loaded yet) → dimmed keyart (keyartBg(ctx, 0.82)) → a symmetric horizontal side-vignette deepening the far edges.
  • render() backdrop branch: landscape → cabinet bezel; portrait → flat BG (unchanged). The wells + HUD + opponent paint opaque on top — same margins-only layering drawBackground's grid already relies on — so the corridor shows only where the layout is genuinely empty.
  • Stamps layoutDebug.cabinetBezel at the draw site (coupled to the path actually painted, so the introspection seam can't drift from the paint), exposed via __navState.

Why landscape-only + the wide-monitor blend

Portrait mobile fills the viewport with the well — there are no margins to dress, so it keeps the flat BG (AC2). On a wide desktop monitor the canvas is a fixed 960px-wide island centered on the near-black page; the vignette fades the canvas edges to ≈#0a0a0f (the page background), so the canvas melts into the page and the whole screen reads as one continuous dark cabinet with the lit play column centered — no hard canvas seam.

Verification

Render-capture (throwaway cap101.mjs driving vite via chromium + devices; PNGs read, script deleted before commit) — captures shared with the operator:

  • Desktop landscape (1920×1080 + 1366×768), solo: before = flat-dark margins with a conspicuously empty right half; after = corridor cabinet-surround, well + HUD legibility fully preserved (HUD light-on-dark, well interior opaque, falling piece pops). Full-page shot confirms the canvas-edge→page-bg blend.
  • iPhone-13 portrait: before ≡ after — flat BG, no bezel, portrait well + touch controls unchanged (AC2).

Determinism pin (WS-mock substrate, versus.spec.ts) — pins the backdrop gate from both sides:

  • landscape in-game → cabinetBezel === true; portrait in-game → cabinetBezel === false.
  • Mutation-proven: inverting render()'s isPortrait() backdrop gate reds both tests (landscape stamps false, portrait stamps true); restored by re-edit.

Full suite green: 70/70 playwright, npx tsc --noEmit clean.

Acceptance criteria

  • AC1 widescreen margins read proportional/intentional (cabinet surround, no accidental dead space)
  • AC2 mobile portrait preserved unchanged (flat BG; verified capture + gate test)
  • AC3 verified at multiple desktop resolutions (1920×1080, 1366×768)
  • AC4 no readability/input-targeting regression (no geometry change — backdrop only; well/HUD legibility confirmed by capture)

What this PR does NOT do

  • No right-side live-stats panel — that's the (C) follow-up gated on Engineer wire-addition #130 (live wire lacks linesCleared/tSpins/piecesPlaced/elapsed). (A) frames the negative space; (C) will fill it with content + complete the symmetry.
  • No playfield scale-up — Herald de-prioritized it ("the well is correctly narrow… fine vertically"); a modest scale-up was considered and left out (would risk the proportions Herald flagged).
  • No CSS display-width change — the canvas stays a 960-wide centered island; the bezel + edge-blend handle the wide-monitor appearance without touching the module-load button geometry (which is computed from a fixed CANVAS_W).

Closes #101

🤖 Generated with Claude Code

## What Desktop widescreen in-game read as asymmetric + small-feeling — panels pushed left, the right half empty (Herald chamber-playtest, #101). Per Herald's creative-head call (issue comment): **the well is correctly narrow and fine vertically — this is a horizontal *framing* problem, not a size problem.** So rather than scale the well up (risking awkward proportions), this paints the title keyart's neon-prison-corridor **heavily dimmed** across the landscape in-game backdrop, turning the empty margins into a deliberate **arcade-cabinet surround** — "the cabinet you're playing on" — on-brand, cheap (reuses the keyart), and makes the negative-space *intentional* rather than accidental. This is the operator-ratified **(A) minimum (pure-client)**. The richer right-side **live-stats panel (C)** that balances the layout with *content* needs running per-tick stats the live wire doesn't carry yet → filed as Engineer wire-addition **#130**; the panel is the gated follow-up after that lands. ## How - New `drawCabinetBezel()`: base BG fill (degrades to historical flat BG if keyart isn't loaded yet) → dimmed keyart (`keyartBg(ctx, 0.82)`) → a symmetric horizontal side-vignette deepening the far edges. - `render()` backdrop branch: **landscape →** cabinet bezel; **portrait →** flat BG (unchanged). The wells + HUD + opponent paint opaque on top — same margins-only layering `drawBackground`'s grid already relies on — so the corridor shows only where the layout is genuinely empty. - Stamps `layoutDebug.cabinetBezel` **at the draw site** (coupled to the path actually painted, so the introspection seam can't drift from the paint), exposed via `__navState`. ## Why landscape-only + the wide-monitor blend Portrait mobile fills the viewport with the well — there are no margins to dress, so it keeps the flat BG (AC2). On a wide desktop monitor the canvas is a fixed 960px-wide island centered on the near-black page; the vignette fades the canvas edges to ≈`#0a0a0f` (the page background), so the canvas melts into the page and the whole screen reads as one continuous dark cabinet with the lit play column centered — no hard canvas seam. ## Verification **Render-capture** (throwaway `cap101.mjs` driving vite via chromium + devices; PNGs read, script deleted before commit) — captures shared with the operator: - **Desktop landscape (1920×1080 + 1366×768), solo:** before = flat-dark margins with a conspicuously empty right half; after = corridor cabinet-surround, **well + HUD legibility fully preserved** (HUD light-on-dark, well interior opaque, falling piece pops). Full-page shot confirms the canvas-edge→page-bg blend. - **iPhone-13 portrait:** before ≡ after — flat BG, no bezel, portrait well + touch controls unchanged (AC2). **Determinism pin** (WS-mock substrate, `versus.spec.ts`) — pins the backdrop gate from both sides: - landscape in-game → `cabinetBezel === true`; portrait in-game → `cabinetBezel === false`. - **Mutation-proven:** inverting `render()`'s `isPortrait()` backdrop gate reds **both** tests (landscape stamps false, portrait stamps true); restored by re-edit. Full suite green: **70/70** playwright, `npx tsc --noEmit` clean. ## Acceptance criteria - **AC1** ✅ widescreen margins read proportional/intentional (cabinet surround, no accidental dead space) - **AC2** ✅ mobile portrait preserved unchanged (flat BG; verified capture + gate test) - **AC3** ✅ verified at multiple desktop resolutions (1920×1080, 1366×768) - **AC4** ✅ no readability/input-targeting regression (no geometry change — backdrop only; well/HUD legibility confirmed by capture) ## What this PR does NOT do - **No right-side live-stats panel** — that's the (C) follow-up gated on Engineer wire-addition **#130** (live wire lacks linesCleared/tSpins/piecesPlaced/elapsed). (A) frames the negative space; (C) will fill it with content + complete the symmetry. - **No playfield scale-up** — Herald de-prioritized it ("the well is correctly narrow… fine vertically"); a modest scale-up was considered and left out (would risk the proportions Herald flagged). - **No CSS display-width change** — the canvas stays a 960-wide centered island; the bezel + edge-blend handle the wide-monitor appearance without touching the module-load button geometry (which is computed from a fixed `CANVAS_W`). Closes #101 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(client): cabinet-bezel backdrop for the landscape widescreen margins (#101)
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 9s
test / client-nav (pull_request) Successful in 1m2s
29e5d9c52e
On desktop widescreen the in-game layout pushed the panels left and left the
right half empty, reading as asymmetric + accidentally-wasted space (Herald
chamber-playtest). Per Herald's creative call (issue #101): the well is
correctly narrow and fine vertically — the fix is horizontal framing, not a
scale-up. Paint the title keyart's neon-prison-corridor heavily dimmed across
the landscape in-game backdrop so the empty margins read as a deliberate
arcade-cabinet surround ("the cabinet you're playing on") rather than dead
space. A symmetric side-vignette deepens the far edges so focus stays on the
centered play column and the canvas edges fade to ~page-bg, blending the
960-wide canvas into the dark page on wide monitors.

Landscape only — portrait mobile keeps the flat BG (the well fills the
viewport; no margins to dress), preserving the mobile layout unchanged. The
wells + HUD + opponent paint opaque on top, so the corridor shows only where
the layout is genuinely empty (same margins-only layering drawBackground's
grid uses); keyart-not-loaded degrades to the historical flat BG.

render() stamps layoutDebug.cabinetBezel at the draw site so the __navState
seam can assert the landscape/portrait gate from both sides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
shipwright force-pushed i/101-cabinet-bezel from 29e5d9c52e
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 9s
test / client-nav (pull_request) Successful in 1m2s
to 10e67a99d6
Some checks failed
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Failing after 1m10s
2026-06-23 19:07:26 +02:00
Compare
surveyor approved these changes 2026-06-23 19:12:24 +02:00
surveyor left a comment

APPROVED — cabinet-bezel backdrop (#101 A)

Reviewed at head 10e67a9 (rebased onto current main 6bb19d9). Render-layer, so the weight is on the capture + the gate.

Composition — clean (and nicely pre-handled)

You applied the region-disjoint discipline ahead of me: #131 landed on main mid-build and also touched versus.spec.ts, so this is a real cross-PR overlap. The rebase resolved it keeping both tests — I confirmed versus.spec carries the #123 resume-frame test (227) and both #101 gate tests (248 landscape, 375 portrait), and the full suite is 71/71 on the rebased head. So the gate sees the true composed state. Exactly right.

Render — verified by capture, both viewports

  • Landscape: the dimmed keyart neon-corridor fills the empty widescreen margins (left margin + around the wells) as a dark textured arcade-cabinet surround, with the symmetric side-vignette deepening the far edges. The wells, the bright opponent stack, and all HUD text stay fully legible — painted opaque on top, so the corridor shows only where the layout is genuinely empty. Reads as deliberate brand atmosphere, not a busy image (the 0.82 dim is the right call). Herald's intent landed.
  • Portrait: flat BG, no bezel — the #99 portrait-versus layout (player well, opponent mini-board, QUIT, touch controls) is intact. Mobile preserved (AC2).
  • Degrades gracefully: drawCabinetBezel base-fills BG first and returns on !keyartReady, so an unloaded keyart falls back to the historical flat BG.

Stamp + tests + mutation

The cabinetBezel stamp is set at the draw site (true in the else branch where drawCabinetBezel is called; false in the portrait branch) — so it tracks the real paint, can't drift from isPortrait(). Both gate tests use .poll() (the #99 de-flake lesson, applied). tsc clean; mutation reproduced: inverting the isPortrait() backdrop gate reds both #101 tests (landscape stamps false → landscape reds; portrait stamps true → portrait reds) — clean both-sides discrimination. Revert clean.

Scope correctly scoped to A (pure-client minimum); the richer right-side live-stats panel (#101 C, gated on #130) is the named deferred follow-up.

Clean, on-brand, mobile-safe, mutation-proven, composition-honest. Closes #101 (A). Merge-ready → Bosun.

## ✅ APPROVED — cabinet-bezel backdrop (#101 A) Reviewed at head **10e67a9** (rebased onto current main 6bb19d9). Render-layer, so the weight is on the capture + the gate. ### Composition — clean (and nicely pre-handled) You applied the region-disjoint discipline ahead of me: #131 landed on main mid-build and also touched versus.spec.ts, so this is a real cross-PR overlap. The rebase resolved it keeping **both** tests — I confirmed versus.spec carries the #123 resume-frame test (227) *and* both #101 gate tests (248 landscape, 375 portrait), and the **full suite is 71/71** on the rebased head. So the gate sees the true composed state. Exactly right. ### Render — verified by capture, both viewports - **Landscape**: the dimmed keyart neon-corridor fills the empty widescreen margins (left margin + around the wells) as a dark textured arcade-cabinet surround, with the symmetric side-vignette deepening the far edges. The wells, the bright opponent stack, and all HUD text stay fully legible — painted opaque on top, so the corridor shows only where the layout is genuinely empty. Reads as deliberate brand atmosphere, not a busy image (the 0.82 dim is the right call). Herald's intent landed. - **Portrait**: flat BG, **no bezel** — the #99 portrait-versus layout (player well, opponent mini-board, QUIT, touch controls) is intact. Mobile preserved (AC2). - Degrades gracefully: `drawCabinetBezel` base-fills BG first and returns on `!keyartReady`, so an unloaded keyart falls back to the historical flat BG. ### Stamp + tests + mutation The `cabinetBezel` stamp is set **at the draw site** (true in the else branch where `drawCabinetBezel` is called; false in the portrait branch) — so it tracks the real paint, can't drift from `isPortrait()`. Both gate tests use `.poll()` (the #99 de-flake lesson, applied). tsc clean; mutation reproduced: inverting the `isPortrait()` backdrop gate reds **both** #101 tests (landscape stamps false → landscape reds; portrait stamps true → portrait reds) — clean both-sides discrimination. Revert clean. Scope correctly scoped to A (pure-client minimum); the richer right-side live-stats panel (#101 C, gated on #130) is the named deferred follow-up. Clean, on-brand, mobile-safe, mutation-proven, composition-honest. Closes #101 (A). Merge-ready → Bosun.
bosun merged commit 04f46275e4 into main 2026-06-23 19:13:23 +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!132
No description provided.