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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-02 00:16:03 +02:00
1 parent fda0e504f0
commit ba8118546d
12 files changed
+312 -26

No files matched your search

+6 -3
View File
@@ -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,
},
};
@@ -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)-<palette>-<step>
* where <palette> is one of the Tailwind stock hues and <step> is a
* numeric 50…950 shade. The `-<step>` 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;
+7
View File
@@ -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",
},
},
]);
@@ -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 `<PageHeader>` renders exactly one, correctly-styled
* `<h1>` (one-H1-per-page vocabulary);
* 3. `<QueryErrorCard>` 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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
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 <main>", () => {
// 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("<PageHeader> — one canonical H1", () => {
it("renders exactly one <h1> with the canonical type scale", () => {
const html = render(
<PageHeader title="Dashboard" description="Your day at a glance" />,
);
const h1Count = (html.match(/<h1\b/g) ?? []).length;
expect(h1Count).toBe(1);
// The H1 carries the fixed header type scale used app-wide.
expect(html).toMatch(
/<h1[^>]*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(<PageHeader title="Settings" />);
expect((html.match(/<h1\b/g) ?? []).length).toBe(1);
expect(html).not.toContain("<h2");
});
});
describe("<QueryErrorCard> — retry affordance", () => {
it("renders a retry button when onRetry is provided", () => {
const html = render(<QueryErrorCard onRetry={() => {}} />);
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('role="alert"');
// The retry affordance is a real <button> so a failed read is
// recoverable in-place.
expect(html).toContain("<button");
expect(html).toContain("Retry");
});
it("omits the retry button when no onRetry is supplied", () => {
const html = render(<QueryErrorCard />);
expect(html).toContain('data-slot="query-error-card"');
expect(html).not.toContain("<button");
});
});
@@ -65,6 +65,67 @@ describe("motion-reduce coverage — every animate-spin pairs with motion-reduce
});
});
/**
* v1.26.0 — indefinite-animation reduced-motion sweep.
*
* `animate-pulse` / `animate-bounce` / `animate-ping` are Tailwind's
* INDEFINITE keyframe utilities: unlike a one-shot entrance, they loop
* until the element unmounts. Any such site that runs for an unbounded
* time (a typing indicator, a "listening" mic pulse, a live-status dot)
* must pair with `motion-reduce:animate-none` so a motion-sensitive user
* sees a static element instead of a perpetual throb.
*
* Allowlisted: load-terminating skeletons. A `<Skeleton>` / chart
* placeholder animates only while data is in flight and then unmounts, so
* its pulse is transient rather than perpetual; those files own their own
* reduced-motion story (and today still carry the modifier anyway). The
* allowlist is the escape hatch for that one legitimate class of use — an
* indefinite site elsewhere still has to carry the modifier.
*
* The check is textual and skips comment lines, mirroring the
* `animate-spin` sweep above.
*/
describe("motion-reduce coverage — indefinite pulse/bounce/ping pairs with motion-reduce:animate-none", () => {
const files = collectTsxFiles(ROOT);
// Load-terminating skeleton surfaces: their animation is bounded by the
// data-load lifecycle, so they are exempt from the perpetual-animation
// rule. Path-suffix matched against the absolute filename.
const SKELETON_ALLOWLIST = [
"src/components/ui/skeleton.tsx",
"src/components/charts/chart-skeleton.tsx",
];
const INDEFINITE_RE = /animate-(pulse|bounce|ping)/;
it("every indefinite animation site declares motion-reduce:animate-none", () => {
const violations: string[] = [];
for (const file of files) {
const posix = file.replace(/\\/g, "/");
if (SKELETON_ALLOWLIST.some((f) => posix.includes(f))) continue;
const src = readFileSync(file, "utf8");
const lines = src.split("\n");
lines.forEach((line, idx) => {
if (!INDEFINITE_RE.test(line)) return;
// Skip comment / JSDoc prose that merely names the utility.
const trimmed = line.trim();
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
if (!line.includes("motion-reduce:animate-none")) {
violations.push(`${file}:${idx + 1}: ${trimmed}`);
}
});
}
expect(
violations,
`The following indefinite animate-pulse/bounce/ping sites are missing ` +
`motion-reduce:animate-none. Append the modifier so motion-sensitive ` +
`users see a static element (or allowlist the file if it is a ` +
`load-terminating skeleton):\n` +
violations.join("\n"),
).toEqual([]);
});
});
/**
* v1.11.3 — `.animate-insight-in` reduced-motion guard.
*
+8 -8
View File
@@ -81,10 +81,10 @@ function NotificationHealthPanel() {
>
<span className="font-medium">{c.channel}</span>
<span className="flex items-center gap-3 text-xs">
<span className="text-success dark:text-green-400">
<span className="text-success">
{t("admin.notificationHealth.ok")}: {c.ok}
</span>
<span className="text-destructive dark:text-red-400">
<span className="text-destructive">
{t("admin.notificationHealth.error")}: {c.error}
</span>
<span className="text-muted-foreground">
@@ -323,9 +323,9 @@ export function RemindersSection() {
{testNotification.data.results.map((r, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
{r.success ? (
<CheckCircle2 className="text-success h-3.5 w-3.5 shrink-0 dark:text-green-400" />
<CheckCircle2 className="text-success h-3.5 w-3.5 shrink-0" />
) : (
<XCircle className="text-destructive h-3.5 w-3.5 shrink-0 dark:text-red-400" />
<XCircle className="text-destructive h-3.5 w-3.5 shrink-0" />
)}
<span className="font-medium">{r.channel}</span>
{r.error && (
@@ -371,11 +371,11 @@ export function RemindersSection() {
{med.schedules.map((sched, j) => {
const statusColor =
sched.status === "open"
? "text-success dark:text-green-400"
? "text-success"
: sched.status === "threshold"
? "text-warning dark:text-yellow-400"
? "text-warning"
: sched.status === "missed"
? "text-destructive dark:text-red-400"
? "text-destructive"
: sched.status === "skipped"
? "text-muted-foreground"
: "";
@@ -392,7 +392,7 @@ export function RemindersSection() {
</span>
<span className={statusColor}>{sched.label}</span>
{sched.notificationSent && (
<span className="text-success flex shrink-0 items-center gap-0.5 dark:text-green-400">
<span className="text-success flex shrink-0 items-center gap-0.5">
<Bell className="h-3 w-3" />
{t("admin.reminderCheckNotifSent")}
</span>
+1 -1
View File
@@ -205,7 +205,7 @@ export function ClinicianView({
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
{wellness.length > 0 ? (
<section className="rounded-lg border border-dashed border-amber-500/50 bg-amber-500/5 p-5">
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-5">
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
{t("clinicianView.wellness.title")}
</h2>
@@ -184,10 +184,10 @@ describe("<IntakeHistoryListV2> — populated rows", () => {
expect(html).toContain("lucide-check");
expect(html).toContain("lucide-skip-forward");
// The green styling pins the taken branch visually so a future
// The success styling pins the taken branch visually so a future
// refactor cannot silently collapse the two chips back into one
// neutral badge.
expect(html).toMatch(/bg-green-500\/20[^"]*text-green-400/);
expect(html).toMatch(/bg-success\/15[^"]*text-success/);
// Skipped rows render as a muted outline chip — never as the
// secondary "Eingenommen" badge.
@@ -252,7 +252,7 @@ export function InjectionSitePicker({
cy={coord.y}
r="7"
fill="none"
className="stroke-amber-500"
className="stroke-[var(--warning)]"
strokeWidth="1.2"
/>
)}
@@ -360,7 +360,7 @@ export function IntakeHistoryListV2({
{isTaken ? (
<Badge
variant="secondary"
className="bg-success/15 text-success gap-1 text-xs dark:bg-green-500/20 dark:text-green-400"
className="bg-success/15 text-success gap-1 text-xs"
>
<Check aria-hidden="true" className="h-3 w-3" />
{t("medications.intakeHistoryStatusTaken")}
@@ -152,7 +152,7 @@ export function LogInjectionSiteDialog({
cy="8"
r="6"
fill="none"
className="stroke-amber-500"
className="stroke-[var(--warning)]"
strokeWidth="1.8"
/>
</svg>
+10 -9
View File
@@ -10,16 +10,17 @@ interface PasswordStrengthProps {
minLength?: number;
}
// The bar fill stays vivid (it's a non-text indicator), but the label text
// routes through the AA-tuned semantic tokens — the raw Tailwind 500 tones
// failed contrast against the white card. Weakest is destructive, the middle
// band warning, the strong band success.
// Both the bar fill and the label route through the AA-tuned semantic tokens
// — the raw Tailwind 500 tones failed contrast against the white card and
// drift off the app's token vocabulary. Weakest two bands are destructive,
// the middle band warning, the strong two bands success; fill and text share
// the same tone per score so the meter reads as one signal.
const SCORE_COLORS = [
{ color: "bg-red-500", textColor: "text-destructive" },
{ color: "bg-orange-500", textColor: "text-destructive" },
{ color: "bg-yellow-500", textColor: "text-warning" },
{ color: "bg-green-500", textColor: "text-success" },
{ color: "bg-emerald-600", textColor: "text-success" },
{ color: "bg-destructive", textColor: "text-destructive" },
{ color: "bg-destructive", textColor: "text-destructive" },
{ color: "bg-warning", textColor: "text-warning" },
{ color: "bg-success", textColor: "text-success" },
{ color: "bg-success", textColor: "text-success" },
];
export function PasswordStrength({