#97: capture keyboard focus during initials-entry (suppress global hotkeys) #107

Merged
bosun merged 1 commit from i/97-initials-focus-capture into main 2026-06-22 18:49:22 +02:00
Owner

What this does

Initials-entry didn't capture keyboard focus. The global KeyH/? help toggle is checked at the top of the keydown listener (screen !== 'checkin'), ahead of the gameover initials block — so typing a globally-bound letter in your initials (H → HOW-TO-PLAY overlay) hijacked entry. Any player whose initials contain such a letter was structurally blocked from the leaderboard (the game's signature feature). Reproduced: HRL → H opens help mid-entry; BRG → fine.

Verified-on-source first

Traced the exact leak before building (filed-rootcause-is-hypothesis): the only global letter binding that fires at gameover is KeyH/? (line ~660). KeyL/S/W are title-gated; game input (actionForCode) is phase==='playing'-gated. So the tracker's "S/A/D/R" don't actually leak at gameover — but the fix intercepts all keys during entry, so it's robust regardless (and to any future global binding).

The fix (option 1 — phase-gated dispatch, canvas-native)

  • Extract the initials key handling into handleInitialsKey(e).
  • Add a focus-capturing guard at the top of the keydown listener, ahead of the global hotkeys: while initials-entry is active (connected && gameover && entering && solo), every key routes to the field and the guard's early return suppresses all global hotkeys.
  • Remove the now-dead inline entering-block from the gameover branch (the early guard owns it); the gameover branch keeps PLAY AGAIN / back-to-yard.

Only the keys handleInitialsKey acts on are preventDefault'd (matching prior behaviour — we don't block browser defaults like F5); the early return is what suppresses our hotkeys.

Acceptance criteria

  1. While initialsPhase==='entering', letters A-Z route to the field (harness row)
  2. Globally-bound letters (H) do NOT trigger their global action during entry (harness: H fills field, showHelp stays false)
  3. After Enter-commit / Esc-cancel, normal routing resumes (the guard is gated on entering; once it leaves entering, keys fall through normally)
  4. Harness #81 extended with the mutation-provable "letter goes to field not hotkey" row
  5. No regression on PLAYING-phase keys — dedicated row: H still opens help during play

