fix(canvas/a11y): add type="button" to remaining canvas component buttons (batch 3)

WCAG 4.1.2 / bug #1669 follow-up — final batch completing the campaign.
Added type="button" to all buttons missing it across 14 canvas components.

Files changed (14, all additions):
- Toolbar.tsx: Stop All, Restart All, A2A toggle, Audit shortcut, Quick help, Search shortcut, Help close (7)
- MemoryInspectorPanel.tsx: scope tabs, refresh, search clear ×2, expand, delete (6)
- TemplatePalette.tsx: org refresh, toggle, Import Agent, org import, deploy template, palette refresh (6)
- ProvisioningTimeout.tsx: Retry, Cancel Request, View Logs, Keep, Remove Workspace (5)
- ConsoleModal.tsx: close, Copy output, Close (3)
- OnboardingWizard.tsx: Skip guide, action, Next (3)
- ConversationTraceModal.tsx: close ×2 (2)
- WorkspaceNode.tsx: Restart banner, Extract from team (2)
- CommunicationOverlay.tsx: toggle, close panel (2)
- Toaster.tsx: dismiss ×2 (2)
- SearchDialog.tsx: search result button (1)
- TermsGate.tsx: accept (1)
- ErrorBoundary.tsx: Reload (1)
- BundleDropZone.tsx: import trigger (1)

Total campaign (batches 1-3): 27 + 42 = 69 buttons fixed across 24 components.
All 477 canvas vitest tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI · core-uiux 2026-04-24 00:23:51 +00:00
parent 32a3b84147
commit 6a96641c37
14 changed files with 228 additions and 0 deletions

View File

