Split The code is public; the app answers only to the crew.
The rebind panel's wiring has no test: give main.ts's capture lifecycle a DOM harness (or a module) #156
scout/cs:scout/114-the-rebind-panel-s-wiring-has-no-test-gi → main 3 commits fork 2 threads · 2 unresolved
src/client/capture.test.ts added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | /** | |
| 2 | * The capture lifecycle, with no DOM at all — `handle` only asks for the | |
| 3 | * three things a `KeyboardEvent` actually has. What is worth checking here is | |
| 4 | * exactly the trust `main.ts` used to ask for on faith: a capture that starts | |
| 5 | * over rather than piling up, a key that reaches the game once it resolves | |
| 6 | * and never again, and the stop-it-dead ordering that keeps the key that | |
| 7 | * rebinds an action from also driving it. | |
| 8 | */ | |
| 9 | ||
| 10 | import { expect, test } from "bun:test"; | |
| 11 | import { DEFAULT_BINDINGS, type Action } from "./input.ts"; | |
| 12 | import { createCapture, type CaptureEvent } from "./capture.ts"; | |
| 13 | ||
| 14 | /** A keydown as `handle` sees it, with spies on the two calls that matter. */ | |
| 15 | function key(code: string, repeat = false): CaptureEvent & { prevented: boolean; stopped: boolean } { | |
| 16 | const event = { | |
| 17 | code, | |
| 18 | repeat, | |
| 19 | prevented: false, | |
| 20 | stopped: false, | |
| 21 | preventDefault(): void { | |
| 22 | event.prevented = true; | |
| 23 | }, | |
| 24 | stopImmediatePropagation(): void { | |
| 25 | event.stopped = true; | |
| 26 | }, | |
| 27 | }; | |
| 28 | return event; | |
| 29 | } | |
| 30 | ||
| 31 | function harness() { | |
| 32 | let current: Record<Action, string> = DEFAULT_BINDINGS; | |
| 33 | const bound: Record<Action, string>[] = []; | |
| 34 | const capture = createCapture(() => current, (next) => { | |
| 35 | current = next; | |
| 36 | bound.push(next); | |
| 37 | }); | |
| 38 | return { capture, bound, setBindings: (next: Record<Action, string>) => { current = next; } }; | |
| 39 | } | |
| 40 | ||
| 41 | test("idle: a key touches nothing and is not claimed", () => { | |
| 42 | const { capture } = harness(); | |
| 43 | const event = key("KeyG"); | |
| 44 | expect(capture.handle(event)).toBeNull(); | |
| 45 | expect(event.prevented).toBe(false); | |
| 46 | expect(event.stopped).toBe(false); | |
| 47 | expect(capture.pending).toBeNull(); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a pending capture claims the key it resolves on: preventDefault and stopImmediatePropagation both fire", () => { | |
| 51 | const { capture } = harness(); | |
| 52 | capture.start("jump"); | |
| 53 | const event = key("KeyJ"); | |
| 54 | capture.handle(event); | |
| 55 | expect(event.prevented).toBe(true); | |
| 56 | expect(event.stopped).toBe(true); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a bound key resolves the capture, updates the bindings and calls onBound", () => { | |
| 60 | const { capture, bound } = harness(); | |
| 61 | capture.start("jump"); | |
| 62 | const result = capture.handle(key("KeyJ")); | |
| 63 | expect(result).toEqual({ kind: "bound", action: "jump", bindings: { ...DEFAULT_BINDINGS, jump: "KeyJ" } }); | |
| 64 | expect(bound).toEqual([{ ...DEFAULT_BINDINGS, jump: "KeyJ" }]); | |
| 65 | expect(capture.pending).toBeNull(); | |
| 66 | }); | |
| 67 | ||
| 68 | test("Escape cancels without binding anything", () => { | |
| 69 | const { capture, bound } = harness(); | |
| 70 | capture.start("jump"); | |
| 71 | const result = capture.handle(key("Escape")); | |
| 72 | expect(result).toEqual({ kind: "cancelled", action: "jump" }); | |
| 73 | expect(bound).toEqual([]); | |
| 74 | expect(capture.pending).toBeNull(); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a reserved code is rejected with a reason, and nothing is bound", () => { | |
| 78 | const { capture, bound } = harness(); | |
| 79 | capture.start("jump"); | |
| 80 | const result = capture.handle(key("KeyW")); // reserved: the movement fallback | |
| 81 | expect(result?.kind).toBe("rejected"); | |
| 82 | expect(capture.pending).toBeNull(); | |
| 83 | expect(bound).toEqual([]); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a code another action already holds is rejected, naming which one", () => { | |
| 87 | const { capture } = harness(); | |
| 88 | capture.start("jump"); | |
| 89 | const result = capture.handle(key("KeyE")); // DEFAULT_BINDINGS.use | |
| 90 | expect(result).toEqual({ kind: "rejected", action: "jump", reason: expect.stringContaining("Use") }); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a repeat while pending is claimed but leaves the capture waiting for the real key", () => { | |
| 94 | const { capture, bound } = harness(); | |
| 95 | capture.start("jump"); | |
| 96 | const repeat = key("KeyJ", true); | |
| 97 | const result = capture.handle(repeat); | |
| 98 | expect(result).toBeNull(); | |
| 99 | expect(repeat.prevented).toBe(true); // still claimed — a held key must not leak to the game either | |
| 100 | expect(repeat.stopped).toBe(true); | |
| 101 | expect(capture.pending).toBe("jump"); | |
| 102 | ||
| 103 | const real = capture.handle(key("KeyJ")); | |
| 104 | expect(real?.kind).toBe("bound"); | |
| 105 | expect(bound.length).toBe(1); | |
| 106 | }); | |
| 107 | ||
| 108 | test("once resolved, the capture is idle again: the very next key reaches nobody and touches nothing", () => { | |
| 109 | const { capture } = harness(); | |
| 110 | capture.start("jump"); | |
| 111 | capture.handle(key("KeyJ")); | |
| 112 | const next = key("KeyF"); | |
| 113 | expect(capture.handle(next)).toBeNull(); | |
| 114 | expect(next.prevented).toBe(false); | |
| 115 | expect(next.stopped).toBe(false); | |
| 116 | }); | |
| 117 | ||
| 118 | test("starting a new capture replaces a pending one outright: the old action's key no longer resolves anything", () => { | |
| 119 | const { capture, bound } = harness(); | |
| 120 | capture.start("jump"); | |
| 121 | capture.start("duck"); // e.g. a second button clicked before the first key arrived | |
| 122 | expect(capture.pending).toBe("duck"); | |
| 123 | const result = capture.handle(key("KeyJ")); | |
| 124 | expect(result?.action).toBe("duck"); | |
| 125 | expect(bound).toEqual([{ ...DEFAULT_BINDINGS, duck: "KeyJ" }]); | |
| 126 | }); | |
| 127 | ||
| 128 | test("cancel while pending leaves the next key untouched — the reset button, closing the panel, blur, start, resume and toMenu all use this", () => { | |
| 129 | const { capture } = harness(); | |
| 130 | capture.start("jump"); | |
| 131 | capture.cancel(); | |
| 132 | expect(capture.pending).toBeNull(); | |
| 133 | const event = key("KeyJ"); | |
| 134 | expect(capture.handle(event)).toBeNull(); | |
| 135 | expect(event.prevented).toBe(false); | |
| 136 | }); | |
| 137 | ||
| 138 | test("cancel with nothing pending is a no-op, not an error", () => { | |
| 139 | const { capture } = harness(); | |
| 140 | expect(() => capture.cancel()).not.toThrow(); | |
| 141 | expect(capture.pending).toBeNull(); | |
| 142 | }); | |
| 143 | ||
| 144 | test("getBindings is read fresh on every key, not captured at start", () => { | |
| 145 | const { capture, setBindings, bound } = harness(); | |
| 146 | capture.start("jump"); | |
| 147 | setBindings({ ...DEFAULT_BINDINGS, reload: "KeyJ" }); // KeyJ taken elsewhere mid-capture | |
approved — the one blocking finding is fixed, and I confirmed the fix bites rather than just reading it. The freshness test now writes to a different action mid-capture, so only a fresh
I re-ran the same mutation as last round — snapshot The Verified rather than assumed, against the pull's base (
Two things I am not blocking on, for whenever you are next in this file:
Good change. It does what #114 asked, in the shape the issue preferred, and it leaves the rebind panel with a lifecycle that cannot silently eat the next match's first key. |
||
| 148 | const result = capture.handle(key("KeyJ")); | |
| 149 | expect(result).toEqual({ kind: "rejected", action: "jump", reason: expect.stringContaining("Reload") }); | |
| 150 | expect(bound).toEqual([]); | |
| 151 | }); |
src/client/capture.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
x |
||
| 2 | * The rebind panel's capture lifecycle: at most one key is ever being waited | |
| 3 | * for. `main.ts` owns a single persistent window listener, added once in the | |
| 4 | * capturing phase, that forwards every keydown here — `start` and `cancel` | |
| 5 | * only flip a pending flag, so there is no per-capture `addEventListener` to | |
| 6 | * forget a `removeEventListener` for. That forgetting was the bug: a capture | |
| 7 | * left pending past a resume or a new match silently ate, and rebound, the | |
| 8 | * first key the next thing to run saw. | |
| 9 | */ | |
| 10 | ||
| 11 | import { decideBind } from "./bind.ts"; | |
| 12 | import type { Action } from "./input.ts"; | |
| 13 | ||
| 14 | export type CaptureResult = | |
| 15 | | { kind: "bound"; action: Action; bindings: Record<Action, string> } | |
| 16 | | { kind: "cancelled"; action: Action } | |
| 17 | | { kind: "rejected"; action: Action; reason: string }; | |
| 18 | ||
| 19 | /** The one shape of event `handle` needs — a real `KeyboardEvent` satisfies it, and so does a test's plain object. */ | |
| 20 | export interface CaptureEvent { | |
| 21 | code: string; | |
| 22 | repeat: boolean; | |
| 23 | preventDefault(): void; | |
| 24 | stopImmediatePropagation(): void; | |
| 25 | } | |
| 26 | ||
| 27 | export interface Capture { | |
| 28 | /** The action a key is currently being waited for, or `null` when idle. */ | |
| 29 | readonly pending: Action | null; | |
| 30 | /** Begin waiting for `action`'s next key. Replaces whatever was pending first, if anything was. */ | |
| 31 | start(action: Action): void; | |
| 32 | /** Stop waiting, with nothing bound. A no-op when nothing is pending. */ | |
| 33 | cancel(): void; | |
| 34 | /** | |
| 35 | * Every keydown the window sees, forwarded here first. `null` and untouched | |
| 36 | * when idle. While pending, the event is always claimed — `preventDefault` | |
| 37 | * and `stopImmediatePropagation`, so the key that rebinds fire does not also | |
| 38 | * fire it — and the capture resolves unless the key is a repeat of one | |
| 39 | * already held down, which leaves it pending for the key that actually comes next. | |
| 40 | */ | |
| 41 | handle(event: CaptureEvent): CaptureResult | null; | |
| 42 | } | |
| 43 | ||
| 44 | /** `getBindings` is read fresh on every key, so a bind made elsewhere mid-capture is never raced against a stale copy. */ | |
| 45 | export function createCapture( | |
| 46 | getBindings: () => Record<Action, string>, | |
| 47 | onBound: (bindings: Record<Action, string>) => void, | |
| 48 | ): Capture { | |
| 49 | let pending: Action | null = null; | |
| 50 | return { | |
| 51 | get pending(): Action | null { | |
| 52 | return pending; | |
| 53 | }, | |
| 54 | start(action: Action): void { | |
| 55 | pending = action; | |
| 56 | }, | |
| 57 | cancel(): void { | |
| 58 | pending = null; | |
| 59 | }, | |
| 60 | handle(event: CaptureEvent): CaptureResult | null { | |
| 61 | if (pending === null) return null; | |
| 62 | event.preventDefault(); | |
| 63 | event.stopImmediatePropagation(); | |
| 64 | if (event.repeat) return null; | |
| 65 | const action = pending; | |
| 66 | pending = null; | |
| 67 | if (event.code === "Escape") return { kind: "cancelled", action }; | |
| 68 | const outcome = decideBind(action, event.code, getBindings()); | |
| 69 | if (!outcome.ok) return { kind: "rejected", action, reason: outcome.reason }; | |
| 70 | onBound(outcome.bindings); | |
| 71 | return { kind: "bound", action, bindings: outcome.bindings }; | |
| 72 | }, | |
| 73 | }; | |
| 74 | } |
src/client/main.ts +49 −55
| @@ -9,8 +9,9 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { createAudio } from "./audio.ts"; |
| 12 | import { ACTION_LABELS, decideBind, labelForCode } from "./bind.ts"; | |
| 12 | import { ACTION_LABELS, labelForCode } from "./bind.ts"; | |
| 13 | 13 | import { buyMenu, type BuyRow } from "./buy.ts"; |
| 14 | import { createCapture } from "./capture.ts"; | |
| 14 | 15 | import { cuesHeard } from "./earshot.ts"; |
| 15 | 16 | import { attachFullscreen } from "./fullscreen.ts"; |
| 16 | 17 | import { drawHud } from "./hud.ts"; |
| @@ -239,10 +240,27 @@ function boot(): void { | ||
| 239 | 240 | controlsMenu.innerHTML = controlsPanelHtml; |
| 240 | 241 | controlsPause.innerHTML = controlsPanelHtml; |
| 241 | 242 | |
| 243 | // What a captured key does to the map is capture.ts's decision — it only | |
| 244 | // ever holds one pending action and never a listener, so there is nothing | |
| 245 | // here to forget: `refreshBindingButtons` reads `capture.pending` fresh | |
| 246 | // every time, which is what makes cancelling anywhere just work. | |
| 247 | const capture = createCapture( | |
| 248 | () => settings.bindings, | |
| 249 | (bindings) => { | |
| 250 | settings = { ...settings, bindings }; | |
| 251 | controls.setBindings(bindings); | |
| 252 | saveSettings(settings); | |
| 253 | }, | |
| 254 | ); | |
| 255 | // The status line belongs to whichever container's button opened the | |
| 256 | // capture still pending — there are two (menu, pause) with one apiece. | |
| 257 | let captureStatus: HTMLElement | null = null; | |
| 258 | ||
| 242 | 259 | const refreshBindingButtons = (): void => { |
| 243 | 260 | for (const container of [controlsMenu, controlsPause]) { |
| 244 | 261 | for (const button of Array.from(container.querySelectorAll<HTMLButtonElement>("button.bind"))) { |
| 245 | button.textContent = labelForCode(settings.bindings[button.dataset.bind as Action]); | |
| 262 | const action = button.dataset.bind as Action; | |
| 263 | button.textContent = capture.pending === action ? "Press a key…" : labelForCode(settings.bindings[action]); | |
| 246 | 264 | } |
| 247 | 265 | } |
| 248 | 266 | }; |
| @@ -250,64 +268,40 @@ function boot(): void { | ||
| 250 | 268 | |
| 251 | 269 | // At most one capture is ever live: starting a new one, or anything that |
| 252 | 270 | // takes the player off the panel — resuming, starting a match, the window |
| 253 | // losing focus — cancels whatever capture was pending first. Without that | |
| 254 | // a forgotten capture's window listener outlives the click that opened it, | |
| 255 | // silently eating (and rebinding) the first key the next match sees. | |
| 256 | let cancelCapture: (() => void) | null = null; | |
| 271 | // losing focus — cancels whatever was pending first. | |
| 272 | const cancelCapture = (): void => { | |
| 273 | capture.cancel(); | |
| 274 | if (captureStatus !== null) captureStatus.textContent = ""; | |
| 275 | refreshBindingButtons(); | |
| 276 | }; | |
| 257 | 277 | |
| 258 | /** | |
| 259 | * One key, captured with the window itself in the capturing phase so it is | |
| 260 | * seen and stopped dead — `stopImmediatePropagation`, not just | |
| 261 | * `stopPropagation` — before it ever reaches input.ts's own window | |
| 262 | * listener or a second capture on another button: without that, the key | |
| 263 | * that rebinds fire also fires it. | |
| 264 | */ | |
| 265 | function captureBinding(action: Action, button: HTMLButtonElement, status: HTMLElement): void { | |
| 266 | cancelCapture?.(); | |
| 267 | const label = button.textContent; | |
| 268 | const finish = (): void => { | |
| 269 | window.removeEventListener("keydown", onKey, true); | |
| 270 | cancelCapture = null; | |
| 271 | }; | |
| 272 | cancelCapture = () => { | |
| 273 | finish(); | |
| 274 | button.textContent = label; | |
| 275 | }; | |
| 276 | button.textContent = "Press a key…"; | |
| 277 | status.textContent = ""; | |
| 278 | const onKey = (event: KeyboardEvent): void => { | |
| 279 | event.preventDefault(); | |
| 280 | event.stopImmediatePropagation(); | |
| 281 | if (event.repeat) return; | |
| 282 | finish(); | |
| 283 | if (event.code === "Escape") { | |
| 284 | button.textContent = label; | |
| 285 | return; | |
| 286 | } | |
| 287 | const outcome = decideBind(action, event.code, settings.bindings); | |
| 288 | if (!outcome.ok) { | |
| 289 | status.textContent = outcome.reason; | |
| 290 | button.textContent = label; | |
| 291 | return; | |
| 292 | } | |
| 293 | settings = { ...settings, bindings: outcome.bindings }; | |
| 294 | controls.setBindings(settings.bindings); | |
| 295 | saveSettings(settings); | |
| 296 | refreshBindingButtons(); | |
| 297 | }; | |
| 298 | window.addEventListener("keydown", onKey, true); | |
| 299 | } | |
| 278 | // One persistent listener, added once, in the capturing phase so it is | |
| 279 | // seen and stopped dead — `stopImmediatePropagation`, not just | |
| 280 | // `stopPropagation` — before a pending capture's key ever reaches | |
| 281 | // input.ts's own window listener: without that, the key that rebinds fire | |
| 282 | // also fires it. Idle, `capture.handle` touches nothing and this is a pass-through. | |
| 283 | window.addEventListener("keydown", (event) => { | |
| 284 | const result = capture.handle(event); | |
| 285 | if (result === null) return; | |
| 286 | if (captureStatus !== null) captureStatus.textContent = result.kind === "rejected" ? result.reason : ""; | |
| 287 | refreshBindingButtons(); | |
| 288 | }, true); | |
| 300 | 289 | |
| 301 | 290 | for (const container of [controlsMenu, controlsPause]) { |
| 302 | 291 | const status = container.querySelector(".bind-status") as HTMLElement; |
| 303 | 292 | for (const button of Array.from(container.querySelectorAll<HTMLButtonElement>("button.bind"))) { |
| 304 | 293 | const action = button.dataset.bind as Action; |
| 305 | button.addEventListener("click", () => captureBinding(action, button, status)); | |
| 294 | button.addEventListener("click", () => { | |
| 295 | captureStatus = status; | |
| 296 | status.textContent = ""; | |
| 297 | capture.start(action); | |
| 298 | refreshBindingButtons(); | |
| 299 | }); | |
| 306 | 300 | button.addEventListener("keydown", (event) => event.stopPropagation()); |
| 307 | 301 | } |
| 308 | 302 | const reset = container.querySelector(".reset-bindings") as HTMLButtonElement; |
| 309 | 303 | reset.addEventListener("click", () => { |
| 310 | cancelCapture?.(); | |
| 304 | capture.cancel(); | |
| 311 | 305 | settings = { ...settings, bindings: { ...DEFAULT_BINDINGS } }; |
| 312 | 306 | controls.setBindings(settings.bindings); |
| 313 | 307 | saveSettings(settings); |
| @@ -323,10 +317,10 @@ function boot(): void { | ||
| 323 | 317 | const summary = details.querySelector("summary") as HTMLElement; |
| 324 | 318 | summary.addEventListener("keydown", (event) => event.stopPropagation()); |
| 325 | 319 | details.addEventListener("toggle", () => { |
| 326 | if (!details.open) cancelCapture?.(); | |
| 320 | if (!details.open) cancelCapture(); | |
| 327 | 321 | }); |
| 328 | 322 | } |
| 329 | window.addEventListener("blur", () => cancelCapture?.()); | |
| 323 | window.addEventListener("blur", () => cancelCapture()); | |
| 330 | 324 | |
| 331 | 325 | let world: World | null = null; |
| 332 | 326 | let matchId = ""; |
| @@ -605,7 +599,7 @@ function boot(): void { | ||
| 605 | 599 | } |
| 606 | 600 | |
| 607 | 601 | function start(): void { |
| 608 | cancelCapture?.(); | |
| 602 | cancelCapture(); | |
| 609 | 603 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; |
| 610 | 604 | const team = sideField.value === "t" ? "t" : "ct"; |
| 611 | 605 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| @@ -635,13 +629,13 @@ function boot(): void { | ||
| 635 | 629 | $("play").addEventListener("click", start); |
| 636 | 630 | resumeButton.addEventListener("click", () => { |
| 637 | 631 | if (renderer.lost) return; |
| 638 | cancelCapture?.(); | |
| 632 | cancelCapture(); | |
| 639 | 633 | show(pause, false); |
| 640 | 634 | refreshPad(); |
| 641 | 635 | lock(); |
| 642 | 636 | }); |
| 643 | 637 | const toMenu = (): void => { |
| 644 | cancelCapture?.(); | |
| 638 | cancelCapture(); | |
| 645 | 639 | // A match still going when the menu is reached — left from the pause |
| 646 | 640 | // overlay, not from a summary already posted as finished — gets the one |
| 647 | 641 | // post that says so, sent before the world it reads is gone. |
changes_requested — one test in the new suite cannot fail, and it is the one that guards
capture.ts's only stated invariant.(Housekeeping: a stray review comment reading
xoncapture.ts:1is mine — an API probe while I worked out that this endpoint wants an anchor. There is no delete route. Ignore it.)The extraction itself is right, and the right call over a DOM harness — the issue named the module as the better option. The leak class is structurally gone: one persistent capture-phase listener, a bare pending flag, and every site that mutates
pending(start, Reset to defaults, closing the<details>, blur,start(), resume,toMenu) followed by a repaint.refreshBindingButtonsreadingcapture.pendinginstead of a saved label is what makes that hold. I checked all seven sites: none changespendingwithout repainting.input.tsregisters its own keydown in the bubble phase, so the capture-phase interception still wins regardless of boot order — the rebind key cannot also fire the action.I verified the rest locally:
bun run checkis green (tsc + 697 pass), the app boots from this branch and/answers in 7ms with the CSP,X-Content-Type-OptionsandReferrer-Policyfloor intact, the bundle builds with the new module, no new dependency, no server surface touched, no dead code left. The screenshots at 6627777 render correctly on phone and desktop.I also mutation-tested the suite rather than trusting the count. Four of five breakages get caught: dropping
stopImmediatePropagationfails 2 tests, clearingpendingon a repeat fails 1, leavingpendingset after a resolve fails 4, and makingstartrefuse to replace a pending capture fails 1. That part is a real suite.The fifth does not. This line is the whole test:
The mid-capture write is to
jump— the same action being captured — anddecideBindoverwritesjumpeither way. Stale map (jump: "Space") and fresh map (jump: "KeyX") both yield{ ...DEFAULT_BINDINGS, jump: "KeyJ" }, so the assertion is true no matter whengetBindings()is read. I patchedcreateCaptureto snapshotgetBindings()instartand read the snapshot inhandle— precisely the race the doc comment on line 44 says cannot happen — and all 12 tests still passed.Fix this first: write to a different action, so only a fresh read can produce the right answer.
A snapshot taken at
startbindsKeyJhere; a fresh read rejects it as taken. Confirm by making that snapshot mutation locally and watching this one go red before you push.Two notes, neither blocking on its own:
captureStatus(main.ts:257) is set on click and never set back tonull, and a cancel leaves the last rejection reason on screen. That matches the old code, so it is not a regression — but now that the lifecycle has an owner, clearing the status insidecancelCapturewould put the last piece of per-capture bookkeeping where the rest of it lives.404 not foundfrom the proxy on/, not the app's own HTML 404, on every try over several minutes. The screenshots at this head and a local boot both say the app is fine, so I read this as the preview being asleep rather than anything in the diff — worth a glance when you push the fix.Everything else on my list passes: no identity or write path touched, inputs still validated at the boundary, nothing shelled or interpolated, no secret added,
src/tty.tsunaffected (bindings are a browser-local setting it never exposed), and the README needs nothing because no behaviour changed. Fix the one test and this is an approve.