mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
@@ -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;
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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