From dc2a17a6268fff3e2007c9a4ca6656f511af47b0 Mon Sep 17 00:00:00 2001 From: pt-act <211776491+pt-act@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:01:24 +0100 Subject: [PATCH 1/3] fix(react): honor prefers-reduced-motion in the shared stylesheet --- .changeset/reduced-motion.md | 10 ++++++++++ packages/react/src/styles/globals.css | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 .changeset/reduced-motion.md diff --git a/.changeset/reduced-motion.md b/.changeset/reduced-motion.md new file mode 100644 index 0000000000..0ce1a65ad0 --- /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/packages/react/src/styles/globals.css b/packages/react/src/styles/globals.css index edd97f3378..f6b2410037 100644 --- a/packages/react/src/styles/globals.css +++ b/packages/react/src/styles/globals.css @@ -294,3 +294,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; + } +} From c1ad1772acc8cc5f85d470c3ec55e77cca4c9127 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:12:06 -0700 Subject: [PATCH 2/3] Test queue timeout with a controlled clock --- apps/cloud/src/mcp/session-build-semaphore.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad76343..584b65ee0e 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 }); From b62e3a4e3e642ea1a0bfa7e020bcb262fcf1eca0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:39:46 -0700 Subject: [PATCH 3/3] Cover reduced motion changes in the browser --- e2e/scenarios/reduced-motion.test.ts | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 e2e/scenarios/reduced-motion.test.ts diff --git a/e2e/scenarios/reduced-motion.test.ts b/e2e/scenarios/reduced-motion.test.ts new file mode 100644 index 0000000000..f2e751421e --- /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); + }); + }); + }), +);