fix(ui): the palette ranks what you typed, and lib stops leaning on components

Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle
`@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed
matches in pure group order and always highlighted the first one.

- CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint,
  keywords or group name; an item ranks by its worst word, then the sum. The group order stays
  fixed, rows are ranked inside each group, and the best row overall starts highlighted. The
  highlight is held by id and follows the best match until the arrows or the pointer pick a row,
  so a channel list landing mid-typing no longer shifts it.
- A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with
  nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing
  element, and "Nothing matches" sits outside it; a polite status announces the count once the
  typing pauses.
- `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`,
  and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib
  helpers imported the column contract from a ui component, and `playerModalHooks` its types from a
  feature component.
- eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included;
  the boundary contract test proves it fires. CLAUDE.md and the ui README say so.
- keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
This commit is contained in:
2026-09-13 02:14:07 +02:00
parent 8a50538275
commit 78a9324381
25 changed files with 418 additions and 167 deletions
@@ -61,3 +61,36 @@ describe("components/ui/ boundary rule", () => {
expect(found).toEqual([]);
});
});
// E11 S3: one layer down, `lib/` may not import from `components/` at all. The type-only and the
// re-export spellings are in here on purpose — both are how a lib helper most easily leans on a
// component (`lib/sortOptions` imported `Column` from `ui/tableHeader` as a type).
describe("lib/ boundary rule", () => {
const AS_LIB = "src/lib/__boundary_probe.ts";
it("refuses components/ by every spelling: value, type-only, re-export, and from a nested lib dir", async () => {
const source =
`import F from "../components/Feed";\n` +
`import type { Column } from "../components/ui/tableHeader";\n` +
`export type { FilterMap } from "../components/ui/tableHeader";\n` +
`export default F;\n`;
const found = await restrictedIn(source, AS_LIB);
const nested = await restrictedIn(
`import M from "../../components/ui/Modal";\nexport default M;\n`,
"src/lib/api/__boundary_probe.ts",
);
expect(found).toHaveLength(3);
expect(nested).toHaveLength(1);
expect([...found, ...nested].every((m) => m.includes("may not import from components/"))).toBe(
true,
);
});
it("allows lib's own modules and the i18n setup", async () => {
const found = await restrictedIn(
`import { qk } from "./queryKeys";\nimport { api } from "./api";\nimport i18n from "../i18n";\nexport default [qk, api, i18n];\n`,
AS_LIB,
);
expect(found).toEqual([]);
});
});