diff --git a/tests/unit/canvas-import-boundary.test.ts b/tests/unit/canvas-import-boundary.test.ts
new file mode 100644
index 00000000..16d71047
--- /dev/null
+++ b/tests/unit/canvas-import-boundary.test.ts
@@ -0,0 +1,149 @@
+// @vitest-environment node
+/**
+ * The canvas modules are reused by the MCP server's browser shell, which
+ * has no chat and no Next.js. They must not import the chat, the tool
+ * handlers, Next-only modules or server-only code. Checked twice: the
+ * import statements of each file, and what esbuild pulls into a bundle of
+ * the canvas components.
+ */
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
+import path from "node:path"
+import { build } from "esbuild"
+import { describe, expect, it } from "vitest"
+
+const root = process.cwd()
+
+const SCANNED = [
+ "components/canvas",
+ "contexts/diagram-context.tsx",
+ "lib/drawio",
+ "lib/diagram-pages.ts",
+ "lib/diagram-diff.ts",
+ "stores/canvas-store.ts",
+ "stores/versions-store.ts",
+]
+
+const FORBIDDEN = [
+ /^@\/components\/chat\//,
+ /^@\/hooks\/use-diagram-tool-handlers/,
+ /^next\/navigation$/,
+ /^next\/font(\/|$)/,
+ /^next\/script$/,
+ /^next\/headers$/,
+ /^server-only$/,
+]
+
+function listFiles(entry: string): string[] {
+ const full = path.join(root, entry)
+ if (statSync(full).isFile()) return [entry]
+ return readdirSync(full, { recursive: true })
+ .map(String)
+ .filter((name) => /\.tsx?$/.test(name))
+ .map((name) => path.join(entry, name))
+}
+
+/** Module specifiers a file imports at runtime (type-only imports do not
+ * reach a bundle) */
+function valueImports(source: string): string[] {
+ const code = source
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/^\s*\/\/.*$/gm, "")
+ const specifiers: string[] = []
+ const statements =
+ /(?:^|\n)\s*(import|export)\s+([\s\S]*?)\s*from\s*["']([^"']+)["']|(?:^|\n)\s*import\s+["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g
+ for (const match of code.matchAll(statements)) {
+ const [, keyword, clause, from, bare, dynamic] = match
+ if (keyword && /^type\s/.test(clause)) continue
+ specifiers.push(from ?? bare ?? dynamic)
+ }
+ return specifiers
+}
+
+/** The file an "@/..." or relative specifier points at, when it is one of
+ * ours */
+function resolveLocal(fromFile: string, specifier: string): string | null {
+ let base: string
+ if (specifier.startsWith("@/")) base = path.join(root, specifier.slice(2))
+ else if (specifier.startsWith(".")) {
+ base = path.resolve(root, path.dirname(fromFile), specifier)
+ } else return null
+ for (const candidate of [
+ base,
+ `${base}.ts`,
+ `${base}.tsx`,
+ path.join(base, "index.ts"),
+ path.join(base, "index.tsx"),
+ ]) {
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
+ return path.relative(root, candidate)
+ }
+ }
+ return null
+}
+
+const isServerOnly = (file: string) =>
+ /^\s*import\s+["']server-only["']/m.test(
+ readFileSync(path.join(root, file), "utf8"),
+ )
+
+describe("canvas import boundary", () => {
+ const files = SCANNED.flatMap(listFiles)
+
+ it("scans the canvas modules", () => {
+ expect(files).toContain("components/canvas/canvas-stage.tsx")
+ expect(files).toContain("lib/drawio/editor-bridge.ts")
+ })
+
+ it.each(files)("%s imports no chat or Next-only module", (file) => {
+ const source = readFileSync(path.join(root, file), "utf8")
+ const offending = valueImports(source).filter((specifier) => {
+ if (FORBIDDEN.some((rule) => rule.test(specifier))) return true
+ const target = resolveLocal(file, specifier)
+ if (!target) return false
+ return target.startsWith("components/chat/") || isServerOnly(target)
+ })
+ expect(offending).toEqual([])
+ })
+
+ it("bundles the canvas components without the chat", async () => {
+ const result = await build({
+ entryPoints: [
+ "components/canvas/canvas-stage.tsx",
+ "components/canvas/compare-dialog.tsx",
+ "components/canvas/version-card.tsx",
+ "components/canvas/version-strip.tsx",
+ ],
+ absWorkingDir: root,
+ // Several entries need an outdir, even when nothing is written
+ outdir: "out",
+ bundle: true,
+ write: false,
+ metafile: true,
+ platform: "browser",
+ format: "esm",
+ jsx: "automatic",
+ alias: { "@": root },
+ external: ["react", "react-dom", "next/*"],
+ logLevel: "silent",
+ })
+ const inputs = result.metafile.inputs
+ const bundled = Object.keys(inputs)
+ expect(bundled.length).toBeGreaterThan(10)
+ expect(
+ bundled.filter(
+ (file) =>
+ file.startsWith("components/chat/") ||
+ file.includes("node_modules/server-only/"),
+ ),
+ ).toEqual([])
+ const nextImports = Object.entries(inputs).flatMap(([file, input]) =>
+ input.imports
+ .map((entry) => entry.path)
+ .filter((specifier) =>
+ FORBIDDEN.some((rule) => rule.test(specifier)),
+ )
+ .map((specifier) => `${file} -> ${specifier}`),
+ )
+ expect(nextImports).toEqual([])
+ })
+})
diff --git a/tests/unit/version-card.test.tsx b/tests/unit/version-card.test.tsx
new file mode 100644
index 00000000..a9d7de54
--- /dev/null
+++ b/tests/unit/version-card.test.tsx
@@ -0,0 +1,143 @@
+import { cleanup, fireEvent, render } from "@testing-library/react"
+import type React from "react"
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { VersionCard } from "@/components/canvas/version-card"
+import { VersionStrip } from "@/components/canvas/version-strip"
+import {
+ type VersionItem,
+ VersionsProvider,
+ type VersionsSource,
+} from "@/components/canvas/versions-context"
+import { DictionaryProvider } from "@/hooks/use-dictionary"
+import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
+import type { Dictionary } from "@/lib/i18n/dictionaries"
+import en from "@/lib/i18n/dictionaries/en.json"
+
+afterEach(cleanup)
+
+// jsdom has no scrolling; the strip scrolls to its newest version
+Element.prototype.scrollTo = vi.fn()
+
+const version = (id: string, number: number): VersionItem => ({
+ id,
+ number,
+ xml: `${id}`,
+ beforeXml: "",
+ svg: "",
+ summary: { ...EMPTY_SUMMARY, shapesAdded: 2 },
+})
+
+function source(overrides: Partial = {}): VersionsSource {
+ return {
+ versions: [version("v1", 1), version("v2", 2)],
+ onCanvasId: "v2",
+ undoneId: null,
+ isBusy: false,
+ canUndo: true,
+ canRedo: false,
+ restore: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ ...overrides,
+ }
+}
+
+function mount(ui: React.ReactNode, value: VersionsSource) {
+ return render(
+
+ {ui}
+ ,
+ )
+}
+
+describe("VersionCard", () => {
+ it("undoes the latest version from the source", () => {
+ const value = source()
+ const { getByTestId, container } = mount(
+ ,
+ value,
+ )
+ expect(container.textContent).toContain("v2")
+ expect(container.textContent).toContain("Added 2 shapes")
+ expect(getByTestId("version-card").dataset.toolState).toBe(
+ "output-available",
+ )
+ fireEvent.click(getByTestId("version-undo"))
+ expect(value.undo).toHaveBeenCalled()
+ expect(value.redo).not.toHaveBeenCalled()
+ })
+
+ it("redoes an undone change and says so", () => {
+ const value = source({
+ onCanvasId: null,
+ undoneId: "v2",
+ canUndo: false,
+ canRedo: true,
+ })
+ const { getByTestId, container } = mount(
+ ,
+ value,
+ )
+ expect(container.textContent).toContain("Change undone")
+ fireEvent.click(getByTestId("version-undo"))
+ expect(value.redo).toHaveBeenCalled()
+ })
+
+ it("restores an older version; not while busy", () => {
+ const value = source({ isBusy: true })
+ const { getByTestId, rerender } = mount(
+ ,
+ value,
+ )
+ expect(
+ (getByTestId("version-restore") as HTMLButtonElement).disabled,
+ ).toBe(true)
+ const idle = source()
+ rerender(
+
+
+
+
+ ,
+ )
+ fireEvent.click(getByTestId("version-restore"))
+ expect(idle.restore).toHaveBeenCalledWith("v1")
+ })
+})
+
+describe("VersionStrip", () => {
+ it("shows one thumbnail per version, the one on the canvas marked", () => {
+ const { getAllByTestId } = mount(, source())
+ const thumbs = getAllByTestId("version-thumb")
+ expect(thumbs).toHaveLength(2)
+ expect(thumbs[1].title).toContain("Canvas now")
+ expect(thumbs[0].title).not.toContain("Canvas now")
+ })
+
+ it("hides below the minimum", () => {
+ const one = source({ versions: [version("v1", 1)] })
+ const { container, rerender } = mount(, one)
+ expect(container.textContent).toBe("")
+ rerender(
+
+
+
+
+ ,
+ )
+ expect(container.textContent).toContain("Versions")
+ })
+})