Files
siftlode/frontend/eslint.config.js
T
peter 0b24f2d8fa fix(frontend): the chunk guard's logic lives in the guard, so no file sits in two TypeScript projects
The guard's logic was a src/lib file the config project also listed. The app project, which
references the config project, then refuses that source on a clean build (TS6305) — while every
local run reused the referenced project's cached output and stayed green; only the Docker image
build failed. The logic is back inside chunkGuard.ts, its test beside it at the frontend root
(vitest now also collects `*.test.ts` there), and a gate contract fails when the config project's
include reaches into src/ again. The guard was re-proved on a build with a leaking re-export.
2026-10-22 23:48:16 +02:00

886 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
import vitest from "@vitest/eslint-plugin";
import playwright from "eslint-plugin-playwright";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the
// hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this
// config first met are all gone (0 left); what is still suppressed is suppressed inline,
// one site at a time, each naming its invariant. 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.
//
// Gate integrity: every switched-off or narrowed rule here is listed, with its reason and its
// measured count, in docs/gates.md, and lib/gates.contract.test.ts fails when one is not. A rule
// with a single real hit is never switched off — its sites are fixed, or each suppressed inline with
// a `-- <the invariant>` description (eslint-comments/require-description makes that mandatory).
// 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). 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; a global with no owner (null) has no exempt file at all.
const NETWORK_OWNERS = {
fetch: "src/lib/http.ts",
XMLHttpRequest: "src/lib/api/core.ts",
WebSocket: null,
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 — a request outside it never reaches the client log.`
: `No \`${name}\` without a logged owner module.`,
}));
const networkProperties = [
{ object: "window", property: "fetch", message: "Use lib/http.ts `send`." },
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send`." },
{
object: "navigator",
property: "sendBeacon",
message: "Use lib/api/core.ts `beacon`.",
},
];
// 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" });
},
};
},
};
// docs/DESIGN.md "Copy": a date, a number and an alphabetical order are all in the
// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare
// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a
// bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's
// alphabet. So outside those files a `toLocale*String` call or an Intl date/number formatter is refused,
// an `Intl.Collator` outside collate.ts, and a `localeCompare` anywhere.
//
// And in a component, the language itself must come off `useTranslation()`, never the i18n singleton
// (`import i18n from "../i18n"`): the React Compiler memoizes on what it can see, and a singleton read
// is invisible to it — a card kept "2 hr 4 min" and a Plex query its English after a switch to
// Hungarian. The singleton is resolved by SCOPE, so `const { i18n } = useTranslation()` passes.
const localeFormat = {
meta: {
type: "problem",
messages: {
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatNumber, formatDecimal, …), in the UI language.",
order:
"Compare text with compareText (lib/collate.ts), in the UI language — a bare localeCompare sorts in the browser's.",
singleton:
"Take the language from useTranslation() (`const { i18n } = useTranslation()`), not the i18n singleton — the React Compiler cannot see a singleton read, so the value stays in the old language after a switch.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
if (/\.test\.tsx?$/.test(path)) return {};
// lib/format.ts is where the forms live; the Plex player's on-screen clock (lib/plexPlayerPrefs.ts)
// is a clock face with its own 12/24h switch, not a date.
const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path);
const collates = /\/src\/lib\/collate\.ts$/.test(path);
// Every folder that holds components (CLAUDE.md "Architecture shape"), not lib/ below them.
const component = /\/src\/(components|shell|modules|features|watch)\//.test(path);
const importedFromI18n = (node) => {
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
const v = scope.set.get(node.name);
if (v)
return v.defs.some(
(d) => d.type === "ImportBinding" && /\/i18n$|^i18next$/.test(d.parent.source.value),
);
}
return false;
};
return {
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
node,
) {
if (!formats) context.report({ node, messageId: "date" });
},
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat|NumberFormat)$/]"(
node,
) {
if (!formats) context.report({ node, messageId: "date" });
},
"MemberExpression[object.name='Intl'][property.name='Collator']"(node) {
if (!collates) context.report({ node, messageId: "order" });
},
"CallExpression > MemberExpression.callee[property.name='localeCompare']"(node) {
context.report({ node, messageId: "order" });
},
"MemberExpression[property.name='language'][object.type='Identifier']"(node) {
if (component && importedFromI18n(node.object))
context.report({ node, messageId: "singleton" });
},
};
},
};
// 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.
//
// The same rule keeps a raw `<button disabled>` out of the primitives themselves, and out of tests: a
// native disabled drops a focused keyboard user to <body> the moment the button turns disabled (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. (It used to be its own rule, `focus-safe-disabled`.)
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)
]);
// The two that ARE the raw disabled 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).
const NATIVE_DISABLED = new Set(["FocusSafeButton.tsx", "AnchoredPopover.tsx"]);
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.",
role: 'A <{{name}} role="button"> is a hand-built button (its own Enter/Space, no focus-safe disabled, no touch target): use ui/Pressable for a surface that is the control.',
disabled:
"A raw <button disabled> throws a focused user to <body> when it turns disabled. Build the primitive on ui/FocusSafeButton; anywhere else use ui/Button, IconButton or Pressable.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
const ui = path.includes("/src/components/ui/");
// A test renders a raw button as a stand-in trigger or a focus target — not product UI, so only
// the disabled check applies to it.
const exempt = (ui && BUTTON_PRIMITIVES.has(file)) || /\.test\.tsx?$/.test(path);
const nativeDisabled = ui && NATIVE_DISABLED.has(file);
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (
name === "button" &&
!nativeDisabled &&
node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "disabled")
)
context.report({ node, messageId: "disabled" });
else if (exempt) return;
else if (name === "button" || name === "FocusSafeButton")
context.report({ node, messageId: "raw", data: { name } });
// The same button spelled as a div with a role slipped past the rule above (one Plex
// picture did it). A component (capitalised) may take a role for its own reasons.
else if (
/^[a-z]/.test(name) &&
node.attributes.some(
(a) =>
a.type === "JSXAttribute" &&
a.name.name === "role" &&
a.value?.type === "Literal" &&
a.value.value === "button",
)
)
context.report({ node, messageId: "role", data: { name } });
},
};
},
};
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6):
// ~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): 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. An 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" });
},
};
},
};
// The app's layers (CLAUDE.md "Architecture shape"), bottom up: lib/ → components/ui/ → features/ →
// modules/ → shell/. An import may go down, never up — with one door: a module's index.ts, its public
// face, which features/ may read too (the module stores are mounted at the root and read app-wide).
// Anything else of ANOTHER module is reached through that face, or lazily by import(), which is a
// chunk boundary and so the way a page is opened. Measured on the import, resolved to its file, so
// every spelling of a path is the same path. lib/ and ui/ keep their own `no-restricted-imports`
// blocks below; this rule adds the folders above them.
const SRC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "src");
const RANK = { lib: 0, ui: 1, features: 2, modules: 3, shell: 4 };
function placeOf(abs) {
const rel = path.relative(SRC_DIR, abs).split(path.sep).join("/");
if (rel.startsWith("..")) return null;
const parts = rel.replace(/\.(tsx?|jsx?)$/, "").split("/");
const [top, unit] = parts;
if (top === "lib") return { layer: "lib" };
if (top === "components") return unit === "ui" ? { layer: "ui" } : { layer: "other" };
if (top === "features" || top === "modules") {
// `modules/plex` and `modules/plex/index` are the unit's public face.
const face = parts.length === 2 || (parts.length === 3 && parts[2] === "index");
return { layer: top, unit, face };
}
if (top === "shell") return { layer: "shell" };
// main.tsx, watch/, i18n/, content/, design-lab/, the app-wide tests at the root
return { layer: "other", top: parts.length === 1 ? "root" : top };
}
const layers = {
meta: {
type: "problem",
messages: {
up: "{{from}} sits below {{to}}: it may not import from it (`{{spec}}`). Move the shared piece down, or take it as a prop.",
face: "`{{spec}}` is one of {{unit}}'s own files: reach another unit through its index.ts (its public face), or lazily with import().",
},
},
create(context) {
const file = context.filename;
const from = placeOf(file);
// The dev-only lab and the app-wide tests at the root look at everything on purpose.
const free =
!from ||
(from.layer === "other" && from.top === "design-lab") ||
(from.layer === "other" && from.top === "root" && /\.test(kit)?\.|\.testkit\./.test(file));
const check = (node, spec, lazy) => {
if (free || typeof spec !== "string" || !spec.startsWith(".")) return;
const to = placeOf(path.resolve(path.dirname(file), spec));
if (!to || to.layer === "other") return;
const fromRank = from.layer === "other" ? 5 : RANK[from.layer];
if (
to.layer === "shell" &&
from.layer !== "shell" &&
!(from.layer === "other" && from.top === "root")
)
return context.report({
node,
messageId: "up",
data: { from: from.layer, to: "shell", spec },
});
const door = to.layer === "modules" && (to.face || lazy) && from.layer === "features";
if (RANK[to.layer] > fromRank && !door)
return context.report({
node,
messageId: "up",
data: { from: from.layer, to: to.layer, spec },
});
const sameUnit = to.layer === from.layer && to.unit === from.unit;
if (to.layer === "modules" && !sameUnit && !to.face && !lazy)
context.report({ node, messageId: "face", data: { spec, unit: `modules/${to.unit}` } });
};
return {
ImportDeclaration: (n) => check(n.source, n.source.value, false),
ExportNamedDeclaration: (n) => n.source && check(n.source, n.source.value, false),
ExportAllDeclaration: (n) => check(n.source, n.source.value, false),
ImportExpression: (n) => n.source.type === "Literal" && check(n.source, n.source.value, true),
TSImportType: (n) => {
const lit = n.argument?.literal ?? n.argument;
if (lit?.type === "Literal") check(lit, lit.value, false);
},
};
},
};
const localRules = {
rules: {
layers,
"glyph-link": glyphLink,
"sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText,
"button-primitive": buttonPrimitive,
"locale-format": localeFormat,
"input-primitive": inputPrimitive,
"surface-role": surfaceRole,
"literal-a11y-text": literalA11yText,
},
};
// A px-frozen BOX (the no-restricted-syntax block below): an esquery regex, as a string. A box utility
// (`w-[150px]`) or a grid's auto-fill column MINIMUM (`minmax(150px,1fr)`): the text inside grows with
// the slider while the box or the column floor stays, so it re-wraps, clips or cramps at a large text
// size (seen in the grid). `[\d.]+`, not `\d+`: a fractional `text-[9.5px]` slipped past the
// integer-only pattern. A fixed track (`grid-cols-[1fr_240px]`) is left alone, as is VirtualGrid's
// measured `minCol` (a number).
const BOX_PX =
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]|minmax\\([\\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) or rem (minmax(9.375rem,1fr)), not w-[Npx] or minmax(Npx,…).";
// 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 class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`),
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`).
// A clock (`${m}:00`) and a URL (`${u}://`) are not classes.
const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
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).";
// Applies to EVERY linted file: a suppression says which rule and why. A bare
// `eslint-disable-next-line rule` named nothing in four places an audit found, against the
// CLAUDE.md rule that each one names its invariant; a blanket `eslint-disable` names no rule at all.
const suppressionRules = {
plugins: { "@eslint-community/eslint-comments": eslintComments },
linterOptions: { reportUnusedDisableDirectives: "error" },
rules: {
"@eslint-community/eslint-comments/require-description": ["error", { ignore: [] }],
"@eslint-community/eslint-comments/no-unlimited-disable": "error",
},
};
// The two type-aware rules (a curated pair, not strictTypeChecked — ~2000 hits there, none
// real in the sample). A floating promise is a rejection nobody handles: the audit found two real
// ones (the chat dock's device key store, since removed, and VideoEditor's play()). `void` marks a deliberate fire-and-forget
// whose callee reports its own failure. React Query's invalidate/refetch family never rejects
// unless asked to (`throwOnError`), so those calls are known-safe by name.
const typedRules = {
"@typescript-eslint/no-floating-promises": [
"error",
{
allowForKnownSafeCalls: [
{
from: "package",
package: "@tanstack/query-core",
name: [
"invalidateQueries",
"refetchQueries",
"cancelQueries",
"resetQueries",
"prefetchQuery",
"prefetchInfiniteQuery",
],
},
],
},
],
"@typescript-eslint/no-deprecated": "error",
};
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
suppressionRules,
{
files: ["src/**/*.{ts,tsx}"],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser },
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
local: localRules,
},
rules: {
...reactHooks.configs.recommended.rules,
...typedRules,
// The React Compiler's own diagnostics that `recommended` leaves out. Each is a function
// the compiler SKIPS, silently — `panicThreshold` is "none" and there is no logger, so a lint
// error is the only place the skip is said out loud. `todo` is the compiler's "cannot lower
// this shape yet" (all 31 try statements it found were rewritten); the rest had 0 hits when
// switched on, and are tripwires. Three more exist but cannot report anything under
// eslint-plugin-react-hooks 7.1.1 (read in its source and probed), so they are not
// listed: `exhaustive-effect-dependencies` (its validation is compiled off — the classic
// `exhaustive-deps` reports a missing effect dependency), `memoized-effect-dependencies` (nothing
// emits its category) and `rule-suppression` (lint mode hands it no suppression rules). A
// function the compiler skips over a suppression is caught by the coverage contract below.
// `todo` does NOT see every such shape — a conditional, a loop
// or an optional call inside a try skips the function with no lint hit at all — so the full
// per-function picture is src/compilerCoverage.contract.test.ts.
"react-hooks/todo": "error",
"react-hooks/memo-dependencies": "error",
"react-hooks/invariant": "error",
"react-hooks/syntax": "error",
"react-hooks/void-use-memo": "error",
"react-hooks/component-hook-factories": "error",
"react-hooks/capitalized-calls": "error",
"react-hooks/hooks": "error",
"react-hooks/no-deriving-state-in-effects": "error",
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
// React Compiler rule set. On this codebase that landed 283 findings. 66 were fixed or
// suppressed inline, each naming its invariant; there is DELIBERATELY no suppressions file — a
// bulk `--suppress-all` would hide the next violation instead of answering it.
//
// `refs` (the other 217) was once switched OFF as mostly false positives, and that triage was
// wrong: the rule and the React Compiler share one analysis, so every hit was a function the
// compiler SKIPS — 21 popover components among them, and Channels' 0.9 s row menu was the bill.
// Every site was fixed (245 measured) instead of narrowing the rule: the popover
// engine is read through destructuring, render-phase ref writes became `useEffectEvent` /
// `useLatest`. It is on, at error, with no exception.
"react-hooks/refs": "error",
"local/jsx-comment-text": "error",
"local/button-primitive": "error",
"local/locale-format": "error",
"local/input-primitive": "error",
"local/surface-role": "error",
"local/literal-a11y-text": "error",
"local/glyph-link": "error",
"local/sortable-a11y": "error",
"local/layers": "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",
// Every explicit `any` was eliminated (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",
// 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",
// A next-page fetch called bare CANCELS the one in flight and sends the same cursor again;
// our requests ignore the abort, so the server ran both (every feed page 2–3×).
// lib/nextPage.ts NEXT_PAGE makes the second call join the first.
{
selector:
"CallExpression[arguments.length=0]:matches([callee.name='fetchNextPage'], [callee.property.name='fetchNextPage'])",
message:
"fetchNextPage() cancels and re-sends a page already loading: call fetchNextPage(NEXT_PAGE) (lib/nextPage.ts).",
},
// `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 (a design 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.",
},
// A picture that failed to load left the browser's broken-image mark on a card: a bare <img>
// sends a Referer Google's image CDNs refuse, and has no retry and no fallback.
{
selector: "JSXOpeningElement[name.name='img']",
message:
"No bare <img>: use ui/RemoteImage — no Referer, a retry past the browser's negative cache, and a fallback instead of the broken-image mark.",
},
// 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].",
},
{
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].",
},
// …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=${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 },
],
},
},
{
// `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.
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.",
},
],
},
],
},
},
{
// 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 design lab (src/design-lab) 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}"],
plugins: { vitest },
rules: {
// A focused test silently skips the rest of the suite; a skipped one is a test nobody runs
// (vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally).
"vitest/no-focused-tests": "error",
"vitest/no-disabled-tests": "error",
"no-restricted-globals": "off",
"no-restricted-properties": "off",
"local/literal-a11y-text": "off",
"local/input-primitive": "off",
"local/surface-role": "off",
"local/glyph-link": "off",
},
},
{
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
// typed rules as the app; Node globals, since none of it runs in the browser.
files: [
"e2e/**/*.ts",
"*.config.ts",
"reactPlugin.ts",
"devOnlyGuard.ts",
"chunkGuard.ts",
"chunkGuard.test.ts",
],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.node },
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
...typedRules,
// tsc owns these here too (both tsconfig.node.json and e2e/tsconfig.json are strict +
// noUnusedLocals/Parameters).
"@typescript-eslint/no-unused-vars": "off",
"no-undef": "off",
},
},
{
files: ["e2e/**/*.ts"],
plugins: { playwright },
rules: {
"playwright/no-focused-test": "error",
"playwright/no-skipped-test": "error",
},
},
{
// The two plain-JS configs: this file and postcss's.
files: ["*.config.js"],
extends: [js.configs.recommended],
languageOptions: { ecmaVersion: 2022, sourceType: "module", globals: { ...globals.node } },
},
prettier,
);