Merge pull request #42 from Molecule-AI/fix/a11y-audit-11

fix: ARIA tablist for side panel, Radix Dialog for create modal, aria-live for loading states (audit 11)
This commit is contained in:
Hongming Wang 2026-04-14 04:27:35 -07:00 committed by GitHub
commit 5546aa5ca7
6 changed files with 453 additions and 101 deletions

View File

@ -288,6 +288,13 @@ function CanvasInner() {
/>
</ReactFlow>
{/* Screen-reader live region: announces workspace count when canvas loads or changes */}
<div role="status" aria-live="polite" className="sr-only">
{nodes.filter((n) => !n.data.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.data.parentId).length} workspace${nodes.filter((n) => !n.data.parentId).length !== 1 ? "s" : ""} on canvas`}
</div>
{nodes.length === 0 && <EmptyState />}
<OnboardingWizard />
<Toolbar />

View File

@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
interface WorkspaceOption {
@ -11,25 +12,6 @@ interface WorkspaceOption {
export function CreateWorkspaceButton() {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
<path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
New Workspace
</button>
{open && <CreateDialog onClose={() => setOpen(false)} />}
</>
);
}
function CreateDialog({ onClose }: { onClose: () => void }) {
const [name, setName] = useState("");
const [role, setRole] = useState("");
const [tier, setTier] = useState(1);
@ -39,21 +21,28 @@ function CreateDialog({ onClose }: { onClose: () => void }) {
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
// Reset form and load workspaces whenever dialog opens
useEffect(() => {
api.get<WorkspaceOption[]>("/workspaces")
if (!open) return;
setName("");
setRole("");
setTier(1);
setTemplate("");
setParentId("");
setError(null);
api
.get<WorkspaceOption[]>("/workspaces")
.then((ws) => setWorkspaces(ws))
.catch(() => {});
}, []);
}, [open]);
const handleCreate = async () => {
if (!name.trim()) {
setError("Name is required");
return;
}
setCreating(true);
setError(null);
try {
await api.post("/workspaces", {
name: name.trim(),
@ -63,7 +52,7 @@ function CreateDialog({ onClose }: { onClose: () => void }) {
parent_id: parentId || undefined,
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
});
onClose();
setOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create workspace");
} finally {
@ -72,80 +61,144 @@ function CreateDialog({ onClose }: { onClose: () => void }) {
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="bg-zinc-900 border border-zinc-700/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] p-6">
<h2 className="text-base font-semibold text-zinc-100 mb-1">Create Workspace</h2>
<p className="text-xs text-zinc-500 mb-5">Add a new workspace node to the canvas</p>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="shrink-0"
aria-hidden="true"
>
<path
d="M7 1v12M1 7h12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
New Workspace
</button>
</Dialog.Trigger>
<div className="space-y-3.5">
<InputField label="Name" required value={name} onChange={setName} placeholder="e.g. SEO Agent" autoFocus />
<InputField label="Role" value={role} onChange={setRole} placeholder="e.g. SEO Specialist" />
<InputField label="Template" value={template} onChange={setTemplate} placeholder="e.g. seo-agent (from workspace-configs-templates/)" mono />
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm" />
<Dialog.Content
className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-zinc-900 border border-zinc-700/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] p-6"
aria-describedby={undefined}
>
<Dialog.Title className="text-base font-semibold text-zinc-100 mb-1">
Create Workspace
</Dialog.Title>
<p className="text-xs text-zinc-500 mb-5">
Add a new workspace node to the canvas
</p>
<div>
<label className="text-[11px] text-zinc-400 block mb-1">Tier</label>
<div className="grid grid-cols-3 gap-1.5">
{[
{ value: 1, label: "T1", desc: "Sandboxed" },
{ value: 2, label: "T2", desc: "Standard" },
{ value: 3, label: "T3", desc: "Full Access" },
].map((t) => (
<button
key={t.value}
onClick={() => setTier(t.value)}
className={`py-2 rounded-lg text-center transition-colors ${
tier === t.value
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"
: "bg-zinc-800/60 border border-zinc-700/40 text-zinc-400 hover:text-zinc-300 hover:border-zinc-600"
}`}
>
<div className="text-xs font-mono font-semibold">{t.label}</div>
<div className="text-[10px] mt-0.5 opacity-70">{t.desc}</div>
</button>
))}
<div className="space-y-3.5">
<InputField
label="Name"
required
value={name}
onChange={setName}
placeholder="e.g. SEO Agent"
/>
<InputField
label="Role"
value={role}
onChange={setRole}
placeholder="e.g. SEO Specialist"
/>
<InputField
label="Template"
value={template}
onChange={setTemplate}
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
mono
/>
<div>
<div
role="radiogroup"
aria-label="Workspace tier"
className="grid grid-cols-3 gap-1.5"
>
<div className="col-span-3 text-[11px] text-zinc-400 mb-1">
Tier
</div>
{[
{ value: 1, label: "T1", desc: "Sandboxed" },
{ value: 2, label: "T2", desc: "Standard" },
{ value: 3, label: "T3", desc: "Full Access" },
].map((t) => (
<button
key={t.value}
role="radio"
aria-checked={tier === t.value}
onClick={() => setTier(t.value)}
className={`py-2 rounded-lg text-center transition-colors ${
tier === t.value
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"
: "bg-zinc-800/60 border border-zinc-700/40 text-zinc-400 hover:text-zinc-300 hover:border-zinc-600"
}`}
>
<div className="text-xs font-mono font-semibold">
{t.label}
</div>
<div className="text-[10px] mt-0.5 opacity-70">
{t.desc}
</div>
</button>
))}
</div>
</div>
<div>
<label className="text-[11px] text-zinc-400 block mb-1">
Parent Workspace
</label>
<select
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors"
>
<option value="">None (root level)</option>
{workspaces.map((ws) => (
<option key={ws.id} value={ws.id}>
T{ws.tier} · {ws.name}
</option>
))}
</select>
</div>
</div>
<div>
<label className="text-[11px] text-zinc-400 block mb-1">Parent Workspace</label>
<select
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors"
{error && (
<div
role="alert"
className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400"
>
<option value="">None (root level)</option>
{workspaces.map((ws) => (
<option key={ws.id} value={ws.id}>
T{ws.tier} · {ws.name}
</option>
))}
</select>
</div>
</div>
{error}
</div>
)}
{error && (
<div className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
{error}
<div className="flex justify-end gap-2.5 mt-6">
<Dialog.Close asChild>
<button className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
Cancel
</button>
</Dialog.Close>
<button
onClick={handleCreate}
disabled={creating}
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
)}
<div className="flex justify-end gap-2.5 mt-6">
<button
onClick={onClose}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={creating}
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
@ -155,7 +208,6 @@ function InputField({
onChange,
placeholder,
required,
autoFocus,
mono,
}: {
label: string;
@ -163,19 +215,25 @@ function InputField({
onChange: (v: string) => void;
placeholder?: string;
required?: boolean;
autoFocus?: boolean;
mono?: boolean;
}) {
return (
<div>
<label className="text-[11px] text-zinc-400 block mb-1">
{label} {required && <span className="text-red-400">*</span>}
{label}{" "}
{required && (
<>
<span aria-hidden="true" className="text-red-400">
*
</span>
<span className="sr-only"> (required)</span>
</>
)}
</label>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
/>
</div>

View File

@ -138,18 +138,39 @@ export function SidePanel() {
</div>
{/* Tabs */}
<div className="flex border-b border-zinc-800/40 overflow-x-auto bg-zinc-900/20 px-1">
<div
role="tablist"
aria-label="Workspace panel tabs"
className="flex border-b border-zinc-800/40 overflow-x-auto bg-zinc-900/20 px-1"
onKeyDown={(e) => {
const idx = TABS.findIndex((t) => t.id === panelTab);
let next: number | null = null;
if (e.key === "ArrowRight") { e.preventDefault(); next = (idx + 1) % TABS.length; }
else if (e.key === "ArrowLeft") { e.preventDefault(); next = (idx - 1 + TABS.length) % TABS.length; }
else if (e.key === "Home") { e.preventDefault(); next = 0; }
else if (e.key === "End") { e.preventDefault(); next = TABS.length - 1; }
if (next !== null) {
setPanelTab(TABS[next].id);
requestAnimationFrame(() => { document.getElementById(`tab-${TABS[next!].id}`)?.focus(); });
}
}}
>
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
role="tab"
aria-selected={panelTab === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={panelTab === tab.id ? 0 : -1}
onClick={() => setPanelTab(tab.id)}
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 ${
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-1 focus-visible:ring-zinc-600 ${
panelTab === tab.id
? "text-zinc-100 bg-zinc-800/40 border-b-2 border-blue-500"
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/20"
}`}
>
<span className="mr-1 opacity-50">{tab.icon}</span>
<span className="mr-1 opacity-50" aria-hidden="true">{tab.icon}</span>
{tab.label}
</button>
))}
@ -183,7 +204,13 @@ export function SidePanel() {
)}
{/* Tab Content */}
<div className="flex-1 overflow-y-auto">
<div
role="tabpanel"
id={`panel-${panelTab}`}
aria-labelledby={`tab-${panelTab}`}
tabIndex={0}
className="flex-1 overflow-y-auto focus:outline-none"
>
{panelTab === "details" && <DetailsTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "skills" && <SkillsTab key={selectedNodeId} data={node.data} />}
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}

