#97: capture keyboard focus during initials-entry (suppress global hotkeys) #107
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/97-initials-focus-capture"
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 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/Ware title-gated; game input (actionForCode) isphase==='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)
handleInitialsKey(e).connected && gameover && entering && solo), every key routes to the field and the guard's earlyreturnsuppresses all global hotkeys.Only the keys
handleInitialsKeyacts on arepreventDefault'd (matching prior behaviour — we don't block browser defaults like F5); the earlyreturnis what suppresses our hotkeys.Acceptance criteria
initialsPhase==='entering', letters A-Z route to the field (harness row)showHelpstays false)entering; once it leavesentering, keys fall through normally)Verification (full closed loop)
npx tsc --noEmitclean.initials-entering × {Enter,Escape,Backspace}TABLE rows still pass through the new guard +handleInitialsKey— no behaviour change, just earlier routing.screen==='connected'alone → play-row reds, capture-row green (proves theenteringscope is load-bearing — no over-reach).render.tsuntouched, 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
__navStateinterface + getter (main.ts) and to the harnessNavStatetype (nav.spec.ts) — #106 addsleaderboardLoaded, this addsshowHelp— 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
38ba53f231a88e5361d7✅ 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:
if (screen === 'connected')(821) →else if (state.phase === 'gameover')(843) →if (initialsPhase === 'entering' && state.mode === 'solo')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 staleenteringphase still falls through (guard requiressolo) — 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 earlyreturnis what suppresses our hotkeys. Matches prior behavior.Verification (closed loop)
npx tsc --noEmit— clean (exit 0).CI=1fresh server (32 prior + 2 new #97 rows). Existinginitials-entering × {Enter,Escape,Backspace}TABLE rows still pass through the new guard — earlier routing, no behavior change.entering→ never-match) → capture-row reds ("H must enter the field, not open help"), play-row green → proves the guard captures.screen === 'connected'alone → play-row reds ("H must still toggle help during play"), capture-row green → proves thegameover/entering/soloscope is load-bearing, no over-reach.render.tsuntouched, 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/Wtitle-gated, game-inputplaying-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.