mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Honor reduced motion across the product
Change-source: pulse-maintainer
This commit is contained in:
@@ -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<{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user