From 6034116ee2a584852916bff1b1f2b8949263e8a0 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:41:45 +0100 Subject: [PATCH] Honor reduced motion across the product Change-source: pulse-maintainer --- frontend-modern/DESIGN_SYSTEM.md | 1 + .../SharedPrimitives.guardrails.test.ts | 9 +++- frontend-modern/src/index.css | 24 +++++++++ .../83-product-trust-accessibility.spec.ts | 54 +++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/frontend-modern/DESIGN_SYSTEM.md b/frontend-modern/DESIGN_SYSTEM.md index afb4f9c8a..194f098d1 100644 --- a/frontend-modern/DESIGN_SYSTEM.md +++ b/frontend-modern/DESIGN_SYSTEM.md @@ -16,6 +16,7 @@ This document outlines the standard UI primitives, tokens, and components that c - Visible form labels must either use `for` with a matching control ID or wrap a native labelable control; use a heading, paragraph, or span for non-control captions. - Shared form controls must keep a stable accessible name as their value changes; a placeholder is input guidance, not a label. - Compact pointer targets must remain at least 24 by 24 CSS pixels, including icon and copy actions, per [WCAG 2.2 Target Size (Minimum)](https://www.w3.org/TR/WCAG22/#target-size-minimum). +- Product motion must honor the operating-system `prefers-reduced-motion` setting. The global stylesheet completes animations and transitions immediately under that preference; do not override that safeguard or make motion the only indication of state. - Only explicit non-visual route wrappers are exempt from header primitive requirements. - Theme ownership policy: - Only `src/utils/theme.ts` and `index.html` may read/write theme keys (`pulseThemePreference`, `darkMode`, `pulse_dark_mode`) or toggle the root `dark` class. diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index becf1b528..7bea85232 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -4317,7 +4317,7 @@ describe('shared primitive guardrails', () => { expect(buttonModelSource).toContain('warningGhost:'); expect(buttonModelSource).toContain('warningOutline:'); expect(buttonModelSource).toContain('infoGhost:'); - expect(buttonModelSource).toContain(["'2xs'", ": 'h-5 w-5'"].join('')); + expect(buttonModelSource).toContain(["'2xs'", ": 'h-6 w-6'"].join('')); expect(buttonModelSource).toContain(['lg', ": 'h-9 w-9'"].join('')); expect(buttonModelSource).toContain('dangerOutline:'); expect(buttonModelSource).toContain('settingsAction:'); @@ -8159,6 +8159,13 @@ describe('shared primitive guardrails', () => { ); }); + it('bounds all product motion when reduced motion is requested', () => { + expect(frontendIndexCssSource).toMatch( + /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*?\*,[\s\S]*?\*::before,[\s\S]*?\*::after \{[\s\S]*?animation-duration: 0\.01ms !important;[\s\S]*?animation-iteration-count: 1 !important;[\s\S]*?transition-duration: 0\.01ms !important;/, + ); + expect(frontendIndexCssSource).toMatch(/html:focus-within \{[\s\S]*?scroll-behavior: auto;/); + }); + it('keeps search field on shell, runtime, and model owners', () => { const registry = JSON.parse(sharedTemplateRegistrySource) as { rules?: Array<{ diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index 674fbfcc4..80676492e 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -2,6 +2,30 @@ @tailwind components; @tailwind utilities; +/* + * Respect the operating-system motion preference across the whole product. + * Keep animations technically present for components that depend on their + * completion state, but finish them immediately and prevent repeated motion. + * This also covers Tailwind animation/transition utilities and future shared + * primitives instead of requiring every call site to remember an override. + */ +@media (prefers-reduced-motion: reduce) { + html:focus-within { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + transition-delay: 0ms !important; + } +} + /* * Neutralize a Tailwind color/font-size collision: the dead `base` alias in * the color palette was making Tailwind emit `color: var(--color-bg-base)` on diff --git a/tests/integration/tests/83-product-trust-accessibility.spec.ts b/tests/integration/tests/83-product-trust-accessibility.spec.ts index 616f55e51..ca1fdd668 100644 --- a/tests/integration/tests/83-product-trust-accessibility.spec.ts +++ b/tests/integration/tests/83-product-trust-accessibility.spec.ts @@ -27,6 +27,54 @@ const scanForWcagViolations = async (page: Page) => { })); }; +const scanForUnexpectedReducedMotion = async (page: Page) => + page.evaluate(() => { + const parseTimeList = (value: string) => + value.split(",").map((entry) => { + const time = entry.trim(); + if (time.endsWith("ms")) return Number.parseFloat(time) / 1000; + if (time.endsWith("s")) return Number.parseFloat(time); + return 0; + }); + const describe = (element: Element) => { + const id = element.id ? `#${element.id}` : ""; + const classes = Array.from(element.classList) + .slice(0, 3) + .map((name) => `.${name}`) + .join(""); + return `${element.tagName.toLowerCase()}${id}${classes}`; + }; + + return Array.from(document.querySelectorAll("*")).flatMap((element) => { + const style = getComputedStyle(element); + const animationSeconds = parseTimeList(style.animationDuration); + const transitionSeconds = parseTimeList(style.transitionDuration); + const hasRepeatedAnimation = style.animationIterationCount + .split(",") + .some( + (count) => + count.trim() === "infinite" || Number.parseFloat(count) > 1, + ); + const hasVisibleMotion = + animationSeconds.some((seconds) => seconds > 0.001) || + transitionSeconds.some((seconds) => seconds > 0.001) || + hasRepeatedAnimation || + style.scrollBehavior === "smooth"; + + return hasVisibleMotion + ? [ + { + target: describe(element), + animationDuration: style.animationDuration, + animationIterationCount: style.animationIterationCount, + transitionDuration: style.transitionDuration, + scrollBehavior: style.scrollBehavior, + }, + ] + : []; + }); + }); + type WorkerFixtures = { authStorageStatePath: string }; const test = base.extend<{}, WorkerFixtures>({ storageState: async ({ authStorageStatePath }, use) => @@ -100,6 +148,7 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o test("representative authenticated surfaces have no automatically detectable WCAG A/AA violations", async ({ page, }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); const surfaces = [ { route: "/alerts/overview", heading: "Alerts Overview" }, { route: "/settings/system-general", heading: "General" }, @@ -115,6 +164,10 @@ test("representative authenticated surfaces have no automatically detectable WCA await scanForWcagViolations(page), `${surface.route} should have no automatically detectable WCAG A/AA violations`, ).toEqual([]); + expect( + await scanForUnexpectedReducedMotion(page), + `${surface.route} should complete non-essential motion immediately when reduced motion is requested`, + ).toEqual([]); } }); @@ -137,6 +190,7 @@ test("the logged-out entry surface has no automatically detectable WCAG A/AA vio "none", ); expect(await scanForWcagViolations(page)).toEqual([]); + expect(await scanForUnexpectedReducedMotion(page)).toEqual([]); } finally { await context.close(); }