diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 07aea51a..2cf03f30 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -288,6 +288,13 @@ function CanvasInner() { /> + {/* Screen-reader live region: announces workspace count when canvas loads or changes */} +
+ {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`} +
+ {nodes.length === 0 && } diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 1e1b35e5..909bd079 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -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 ( - <> - - - {open && 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(null); const [workspaces, setWorkspaces] = useState([]); + // Reset form and load workspaces whenever dialog opens useEffect(() => { - api.get("/workspaces") + if (!open) return; + setName(""); + setRole(""); + setTier(1); + setTemplate(""); + setParentId(""); + setError(null); + api + .get("/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 ( -
-
-

Create Workspace

-

Add a new workspace node to the canvas

+ + + + -
- - - + + + + + Create Workspace + +

+ Add a new workspace node to the canvas +

-
- -
- {[ - { value: 1, label: "T1", desc: "Sandboxed" }, - { value: 2, label: "T2", desc: "Standard" }, - { value: 3, label: "T3", desc: "Full Access" }, - ].map((t) => ( - - ))} +
+ + + + +
+
+
+ Tier +
+ {[ + { value: 1, label: "T1", desc: "Sandboxed" }, + { value: 2, label: "T2", desc: "Standard" }, + { value: 3, label: "T3", desc: "Full Access" }, + ].map((t) => ( + + ))} +
+
+ +
+ +
-
- - -
-
+ {error} +
+ )} - {error && ( -
- {error} +
+ + + +
- )} - -
- - -
-
-
+ + +
); } @@ -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 (
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" : ""}`} />
diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index 7ba93f62..7858205c 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -138,18 +138,39 @@ export function SidePanel() {
{/* Tabs */} -
+
{ + 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) => ( ))} @@ -183,7 +204,13 @@ export function SidePanel() { )} {/* Tab Content */} -
+
{panelTab === "details" && } {panelTab === "skills" && } {panelTab === "activity" && } diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx index 447dc871..61fde470 100644 --- a/canvas/src/components/TemplatePalette.tsx +++ b/canvas/src/components/TemplatePalette.tsx @@ -89,7 +89,11 @@ export function OrgTemplatesSection() {
- {loading &&
Loading…
} + {loading && ( +
+ Loading… +
+ )} {!loading && orgs.length === 0 && (
@@ -350,11 +354,13 @@ export function TemplatePalette() {
{loading && ( -
Loading...
+
+ Loading… +
)} {!loading && templates.length === 0 && ( -
+
No templates found in
workspace-configs-templates/
)} diff --git a/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx b/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx new file mode 100644 index 00000000..f0bca774 --- /dev/null +++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx @@ -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(); + 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(); + 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") + ); + }); +}); diff --git a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx new file mode 100644 index 00000000..a244f90b --- /dev/null +++ b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx @@ -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(); + const tablist = screen.getByRole("tablist"); + expect(tablist).toBeTruthy(); + expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs"); + }); + + it("renders exactly 12 tab buttons", () => { + render(); + const tabs = screen.getAllByRole("tab"); + expect(tabs.length).toBe(12); + }); + + it("active tab (chat) has aria-selected='true'", () => { + render(); + 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(); + 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(); + 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(); + 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(); + const tabpanel = screen.getByRole("tabpanel"); + expect(tabpanel).toBeTruthy(); + }); + + it("tabpanel has id='panel-chat' and aria-labelledby='tab-chat'", () => { + render(); + 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(); + const tablist = screen.getByRole("tablist"); + fireEvent.keyDown(tablist, { key: "ArrowRight" }); + expect(mockSetPanelTab).toHaveBeenCalledWith("activity"); + }); + + it("ArrowLeft from 'chat' (first) wraps to 'events' (last)", () => { + render(); + const tablist = screen.getByRole("tablist"); + fireEvent.keyDown(tablist, { key: "ArrowLeft" }); + expect(mockSetPanelTab).toHaveBeenCalledWith("events"); + }); + + it("Home key calls setPanelTab with 'chat' (first tab)", () => { + render(); + const tablist = screen.getByRole("tablist"); + fireEvent.keyDown(tablist, { key: "Home" }); + expect(mockSetPanelTab).toHaveBeenCalledWith("chat"); + }); + + it("End key calls setPanelTab with 'events' (last tab)", () => { + render(); + const tablist = screen.getByRole("tablist"); + fireEvent.keyDown(tablist, { key: "End" }); + expect(mockSetPanelTab).toHaveBeenCalledWith("events"); + }); +});