>(undefined);
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail;
- // Small delay so ReactFlow has time to lay out the node
+ // Small delay so ReactFlow has time to measure the newly rendered node
clearTimeout(panTimerRef.current);
panTimerRef.current = setTimeout(() => {
- const node = useCanvasStore.getState().nodes.find((n) => n.id === nodeId);
- if (node) {
- setCenter(node.position.x + 130, node.position.y + 60, { zoom: 1, duration: 500 });
- }
+ fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 });
}, 100);
};
window.addEventListener("molecule:pan-to-node", handler);
@@ -150,7 +149,7 @@ function CanvasInner() {
window.removeEventListener("molecule:pan-to-node", handler);
clearTimeout(panTimerRef.current);
};
- }, [setCenter]);
+ }, [fitView]);
useEffect(() => {
const handler = (e: Event) => {
const { nodeId } = (e as CustomEvent).detail;
diff --git a/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
new file mode 100644
index 00000000..6e175ab4
--- /dev/null
+++ b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
@@ -0,0 +1,171 @@
+// @vitest-environment jsdom
+/**
+ * Tests that Canvas.tsx responds to the "molecule:pan-to-node" custom event
+ * (fired by canvas-events.ts on WORKSPACE_PROVISIONING for new nodes) by
+ * calling fitView({ nodes: [{ id }] }) instead of setCenter with a forced
+ * zoom=1 (which was jarring when the user was zoomed out — issue #426).
+ */
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, act, cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// ── Shared fitView spy — must be set up before vi.mock hoisting ──────────────
+const mockFitView = vi.fn();
+const mockFitBounds = vi.fn();
+
+vi.mock("@xyflow/react", () => {
+ const ReactFlow = ({
+ children,
+ "aria-label": ariaLabel,
+ }: {
+ children?: React.ReactNode;
+ "aria-label"?: string;
+ }) => (
+
+ {children}
+
+ );
+ return {
+ __esModule: true,
+ default: ReactFlow,
+ ReactFlow,
+ ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => (
+ <>{children}>
+ ),
+ Background: () => null,
+ Controls: () => null,
+ MiniMap: () => null,
+ BackgroundVariant: { Dots: "dots" },
+ useReactFlow: () => ({
+ fitView: mockFitView,
+ fitBounds: mockFitBounds,
+ setViewport: vi.fn(),
+ getIntersectingNodes: vi.fn(() => []),
+ setCenter: vi.fn(),
+ }),
+ applyNodeChanges: vi.fn((_: unknown, nodes: unknown) => nodes),
+ useStore: vi.fn(() => ({ width: 800, height: 600 })),
+ };
+});
+
+// ── Canvas store mock ─────────────────────────────────────────────────────────
+const mockStoreState = {
+ nodes: [{ id: "ws-1", position: { x: 100, y: 100 }, data: { name: "WS1" } }],
+ edges: [],
+ selectedNodeId: null,
+ panelTab: "chat",
+ dragOverNodeId: null,
+ contextMenu: null,
+ viewport: { x: 0, y: 0, zoom: 1 },
+ searchOpen: false,
+ onNodesChange: vi.fn(),
+ savePosition: vi.fn(),
+ saveViewport: vi.fn(),
+ selectNode: vi.fn(),
+ openContextMenu: vi.fn(),
+ closeContextMenu: vi.fn(),
+ setDragOverNode: vi.fn(),
+ nestNode: vi.fn(),
+ isDescendant: vi.fn(() => false),
+ setSearchOpen: vi.fn(),
+};
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: Object.assign(
+ vi.fn((selector: (s: typeof mockStoreState) => unknown) =>
+ selector(mockStoreState)
+ ),
+ { getState: () => mockStoreState }
+ ),
+}));
+
+vi.mock("@/store/socket", () => ({
+ connectSocket: vi.fn(),
+ disconnectSocket: vi.fn(),
+}));
+
+// ── Stub child components ─────────────────────────────────────────────────────
+vi.mock("../Toolbar", () => ({ Toolbar: () => null }));
+vi.mock("../SidePanel", () => ({ SidePanel: () => null }));
+vi.mock("../EmptyState", () => ({ EmptyState: () => null }));
+vi.mock("../ContextMenu", () => ({ ContextMenu: () => null }));
+vi.mock("../SearchDialog", () => ({ SearchDialog: () => null }));
+vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
+vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
+vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
+vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
+vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
+vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
+vi.mock("../settings", () => ({
+ SettingsPanel: () => null,
+ DeleteConfirmDialog: () => null,
+}));
+vi.mock("../Toaster", () => ({ Toaster: () => null }));
+vi.mock("../WorkspaceNode", () => ({ WorkspaceNode: () => null }));
+
+import { Canvas } from "../Canvas";
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe("Canvas — molecule:pan-to-node event handler", () => {
+ beforeEach(() => {
+ mockFitView.mockClear();
+ mockFitBounds.mockClear();
+ });
+
+ it("calls fitView with the provisioned nodeId after a 100ms debounce", async () => {
+ vi.useFakeTimers();
+ render(
);
+
+ // Simulate the custom event fired by canvas-events.ts on WORKSPACE_PROVISIONING
+ act(() => {
+ window.dispatchEvent(
+ new CustomEvent("molecule:pan-to-node", { detail: { nodeId: "ws-1" } })
+ );
+ });
+
+ // fitView should NOT be called yet (100ms debounce)
+ expect(mockFitView).not.toHaveBeenCalled();
+
+ // Advance past the 100ms delay
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ });
+
+ expect(mockFitView).toHaveBeenCalledOnce();
+ const [options] = mockFitView.mock.calls[0];
+ expect(options.nodes).toEqual([{ id: "ws-1" }]);
+ expect(options.duration).toBe(400);
+ expect(typeof options.padding).toBe("number");
+
+ vi.useRealTimers();
+ });
+
+ it("debounces rapid successive events — only the last nodeId is fitted", async () => {
+ vi.useFakeTimers();
+ render(
);
+
+ act(() => {
+ window.dispatchEvent(
+ new CustomEvent("molecule:pan-to-node", { detail: { nodeId: "ws-first" } })
+ );
+ window.dispatchEvent(
+ new CustomEvent("molecule:pan-to-node", { detail: { nodeId: "ws-last" } })
+ );
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ });
+
+ // Only one fitView call — the debounce clears the first timer
+ expect(mockFitView).toHaveBeenCalledOnce();
+ expect(mockFitView.mock.calls[0][0].nodes).toEqual([{ id: "ws-last" }]);
+
+ vi.useRealTimers();
+ });
+});
diff --git a/canvas/src/components/__tests__/aria-time-sensitive.test.tsx b/canvas/src/components/__tests__/aria-time-sensitive.test.tsx
new file mode 100644
index 00000000..d7bf8cc9
--- /dev/null
+++ b/canvas/src/components/__tests__/aria-time-sensitive.test.tsx
@@ -0,0 +1,161 @@
+// @vitest-environment jsdom
+/**
+ * WCAG 2 audit — time-sensitive component ARIA fixes:
+ * Fix 1: ApprovalBanner — role="alert" aria-live="assertive" + aria-hidden on ⚠ icon
+ * Fix 2: TerminalTab — role="status" on connection bar, role="alert" on error
+ * Fix 3: BundleDropZone — keyboard file-picker (hidden
+ accessible button)
+ * + role="status" on result toast
+ */
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, screen, cleanup, fireEvent } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// Fix 1 — ApprovalBanner
+// ────────────────────────────────────────────────────────────────────────────
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ get: vi.fn().mockResolvedValue([]),
+ post: vi.fn().mockResolvedValue({}),
+ },
+}));
+
+vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
+
+import { api } from "@/lib/api";
+import { ApprovalBanner } from "../ApprovalBanner";
+
+// Stub a minimal approval so the banner renders
+const mockApproval = {
+ id: "a1",
+ workspace_id: "ws-1",
+ workspace_name: "PM Agent",
+ action: "Run deployment script",
+ reason: "Routine release",
+ status: "pending",
+ created_at: new Date().toISOString(),
+};
+
+describe("ApprovalBanner — ARIA time-sensitive (Fix 1)", () => {
+ beforeEach(() => {
+ vi.mocked(api.get).mockResolvedValue([mockApproval]);
+ });
+
+ it("renders role='alert' with aria-live='assertive' on each approval card", async () => {
+ const { findByRole } = render(
);
+ const alert = await findByRole("alert");
+ expect(alert.getAttribute("aria-live")).toBe("assertive");
+ expect(alert.getAttribute("aria-atomic")).toBe("true");
+ });
+
+ it("⚠ icon span has aria-hidden='true'", async () => {
+ render(
);
+ // Wait for data
+ await screen.findByRole("alert");
+ // The ⚠ span should be aria-hidden
+ const hiddenSpans = document.querySelectorAll('[aria-hidden="true"]');
+ const warningSpan = Array.from(hiddenSpans).find((el) =>
+ el.textContent?.includes("⚠")
+ );
+ expect(warningSpan).not.toBeNull();
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// Fix 2 — TerminalTab
+// ────────────────────────────────────────────────────────────────────────────
+
+// Mock xterm — not installed in jsdom, just need component to render
+vi.mock("@xterm/xterm", () => ({
+ Terminal: class {
+ loadAddon = vi.fn();
+ open = vi.fn();
+ dispose = vi.fn();
+ onData = vi.fn(() => ({ dispose: vi.fn() }));
+ onResize = vi.fn(() => ({ dispose: vi.fn() }));
+ writeln = vi.fn();
+ write = vi.fn();
+ clear = vi.fn();
+ options = {};
+ },
+}));
+vi.mock("@xterm/addon-fit", () => ({
+ FitAddon: class {
+ fit = vi.fn();
+ activate = vi.fn();
+ dispose = vi.fn();
+ },
+}));
+vi.mock("@xterm/addon-web-links", () => ({
+ WebLinksAddon: class { activate = vi.fn(); dispose = vi.fn(); },
+}));
+
+import { TerminalTab } from "../tabs/TerminalTab";
+
+describe("TerminalTab — ARIA live regions (Fix 2)", () => {
+ it("status bar wrapper has role='status' and aria-live='polite'", () => {
+ render(
);
+ const statusBar = document.querySelector('[role="status"]');
+ expect(statusBar).not.toBeNull();
+ expect(statusBar?.getAttribute("aria-live")).toBe("polite");
+ });
+
+ it("status bar text changes reflect connection state (content test)", () => {
+ render(
);
+ // Default state while attempting to connect will show some status text
+ const statusBar = document.querySelector('[role="status"]');
+ expect(statusBar?.textContent?.length).toBeGreaterThan(0);
+ });
+});
+
+// ────────────────────────────────────────────────────────────────────────────
+// Fix 3 — BundleDropZone
+// ────────────────────────────────────────────────────────────────────────────
+
+import { BundleDropZone } from "../BundleDropZone";
+
+describe("BundleDropZone — keyboard accessibility (Fix 3)", () => {
+ it("renders a hidden file input with accept='.bundle.json' and an accessible label", () => {
+ render(
);
+ const input = document.getElementById("bundle-file-input") as HTMLInputElement;
+ expect(input).not.toBeNull();
+ expect(input?.type).toBe("file");
+ expect(input?.accept).toBe(".bundle.json");
+ expect(input?.getAttribute("aria-label")).toBeTruthy();
+ // Must be visually hidden but still reachable by AT
+ expect(input?.className).toContain("sr-only");
+ });
+
+ it("renders a keyboard-accessible import button that is tabbable", () => {
+ render(
);
+ // The button may be sr-only but must exist in the DOM and be focusable
+ const btn = screen.getByRole("button", { name: /import bundle/i });
+ expect(btn).not.toBeNull();
+ });
+
+ it("result toast renders with role='status' and aria-live='polite'", async () => {
+ vi.mocked(api.post).mockResolvedValue({ name: "my-bundle", status: "ok" });
+
+ render(
);
+
+ const input = document.getElementById("bundle-file-input") as HTMLInputElement;
+
+ const file = new File(['{"workspaces":[]}'], "test.bundle.json", {
+ type: "application/json",
+ });
+
+ // Simulate file selection via the hidden input
+ Object.defineProperty(input, "files", { value: [file], configurable: true });
+ await fireEvent.change(input);
+
+ // Toast should appear with role=status
+ const toast = await screen.findByRole("status");
+ expect(toast).not.toBeNull();
+ expect(toast.getAttribute("aria-live")).toBe("polite");
+ });
+});
diff --git a/canvas/src/components/tabs/TerminalTab.tsx b/canvas/src/components/tabs/TerminalTab.tsx
index 6278d377..371a5638 100644
--- a/canvas/src/components/tabs/TerminalTab.tsx
+++ b/canvas/src/components/tabs/TerminalTab.tsx
@@ -121,8 +121,8 @@ export function TerminalTab({ workspaceId }: Props) {
return (
- {/* Status bar */}
-
+ {/* Status bar — role="status" so connection state changes are announced politely */}
+
- {/* Error message */}
+ {/* Error message — role="alert" announces immediately via assertive live region */}
{errorMsg && (
-
+
{errorMsg}
)}
diff --git a/canvas/src/store/__tests__/canvas-events-pan.test.ts b/canvas/src/store/__tests__/canvas-events-pan.test.ts
new file mode 100644
index 00000000..cca7c945
--- /dev/null
+++ b/canvas/src/store/__tests__/canvas-events-pan.test.ts
@@ -0,0 +1,104 @@
+// @vitest-environment jsdom
+/**
+ * Tests the molecule:pan-to-node CustomEvent dispatch from canvas-events.ts.
+ * Runs in jsdom because window.dispatchEvent is required.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { handleCanvasEvent, resetProvisioningSequence } from "../canvas-events";
+import type { WSMessage } from "../socket";
+import type { WorkspaceNodeData } from "../canvas";
+import type { Node, Edge } from "@xyflow/react";
+
+// ── Helpers (copied from canvas-events.test.ts) ──────────────────────────────
+
+function makeNode(
+ id: string,
+ overrides: Partial
= {}
+): Node {
+ return {
+ id,
+ type: "workspaceNode",
+ position: { x: 0, y: 0 },
+ data: {
+ name: `Node-${id}`,
+ status: "online",
+ tier: 1,
+ agentCard: null,
+ activeTasks: 0,
+ collapsed: false,
+ role: "agent",
+ lastErrorRate: 0,
+ lastSampleError: "",
+ url: "http://localhost:9000",
+ parentId: null,
+ currentTask: "",
+ needsRestart: false,
+ runtime: "",
+ ...overrides,
+ },
+ };
+}
+
+function makeMsg(
+ overrides: Partial & { event: string; workspace_id: string }
+): WSMessage {
+ return { timestamp: new Date().toISOString(), payload: {}, ...overrides };
+}
+
+function makeStore(
+ nodes: Node[] = [],
+ edges: Edge[] = []
+) {
+ const state = { nodes, edges, selectedNodeId: null, agentMessages: {} };
+ const get = () => state;
+ const set = vi.fn((partial: Record) => { Object.assign(state, partial); });
+ return { state, get, set };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe("canvas-events – molecule:pan-to-node dispatch", () => {
+ beforeEach(() => {
+ resetProvisioningSequence();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("dispatches molecule:pan-to-node with the new nodeId for a NEW provision", () => {
+ const { get, set } = makeStore([]);
+ const dispatched: Event[] = [];
+ const spy = vi.spyOn(window, "dispatchEvent").mockImplementation((e) => {
+ dispatched.push(e);
+ return true;
+ });
+
+ handleCanvasEvent(
+ makeMsg({ event: "WORKSPACE_PROVISIONING", workspace_id: "ws-new", payload: {} }),
+ get,
+ set
+ );
+
+ expect(dispatched).toHaveLength(1);
+ expect(dispatched[0].type).toBe("molecule:pan-to-node");
+ expect((dispatched[0] as CustomEvent).detail?.nodeId).toBe("ws-new");
+ });
+
+ it("does NOT dispatch molecule:pan-to-node when restarting an existing node", () => {
+ const { get, set } = makeStore([makeNode("ws-existing")]);
+ const dispatched: Event[] = [];
+ const spy = vi.spyOn(window, "dispatchEvent").mockImplementation((e) => {
+ dispatched.push(e);
+ return true;
+ });
+
+ handleCanvasEvent(
+ makeMsg({ event: "WORKSPACE_PROVISIONING", workspace_id: "ws-existing", payload: {} }),
+ get,
+ set
+ );
+
+ expect(dispatched).toHaveLength(0);
+ });
+});