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 <noreply@anthropic.com>
This commit is contained in:
parent
c271293e9f
commit
889d397d32
90
canvas/src/__tests__/reduced-motion.test.ts
Normal file
90
canvas/src/__tests__/reduced-motion.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ export function Legend() {
|
||||
<div className="text-[11px] text-zinc-500 font-medium mb-1">Status</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<StatusItem color="bg-emerald-400" label="Online" />
|
||||
<StatusItem color="bg-sky-400 animate-pulse" label="Starting" />
|
||||
<StatusItem color="bg-sky-400 motion-safe:animate-pulse" label="Starting" />
|
||||
<StatusItem color="bg-amber-400" label="Degraded" />
|
||||
<StatusItem color="bg-red-400" label="Failed" />
|
||||
<StatusItem color="bg-indigo-400" label="Paused" />
|
||||
|
||||
@ -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"
|
||||
}`}
|
||||
/>
|
||||
|
||||
@ -174,7 +174,7 @@ export function SidePanel() {
|
||||
{node.data.currentTask && (
|
||||
<Tooltip text={node.data.currentTask as string}>
|
||||
<div className="px-4 py-2 bg-amber-950/20 border-b border-amber-800/20 flex items-center gap-2 cursor-default">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse shrink-0" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-amber-300/90 truncate">
|
||||
{node.data.currentTask}
|
||||
</span>
|
||||
|
||||
@ -6,7 +6,7 @@ export const STATUS_COLORS: Record<string, string> = {
|
||||
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({
|
||||
|
||||
@ -405,7 +405,7 @@ export function TemplatePalette() {
|
||||
)}
|
||||
|
||||
{isDeploying && (
|
||||
<div className="text-[10px] text-sky-400 mt-1.5 animate-pulse">Deploying...</div>
|
||||
<div className="text-[10px] text-sky-400 mt-1.5 motion-safe:animate-pulse">Deploying...</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -125,7 +125,7 @@ export function Toolbar() {
|
||||
<StatusPill color="bg-zinc-500" count={counts.offline} label="offline" />
|
||||
)}
|
||||
{counts.provisioning > 0 && (
|
||||
<StatusPill color="bg-sky-400 animate-pulse" count={counts.provisioning} label="starting" />
|
||||
<StatusPill color="bg-sky-400 motion-safe:animate-pulse" count={counts.provisioning} label="starting" />
|
||||
)}
|
||||
{counts.failed > 0 && (
|
||||
<StatusPill color="bg-red-400" count={counts.failed} label="failed" />
|
||||
@ -266,7 +266,7 @@ function WsStatusPill({ status }: { status: "connected" | "connecting" | "discon
|
||||
if (status === "connecting") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: reconnecting…">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-zinc-500">Reconnecting</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -33,7 +33,7 @@ const STATUS_CONFIG: Record<string, { dot: string; glow: string; label: string;
|
||||
paused: { dot: "bg-indigo-400", glow: "", label: "Paused", bar: "from-indigo-500/10 to-transparent" },
|
||||
degraded: { dot: "bg-amber-400", glow: "shadow-amber-400/50", label: "Degraded", bar: "from-amber-500/20 to-transparent" },
|
||||
failed: { dot: "bg-red-400", glow: "shadow-red-400/50", label: "Failed", bar: "from-red-500/20 to-transparent" },
|
||||
provisioning: { dot: "bg-sky-400 animate-pulse", glow: "shadow-sky-400/50", label: "Starting", bar: "from-sky-500/20 to-transparent" },
|
||||
provisioning: { dot: "bg-sky-400 motion-safe:animate-pulse", glow: "shadow-sky-400/50", label: "Starting", bar: "from-sky-500/20 to-transparent" },
|
||||
};
|
||||
|
||||
/** Eject/extract arrow icon — visually distinct from delete ✕ */
|
||||
@ -205,7 +205,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
{data.currentTask && (
|
||||
<Tooltip text={String(data.currentTask)}>
|
||||
<div className="flex items-center gap-1.5 mt-1 bg-amber-950/20 px-2 py-1 rounded-md border border-amber-800/20 cursor-default">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse shrink-0" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-amber-300/80 truncate">{data.currentTask}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -240,7 +240,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 animate-pulse" />
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-amber-300/80 tabular-nums">
|
||||
{data.activeTasks} task{data.activeTasks > 1 ? "s" : ""}
|
||||
</span>
|
||||
@ -424,7 +424,7 @@ function TeamMemberChip({
|
||||
) : <div />}
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 animate-pulse" />
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[7px] text-amber-300/80 tabular-nums">
|
||||
{data.activeTasks}
|
||||
</span>
|
||||
@ -436,7 +436,7 @@ function TeamMemberChip({
|
||||
{data.currentTask && (
|
||||
<Tooltip text={String(data.currentTask)}>
|
||||
<div className="flex items-center gap-1 mt-0.5 px-1.5 py-0.5 bg-amber-950/20 rounded border border-amber-800/20 cursor-default">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 animate-pulse shrink-0" />
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[7px] text-amber-300/70 truncate">{data.currentTask}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@ -367,9 +367,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
<div className="bg-zinc-800/50 border border-zinc-700/30 rounded-lg px-3 py-2 max-w-[85%]">
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span className="flex gap-0.5">
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full motion-safe:animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full motion-safe:animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full motion-safe:animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</span>
|
||||
{thinkingElapsed}s
|
||||
</div>
|
||||
|
||||
@ -126,7 +126,7 @@ export function TerminalTab({ workspaceId }: Props) {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
status === "connected" ? "bg-green-500" :
|
||||
status === "connecting" ? "bg-yellow-500 animate-pulse" :
|
||||
status === "connecting" ? "bg-yellow-500 motion-safe:animate-pulse" :
|
||||
status === "error" ? "bg-red-500" : "bg-zinc-500"
|
||||
}`} />
|
||||
<span className="text-[10px] text-zinc-400">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user