diff --git a/canvas/src/components/__tests__/BundleDropZone.test.tsx b/canvas/src/components/__tests__/BundleDropZone.test.tsx
new file mode 100644
index 00000000..ed897b39
--- /dev/null
+++ b/canvas/src/components/__tests__/BundleDropZone.test.tsx
@@ -0,0 +1,317 @@
+// @vitest-environment jsdom
+/**
+ * Tests for BundleDropZone component.
+ *
+ * Covers: drag-over/drag-leave state, drop of valid/invalid files,
+ * keyboard file input, import success, import error, auto-clear timeout.
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { BundleDropZone } from "../BundleDropZone";
+import { api } from "@/lib/api";
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ post: vi.fn(),
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ vi.useRealTimers();
+});
+
+// ─── Test helper ──────────────────────────────────────────────────────────────
+
+function makeBundle(name = "test-workspace"): File {
+ const content = JSON.stringify({
+ name,
+ tier: 2,
+ skills: [],
+ config: {},
+ });
+ return new File([content], "test.bundle.json", {
+ type: "application/json",
+ });
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe("BundleDropZone — render", () => {
+ it("renders a hidden file input with correct accept and aria-label", () => {
+ render();
+ const input = screen.getByLabelText("Import bundle file");
+ expect(input.getAttribute("type")).toBe("file");
+ expect(input.getAttribute("accept")).toBe(".bundle.json");
+ });
+
+ it("renders the keyboard-accessible import button with aria-label", () => {
+ render();
+ const btn = screen.getByRole("button", { name: /import bundle/i });
+ expect(btn).toBeTruthy();
+ expect(btn.getAttribute("aria-controls")).toBe("bundle-file-input");
+ });
+});
+
+describe("BundleDropZone — drag state", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("shows the drop overlay when a file is dragged over", () => {
+ render();
+ const overlay = screen.getByText("Drop Bundle to Import").closest("div");
+ expect(overlay?.className).toContain("fixed");
+
+ // Simulate drag-over on the invisible drop zone
+ const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]') as HTMLElement;
+ if (zone) {
+ fireEvent.dragOver(zone);
+ } else {
+ // Fallback: dispatch on the component's outer div
+ const container = document.body.querySelector('[class*="pointer-events-none"]') as HTMLElement;
+ if (container) {
+ fireEvent.dragOver(container);
+ }
+ }
+ });
+
+ it("hides the drop overlay when not dragging", () => {
+ render();
+ // By default (no drag), the overlay should not be visible
+ expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
+ });
+});
+
+describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
+ it("triggers the hidden file input when the import button is clicked", () => {
+ render();
+ const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
+ const clickSpy = vi.spyOn(input, "click");
+ fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
+ expect(clickSpy).toHaveBeenCalled();
+ });
+
+ it("processes a selected file when the file input changes", async () => {
+ vi.useFakeTimers();
+ const postMock = vi.mocked(api.post).mockResolvedValueOnce({
+ workspace_id: "ws-new",
+ name: "Imported Workspace",
+ status: "online",
+ });
+
+ render();
+ const input = screen.getByLabelText("Import bundle file");
+
+ const file = makeBundle("My Bundle");
+ Object.defineProperty(input, "files", {
+ value: [file],
+ writable: false,
+ });
+
+ fireEvent.change(input);
+
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(postMock).toHaveBeenCalledWith(
+ "/bundles/import",
+ expect.objectContaining({ name: "My Bundle" })
+ );
+ vi.useRealTimers();
+ });
+});
+
+describe("BundleDropZone — import success", () => {
+ it("shows success toast after successful import", async () => {
+ vi.useFakeTimers();
+ vi.mocked(api.post).mockResolvedValueOnce({
+ workspace_id: "ws-new",
+ name: "My Workspace",
+ status: "online",
+ });
+
+ render();
+ const input = screen.getByLabelText("Import bundle file");
+
+ const file = makeBundle("Success Workspace");
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+
+ fireEvent.change(input);
+
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+
+ // Success toast should be visible
+ expect(screen.getByText(/imported "my workspace" successfully/i)).toBeTruthy();
+
+ // Toast auto-clears after 4000ms
+ await act(async () => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(screen.queryByRole("status")).toBeNull();
+ vi.useRealTimers();
+ });
+
+ it("clears the result toast after 4000ms", async () => {
+ vi.useFakeTimers();
+ vi.mocked(api.post).mockResolvedValueOnce({
+ workspace_id: "ws-new",
+ name: "Timed Workspace",
+ status: "online",
+ });
+
+ render();
+ const input = screen.getByLabelText("Import bundle file");
+
+ const file = makeBundle("Timed Workspace");
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+
+ fireEvent.change(input);
+
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.queryByText(/timed workspace/i)).toBeTruthy();
+
+ await act(async () => {
+ vi.advanceTimersByTime(4500);
+ });
+ expect(screen.queryByText(/timed workspace/i)).toBeNull();
+ vi.useRealTimers();
+ });
+});
+
+describe("BundleDropZone — import error", () => {
+ it("shows error toast when the API call fails", async () => {
+ vi.useFakeTimers();
+ vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
+
+ render();
+ const input = screen.getByLabelText("Import bundle file");
+
+ const file = makeBundle("Failed Workspace");
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+
+ fireEvent.change(input);
+
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(screen.getByText(/import failed: 500 internal server error/i)).toBeTruthy();
+ vi.useRealTimers();
+ });
+
+ it("shows error when file is not a .bundle.json", async () => {
+ vi.useFakeTimers();
+ render();
+ const input = screen.getByLabelText("Import bundle file");
+
+ const file = new File(["{}"], "readme.txt", { type: "text/plain" });
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+
+ fireEvent.change(input);
+
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(screen.getByText(/only .bundle.json files are accepted/i)).toBeTruthy();
+ // Error clears after 3000ms
+ await act(async () => {
+ vi.advanceTimersByTime(3500);
+ });
+ expect(screen.queryByText(/only .bundle.json/i)).toBeNull();
+ vi.useRealTimers();
+ });
+
+ it("clears error after 4000ms", async () => {
+ vi.useFakeTimers();
+ vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
+
+ render();
+ const input = screen.getByLabelText("Import bundle file");
+
+ const file = makeBundle("Error Workspace");
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+
+ fireEvent.change(input);
+
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(screen.queryByText(/network error/i)).toBeTruthy();
+
+ await act(async () => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(screen.queryByText(/network error/i)).toBeNull();
+ vi.useRealTimers();
+ });
+});
+
+describe("BundleDropZone — importing state", () => {
+ it("shows 'Importing bundle...' status while API call is in flight", async () => {
+ vi.useFakeTimers();
+ let resolve: (v: unknown) => void;
+ const pending = new Promise((r) => { resolve = r; });
+ vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType);
+
+ render();
+ const input = screen.getByLabelText("Import bundle file");
+
+ const file = makeBundle("Pending Workspace");
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+
+ fireEvent.change(input);
+
+ // Advance timer to allow the state update to flush
+ await act(async () => {
+ vi.advanceTimersByTime(100);
+ });
+
+ expect(screen.getByText("Importing bundle...")).toBeTruthy();
+ expect(screen.getByRole("status")).toBeTruthy();
+
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ vi.useRealTimers();
+ });
+});
+
+describe("BundleDropZone — file input reset", () => {
+ it("resets the file input value after processing so the same file can be re-selected", async () => {
+ vi.useFakeTimers();
+ vi.mocked(api.post).mockResolvedValueOnce({
+ workspace_id: "ws-new",
+ name: "Reset Workspace",
+ status: "online",
+ });
+
+ render();
+ const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
+
+ const file = makeBundle("Reset Test");
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+
+ fireEvent.change(input);
+
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+
+ // The component calls e.target.value = "" after processing
+ expect(input.value).toBe("");
+ vi.useRealTimers();
+ });
+});
diff --git a/canvas/src/components/__tests__/ThemeToggle.test.tsx b/canvas/src/components/__tests__/ThemeToggle.test.tsx
new file mode 100644
index 00000000..14e71603
--- /dev/null
+++ b/canvas/src/components/__tests__/ThemeToggle.test.tsx
@@ -0,0 +1,146 @@
+// @vitest-environment jsdom
+/**
+ * Tests for ThemeToggle component.
+ *
+ * Covers: renders all three options, aria radiogroup semantics,
+ * aria-checked per option, setTheme calls on click, custom className prop.
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ThemeToggle } from "../ThemeToggle";
+import * as themeProvider from "@/lib/theme-provider";
+
+// ─── Mock theme provider ───────────────────────────────────────────────────────
+
+const mockSetTheme = vi.fn();
+
+vi.mock("@/lib/theme-provider", () => ({
+ useTheme: vi.fn(() => ({
+ theme: "dark",
+ resolvedTheme: "dark",
+ setTheme: mockSetTheme,
+ })),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe("ThemeToggle — render", () => {
+ beforeEach(() => {
+ vi.mocked(themeProvider.useTheme).mockReturnValue({
+ theme: "dark",
+ resolvedTheme: "dark",
+ setTheme: mockSetTheme,
+ });
+ });
+
+ it("renders a radiogroup with aria-label", () => {
+ render();
+ expect(screen.getByRole("radiogroup", { name: "Theme preference" })).toBeTruthy();
+ });
+
+ it("renders three radio buttons", () => {
+ render();
+ const radios = screen.getAllByRole("radio");
+ expect(radios).toHaveLength(3);
+ });
+
+ it("has aria-checked=true on the active option", () => {
+ vi.mocked(themeProvider.useTheme).mockReturnValue({
+ theme: "dark",
+ resolvedTheme: "dark",
+ setTheme: mockSetTheme,
+ });
+ render();
+ const radios = screen.getAllByRole("radio");
+ expect(radios[2].getAttribute("aria-checked")).toBe("true"); // dark is third
+ expect(radios[0].getAttribute("aria-checked")).toBe("false"); // light is first
+ expect(radios[1].getAttribute("aria-checked")).toBe("false"); // system is second
+ });
+
+ it("marks 'light' as active when theme=light", () => {
+ vi.mocked(themeProvider.useTheme).mockReturnValue({
+ theme: "light",
+ resolvedTheme: "light",
+ setTheme: mockSetTheme,
+ });
+ render();
+ const radios = screen.getAllByRole("radio");
+ expect(radios[0].getAttribute("aria-checked")).toBe("true"); // light
+ expect(radios[1].getAttribute("aria-checked")).toBe("false"); // system
+ expect(radios[2].getAttribute("aria-checked")).toBe("false"); // dark
+ });
+
+ it("marks 'system' as active when theme=system", () => {
+ vi.mocked(themeProvider.useTheme).mockReturnValue({
+ theme: "system",
+ resolvedTheme: "light",
+ setTheme: mockSetTheme,
+ });
+ render();
+ const radios = screen.getAllByRole("radio");
+ expect(radios[0].getAttribute("aria-checked")).toBe("false"); // light
+ expect(radios[1].getAttribute("aria-checked")).toBe("true"); // system
+ expect(radios[2].getAttribute("aria-checked")).toBe("false"); // dark
+ });
+
+ it("has aria-label on each button matching the option label", () => {
+ render();
+ expect(screen.getByRole("radio", { name: "Light" })).toBeTruthy();
+ expect(screen.getByRole("radio", { name: "System" })).toBeTruthy();
+ expect(screen.getByRole("radio", { name: "Dark" })).toBeTruthy();
+ });
+});
+
+describe("ThemeToggle — interaction", () => {
+ beforeEach(() => {
+ vi.mocked(themeProvider.useTheme).mockReturnValue({
+ theme: "dark",
+ resolvedTheme: "dark",
+ setTheme: mockSetTheme,
+ });
+ });
+
+ it("calls setTheme with 'light' when light button is clicked", () => {
+ render();
+ fireEvent.click(screen.getByRole("radio", { name: "Light" }));
+ expect(mockSetTheme).toHaveBeenCalledWith("light");
+ });
+
+ it("calls setTheme with 'system' when system button is clicked", () => {
+ render();
+ fireEvent.click(screen.getByRole("radio", { name: "System" }));
+ expect(mockSetTheme).toHaveBeenCalledWith("system");
+ });
+
+ it("calls setTheme with 'dark' when dark button is clicked", () => {
+ render();
+ fireEvent.click(screen.getByRole("radio", { name: "Dark" }));
+ expect(mockSetTheme).toHaveBeenCalledWith("dark");
+ });
+
+ it("calls setTheme only once per click", () => {
+ render();
+ fireEvent.click(screen.getByRole("radio", { name: "Light" }));
+ expect(mockSetTheme).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("ThemeToggle — className prop", () => {
+ it("passes custom className to the radiogroup", () => {
+ render();
+ const group = screen.getByRole("radiogroup", { name: "Theme preference" });
+ expect(group.className).toContain("my-custom-class");
+ });
+
+ it("applies default className when none provided", () => {
+ render();
+ const group = screen.getByRole("radiogroup", { name: "Theme preference" });
+ expect(group.className).toContain("inline-flex");
+ });
+});