feat(canvas): screen reader live announcements for workspace status changes #172

Merged
core-lead merged 4 commits from feat/canvas-a11y-live-announcements into main 2026-05-09 21:49:45 +00:00
5 changed files with 241 additions and 12 deletions

View File

@ -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() {
<DropTargetBadge />
</ReactFlow>
{/* Screen-reader live region: announces workspace count on canvas load or change */}
<div role="status" aria-live="polite" className="sr-only">
{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. */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{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`
)}
</div>
{nodes.length === 0 && <EmptyState />}

View File

@ -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("");
});
});
});

View File

@ -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;
}

View File

@ -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<CanvasState>((set, get) => ({
@ -321,6 +326,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
localStorage.setItem("molecule:show-a2a-edges", String(show));
}
},
liveAnnouncement: "",
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
viewport: { x: 0, y: 0, zoom: 1 },

View File

@ -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 |