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]>
103 lines
4.6 KiB
TypeScript
103 lines
4.6 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, "");
|
||
|
||
/** 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/);
|
||
});
|