diff --git a/canvas/src/components/__tests__/ApprovalBanner.test.tsx b/canvas/src/components/__tests__/ApprovalBanner.test.tsx
new file mode 100644
index 00000000..d88cfc1b
--- /dev/null
+++ b/canvas/src/components/__tests__/ApprovalBanner.test.tsx
@@ -0,0 +1,285 @@
+// @vitest-environment jsdom
+/**
+ * Tests for ApprovalBanner component.
+ *
+ * Covers: renders nothing when no approvals, polls /approvals/pending,
+ * shows approval cards, approve/deny decisions, toast notifications.
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
+import { ApprovalBanner } from "../ApprovalBanner";
+import { showToast } from "@/components/Toaster";
+import { api } from "@/lib/api";
+
+vi.mock("@/components/Toaster", () => ({
+ showToast: vi.fn(),
+}));
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
+ id: string;
+ workspace_id: string;
+ workspace_name: string;
+ action: string;
+ reason: string | null;
+ status: string;
+ created_at: string;
+} => ({
+ id,
+ workspace_id: workspaceId,
+ workspace_name: "Test Workspace",
+ action: "Run code execution",
+ reason: "Requires human approval due to workspace policy",
+ status: "pending",
+ created_at: "2026-05-10T10:00:00Z",
+});
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe("ApprovalBanner — empty state", () => {
+ it("renders nothing when there are no pending approvals", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([]);
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("does not render any approve/deny buttons when list is empty", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([]);
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
+ expect(screen.queryByRole("button", { name: /deny/i })).toBeNull();
+ });
+});
+
+describe("ApprovalBanner — renders approval cards", () => {
+ it("renders an alert card for each pending approval", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([
+ pendingApproval("a1"),
+ pendingApproval("a2", "ws-2"),
+ ]);
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ const alerts = screen.getAllByRole("alert");
+ expect(alerts).toHaveLength(2);
+ });
+
+ it("displays the workspace name and action text", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ expect(screen.getByText("Test Workspace needs approval")).toBeTruthy();
+ expect(screen.getByText("Run code execution")).toBeTruthy();
+ });
+
+ it("displays the reason when present", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ expect(screen.getByText(/Requires human approval/i)).toBeTruthy();
+ });
+
+ it("omits the reason div when reason is null", async () => {
+ const approval = pendingApproval("a1");
+ approval.reason = null;
+ vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ expect(screen.queryByText(/Requires human approval/i)).toBeNull();
+ });
+
+ it("renders both Approve and Deny buttons per card", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ expect(screen.getByRole("button", { name: /approve/i })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /deny/i })).toBeTruthy();
+ });
+
+ it("has aria-live=assertive on the alert container", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ const alert = screen.getByRole("alert");
+ expect(alert.getAttribute("aria-live")).toBe("assertive");
+ });
+});
+
+describe("ApprovalBanner — polling", () => {
+ let clearIntervalSpy: ReturnType;
+
+ beforeEach(() => {
+ clearIntervalSpy = vi.spyOn(global, "clearInterval").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ clearIntervalSpy.mockRestore();
+ });
+
+ it("clears the polling interval on unmount", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
+ const { unmount } = render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ unmount();
+ expect(clearIntervalSpy).toHaveBeenCalled();
+ });
+});
+
+describe("ApprovalBanner — decisions", () => {
+ it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
+ const approval = pendingApproval("a1", "ws-1");
+ vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
+ const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
+
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /approve/i }));
+
+ await waitFor(() => {
+ expect(postSpy).toHaveBeenCalledWith(
+ "/workspaces/ws-1/approvals/a1/decide",
+ { decision: "approved", decided_by: "human" }
+ );
+ });
+ });
+
+ it("calls POST with decision=denied on Deny click", async () => {
+ const approval = pendingApproval("a1", "ws-1");
+ vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
+ const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
+
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /deny/i }));
+
+ await waitFor(() => {
+ expect(postSpy).toHaveBeenCalledWith(
+ "/workspaces/ws-1/approvals/a1/decide",
+ { decision: "denied", decided_by: "human" }
+ );
+ });
+ });
+
+ it("removes the card from state after a successful decision", async () => {
+ const approval = pendingApproval("a1", "ws-1");
+ vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
+ vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
+
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+
+ // One alert initially
+ expect(screen.getAllByRole("alert")).toHaveLength(1);
+
+ fireEvent.click(screen.getByRole("button", { name: /approve/i }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+ });
+
+ it("shows a success toast on approve", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
+ vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
+
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /approve/i }));
+
+ await waitFor(() => {
+ expect(showToast).toHaveBeenCalledWith("Approved", "success");
+ });
+ });
+
+ it("shows an info toast on deny", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
+ vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
+
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /deny/i }));
+
+ await waitFor(() => {
+ expect(showToast).toHaveBeenCalledWith("Denied", "info");
+ });
+ });
+
+ it("shows an error toast when POST fails", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
+ vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
+
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /approve/i }));
+
+ await waitFor(() => {
+ expect(showToast).toHaveBeenCalledWith("Failed to submit decision", "error");
+ });
+ });
+
+ it("keeps the card visible when the POST fails", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
+ vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
+
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /approve/i }));
+
+ await waitFor(() => {
+ // Card still shown because the request failed
+ expect(screen.getByRole("alert")).toBeTruthy();
+ });
+ });
+});
+
+describe("ApprovalBanner — handles empty list from server", () => {
+ it("shows nothing when the API returns an empty array on first poll", async () => {
+ vi.spyOn(api, "get").mockResolvedValueOnce([]);
+ render( );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 10));
+ });
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+});
diff --git a/canvas/src/components/__tests__/Legend.test.tsx b/canvas/src/components/__tests__/Legend.test.tsx
new file mode 100644
index 00000000..d2530121
--- /dev/null
+++ b/canvas/src/components/__tests__/Legend.test.tsx
@@ -0,0 +1,185 @@
+// @vitest-environment jsdom
+/**
+ * Tests for Legend component.
+ *
+ * Covers: open/closed state, localStorage persistence, palette-offset
+ * positioning, status/tier/comm items rendering.
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
+import { Legend } from "../Legend";
+import { useCanvasStore } from "@/store/canvas";
+
+// ─── Mock localStorage ────────────────────────────────────────────────────────
+
+const localStorageMock = (() => {
+ let store: Record = {};
+ return {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
+ removeItem: vi.fn((key: string) => { delete store[key]; }),
+ clear: () => { store = {}; },
+ getStore: () => store,
+ };
+})();
+Object.defineProperty(window, "localStorage", { value: localStorageMock });
+
+// ─── Mock canvas store ────────────────────────────────────────────────────────
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+ localStorageMock.clear();
+ vi.clearAllMocks();
+});
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe("Legend — initial render (localStorage open)", () => {
+ it("renders the legend panel when localStorage has no saved preference", () => {
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: false } as ReturnType)
+ );
+ render( );
+ expect(screen.getByText("Legend")).toBeTruthy();
+ });
+
+ it("renders the legend panel when localStorage has open=1", () => {
+ localStorageMock.getItem.mockReturnValueOnce("1");
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: false } as ReturnType)
+ );
+ render( );
+ expect(screen.getByText("Legend")).toBeTruthy();
+ });
+
+ it("renders the collapsed pill when localStorage has open=0", () => {
+ localStorageMock.getItem.mockReturnValueOnce("0");
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: false } as ReturnType)
+ );
+ render( );
+ // Collapsed pill shows "ⓘ Legend"
+ expect(screen.getByText("Legend")).toBeTruthy();
+ // Hide button should not be in the open panel
+ expect(screen.queryByTitle("Hide legend")).toBeNull();
+ });
+});
+
+describe("Legend — open panel content", () => {
+ beforeEach(() => {
+ localStorageMock.getItem.mockReturnValue("1");
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: false } as ReturnType)
+ );
+ });
+
+ it("renders the Status section with status items", () => {
+ render( );
+ expect(screen.getByText("Status")).toBeTruthy();
+ // All statuses from LEGEND_STATUSES
+ expect(screen.getByText("Online")).toBeTruthy();
+ expect(screen.getByText("Offline")).toBeTruthy();
+ expect(screen.getByText("Failed")).toBeTruthy();
+ });
+
+ it("renders the Tier section", () => {
+ render( );
+ expect(screen.getByText("Tier")).toBeTruthy();
+ expect(screen.getByText("Sandboxed")).toBeTruthy();
+ expect(screen.getByText("Standard")).toBeTruthy();
+ expect(screen.getByText("Privileged")).toBeTruthy();
+ expect(screen.getByText("Full Access")).toBeTruthy();
+ });
+
+ it("renders the Communication section", () => {
+ render( );
+ expect(screen.getByText("Communication")).toBeTruthy();
+ expect(screen.getByText("A2A Out")).toBeTruthy();
+ expect(screen.getByText("A2A In")).toBeTruthy();
+ expect(screen.getByText("Task")).toBeTruthy();
+ expect(screen.getByText("Error")).toBeTruthy();
+ });
+
+ it("renders the hide button", () => {
+ render( );
+ expect(screen.getByTitle("Hide legend")).toBeTruthy();
+ });
+});
+
+describe("Legend — close and reopen", () => {
+ it("closes when the hide button is clicked and persists to localStorage", () => {
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: false } as ReturnType)
+ );
+ render( );
+ fireEvent.click(screen.getByTitle("Hide legend"));
+ // localStorage should be updated to "0"
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ "molecule.legend.open",
+ "0"
+ );
+ });
+
+ it("reopens when the collapsed pill is clicked and persists to localStorage", () => {
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: false } as ReturnType)
+ );
+ render( );
+ // Initially open — close it
+ fireEvent.click(screen.getByTitle("Hide legend"));
+ // Collapsed pill appears
+ expect(screen.getByTitle("Show legend")).toBeTruthy();
+ // Reopen
+ fireEvent.click(screen.getByTitle("Show legend"));
+ expect(localStorageMock.setItem).toHaveBeenLastCalledWith(
+ "molecule.legend.open",
+ "1"
+ );
+ });
+});
+
+describe("Legend — palette offset positioning", () => {
+ it("uses left-4 when template palette is NOT open", () => {
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: false } as ReturnType)
+ );
+ render( );
+ const panel = screen.getByText("Legend").closest("div");
+ expect(panel?.className).toContain("left-4");
+ });
+
+ it("uses left-[296px] when template palette IS open", () => {
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: true } as ReturnType)
+ );
+ render( );
+ const panel = screen.getByText("Legend").closest("div");
+ expect(panel?.className).toContain("left-[296px]");
+ });
+});
+
+describe("Legend — aria attributes", () => {
+ it("the hide button has aria-label", () => {
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: false } as ReturnType)
+ );
+ render( );
+ const hideBtn = screen.getByTitle("Hide legend");
+ expect(hideBtn.getAttribute("aria-label")).toBe("Hide legend");
+ });
+
+ it("the show legend pill has aria-label", () => {
+ vi.mocked(useCanvasStore).mockImplementation(
+ (sel) => sel({ templatePaletteOpen: false } as ReturnType)
+ );
+ render( );
+ fireEvent.click(screen.getByTitle("Hide legend"));
+ const pill = screen.getByTitle("Show legend");
+ expect(pill.getAttribute("aria-label")).toBe("Show legend");
+ });
+});
diff --git a/canvas/src/components/__tests__/TermsGate.test.tsx b/canvas/src/components/__tests__/TermsGate.test.tsx
new file mode 100644
index 00000000..2aeee145
--- /dev/null
+++ b/canvas/src/components/__tests__/TermsGate.test.tsx
@@ -0,0 +1,222 @@
+// @vitest-environment jsdom
+/**
+ * Tests for TermsGate component.
+ *
+ * Covers: loading → accepted (already agreed), loading → pending (show
+ * modal), 401 → accepted (not signed in), error state, accept flow,
+ * focus management (WCAG 2.4.3), and modal accessibility.
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
+import { TermsGate } from "../TermsGate";
+
+// PLATFORM_URL is imported from @/lib/api; we mock it via module mock
+vi.mock("@/lib/api", () => ({
+ PLATFORM_URL: "https://app.example.com",
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function mockFetch(res: Response) {
+ vi.spyOn(global, "fetch").mockResolvedValueOnce(res);
+}
+
+async function resolveFetch(res: Response) {
+ await act(async () => {
+ vi.spyOn(global, "fetch").mockResolvedValueOnce(res);
+ });
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe("TermsGate — loading → accepted", () => {
+ it("renders children immediately (loading state)", () => {
+ mockFetch(new Response(JSON.stringify({ accepted: true }), { status: 200 }));
+ render(
+
+ App content
+
+ );
+ // Children are always rendered (TermsGate does not hide them)
+ expect(screen.getByTestId("children")).toBeTruthy();
+ });
+
+ it("shows no dialog when server returns accepted=true", async () => {
+ mockFetch(new Response(JSON.stringify({ accepted: true }), { status: 200 }));
+ render(
+
+ App content
+
+ );
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+ });
+
+ it("shows no dialog when server returns 401 (not signed in)", async () => {
+ mockFetch(new Response(null, { status: 401 }));
+ render(
+
+ App content
+
+ );
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+ });
+});
+
+describe("TermsGate — pending state → modal", () => {
+ it("shows the terms dialog when server returns accepted=false", async () => {
+ mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
+ render(
+
+ App content
+
+ );
+ await waitFor(() => {
+ expect(screen.getByRole("dialog")).toBeTruthy();
+ });
+ });
+
+ it("dialog has aria-modal=true and correct labelling", async () => {
+ mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
+ render(
+
+ App content
+
+ );
+ const dialog = await waitFor(() => screen.getByRole("dialog"));
+ expect(dialog.getAttribute("aria-modal")).toBe("true");
+ expect(dialog.getAttribute("aria-labelledby")).toBeTruthy();
+ const title = document.getElementById(dialog.getAttribute("aria-labelledby")!);
+ expect(title?.textContent).toMatch(/terms/i);
+ });
+
+ it("dialog body contains the terms text", async () => {
+ mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
+ render(App content
);
+ await waitFor(() => screen.getByRole("dialog"));
+ expect(screen.getByText(/Terms of Service/i)).toBeTruthy();
+ expect(screen.getByText(/Privacy Policy/i)).toBeTruthy();
+ expect(screen.getByText(/AWS us-east-2/i)).toBeTruthy();
+ });
+
+ it("the I agree button is present", async () => {
+ mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
+ render(App content
);
+ await waitFor(() => screen.getByRole("dialog"));
+ expect(screen.getByRole("button", { name: /i agree/i })).toBeTruthy();
+ });
+
+ it("links to terms and privacy policy have correct hrefs", async () => {
+ mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
+ render(App content
);
+ await waitFor(() => screen.getByRole("dialog"));
+ const links = screen.getAllByRole("link");
+ const hrefs = links.map((l) => l.getAttribute("href"));
+ expect(hrefs).toContain("/legal/terms");
+ expect(hrefs).toContain("/legal/privacy");
+ });
+});
+
+describe("TermsGate — focus management (WCAG 2.4.3)", () => {
+ it("moves focus to the I agree button when modal opens", async () => {
+ mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
+ render(App content
);
+ const dialog = await waitFor(() => screen.getByRole("dialog"));
+ // Focus is moved via requestAnimationFrame — wait a tick
+ await act(async () => {
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+ });
+ const agreeBtn = screen.getByRole("button", { name: /i agree/i });
+ expect(document.activeElement).toBe(agreeBtn);
+ });
+});
+
+describe("TermsGate — accept flow", () => {
+ it("calls POST /cp/auth/accept-terms and closes dialog on success", async () => {
+ // First: terms-status → pending
+ mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
+ // Second: accept-terms → 200
+ const postMock = mockFetch(new Response(null, { status: 200 }));
+
+ render(App content
);
+ await waitFor(() => screen.getByRole("dialog"));
+
+ fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ // Check POST was called
+ const calls = vi.mocked(global.fetch).mock.calls;
+ expect(calls.some(
+ ([url, opts]) =>
+ (url as string).includes("/accept-terms") &&
+ (opts as RequestInit).method === "POST"
+ )).toBe(true);
+ });
+
+ it("shows error message and keeps modal open when accept fails", async () => {
+ mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
+ mockFetch(new Response("Internal Server Error", { status: 500 }));
+
+ render(App content
);
+ await waitFor(() => screen.getByRole("dialog"));
+
+ fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeTruthy();
+ });
+ // Dialog is still open
+ expect(screen.getByRole("dialog")).toBeTruthy();
+ });
+
+ it.skip("disables the button while submitting (requires fake-timers around fireEvent.click)", async () => {
+ // This test requires vi.useFakeTimers() + act(() => { fireEvent.click(btn); vi.runAllTimers(); })
+ // to synchronously advance through the async boundary between click and fetch initiation.
+ // The current test structure fires the fetch before click, so this is skipped pending
+ // a refactor of the component to not initiate fetch synchronously on user gesture.
+ });
+});
+
+describe("TermsGate — error state", () => {
+ it("shows an error alert when terms-status fetch fails with non-401", async () => {
+ mockFetch(new Response("Gateway Timeout", { status: 504 }));
+ render(App content
);
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeTruthy();
+ });
+ });
+
+ it("error alert contains the status code", async () => {
+ mockFetch(new Response(null, { status: 503 }));
+ render(App content
);
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeTruthy();
+ });
+ expect(screen.getByRole("alert").textContent).toMatch(/503/);
+ });
+});
+
+describe("TermsGate — children always rendered", () => {
+ it("renders children even when modal is shown (does not gate them)", async () => {
+ mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
+ render(
+
+ Behind the modal
+
+ );
+ await waitFor(() => screen.getByRole("dialog"));
+ expect(screen.getByTestId("children-visible")).toBeTruthy();
+ });
+});
diff --git a/canvas/src/components/__tests__/Tooltip.test.tsx b/canvas/src/components/__tests__/Tooltip.test.tsx
new file mode 100644
index 00000000..f2f7de99
--- /dev/null
+++ b/canvas/src/components/__tests__/Tooltip.test.tsx
@@ -0,0 +1,235 @@
+// @vitest-environment jsdom
+/**
+ * Tests for Tooltip component.
+ *
+ * Covers: portal rendering, 400ms hover delay, keyboard focus reveal,
+ * Esc dismiss, no render when text is empty.
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
+import { Tooltip } from "../Tooltip";
+
+afterEach(cleanup);
+
+describe("Tooltip — render", () => {
+ it("renders children without showing tooltip on mount", () => {
+ render(
+
+ Hover me
+
+ );
+ expect(screen.getByRole("button", { name: "Hover me" })).toBeTruthy();
+ // Tooltip portal is not yet in the DOM (no timer fires on mount)
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+
+ it("does not render the tooltip portal when text is empty string", () => {
+ render(
+
+ Hover me
+
+ );
+ // Move mouse over trigger
+ fireEvent.mouseEnter(screen.getByRole("button"));
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+
+ it("mounts the tooltip into a portal attached to document.body", () => {
+ render(
+
+ Hover me
+
+ );
+ // Simulate mouse enter → 400ms delay → tooltip renders
+ fireEvent.mouseEnter(screen.getByRole("button"));
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(document.body.querySelector('[role="tooltip"]')).toBeTruthy();
+ });
+});
+
+describe("Tooltip — hover delay", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("does NOT show tooltip before the 400ms delay expires", () => {
+ render(
+
+ Hover me
+
+ );
+ fireEvent.mouseEnter(screen.getByRole("button"));
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+
+ it("shows tooltip after 400ms hover delay", () => {
+ render(
+
+ Hover me
+
+ );
+ fireEvent.mouseEnter(screen.getByRole("button"));
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.queryByRole("tooltip")).toBeTruthy();
+ });
+
+ it("hides tooltip immediately on mouse leave (clears pending timer)", () => {
+ render(
+
+ Hover me
+
+ );
+ const btn = screen.getByRole("button");
+ fireEvent.mouseEnter(btn);
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ expect(screen.queryByRole("tooltip")).toBeNull();
+
+ fireEvent.mouseLeave(btn);
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ // Still not shown because mouseLeave cancelled the timer
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ });
+
+ it("does not show on a second mouseEnter after mouseLeave", () => {
+ render(
+
+ Hover me
+
+ );
+ const btn = screen.getByRole("button");
+ fireEvent.mouseEnter(btn);
+ fireEvent.mouseLeave(btn);
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.queryByRole("tooltip")).toBeNull();
+
+ // Re-enter
+ fireEvent.mouseEnter(btn);
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.queryByRole("tooltip")).toBeTruthy();
+ });
+});
+
+describe("Tooltip — keyboard focus reveal", () => {
+ it("shows tooltip on focus without needing the hover timer", () => {
+ vi.useFakeTimers();
+ render(
+
+ Focus me
+
+ );
+ const btn = screen.getByRole("button");
+ // No timer needed — onFocus shows immediately
+ act(() => {
+ btn.focus();
+ });
+ expect(screen.queryByRole("tooltip")).toBeTruthy();
+ vi.useRealTimers();
+ });
+
+ it("hides tooltip on blur", () => {
+ vi.useFakeTimers();
+ render(
+
+ Focus me
+
+ );
+ const btn = screen.getByRole("button");
+ act(() => {
+ btn.focus();
+ });
+ expect(screen.queryByRole("tooltip")).toBeTruthy();
+
+ act(() => {
+ btn.blur();
+ });
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ vi.useRealTimers();
+ });
+});
+
+describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
+ it("dismisses tooltip on Escape without blurring the trigger", () => {
+ vi.useFakeTimers();
+ render(
+
+ Hover me
+
+ );
+ const btn = screen.getByRole("button");
+ fireEvent.mouseEnter(btn);
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.queryByRole("tooltip")).toBeTruthy();
+ expect(document.activeElement).toBe(btn);
+
+ act(() => {
+ fireEvent.keyDown(window, { key: "Escape" });
+ });
+ expect(screen.queryByRole("tooltip")).toBeNull();
+ // Trigger is still focused (Esc dismisses tooltip but does not blur)
+ expect(document.activeElement).toBe(btn);
+ vi.useRealTimers();
+ });
+
+ it("does nothing on non-Escape keys while tooltip is open", () => {
+ vi.useFakeTimers();
+ render(
+
+ Hover me
+
+ );
+ const btn = screen.getByRole("button");
+ fireEvent.mouseEnter(btn);
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.queryByRole("tooltip")).toBeTruthy();
+
+ act(() => {
+ fireEvent.keyDown(window, { key: "Enter" });
+ });
+ // Tooltip still visible
+ expect(screen.queryByRole("tooltip")).toBeTruthy();
+ vi.useRealTimers();
+ });
+});
+
+describe("Tooltip — aria-describedby", () => {
+ it("associates tooltip with the trigger via aria-describedby", () => {
+ render(
+
+ Hover me
+
+ );
+ const btn = screen.getByRole("button");
+ const describedBy = btn.getAttribute("aria-describedby");
+ expect(describedBy).toBeTruthy();
+ // The describedby id matches the tooltip id
+ const tooltipId = describedBy!.replace(/.*?:\s*/, "");
+ expect(document.getElementById(tooltipId)).toBeTruthy();
+ });
+});