From ba8118546d9ebf2cb57e0604c3e58124dd721d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 2 Jul 2026 00:16:03 +0200 Subject: [PATCH] build(lint): ban raw palette colors and guard the design contract Add the healthlog/no-raw-palette-color rule so UI code routes color through the semantic tokens instead of Tailwind palette shades, and sweep the remaining offenders (password-strength scale, injection-site accents, redundant dark overrides). Add guard tests for width stability, the single page-header vocabulary, and motion-reduce coverage on indefinite animations. --- eslint-plugins/healthlog/index.js | 9 +- .../healthlog/no-raw-palette-color.js | 122 ++++++++++++++++++ eslint.config.mjs | 7 + .../design-consistency-guards.test.tsx | 92 +++++++++++++ .../motion-reduce-spin-coverage.test.ts | 61 +++++++++ src/components/admin/reminders-section.tsx | 16 +-- src/components/clinician/clinician-view.tsx | 2 +- .../__tests__/intake-history-list-v2.test.tsx | 4 +- .../medications/injection-site-picker.tsx | 2 +- .../medications/intake-history-list-v2.tsx | 2 +- .../medications/log-injection-site-dialog.tsx | 2 +- src/components/ui/password-strength.tsx | 19 +-- 12 files changed, 312 insertions(+), 26 deletions(-) create mode 100644 eslint-plugins/healthlog/no-raw-palette-color.js create mode 100644 src/components/__tests__/design-consistency-guards.test.tsx diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index d5849ae5f..1119c7d7b 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -2,9 +2,10 @@ * @fileoverview Aggregates the project-local `healthlog/*` ESLint rules * into a single plugin object for the flat config. * - * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard. - * - `safe-fetch-required` — outbound fetch must route through safeFetch. - * - `api-fetch-required` — client /api/ calls must route through apiFetch. + * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard. + * - `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. */ "use strict"; @@ -12,11 +13,13 @@ 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"); module.exports = { rules: { "queryKey-factory": queryKeyFactory.rules["queryKey-factory"], "safe-fetch-required": safeFetchRequired, "api-fetch-required": apiFetchRequired, + "no-raw-palette-color": noRawPaletteColor, }, }; diff --git a/eslint-plugins/healthlog/no-raw-palette-color.js b/eslint-plugins/healthlog/no-raw-palette-color.js new file mode 100644 index 000000000..6fe38790a --- /dev/null +++ b/eslint-plugins/healthlog/no-raw-palette-color.js @@ -0,0 +1,122 @@ +/** + * @fileoverview ESLint rule — raw Tailwind palette color utilities 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` / + * `text-success` / `text-destructive` and their `bg-*` / `border-*` / + * opacity forms, plus `stroke-[var(--warning)]` for SVG accents). A raw + * `text-amber-500` / `bg-green-500` / `dark:text-red-400` re-introduces a + * hard-coded Tailwind stock tone that drifts off the token system, breaks + * light/dark parity, and often fails AA against the card surface. This + * 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. + * + * Scope: `src/components/**` + `src/app/**`. The check inspects 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. + * + * Exempt files (narrow, path-suffix matched): + * - `src/app/global-error.tsx` — the App-Router global error boundary + * renders WITHOUT the token stylesheet loaded, so a future edit there + * legitimately needs raw palette / inline colour. + * Test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) are exempt too. + * + * @see src/app/globals.css — the semantic token definitions. + */ + +"use strict"; + +// Files exempt from the rule. Posix-style suffix match against the +// absolute filename via `String#includes`, mirroring the sibling rules. +const EXEMPT_FILES = [ + // Runs without the token stylesheet — see the header note. + "src/app/global-error.tsx", +]; + +// Only enforce inside the app UI roots. +const ENFORCED_ROOTS = ["src/components/", "src/app/"]; + +// Raw Tailwind palette utility: a color property prefix, a stock hue, and +// a numeric 50…950 shade. The `\b` bounds let a `dark:` / `hover:` / group +// modifier prefix and a `/10` opacity suffix ride along while still +// pinning the numeric shade that distinguishes raw palette from tokens. +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/; + +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 (EXEMPT_FILES.some((f) => posix.includes(f))) return false; + if (isTestFile(posix)) return false; + return true; +} + +/** @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).", + }, + schema: [], + 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)].', + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + + 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] }, + }); + } + } + + return { + Literal(node) { + if (typeof node.value === "string") { + checkText(node, node.value); + } + }, + TemplateElement(node) { + checkText(node, node.value.cooked ?? node.value.raw); + }, + }; + }, +}; + +module.exports = noRawPaletteColorRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 560d86560..3662faa09 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -44,6 +44,13 @@ const eslintConfig = defineConfig([ // envelope unwrap, one ApiError shape. Wrapper internals and test // files are exempt; see the rule file for the allowlist. "healthlog/api-fetch-required": "error", + // v1.26.0 — the design-consistency wave locked the app's colour + // 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", }, }, ]); diff --git a/src/components/__tests__/design-consistency-guards.test.tsx b/src/components/__tests__/design-consistency-guards.test.tsx new file mode 100644 index 000000000..3187b2754 --- /dev/null +++ b/src/components/__tests__/design-consistency-guards.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; + +/** + * v1.26.0 — design-consistency guardrails. + * + * These pin the invariants the design-consistency wave established so a + * later edit cannot silently undo them: + * 1. the authenticated shell's scroll container keeps its + * scrollbar-gutter reservation and single centred max-width box (the + * width-stability contract — a 4th width-shift regression must not + * recur); + * 2. the canonical `` renders exactly one, correctly-styled + * `

` (one-H1-per-page vocabulary); + * 3. `` renders its retry affordance so a failed read + * never falls through to an honest-empty state. + */ + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("width stability — auth-shell scroll container", () => { + const source = readFileSync( + join(process.cwd(), "src", "components", "layout", "auth-shell.tsx"), + "utf8", + ); + + it("keeps [scrollbar-gutter:stable] on the scroll
", () => { + // The reserved gutter is what stops the whole viewport widening by a + // scrollbar's width when a short page (e.g. Integrations) hides the + // scroll. Without it the centred container shifts on every route swap. + expect(source).toContain("[scrollbar-gutter:stable]"); + }); + + it("keeps the single centred `mx-auto max-w-screen-xl` container", () => { + // Every authenticated surface shares ONE normalised content box. The + // literal ordering `mx-auto max-w-screen-xl` is the class the shell + // ships; a duplicate or differently-capped box would reintroduce the + // per-route width drift this guard exists to prevent. + expect(source).toContain("mx-auto max-w-screen-xl"); + const occurrences = source.split("mx-auto max-w-screen-xl").length - 1; + expect(occurrences).toBe(1); + }); +}); + +describe(" — one canonical H1", () => { + it("renders exactly one

with the canonical type scale", () => { + const html = render( + , + ); + const h1Count = (html.match(/]*class="[^"]*text-2xl font-bold tracking-tight[^"]*"/, + ); + expect(html).toContain("Dashboard"); + }); + + it("renders no second heading when only a title is supplied", () => { + const html = render(); + expect((html.match(/ — retry affordance", () => { + it("renders a retry button when onRetry is provided", () => { + const html = render( {}} />); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('role="alert"'); + // The retry affordance is a real