Files
STCourses/packages/ui/test/motion-css.test.ts
T
Lucas WintherandClaude Opus 5.5 89ff72cc28 fix(web): keep the sidebar through route transitions; end them within 120 ms
Router updates now run in a React transition, so a route whose code is still loading keeps the current page up
and the view transition captures the new page with its sidebar (it used to drop out and pop back on the first
course-to-lesson visit). The root group's 250 ms UA morph is off: pointer input goes to the document while a
transition runs, so it stays as short as the old page's 120 ms fade. The pointer-events rule had no effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:24:43 +01:00

103 lines
4.6 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 are short and keep the header and sidebar still", () => {
const css = strip(tokensCss);
// the UA default (a 250 ms root morph) would outlast the 220 ms ceiling
expect(css).toMatch(/::view-transition-group\(root\)\s*\{\s*animation:\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/);
});