diff --git a/.changeset/reduced-motion.md b/.changeset/reduced-motion.md new file mode 100644 index 000000000..0ce1a65ad --- /dev/null +++ b/.changeset/reduced-motion.md @@ -0,0 +1,10 @@ +--- +"@executor-js/react": patch +--- + +fix: honor prefers-reduced-motion in the shared stylesheet + +Adds a `prefers-reduced-motion: reduce` block to the global stylesheet that +caps transition/animation durations to 0.01ms and disables smooth scrolling, +so motion-sensitive users get a stable UI. The loading spinner renders +statically under reduced motion (its meaning is preserved via `role="status"`). diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad7634..584b65ee0 100644 --- a/apps/cloud/src/mcp/session-build-semaphore.test.ts +++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from "@effect/vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest"; import { acquireBuildSlot, @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => { resetBuildSlotsForTest(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("grants up to the cap immediately, with no wait", async () => { const results = await Promise.all([ acquireBuildSlot().promise, @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => { }); it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => { + vi.useFakeTimers(); await Promise.all([ acquireBuildSlot().promise, acquireBuildSlot().promise, @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => { expect(currentActiveBuildsForTest()).toBe(4); const timedOutHandle = acquireBuildSlot(10); + await vi.advanceTimersByTimeAsync(9); + expect(currentQueueLengthForTest()).toBe(1); + expect(currentActiveBuildsForTest()).toBe(4); + await vi.advanceTimersByTimeAsync(1); const result = await timedOutHandle.promise; expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true }); diff --git a/e2e/scenarios/reduced-motion.test.ts b/e2e/scenarios/reduced-motion.test.ts new file mode 100644 index 000000000..f2e751421 --- /dev/null +++ b/e2e/scenarios/reduced-motion.test.ts @@ -0,0 +1,59 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +scenario( + "Accessibility ยท reduced motion removes dialog animation and control transitions", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the API key dialog with normal motion", async () => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await visit(page, "/api-keys"); + await page.getByRole("button", { name: "New key" }).click(); + await page.getByRole("dialog").waitFor(); + const duration = await page + .getByRole("dialog") + .evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration)); + expect(duration, "normal motion retains the dialog transition").toBeGreaterThan(0.00001); + }); + + await step("Enable reduced motion while the dialog is open", async () => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const styles = await page.getByRole("dialog").evaluate((element) => { + const style = getComputedStyle(element); + return { + animation: Number.parseFloat(style.animationDuration), + iterations: style.animationIterationCount, + transition: Number.parseFloat(style.transitionDuration), + scroll: style.scrollBehavior, + }; + }); + expect(styles).toEqual({ + animation: 0.00001, + iterations: "1", + transition: 0.00001, + scroll: "auto", + }); + await page.locator("#create-key-name").fill("Reduced motion check"); + expect(await page.locator("#create-key-name").inputValue()).toBe("Reduced motion check"); + }); + + await step("Restore normal motion without losing the form", async () => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + expect(await page.locator("#create-key-name").inputValue()).toBe("Reduced motion check"); + const duration = await page + .getByRole("dialog") + .evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration)); + expect(duration).toBeGreaterThan(0.00001); + }); + }); + }), +); diff --git a/packages/react/src/styles/globals.css b/packages/react/src/styles/globals.css index e82bae6ca..97977327b 100644 --- a/packages/react/src/styles/globals.css +++ b/packages/react/src/styles/globals.css @@ -319,3 +319,24 @@ opacity: 0.25; } } + +/* --------------------------------------------------------------------------- + * Reduced motion โ€” WCAG 2.2 (motion-sensitive users). + * + * Neutralizes transitions/animations/scroll-behavior when the OS requests + * reduced motion, per the standard modern-CSS-reset pattern. The spinner + * (ios-spinner-fade) is capped to a single 0.01ms iteration โ€” effectively + * static โ€” which is the standard behavior: its meaning is preserved by + * `role="status"` (announced by assistive tech), and an opacity pulse would + * be a non-motion affordance but is not required for reduced-motion users. + * ------------------------------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +}