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

@@ -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({