Files
STCourses/packages/ui/test/motion-css.test.ts
T
Lucas WintherandClaude Opus 5.5 6bb7681306 test(web): reduced motion disables all motion
Also turns off the fold height transition, which the universal selector does not reach (::details-content).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:55:32 +01:00

102 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Static motion lint (v1.1 package M): every animation and transition in the shared and learner stylesheets is
// light (120–220 ms, ease-out without overshoot), animates only opacity and short translates, and is switched off
// by prefers-reduced-motion.
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { learnerCss } from "../../feature-learner/src/web/styles";
const tokensCss = readFileSync(join(import.meta.dir, "../src/tokens.css"), "utf8");
const sheets: [string, string][] = [["tokens.css", tokensCss], ["learnerCss", learnerCss]];
const strip = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, "");
/** Duration tokens, in ms. */
function tokens(): Record<string, number> {
const out: Record<string, number> = {};
for (const m of strip(tokensCss).matchAll(/(--t-[\w-]+|--fast)\s*:\s*([\d.]+)ms/g)) out[m[1]!] = Number(m[2]);
return out;
}
test("the motion duration tokens exist and are 120–220 ms", () => {
const t = tokens();
for (const name of ["--t-exit", "--t-fast", "--t-enter", "--t-fold", "--t-bar"]) {
expect([name, t[name] !== undefined]).toEqual([name, true]);
expect(t[name]!).toBeGreaterThanOrEqual(120);
expect(t[name]!).toBeLessThanOrEqual(220);
}
});
test("every animation and transition duration is 0 or 120–220 ms", () => {
const t = tokens();
const bad: string[] = [];
for (const [name, css] of sheets) {
for (const m of strip(css).matchAll(/(?:^|[;{\s])(animation|transition)(?:-duration)?\s*:\s*([^;}]+)/g)) {
const value = m[2]!;
if (/^\s*none/.test(value)) continue;
const durations: number[] = [];
for (const d of value.matchAll(/var\((--[\w-]+)\)|(?<![\w.-])([\d.]+)(ms|s)\b/g)) {
if (d[1]) { if (d[1] in t) durations.push(t[d[1]]!); continue; }
durations.push(Number(d[2]) * (d[3] === "s" ? 1000 : 1));
}
// The first time in a declaration is the duration; a second one is a delay, which must be 0 or within range too.
for (const ms of durations) if (ms !== 0 && (ms < 120 || ms > 220)) bad.push(`${name}: ${m[0].trim()}`);
}
}
expect(bad).toEqual([]);
});
test("keyframes animate only opacity and short translates", () => {
const bad: string[] = [];
let count = 0;
for (const [name, css] of sheets) {
for (const k of strip(css).matchAll(/@keyframes\s+([\w-]+)\s*\{((?:[^{}]*\{[^}]*\})*)\s*\}/g)) {
count++;
for (const d of k[2]!.matchAll(/([\w-]+)\s*:\s*([^;}]+)/g)) {
const [prop, value] = [d[1]!, d[2]!.trim()];
if (prop === "opacity") continue;
if (prop === "translate") {
const px = [...value.matchAll(/(-?[\d.]+)px/g)].map((x) => Math.abs(Number(x[1])));
if (px.every((n) => n <= 8) && !/%|em|vw|vh/.test(value)) continue;
}
bad.push(`${name} @keyframes ${k[1]}: ${prop}: ${value}`);
}
}
}
expect(count).toBeGreaterThan(0);
expect(bad).toEqual([]);
});
test("easing curves do not overshoot (no bounce)", () => {
const bad: string[] = [];
for (const [name, css] of sheets) {
for (const m of css.matchAll(/cubic-bezier\(([^)]*)\)/g)) {
const [, y1, , y2] = m[1]!.split(",").map(Number);
if (!(y1! >= 0 && y1! <= 1 && y2! >= 0 && y2! <= 1)) bad.push(`${name}: ${m[0]}`);
}
}
expect(bad).toEqual([]);
});
test("prefers-reduced-motion turns off every animation, transition and view transition", () => {
const css = strip(tokensCss);
const at = css.indexOf("@media (prefers-reduced-motion: reduce)");
expect(at).toBeGreaterThan(-1);
const block = css.slice(at, css.indexOf("\n}", at));
expect(block).toMatch(/\*,\s*\*::before,\s*\*::after/);
expect(block).toContain("animation: none !important");
expect(block).toContain("transition: none !important");
expect(block).toMatch(/::view-transition-group\(\*\)/);
expect(block).toMatch(/::view-transition-old\(\*\)/);
expect(block).toMatch(/::view-transition-new\(\*\)/);
// `*` does not reach ::details-content (fold heights), so it has its own rule.
expect(block).toMatch(/\*::details-content\s*\{\s*transition:\s*none !important/);
});
test("view transitions never take pointer input and keep the header and sidebar still", () => {
const css = strip(tokensCss);
expect(css).toMatch(/::view-transition\s*\{[^}]*pointer-events:\s*none/);
expect(css).toMatch(/\.stc-top\s*\{[^}]*view-transition-name:\s*stc-top/);
expect(strip(learnerCss)).toMatch(/\.lr-side\s*\{[^}]*view-transition-name:\s*lr-side/);
expect(css).toMatch(/::view-transition-group\(stc-top\)[^{]*\{[^}]*animation:\s*none/);
});