From 6088e476a7570e6f8db08b7ba5364df4d6940adf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 25 Sep 2026 23:51:39 +0200 Subject: [PATCH] fix(design): the command palette's active row takes the menus' roving highlight, not an invisible bg-card; the Plex typeahead's hand copy of it goes (E28) The palette row was ~1.07:1 on its glass (B3 names bg-card hovers a violation). ROVING_HIGHLIGHT is exported from AnchoredPopover for the listboxes that are not PopoverItems; a contract holds that no file paints the fill itself and every hand-built option row takes the recipe. --- frontend/src/components/PlexSearchBox.tsx | 8 ++-- .../src/components/ui/AnchoredPopover.tsx | 6 ++- .../components/ui/CommandPalette.dom.test.tsx | 13 ++++++ frontend/src/components/ui/CommandPalette.tsx | 7 ++-- .../src/lib/rovingHighlight.contract.test.ts | 40 +++++++++++++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 frontend/src/lib/rovingHighlight.contract.test.ts diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx index 29e62d2c..74734eee 100644 --- a/frontend/src/components/PlexSearchBox.tsx +++ b/frontend/src/components/PlexSearchBox.tsx @@ -11,6 +11,7 @@ import { useDebounced } from "../lib/useDebounced"; import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; +import { ROVING_HIGHLIGHT } from "./ui/AnchoredPopover"; // The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a // GitHub-style field selector inside the box) and an instant typeahead of the top matches attached @@ -156,10 +157,9 @@ export default function PlexSearchBox() { onMouseEnter={() => setHi(i)} onClick={() => pick(i)} // The pointer moves `hi` too, so the highlight IS the hover: the menus' roving - // recipe (AnchoredPopover's HIGHLIGHT_UNSELECTED), one look for the mouse and the keys. - className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ - hi === i ? "bg-accent/35 text-fg ring-1 ring-inset ring-accent/60" : "" - }`} + // recipe (AnchoredPopover's ROVING_HIGHLIGHT), one look for the mouse and the keys. + data-active={hi === i ? "true" : undefined} + className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ROVING_HIGHLIGHT}`} > {children} diff --git a/frontend/src/components/ui/CommandPalette.dom.test.tsx b/frontend/src/components/ui/CommandPalette.dom.test.tsx index f27e85aa..49450919 100644 --- a/frontend/src/components/ui/CommandPalette.dom.test.tsx +++ b/frontend/src/components/ui/CommandPalette.dom.test.tsx @@ -124,6 +124,19 @@ describe("CommandPalette", () => { ).toContain("page:feed"); }); + // E28 F35: the active row wore `bg-card`, ~1.07:1 on the palette's glass. It is the menus' row now. + it("marks the active row with the menus' roving highlight, and only that row", () => { + open(); + const rows = screen.getAllByRole("option"); + const active = rows.filter((r) => r.hasAttribute("data-active")); + expect(active).toHaveLength(1); + expect(active[0]!.getAttribute("aria-selected")).toBe("true"); + for (const r of rows) { + expect(r.className).toContain("data-active:bg-accent/35"); + expect(r.className).not.toMatch(/(^|\s)bg-card(\s|$)/); + } + }); + // The arrows walk the flat result list, not the sections — crossing a group heading is invisible. it("walks results with the arrows, across group boundaries", () => { open(); diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx index db0aff29..b5211f36 100644 --- a/frontend/src/components/ui/CommandPalette.tsx +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -7,6 +7,7 @@ import { closeTopOverlayThenRun } from "../../lib/history"; import { pageStep, pageTarget } from "../../lib/listPaging"; import { Pressable } from "./Pressable"; import { InnerInput } from "./Input"; +import { ROVING_HIGHLIGHT } from "./AnchoredPopover"; import { fieldClass } from "./fieldStyles"; // The app's jump-to bar: one text field over every place you can go and every app-wide thing you can @@ -270,9 +271,9 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups: if (e.movementX === 0 && e.movementY === 0) return; setPicked(item.id); }} - className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm ${ - item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg" - }`} + // The menus' roving highlight: a `bg-card` row was ~1.07:1 on the palette's glass (E28). + data-active={item.id === activeId ? "true" : undefined} + className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-muted hover:text-fg ${ROVING_HIGHLIGHT}`} > {item.icon && {item.icon}} {item.label} diff --git a/frontend/src/lib/rovingHighlight.contract.test.ts b/frontend/src/lib/rovingHighlight.contract.test.ts new file mode 100644 index 00000000..e422a2bf --- /dev/null +++ b/frontend/src/lib/rovingHighlight.contract.test.ts @@ -0,0 +1,40 @@ +import { readdirSync, readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +// E28 audit F35. The keyboard row of a listbox has one look, ui/AnchoredPopover's `ROVING_HIGHLIGHT` +// (a menu row, a combobox option, the command palette, the Plex typeahead). A listbox that paints its +// own drifted: the palette's `bg-card` row was ~1.07:1 on its glass, and the Plex typeahead carried a +// hand copy of the recipe that would not follow a change to it. +function sources(): Array<[string, string]> { + const walk = (u: URL, prefix: string): Array<[string, string]> => + readdirSync(u, { withFileTypes: true }).flatMap((e) => { + if (e.isDirectory()) return walk(new URL(`${e.name}/`, u), `${prefix}${e.name}/`); + if (!/\.tsx?$/.test(e.name) || e.name.includes(".test.")) return []; + return [ + [`${prefix}${e.name}`, readFileSync(new URL(e.name, u), "utf-8")] as [string, string], + ]; + }); + return walk(new URL("../", import.meta.url), ""); +} + +describe("the roving highlight", () => { + it("is painted only by the recipe in ui/AnchoredPopover", () => { + const copies = sources() + .filter( + ([name]) => name !== "components/ui/AnchoredPopover.tsx" && !name.startsWith("design-lab/"), + ) + .filter(([, src]) => /(^|[\s"`'])bg-accent\/35(?![\w/-])/.test(src)) + .map(([name]) => name); + expect(copies).toEqual([]); + }); + + it("is what every hand-built option row takes", () => { + const own = sources() + .filter(([, src]) => src.includes('role="option"')) + // A PopoverItem row takes the recipe itself (the Combobox's options). + .filter(([, src]) => !src.includes("ROVING_HIGHLIGHT") && !src.includes(" name); + expect(own).toEqual([]); + }); +});