From f9a130e446bb194866b1c4d87656bc5347d34732 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 18 Jun 2026 11:17:34 +0000 Subject: [PATCH] fix(ui): single focus ring (no double-ring) + neutralize stagger delay under reduced motion Signed-off-by: Ettore Di Giacinto Assisted-by: Claude:claude-opus-4-8 [Claude Code] --- core/http/react-ui/e2e/design-system.spec.js | 16 ++++++++++++++++ core/http/react-ui/src/App.css | 13 +++++-------- core/http/react-ui/src/index.css | 3 +++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/core/http/react-ui/e2e/design-system.spec.js b/core/http/react-ui/e2e/design-system.spec.js index 2ca03407c..d98993c19 100644 --- a/core/http/react-ui/e2e/design-system.spec.js +++ b/core/http/react-ui/e2e/design-system.spec.js @@ -26,3 +26,19 @@ test.describe('Editorial design system', () => { expect(name).toBe('pageReveal') }) }) + +test.describe('reduced motion', () => { + test('stagger animation-delay is neutralized under reduced motion', async ({ page }) => { + // Emulate prefers-reduced-motion explicitly. (The fixture-option form + // test.use({ reducedMotion }) does not propagate through our extended + // coverage `page` fixture, so set it on the page directly.) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.goto('/app') // Home renders .reveal-stagger children + // .home-status-line is staggerStyle(1) -> 60ms delay without the fix. + const child = page.locator('.home-status-line').first() + await expect(child).toBeVisible({ timeout: 15_000 }) + const delay = await child.evaluate(el => getComputedStyle(el).animationDelay) + // Under reduced motion the per-child delay must be ~0 (not 60ms+). + expect(parseFloat(delay)).toBeLessThan(0.05) + }) +}) diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 5c7bad21e..f26fdf4aa 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -1668,20 +1668,17 @@ box-shadow: 0 0 0 3px var(--color-focus-ring); } -/* Global focus ring — any interactive that isn't a .btn */ +/* Global focus ring - any interactive that isn't a .btn. This box-shadow ring + is the single focus technique app-wide (it covers .btn plus the :where(...) + list below). The strengthened --color-focus-ring keeps it WCAG-AA visible + (>=3:1), so no separate solid-outline rule is needed; a bare :focus-visible + outline here would double-ring every element. */ :where(a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible { outline: none; box-shadow: 0 0 0 3px var(--color-focus-ring); border-radius: var(--radius-sm); } -/* Solid outline fallback so the focus ring stays AA-visible (>=3:1) on every - interactive element, including those that clip or override box-shadow. */ -:focus-visible { - outline: 2px solid var(--color-focus-ring); - outline-offset: 2px; -} - .btn-primary { background: var(--color-primary); color: var(--color-primary-text); diff --git a/core/http/react-ui/src/index.css b/core/http/react-ui/src/index.css index 335694e9d..fb8ec75af 100644 --- a/core/http/react-ui/src/index.css +++ b/core/http/react-ui/src/index.css @@ -93,6 +93,9 @@ a:hover { @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; + /* Neutralize per-child stagger delays (e.g. .reveal-stagger) so content + does not sit at the hidden "from" keyframe before snapping in. */ + animation-delay: 0s !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important;