From 889d397d323b416f56f7ccc6153377248249e64a Mon Sep 17 00:00:00 2001 From: Dev Lead Agent Date: Tue, 14 Apr 2026 11:25:23 +0000 Subject: [PATCH] fix(a11y): prefers-reduced-motion WCAG 2.3.3 compliance globals.css: append @media (prefers-reduced-motion: reduce) block that zeroes animation/transition durations, disables .animate-in/.slide-in-from-* entry animations (Toaster, ApprovalBanner, SidePanel slide), strips dashdraw and node-appear keyframes from React Flow elements. Components: replace all bare animate-pulse (13 occurrences across WorkspaceNode, StatusDot, Toolbar, SidePanel, Legend, SearchDialog, TerminalTab, TemplatePalette) with motion-safe:animate-pulse so status indicator pulsing stops for users with vestibular disorders. Replace 3 animate-bounce occurrences in ChatTab typing indicator with motion-safe:animate-bounce. Tests: new canvas/src/__tests__/reduced-motion.test.ts (12 tests) verifies the @media block is present in globals.css and that every component file uses the motion-safe: variant rather than bare animation classes. Co-Authored-By: Claude Sonnet 4.6 --- canvas/src/__tests__/reduced-motion.test.ts | 90 +++++++++++++++++++++ canvas/src/app/globals.css | 36 +++++++++ canvas/src/components/Legend.tsx | 2 +- canvas/src/components/SearchDialog.tsx | 2 +- canvas/src/components/SidePanel.tsx | 2 +- canvas/src/components/StatusDot.tsx | 2 +- canvas/src/components/TemplatePalette.tsx | 2 +- canvas/src/components/Toolbar.tsx | 4 +- canvas/src/components/WorkspaceNode.tsx | 10 +-- canvas/src/components/tabs/ChatTab.tsx | 6 +- canvas/src/components/tabs/TerminalTab.tsx | 2 +- 11 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 canvas/src/__tests__/reduced-motion.test.ts diff --git a/canvas/src/__tests__/reduced-motion.test.ts b/canvas/src/__tests__/reduced-motion.test.ts new file mode 100644 index 00000000..3f7eee05 --- /dev/null +++ b/canvas/src/__tests__/reduced-motion.test.ts @@ -0,0 +1,90 @@ +/** + * WCAG 2.3.3 — prefers-reduced-motion compliance + * Verifies that all animation classes are guarded by motion-safe: variants + * and that globals.css contains the @media rule. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { join } from "path"; + +const root = join(__dirname, "../.."); + +function readSrc(rel: string) { + return readFileSync(join(root, "src", rel), "utf8"); +} + +describe("prefers-reduced-motion compliance", () => { + it("globals.css contains @media (prefers-reduced-motion: reduce) block", () => { + const css = readSrc("app/globals.css"); + expect(css).toContain("prefers-reduced-motion: reduce"); + expect(css).toContain("animation-duration: 0.01ms"); + }); + + it("ChatTab.tsx uses motion-safe:animate-bounce, not bare animate-bounce", () => { + const src = readSrc("components/tabs/ChatTab.tsx"); + // Must not have bare animate-bounce (not preceded by motion-safe:) + expect(src.includes("animate-bounce") && !src.includes("motion-safe:animate-bounce")).toBe(false); + // Must have guarded version + expect(src).toContain("motion-safe:animate-bounce"); + }); + + it("WorkspaceNode.tsx uses motion-safe:animate-pulse, not bare animate-pulse", () => { + const src = readSrc("components/WorkspaceNode.tsx"); + expect(src.includes("animate-pulse") && !src.includes("motion-safe:animate-pulse")).toBe(false); + expect(src).toContain("motion-safe:animate-pulse"); + }); + + it("StatusDot.tsx uses motion-safe:animate-pulse", () => { + const src = readSrc("components/StatusDot.tsx"); + expect(src.includes("animate-pulse") && !src.includes("motion-safe:animate-pulse")).toBe(false); + expect(src).toContain("motion-safe:animate-pulse"); + }); + + it("Toolbar.tsx uses motion-safe:animate-pulse", () => { + const src = readSrc("components/Toolbar.tsx"); + expect(src.includes("animate-pulse") && !src.includes("motion-safe:animate-pulse")).toBe(false); + expect(src).toContain("motion-safe:animate-pulse"); + }); + + it("SidePanel.tsx uses motion-safe:animate-pulse", () => { + const src = readSrc("components/SidePanel.tsx"); + expect(src.includes("animate-pulse") && !src.includes("motion-safe:animate-pulse")).toBe(false); + expect(src).toContain("motion-safe:animate-pulse"); + }); + + it("Legend.tsx uses motion-safe:animate-pulse", () => { + const src = readSrc("components/Legend.tsx"); + expect(src.includes("animate-pulse") && !src.includes("motion-safe:animate-pulse")).toBe(false); + expect(src).toContain("motion-safe:animate-pulse"); + }); + + it("SearchDialog.tsx uses motion-safe:animate-pulse", () => { + const src = readSrc("components/SearchDialog.tsx"); + expect(src.includes("animate-pulse") && !src.includes("motion-safe:animate-pulse")).toBe(false); + expect(src).toContain("motion-safe:animate-pulse"); + }); + + it("TerminalTab.tsx uses motion-safe:animate-pulse", () => { + const src = readSrc("components/tabs/TerminalTab.tsx"); + expect(src.includes("animate-pulse") && !src.includes("motion-safe:animate-pulse")).toBe(false); + expect(src).toContain("motion-safe:animate-pulse"); + }); + + it("TemplatePalette.tsx uses motion-safe:animate-pulse", () => { + const src = readSrc("components/TemplatePalette.tsx"); + expect(src.includes("animate-pulse") && !src.includes("motion-safe:animate-pulse")).toBe(false); + expect(src).toContain("motion-safe:animate-pulse"); + }); + + it("globals.css disables animate-in and slide-in classes under reduced-motion", () => { + const css = readSrc("app/globals.css"); + expect(css).toContain(".animate-in"); + expect(css).toContain(".slide-in-from-bottom"); + expect(css).toContain("animation: none !important"); + }); + + it("globals.css disables React Flow animated edges under reduced-motion", () => { + const css = readSrc("app/globals.css"); + expect(css).toContain(".react-flow__edge.animated"); + }); +}); diff --git a/canvas/src/app/globals.css b/canvas/src/app/globals.css index f7b097eb..a88ce30a 100644 --- a/canvas/src/app/globals.css +++ b/canvas/src/app/globals.css @@ -110,3 +110,39 @@ body { .react-flow__node { animation: node-appear 0.3s ease-out; } + +/* ============================================================ + REDUCED MOTION — WCAG 2.3.3 + Disable all animations/transitions for users who prefer it. + ============================================================ */ +@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; + } + + /* Custom slide-in / animate-in classes used by Toaster & ApprovalBanner */ + .animate-in, + .slide-in-from-bottom, + .slide-in-from-top, + .slide-in-from-right { + animation: none !important; + transform: none !important; + opacity: 1 !important; + } + + /* React Flow animated edges */ + .react-flow__edge.animated .react-flow__edge-path { + animation: none !important; + stroke-dasharray: none !important; + } + + /* React Flow node appear animation */ + .react-flow__node { + animation: none !important; + } +} diff --git a/canvas/src/components/Legend.tsx b/canvas/src/components/Legend.tsx index ea39f373..e649b6d5 100644 --- a/canvas/src/components/Legend.tsx +++ b/canvas/src/components/Legend.tsx @@ -10,7 +10,7 @@ export function Legend() {
Status
- + diff --git a/canvas/src/components/SearchDialog.tsx b/canvas/src/components/SearchDialog.tsx index 81902c7b..44c79b90 100644 --- a/canvas/src/components/SearchDialog.tsx +++ b/canvas/src/components/SearchDialog.tsx @@ -145,7 +145,7 @@ export function SearchDialog() { className={`w-2 h-2 rounded-full shrink-0 ${ node.data.status === "online" ? "bg-emerald-400" : node.data.status === "failed" ? "bg-red-400" : - node.data.status === "provisioning" ? "bg-sky-400 animate-pulse" : + node.data.status === "provisioning" ? "bg-sky-400 motion-safe:animate-pulse" : "bg-zinc-500" }`} /> diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index 7ba93f62..096dda16 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -174,7 +174,7 @@ export function SidePanel() { {node.data.currentTask && (
-
+
{node.data.currentTask} diff --git a/canvas/src/components/StatusDot.tsx b/canvas/src/components/StatusDot.tsx index 01cdaab0..6e234892 100644 --- a/canvas/src/components/StatusDot.tsx +++ b/canvas/src/components/StatusDot.tsx @@ -6,7 +6,7 @@ export const STATUS_COLORS: Record = { paused: "bg-indigo-400", degraded: "bg-amber-400", failed: "bg-red-400", - provisioning: "bg-sky-400 animate-pulse", + provisioning: "bg-sky-400 motion-safe:animate-pulse", }; export function StatusDot({ diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx index 447dc871..3bd5fb44 100644 --- a/canvas/src/components/TemplatePalette.tsx +++ b/canvas/src/components/TemplatePalette.tsx @@ -405,7 +405,7 @@ export function TemplatePalette() { )} {isDeploying && ( -
Deploying...
+
Deploying...
)} ); diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index 11ed276c..b69c0fd0 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -125,7 +125,7 @@ export function Toolbar() { )} {counts.provisioning > 0 && ( - + )} {counts.failed > 0 && ( @@ -266,7 +266,7 @@ function WsStatusPill({ status }: { status: "connected" | "connecting" | "discon if (status === "connecting") { return (
-
+
Reconnecting
); diff --git a/canvas/src/components/WorkspaceNode.tsx b/canvas/src/components/WorkspaceNode.tsx index 246fb512..7afac42f 100644 --- a/canvas/src/components/WorkspaceNode.tsx +++ b/canvas/src/components/WorkspaceNode.tsx @@ -33,7 +33,7 @@ const STATUS_CONFIG: Record>) {data.currentTask && (
-
+
{data.currentTask}
@@ -240,7 +240,7 @@ export function WorkspaceNode({ id, data }: NodeProps>) {data.activeTasks > 0 && (
-
+
{data.activeTasks} task{data.activeTasks > 1 ? "s" : ""} @@ -424,7 +424,7 @@ function TeamMemberChip({ ) :
} {data.activeTasks > 0 && (
-
+
{data.activeTasks} @@ -436,7 +436,7 @@ function TeamMemberChip({ {data.currentTask && (
-
+
{data.currentTask}
diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 94d7818e..de1fbbae 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -367,9 +367,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
- - - + + + {thinkingElapsed}s
diff --git a/canvas/src/components/tabs/TerminalTab.tsx b/canvas/src/components/tabs/TerminalTab.tsx index dedcf81c..669df931 100644 --- a/canvas/src/components/tabs/TerminalTab.tsx +++ b/canvas/src/components/tabs/TerminalTab.tsx @@ -126,7 +126,7 @@ export function TerminalTab({ workspaceId }: Props) {