diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index ebd8a1d3..5983b72f 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { ReactFlow, ReactFlowProvider, @@ -187,6 +187,23 @@ function CanvasInner() { // Pan-to-node / zoom-to-team CustomEvent listeners + viewport save. const { onMoveEnd } = useCanvasViewport(); + // Screen-reader announcements — read liveAnnouncement from the store and + // immediately clear it so the same announcement doesn't re-fire on + // re-render. Using a ref avoids a setState loop while keeping the + // effect reactive to new announcement strings. + const liveAnnouncement = useCanvasStore((s) => s.liveAnnouncement); + const clearAnnouncement = useCanvasStore((s) => s.setLiveAnnouncement); + const prevAnnouncement = useRef(""); + useEffect(() => { + if (liveAnnouncement && liveAnnouncement !== prevAnnouncement.current) { + prevAnnouncement.current = liveAnnouncement; + // Small delay so the DOM update lands before clearing, giving + // screen readers time to pick up the new text. + const timer = setTimeout(() => clearAnnouncement(""), 500); + return () => clearTimeout(timer); + } + }, [liveAnnouncement, clearAnnouncement]); + // Delete-confirmation lives in the store so the dialog survives ContextMenu // unmounting — the prior local-in-ContextMenu state raced with the menu's // outside-click handler. @@ -326,11 +343,21 @@ function CanvasInner() { - {/* Screen-reader live region: announces workspace count on canvas load or change */} -
- {nodes.filter((n) => !n.parentId).length === 0 - ? "No workspaces on canvas" - : `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`} + {/* Screen-reader live region — announces workspace count on initial load and + live status updates from WebSocket events (online, offline, provisioning, etc.). + The liveAnnouncement text is cleared after the screen reader has had time + to read it so the same message doesn't re-announce on re-render. */} +
+ {liveAnnouncement || ( + nodes.filter((n) => !n.parentId).length === 0 + ? "No workspaces on canvas" + : `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas` + )}
{nodes.length === 0 && } diff --git a/canvas/src/store/__tests__/canvas-events.test.ts b/canvas/src/store/__tests__/canvas-events.test.ts index 84f81d57..ddd7d0cc 100644 --- a/canvas/src/store/__tests__/canvas-events.test.ts +++ b/canvas/src/store/__tests__/canvas-events.test.ts @@ -835,3 +835,181 @@ describe("handleCanvasEvent – unknown event", () => { ).not.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// Screen-reader live announcements +// --------------------------------------------------------------------------- + +describe("handleCanvasEvent – liveAnnouncement", () => { + it("announces WORKSPACE_ONLINE with node name", () => { + const node = makeNode("ws-1", { name: "Alpha" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_ONLINE", workspace_id: "ws-1" }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Alpha is now online"); + }); + + it("announces WORKSPACE_OFFLINE with node name", () => { + const node = makeNode("ws-1", { name: "Beta" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_OFFLINE", workspace_id: "ws-1" }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Beta is now offline"); + }); + + it("announces WORKSPACE_PAUSED with node name", () => { + const node = makeNode("ws-1", { name: "Gamma" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_PAUSED", workspace_id: "ws-1" }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Gamma has been paused"); + }); + + it("announces WORKSPACE_DEGRADED with node name", () => { + const node = makeNode("ws-1", { name: "Delta" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_DEGRADED", + workspace_id: "ws-1", + payload: { sample_error: "connection timeout" }, + }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Delta is degraded"); + }); + + it("announces WORKSPACE_PROVISIONING for new workspace with payload name", () => { + const { get, set, state } = makeStore([]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_PROVISIONING", + workspace_id: "ws-new", + payload: { name: "NewBot" }, + }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("NewBot is provisioning"); + }); + + it("announces WORKSPACE_PROVISIONING for new workspace with default name", () => { + const { get, set, state } = makeStore([]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_PROVISIONING", + workspace_id: "ws-new", + payload: {}, + }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("New Workspace is provisioning"); + }); + + it("announces WORKSPACE_REMOVED with node name", () => { + const node = makeNode("ws-1", { name: "Gamma" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_REMOVED", workspace_id: "ws-1" }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Gamma was removed"); + }); + + it("announces WORKSPACE_PROVISION_FAILED with node name", () => { + const node = makeNode("ws-1", { name: "Delta" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_PROVISION_FAILED", + workspace_id: "ws-1", + payload: { error: "docker pull failed" }, + }), + get, + set + ); + + expect(state.liveAnnouncement).toBe("Delta provisioning failed"); + }); + + it("does not announce for TASK_UPDATED", () => { + const node = makeNode("ws-1", { name: "Alpha" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ + event: "TASK_UPDATED", + workspace_id: "ws-1", + payload: { current_task: "building release", active_tasks: 1 }, + }), + get, + set + ); + + // TASK_UPDATED is noisy (every heartbeat); it should not announce + expect(state.liveAnnouncement ?? "").toBe(""); + }); + + it("does not announce for AGENT_MESSAGE", () => { + const node = makeNode("ws-1", { name: "Alpha" }); + const { get, set, state } = makeStore([node]); + + handleCanvasEvent( + makeMsg({ + event: "AGENT_MESSAGE", + workspace_id: "ws-1", + payload: { message: "hello from the agent" }, + }), + get, + set + ); + + expect(state.liveAnnouncement ?? "").toBe(""); + }); + + it("uses payload name for ONLINE when node not found in store", () => { + const { get, set, state } = makeStore([]); + + handleCanvasEvent( + makeMsg({ + event: "WORKSPACE_ONLINE", + workspace_id: "ws-1", + payload: { name: "FromPayload" }, + }), + get, + set + ); + + // ONLINE when node doesn't exist just buffers _pendingOnline; + // no announcement should be set + expect(state.liveAnnouncement ?? "").toBe(""); + }); +}); +}); diff --git a/canvas/src/store/canvas-events.ts b/canvas/src/store/canvas-events.ts index 30807530..97b204e2 100644 --- a/canvas/src/store/canvas-events.ts +++ b/canvas/src/store/canvas-events.ts @@ -80,6 +80,7 @@ export function handleCanvasEvent( switch (msg.event) { case "WORKSPACE_ONLINE": { const existing = nodes.find((n) => n.id === msg.workspace_id); + const nodeName = existing?.data.name ?? (msg.payload.name as string) ?? "Workspace"; if (!existing) { // PROVISIONING event hasn't been applied yet (WS reorder or // this tab joined mid-deploy). Buffer so the later PROVISIONING @@ -105,6 +106,7 @@ export function handleCanvasEvent( ? { ...n, data: { ...n.data, status: "online" } } : n, ), + liveAnnouncement: `${nodeName} is now online`, }); // Remove the laser class after its keyframe ends so the edge // settles into the app's default solid styling. Fire-and-forget. @@ -123,28 +125,36 @@ export function handleCanvasEvent( } case "WORKSPACE_OFFLINE": { + const offlineNode = nodes.find((n) => n.id === msg.workspace_id); + const offlineName = offlineNode?.data.name ?? "Workspace"; set({ nodes: nodes.map((n) => n.id === msg.workspace_id ? { ...n, data: { ...n.data, status: "offline" } } : n ), + liveAnnouncement: `${offlineName} is now offline`, }); break; } case "WORKSPACE_PAUSED": { + const pausedNode = nodes.find((n) => n.id === msg.workspace_id); + const pausedName = pausedNode?.data.name ?? "Workspace"; set({ nodes: nodes.map((n) => n.id === msg.workspace_id ? { ...n, data: { ...n.data, status: "paused", currentTask: "" } } : n ), + liveAnnouncement: `${pausedName} has been paused`, }); break; } case "WORKSPACE_DEGRADED": { + const degradedNode = nodes.find((n) => n.id === msg.workspace_id); + const degradedName = degradedNode?.data.name ?? "Workspace"; set({ nodes: nodes.map((n) => n.id === msg.workspace_id @@ -160,6 +170,7 @@ export function handleCanvasEvent( } : n ), + liveAnnouncement: `${degradedName} is degraded`, }); break; } @@ -230,6 +241,7 @@ export function handleCanvasEvent( // removed per demo feedback. A2A edges (showA2AEdges) still // render when enabled — those represent runtime traffic, // which nesting doesn't express. + const newNodeName = (msg.payload.name as string) ?? "New Workspace"; set({ nodes: [ ...nodes, @@ -244,7 +256,7 @@ export function handleCanvasEvent( ...(parentId ? { parentId } : {}), className: "mol-deploy-spawn", data: { - name: (msg.payload.name as string) ?? "New Workspace", + name: newNodeName, status: "provisioning", tier: (msg.payload.tier as number) ?? 1, agentCard: null, @@ -261,6 +273,7 @@ export function handleCanvasEvent( }, }, ], + liveAnnouncement: `${newNodeName} is provisioning`, }); // Grow the parent to fit the just-landed child. DEBOUNCED @@ -345,6 +358,7 @@ export function handleCanvasEvent( case "WORKSPACE_REMOVED": { const removedNode = nodes.find((n) => n.id === msg.workspace_id); + const removedName = removedNode?.data.name ?? "Workspace"; const parentOfRemoved = removedNode?.data.parentId ?? null; set({ nodes: nodes @@ -363,6 +377,7 @@ export function handleCanvasEvent( e.source !== msg.workspace_id && e.target !== msg.workspace_id ), selectedNodeId: selectedNodeId === msg.workspace_id ? null : selectedNodeId, + liveAnnouncement: `${removedName} was removed`, }); break; } @@ -445,6 +460,8 @@ export function handleCanvasEvent( case "WORKSPACE_PROVISION_FAILED": { const errorMsg = (msg.payload.error as string) ?? "Unknown provisioning error"; + const failedNode = nodes.find((n) => n.id === msg.workspace_id); + const failedName = failedNode?.data.name ?? "Workspace"; set({ nodes: nodes.map((n) => n.id === msg.workspace_id @@ -458,6 +475,7 @@ export function handleCanvasEvent( } : n ), + liveAnnouncement: `${failedName} provisioning failed`, }); break; } diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index 6f09907d..e2d6e150 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -225,6 +225,11 @@ interface CanvasState { /** Whether the A2A topology overlay is visible. Persisted to localStorage. Default: true. */ showA2AEdges: boolean; setShowA2AEdges: (show: boolean) => void; + /** Screen-reader announcement text. Set by handleCanvasEvent on significant + * status changes; consumed and cleared by the aria-live region in Canvas.tsx + * so the same announcement doesn't re-fire on re-render. */ + liveAnnouncement: string; + setLiveAnnouncement: (msg: string) => void; } export const useCanvasStore = create((set, get) => ({ @@ -321,6 +326,8 @@ export const useCanvasStore = create((set, get) => ({ localStorage.setItem("molecule:show-a2a-edges", String(show)); } }, + liveAnnouncement: "", + setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }), viewport: { x: 0, y: 0, zoom: 1 }, diff --git a/docs/design-system/canvas-audit-items.md b/docs/design-system/canvas-audit-items.md index 86888f69..2affbbdb 100644 --- a/docs/design-system/canvas-audit-items.md +++ b/docs/design-system/canvas-audit-items.md @@ -41,11 +41,10 @@ canvas/src/ ## Known Issues -### 🔴 HIGH: secrets-store.ts Performance +### ✅ MEDIUM: secrets-store.ts Performance (mitigated) **File:** `canvas/src/stores/secrets-store.ts` -**Issue:** `getGrouped()` selector creates new objects every call (Object.fromEntries + arrays). Not memoized. -**Impact:** Causes unnecessary re-renders on frequent selector access. -**Fix needed:** Memoize the selector or use a proper Zustand selector pattern. +**Issue:** `getGrouped()` selector creates new objects every call. Not memoized. +**Impact:** Mitigated — `SecretsTab.tsx` wraps the call in `useMemo`, so no active re-render issues in the single consumer. The store-level fix (memoizing `getGrouped` itself) is optional but low priority now. ### 🟡 MEDIUM: Pre-commit Hook Verification **Issue:** Pre-commit hook checks 'use client' on hook-using components but unclear if it actually fails on violations. @@ -108,7 +107,7 @@ canvas/src/ | Priority | Item | Files | Status | |----------|------|-------|--------| -| HIGH | Screen reader announcements for canvas state changes | Canvas.tsx | Not started | +| ~~HIGH~~ | ~~Screen reader announcements for canvas state changes~~ | ~~Canvas.tsx, canvas-events.ts, canvas.ts~~ | ✅ Done — PR #172 | | MEDIUM | Keyboard shortcut help dialog | useKeyboardShortcuts.ts | Not started | | MEDIUM | Keyboard-accessible node drag | WorkspaceNode.tsx, useDragHandlers.ts | Not started | | LOW | Edge anchor keyboard accessibility | A2AEdge.tsx | Not started |