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 |