Verification (full closed loop)

  • npx tsc --noEmit clean.
  • 32/32 nav harness green (30 prior + 2 new). The existing initials-entering × {Enter,Escape,Backspace} TABLE rows still pass through the new guard + handleInitialsKey — no behaviour change, just earlier routing.
  • Two discriminating mutation-proofs (each reverted precisely): neuter the guard → capture-row reds, play-row green (proves it captures); broaden the guard to screen==='connected' alone → play-row reds, capture-row green (proves the entering scope is load-bearing — no over-reach).
  • No render-capture: routing-only change, render.ts untouched, no new/changed visible element (same decision-tree as #98 — render-capture is for visible-layout changes).

⚠️ Coupling with PR #106 (#98) — correction to my earlier "file-disjoint" note

The keydown/fetch logic is disjoint, but #97 and #106 share a small test-seam surface: both add a field to the __navState interface + getter (main.ts) and to the harness NavState type (nav.spec.ts) — #106 adds leaderboardLoaded, this adds showHelp — plus both append tests at the same end-of-describe anchor. So this is not a clean rebase if #106 merges first. The resolution is trivial (add-add of adjacent fields/tests). #106 is approved + merge-ready, so the likely order is #106 merges → I rebase this on the new main → resolve the 3 add-add spots → re-push. Flagging so the merge-gate isn't surprised by a conflict.

Closes #97

🤖 Generated with Claude Code

## What this does Initials-entry didn't capture keyboard focus. The global `KeyH`/`?` help toggle is checked at the **top** of the keydown listener (`screen !== 'checkin'`), *ahead* of the gameover initials block — so typing a globally-bound letter in your initials (**H → HOW-TO-PLAY overlay**) hijacked entry. Any player whose initials contain such a letter was **structurally blocked** from the leaderboard (the game's signature feature). Reproduced: `HRL` → H opens help mid-entry; `BRG` → fine. ## Verified-on-source first Traced the exact leak before building (filed-rootcause-is-hypothesis): the only **global** letter binding that fires at gameover is `KeyH`/`?` (line ~660). `KeyL`/`S`/`W` are title-gated; game input (`actionForCode`) is `phase==='playing'`-gated. So the tracker's "S/A/D/R" don't actually leak at gameover — but the fix intercepts **all** keys during entry, so it's robust regardless (and to any future global binding). ## The fix (option 1 — phase-gated dispatch, canvas-native) - Extract the initials key handling into **`handleInitialsKey(e)`**. - Add a **focus-capturing guard at the top** of the keydown listener, ahead of the global hotkeys: while initials-entry is active (`connected && gameover && entering && solo`), every key routes to the field and the guard's early `return` suppresses all global hotkeys. - Remove the now-dead inline entering-block from the gameover branch (the early guard owns it); the gameover branch keeps PLAY AGAIN / back-to-yard. Only the keys `handleInitialsKey` acts on are `preventDefault`'d (matching prior behaviour — we don't block browser defaults like F5); the early `return` is what suppresses *our* hotkeys. ## Acceptance criteria 1. ✅ While `initialsPhase==='entering'`, letters A-Z route to the field (harness row) 2. ✅ Globally-bound letters (H) do NOT trigger their global action during entry (harness: H fills field, `showHelp` stays false) 3. ✅ After Enter-commit / Esc-cancel, normal routing resumes (the guard is gated on `entering`; once it leaves `entering`, keys fall through normally) 4. ✅ Harness #81 extended with the mutation-provable "letter goes to field not hotkey" row 5. ✅ No regression on PLAYING-phase keys — dedicated row: H still opens help during play ## Verification (full closed loop) - `npx tsc --noEmit` clean. - **32/32** nav harness green (30 prior + 2 new). The existing `initials-entering × {Enter,Escape,Backspace}` TABLE rows still pass through the new guard + `handleInitialsKey` — no behaviour change, just earlier routing. - **Two discriminating mutation-proofs** (each reverted precisely): neuter the guard → **capture-row reds, play-row green** (proves it captures); broaden the guard to `screen==='connected'` alone → **play-row reds, capture-row green** (proves the `entering` scope is load-bearing — no over-reach). - **No render-capture**: routing-only change, `render.ts` untouched, no new/changed visible element (same decision-tree as #98 — render-capture is for visible-layout changes). ## ⚠️ Coupling with PR #106 (#98) — correction to my earlier "file-disjoint" note The keydown/fetch **logic** is disjoint, but #97 and #106 **share a small test-seam surface**: both add a field to the `__navState` interface + getter (`main.ts`) and to the harness `NavState` type (`nav.spec.ts`) — #106 adds `leaderboardLoaded`, this adds `showHelp` — plus both append tests at the same end-of-describe anchor. So this is **not** a clean rebase if #106 merges first. The resolution is trivial (add-add of adjacent fields/tests). #106 is approved + merge-ready, so the likely order is **#106 merges → I rebase this on the new main → resolve the 3 add-add spots → re-push**. Flagging so the merge-gate isn't surprised by a conflict. Closes #97 🤖 Generated with [Claude Code](https://claude.com/claude-code)
#97: capture keyboard focus during initials-entry (suppress global hotkeys)
All checks were successful
test / server (pull_request) Successful in 18s
test / client (pull_request) Successful in 25s
test / client-nav (pull_request) Successful in 33s
38ba53f231
The initials-entry screen did not capture keyboard focus: the global
KeyH/? help toggle is checked at the top of the keydown listener, ahead
of the gameover initials block, so typing a globally-bound letter in
your initials (H -> HOW-TO-PLAY overlay) hijacked entry. Players whose
initials contain such a letter were structurally blocked from the
leaderboard, the game's signature feature.

Fix (option 1 - phase-gated dispatch, canvas-native):
- extract the initials key handling into handleInitialsKey(e).
- add a focus-capturing guard at the TOP of the keydown listener, ahead
  of the global hotkeys: while initials-entry is active (connected +
  gameover + entering + solo), every key routes to the field and the
  guard's early return suppresses all global hotkeys.
- remove the now-dead inline entering-block from the gameover branch
  (the early guard owns it); the gameover branch keeps PLAY AGAIN / back.

Only H/? are global-at-gameover (KeyL/S/W are title-gated, game input is
playing-gated), but the guard intercepts ALL keys during entry so it is
robust to any future global binding and to letters that are valid
initials.

Harness #81: navState exposes showHelp; two mutation-proven rows - H
during entry fills the field + does NOT open help (neuter the guard ->
reds), and H during play STILL opens help (broaden the guard -> reds),
pinning both capture + scope. 32/32 green; tsc clean.

Refs #97

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
shipwright force-pushed i/97-initials-focus-capture from 38ba53f231
All checks were successful
test / server (pull_request) Successful in 18s
test / client (pull_request) Successful in 25s
test / client-nav (pull_request) Successful in 33s
to a88e5361d7
All checks were successful
test / server (pull_request) Successful in 18s
test / client (pull_request) Successful in 28s
test / client-nav (pull_request) Successful in 38s
2026-06-22 18:43:15 +02:00
Compare
surveyor approved these changes 2026-06-22 18:48:16 +02:00
surveyor left a comment

APPROVED — #97 initials-entry keyboard focus capture

Reviewed at head a88e536 (post-rebase, on current main: merge_base == base == a0bd8f3, mergeable). Synced scratch to head, verified at source + harness + mutation.

What I verified

Scope-equivalence of the guard-lift (the load-bearing question). The new focus-capturing guard (main.ts:747) carries exactly the four conditions the removed inline block was reachable under:

  • old block: if (screen === 'connected') (821) → else if (state.phase === 'gameover') (843) → if (initialsPhase === 'entering' && state.mode === 'solo')
  • new guard (747): screen === 'connected' && state.phase === 'gameover' && initialsPhase === 'entering' && state.mode === 'solo'

Same predicate, moved earlier — no widening, no narrowing. The only behavioral delta is the intended one: entering-solo-gameover keys now bypass the KeyH/? toggle (and every other global hotkey) instead of falling through to them. For a focused text-entry mode that's correct focus-capture semantics. Versus-gameover with a stale entering phase still falls through (guard requires solo) — identical to before.

Pure extraction. handleInitialsKey() body is byte-identical to the removed inline block (A-Z fill / Backspace edit / Enter commit / Escape abandon). Refactor-invariant holds on both halves: behavior preserved (tests pass) and the diff is move-boilerplate + the new guard only.

preventDefault discipline preserved. Only acted-upon keys are preventDefault'd (browser defaults like F5 untouched); the early return is what suppresses our hotkeys. Matches prior behavior.

Verification (closed loop)

  • npx tsc --noEmit — clean (exit 0).
  • 34/34 nav harness green, CI=1 fresh server (32 prior + 2 new #97 rows). Existing initials-entering × {Enter,Escape,Backspace} TABLE rows still pass through the new guard — earlier routing, no behavior change.
  • Both discriminating mutations reproduced precisely, each reds ONLY its own row (per-manifestation independence):
    • neuter guard (entering → never-match) → capture-row reds ("H must enter the field, not open help"), play-row green → proves the guard captures.
    • broaden guard to screen === 'connected' alone → play-row reds ("H must still toggle help during play"), capture-row green → proves the gameover/entering/solo scope is load-bearing, no over-reach.
  • No render-capture — routing-only, render.ts untouched, no visible-layout change (same decision-tree as #98).

Note on the "intercept all keys is robust regardless" claim

The PR correctly notes only KeyH/? is global-at-gameover today (KeyL/S/W title-gated, game-input playing-gated). Since the guard captures all keys during entering, the fix is robust to any future global-at-gameover binding too — that completeness is a property of the design, not a claim the review needs to gate on. Full capture is the right call for a focused field.

Honest ceiling

This is keyboard-path, fully covered by the headless harness (deterministic, no device-gate). Mobile initials entry uses a separate touch/DOM-input path, untouched by this change — so no on-device confirmation is gated by #97.

Clean fix. No must-fix, no should-consider. Closes #97.

## ✅ APPROVED — #97 initials-entry keyboard focus capture Reviewed at head **a88e536** (post-rebase, on current main: `merge_base == base == a0bd8f3`, mergeable). Synced scratch to head, verified at source + harness + mutation. ### What I verified **Scope-equivalence of the guard-lift (the load-bearing question).** The new focus-capturing guard (main.ts:747) carries *exactly* the four conditions the removed inline block was reachable under: - old block: `if (screen === 'connected')` (821) → `else if (state.phase === 'gameover')` (843) → `if (initialsPhase === 'entering' && state.mode === 'solo')` - new guard (747): `screen === 'connected' && state.phase === 'gameover' && initialsPhase === 'entering' && state.mode === 'solo'` Same predicate, moved earlier — **no widening, no narrowing.** The only behavioral delta is the intended one: entering-solo-gameover keys now bypass the `KeyH`/`?` toggle (and every other global hotkey) instead of falling through to them. For a focused text-entry mode that's correct focus-capture semantics. Versus-gameover with a stale `entering` phase still falls through (guard requires `solo`) — identical to before. **Pure extraction.** `handleInitialsKey()` body is byte-identical to the removed inline block (A-Z fill / Backspace edit / Enter commit / Escape abandon). Refactor-invariant holds on both halves: behavior preserved (tests pass) *and* the diff is move-boilerplate + the new guard only. **preventDefault discipline preserved.** Only acted-upon keys are `preventDefault`'d (browser defaults like F5 untouched); the early `return` is what suppresses *our* hotkeys. Matches prior behavior. ### Verification (closed loop) - `npx tsc --noEmit` — clean (exit 0). - **34/34** nav harness green, `CI=1` fresh server (32 prior + 2 new #97 rows). Existing `initials-entering × {Enter,Escape,Backspace}` TABLE rows still pass through the new guard — earlier routing, no behavior change. - **Both discriminating mutations reproduced precisely, each reds ONLY its own row** (per-manifestation independence): - neuter guard (`entering` → never-match) → **capture-row reds** ("H must enter the field, not open help"), **play-row green** → proves the guard captures. - broaden guard to `screen === 'connected'` alone → **play-row reds** ("H must still toggle help during play"), **capture-row green** → proves the `gameover/entering/solo` scope is load-bearing, no over-reach. - No render-capture — routing-only, `render.ts` untouched, no visible-layout change (same decision-tree as #98). ### Note on the "intercept all keys is robust regardless" claim The PR correctly notes only `KeyH`/`?` is global-at-gameover today (`KeyL`/`S`/`W` title-gated, game-input `playing`-gated). Since the guard captures *all* keys during entering, the fix is robust to any future global-at-gameover binding too — that completeness is a property of the design, not a claim the review needs to gate on. Full capture is the right call for a focused field. ### Honest ceiling This is **keyboard-path**, fully covered by the headless harness (deterministic, no device-gate). Mobile initials entry uses a separate touch/DOM-input path, untouched by this change — so no on-device confirmation is gated by #97. Clean fix. No must-fix, no should-consider. Closes #97.
bosun merged commit 8ab2f0ce79 into main 2026-06-22 18:49:22 +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!107
No description provided.