@ -108,6 +108,7 @@ export function BundleDropZone() {
{/* Keyboard-accessible import button visible on focus or hover so
keyboard / AT users can trigger bundle import without drag-and-drop (WCAG 2.1.1) */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
aria-label="Import bundle file"
aria-controls="bundle-file-input"

View File

@ -99,6 +99,7 @@ export function CommunicationOverlay() {
if (!visible || comms.length === 0) {
return (
<button
type="button"
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
@ -115,6 +116,7 @@ export function CommunicationOverlay() {
<span aria-hidden="true"> </span>Communications ({comms.length})
</div>
<button
type="button"
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-zinc-500 hover:text-zinc-300 text-xs"

View File

@ -109,6 +109,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
)}
</div>
<button
type="button"
ref={closeButtonRef}
onClick={onClose}
aria-label="Close"
@ -146,6 +147,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
{output && (
<button
type="button"
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(output);
@ -159,6 +161,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
</button>
)}
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-[11px] text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>

View File

@ -112,6 +112,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
</div>
<Dialog.Close asChild>
<button
type="button"
aria-label="Close conversation trace"
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
>
@ -283,6 +284,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
>
Close

View File

@ -81,6 +81,7 @@ export class ErrorBoundary extends React.Component<
</p>
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={this.handleReload}
className="rounded-lg bg-blue-600 hover:bg-blue-500 px-5 py-2 text-sm font-medium text-white transition-colors"
>

View File

@ -160,6 +160,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
<div className="flex items-center gap-1">
{SCOPES.map((scope) => (
<button
type="button"
key={scope}
onClick={() => setActiveScope(scope)}
aria-pressed={activeScope === scope}
@ -201,6 +202,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery("");
setDebouncedQuery("");
@ -240,6 +242,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
: `${entries.length} memories`}
</span>
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
aria-label="Refresh memories"
@ -273,6 +276,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
Try a different query or{" "}
<button
type="button"
onClick={() => {
setSearchQuery("");
setDebouncedQuery("");
@ -339,6 +343,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
{/* Header row */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
@ -409,6 +414,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
Created: {new Date(entry.created_at).toLocaleString()}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();

View File

@ -159,6 +159,7 @@ export function OnboardingWizard() {
Step {currentStepIdx + 1} of {STEPS.length}
</span>
<button
type="button"
onClick={dismiss}
aria-label="Skip onboarding guide"
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
@ -178,6 +179,7 @@ export function OnboardingWizard() {
{/* Action button */}
<div className="flex gap-2">
<button
type="button"
onClick={handleAction}
className="flex-1 px-3 py-1.5 bg-blue-600/90 hover:bg-blue-500 rounded-lg text-[11px] font-medium text-white transition-colors"
>
@ -191,6 +193,7 @@ export function OnboardingWizard() {
</button>
{step !== "done" && (
<button
type="button"
onClick={() => {
const next = STEPS[currentStepIdx + 1];
if (next) setStep(next.id);

View File

@ -284,6 +284,7 @@ export function ProvisioningTimeout({
{/* Action buttons */}
<div className="flex items-center gap-2 mt-2.5">
<button
type="button"
onClick={() => handleRetry(entry.workspaceId)}
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
@ -291,6 +292,7 @@ export function ProvisioningTimeout({
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
</button>
<button
type="button"
onClick={() => handleCancelRequest(entry.workspaceId)}
disabled={isRetrying || isCancelling}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-[11px] text-zinc-300 rounded-lg border border-zinc-600 disabled:opacity-40 transition-colors"
@ -298,6 +300,7 @@ export function ProvisioningTimeout({
{isCancelling ? "Cancelling..." : "Cancel"}
</button>
<button
type="button"
onClick={() => handleViewLogs(entry.workspaceId)}
className="px-3 py-1.5 text-[11px] text-amber-400 hover:text-amber-300 transition-colors"
>
@ -323,12 +326,14 @@ export function ProvisioningTimeout({
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmingCancel(null)}
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
Keep
</button>
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
>

View File

@ -132,6 +132,7 @@ export function SearchDialog() {
) : (
filtered.map((node, index) => (
<button
type="button"
key={node.id}
id={`search-result-${node.id}`}
role="option"

View File

@ -159,6 +159,7 @@ export function OrgTemplatesSection() {
)}
</button>
<button
type="button"
onClick={loadOrgs}
aria-label="Refresh org templates"
className="text-[10px] text-zinc-500 hover:text-zinc-300"
@ -209,6 +210,7 @@ export function OrgTemplatesSection() {
</p>
)}
<button
type="button"
onClick={() => handleImport(o)}
disabled={isImporting}
className="w-full px-2 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[10px] text-blue-300 font-medium transition-colors disabled:opacity-50"
@ -284,6 +286,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
onChange={(e) => e.target.files && handleFiles(e.target.files)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50"
@ -405,6 +408,7 @@ export function TemplatePalette() {
<>
{/* Toggle button */}
<button
type="button"
onClick={() => setOpen(!open)}
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
open
@ -477,6 +481,7 @@ export function TemplatePalette() {
return (
<button
type="button"
key={t.id}
onClick={() => handleDeploy(t)}
disabled={isDeploying}
@ -521,6 +526,7 @@ export function TemplatePalette() {
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
<ImportAgentButton onImported={loadTemplates} />
<button
type="button"
onClick={loadTemplates}
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors block"
>

View File

@ -102,6 +102,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={accept}
disabled={submitting}
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"

View File

@ -63,6 +63,7 @@ export function Toaster() {
<div key={toast.id} className={toastCls(toast.type)}>
<span>{toast.message}</span>
<button
type="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"
@ -90,6 +91,7 @@ export function Toaster() {
<div key={toast.id} className={toastCls(toast.type)}>
<span>{toast.message}</span>
<button
type="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"

View File

@ -168,6 +168,7 @@ export function Toolbar() {
{/* Stop All — visible when agents have active tasks */}
{counts.activeTasks > 0 && (
<button
type="button"
onClick={stopAll}
disabled={stopping}
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
@ -186,6 +187,7 @@ export function Toolbar() {
{/* Restart All — only shows when workspaces are flagged as needsRestart */}
{needsRestartNodes.length > 0 && (
<button
type="button"
onClick={() => setRestartConfirmOpen(true)}
disabled={restartingAll}
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
@ -208,6 +210,7 @@ export function Toolbar() {
{/* A2A topology overlay toggle */}
<button
type="button"
onClick={() => setShowA2AEdges(!showA2AEdges)}
aria-pressed={showA2AEdges}
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
@ -241,6 +244,7 @@ export function Toolbar() {
{/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */}
<button
type="button"
onClick={() => {
if (selectedNodeId) {
setPanelTab("audit");
@ -268,6 +272,7 @@ export function Toolbar() {
{/* Search shortcut */}
<button
type="button"
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
aria-label="Search workspaces"
title="Search (⌘K)"
@ -282,6 +287,7 @@ export function Toolbar() {
{/* Quick help */}
<div ref={helpRef} className="relative">
<button
type="button"
onClick={() => setHelpOpen((open) => !open)}
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
aria-expanded={helpOpen}
@ -299,6 +305,7 @@ export function Toolbar() {
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400">Quick start</span>
<button
type="button"
onClick={() => setHelpOpen(false)}
className="text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors"
>

View File

@ -250,6 +250,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
{/* Needs restart banner */}
{data.needsRestart && !data.currentTask && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
@ -317,6 +318,193 @@ function countDescendants(nodeId: string, allNodes: Node<WorkspaceNodeData>[], v
return count;
}
/** Subscribes to allNodes only when children exist — isolates re-renders from parent */
function EmbeddedTeam({ members, depth, onSelect, onExtract }: {
members: Node<WorkspaceNodeData>[];
depth: number;
onSelect: (id: string) => void;
onExtract: (id: string) => void;
}) {
const allNodes = useCanvasStore((s) => s.nodes);
// Use grid layout at depth 0 when there are multiple members (departments side-by-side)
const useGrid = depth === 0 && members.length >= 2;
return (
<div className="mt-2 pt-2 border-t border-zinc-700/30">
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1.5">Team Members</div>
<div className={useGrid
? "grid grid-cols-2 gap-1.5 lg:grid-cols-3"
: "space-y-1.5"
}>
{members.map((child) => (
<TeamMemberChip key={child.id} node={child} allNodes={allNodes} depth={depth} onSelect={onSelect} onExtract={onExtract} />
))}
</div>
</div>
);
}
/** Recursive mini-card — mirrors parent card layout at smaller scale */
function TeamMemberChip({
node,
allNodes,
depth,
onSelect,
onExtract,
}: {
node: Node<WorkspaceNodeData>;
allNodes: Node<WorkspaceNodeData>[];
depth: number;
onSelect: (id: string) => void;
onExtract: (id: string) => void;
}) {
const { data } = node;
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" };
const isOnline = data.status === "online";
const skills = getSkillNames(data.agentCard);
const subChildren = useMemo(
() => allNodes.filter((n) => n.data.parentId === node.id),
[allNodes, node.id]
);
const hasSubChildren = subChildren.length > 0;
const descendantCount = useMemo(
() => hasSubChildren ? countDescendants(node.id, allNodes) : 0,
[allNodes, node.id, hasSubChildren]
);
return (
<div
role="button"
tabIndex={0}
aria-label={`Select ${data.name}`}
className="group/child relative rounded-lg bg-zinc-800/60 hover:bg-zinc-700/70 border border-zinc-700/30 hover:border-zinc-600/40 overflow-hidden transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
onClick={(e) => {
e.stopPropagation();
onSelect(node.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onSelect(node.id);
}
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
useCanvasStore.getState().openContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id, nodeData: data });
}}
>
{/* Status gradient bar */}
<div className={`absolute inset-x-0 top-0 h-5 bg-gradient-to-b ${statusCfg.bar} pointer-events-none`} />
<div className="relative px-2 py-1.5">
{/* Header: name + badges + extract */}
<div className="flex items-center justify-between gap-1 mb-0.5">
<div className="flex items-center gap-1.5 min-w-0">
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusCfg.dot}`} />
<span className="text-[10px] font-semibold text-zinc-200 truncate leading-tight">
{data.name}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{hasSubChildren && (
<span className="text-[7px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1 py-0.5 rounded">
{descendantCount}
</span>
)}
<span className={`text-[7px] font-mono px-1 py-0.5 rounded ${tierCfg.color}`}>
{tierCfg.label}
</span>
<button
type="button"
aria-label={`Extract ${data.name} from team`}
title={`Extract ${data.name} from team`}
onClick={(e) => {
e.stopPropagation();
onExtract(node.id);
}}
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:outline-none rounded"
>
<EjectIcon aria-hidden="true" />
</button>
</div>
</div>
{/* Role */}
{data.role && (
<div className="text-[10px] text-zinc-500 mb-1 leading-tight truncate">{data.role}</div>
)}
{/* Skills */}
{skills.length > 0 && (
<div className="flex flex-wrap gap-0.5 mb-1">
{skills.slice(0, 3).map((skill) => (
<span
key={skill}
className={`text-[10px] px-1 py-0.5 rounded border ${
isOnline
? "text-emerald-300/70 bg-emerald-950/20 border-emerald-800/20"
: "text-zinc-500 bg-zinc-800/40 border-zinc-700/30"
}`}
>
{skill}
</span>
))}
{skills.length > 3 && (
<span className="text-[10px] text-zinc-400 self-center">+{skills.length - 3}</span>
)}
</div>
)}
{/* Status + active tasks row */}
<div className="flex items-center justify-between">
{data.status !== "online" ? (
<span className={`text-[10px] uppercase tracking-widest font-medium ${
data.status === "failed" ? "text-red-400" :
data.status === "degraded" ? "text-amber-300" :
data.status === "provisioning" ? "text-sky-400" :
"text-zinc-500"
}`}>
{statusCfg.label}
</span>
) : <div />}
{data.activeTasks > 0 && (
<div className="flex items-center gap-0.5">
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
<span className="text-[10px] text-amber-300 tabular-nums">
{data.activeTasks}
</span>
</div>
)}
</div>
{/* Current task banner for sub-agents */}
{data.currentTask && (
<Tooltip text={String(data.currentTask)}>
<div className="flex items-center gap-1 mt-0.5 px-1.5 py-0.5 bg-amber-950/20 rounded border border-amber-800/20 cursor-default">
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
<span className="text-[10px] text-amber-300 truncate">{data.currentTask}</span>
</div>
</Tooltip>
)}
{/* Recursive sub-children rendered inside this card */}
{hasSubChildren && depth < MAX_NESTING_DEPTH && (
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/20">
<div className="text-[10px] text-zinc-400 uppercase tracking-widest mb-1">Team</div>
<div className={subChildren.length >= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}>
{subChildren.map((sub) => (
<TeamMemberChip key={sub.id} node={sub} allNodes={allNodes} depth={depth + 1} onSelect={onSelect} onExtract={onExtract} />
))}
</div>
</div>
)}
</div>
</div>
);
}
function getSkillNames(agentCard: Record<string, unknown> | null): string[] {
if (!agentCard) return [];