From 8ac0d41d789352a5122532502c32aa9c9306f78c Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 28 Jul 2026 18:20:11 -0700 Subject: [PATCH] fix(stats): respect prefers-reduced-motion in the delete indicator The delete indicator's sweeping bar and spinner loop forever, which is exactly the motion prefers-reduced-motion exists to suppress. Shortening the duration the way the overlay does for its one-shot notification animations would speed a looping animation up instead of removing it, so the looping rules stop outright and hold a static busy state: the bar fills its track rather than sitting off-screen at its start transform, and the toast text carries the meaning. --- stats/src/styles/globals.css | 20 ++++++++++++++++++++ stats/src/styles/globals.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/stats/src/styles/globals.css b/stats/src/styles/globals.css index 457e87e9..8298b9b1 100644 --- a/stats/src/styles/globals.css +++ b/stats/src/styles/globals.css @@ -108,3 +108,23 @@ body.overlay-mode #root { .animate-indeterminate { animation: indeterminate-sweep 1.1s ease-in-out infinite; } + +@media (prefers-reduced-motion: reduce) { + .animate-fade-in { + animation-duration: 1ms; + } + + /* Looping motion can't just be shortened the way a one-shot entrance can — + that speeds it up rather than removing it. The delete indicator holds a + static busy state instead: the bar fills its track and the spinner stops, + leaving the toast text to carry the meaning. */ + .animate-indeterminate { + animation: none; + width: 100%; + transform: none; + } + + .animate-spin { + animation: none; + } +} diff --git a/stats/src/styles/globals.test.ts b/stats/src/styles/globals.test.ts index 7055c73e..6ca19245 100644 --- a/stats/src/styles/globals.test.ts +++ b/stats/src/styles/globals.test.ts @@ -14,3 +14,25 @@ test('stats overlay mode paints an opaque full-viewport background', () => { /body\.overlay-mode #root\s*\{[^}]*background-color:\s*var\(--color-ctp-base\);/s, ); }); + +test('reduced motion stops the looping delete indicator rather than speeding it up', () => { + const reducedMotion = /@media \(prefers-reduced-motion: reduce\)\s*\{(?.*)\n\}/s.exec(css) + ?.groups?.body; + assert.ok(reducedMotion, 'expected a prefers-reduced-motion block'); + + // Shortening an infinite animation makes it run faster, so the looping rules + // have to switch it off outright and hold a static state instead. + assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation:\s*none;/s); + assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*width:\s*100%;/s); + assert.match(reducedMotion, /\.animate-spin\s*\{[^}]*animation:\s*none;/s); + assert.doesNotMatch(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation-duration:/s); + assert.doesNotMatch(reducedMotion, /\.animate-spin\s*\{[^}]*animation-duration:/s); +}); + +test('the looping delete indicator keeps its animation for everyone else', () => { + const beforeMediaQuery = css.slice(0, css.indexOf('@media (prefers-reduced-motion')); + assert.match( + beforeMediaQuery, + /\.animate-indeterminate\s*\{\s*animation:\s*indeterminate-sweep/s, + ); +});