From 95abca2f4f4c90bf1b9f1a54c970156e7abc7c24 Mon Sep 17 00:00:00 2001 From: Dev Lead Agent Date: Tue, 14 Apr 2026 11:25:23 +0000 Subject: [PATCH 1/2] 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 000000000..3f7eee05e --- /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 f7b097ebe..a88ce30ab 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 ea39f373e..e649b6d51 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 81902c7bd..44c79b908 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 7ba93f629..096dda165 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 01cdaab04..6e2348929 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 447dc871d..3bd5fb443 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 11ed276c3..b69c0fd06 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 246fb512f..7afac42ff 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 94d7818eb..de1fbbaee 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 dedcf81cc..669df9312 100644 --- a/canvas/src/components/tabs/TerminalTab.tsx +++ b/canvas/src/components/tabs/TerminalTab.tsx @@ -126,7 +126,7 @@ export function TerminalTab({ workspaceId }: Props) {
From 6e5909324633605a647b1cc15ac8774787c1cbab Mon Sep 17 00:00:00 2001 From: Dev Lead Agent Date: Tue, 14 Apr 2026 11:32:16 +0000 Subject: [PATCH 2/2] fix(canvas): add 100px origin offset to provisioning grid to avoid viewport clipping New nodes now start at (100, 100) instead of (0, 0), preventing them from being clipped at the viewport edge. Also normalises the index source from filtered root-only nodes to nodes.length, consistent with the form-created node path from PR #25. Co-Authored-By: Claude Sonnet 4.6 --- canvas/src/store/__tests__/canvas-events.test.ts | 2 +- canvas/src/store/canvas-events.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/canvas/src/store/__tests__/canvas-events.test.ts b/canvas/src/store/__tests__/canvas-events.test.ts index b4867cd33..60b472561 100644 --- a/canvas/src/store/__tests__/canvas-events.test.ts +++ b/canvas/src/store/__tests__/canvas-events.test.ts @@ -192,7 +192,7 @@ describe("handleCanvasEvent – WORKSPACE_PROVISIONING", () => { const n = newNodes[0]; expect(n.id).toBe("ws-new"); expect(n.type).toBe("workspaceNode"); - expect(n.position).toEqual({ x: 0, y: 0 }); + expect(n.position).toEqual({ x: 100, y: 100 }); expect(n.data.name).toBe("Brand New"); expect(n.data.tier).toBe(3); expect(n.data.status).toBe("provisioning"); diff --git a/canvas/src/store/canvas-events.ts b/canvas/src/store/canvas-events.ts index 81a3eb0e4..e091d34e9 100644 --- a/canvas/src/store/canvas-events.ts +++ b/canvas/src/store/canvas-events.ts @@ -92,9 +92,11 @@ export function handleCanvasEvent( const GRID_COL_WIDTH = 320; const GRID_ROW_HEIGHT = 160; const GRID_COLS = 4; - const rootIndex = nodes.filter((n) => !n.data.parentId).length; - const x = (rootIndex % GRID_COLS) * GRID_COL_WIDTH; - const y = Math.floor(rootIndex / GRID_COLS) * GRID_ROW_HEIGHT; + const GRID_ORIGIN_X = 100; + const GRID_ORIGIN_Y = 100; + const idx = nodes.length; + const x = GRID_ORIGIN_X + (idx % GRID_COLS) * GRID_COL_WIDTH; + const y = GRID_ORIGIN_Y + Math.floor(idx / GRID_COLS) * GRID_ROW_HEIGHT; set({ nodes: [