fix(canvas): WCAG critical — ARIA live toasts, dialog focus trap, keyboard nav

Addresses the three release-blocking WCAG violations from the UX audit
(3rd consecutive cycle) and the new ChatTab ARIA gap from Audit #2.

Changes:
- Toaster: split into polite (success/info) + assertive (error) live
  regions, both always in DOM so screen readers register them before
  any toast fires. Adds x dismiss button on every toast. Errors no
  longer auto-expire after 4s — persist until explicitly dismissed.
- ConfirmDialog: on open, requestAnimationFrame focuses the first
  button inside the dialog. Tab/Shift-Tab is now trapped inside the
  dialog while open. Added role="dialog" aria-modal="true" and
  aria-labelledby pointing to the title h3.
- WorkspaceNode: outer div gains role="button", tabIndex={0},
  aria-label, aria-pressed, and onKeyDown (Enter/Space => selectNode,
  ContextMenu key => openContextMenu). Keyboard-only users can now
  reach and activate workspace nodes.
- ChatTab sub-tab bar: role="tablist" on wrapper, role="tab" +
  aria-selected + aria-controls on each button, matching
  role="tabpanel" + id on each panel div. Textarea gets
  aria-label="Message to agent".

453/453 Vitest tests pass. Production build clean (Next.js 15).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev Lead Agent 2026-04-15 08:31:06 +00:00
parent 4a65c72860
commit cf8db07020
5 changed files with 1778 additions and 186 deletions

1784
canvas/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -42,11 +42,48 @@ export function ConfirmDialog({
setMounted(true);
}, []);
// Move focus into the dialog when it opens (WCAG 2.1 SC 2.4.3 / 3.2.2)
useEffect(() => {
if (!open || !mounted) return;
const raf = requestAnimationFrame(() => {
dialogRef.current?.querySelector<HTMLElement>("button")?.focus();
});
return () => cancelAnimationFrame(raf);
}, [open, mounted]);
// Keyboard: Escape cancels, Enter confirms, Tab is trapped within the dialog
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancelRef.current();
if (e.key === "Enter") onConfirmRef.current();
if (e.key === "Escape") {
onCancelRef.current();
return;
}
if (e.key === "Enter") {
onConfirmRef.current();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => !el.hasAttribute("disabled"));
if (focusable.length === 0) { e.preventDefault(); return; }
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
@ -68,13 +105,16 @@ export function ConfirmDialog({
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
{/* Dialog */}
{/* Dialog — role="dialog" + aria-modal prevent interaction with background */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
>
<div className="px-5 py-4">
<h3 className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
<p className="text-[13px] text-zinc-400 leading-relaxed">{message}</p>
</div>

View File

@ -18,35 +18,87 @@ export function showToast(message: string, type: Toast["type"] = "info") {
export function Toaster() {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = (id: string) =>
setToasts((prev) => prev.filter((t) => t.id !== id));
useEffect(() => {
addToastFn = (message, type = "info") => {
const id = Math.random().toString(36).slice(2);
setToasts((prev) => [...prev.slice(-4), { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
// Errors persist until the user explicitly dismisses them.
// Success / info auto-expire after 4 s.
if (type !== "error") {
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}
};
return () => {
addToastFn = null;
};
return () => { addToastFn = null; };
}, []);
if (toasts.length === 0) return null;
const toastCls = (type: Toast["type"]) =>
`flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
type === "success"
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
: type === "error"
? "bg-red-950/90 border border-red-700/40 text-red-200"
: "bg-zinc-900/90 border border-zinc-700/40 text-zinc-200"
}`;
const pos =
"fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center";
return (
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center">
{toasts.map((toast) => (
<div
key={toast.id}
className={`px-4 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
toast.type === "success"
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
: toast.type === "error"
? "bg-red-950/90 border border-red-700/40 text-red-200"
: "bg-zinc-900/90 border border-zinc-700/40 text-zinc-200"
}`}
>
{toast.message}
</div>
))}
</div>
<>
{/*
* Polite live region success & info notifications.
* Always rendered so screen readers register it before any toast fires.
*/}
<div role="status" aria-live="polite" aria-atomic="false" className={pos}>
{toasts
.filter((t) => t.type !== "error")
.map((toast) => (
<div key={toast.id} className={toastCls(toast.type)}>
<span>{toast.message}</span>
<button
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
>
×
</button>
</div>
))}
</div>
{/*
* Assertive live region errors only.
* aria-live="assertive" interrupts the screen reader immediately.
* Errors never auto-expire; user must dismiss via the × button.
*/}
<div
role="alert"
aria-live="assertive"
aria-atomic="false"
className={pos}
>
{toasts
.filter((t) => t.type === "error")
.map((toast) => (
<div key={toast.id} className={toastCls(toast.type)}>
<span>{toast.message}</span>
<button
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
>
×
</button>
</div>
))}
</div>
</>
);
}

View File

@ -77,6 +77,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
return (
<div
role="button"
tabIndex={0}
aria-label={`${data.name} workspace — ${data.status}`}
aria-pressed={isSelected}
onClick={(e) => {
e.stopPropagation();
selectNode(isSelected ? null : id);
@ -92,6 +96,21 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
e.stopPropagation();
openContextMenu({ x: e.clientX, y: e.clientY, nodeId: id, nodeData: data });
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectNode(isSelected ? null : id);
} else if (e.key === "ContextMenu") {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
openContextMenu({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
nodeId: id,
nodeData: data,
});
}
}}
className={`
group relative rounded-xl
${hasGrandchildren ? "min-w-[720px] max-w-[960px]" : hasChildren ? "min-w-[320px] max-w-[450px]" : "min-w-[210px] max-w-[280px]"}

View File

@ -97,9 +97,12 @@ export function ChatTab({ workspaceId, data }: Props) {
return (
<div className="flex flex-col h-full">
{/* Sub-tab bar */}
<div className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0">
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
<div role="tablist" className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0">
<button
role="tab"
aria-selected={subTab === "my-chat"}
aria-controls="chat-panel-my-chat"
onClick={() => setSubTab("my-chat")}
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
subTab === "my-chat"
@ -110,6 +113,9 @@ export function ChatTab({ workspaceId, data }: Props) {
My Chat
</button>
<button
role="tab"
aria-selected={subTab === "agent-comms"}
aria-controls="chat-panel-agent-comms"
onClick={() => setSubTab("agent-comms")}
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
subTab === "agent-comms"
@ -123,9 +129,13 @@ export function ChatTab({ workspaceId, data }: Props) {
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{subTab === "my-chat" ? (
<MyChatPanel workspaceId={workspaceId} data={data} />
<div id="chat-panel-my-chat" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
<MyChatPanel workspaceId={workspaceId} data={data} />
</div>
) : (
<AgentCommsPanel workspaceId={workspaceId} />
<div id="chat-panel-agent-comms" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
<AgentCommsPanel workspaceId={workspaceId} />
</div>
)}
</div>
</div>
@ -408,6 +418,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
<div className="p-3 border-t border-zinc-800">
<div className="flex gap-2">
<textarea
aria-label="Message to agent"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {