Files
STCourses/packages/ui/test/motion-css.test.ts
T
Lucas WintherandClaude Opus 5.5 b5e00af4f7 fix(web,learner): navigation swaps at once; content-only entrance, loading line, focus (R19)
Drops document-level View Transitions and the lesson hold: they dropped clicks for 120-180 ms, froze the screen
while route code loaded, and let the dock act on a stale lesson. Navigation now swaps immediately; only the
content region enters (opacity and a 6 px rise, 160 ms); a 2 px loading line appears when code or data takes over
150 ms; the dock works from the outline and the URL, so a second Next advances again; focus moves to the new h1
and a polite live region announces it. Route chunks of a feature preload together so the sidebar never drops.
Scroll restore stops on user input; pdf.js never starts a load after cleanup.

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

106 lines
4.7 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, "");
/** The loading line's sweep is a progress indicator, not navigation motion: exempt from the duration and property rules. */
const INDICATORS = ["stc-loading-sweep"];
/** 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-page", "--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) || INDICATORS.some((n) => value.includes(n))) 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++;
if (INDICATORS.includes(k[1]!)) continue;
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 and 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");
// `*` 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("no View Transitions (ruling R19): navigation never blocks input", () => {
for (const [name, css] of sheets) expect([name, /view-transition/.test(strip(css))]).toEqual([name, false]);
});
test("page entrance: opacity plus a 6 px rise in 160 ms", () => {
const css = strip(tokensCss);
expect(tokens()["--t-page"]).toBe(160);
expect(css).toMatch(/--rise:\s*6px/);
expect(css).toMatch(/@keyframes stc-page\s*\{\s*from\s*\{\s*opacity:\s*0;\s*translate:\s*0 var\(--rise\);/);
expect(strip(learnerCss)).toMatch(/\.lr-main \.lr-lesson[^{]*\{\s*animation:\s*stc-page var\(--t-page\)/);
});