From 0cc6433d1f8e78dba0292f85dff51b56fc88d72d Mon Sep 17 00:00:00 2001 From: Nabzx Date: Fri, 25 Sep 2026 12:37:52 +0100 Subject: [PATCH 1/2] captureHotkey: a pure isCaptureCancelKey for Escape and Tab (#435) Kept separate from captureHotkeyFromEvent, same reasoning that function already gives for staying framework-free: which key closes capture mode is a different question from which key makes a valid hotkey. Both were previously just "Unsupported key" like any other rejected candidate, with nothing anywhere treating them as a way out. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- src/hotkey/captureHotkey.test.ts | 28 +++++++++++++++++++++++++++- src/hotkey/captureHotkey.ts | 13 +++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/hotkey/captureHotkey.test.ts b/src/hotkey/captureHotkey.test.ts index 12f70f8..5eae99b 100644 --- a/src/hotkey/captureHotkey.test.ts +++ b/src/hotkey/captureHotkey.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { captureHotkeyFromEvent, type CapturedKeyEvent } from "./captureHotkey"; +import { captureHotkeyFromEvent, isCaptureCancelKey, type CapturedKeyEvent } from "./captureHotkey"; import { STANDALONE_CAPS_LOCK_KEY_CODE, STANDALONE_COMMAND_KEY_CODE, @@ -80,3 +80,29 @@ describe("captureHotkeyFromEvent", () => { expect(captureHotkeyFromEvent(keyEvent(event))).toEqual({ ok: false, reason: "Unsupported key" }); }); }); + +// #435: without this, a keyboard-only user who opens capture mode has no +// way out of it - Escape and Tab were both just "Unsupported key" like any +// other rejected candidate, and the component preventDefaults every +// keydown while recording, so Tab's own default (moving focus) never ran +// either. A real keyboard trap, not a hypothetical one. +describe("isCaptureCancelKey", () => { + it.each(["Escape", "Tab"])("treats %s as a cancel, not a candidate hotkey", (code) => { + expect(isCaptureCancelKey({ code })).toBe(true); + }); + + it.each([ + "AltLeft", + "MetaLeft", + "ControlLeft", + "Fn", + "CapsLock", + "F1", + "KeyD", + "ArrowUp", + "ShiftLeft", + "Space", + ])("does not treat %s as a cancel", (code) => { + expect(isCaptureCancelKey({ code })).toBe(false); + }); +}); diff --git a/src/hotkey/captureHotkey.ts b/src/hotkey/captureHotkey.ts index aaf049c..2da8dca 100644 --- a/src/hotkey/captureHotkey.ts +++ b/src/hotkey/captureHotkey.ts @@ -40,3 +40,16 @@ export function captureHotkeyFromEvent(event: CapturedKeyEvent): CaptureResult { return { ok: true, hotkey: { keyCode, modifiers: [] } }; } + +// #435: neither Escape nor Tab is a candidate hotkey - captureHotkeyFromEvent +// already says so via UNSUPPORTED_KEY - but before this, "unsupported" just +// meant "keep waiting for a real one", with no way out. A keyboard-only user +// who opened capture mode had no way out of it at all: Tab doesn't move +// focus (the component preventDefaults every keydown while recording) and +// Escape doesn't cancel - a WCAG "no keyboard trap" violation, not a +// hypothetical one. The two keys need to close capture mode instead, a +// different question from which key makes a valid hotkey, so it's checked +// separately rather than folded into captureHotkeyFromEvent's result shape. +export function isCaptureCancelKey(event: Pick): boolean { + return event.code === "Escape" || event.code === "Tab"; +} From 6f057518c7c85ed0fc36f61f0485f5db8d80e2a3 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Fri, 25 Sep 2026 12:38:12 +0100 Subject: [PATCH 2/2] HotkeySettings: fix the keyboard trap, close capture on blur too (#435) Every keydown was preventDefault'd while recording - including Tab, so its own default (moving focus) never ran either - and there was no Escape handler and no blur handler. A keyboard-only user who opened "Change shortcut" had no way out of it short of successfully producing one of the accepted standalone hotkeys. Escape and Tab now cancel via isCaptureCancelKey - Tab specifically isn't preventDefault'd, so the browser's own focus-move still happens right after cancelling, rather than being cancelled and then also suppressed. A window blur (Cmd-Tabbing away, or any other way the app loses focus mid-capture) cancels too, so capture mode isn't still armed for whatever key happens to land first when the app regains focus. Also added "(Esc to cancel)" to the capture prompt shown on the button while recording - the fix doesn't help much if nobody knows it's there. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- src/HotkeySettings.tsx | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/HotkeySettings.tsx b/src/HotkeySettings.tsx index c8df730..959e490 100644 --- a/src/HotkeySettings.tsx +++ b/src/HotkeySettings.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; -import { captureHotkeyFromEvent, type StoredHotkey } from "./hotkey/captureHotkey"; +import { captureHotkeyFromEvent, isCaptureCancelKey, type StoredHotkey } from "./hotkey/captureHotkey"; import { formatHotkey } from "./hotkey/keycodeMap"; -export const SHORTCUT_CAPTURE_PROMPT = "Press Option, Command, Control, Fn, Caps Lock, or F1–F19…"; +export const SHORTCUT_CAPTURE_PROMPT = "Press Option, Command, Control, Fn, Caps Lock, or F1–F19… (Esc to cancel)"; export const SHORTCUT_GUIDANCE = "F1–F12 require the top row to be in function-key mode. F13–F19 depend on keyboard support. Choose one standalone key: Option, Command, Control, Fn, or Caps Lock. OpenStream cannot reliably detect whether macOS or another app also uses a key. Fn and Caps Lock may not produce a usable event on every keyboard."; @@ -48,7 +48,25 @@ export default function HotkeySettings() { .finally(() => setShortcutChangePending(false)); } + // #435: the only way out of capture mode besides successfully picking a + // hotkey - Escape, Tab, or the whole app losing focus (see the blur + // listener below). Leaves the shortcut untouched, unlike applyShortcut. + function cancelCapture() { + if (!captureOpen) return; + captureOpen = false; + stopNativeCapture(); + setError(null); + setRecording(false); + } + function onKeyDown(event: KeyboardEvent) { + if (isCaptureCancelKey(event)) { + // Tab keeps its normal job - moving focus - Escape has no default + // action worth preserving here. + if (event.code !== "Tab") event.preventDefault(); + cancelCapture(); + return; + } event.preventDefault(); const result = captureHotkeyFromEvent(event); if (!result.ok) { @@ -62,11 +80,17 @@ export default function HotkeySettings() { // than a reliable DOM keydown, so the main process captures it separately. const unsubscribe = window.openstream.settings.onShortcutCaptured(applyShortcut); window.addEventListener("keydown", onKeyDown, true); + // #435: Cmd-Tabbing away (or any other way the window loses focus) + // while capturing shouldn't leave capture mode armed for when it comes + // back - the key that was about to be pressed was almost certainly + // meant for whatever the user switched to. + window.addEventListener("blur", cancelCapture); void window.openstream.settings.startShortcutCapture().catch(() => {}); return () => { captureOpen = false; window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", cancelCapture); unsubscribe(); stopNativeCapture(); };