Honor reduced motion across the product

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-08-31 20:41:45 +01:00
parent 327682ad34
commit 6034116ee2
4 changed files with 87 additions and 1 deletions
+1
View File
@@ -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.
@@ -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<{
+24
View File
@@ -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
@@ -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();
}