View File

@ -89,7 +89,11 @@ export function OrgTemplatesSection() {
</button>
</div>
{loading && <div className="text-[10px] text-zinc-500">Loading</div>}
{loading && (
<div role="status" aria-live="polite" className="text-[10px] text-zinc-500">
Loading
</div>
)}
{!loading && orgs.length === 0 && (
<div className="text-[10px] text-zinc-500">
@ -350,11 +354,13 @@ export function TemplatePalette() {
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{loading && (
<div className="text-xs text-zinc-500 text-center py-8">Loading...</div>
<div role="status" aria-live="polite" className="text-xs text-zinc-500 text-center py-8">
Loading
</div>
)}
{!loading && templates.length === 0 && (
<div className="text-xs text-zinc-500 text-center py-8">
<div role="status" aria-live="polite" className="text-xs text-zinc-500 text-center py-8">
No templates found in<br />workspace-configs-templates/
</div>
)}

View File

@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn().mockResolvedValue([]),
post: vi.fn().mockResolvedValue({}),
},
}));
// Import component AFTER mocks
import { CreateWorkspaceButton } from "../CreateWorkspaceDialog";
async function openDialog() {
render(<CreateWorkspaceButton />);
const trigger = screen
.getAllByRole("button")
.find((b) => b.textContent?.includes("New Workspace"));
expect(trigger).toBeTruthy();
fireEvent.click(trigger!);
await waitFor(() =>
expect(screen.queryByRole("dialog")).toBeTruthy()
);
}
describe("CreateWorkspaceDialog — accessibility", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("dialog is absent before the trigger is clicked", () => {
render(<CreateWorkspaceButton />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("clicking the trigger renders a role=dialog", async () => {
await openDialog();
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("dialog has aria-labelledby pointing to the 'Create Workspace' title", async () => {
await openDialog();
const dialog = screen.getByRole("dialog");
const labelledBy = dialog.getAttribute("aria-labelledby");
expect(labelledBy).toBeTruthy();
const titleEl = document.getElementById(labelledBy!);
expect(titleEl?.textContent?.trim()).toBe("Create Workspace");
});
it("dialog has data-state='open' when visible (Radix modal state)", async () => {
await openDialog();
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("data-state")).toBe("open");
});
it("Cancel button closes the dialog", async () => {
await openDialog();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("empty-name submit renders a role=alert error message", async () => {
await openDialog();
// Click Create without filling in Name
fireEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(screen.getByRole("alert")).toBeTruthy()
);
expect(screen.getByRole("alert").textContent).toContain("required");
});
it("tier buttons have role=radio and aria-checked reflects selection", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
expect(radios.length).toBe(3);
// T1 is default selection
const t1 = radios.find((r) => r.textContent?.includes("T1"));
const t2 = radios.find((r) => r.textContent?.includes("T2"));
expect(t1?.getAttribute("aria-checked")).toBe("true");
expect(t2?.getAttribute("aria-checked")).toBe("false");
// Click T2 and verify aria-checked flips
fireEvent.click(t2!);
await waitFor(() =>
expect(t2?.getAttribute("aria-checked")).toBe("true")
);
});
});

View File

@ -0,0 +1,162 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});
// ── Mock all tab content components to null ──────────────────────────────────
vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
vi.mock("../tabs/MemoryTab", () => ({ MemoryTab: () => null }));
vi.mock("../tabs/TracesTab", () => ({ TracesTab: () => null }));
vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => null }));
vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => null }));
vi.mock("../tabs/ScheduleTab", () => ({ ScheduleTab: () => null }));
vi.mock("../tabs/ChannelsTab", () => ({ ChannelsTab: () => null }));
// ── Mock StatusDot and Tooltip ───────────────────────────────────────────────
vi.mock("../StatusDot", () => ({ StatusDot: () => null }));
vi.mock("../Tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
// ── Mock canvas store ────────────────────────────────────────────────────────
const mockSetPanelTab = vi.fn();
const mockStoreState = {
selectedNodeId: "ws-1",
panelTab: "chat",
setPanelTab: mockSetPanelTab,
selectNode: vi.fn(),
nodes: [
{
id: "ws-1",
data: {
name: "Test WS",
status: "online",
tier: 1,
role: "Engineer",
parentId: null,
needsRestart: false,
currentTask: null,
agentCard: null,
},
},
],
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof mockStoreState) => unknown) =>
selector(mockStoreState)
),
{ getState: () => mockStoreState }
),
summarizeWorkspaceCapabilities: () => ({ runtime: "claude-code", skillCount: 0 }),
}));
// ── Import component under test AFTER all mocks ──────────────────────────────
import { SidePanel } from "../SidePanel";
const TABS = [
"chat", "activity", "details", "skills", "terminal",
"config", "schedule", "channels", "files", "memory", "traces", "events",
];
describe("SidePanel — ARIA tablist pattern", () => {
it("renders a tablist with aria-label='Workspace panel tabs'", () => {
render(<SidePanel />);
const tablist = screen.getByRole("tablist");
expect(tablist).toBeTruthy();
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
});
it("renders exactly 12 tab buttons", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
expect(tabs.length).toBe(12);
});
it("active tab (chat) has aria-selected='true'", () => {
render(<SidePanel />);
const chatTab = screen.getAllByRole("tab").find(
(t) => t.id === "tab-chat"
);
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
});
it("all other 11 tabs have aria-selected='false'", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
const inactive = tabs.filter((t) => t.id !== "tab-chat");
expect(inactive.length).toBe(11);
for (const tab of inactive) {
expect(tab.getAttribute("aria-selected")).toBe("false");
}
});
it("active tab has tabIndex=0 and all others have tabIndex=-1 (roving tabIndex)", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
const zeros = tabs.filter((t) => t.getAttribute("tabindex") === "0");
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
expect(zeros.length).toBe(1);
expect(zeros[0].id).toBe("tab-chat");
expect(minusOnes.length).toBe(11);
});
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
render(<SidePanel />);
const chatTab = document.getElementById("tab-chat");
expect(chatTab).toBeTruthy();
expect(chatTab?.getAttribute("aria-controls")).toBe("panel-chat");
});
it("renders a role=tabpanel for the active tab", () => {
render(<SidePanel />);
const tabpanel = screen.getByRole("tabpanel");
expect(tabpanel).toBeTruthy();
});
it("tabpanel has id='panel-chat' and aria-labelledby='tab-chat'", () => {
render(<SidePanel />);
const panel = document.getElementById("panel-chat");
expect(panel).toBeTruthy();
expect(panel?.getAttribute("aria-labelledby")).toBe("tab-chat");
});
it("ArrowRight calls setPanelTab with 'activity' (next tab after chat)", () => {
render(<SidePanel />);
const tablist = screen.getByRole("tablist");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
expect(mockSetPanelTab).toHaveBeenCalledWith("activity");
});
it("ArrowLeft from 'chat' (first) wraps to 'events' (last)", () => {
render(<SidePanel />);
const tablist = screen.getByRole("tablist");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
expect(mockSetPanelTab).toHaveBeenCalledWith("events");
});
it("Home key calls setPanelTab with 'chat' (first tab)", () => {
render(<SidePanel />);
const tablist = screen.getByRole("tablist");
fireEvent.keyDown(tablist, { key: "Home" });
expect(mockSetPanelTab).toHaveBeenCalledWith("chat");
});
it("End key calls setPanelTab with 'events' (last tab)", () => {
render(<SidePanel />);
const tablist = screen.getByRole("tablist");
fireEvent.keyDown(tablist, { key: "End" });
expect(mockSetPanelTab).toHaveBeenCalledWith("events");
});
});