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() {