diff --git a/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs b/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs new file mode 100644 index 000000000..b55629c45 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs @@ -0,0 +1,161 @@ +/** + * Unit tests for the `healthlog/no-raw-palette-color` rule — the four + * detectors (`palette`, `dracula`, `color-props`, `arbitrary-value`), the + * `checks` option that lets the flat config run them at split severities, + * and the documented exemptions (global-error.tsx, themeColor metadata, + * test files, `var(--token)` values). + * + * RuleTester needs a filename inside the enforced roots + * (`src/components/` / `src/app/`) — the rule is scope-gated. + */ + +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "../no-raw-palette-color.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("no-raw-palette-color (default checks)", rule, { + valid: [ + // Semantic tokens never carry a numeric shade. + { + code: 'const c = "text-warning bg-success/10 border-info";', + filename: APP_FILE, + }, + // QR quiet-zone style bare white/black stays legal. + { code: 'const c = "bg-white text-black";', filename: APP_FILE }, + // var(--token) is the sanctioned chart-colour carrier. + { code: '', filename: APP_FILE }, + { code: 'const cfg = { stroke: "var(--chart-1)" };', filename: APP_FILE }, + // Arbitrary values referencing a token are fine. + { code: 'const c = "stroke-[var(--warning)]";', filename: APP_FILE }, + // Non-colour props carrying a hash are not colour literals. + { code: '', filename: APP_FILE }, + // themeColor viewport metadata is exempt by ancestry. + { + code: 'export const viewport = { themeColor: [{ media: "(prefers-color-scheme: dark)", color: "#282a36" }] };', + filename: "/repo/src/app/layout.tsx", + }, + // Dracula utilities do NOT error under the default check set — they + // ride the separate warn-level registration. + { code: 'const c = "text-dracula-green";', filename: APP_FILE }, + // Outside the enforced roots nothing matches. + { code: 'const c = "text-amber-500";', filename: "/repo/src/lib/x.ts" }, + // The global error boundary renders without the token stylesheet. + { + code: 'const c = "text-amber-500";', + filename: "/repo/src/app/global-error.tsx", + }, + // Test files are exempt. + { + code: 'const c = "text-amber-500";', + filename: "/repo/src/components/__tests__/example.test.tsx", + }, + ], + invalid: [ + // (original) raw Tailwind stock palette utility. + { + code: 'const c = "text-amber-500";', + filename: APP_FILE, + errors: [{ messageId: "rawPalette" }], + }, + { + code: "const c = `bg-green-500/10 ${extra}`;", + filename: APP_FILE, + errors: [{ messageId: "rawPalette" }], + }, + // (b) colour-shaped JSX prop with a raw hex. + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + // (b) colour-shaped object property (Recharts config / style objects). + { + code: 'const cfg = { fill: "#8b5cf6" };', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + { + code: '
', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + // (b) hex embedded in a longer value (gradient) still trips. + { + code: '
', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + // (c) Tailwind arbitrary colour values. + { + code: 'const c = "bg-[#282a36]";', + filename: APP_FILE, + errors: [{ messageId: "arbitraryColor" }], + }, + { + code: 'const c = "text-[oklch(0.7_0.1_300)]";', + filename: APP_FILE, + errors: [{ messageId: "arbitraryColor" }], + }, + { + code: 'const c = "border-[rgb(40,42,54)]";', + filename: APP_FILE, + errors: [{ messageId: "arbitraryColor" }], + }, + ], +}); + +ruleTester.run("no-raw-palette-color (dracula check)", rule, { + valid: [ + // Semantic + token forms stay clean under the dracula-only check set. + { + code: 'const c = "text-success bg-warning/10 text-amber-500";', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + }, + // dracula var() references in CSS-in-JS strings are not utilities. + { + code: 'const c = "stroke-[var(--dracula-purple)]";', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + }, + ], + invalid: [ + { + code: 'const c = "text-dracula-green";', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + errors: [{ messageId: "draculaUtility" }], + }, + { + code: 'const c = "bg-dracula-orange/15 border-dracula-purple";', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + errors: [{ messageId: "draculaUtility" }], + }, + { + code: 'const c = cn(isUp ? "text-dracula-cyan" : "text-muted-foreground");', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + errors: [{ messageId: "draculaUtility" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 1119c7d7b..57d105ca5 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -21,5 +21,10 @@ module.exports = { "safe-fetch-required": safeFetchRequired, "api-fetch-required": apiFetchRequired, "no-raw-palette-color": noRawPaletteColor, + // 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 + // the rule header. + "no-dracula-utility": noRawPaletteColor, }, }; diff --git a/eslint-plugins/healthlog/no-raw-palette-color.js b/eslint-plugins/healthlog/no-raw-palette-color.js index 6fe38790a..3dfcffc40 100644 --- a/eslint-plugins/healthlog/no-raw-palette-color.js +++ b/eslint-plugins/healthlog/no-raw-palette-color.js @@ -1,6 +1,6 @@ /** - * @fileoverview ESLint rule — raw Tailwind palette color utilities are - * banned in app UI; route every tone through a semantic token. + * @fileoverview ESLint rule — raw colour literals are banned in app UI; + * route every tone through a semantic token. * * v1.26.0 — the design-consistency wave collapsed the app's colour * vocabulary onto semantic tokens (`text-warning` / `text-info` / @@ -12,18 +12,41 @@ * rule flags those utilities at authoring time and in CI so the tokenised * palette cannot regress. * - * Match: a className fragment - * (text|bg|border|ring|stroke|fill|from|to|via)-- - * where is one of the Tailwind stock hues and is a - * numeric 50…950 shade. The `-` suffix is what pins it to the raw - * palette — bare `bg-white` / `text-black` (QR quiet-zone) and semantic - * tokens (`text-warning`, `bg-success/10`) never carry a numeric shade and - * so never match. + * v1.27.x — the rule grows three checks that closed known bypasses: * - * Scope: `src/components/**` + `src/app/**`. The check inspects string - * literals and template-literal chunks (covering `className="…"`, + * - `palette` (error) — the original check: a className fragment + * `(text|bg|border|ring|stroke|fill|from|to|via)--` + * with a numeric 50…950 shade. Bare `bg-white` / `text-black` (QR + * quiet-zone) and semantic tokens never carry a numeric shade and so + * never match. + * - `color-props` (error) — colour-shaped JS props / object keys + * (`color` / `fill` / `stroke` / `background*`) receiving a string + * that contains a `#hex` / `rgb(` / `hsl(` / `oklch(` literal, e.g. + * `color="#22c55e"` or a Recharts `stroke`. Chart colours are passed + * as `var(--token)` strings instead. The `themeColor` viewport + * metadata is exempt (the PWA chrome colour is read by the browser + * before any stylesheet loads, so it must stay a literal). + * - `arbitrary-value` (error) — Tailwind arbitrary colour values + * (`bg-[#…]`, `text-[oklch(…)]`). `…-[var(--token)]` never matches. + * - `dracula` (WARN, staged) — raw `*-dracula-*` utilities. ~250 + * legacy sites predate the token system; the light-theme overrides in + * `globals.css` defuse their AA failures, so the check ships as a + * warning first (registered separately as + * `healthlog/no-dracula-utility`). It moves to error once the + * semantic sweep (status meaning → `text-success/warning/info/ + * destructive`, brand use → `--brand-*` tokens) has retired the + * legacy sites. + * + * The `checks` option selects which detectors run, so the flat config can + * register the same module twice at different severities (see + * `eslint-plugins/healthlog/index.js` + `eslint.config.mjs`). + * + * Scope: `src/components/**` + `src/app/**`. The string checks inspect + * string literals and template-literal chunks (covering `className="…"`, * `cn("…")`, cva variant maps, and `clsx` calls alike) — a syntactic - * match, mirroring the queryKey-factory / safe-fetch-required rules. + * match, mirroring the queryKey-factory / safe-fetch-required rules. The + * `color-props` check additionally inspects JSX attributes and object + * properties so it can key off the prop NAME. * * Exempt files (narrow, path-suffix matched): * - `src/app/global-error.tsx` — the App-Router global error boundary @@ -53,6 +76,27 @@ const ENFORCED_ROOTS = ["src/components/", "src/app/"]; const RAW_PALETTE_RE = /\b(?:text|bg|border|ring|stroke|fill|from|to|via)-(?:amber|sky|red|green|emerald|blue|orange|yellow|pink|purple|violet|zinc|gray|slate|neutral|stone|rose|lime|teal|cyan|indigo|fuchsia)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/; +// Raw `dracula-*` utility: the pre-token palette accessed directly +// (`text-dracula-green`, `bg-dracula-orange/15`). Same prefix set and +// modifier tolerance as the palette check. +const DRACULA_RE = + /\b(?:text|bg|border|ring|stroke|fill|from|to|via)-dracula-[a-z]+\b/; + +// Tailwind arbitrary colour value: `bg-[#…]`, `text-[rgb(…)]`, +// `border-[oklch(…)]`. Matching on the bracket-open plus a colour-literal +// head keeps `…-[var(--token)]` (the sanctioned escape hatch) clean. +const ARBITRARY_COLOR_RE = /-\[(?:#|rgba?\(|hsla?\(|oklch\()/; + +// Colour-shaped prop / object-key names for the `color-props` check. +// Anchored so `themeColor` / `colorScheme` etc. never match. +const COLOR_PROP_NAME_RE = /^(?:color|fill|stroke|background[A-Za-z]*)$/; + +// A colour literal inside a string value: hex, rgb()/rgba(), hsl()/hsla(), +// oklch(). `var(--token)` strings never match. +const COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(/; + +const DEFAULT_CHECKS = ["palette", "color-props", "arbitrary-value"]; + function toPosix(filename) { return filename.replace(/\\/g, "/"); } @@ -74,18 +118,71 @@ function isEnforced(filename) { return true; } +/** + * The `themeColor` viewport metadata carries per-scheme colour literals + * (`{ media, color }` entries) that the browser reads before any + * stylesheet exists — the one sanctioned inline-colour site. Walk the + * ancestor chain for a `themeColor` property so those entries stay exempt + * without exempting the whole layout file. + */ +function isInsideThemeColor(node) { + for (let cur = node.parent; cur; cur = cur.parent) { + if ( + cur.type === "Property" && + !cur.computed && + ((cur.key.type === "Identifier" && cur.key.name === "themeColor") || + (cur.key.type === "Literal" && cur.key.value === "themeColor")) + ) { + return true; + } + } + return false; +} + +/** Extract the static string content of a Literal / TemplateLiteral value. */ +function staticString(node) { + if (!node) return null; + if (node.type === "Literal" && typeof node.value === "string") { + return node.value; + } + if (node.type === "TemplateLiteral") { + return node.quasis.map((q) => q.value.cooked ?? q.value.raw).join(" "); + } + return null; +} + /** @type {import("eslint").Rule.RuleModule} */ const noRawPaletteColorRule = { meta: { type: "problem", docs: { description: - "Ban raw Tailwind palette color utilities in app UI; route every tone through a semantic token (text-warning / text-info / text-success / text-destructive and their bg / border / opacity forms).", + "Ban raw colour literals in app UI — Tailwind stock palette utilities, raw dracula-* utilities, hex/rgb/hsl/oklch in colour-shaped JS props, and arbitrary-value colours; route every tone through a semantic token.", }, - schema: [], + schema: [ + { + type: "object", + properties: { + checks: { + type: "array", + items: { + enum: ["palette", "dracula", "color-props", "arbitrary-value"], + }, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], messages: { rawPalette: 'Raw Tailwind palette utility "{{match}}" bypasses the semantic token system (drifts off the palette, breaks light/dark parity, often fails AA). Use a token: caution → text-warning / border-warning / bg-warning/10, info → text-info, success → text-success, destructive → text-destructive; SVG accents → stroke-[var(--warning)].', + draculaUtility: + 'Raw dracula utility "{{match}}" reads the pre-token palette directly. Status meaning → text-success / text-warning / text-info / text-destructive (and their bg/border forms); brand identity → a named --brand-* token. Legacy sites survive on the light-theme overrides in globals.css; do not add new ones.', + colorProp: + 'Colour-shaped prop "{{name}}" receives the raw colour literal "{{value}}". Pass a theme token instead — color="var(--success)" / stroke="var(--chart-1)" — so both themes and future token retunes apply.', + arbitraryColor: + 'Arbitrary-value colour utility "{{match}}" hard-codes a tone outside the token system. Use a semantic utility (text-warning, bg-success/10) or a token reference (…-[var(--token)]).', }, }, create(context) { @@ -94,16 +191,56 @@ const noRawPaletteColorRule = { return {}; } + const options = context.options?.[0] ?? {}; + const checks = new Set(options.checks ?? DEFAULT_CHECKS); + function checkText(node, text) { if (typeof text !== "string") return; - const m = RAW_PALETTE_RE.exec(text); - if (m) { - context.report({ - node, - messageId: "rawPalette", - data: { match: m[0] }, - }); + if (checks.has("palette")) { + const m = RAW_PALETTE_RE.exec(text); + if (m) { + context.report({ + node, + messageId: "rawPalette", + data: { match: m[0] }, + }); + } } + if (checks.has("dracula")) { + const m = DRACULA_RE.exec(text); + if (m) { + context.report({ + node, + messageId: "draculaUtility", + data: { match: m[0] }, + }); + } + } + if (checks.has("arbitrary-value")) { + const m = ARBITRARY_COLOR_RE.exec(text); + if (m) { + context.report({ + node, + messageId: "arbitraryColor", + data: { match: m[0] }, + }); + } + } + } + + function checkColorProp(node, name, valueNode) { + if (!checks.has("color-props")) return; + if (!COLOR_PROP_NAME_RE.test(name)) return; + const value = staticString(valueNode); + if (value === null) return; + const m = COLOR_LITERAL_RE.exec(value); + if (!m) return; + if (isInsideThemeColor(node)) return; + context.report({ + node, + messageId: "colorProp", + data: { name, value: m[0] }, + }); } return { @@ -115,6 +252,25 @@ const noRawPaletteColorRule = { TemplateElement(node) { checkText(node, node.value.cooked ?? node.value.raw); }, + JSXAttribute(node) { + if (node.name?.type !== "JSXIdentifier") return; + const valueNode = + node.value?.type === "JSXExpressionContainer" + ? node.value.expression + : node.value; + checkColorProp(node, node.name.name, valueNode); + }, + Property(node) { + if (node.computed) return; + const name = + node.key.type === "Identifier" + ? node.key.name + : node.key.type === "Literal" && typeof node.key.value === "string" + ? node.key.value + : null; + if (!name) return; + checkColorProp(node, name, node.value); + }, }; }, }; diff --git a/eslint.config.mjs b/eslint.config.mjs index 3662faa09..4776807ab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,9 +48,22 @@ const eslintConfig = defineConfig([ // vocabulary onto semantic tokens. Raw Tailwind palette utilities // (`text-amber-500`, `bg-green-500`, `dark:text-red-400`, …) under // src/components + src/app are banned so the tokenised palette can't - // regress. `src/app/global-error.tsx` (renders without the token - // stylesheet) and test files are exempt; see the rule file. - "healthlog/no-raw-palette-color": "error", + // regress. v1.27.x extends the rule with two further error-level + // checks: colour-shaped JS props carrying a raw `#hex`/`rgb(`/`hsl(`/ + // `oklch(` literal (the Recharts `color`/`stroke`/`fill` blind spot), + // and Tailwind arbitrary-value colours (`bg-[#…]`). + // `src/app/global-error.tsx` (renders without the token stylesheet), + // the `themeColor` viewport metadata, and test files are exempt; see + // the rule file. + "healthlog/no-raw-palette-color": [ + "error", + { checks: ["palette", "color-props", "arbitrary-value"] }, + ], + // Staged: raw `*-dracula-*` utilities warn for now — ~250 legacy + // sites predate the token system and the light-theme overrides in + // 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"] }], }, }, ]);