diff --git a/CLAUDE.md b/CLAUDE.md index f8f7bf40..cb86892c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory | FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals | | FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler | | FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `` | -| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `` (lint-enforced) | | FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals | | FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached | | FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) | diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index a8678c72..4dd46e4e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -212,15 +212,10 @@ export default tseslint.config( "error", { allowShortCircuit: true, allowTernary: true }, ], - // R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT scale - // with the Settings --font-scale slider while its rem neighbours do; use `text-2xs`/`text-3xs` - // or a `text-[Nrem]` arbitrary instead. Matches both plain strings and template-literal chunks; - // rem arbitraries (text-[0.75rem]) don't match (no "px"). + // Source-shape rules, each with its reason right above it (contract-tested in + // lib/*.contract.test.ts). "no-restricted-syntax": [ "error", - // The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a - // longer one or a user's own list a FilterSelect. Six native selects had drifted in; the - // 2026-09 debt burn replaced them, and this keeps the next one out. // `enabled:` sees only the native attribute; a FocusSafeButton that holds the focus is // disabled by aria-disabled, so a bare `enabled:hover:` lit it up on hover as if it worked. { @@ -233,11 +228,18 @@ export default tseslint.config( message: "`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.", }, + // The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a + // longer one or a user's own list a FilterSelect. Six native selects had drifted in; the + // 2026-09 debt burn replaced them, and this keeps the next one out. { selector: "JSXOpeningElement[name.name='select']", message: "No native (owner's - pick, 2026-09-19). The caption is the group's name; the hint rides on the row. */} -
+ pick, 2026-09-19). The caption is the group's name, the hint its description. */} +
setYtCount(Number(v))} options={YT_COUNTS.map((n) => ({ value: String(n), label: String(n) }))} diff --git a/frontend/src/components/PaletteLauncher.dom.test.tsx b/frontend/src/components/PaletteLauncher.dom.test.tsx index 1e19691f..05c7dc29 100644 --- a/frontend/src/components/PaletteLauncher.dom.test.tsx +++ b/frontend/src/components/PaletteLauncher.dom.test.tsx @@ -54,8 +54,9 @@ describe("the command palette's visible entry", () => { it("is a key cap inside the search field, naming the chord the palette row binds", () => { page = "feed"; const isOpen = mount(); - const cap = screen.getByRole("button", { name: "palette.open" }); - expect(cap.textContent).toMatch(/^(Ctrl|⌘) K$/); + const cap = screen.getByRole("button", { name: /^palette\.open \((Ctrl|⌘) K\)$/ }); + // Label in Name: the name carries what the cap shows, so "click Ctrl K" finds it. + expect(cap.getAttribute("aria-label")).toContain(cap.textContent); expect(screen.getByRole("textbox")).toBeTruthy(); fireEvent.click(cap); expect(isOpen()).toBe(true); @@ -65,8 +66,9 @@ describe("the command palette's visible entry", () => { page = "home"; const isOpen = mount(); expect(screen.queryByRole("textbox")).toBeNull(); - const pill = screen.getByRole("button", { name: "palette.open" }); - expect(pill.textContent).toContain("palette.title"); + // Named by what it shows ("Jump to…"), not by a label that replaced it. + const pill = screen.getByRole("button", { name: "palette.title" }); + expect(pill.getAttribute("title")).toBe("palette.open"); fireEvent.click(pill); expect(isOpen()).toBe(true); }); diff --git a/frontend/src/components/PaletteLauncher.tsx b/frontend/src/components/PaletteLauncher.tsx index 49cfb064..ce4cefbd 100644 --- a/frontend/src/components/PaletteLauncher.tsx +++ b/frontend/src/components/PaletteLauncher.tsx @@ -20,6 +20,10 @@ function useCap(): string { }); } +// Each accessible name CONTAINS what the control shows (WCAG 2.5.3, Label in Name): a voice-control +// user says what they see — "click Jump to", "click Ctrl K" — and a name that replaced the visible +// words matched nothing. + /** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it * too; left out of a phone's narrow header, where there is no keyboard to press it on. */ export function PaletteKeycap() { @@ -29,7 +33,7 @@ export function PaletteKeycap() {