The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.
- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
floor, 44 on touch) replace five chip geometries; FilterChipGroup
renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
round, overlay; 44 on touch); the YT modal bar is on the sm rung with
pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
add) announce checked/mixed; a one-letter tag chip was 21px wide;
descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
control-primitive files is a lint error (contract-tested, seen red).
425 lines
21 KiB
JavaScript
425 lines
21 KiB
JavaScript
import js from "@eslint/js";
|
|
import tseslint from "typescript-eslint";
|
|
import reactHooks from "eslint-plugin-react-hooks";
|
|
import reactRefresh from "eslint-plugin-react-refresh";
|
|
import prettier from "eslint-config-prettier";
|
|
import globals from "globals";
|
|
|
|
// Flat config (ESLint 9). Deliberately NARROW: the high-value rule here is
|
|
// react-hooks/exhaustive-deps — 33 suppressions across the app exist for it, and until this
|
|
// sprint nothing ran to make them mean anything. tsc already owns unused vars, undefined names
|
|
// and types (noUnusedLocals/Parameters + strict), so typescript-eslint's non-type-checked
|
|
// `recommended` is layered on but its overlap with tsc is turned off below to avoid double-reporting
|
|
// and a churny diff. `eslint-config-prettier` is LAST so formatting rules never fight Prettier.
|
|
//
|
|
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
|
|
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
|
|
|
|
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
|
|
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket
|
|
// (lib/messagesSocket.ts). A bare `fetch` elsewhere is a request the client log never sees, which is
|
|
// exactly how the baseline survey found four. Each owner is exempt from its OWN global only.
|
|
const NETWORK_OWNERS = {
|
|
fetch: "src/lib/http.ts",
|
|
XMLHttpRequest: "src/lib/api/core.ts",
|
|
WebSocket: "src/lib/messagesSocket.ts",
|
|
EventSource: null,
|
|
};
|
|
const networkGlobals = (exempt) =>
|
|
Object.entries(NETWORK_OWNERS)
|
|
.filter(([name]) => name !== exempt)
|
|
.map(([name, owner]) => ({
|
|
name,
|
|
message: owner
|
|
? `Use the logged request layer: \`${name}\` belongs to ${owner} only (E22 S4 — a request outside it never reaches the client log).`
|
|
: `No \`${name}\` without a logged owner module (E22 S4).`,
|
|
}));
|
|
const networkProperties = [
|
|
{ object: "window", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
|
|
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
|
|
{
|
|
object: "navigator",
|
|
property: "sendBeacon",
|
|
message: "Use lib/api/core.ts `beacon` (E22 S4).",
|
|
},
|
|
];
|
|
|
|
// A `//` or `/* */` inside JSX children is not a comment — it is TEXT, rendered on the page. One
|
|
// slipped in when a return was wrapped in a fragment (Playlists.tsx) and only a visual check caught
|
|
// it: tsc, eslint and every test stayed green. `eslint-plugin-react` has this rule, but it would bring
|
|
// ~1800 lockfile lines of dependencies for ten lines of logic, so it lives here.
|
|
const jsxCommentText = {
|
|
meta: {
|
|
type: "problem",
|
|
messages: { text: "A comment inside JSX children renders as text. Wrap it in {/* */}." },
|
|
},
|
|
create(context) {
|
|
return {
|
|
JSXText(node) {
|
|
if (/^\s*(\/\/|\/\*)/m.test(node.value)) context.report({ node, messageId: "text" });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
// A raw `<button disabled>` drops a focused keyboard user to <body> the moment its caller disables it
|
|
// (a mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in
|
|
// this app are disabled by their own click. `ui/FocusSafeButton` (and `ui/Button` on top of it)
|
|
// keeps the focus; this is what stops a new raw one from reopening the hole.
|
|
const focusSafeDisabled = {
|
|
meta: {
|
|
type: "problem",
|
|
messages: {
|
|
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Use ui/FocusSafeButton (or ui/Button).",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
JSXOpeningElement(node) {
|
|
if (node.name.type !== "JSXIdentifier" || node.name.name !== "button") return;
|
|
if (node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "disabled"))
|
|
context.report({ node, messageId: "raw" });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
|
|
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
|
|
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
|
|
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
|
|
// side panel, a toast) are built from the primitives like any feature component.
|
|
const BUTTON_PRIMITIVES = new Set([
|
|
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
|
|
"Button.tsx",
|
|
"IconButton.tsx",
|
|
"Pressable.tsx",
|
|
"Chip.tsx",
|
|
"DragHandle.tsx",
|
|
"AnchoredPopover.tsx", // the menu row: floating-ui's roving focus reads its native `disabled`
|
|
"SegmentedToggle.tsx",
|
|
"Tabs.tsx",
|
|
"form.tsx", // Switch
|
|
"PriorityStepper.tsx",
|
|
"SortControl.tsx",
|
|
"FilterSelect.tsx", // the dropdown trigger (A5, S3c)
|
|
"Combobox.tsx", // the field's own chips and chevron (A4, S3c)
|
|
]);
|
|
const buttonPrimitive = {
|
|
meta: {
|
|
type: "problem",
|
|
messages: {
|
|
raw: "A raw <{{name}}> outside the control primitives: use ui/Button (a labelled action), IconButton (a glyph), Pressable (a card or row that is the control), Chip or DragHandle.",
|
|
},
|
|
},
|
|
create(context) {
|
|
const path = context.filename.split("\\").join("/");
|
|
const file = path.slice(path.lastIndexOf("/") + 1);
|
|
if (path.includes("/src/components/ui/") && BUTTON_PRIMITIVES.has(file)) return {};
|
|
return {
|
|
JSXOpeningElement(node) {
|
|
if (node.name.type !== "JSXIdentifier") return;
|
|
const name = node.name.name;
|
|
if (name === "button" || name === "FocusSafeButton")
|
|
context.report({ node, messageId: "raw", data: { name } });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
|
|
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
|
|
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
|
|
// nothing looks at an attribute. A URL-shaped placeholder is not a word, so it passes.
|
|
const A11Y_TEXT = new Set([
|
|
"aria-label",
|
|
"aria-description",
|
|
"aria-placeholder",
|
|
"aria-roledescription",
|
|
"aria-valuetext",
|
|
"title",
|
|
"placeholder",
|
|
"alt",
|
|
]);
|
|
const literalText = (v) => {
|
|
if (!v) return null;
|
|
if (v.type === "Literal") return typeof v.value === "string" ? v.value : null;
|
|
if (v.type !== "JSXExpressionContainer") return null;
|
|
const e = v.expression;
|
|
if (e.type === "Literal") return typeof e.value === "string" ? e.value : null;
|
|
if (e.type === "TemplateLiteral" && e.expressions.length === 0) return e.quasis[0].value.cooked;
|
|
return null;
|
|
};
|
|
const literalA11yText = {
|
|
meta: {
|
|
type: "problem",
|
|
messages: { literal: "`{{name}}` is read or shown to the user: translate it with t()." },
|
|
},
|
|
create(context) {
|
|
return {
|
|
JSXAttribute(node) {
|
|
const name = node.name.type === "JSXIdentifier" ? node.name.name : null;
|
|
if (!name || !A11Y_TEXT.has(name)) return;
|
|
const text = literalText(node.value);
|
|
if (text && /[A-Za-z]{2,}/.test(text) && !/^https?:/.test(text))
|
|
context.report({ node, messageId: "literal", data: { name } });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
// dnd-kit's screen-reader text is hard-coded English and names an item by its id; three of the four
|
|
// sortable lists shipped it that way under a Hungarian UI. `ui/sortableA11y` is the one place that
|
|
// replaces it, and this is what keeps the next sortable list from skipping it.
|
|
const sortableA11y = {
|
|
meta: {
|
|
type: "problem",
|
|
messages: {
|
|
context: "A <DndContext> needs `accessibility={useSortableA11y(...)}` (ui/sortableA11y).",
|
|
item: "Use `useSortableItem` from ui/sortableA11y: it announces the role in the user's language.",
|
|
},
|
|
},
|
|
create(context) {
|
|
const own = context.filename.split("\\").join("/").endsWith("components/ui/sortableA11y.ts");
|
|
return {
|
|
JSXOpeningElement(node) {
|
|
if (node.name.type !== "JSXIdentifier" || node.name.name !== "DndContext") return;
|
|
if (
|
|
!node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "accessibility")
|
|
)
|
|
context.report({ node, messageId: "context" });
|
|
},
|
|
ImportDeclaration(node) {
|
|
if (own || node.source.value !== "@dnd-kit/sortable") return;
|
|
for (const sp of node.specifiers)
|
|
if (sp.type === "ImportSpecifier" && sp.imported.name === "useSortable")
|
|
context.report({ node: sp, messageId: "item" });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const localRules = {
|
|
rules: {
|
|
"sortable-a11y": sortableA11y,
|
|
"jsx-comment-text": jsxCommentText,
|
|
"focus-safe-disabled": focusSafeDisabled,
|
|
"button-primitive": buttonPrimitive,
|
|
"literal-a11y-text": literalA11yText,
|
|
},
|
|
};
|
|
|
|
// A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string.
|
|
// `[\d.]+`, not `\d+`: a fractional `text-[9.5px]` slipped past the integer-only pattern.
|
|
const BOX_PX =
|
|
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
|
|
const BOX_PX_MESSAGE =
|
|
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
|
|
|
export default tseslint.config(
|
|
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
|
{
|
|
files: ["src/**/*.{ts,tsx}"],
|
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
languageOptions: {
|
|
ecmaVersion: 2022,
|
|
globals: { ...globals.browser },
|
|
},
|
|
plugins: {
|
|
"react-hooks": reactHooks,
|
|
"react-refresh": reactRefresh,
|
|
local: localRules,
|
|
},
|
|
linterOptions: {
|
|
reportUnusedDisableDirectives: "error",
|
|
},
|
|
rules: {
|
|
...reactHooks.configs.recommended.rules,
|
|
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
|
|
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were
|
|
// triaged rather than bulk-suppressed, because the two answers are different:
|
|
//
|
|
// react-hooks/refs 217 — OFF, see below.
|
|
// the other 66 — real. They went into eslint-suppressions.json as a ratchet for one
|
|
// sprint and E27 S2 burned the file down to nothing: what survives is
|
|
// an inline directive at the site, each naming the invariant it
|
|
// protects. There is DELIBERATELY no suppressions file any more — a
|
|
// bulk `--suppress-all` would hide the next violation instead of
|
|
// answering it, and it cannot hold a warning-level rule anyway.
|
|
//
|
|
// `refs` is off because it fires on this app's popover architecture, not on its history, so
|
|
// suppressing it would not help the next popover anyone writes. Measured over the 217:
|
|
// 79 are `pop.refs.setReference` / `getReferenceProps` — @floating-ui/react's callback-ref
|
|
// setters, which are the documented way to use it — and 74 more are passing the popover handle
|
|
// down as a prop (`popover={pop}`, `ref={fade.ref}`), which is what `components/ui`'s popover
|
|
// engine is FOR. That is 153 of 217 flagged for having the word `refs` in an object that is not
|
|
// a React ref. The remaining ~64 are genuine render-phase `someRef.current = x` writes and are
|
|
// real (E27 S2 answered the ones the other rules could see, at their sites).
|
|
// Re-evaluate when the rule can: it shipped in 1.0.0 and this looks like over-approximation.
|
|
"react-hooks/refs": "off",
|
|
"local/jsx-comment-text": "error",
|
|
"local/focus-safe-disabled": "error",
|
|
"local/button-primitive": "error",
|
|
"local/literal-a11y-text": "error",
|
|
"local/sortable-a11y": "error",
|
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
|
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
|
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
|
"@typescript-eslint/no-unused-vars": "off",
|
|
"no-undef": "off",
|
|
// R7 S3a eliminated every explicit `any` (api.ts prefs/data bags → Record<string, unknown>;
|
|
// catch clauses → unknown + HttpError narrowing; the YouTube IFrame boundary → lib/youtube.ts
|
|
// types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
|
|
// creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`.
|
|
"@typescript-eslint/no-explicit-any": "error",
|
|
// E22 S4: the client log (lib/log.ts) is the one place diagnostics go — redacted, levelled,
|
|
// bounded. A `console.*` call is none of those, and it is invisible to the user's own log.
|
|
"no-console": "error",
|
|
"no-restricted-globals": ["error", ...networkGlobals(null)],
|
|
"no-restricted-properties": ["error", ...networkProperties],
|
|
// Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`,
|
|
// `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
|
|
"@typescript-eslint/no-unused-expressions": [
|
|
"error",
|
|
{ allowShortCircuit: true, allowTernary: true },
|
|
],
|
|
// Source-shape rules, each with its reason right above it (contract-tested in
|
|
// lib/*.contract.test.ts).
|
|
"no-restricted-syntax": [
|
|
"error",
|
|
// `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.
|
|
{
|
|
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
|
|
message:
|
|
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
|
},
|
|
{
|
|
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
|
|
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 <select>: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.",
|
|
},
|
|
// 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").
|
|
{
|
|
selector: "Literal[value=/text-\\[[\\d.]+px\\]/]",
|
|
message:
|
|
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
|
},
|
|
{
|
|
selector: "TemplateElement[value.raw=/text-\\[[\\d.]+px\\]/]",
|
|
message:
|
|
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
|
},
|
|
// …and the same for a BOX: a `w-[150px]` stays 150px while the text inside it grows with the
|
|
// slider, so the label re-wraps or clips at large sizes. Use the spacing scale (`w-37.5` is
|
|
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
|
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
|
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
|
|
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
|
|
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
|
|
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
|
|
// the first "just this once" import and the directory silently becomes another components/.
|
|
files: ["src/components/ui/**/*.{ts,tsx}"],
|
|
rules: {
|
|
"no-restricted-imports": [
|
|
"error",
|
|
{
|
|
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
|
|
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
|
|
// this rule shipped that way and matched far less than it read like (E10 review round 1).
|
|
patterns: [
|
|
{
|
|
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
|
|
// eslint matches `patterns` gitignore-style, where `../*` also swallows `../../lib/x`.
|
|
regex: String.raw`^\.\./(?!\.\./)`,
|
|
message:
|
|
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
|
},
|
|
{
|
|
// The same file by its OTHER spelling. `../../components/Feed` resolves exactly where
|
|
// `../Feed` does, so the rule above is only half the boundary; `ui/` is carved out
|
|
// because a sibling primitive is the one thing this path may legitimately name.
|
|
regex: String.raw`^\.\./\.\./components/(?!ui/)`,
|
|
message:
|
|
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
|
},
|
|
{
|
|
// `(/|$)` so the BARREL counts too: `../../lib/api` is the spelling every feature
|
|
// component uses, and a `/`-anchored pattern let exactly that one through.
|
|
regex: String.raw`^\.\./\.\./lib/api(/|$)`,
|
|
message:
|
|
"components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
// E11 S3: the same one-way rule one layer down. `lib/` is what components are built ON (hooks,
|
|
// the API slices, pure helpers), so it may not reach back UP into `components/` — not even for a
|
|
// type. That is how the `Column` contract came to be owned by a ui component that three lib
|
|
// helpers had to import from. A piece both layers need moves down into `lib/`.
|
|
files: ["src/lib/**/*.{ts,tsx}"],
|
|
rules: {
|
|
"no-restricted-imports": [
|
|
"error",
|
|
{
|
|
patterns: [
|
|
{
|
|
// Any depth (`lib/api/*` is one level deeper), and `(/|$)` so a bare barrel counts too.
|
|
regex: String.raw`^(\.\./)+components(/|$)`,
|
|
message:
|
|
"lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
// The network owners (see NETWORK_OWNERS), each exempt from its own global only.
|
|
...Object.entries(NETWORK_OWNERS)
|
|
.filter(([, owner]) => owner)
|
|
.map(([name, owner]) => ({
|
|
files: [owner],
|
|
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
|
|
})),
|
|
{
|
|
// The two that ARE the raw button: the primitive itself, and the popover's menu row, whose
|
|
// native `disabled` is what floating-ui's roving focus reads to skip it (see ActionMenu).
|
|
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
|
|
rules: { "local/focus-safe-disabled": "off" },
|
|
},
|
|
{
|
|
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
|
|
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
|
|
// owner's working text, not the product's, so the translate-every-label rule does not apply.
|
|
files: ["src/design-lab/**/*.{ts,tsx}"],
|
|
rules: { "local/literal-a11y-text": "off" },
|
|
},
|
|
{
|
|
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
|
|
// …and render throwaway markup whose labels no user ever hears.
|
|
files: ["src/**/*.test.{ts,tsx}"],
|
|
rules: {
|
|
"no-restricted-globals": "off",
|
|
"no-restricted-properties": "off",
|
|
"local/literal-a11y-text": "off",
|
|
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
|
|
"local/button-primitive": "off",
|
|
},
|
|
},
|
|
prettier,
|
|
);
|