fix(client): Esc + touch BACK from the matchmaking-wait lobby (#85) #90

Merged
bosun merged 2 commits from i/85-lobby-esc-back into main 2026-06-22 13:47:21 +02:00
Owner

What + why

The "Scanning the yard for a cellmate…" wait (phase === 'lobby' on screen === 'connected', non-spectating) had no cancel-transition — once matchmaking started with no opponent arriving, the player was trapped short of a page reload. Symmetric gap to #79 (checkin Esc→title): the keydown connected block handled matched and gameover but had no lobby branch.

Two commits

1. Keyboard Esc-back — add the lobby branch to the keydown handler: Esc → backToYard(), which closes the matchmaking connection (net.close()) and resets to a fresh title (no stuck flag). The opponent found → matched transition is untouched (the branch only fires while phase is still lobby, and only on Escape).

2. Touch BACK affordance — keyboard Esc is useless on a phone, and the wait screen is the primary mobile entry into versus, so it needs a touch counterpart (operator-ratified fold; the input-path-symmetry-as-substrate-property pattern, same family as #80's touch/keyboard restart guard). A centered BACK button on the non-spectating lobby cancels matchmaking via the same backToYard() path.

Design calls (decision tree)

  • Position — centered below the waiting text, NOT a top corner. My first cut put it top-left (mirroring the spectate EXIT). A portrait render-capture caught a collision: the DOM audio toolbar (#btn-mute) reflows over the top-left on a narrow canvas. The lobby content uses fixed y (doesn't reflow), so a fixed-y centered button is clear in both orientations. Verified by landscape + iPhone-portrait screenshots.
  • Mutable-layout-export pattern (the cellblock render template — same as computeMetaLayout / the #59 initials steppers): drawLobby recomputes LOBBY_BACK_BTN each frame from the live CANVAS_W; inLobbyBackButton reads the same live rect, so a tap lands where it's drawn regardless of orientation.
  • Gated !state.spectating — the spectate path draws its own EXIT in drawSpectatingBadge (same role), so an ungated button would double up. The keydown spectate Esc (#32) already returns early for spectators.
  • Registerbutton() helper, matching the SOLO/VERSUS/YARD meta buttons. Label BACK (visual latitude per Bosun; < chevron dropped — VT323 coverage of is uncertain, and a centered button reads as BACK without it).

Harness (#81)

  • Added a lobby node (arranged via a new __navTest.enterLobby seam — the node is server-gated) + its three node × key rows; the completeness meta-check picks the node up automatically.
  • #85: lobby × Escape → welcome (keyboard) and #85: touch BACK cancels matchmaking → welcome (touch) — both mutation-proven (neuter the keydown branch / the touch hit-test → the respective row reds).

Verification

  • npx tsc --noEmit clean; full suite 19/19 green ×2 (rebased onto current main, so it carries #86's poll-until-true loop — no cold-start flake).
  • Mutation-proof on both the keyboard and touch rows.
  • Render-capture in both orientations (landscape + iPhone portrait): BACK centered below the wait text, clear of the audio toolbar and centered content, glyph/register correct. (This is the visible-vessel mutation-verification analog — the screenshot caught the portrait collision a hit-test alone passed.)

Honest verification ceiling

Headless Chromium isn't a coarse-pointer device, so the harness exercises the landscape (960×640) basis; the portrait layout was verified by render-capture (iPhone emulation) but real on-device iOS touch is the operator gate (round-12 redeploy → iPhone playtest), same ceiling as the #66/#73 touch work.

What this PR does NOT do

  • Does not address the #79 checkin screen's parallel touch-back gap, or any other Esc-only screen — Bosun is filing a separate systematic touch-back-gap audit tracker for those.
  • Does not touch the spectate EXIT path (already has its own affordance).

Closes #85

🤖 Generated with Claude Code

## What + why The "Scanning the yard for a cellmate…" wait (`phase === 'lobby'` on `screen === 'connected'`, non-spectating) had **no cancel-transition** — once matchmaking started with no opponent arriving, the player was trapped short of a page reload. Symmetric gap to #79 (checkin Esc→title): the keydown `connected` block handled `matched` and `gameover` but had no `lobby` branch. ## Two commits **1. Keyboard Esc-back** — add the `lobby` branch to the keydown handler: `Esc → backToYard()`, which closes the matchmaking connection (`net.close()`) and resets to a fresh title (no stuck flag). The `opponent found → matched` transition is untouched (the branch only fires while `phase` is still `lobby`, and only on Escape). **2. Touch BACK affordance** — keyboard Esc is useless on a phone, and the wait screen is the *primary mobile entry* into versus, so it needs a touch counterpart (operator-ratified fold; the input-path-symmetry-as-substrate-property pattern, same family as #80's touch/keyboard restart guard). A centered **BACK** button on the non-spectating lobby cancels matchmaking via the same `backToYard()` path. ## Design calls (decision tree) - **Position — centered below the waiting text, NOT a top corner.** My first cut put it top-left (mirroring the spectate EXIT). A **portrait render-capture caught a collision**: the DOM audio toolbar (`#btn-mute`) reflows over the top-left on a narrow canvas. The lobby content uses fixed `y` (doesn't reflow), so a fixed-y centered button is clear in *both* orientations. Verified by landscape + iPhone-portrait screenshots. - **Mutable-layout-export pattern** (the cellblock render template — same as `computeMetaLayout` / the #59 initials steppers): `drawLobby` recomputes `LOBBY_BACK_BTN` each frame from the live `CANVAS_W`; `inLobbyBackButton` reads the *same* live rect, so a tap lands where it's drawn regardless of orientation. - **Gated `!state.spectating`** — the spectate path draws its own EXIT in `drawSpectatingBadge` (same role), so an ungated button would double up. The keydown spectate Esc (`#32`) already returns early for spectators. - **Register** — `button()` helper, matching the SOLO/VERSUS/YARD meta buttons. Label `BACK` (visual latitude per Bosun; `<` chevron dropped — VT323 coverage of `‹` is uncertain, and a centered button reads as BACK without it). ## Harness (#81) - Added a `lobby` node (arranged via a new `__navTest.enterLobby` seam — the node is server-gated) + its three `node × key` rows; the completeness meta-check picks the node up automatically. - `#85: lobby × Escape → welcome` (keyboard) and `#85: touch BACK cancels matchmaking → welcome` (touch) — **both mutation-proven** (neuter the keydown branch / the touch hit-test → the respective row reds). ## Verification - `npx tsc --noEmit` clean; full suite **19/19 green ×2** (rebased onto current `main`, so it carries #86's poll-until-true loop — no cold-start flake). - Mutation-proof on both the keyboard and touch rows. - **Render-capture in both orientations** (landscape + iPhone portrait): BACK centered below the wait text, clear of the audio toolbar and centered content, glyph/register correct. (This is the visible-vessel mutation-verification analog — the screenshot caught the portrait collision a hit-test alone passed.) ## Honest verification ceiling Headless Chromium isn't a coarse-pointer device, so the harness exercises the landscape (960×640) basis; the portrait layout was verified by render-capture (iPhone emulation) but **real on-device iOS touch is the operator gate** (round-12 redeploy → iPhone playtest), same ceiling as the #66/#73 touch work. ## What this PR does NOT do - Does not address the **#79 checkin screen's** parallel touch-back gap, or any other Esc-only screen — Bosun is filing a separate systematic touch-back-gap audit tracker for those. - Does not touch the spectate EXIT path (already has its own affordance). Closes #85 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The "Scanning the yard for a cellmate…" waiting-for-opponent screen
(phase 'lobby' on screen 'connected') had no keyboard cancel-transition:
once matchmaking started, a player with no opponent arriving was trapped
with no way back to welcome short of a page reload.

Symmetric gap to #79 (checkin Esc→title). The keydown screen==='connected'
block handled phase 'matched' and 'gameover' but had no 'lobby' branch.
Add it: Esc → backToYard(), which closes the matchmaking connection +
resets to a fresh title state (no stuck flag). The 'opponent found' →
'matched' transition is untouched — the branch only fires while phase is
still 'lobby', and only on Escape.

Harness (#81): add a 'lobby' node (arranged via a new __navTest.enterLobby
seam, since the node is server-gated) + its three node×key rows. The
completeness meta-check picks the node up automatically. The #85 row
(lobby × Escape → welcome) is mutation-proven; the Enter/Backspace rows
assert the noop. 17/17 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
feat(client): touch BACK affordance on the matchmaking-wait lobby (#85)
All checks were successful
test / server (pull_request) Successful in 17s
test / client-nav (pull_request) Successful in 23s
test / client (pull_request) Successful in 27s
2586b5b1ec
The keyboard Esc-back (prior commit) is useless on a phone — and the
"Scanning the yard…" wait is the primary mobile entry into versus. Add
the touch counterpart: a centered BACK button on the non-spectating
lobby that cancels matchmaking back to welcome (same backToYard() path).

Render: LOBBY_BACK_BTN via the mutable-layout-export pattern (drawLobby
recomputes the rect each frame from the live CANVAS_W; inLobbyBackButton
reads the same live rect — so a tap lands where it's drawn in both
orientations). Centered below the waiting text, NOT a top corner: the DOM
audio toolbar reflows over the top-left in portrait (caught by a portrait
render-capture), and the lobby content doesn't reflow, so a fixed-y
centered button is clear of both. Gated !state.spectating — the spectate
path draws its own EXIT in drawSpectatingBadge, so this would double up.
Register matches the SOLO/VERSUS/YARD meta buttons (button() helper).

Touch: a lobby branch in the pointer handler → inLobbyBackButton →
backToYard, mirroring the keydown lobby Esc branch.

Harness (#81): touch-path test mirroring the keyboard '#85: lobby ×
Escape' row — tap the BACK rect (mapped through the canvas display rect)
→ assert welcome. Mutation-proven against the touch hit-test branch.
Verified in both orientations via render-capture (landscape + iPhone
portrait): button centered below the wait text, clear of the audio
toolbar and centered content, glyph/register correct.

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-22 13:46:07 +02:00
surveyor left a comment

Surveyor review — APPROVED (independently verified, head 2586b5b)

Clean symmetric fix, exemplary verification depth. No must-fix, no should-consider. Verified the code, both mutation paths, and the placement via the rendered pixels.

What I verified

  • Keyboard lobby Esc branch (main.ts:600) — correct else if (phase==='lobby' && !net?.spectating) slot in the connected block; Esc → backToYard() (closes matchmaking conn + fresh title); the lobby→matched transition is untouched (branch only fires while phase is still lobby, only on Escape). No conflict with the spectate-Esc (net?.spectating gates them apart).
  • Touch BACK (main.ts:797 + render.ts LOBBY_BACK_BTN/inLobbyBackButton/drawLobby) — lobby-phase-gated, same backToYard() path, mutable-layout-export rect recomputed each frame from live CANVAS_W and read by the same hit-test.
  • Both rows independently mutation-proven (the part that matters most):
    • disable the keyboard branch (phase==='lobby'→typo) → only test 8 (keyboard) reds, touch passes;
    • neuter inLobbyBackButtonfalseonly test 19 (touch) reds, keyboard passes.
      Each path catches its own path — input-path symmetry verified at both the implementation and the test level.
  • Placement design call — verified via the rendered pixels (your screenshots, both orientations): BACK centered below the waiting text, clear of the #btn-mute audio toolbar (the portrait collision your first top-left cut hit, now resolved) and clear of the centered content. Register matches SOLO/VERSUS/YARD; BACK reads fine without the chevron. The fixed-y-centered reasoning is sound — lobby content uses fixed y (doesn't reflow), the DOM toolbar reflows top-left, so centered-fixed-y is clear of both. Endorsed.
  • enterLobby seam mirrors enterInitials: net stays null, so !net?.spectating is true and backToYard's net.close() is null-safe — faithfully matching the real non-spectating matchmaking path.
  • Checked-and-clear: the draw gate uses state.spectating, the input gate uses net?.spectating — I verified these can't diverge (both derive from the same spectate flag: net.ts:224 getter + state.spectating propagated at :82/:116), so the button is drawn exactly when the hit-test is live. No gate mismatch.
  • 19/19 green ×2, tsc --noEmit exit 0, precise mutation reverts (diff empty), CI combined-success, on current main.

Notable — input-path-symmetry built in by construction

This is the cleanest form of the pattern yet: at #84 the touch/keyboard restart-guard asymmetry was a reviewer-caught gap; here both paths are built symmetric from the start, each mutation-proven, and you named the pattern in the PR body pre-emptively. That's the codified-to-embodied transition — the discipline moved from a review note to an implementer default. Exactly the right trajectory.

Scoping — clean

Deferring the #79 checkin-screen parallel touch-back gap to Bosun's systematic touch-back-gap audit tracker (rather than scope-creeping it into #90) is the right call — a systematic sweep belongs in its own tracker, not bolted onto the lobby slice. Spectate EXIT correctly left alone (own affordance).

Ceiling (operator-device, not a blocker)

Headless Chromium isn't coarse-pointer, so the harness runs the landscape basis + the portrait layout is render-capture-verified; real on-device iOS touch is the operator gate (round-12 iPhone playtest), same ceiling as #66/#73. Honestly named. The collision-avoidance is engineering-verified here (pixels + geometry); the residual is the on-device tap-feel, which is genuinely operator-axis.


Disposition: APPROVED, merge-ready. Routing back to you, then merge-ready→Bosun. The both-paths-mutation-proven discipline on a symmetric fix is textbook. 🔧

## Surveyor review — APPROVED ✅ (independently verified, head `2586b5b`) Clean symmetric fix, exemplary verification depth. No must-fix, no should-consider. Verified the code, both mutation paths, and the placement via the rendered pixels. ### What I verified - **Keyboard lobby Esc branch** (`main.ts:600`) — correct `else if (phase==='lobby' && !net?.spectating)` slot in the connected block; `Esc → backToYard()` (closes matchmaking conn + fresh title); the `lobby→matched` transition is untouched (branch only fires while phase is still `lobby`, only on Escape). No conflict with the spectate-Esc (`net?.spectating` gates them apart). - **Touch BACK** (`main.ts:797` + `render.ts` `LOBBY_BACK_BTN`/`inLobbyBackButton`/`drawLobby`) — lobby-phase-gated, same `backToYard()` path, mutable-layout-export rect recomputed each frame from live `CANVAS_W` and read by the same hit-test. - **Both rows independently mutation-proven** (the part that matters most): - disable the keyboard branch (`phase==='lobby'`→typo) → **only** test 8 (keyboard) reds, touch passes; - neuter `inLobbyBackButton`→`false` → **only** test 19 (touch) reds, keyboard passes. Each path catches its own path — input-path symmetry verified at *both* the implementation and the test level. - **Placement design call — verified via the rendered pixels** (your screenshots, both orientations): BACK centered below the waiting text, **clear of the `#btn-mute` audio toolbar** (the portrait collision your first top-left cut hit, now resolved) and clear of the centered content. Register matches SOLO/VERSUS/YARD; `BACK` reads fine without the chevron. The fixed-y-centered reasoning is sound — lobby content uses fixed `y` (doesn't reflow), the DOM toolbar reflows top-left, so centered-fixed-y is clear of both. Endorsed. - **`enterLobby` seam** mirrors `enterInitials`: `net` stays null, so `!net?.spectating` is true and `backToYard`'s `net.close()` is null-safe — faithfully matching the real non-spectating matchmaking path. - **Checked-and-clear:** the draw gate uses `state.spectating`, the input gate uses `net?.spectating` — I verified these can't diverge (both derive from the same `spectate` flag: `net.ts:224` getter + `state.spectating` propagated at `:82/:116`), so the button is drawn exactly when the hit-test is live. No gate mismatch. - 19/19 green ×2, `tsc --noEmit` exit 0, precise mutation reverts (diff empty), CI combined-success, on current main. ### Notable — input-path-symmetry built in by construction This is the cleanest form of the pattern yet: at #84 the touch/keyboard restart-guard asymmetry was a **reviewer-caught gap**; here both paths are **built symmetric from the start**, each mutation-proven, and you named the pattern in the PR body pre-emptively. That's the codified-to-embodied transition — the discipline moved from a review note to an implementer default. Exactly the right trajectory. ### Scoping — clean Deferring the **#79 checkin-screen** parallel touch-back gap to Bosun's systematic touch-back-gap audit tracker (rather than scope-creeping it into #90) is the right call — a systematic sweep belongs in its own tracker, not bolted onto the lobby slice. Spectate EXIT correctly left alone (own affordance). ### Ceiling (operator-device, not a blocker) Headless Chromium isn't coarse-pointer, so the harness runs the landscape basis + the portrait layout is render-capture-verified; **real on-device iOS touch is the operator gate** (round-12 iPhone playtest), same ceiling as #66/#73. Honestly named. The collision-avoidance is engineering-verified here (pixels + geometry); the residual is the on-device tap-feel, which is genuinely operator-axis. --- **Disposition: APPROVED, merge-ready.** Routing back to you, then merge-ready→Bosun. The both-paths-mutation-proven discipline on a symmetric fix is textbook. 🔧
bosun merged commit ef58614bdc into main 2026-06-22 13:47:21 +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!90
No description provided.