From c8eb982945793afeaae6bdcbd8c4c89943b14913 Mon Sep 17 00:00:00 2001 From: "Adebanjo Abraham.I" Date: Tue, 28 Jul 2026 14:01:03 +0100 Subject: [PATCH] fix: restore visible focus-visible outline on CommandPalette input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #699 Note on scope: the issue references src/pages/OnboardingTour.tsx and does not exist anywhere in this repo (confirmed via full-text and git history search) — there is no tour/walkthrough/spotlight feature of any kind in this codebase. The literal, universal requirement ("visible keyboard-only focus outlines on every interactive element") is already substantially satisfied globally: src/index.css defines a single @layer focus with *:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px }, reinforced per-component across most existing pages (see src/styles/focus.css and the extensive focus-layer.test.ts suite this repo already maintains for exactly this kind of regression). Since there's no OnboardingTour to fix, I audited every remaining unconditional `outline: none` in the codebase for the one bug class this repo's own focus-layer.test.ts already tests for (see e.g. "Dropdown trigger no longer overrides :focus-visible with inline outline:none"). Two were legitimate design choices with an equally-visible substitute indicator (ParamsBuilder / RequestBodyEditor raw-body textareas use a `.pb-raw-shell:focus-within` / `.rbe-shell:focus-within` wrapper border + box-shadow instead of an outline on the textarea itself — not a bug). One was real: CommandPalette.css is imported directly (unlayered — never wrapped in @layer focus), and `.command-palette-input { outline: none; }` was unconditional, with no :focus-visible restoration anywhere in the file. Per CSS cascade layer rules, an unlayered declaration always beats a layered one regardless of specificity, so nothing in @layer focus could ever restore this input's outline. Every keyboard user tabbing into or typing in the command palette's primary search field (Cmd/Ctrl+K) got zero visible focus indicator — a real WCAG 2.4.7 violation. Fixed by splitting the unconditional outline:none into the same suppress-on-:focus / restore-on-:focus-visible pattern used by the global reset, so it's correct standalone regardless of layer/cascade context. Also added explicit :focus-visible reinforcement for the clear/close buttons, matching this repo's established convention of stating per-component coverage explicitly for audits even where the global rule already technically applies (see focus.css's own header comment). Tests: 4 new source-assertion cases in focus-layer.test.ts, following this repo's own established convention for this exact class of bug (regex assertions against the CSS source — see the existing Dropdown test). Confirms the unconditional outline:none is gone, the :focus-visible restoration exists with the correct accent token, the :focus suppression is conditional not blanket, and the clear/close buttons have explicit reinforcement. npx vitest run src/focus-layer.test.ts src/components/CommandPalette.test.tsx Test Files 2 passed (2) Tests 28 passed (28) --- src/components/CommandPalette.css | 29 ++++++++++++++++++++++++++++- src/focus-layer.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/components/CommandPalette.css b/src/components/CommandPalette.css index da9a221..f438b5e 100644 --- a/src/components/CommandPalette.css +++ b/src/components/CommandPalette.css @@ -62,10 +62,28 @@ color: var(--text); font-size: 16px; font-family: var(--font-family); - outline: none; padding: 4px 0; } +/* + * #699 — this file is unlayered (imported directly, not inside @layer + * focus), so an unconditional `outline: none` here would permanently defeat + * the global `*:focus-visible` ring in src/index.css: unlayered rules always + * beat layered ones regardless of specificity, so no rule inside @layer + * focus could ever restore it. Suppress only on :focus (mouse/programmatic) + * and restore explicitly on :focus-visible (keyboard), mirroring the same + * pattern the global reset uses. + */ +.command-palette-input:focus { + outline: none; +} + +.command-palette-input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} + .command-palette-clear-button, .command-palette-close-button { background: var(--surface-soft); @@ -86,6 +104,15 @@ background: var(--surface); } +/* Explicit reinforcement (the global *:focus-visible ring already applies + here since neither button overrides `outline`; stated explicitly so + accessibility audits don't have to trace the cascade to confirm it). */ +.command-palette-clear-button:focus-visible, +.command-palette-close-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .command-palette-results { flex: 1; overflow-y: auto; diff --git a/src/focus-layer.test.ts b/src/focus-layer.test.ts index 30e157b..ff43595 100644 --- a/src/focus-layer.test.ts +++ b/src/focus-layer.test.ts @@ -100,4 +100,33 @@ describe("@layer focus contract", () => { expect(marketplacePage).not.toMatch(/\.marketplace-filter-button:focus\s*\{/); expect(recentlyActiveRail).not.toMatch(/button:focus\s*\{/); }); + + // ── #699 — CommandPalette: unlayered outline:none permanently defeated the + // global *:focus-visible ring (unlayered CSS always beats @layer rules + // regardless of specificity, so nothing in @layer focus could restore it). + it("CommandPalette input no longer carries an unconditional outline:none", () => { + const css = read("src/components/CommandPalette.css"); + // The old bug: `.command-palette-input { ... outline: none; ... }` with + // no accompanying :focus/:focus-visible split. Only a *conditional* + // `:focus { outline: none }` is allowed now. + expect(css).not.toMatch(/\.command-palette-input\s*\{[^}]*outline:\s*none/); + }); + + it("CommandPalette input restores a visible ring on :focus-visible", () => { + const css = read("src/components/CommandPalette.css"); + expect(css).toMatch( + /\.command-palette-input:focus-visible[\s\S]*?outline:\s*2px solid var\(--accent\)/, + ); + }); + + it("CommandPalette input only suppresses outline on :focus, not unconditionally", () => { + const css = read("src/components/CommandPalette.css"); + expect(css).toMatch(/\.command-palette-input:focus\s*\{\s*outline:\s*none;\s*\}/); + }); + + it("CommandPalette clear/close buttons have explicit focus-visible reinforcement", () => { + const css = read("src/components/CommandPalette.css"); + expect(css).toMatch(/\.command-palette-clear-button:focus-visible/); + expect(css).toMatch(/\.command-palette-close-button:focus-visible/); + }); }); \ No newline at end of file