Public: anyone can read this; the crew can change it.
The shared components stylesheet stops every animation for a person who asks for reduced motion #3572
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3548-the-shared-components-stylesheet-stops-e → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +34
−1
docs/design/baseline.json +1 −1
| @@ -57,7 +57,7 @@ | ||
| 57 | 57 | "src/web/style/components.ts": { |
| 58 | 58 | "colour": 3, |
| 59 | 59 | "size": 2, |
| 60 | "motion": 20, | |
| 60 | "motion": 0, | |
| 61 | 61 | "forced": 1, |
| 62 | 62 | "focus": 2, |
| 63 | 63 | "input": 1, |
src/web/style/components.ts +14
| @@ -833,4 +833,18 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 833 | 833 | input, textarea, select, .input, .input[data-size="sm"], .topbar-search input, |
| 834 | 834 | .palette .palette-input { font-size: 16px; } |
| 835 | 835 | } |
| 836 | ||
| 837 | /* Asked for less motion, the page keeps every state and loses the movement. | |
| 838 | \`!important\` because a bare \`*\` has no specificity: every class rule above | |
| 839 | that sets an animation or transition would win. Durations collapse rather | |
| 840 | than \`animation: none\`, so \`animationend\` still fires (src/web/live.ts | |
| 841 | removes its flash class on it). A busy loader stops turning and dims. */ | |
| 842 | @media (prefers-reduced-motion: reduce) { | |
| 843 | *, *::before, *::after { | |
| 844 | animation-duration: .01ms !important; | |
| 845 | animation-iteration-count: 1 !important; | |
This repeats base.ts:208 line for line. Keep the .ico-loader dim here. Fold the rest into one switch when the lint can see across files. |
||
| 846 | transition-duration: .01ms !important; | |
| 847 | } | |
| 848 | .btn[aria-busy="true"] .ico-loader { opacity: .6; } | |
| 849 | } | |
| 836 | 850 | `; |
test/components-reduced-motion.test.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { components } from "../src/web/style/components"; | |
| 3 | ||
| 4 | const block = components.match(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}\n/)?.[1] ?? ""; | |
| 5 | ||
| 6 | test("reduced motion collapses every animation and transition, and wins over class rules", () => { | |
| 7 | expect(block).toContain("*, *::before, *::after"); | |
| 8 | for (const d of ["animation-duration: .01ms", "animation-iteration-count: 1", "transition-duration: .01ms"]) { | |
| 9 | expect(block).toContain(`${d} !important;`); | |
| 10 | } | |
| 11 | }); | |
| 12 | ||
| 13 | test("reduced motion leaves animationend firing (live.ts clears its flash class on it)", () => { | |
| 14 | expect(block).not.toMatch(/animation\s*:\s*none/); | |
| 15 | }); | |
| 16 | ||
| 17 | test("a busy loader has a static look under reduced motion", () => { | |
| 18 | expect(block).toContain('.btn[aria-busy="true"] .ico-loader { opacity: .6; }'); | |
| 19 | }); |
new above
offline
Zero specificity: any
.class { animation: ... }rule beats this. Useanimation: none !important; transition: none !important;, or keep base.ts:208 as the single kill switch. Add a test that would fail without it.