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]>
106 lines
4.7 KiB
TypeScript
106 lines
4.7 KiB
TypeScript
// 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\)/);
|
||
});
|