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([]);
+ });
+});