diff --git a/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs b/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs new file mode 100644 index 000000000..6bb22a55c --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs @@ -0,0 +1,112 @@ +/** + * Unit tests for the `healthlog/spacing-scale` rule — pt-* / pb-* slot + * padding on CardHeader/CardContent is banned (the Card is gap-based; + * density lives on the Card itself). Covers modifier prefixes, + * cn()/ternary/template collection, the safe-area non-match, and the + * scope gating shared with the sibling rules. + */ + +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "../spacing-scale.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +const APP_FILE = "/repo/src/components/insights/example.tsx"; + +ruleTester.run("spacing-scale", rule, { + valid: [ + // Density on the Card itself is the sanctioned pattern. + { + code: 'x', + filename: APP_FILE, + }, + // Slots without className, or with unrelated classes, are fine. + { code: "", filename: APP_FILE }, + { + code: '', + filename: APP_FILE, + }, + // Other paddings on slots are not this rule's business. + { + code: '', + filename: APP_FILE, + }, + // Safe-area helpers are not scale steps and never match. + { + code: '', + filename: APP_FILE, + }, + // pt-/pb- on arbitrary elements stays legal — the contract is about + // the gap-based Card slots only. + { code: '
', filename: APP_FILE }, + // Outside the enforced roots nothing matches. + { + code: '', + filename: "/repo/scripts/tool.tsx", + }, + // Test files are exempt. + { + code: '', + filename: "/repo/src/components/__tests__/example.test.tsx", + }, + ], + invalid: [ + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // Modifier-prefixed and fractional steps. + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // Arbitrary-value padding. + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // Collected through cn() args… + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // …ternaries… + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }, { messageId: "slotPadding" }], + }, + // …and template literals. + { + code: "", + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // Member-expression component tails match too. + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 57d105ca5..fe97d8368 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -6,6 +6,7 @@ * - `safe-fetch-required` — outbound fetch must route through safeFetch. * - `api-fetch-required` — client /api/ calls must route through apiFetch. * - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI. + * - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots. */ "use strict"; @@ -14,6 +15,7 @@ const queryKeyFactory = require("./queryKey-factory.js"); const safeFetchRequired = require("./safe-fetch-required.js"); const apiFetchRequired = require("./api-fetch-required.js"); const noRawPaletteColor = require("./no-raw-palette-color.js"); +const spacingScale = require("./spacing-scale.js"); module.exports = { rules: { @@ -21,6 +23,7 @@ module.exports = { "safe-fetch-required": safeFetchRequired, "api-fetch-required": apiFetchRequired, "no-raw-palette-color": noRawPaletteColor, + "spacing-scale": spacingScale, // Same module as `no-raw-palette-color`, registered under a second // name so the flat config can run the `dracula` check at a different // severity (warn) than the error-level checks. See the staged plan in diff --git a/eslint-plugins/healthlog/spacing-scale.js b/eslint-plugins/healthlog/spacing-scale.js new file mode 100644 index 000000000..0cf06c9f8 --- /dev/null +++ b/eslint-plugins/healthlog/spacing-scale.js @@ -0,0 +1,169 @@ +/** + * @fileoverview ESLint rule — 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` attribute carries a static `pt-` / `pb-` fragment — + * including modifier-prefixed (`md:pb-2`) and arbitrary-value + * (`pb-[10px]`) forms. Strings are collected from the whole attribute + * subtree, so `cn("pb-2", …)`, ternaries, and template literals are all + * seen. Non-numeric suffixes (`pb-safe`) never match. + * + * Scope: `src/components/**` + `src/app/**`; test files exempt — same + * gating as the sibling rules. `src/components/ui/card.tsx` itself + * composes raw `
`s and is untouched by construction. + * + * There is deliberately NO allowlist: no current site has a documented + * load-bearing reason for a slot padding override. 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. + */ + +"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. +const SLOT_PADDING_RE = + /(?:^|\s)(?:[^\s"']*:)*(p[tb]-(?:\d+(?:\.\d+)?|px|\[[^\]]+\]))(?=\s|$)/; + +const SLOT_NAMES = new Set(["CardHeader", "CardContent"]); + +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: + "Ban pt-*/pb-* overrides on CardHeader/CardContent — the Card is gap-based; density is expressed via the Card's own gap/py, never via slot padding.", + }, + 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: .', + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + + return { + JSXOpeningElement(node) { + const name = elementName(node); + if (!name || !SLOT_NAMES.has(name)) return; + + 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); + 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 }, + }); + } + } + }, + }; + }, +}; + +module.exports = spacingScaleRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 4776807ab..2ef764ed1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -64,6 +64,10 @@ const eslintConfig = defineConfig([ // globals.css keep them readable. Moves to error once the semantic // sweep has retired the legacy sites (see the rule header). "healthlog/no-dracula-utility": ["warn", { checks: ["dracula"] }], + // The Card primitive is gap-based; pt-/pb- on CardHeader/CardContent + // fights the gap and re-opens the sl-001 drift class. Density lives + // on the Card itself (`gap-2 py-3 md:py-4`). See the rule header. + "healthlog/spacing-scale": "error", }, }, ]); diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx index 40a10fd9c..178617bb1 100644 --- a/src/components/insights/device-score-tile.tsx +++ b/src/components/insights/device-score-tile.tsx @@ -121,7 +121,7 @@ export function DeviceScoreTile({ ) : null} - + {isLearning ? ( - + {isLoading ? (