diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx
index 24c6c2fa..07aea51a 100644
--- a/canvas/src/components/Canvas.tsx
+++ b/canvas/src/components/Canvas.tsx
@@ -226,7 +226,14 @@ function CanvasInner() {
const settingsWorkspaceId = selectedNodeId ?? "global";
return (
-
+
+ >
);
}
diff --git a/canvas/src/components/__tests__/Canvas.a11y.test.tsx b/canvas/src/components/__tests__/Canvas.a11y.test.tsx
new file mode 100644
index 00000000..792e121c
--- /dev/null
+++ b/canvas/src/components/__tests__/Canvas.a11y.test.tsx
@@ -0,0 +1,138 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+});
+
+// ── Mock @xyflow/react ────────────────────────────────────────────────────────
+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: vi.fn(),
+ setViewport: vi.fn(),
+ getIntersectingNodes: vi.fn(() => []),
+ fitBounds: vi.fn(),
+ setCenter: vi.fn(),
+ }),
+ applyNodeChanges: vi.fn((_: unknown, nodes: unknown) => nodes),
+ useStore: vi.fn(() => ({ width: 800, height: 600 })),
+ };
+});
+
+// ── Mock the canvas store ─────────────────────────────────────────────────────
+const mockStoreState = {
+ nodes: [],
+ 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 }
+ ),
+}));
+
+// ── Mock the socket store ─────────────────────────────────────────────────────
+vi.mock("@/store/socket", () => ({
+ connectSocket: vi.fn(),
+ disconnectSocket: vi.fn(),
+}));
+
+// ── Mock all heavy child components to null ───────────────────────────────────
+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 the component under test AFTER mocks ───────────────────────────────
+import { Canvas } from "../Canvas";
+
+describe("Canvas — accessibility landmarks", () => {
+ it("renders a landmark with id='canvas-main'", () => {
+ render();
+ const main = screen.getByRole("main");
+ expect(main).toBeTruthy();
+ expect(main.id).toBe("canvas-main");
+ });
+
+ it("renders a skip-to-content link pointing at #canvas-main", () => {
+ render();
+ const skipLink = document.querySelector('a[href="#canvas-main"]');
+ expect(skipLink).toBeTruthy();
+ expect(skipLink?.textContent?.trim()).toBe("Skip to canvas");
+ });
+
+ it("ReactFlow wrapper receives aria-label describing the canvas", () => {
+ render();
+ const flow = document.querySelector('[data-testid="react-flow"]');
+ expect(flow?.getAttribute("aria-label")).toBe(
+ "Molecule AI workspace canvas"
+ );
+ });
+
+ it("skip link appears before in the DOM", () => {
+ render();
+ const body = document.body;
+ const skipLink = body.querySelector('a[href="#canvas-main"]');
+ const main = body.querySelector("main");
+ expect(skipLink).toBeTruthy();
+ expect(main).toBeTruthy();
+ // skip link must come before main in the DOM order
+ const position = skipLink!.compareDocumentPosition(main!);
+ expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+});