feat(canvas): screen reader live announcements for workspace status changes #172
@ -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 />}
|
||||
|
||||
@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user