/** * @fileoverview ESLint rule — spacing-scale discipline. Two checks: * * 1. `slotPadding` — no `pt-*` / `pb-*` overrides on the gap-based Card * slots. The Card primitive is gap-based, not padding-based * (`src/components/ui/card.tsx`): the header→body distance comes from * the PARENT flex gap, never from slot padding. A `pb-*` on * `` or a `pt-*` on `` therefore adds on top * of the gap (or is a no-op) and inverts its own intent — the drift * class the sl-001 sweep cleaned (~35 stale overrides). Density is * expressed on the Card itself (``, * the sanctioned compact tier), never on a slot. * * Detection: JSX elements named `CardHeader` / `CardContent` (any * member-expression tail, so `UI.CardHeader` matches too) whose * `className` carries a static `pt-` / `pb-` fragment — including * modifier-prefixed (`md:pb-2`) and arbitrary-value (`pb-[10px]`) forms. * Non-numeric suffixes (`pb-safe`) never match. * * 2. `cardShellOffScale` — no off-scale spacing step on a hand-rolled card * SHELL. The scale (§2) is `1 / 1.5 / 2 / 3 / 4 / 6 / 8 / 10`; the `5` * (20 px) and `7` (28 px) steps and every half-step other than `1.5` * (`2.5`, `3.5`, …) are banned. The shell heuristic (§7.2) keys off the * `bg-card` + `border` pair on ONE element: a `p-5` / `py-2.5` / * `space-y-3.5` / `gap-3.5` / `ml-5` there sits off the scale next to * every sibling `` (which is `p-4 md:p-6`), and reads on the same * viewport. The prefix set is the full spacing family — padding * (`p/px/py/pt/pb/pl/pr`), margin (`m/mx/my/mt/mb/ml/mr`), gap * (`gap/gap-x/gap-y`), and `space-x/space-y` — so the `pl-`/`ml-`/`gap-` * spellings that slipped the earlier `5`-only check are covered. * Deliberately NARROW: it fires only when the same element paints a card * surface, so a justified `pl-5` list-marker inset (no `bg-card`), a * form-body `space-y-5` rhythm (no shell), and a deliberate negative * margin (`-mt-5`, never matched) never trip it. Height (`h-`) is a * dimension, not a scale step (`h-11` tap floor, `h-14`, `h-64` are all * legal), so it is out of scope by design. Hand-rolled shells sweep to * `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces compose * ``. * * Both checks collect strings from the whole `className` subtree, so * `cn("…", cond && "…")`, ternaries, and template literals are all seen. * * Scope: `src/components/**` + `src/app/**`; test files exempt — same * gating as the sibling rules. `src/components/ui/*` primitives compose the * shells themselves and are untouched by construction (they carry no * `bg-card` + `border` + step-5 combination). * * There is deliberately NO allowlist: no current site has a documented * load-bearing reason. If one ever appears it carries an inline * `eslint-disable-next-line` with the reason — visible in review — rather * than a silent registry entry here. * * @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2 + §7. */ "use strict"; // Only enforce inside the app UI roots. const ENFORCED_ROOTS = ["src/components/", "src/app/"]; // A pt-/pb- utility with a numeric step, `px`, or an arbitrary value, // optionally behind modifier prefixes (`md:`, `hover:`, `[.border-b]:`). // `pb-safe` (safe-area helpers) and word-suffixed utilities never match. // // The modifier chain excludes `:` from its chunk class so each chunk ends at // exactly one colon. With `:` inside the class the star was ambiguous — // `(?:[^\s"']*:)*` could carve the same prefix into chunks many ways — and a // long non-matching class string made the engine try them all // (exponential backtracking, CodeQL js/redos). Same strings accepted, // one parse each, linear time. const SLOT_PADDING_RE = /(?:^|\s)(?:[^\s"':]*:)*(p[tb]-(?:\d+(?:\.\d+)?|px|\[[^\]]+\]))(?=\s|$)/; const SLOT_NAMES = new Set(["CardHeader", "CardContent"]); // Card-shell heuristic: the element paints a card surface (`bg-card`) AND a // border. `\bborder\b` matches `border`, `border-border`, `border-b`, // `border-dashed` — any border utility qualifies as a shell. const SHELL_BG_RE = /\bbg-card\b/; const SHELL_BORDER_RE = /\bborder(?:-|\b)/; // An off-scale spacing step on a shell — the banned `5` / `7` integer steps // and any half-step other than `1.5` (`2.5`, `3.5`, …), across the full // spacing prefix family (padding, margin, gap, space), with optional modifier // prefixes (`sm:p-5`). A leading `-` (negative margin) is never matched // because the prefix has to sit at a `(?:^|\s)` / modifier boundary. Height // (`h-`) is a dimension, not a scale step, and is intentionally OUT of scope. const SPACING_PREFIX = "space-x|space-y|gap-x|gap-y|gap|px|py|pt|pb|pl|pr|p|mx|my|mt|mb|ml|mr|m"; // `5` or `7`; or a half-step whose integer part is anything but `1` // (`0.5`, `2.5`…`9.5`, `10.5`+), so the sanctioned `1.5` is left alone. const OFF_SCALE_STEP = "5|7|(?:[02-9]|\\d\\d+)\\.5"; // Modifier chain shaped like SLOT_PADDING_RE's, and linear for the same // reason: `:` is excluded from the chunk class so the star has one parse. const SHELL_OFFSCALE_RE = new RegExp( `(?:^|\\s)(?:[^\\s"':]*:)*((?:${SPACING_PREFIX})-(?:${OFF_SCALE_STEP}))(?=\\s|$)`, ); function toPosix(filename) { return filename.replace(/\\/g, "/"); } function isTestFile(posix) { return ( /\.test\.[cm]?[jt]sx?$/.test(posix) || /\.spec\.[cm]?[jt]sx?$/.test(posix) || posix.includes("/__tests__/") || posix.includes("/__mocks__/") ); } function isEnforced(filename) { const posix = toPosix(filename); if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false; if (isTestFile(posix)) return false; return true; } /** Resolve the rendered-element name (`CardHeader`, `UI.CardHeader` → tail). */ function elementName(openingElement) { const nameNode = openingElement.name; if (nameNode.type === "JSXIdentifier") return nameNode.name; if ( nameNode.type === "JSXMemberExpression" && nameNode.property.type === "JSXIdentifier" ) { return nameNode.property.name; } return null; } /** * Collect every static string reachable inside the className attribute * value: plain literals, template chunks, and the arguments of helper * calls (`cn(…)`, `clsx(…)`), conditionals and logical fallbacks. */ function collectStrings(node, out) { if (!node) return; switch (node.type) { case "Literal": if (typeof node.value === "string") out.push({ node, text: node.value }); return; case "TemplateLiteral": for (const quasi of node.quasis) { out.push({ node: quasi, text: quasi.value.cooked ?? quasi.value.raw }); } for (const expr of node.expressions) collectStrings(expr, out); return; case "CallExpression": for (const arg of node.arguments) collectStrings(arg, out); return; case "ConditionalExpression": collectStrings(node.consequent, out); collectStrings(node.alternate, out); return; case "LogicalExpression": collectStrings(node.left, out); collectStrings(node.right, out); return; case "ArrayExpression": for (const el of node.elements) collectStrings(el, out); return; case "JSXExpressionContainer": collectStrings(node.expression, out); return; default: return; } } /** @type {import("eslint").Rule.RuleModule} */ const spacingScaleRule = { meta: { type: "problem", docs: { description: "Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale spacing step (`5`/`7`/half-steps) on a bg-card+border shell.", }, schema: [], messages: { slotPadding: '"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: .', cardShellOffScale: 'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (UI-STANDARDS §2) and read off next to every sibling (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose .', }, }, create(context) { const filename = context.filename ?? context.getFilename?.(); if (!filename || !isEnforced(filename)) { return {}; } return { JSXOpeningElement(node) { const classNameAttr = node.attributes.find( (attr) => attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "className", ); if (!classNameAttr) return; const strings = []; collectStrings(classNameAttr.value, strings); // Check 1 — pt-/pb- slot padding on the gap-based Card slots. const name = elementName(node); if (name && SLOT_NAMES.has(name)) { for (const { node: strNode, text } of strings) { const m = SLOT_PADDING_RE.exec(text); if (m) { context.report({ node: strNode, messageId: "slotPadding", data: { match: m[1], element: name }, }); } } } // Check 2 — off-scale `5` step on a card shell. Join every collected // string so the `bg-card` + `border` pair and the step-5 utility are // seen together even when split across cn() args, then report once. const joined = strings.map((s) => s.text).join(" "); if (SHELL_BG_RE.test(joined) && SHELL_BORDER_RE.test(joined)) { const m = SHELL_OFFSCALE_RE.exec(joined); if (m) { context.report({ node: classNameAttr, messageId: "cardShellOffScale", data: { match: m[1] }, }); } } }, }; }, }; module.exports = spacingScaleRule;