mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(lint): guard the gap-based Card slots against padding overrides
Add healthlog/spacing-scale (error): pt-*/pb-* on CardHeader/CardContent 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. This is the drift class the earlier slot-padding sweep cleaned; the rule keeps it from reaccumulating. Density belongs on the Card itself (gap-2 py-3 md:py-4). No allowlist — an exception, should one ever be load-bearing, carries an inline disable with its reason. Remove the two stragglers the rule caught: pb-1 on the device-score tile and pb-4 md:pb-6 on the intake history list, both stacking dead padding on top of the card gap. RuleTester coverage for the modifier-prefixed, arbitrary-value, cn()/ternary/template and member-expression forms.
This commit is contained in:
1 parent
e9692f0b81
commit
68b4dd08a7
6 files changed
+290
-2
No files matched your search
@@ -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: '<Card className="gap-2 py-3 md:py-4"><CardContent>x</CardContent></Card>',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// Slots without className, or with unrelated classes, are fine.
|
||||
{ code: "<CardHeader />", filename: APP_FILE },
|
||||
{
|
||||
code: '<CardContent className="px-0 space-y-4" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// Other paddings on slots are not this rule's business.
|
||||
{
|
||||
code: '<CardContent className="pl-2 pr-2" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// Safe-area helpers are not scale steps and never match.
|
||||
{
|
||||
code: '<CardContent className="pb-safe" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// pt-/pb- on arbitrary elements stays legal — the contract is about
|
||||
// the gap-based Card slots only.
|
||||
{ code: '<div className="pb-2" />', filename: APP_FILE },
|
||||
// Outside the enforced roots nothing matches.
|
||||
{
|
||||
code: '<CardHeader className="pb-2" />',
|
||||
filename: "/repo/scripts/tool.tsx",
|
||||
},
|
||||
// Test files are exempt.
|
||||
{
|
||||
code: '<CardHeader className="pb-2" />',
|
||||
filename: "/repo/src/components/__tests__/example.test.tsx",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '<CardHeader className="pb-2" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }],
|
||||
},
|
||||
{
|
||||
code: '<CardContent className="pt-0" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }],
|
||||
},
|
||||
// Modifier-prefixed and fractional steps.
|
||||
{
|
||||
code: '<CardHeader className="md:pb-1.5" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }],
|
||||
},
|
||||
// Arbitrary-value padding.
|
||||
{
|
||||
code: '<CardContent className="pt-[10px]" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }],
|
||||
},
|
||||
// Collected through cn() args…
|
||||
{
|
||||
code: '<CardHeader className={cn("pb-3", extra)} />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }],
|
||||
},
|
||||
// …ternaries…
|
||||
{
|
||||
code: '<CardContent className={dense ? "pt-2" : "pt-4"} />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }, { messageId: "slotPadding" }],
|
||||
},
|
||||
// …and template literals.
|
||||
{
|
||||
code: "<CardHeader className={`pb-2 ${extra}`} />",
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }],
|
||||
},
|
||||
// Member-expression component tails match too.
|
||||
{
|
||||
code: '<UI.CardHeader className="pb-2" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
* `<CardHeader>` or a `pt-*` on `<CardContent>` 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 (`<Card className="gap-2 py-3 md:py-4">`,
|
||||
* 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-<n>` / `pb-<n>` 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 `<div>`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: <Card className="gap-2 py-3 md:py-4">.',
|
||||
},
|
||||
},
|
||||
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;
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -121,7 +121,7 @@ export function DeviceScoreTile({
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pb-1">
|
||||
<CardContent className="space-y-3">
|
||||
{isLearning ? (
|
||||
<LearningGate
|
||||
compact
|
||||
|
||||
@@ -238,7 +238,7 @@ export function IntakeHistoryListV2({
|
||||
{t("medications.intakeHistory")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4 md:pb-6">
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
Reference in new issue
Block a user