mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
Reference in new issue
Block a user