Files
HealthLog/src/components/__tests__/motion-reduce-spin-coverage.test.ts
T
Marc-André Bombeck ba8118546d 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.
2026-07-02 00:16:03 +02:00

181 lines
7.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.43 W11 — motion-reduce sweep guard.
*
* Every `animate-spin` site in component / app code must also carry
* `motion-reduce:animate-none` so that motion-sensitive users see a
* static icon instead of a continuous rotation. The audit found 21
* sites missing the modifier; this test prevents regressions.
*
* Scope: `src/**` excluding test files. The check is textual to avoid
* rendering every loading state — a CI-cheap guard that catches drift
* any time a new spinner is added without the modifier.
*/
const ROOT = join(process.cwd(), "src");
const EXCLUDE_DIRS = new Set(["__tests__", "node_modules"]);
function collectTsxFiles(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (EXCLUDE_DIRS.has(entry)) continue;
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
collectTsxFiles(full, acc);
} else if (
entry.endsWith(".tsx") &&
!entry.endsWith(".test.tsx") &&
!entry.endsWith(".test.ts")
) {
acc.push(full);
}
}
return acc;
}
describe("motion-reduce coverage — every animate-spin pairs with motion-reduce:animate-none", () => {
const files = collectTsxFiles(ROOT);
it("collects component files to scan", () => {
expect(files.length).toBeGreaterThan(50);
});
it("every animate-spin site declares motion-reduce:animate-none", () => {
const violations: string[] = [];
for (const file of files) {
const src = readFileSync(file, "utf8");
const lines = src.split("\n");
lines.forEach((line, idx) => {
if (!line.includes("animate-spin")) return;
// Skip comments and string literals that aren't classNames.
if (line.trim().startsWith("//") || line.trim().startsWith("*")) return;
if (!line.includes("motion-reduce:animate-none")) {
violations.push(`${file}:${idx + 1}: ${line.trim()}`);
}
});
}
expect(
violations,
`The following animate-spin sites are missing motion-reduce:animate-none. ` +
`Append the modifier so motion-sensitive users see a static icon:\n` +
violations.join("\n"),
).toEqual([]);
});
});
/**
* 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.
*
* Unlike `animate-spin` (a Tailwind utility paired with an inline
* `motion-reduce:animate-none` per site), `.animate-insight-in` is a custom
* keyframe utility defined in `globals.css` and applied across five insight
* surfaces (recommendations-grid, recommendation-card, insight-status-card,
* hero-strip, arztbericht-hero-card). Guarding it at each call site would be
* fragile, so the guard lives once in `globals.css`: a
* `@media (prefers-reduced-motion: reduce)` block collapses the animation to
* `none`, covering every consumer at once. This test pins that central guard
* so a future edit cannot drop it and silently re-introduce a 400 ms motion
* for motion-sensitive users.
*/
describe("motion-reduce coverage — animate-insight-in collapses under prefers-reduced-motion", () => {
const GLOBALS = join(process.cwd(), "src", "app", "globals.css");
const css = readFileSync(GLOBALS, "utf8");
it("globals.css defines the .animate-insight-in utility", () => {
expect(css).toMatch(/\.animate-insight-in\s*\{[^}]*animation:/);
});
it("a prefers-reduced-motion block collapses .animate-insight-in to no motion", () => {
// Locate the reduced-motion media query, then assert that within its
// body `.animate-insight-in` is reset to `animation: none`. The check
// is anchored on the media query so a stray `.animate-insight-in {
// animation: none }` outside a reduced-motion context cannot satisfy it.
const mediaStart = css.search(
/@media\s*\(\s*prefers-reduced-motion:\s*reduce\s*\)/,
);
expect(
mediaStart,
"Expected a `@media (prefers-reduced-motion: reduce)` block in globals.css.",
).toBeGreaterThanOrEqual(0);
// Scan from the media query to the end of the file — every motion the app
// defers under reduced motion lives in such a block, and `.animate-insight-in`
// must be one of them.
const fromMedia = css.slice(mediaStart);
const guarded = /\.animate-insight-in\s*\{\s*animation:\s*none/.test(
fromMedia,
);
expect(
guarded,
"Expected `.animate-insight-in { animation: none }` inside a " +
"`@media (prefers-reduced-motion: reduce)` block in globals.css. " +
"Without it the 400 ms entrance keyframe plays for motion-sensitive " +
"users on every insight card.",
).toBe(true);
});
});