// 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 { const out: Record = {}; 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-]+)\)|(? 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\(\*\)/); }); 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/); });