From 8059f8d97d1e92d6000f2492f096c674a07e3211 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 19 May 2026 06:45:36 -0700 Subject: [PATCH] studio: respect prefers-reduced-motion across animations (#5611) * studio: respect prefers-reduced-motion across animations Tailwind animate-in/out, Radix dialog/popover zoom-in/slide-in transforms, and the infinite shine / shiny-text / icon-pop keyframes all run at their full duration regardless of the user's OS-level reduced-motion preference. A Playwright probe that emulated the media query confirmed every measured transition was identical between no-preference and reduce, so users with vestibular triggers see the same scaling overlays and continuous shimmers. Add the canonical universal-selector override so animation-duration, animation-iteration-count, and transition-duration collapse to ~0ms when the preference is set, leaving end states intact. Probe re-run shows settings-dialog animationDuration drop from 0.1s to 1e-05s and the 50ms mid-open screenshot is byte-identical to the settled one. * studio: exempt .animate-spin from reduced-motion collapse The universal-selector rule from the previous commit froze every animation including .animate-spin, which is used as the canonical in-progress indicator across Studio: tool execution loaders (tool-ui-python/terminal/web-search/code-execution/fallback/group), sonner toast spinners, Tauri startup + update screens, and the generic primitive in components/ui/spinner.tsx. Freezing those leaves reduced-motion users with no visual signal that work is in flight, which trades one accessibility win for another. WCAG treats progress indicators as "essential motion" that should keep moving. Restore .animate-spin with a 1.5s cadence (instead of the default 1s) so the rotation is still perceptible but less aggressive than the no-preference path. animation-iteration-count goes back to `infinite` so the spinner doesn't halt after one rotation. Verified via a focused probe that injects a .animate-spin element and a .animate-in fade element side by side: no-preference spin=1s infinite fade=0.15s reduce spin=1.5s infinite fade=1e-05s --------- Co-authored-by: danielhanchen --- studio/frontend/src/index.css | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 7afa87f597..8f132cd95b 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -1187,3 +1187,34 @@ color: var(--popover-foreground) !important; border-color: var(--border) !important; } + +/* + * prefers-reduced-motion: honour the OS-level "reduce motion" preference. + * Tailwind animate-in/out, Radix open/close transforms, infinite shine/pulse + * keyframes, and Framer Motion layout transitions all ignore the media query + * by default. Forcing every animation/transition to ~0ms collapses zoom-ins, + * slide-ins, and continuous shimmers to a single frame without removing the + * end state. Hover colour changes become instant rather than fading, which is + * the documented WCAG outcome (motion is "minimised, not removed"). + * + * .animate-spin is the exception: loading spinners are essential progress + * indicators across Studio (tool execution loaders, sonner toasts, Tauri + * startup / update screens, the primitive). Freezing them + * removes the only visual signal that work is in flight, so they keep + * animating but at a slower, less aggressive 1.5s cadence. + */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .animate-spin { + animation-duration: 1.5s !important; + animation-iteration-count: infinite !important; + } +}