Skip to content
Closed
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
90 changes: 90 additions & 0 deletions canvas/src/__tests__/reduced-motion.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
36 changes: 36 additions & 0 deletions canvas/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion canvas/src/components/Legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
2 changes: 1 addition & 1 deletion canvas/src/components/SearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}`}
/>
Expand Down
2 changes: 1 addition & 1 deletion canvas/src/components/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion canvas/src/components/StatusDot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion canvas/src/components/TemplatePalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
Expand Down
4 changes: 2 additions & 2 deletions canvas/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down Expand Up @@ -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>
);
Expand Down
10 changes: 5 additions & 5 deletions canvas/src/components/WorkspaceNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ✕ */
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand All @@ -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>
Expand Down
6 changes: 3 additions & 3 deletions canvas/src/components/tabs/ChatTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion canvas/src/components/tabs/TerminalTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
2 changes: 1 addition & 1 deletion canvas/src/store/__tests__/canvas-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
8 changes: 5 additions & 3 deletions canvas/src/store/canvas-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading