Files
siftlode/frontend/eslint.config.js
T
peter 113da76c36 fix(design): a user row answers as a whole card — the hover layer under its badges and buttons, a click anywhere opens the account (E28)
The layer was the name button's own and ended at the row's first badge or button. The card now paints
the B3 layer under its content (isolate + a new z-under level), the name button's hit layer spans the
card, and the actions sit above it.
2026-09-25 23:43:26 +02:00

626 lines
31 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",
"SelectTrigger.tsx", // the dropdown trigger (A5)
"FilterSelect.tsx", // its option rows
"Combobox.tsx", // the field's chevron (A4)
]);
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 same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
// file picker has no look (it is hidden behind a Button), so it passes.
const INPUT_PRIMITIVES = new Set([
"Input.tsx",
"NativeControls.tsx",
"PriorityStepper.tsx", // the number box laid over the stepper's own face
]);
const inputPrimitive = {
meta: {
type: "problem",
messages: {
raw: "A raw <{{name}}> outside the field primitives: use ui/Input, Textarea or InnerInput (inside a fieldClass frame), or Checkbox / CheckRow / Slider / ColorInput.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && INPUT_PRIMITIVES.has(file)) return {};
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (name !== "input" && name !== "textarea") return;
const type = node.attributes.find(
(a) => a.type === "JSXAttribute" && a.name.name === "type",
);
const v = type?.value?.type === "Literal" ? type.value.value : null;
if (name === "input" && (v === "file" || v === "hidden")) return;
context.report({ node, messageId: "raw", data: { name } });
},
};
},
};
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
// role table and the control primitives that paint a glass BUTTON face.
const SURFACE_OWNERS = new Set([
"surfaces.ts",
"buttonStyles.ts", // the `glass` button face
"SelectTrigger.tsx",
"fieldStyles.ts", // the header search floats over the page
]);
const GLASS_TIER = /^glass(-card|-menu)?$/;
// A glass surface's depth is its own box-shadow, whose inset top highlight a `shadow-*` utility would
// replace wholesale (the header search and the debug pill lost theirs). A shadow COLOUR only sets a
// variable, and `shadow-float` IS the surface's shadow, so both pass.
const SHADOW_SIZE = /^shadow-(xs|sm|md|lg|xl|2xl|\[.+\]|\(.+\))$/;
const isShadowSize = (w) => SHADOW_SIZE.test(w.slice(w.lastIndexOf(":") + 1));
const isSurfaceRole = (n) =>
n?.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === "SURFACE";
const surfaceRole = {
meta: {
type: "problem",
messages: {
raw: "A bare `{{tier}}` class: use a role from ui/surfaces.ts (SURFACE.panel / drawer / card / menu / popover / tooltip), which brings the radius too.",
shadow:
"`{{shadow}}` on a glass surface replaces its own shadow and drops the top highlight; the role's depth is the intended one.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && SURFACE_OWNERS.has(file)) return {};
if (path.includes("/src/lib/")) return {}; // no classes live there, only prose ("a glass tuner")
const check = (node, text) => {
if (typeof text !== "string") return;
const words = text.split(/\s+/).filter(Boolean);
// A lone word is a value (`variant="glass"`); a class string has company, or is a template
// fragment that a space joins to its neighbour.
if (words.length < 2 && text === text.trim()) return;
const tier = words.find((w) => GLASS_TIER.test(w));
if (tier) context.report({ node, messageId: "raw", data: { tier } });
};
// A role plus a shadow size in one class list: `${SURFACE.x} shadow-lg` or clsx(SURFACE.x, "shadow-lg").
const shadowBeside = (node, parts) => {
if (!parts.some(isSurfaceRole)) return;
for (const part of parts) {
const text =
part.type === "TemplateElement"
? part.value.cooked
: part.type === "Literal" && typeof part.value === "string"
? part.value
: "";
const shadow = text.split(/\s+/).find(isShadowSize);
if (shadow) context.report({ node, messageId: "shadow", data: { shadow } });
}
};
return {
Literal(node) {
check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked);
},
TemplateLiteral(node) {
shadowBeside(node, [...node.quasis, ...node.expressions]);
},
CallExpression(node) {
shadowBeside(node, node.arguments);
},
};
},
};
// 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" });
},
};
},
};
// An <a> whose content is only a glyph is a link that looks like a button (DESIGN.md): `IconLink`, with
// a name, a 24+ box and the icon button's layer. The E28 audit found five bare 14px ones. Text or an
// expression anywhere in its CHILDREN (not its attributes: every link has an `href={…}`) is a text
// link or a card, and passes; a self-closing <a> is a `Trans` slot that i18n fills with text.
const glyphLink = {
meta: {
type: "problem",
messages: {
glyph:
"a glyph-only <a> is a link that looks like a button: use ui/LinkButton's IconLink (a name, a 24+ box, the state layer).",
},
},
create(context) {
const hasText = (children) =>
children.some(
(c) =>
(c.type === "JSXText" && /\S/.test(c.value)) ||
(c.type === "JSXExpressionContainer" && c.expression.type !== "JSXEmptyExpression") ||
((c.type === "JSXElement" || c.type === "JSXFragment") && hasText(c.children)),
);
return {
JSXElement(node) {
const open = node.openingElement;
if (open.name.type !== "JSXIdentifier" || open.name.name !== "a" || open.selfClosing)
return;
if (!hasText(node.children)) context.report({ node: open, messageId: "glyph" });
},
};
},
};
const localRules = {
rules: {
"glyph-link": glyphLink,
"sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"input-primitive": inputPrimitive,
"surface-role": surfaceRole,
"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].";
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
const Z_RAW_MESSAGE =
"z-index off the layer scale: use a named level (z-under, z-base, z-menu, z-chrome, z-panel, z-rail, z-overlay, z-popover, z-tooltip), not z-N or z-[…].";
// A hover that swaps a colour instead of laying the B3 state layer (DESIGN.md Tokens): `bg-card`,
// `bg-border` and `bg-surface` vanish on a light card, an opacity dim reads as "disabled", and
// `bg-(--hover)` restates the `state-layer` utility by hand. `group-hover:` (a reveal on a parent's
// hover, C1) and an edged pill's accent hover (F14) are not matched.
const HOVER_SWAP =
"/(^|[\\s:])hover:(bg-(card|border|surface|muted)(?![\\w-])|bg-\\(--(hover|press)\\)|opacity-)/";
const HOVER_SWAP_MESSAGE =
"hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.";
// A palette colour (DESIGN.md Tokens "only semantic tokens", audit F16): nothing measures `text-amber-500`,
// so a gold that reads on dark ships at 2:1 on light. Black and white are not matched: they are the
// media scrims and the text on them, the same in every theme.
const PALETTE =
"/(^|[\\s:])(text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|divide|caret|shadow)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d/";
const PALETTE_MESSAGE =
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
// A static label with an edge (DESIGN.md "Static vs pressable", audit F29): a <span> drawn round with a
// border reads as a chip, is clicked and does nothing. A label that does nothing is ui/Badge (flat).
// A small `rounded` box (a check mark drawn inside a menu row) is a glyph, not a label: only the label
// radii match.
const EDGED_LABEL = "/^(?=.*(^|\\s)rounded-(full|md|lg|xl|2xl)(\\s|$))(?=.*(^|\\s)border(\\s|$))/";
const EDGED_LABEL_MESSAGE =
"a static label with an edge reads as a pressable chip: use ui/Badge (flat, tonal) for a label that does nothing.";
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`).
const SPLIT_CLASS = "/^[a-z][\\w-]*:[\\w\\[!-]/";
const SPLIT_CLASS_MESSAGE =
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
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/input-primitive": "error",
"local/surface-role": "error",
"local/literal-a11y-text": "error",
"local/glyph-link": "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 `act:hover:` (index.css).",
},
{
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
message:
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
},
// Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an
// interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every
// hover colour of the button and chip primitives was dead that way (E28 audit).
{
selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`,
message: SPLIT_CLASS_MESSAGE,
},
// 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 },
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `Literal[value=${PALETTE}]`, message: PALETTE_MESSAGE },
{ selector: `TemplateElement[value.raw=${PALETTE}]`, message: PALETTE_MESSAGE },
{
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] Literal[value=${EDGED_LABEL}]`,
message: EDGED_LABEL_MESSAGE,
},
{
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] TemplateElement[value.raw=${EDGED_LABEL}]`,
message: EDGED_LABEL_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",
"local/input-primitive": "off",
"local/surface-role": "off",
"local/glyph-link": "off",
},
},
prettier,
);