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