Public: anyone can read this; the crew can change it.

design-lint refuses an animation or transition with no reduced-motion answer #12441

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12418-design-lint-refuses-an-animation-or-tran → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/12418-design-lint-refuses-an-animation-or-tran was merged into main as f36206a.

Review

Sign in to review this change.
5 files changed +156 −2
docs/design/sections/4-space-radius-motion.md +6 −1
@@ -13,7 +13,12 @@
1313 - **Motion**: 100–200ms, `cubic-bezier(.2,0,0,1)`, enter only. Only `color`,
1414 `background-color`, `border-color`, `opacity` and `transform` are ever
1515 animated. Never `height`, never `box-shadow` on scroll. Everything is inside
16 the `prefers-reduced-motion` kill switch.
16 the `prefers-reduced-motion` kill switch. `bun scripts/design-lint.ts` refuses

The sentence reads as if motion is refused both when unanswered and when it is not inside no-preference. Say plainly: it is refused unless a reduce rule in the sheet turns it off, or it sits inside (prefers-reduced-motion: no-preference).

17 an `animation` or `transition` over 100ms (naming its selector) unless a
18 `@media (prefers-reduced-motion: reduce)` rule in the same sheet turns it off,
19 or it sits inside `(prefers-reduced-motion: no-preference)`. It reads the
20 `animation`/`transition` shorthands and their `-duration` longhands; the
21 `animation-delay` and `transition-delay` longhands are not checked.
1722 - **Focus** is the accent at 2px with 2px offset, identical on every control,
1823 and inset (−2px) on full-bleed rows so nothing reflows. Never `outline: none`.
1924 - **Sticky, and only these**: the topbar, a diff file's header, the file path
scripts/design-lint.ts +15 −1
@@ -67,9 +67,11 @@ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
6767 import { join, relative } from "node:path";
6868 import { STRANGER_NEVER_SEES } from "../src/design/first_five_minutes.ts";
6969 import { compareLayout, layoutCounts, scanLayout } from "../src/design/layout.ts";
70 import { motionFindings } from "../src/design/motion.ts";
7071 import { cssBlocksAt, scanOverlay, sourceFiles } from "../src/design/overlay.ts";
7172
7273 // The covered-page rule lives in src/design/ so the release preflight runs it too (#11916).
74 export { motionFindings, type MotionFinding } from "../src/design/motion.ts";
7375 export { compareLayout, layoutCounts, layoutIn, scanLayout } from "../src/design/layout.ts";
7476 export { matchesClosedOverlay, overlay, overlayClassesIn, type OverlayFinding, scanOverlay, sourceFiles } from "../src/design/overlay.ts";
7577
@@ -561,6 +563,18 @@ export function scanStranger(root: string): string[] {
561563 return STRANGER_FILES.flatMap((f) => strangerWords(readFileSync(join(root, f), "utf8")).map((w) => `${f}:${w.line} "${w.word}" in "${w.text}" → a stranger is never shown this word (docs/DESIGN.md § The first five minutes): say it in plain words`));
562564 }
563565
566 /** Calm (#12418): a transition or animation over 100 ms with no reduced-motion answer in its sheet. Not a ratchet: none is allowed. */
567 export function scanMotion(root: string): string[] {
568 const out: string[] = [];
569 for (const f of sourceFiles(root)) {
570 const text = readFileSync(f, "utf8");
571 for (const css of cssBlocks(text)) {
572 for (const m of motionFindings(css)) out.push(`${relative(root, f)}: ${m.selector} — ${m.property} of ${m.ms}ms with no @media (prefers-reduced-motion: reduce) rule in the sheet turning it off (docs/DESIGN.md §4)`);
573 }
574 }
575 return out;
576 }
577
564578 const BASELINE = "docs/design/baseline.json";
565579 const DESIGN = "docs/DESIGN.md";
566580 const SECTIONS = "docs/design/sections";
@@ -577,7 +591,7 @@ if (import.meta.main) {
577591 } else {
578592 const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8"));
579593 const debt: QuietDebt = JSON.parse(readFileSync(join(root, QUIET_DEBT), "utf8"));
580 const problems = [...compare(base, now, designText(root), areaFiles(root)), ...compareQuiet(debt, scanQuiet(root)), ...scanBanner(root), ...compareLayout(base.layout ?? {}, scanLayout(root)), ...scanOverlay(root), ...scanCards(root), ...scanStranger(root)];
594 const problems = [...compare(base, now, designText(root), areaFiles(root)), ...compareQuiet(debt, scanQuiet(root)), ...scanBanner(root), ...compareLayout(base.layout ?? {}, scanLayout(root)), ...scanOverlay(root), ...scanCards(root), ...scanStranger(root), ...scanMotion(root)];
581595 for (const p of problems) console.error(p);
582596 if (problems.length) process.exit(1);
583597 console.log("design: no colour, size or component added outside the system");
src/design/motion.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * Calm (#12418): the page does not move unless asked. A stylesheet rule whose
3 * `animation` or `transition` runs longer than 100 ms is refused unless a
4 * `@media (prefers-reduced-motion: reduce)` rule in the same sheet turns it
5 * off — the selector itself, or a universal `*` that shortens every duration.
6 * Plain data in (one sheet's text), plain findings out.
7 */
8
9 export type MotionFinding = { selector: string; property: "animation" | "transition"; ms: number };
10
11 /** `reduce` rules may answer; `no-preference` rules are exempt; neither is both. */
12 type Mode = "" | "reduce" | "no-preference";
13 type Rule = { selector: string; body: string; mode: Mode };
14
15 const strip = (css: string): string => css.replace(/\/\*[\s\S]*?\*\//g, "");
16
17 /**
18 * Every `selector { body }` of a sheet, @media/@supports nesting flattened;
19 * @keyframes steps are skipped.
20 */
21 function rules(css: string): Rule[] {
22 const out: Rule[] = [];
23 const walk = (text: string, mode: Mode): void => {
24 let i = 0;
25 while (i < text.length) {
26 const open = text.indexOf("{", i);
27 if (open < 0) return;
28 let depth = 1, j = open + 1;
29 while (j < text.length && depth) depth += text[j] === "{" ? 1 : text[j] === "}" ? -1 : 0, j++;
30 const head = text.slice(i, open).replace(/^[\s;}]+/, "").trim();
31 const inner = text.slice(open + 1, j - 1);
32 if (/^@keyframes\b/i.test(head)) {
outdated

reduced is true for both reduce and no-preference, so rules inside no-preference become reduce 'answers' in calm. Split the flag in two: one for exemption, one for answering. Add a test.

33 /* the steps of an animation are not rules */
34 } else if (head.startsWith("@")) {
35 const q = /prefers-reduced-motion\s*:\s*(reduce|no-preference)/i.exec(head);
36 walk(inner, mode || (q ? (q[1]!.toLowerCase() as Mode) : ""));
37 } else if (head) {
38 out.push({ selector: head.replace(/\s+/g, " "), body: inner, mode });
39 }
40 i = j;
41 }
42 };
43 walk(strip(css), "");
44 return out;
45 }
46
47 const TIME = /(?<![\w.-])(\d*\.?\d+)(ms|s)\b/g;
48 const ms = (m: RegExpMatchArray): number => Number(m[1]) * (m[2] === "s" ? 1000 : 1);
49
50 /** The longest time in a value; a transition's delay counts too, being movement that waits. */
51 function longest(value: string): number {
52 let max = 0;
53 for (const m of value.matchAll(TIME)) max = Math.max(max, ms(m));
54 return max;
55 }
56
57 /** Does a reduced-motion rule's body stop `property` (none, or a duration of 100 ms or less)? */
58 function stops(body: string, property: "animation" | "transition"): boolean {
59 for (const d of body.matchAll(new RegExp(`(?<![\\w-])${property}(-duration|-name)?\\s*:\\s*([^;}]*)`, "g"))) {
60 const v = d[2]!.replace(/!important/i, "").trim();
61 if (d[1] === "-name" || (!d[1] && /^none\b/.test(v))) return true;
62 if (d[1] === "-duration" || (!d[1] && v)) return longest(v) <= 100;
63 }
64 return false;
65 }
66
67 export function motionFindings(css: string): MotionFinding[] {
68 const all = rules(css);
69 const calm = all.filter((r) => r.mode === "reduce");
70 const out: MotionFinding[] = [];
71 for (const r of all) {
72 if (r.mode) continue;
73 for (const property of ["animation", "transition"] as const) {
74 let long = 0;
75 for (const d of r.body.matchAll(new RegExp(`(?<![\\w-])${property}(-duration)?\\s*:\\s*([^;}]*)`, "g"))) {
76 if (/^none\b/.test(d[2]!.trim())) continue;
77 long = Math.max(long, longest(d[2]!));
78 }
79 if (long <= 100) continue;
80 const answered = calm.some((c) => stops(c.body, property) && c.selector.split(",").some((s) => /(^|\s)\*(::\w+)?$/.test(s.trim()) || r.selector.split(",").some((own) => own.trim() === s.trim())));
81 if (!answered) out.push({ selector: r.selector, property, ms: long });
82 }
83 }
84 return out;
85 }
src/web/style/content.ts +1
@@ -1188,6 +1188,7 @@ textarea.editor:focus-visible { outline: none; }
11881188 /* Reduced motion: the pulses hold still at a legible level, the scroll-tied
11891189 fade stays at its resting width, and these transitions land at once. */
11901190 @media (prefers-reduced-motion: reduce) {
1191 *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
outdated

Add animation-iteration-count: 1 !important here. A .01ms infinite animation flickers rather than stopping.

11911192 .run-log [data-line="pulse"] i { animation: none; }
11921193 .run-log [data-line="pulse"] i { opacity: .6; }
11931194 .ex-dir > summary > .ico, .code-layout > #code-main[aria-busy="true"], .code-layout > [data-split]::after { transition: none; }
test/design-lint-motion.test.ts added +49
@@ -0,0 +1,49 @@
1 import { describe, expect, test } from "bun:test";
2 import { mkdirSync, readFileSync, mkdtempSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { motionFindings, scanMotion } from "../scripts/design-lint.ts";
6
7 describe("design lint: motion needs a reduced-motion answer (#12418)", () => {
8 test("a slow transition with no answer fails, naming the selector", () => {
9 const f = motionFindings(`.card a { transition: color 150ms ease; }`);
10 expect(f).toEqual([{ selector: ".card a", property: "transition", ms: 150 }]);
11 });
12 test("an animation in seconds fails too", () => {
13 expect(motionFindings(`.dot { animation: pulse 2s infinite; } @keyframes pulse { 50% { opacity: .3; } }`).map((x) => x.selector)).toEqual([".dot"]);
14 });
15 test("a shared reduced-motion block passes", () => {
16 const css = `.a { transition: opacity 200ms; } .b { animation: x 1s; }
17 @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } }`;
18 expect(motionFindings(css)).toEqual([]);
19 });
20 test("the same selector turned off passes; a different one does not", () => {
21 const off = `.a { transition: opacity 200ms; } @media (prefers-reduced-motion: reduce) { .a { transition: none; } }`;
22 expect(motionFindings(off)).toEqual([]);
23 expect(motionFindings(off.replace("{ .a { transition: none", "{ .z { transition: none")).length).toBe(1);
24 });
25 test("a duration answer only for animations leaves a transition failing", () => {
26 const css = `.a { transition: opacity 200ms; } @media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms; } }`;
27 expect(motionFindings(css).length).toBe(1);
28 });
29 test("100 ms or less, and motion only under no-preference, pass", () => {
30 expect(motionFindings(`.a { transition: color 100ms; } .b { transition: none; }`)).toEqual([]);
31 expect(motionFindings(`@media (prefers-reduced-motion: no-preference) { .a { animation: p 2s infinite; } @keyframes p { 50% { opacity: .3; } } }`)).toEqual([]);
32 });
33 test("a no-preference rule exempts itself but never answers for a reduce-less selector", () => {
34 const css = `.a { transition: opacity 200ms; } @media (prefers-reduced-motion: no-preference) { .a { transition: none; } }`;
35 expect(motionFindings(css).map((x) => x.selector)).toEqual([".a"]);
36 });
37 test("the sheets of a tree are scanned, naming the file", () => {
38 const root = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "motion-"));
39 mkdirSync(join(root, "src/web"), { recursive: true });
40 writeFileSync(join(root, "src/web/a.ts"), "export const css = /* css */ `.x { transition: color 150ms; }`;\n");
41 writeFileSync(join(root, "src/web/b.ts"), "export const css = /* css */ `.y { transition: color 90ms; }`;\n");
42 const out = scanMotion(root);
43 expect(out.length).toBe(1);
44 expect(out[0]).toContain("src/web/a.ts: .x");
45 });
46 test("the shared block holds infinite animations still", () => {
47 expect(readFileSync("src/web/style/content.ts", "utf8")).toContain("animation-iteration-count: 1 !important");
48 });
49 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs