Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/reduced-motion.md
Original file line number Diff line number Diff line change
@@ -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"`).
11 changes: 10 additions & 1 deletion apps/cloud/src/mcp/session-build-semaphore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach } from "@effect/vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest";

import {
acquireBuildSlot,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
Expand Down
59 changes: 59 additions & 0 deletions e2e/scenarios/reduced-motion.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
}),
);
21 changes: 21 additions & 0 deletions packages/react/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading