Public: anyone can read this; the crew can change it.
design-lint refuses an animation or transition with no reduced-motion answer #12441
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12418-design-lint-refuses-an-animation-or-tran → main 5 commits fork 3 threads · 3 unresolved
5 files
changed +156
−2
docs/design/sections/4-space-radius-motion.md +6 −1
| @@ -13,7 +13,12 @@ | ||
| 13 | 13 | - **Motion**: 100–200ms, `cubic-bezier(.2,0,0,1)`, enter only. Only `color`, |
| 14 | 14 | `background-color`, `border-color`, `opacity` and `transform` are ever |
| 15 | 15 | 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 | |
| 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. | |
| 17 | 22 | - **Focus** is the accent at 2px with 2px offset, identical on every control, |
| 18 | 23 | and inset (−2px) on full-bleed rows so nothing reflows. Never `outline: none`. |
| 19 | 24 | - **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"; | ||
| 67 | 67 | import { join, relative } from "node:path"; |
| 68 | 68 | import { STRANGER_NEVER_SEES } from "../src/design/first_five_minutes.ts"; |
| 69 | 69 | import { compareLayout, layoutCounts, scanLayout } from "../src/design/layout.ts"; |
| 70 | import { motionFindings } from "../src/design/motion.ts"; | |
| 70 | 71 | import { cssBlocksAt, scanOverlay, sourceFiles } from "../src/design/overlay.ts"; |
| 71 | 72 | |
| 72 | 73 | // 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"; | |
| 73 | 75 | export { compareLayout, layoutCounts, layoutIn, scanLayout } from "../src/design/layout.ts"; |
| 74 | 76 | export { matchesClosedOverlay, overlay, overlayClassesIn, type OverlayFinding, scanOverlay, sourceFiles } from "../src/design/overlay.ts"; |
| 75 | 77 | |
| @@ -561,6 +563,18 @@ export function scanStranger(root: string): string[] { | ||
| 561 | 563 | 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`)); |
| 562 | 564 | } |
| 563 | 565 | |
| 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 | ||
| 564 | 578 | const BASELINE = "docs/design/baseline.json"; |
| 565 | 579 | const DESIGN = "docs/DESIGN.md"; |
| 566 | 580 | const SECTIONS = "docs/design/sections"; |
| @@ -577,7 +591,7 @@ if (import.meta.main) { | ||
| 577 | 591 | } else { |
| 578 | 592 | const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8")); |
| 579 | 593 | 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)]; | |
| 581 | 595 | for (const p of problems) console.error(p); |
| 582 | 596 | if (problems.length) process.exit(1); |
| 583 | 597 | 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
|
||
| 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; } | ||
| 1188 | 1188 | /* Reduced motion: the pulses hold still at a legible level, the scroll-tied |
| 1189 | 1189 | fade stays at its resting width, and these transitions land at once. */ |
| 1190 | 1190 | @media (prefers-reduced-motion: reduce) { |
| 1191 | *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } | |
outdated
Add |
||
| 1191 | 1192 | .run-log [data-line="pulse"] i { animation: none; } |
| 1192 | 1193 | .run-log [data-line="pulse"] i { opacity: .6; } |
| 1193 | 1194 | .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 | }); |
new above
offline
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).