diff --git a/apps/cli/src/__tests__/fixtures.ts b/apps/cli/src/__tests__/fixtures.ts index 50599a43..9911b303 100644 --- a/apps/cli/src/__tests__/fixtures.ts +++ b/apps/cli/src/__tests__/fixtures.ts @@ -197,6 +197,8 @@ export function builtDiagram( title, document, scene: { + kind: "canvas", + version: 1, diagramId: id, title, width: 640, @@ -204,6 +206,9 @@ export function builtDiagram( accentColor: "#7c3aed", backgroundColor: "#ffffff", elements: [], + layers: [], + layouts: [], + zOrder: [], }, excalidraw: { type: "excalidraw", diff --git a/apps/cli/src/patch.ts b/apps/cli/src/patch.ts index aee9641e..0e4ef724 100644 --- a/apps/cli/src/patch.ts +++ b/apps/cli/src/patch.ts @@ -67,6 +67,8 @@ export const decodePatchInput = Effect.fn("sketchi.cli.patch.decodeInput")( requestId: "cli-patch-validation", source: { scene: { + kind: "canvas", + version: 1, diagramId: "validation", title: "Validation", width: 1, @@ -74,6 +76,9 @@ export const decodePatchInput = Effect.fn("sketchi.cli.patch.decodeInput")( accentColor: "#000000", backgroundColor: "#ffffff", elements: [], + layers: [], + layouts: [], + zOrder: [], }, }, }); diff --git a/apps/playground/src/application-structure.test.ts b/apps/playground/src/application-structure.test.ts index 397be693..d750f45b 100644 --- a/apps/playground/src/application-structure.test.ts +++ b/apps/playground/src/application-structure.test.ts @@ -16,6 +16,7 @@ const expectedPublicFullPaths = [ "/api/studio/projects/from-artifact", "/api/v1/artifacts/$artifactId", "/api/v1/artifacts/$artifactId/patch", + "/api/v1/canvases/create", "/api/v1/flowcharts/build", "/api/v1/generate", "/api/v1/mindmaps/build", @@ -77,6 +78,7 @@ describe("Playground application structure", () => { "api/studio/projects_/$projectId.ts", "api/v1/artifacts/$artifactId.ts", "api/v1/artifacts/$artifactId/patch.ts", + "api/v1/canvases/create.ts", "api/v1/flowcharts/build.ts", "api/v1/generate.ts", "api/v1/mindmaps/build.ts", diff --git a/apps/playground/src/features/playground/deploy-pipeline-sample.ts b/apps/playground/src/features/playground/deploy-pipeline-sample.ts index 12c78149..08493e0b 100644 --- a/apps/playground/src/features/playground/deploy-pipeline-sample.ts +++ b/apps/playground/src/features/playground/deploy-pipeline-sample.ts @@ -83,6 +83,10 @@ export const DEPLOY_PIPELINE_SCENE = { }; } + if (element.type !== "text") { + return element; + } + return { ...element, fontSize: 15, diff --git a/apps/playground/src/routeTree.gen.ts b/apps/playground/src/routeTree.gen.ts index 089a5aa0..29825480 100644 --- a/apps/playground/src/routeTree.gen.ts +++ b/apps/playground/src/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as ApiStudioProjectsRouteImport } from "./routes/api/studio/proje import { Route as ApiV1SequencesBuildRouteImport } from "./routes/api/v1/sequences/build"; import { Route as ApiV1MindmapsBuildRouteImport } from "./routes/api/v1/mindmaps/build"; import { Route as ApiV1FlowchartsBuildRouteImport } from "./routes/api/v1/flowcharts/build"; +import { Route as ApiV1CanvasesCreateRouteImport } from "./routes/api/v1/canvases/create"; import { Route as ApiV1ArtifactsArtifactIdRouteImport } from "./routes/api/v1/artifacts/$artifactId"; import { Route as ApiStudioProjectsProjectIdRouteImport } from "./routes/api/studio/projects_/$projectId"; import { Route as ApiStudioProjectsFromArtifactRouteImport } from "./routes/api/studio/projects/from-artifact"; @@ -111,6 +112,11 @@ const ApiV1FlowchartsBuildRoute = ApiV1FlowchartsBuildRouteImport.update({ path: "/api/v1/flowcharts/build", getParentRoute: () => rootRouteImport, } as any); +const ApiV1CanvasesCreateRoute = ApiV1CanvasesCreateRouteImport.update({ + id: "/api/v1/canvases/create", + path: "/api/v1/canvases/create", + getParentRoute: () => rootRouteImport, +} as any); const ApiV1ArtifactsArtifactIdRoute = ApiV1ArtifactsArtifactIdRouteImport.update({ id: "/api/v1/artifacts/$artifactId", @@ -160,6 +166,7 @@ export interface FileRoutesByFullPath { "/api/studio/projects/from-artifact": typeof ApiStudioProjectsFromArtifactRoute; "/api/studio/projects/$projectId": typeof ApiStudioProjectsProjectIdRoute; "/api/v1/artifacts/$artifactId": typeof ApiV1ArtifactsArtifactIdRouteWithChildren; + "/api/v1/canvases/create": typeof ApiV1CanvasesCreateRoute; "/api/v1/flowcharts/build": typeof ApiV1FlowchartsBuildRoute; "/api/v1/mindmaps/build": typeof ApiV1MindmapsBuildRoute; "/api/v1/sequences/build": typeof ApiV1SequencesBuildRoute; @@ -183,6 +190,7 @@ export interface FileRoutesByTo { "/api/studio/projects/from-artifact": typeof ApiStudioProjectsFromArtifactRoute; "/api/studio/projects/$projectId": typeof ApiStudioProjectsProjectIdRoute; "/api/v1/artifacts/$artifactId": typeof ApiV1ArtifactsArtifactIdRouteWithChildren; + "/api/v1/canvases/create": typeof ApiV1CanvasesCreateRoute; "/api/v1/flowcharts/build": typeof ApiV1FlowchartsBuildRoute; "/api/v1/mindmaps/build": typeof ApiV1MindmapsBuildRoute; "/api/v1/sequences/build": typeof ApiV1SequencesBuildRoute; @@ -207,6 +215,7 @@ export interface FileRoutesById { "/api/studio/projects/from-artifact": typeof ApiStudioProjectsFromArtifactRoute; "/api/studio/projects_/$projectId": typeof ApiStudioProjectsProjectIdRoute; "/api/v1/artifacts/$artifactId": typeof ApiV1ArtifactsArtifactIdRouteWithChildren; + "/api/v1/canvases/create": typeof ApiV1CanvasesCreateRoute; "/api/v1/flowcharts/build": typeof ApiV1FlowchartsBuildRoute; "/api/v1/mindmaps/build": typeof ApiV1MindmapsBuildRoute; "/api/v1/sequences/build": typeof ApiV1SequencesBuildRoute; @@ -232,6 +241,7 @@ export interface FileRouteTypes { | "/api/studio/projects/from-artifact" | "/api/studio/projects/$projectId" | "/api/v1/artifacts/$artifactId" + | "/api/v1/canvases/create" | "/api/v1/flowcharts/build" | "/api/v1/mindmaps/build" | "/api/v1/sequences/build" @@ -255,6 +265,7 @@ export interface FileRouteTypes { | "/api/studio/projects/from-artifact" | "/api/studio/projects/$projectId" | "/api/v1/artifacts/$artifactId" + | "/api/v1/canvases/create" | "/api/v1/flowcharts/build" | "/api/v1/mindmaps/build" | "/api/v1/sequences/build" @@ -278,6 +289,7 @@ export interface FileRouteTypes { | "/api/studio/projects/from-artifact" | "/api/studio/projects_/$projectId" | "/api/v1/artifacts/$artifactId" + | "/api/v1/canvases/create" | "/api/v1/flowcharts/build" | "/api/v1/mindmaps/build" | "/api/v1/sequences/build" @@ -301,6 +313,7 @@ export interface RootRouteChildren { ApiStudioDiagramsDiagramIdRoute: typeof ApiStudioDiagramsDiagramIdRoute; ApiStudioProjectsProjectIdRoute: typeof ApiStudioProjectsProjectIdRoute; ApiV1ArtifactsArtifactIdRoute: typeof ApiV1ArtifactsArtifactIdRouteWithChildren; + ApiV1CanvasesCreateRoute: typeof ApiV1CanvasesCreateRoute; ApiV1FlowchartsBuildRoute: typeof ApiV1FlowchartsBuildRoute; ApiV1MindmapsBuildRoute: typeof ApiV1MindmapsBuildRoute; ApiV1SequencesBuildRoute: typeof ApiV1SequencesBuildRoute; @@ -420,6 +433,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof ApiV1FlowchartsBuildRouteImport; parentRoute: typeof rootRouteImport; }; + "/api/v1/canvases/create": { + id: "/api/v1/canvases/create"; + path: "/api/v1/canvases/create"; + fullPath: "/api/v1/canvases/create"; + preLoaderRoute: typeof ApiV1CanvasesCreateRouteImport; + parentRoute: typeof rootRouteImport; + }; "/api/v1/artifacts/$artifactId": { id: "/api/v1/artifacts/$artifactId"; path: "/api/v1/artifacts/$artifactId"; @@ -500,6 +520,7 @@ const rootRouteChildren: RootRouteChildren = { ApiStudioDiagramsDiagramIdRoute: ApiStudioDiagramsDiagramIdRoute, ApiStudioProjectsProjectIdRoute: ApiStudioProjectsProjectIdRoute, ApiV1ArtifactsArtifactIdRoute: ApiV1ArtifactsArtifactIdRouteWithChildren, + ApiV1CanvasesCreateRoute: ApiV1CanvasesCreateRoute, ApiV1FlowchartsBuildRoute: ApiV1FlowchartsBuildRoute, ApiV1MindmapsBuildRoute: ApiV1MindmapsBuildRoute, ApiV1SequencesBuildRoute: ApiV1SequencesBuildRoute, diff --git a/apps/playground/src/routes/api/v1/canvases/create.ts b/apps/playground/src/routes/api/v1/canvases/create.ts new file mode 100644 index 00000000..7b48167c --- /dev/null +++ b/apps/playground/src/routes/api/v1/canvases/create.ts @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/api/v1/canvases/create")({ + server: { + handlers: { + POST: async ({ request }) => { + const [ + { getPlaygroundRequestBoundary }, + { handleCreateCanvasRequest }, + { runPlaygroundEffect }, + ] = await Promise.all([ + import("@/server/bindings/cloudflare-bindings.server"), + import("@/server/codemode/api.server"), + import("@/server/runtime/runtime.server"), + ]); + + return runPlaygroundEffect( + handleCreateCanvasRequest(request), + getPlaygroundRequestBoundary(request), + ); + }, + }, + }, +}); diff --git a/apps/playground/src/server/codemode/api.server.test.ts b/apps/playground/src/server/codemode/api.server.test.ts index c08c2588..f40093da 100644 --- a/apps/playground/src/server/codemode/api.server.test.ts +++ b/apps/playground/src/server/codemode/api.server.test.ts @@ -11,6 +11,7 @@ import { handleBuildFlowchartRequest as handleBuildFlowchartRequestEffect, handleBuildMindmapRequest as handleBuildMindmapRequestEffect, handleBuildSequenceDiagramRequest as handleBuildSequenceDiagramRequestEffect, + handleCreateCanvasRequest as handleCreateCanvasRequestEffect, handleGetArtifactRequest as handleGetArtifactRequestEffect, handlePatchArtifactRequest as handlePatchArtifactRequestEffect, } from "./api.server"; @@ -50,6 +51,13 @@ function handleBuildSequenceDiagramRequest(env: StudioEnv, request: Request) { ); } +function handleCreateCanvasRequest(env: StudioEnv, request: Request) { + return runPlaygroundEffect( + handleCreateCanvasRequestEffect(request), + testBoundary(env, request), + ); +} + function handleGetArtifactRequest( env: StudioEnv, request: Request, @@ -300,6 +308,91 @@ function delay(ms: number): Promise { } describe("Code Mode API handlers", () => { + it("creates a CanvasSpec through the normal Worker route", async () => { + const response = await handleCreateCanvasRequest( + {}, + postRequest("https://studio.test/api/v1/canvases/create", { + requestId: "canvas-http", + spec: { + kind: "canvas", + version: 1, + diagramId: "http-canvas", + title: "HTTP canvas", + width: 480, + height: 320, + accentColor: "#111827", + backgroundColor: "#ffffff", + elements: [ + { + type: "node", + id: "card", + nodeId: "card", + shape: "rectangle", + x: 40, + y: 40, + width: 240, + height: 120, + label: "Canvas", + }, + ], + layers: [], + layouts: [], + zOrder: ["card"], + }, + options: { + artifactFormats: ["scene", "excalidraw"], + inlineArtifacts: ["scene"], + }, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + status: "accepted", + requestId: "canvas-http", + normalizedSpec: { kind: "canvas", version: 1 }, + artifact: { + diagramId: "http-canvas", + formats: [{ format: "scene" }, { format: "excalidraw" }], + }, + }); + }); + + it("returns typed HTTP 422 for an empty CanvasSpec", async () => { + const response = await handleCreateCanvasRequest( + {}, + postRequest("https://studio.test/api/v1/canvases/create", { + spec: { + kind: "canvas", + version: 1, + diagramId: "empty-http-canvas", + title: "Empty HTTP canvas", + width: 480, + height: 320, + accentColor: "#111827", + backgroundColor: "#ffffff", + elements: [], + layers: [], + layouts: [], + zOrder: [], + }, + }), + ); + + expect(response.status).toBe(422); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + status: "invalid_canvas", + issues: [ + expect.objectContaining({ + code: "invalid_canvas_geometry", + stage: "canvas", + }), + ], + }); + }); + it("builds a public sequence diagram through the no-auth HTTP handler", async () => { const response = await handleBuildSequenceDiagramRequest( {}, diff --git a/apps/playground/src/server/codemode/api.server.ts b/apps/playground/src/server/codemode/api.server.ts index 1703cc48..7b5a013c 100644 --- a/apps/playground/src/server/codemode/api.server.ts +++ b/apps/playground/src/server/codemode/api.server.ts @@ -6,6 +6,7 @@ import { type BuildFlowchartResult, type BuildMindmapResult, type BuildSequenceDiagramResult, + type CreateCanvasResult, type GetArtifactResult, type StoredArtifactFormat, } from "@sketchi/diagram-agent"; @@ -109,7 +110,7 @@ async function readBoundedBuildJson( } function requestTooLargeResult( - diagramType: "Flowchart" | "Mindmap" | "Sequence diagram", + diagramType: "Canvas" | "Flowchart" | "Mindmap" | "Sequence diagram", ) { return { ok: false as const, @@ -178,6 +179,22 @@ function sequenceBuildStatus(result: BuildSequenceDiagramResult): number { } } +function canvasCreateStatus(result: CreateCanvasResult): number { + if (result.ok) return 200; + switch (result.status) { + case "invalid_input": + return 400; + case "invalid_canvas": + return 422; + case "limit_exceeded": + return 413; + case "render_failed": + case "export_failed": + case "storage_failed": + return 500; + } +} + function getStatus(result: GetArtifactResult): number { if (result.ok) { return 200; @@ -515,6 +532,68 @@ export const handleBuildSequenceDiagramRequest = Effect.fn( ); }); +export const handleCreateCanvasRequest = Effect.fn( + "playground.http.createCanvas", +)(function* (request: Request) { + const clock = yield* PlaygroundClock; + const codeMode = yield* PlaygroundCodeMode; + const usage = yield* PlaygroundCodeModeUsage; + const usageContext = yield* usage.createContext; + const startedAt = yield* clock.nowMillis; + const boundedRequest = yield* requestRead(() => + readBoundedBuildJson(request), + ); + if (!boundedRequest.ok) { + const result = requestTooLargeResult("Canvas"); + const finishedAt = yield* clock.nowMillis; + yield* usage.capture({ + context: usageContext, + durationMs: finishedAt - startedAt, + operation: "createCanvas", + requestBody: boundedRequest.body, + responseBody: result, + statusCode: 413, + surface: "api", + }); + return jsonResponse( + result, + 413, + codeModeUsageResponseHeaders(usageContext), + ); + } + + const requestBody = boundedRequest.body; + const codeModeInput = yield* Effect.promise(() => + decodeCodeModeHttpInput( + CodeModeHttpSchemas.createCanvas.input, + requestBody, + ), + ); + const result = yield* withTelemetryCorrelation( + codeMode.createCanvas(codeModeInput), + { + attemptId: usageContext.attemptId, + runId: usageContext.runId, + }, + ); + const status = canvasCreateStatus(result); + const finishedAt = yield* clock.nowMillis; + yield* usage.capture({ + context: usageContext, + durationMs: finishedAt - startedAt, + operation: "createCanvas", + requestBody, + responseBody: result, + statusCode: status, + surface: "api", + }); + return jsonResponse( + result, + status, + codeModeUsageResponseHeaders(usageContext), + ); +}); + export const handleGetArtifactRequest = Effect.fn( "playground.http.getArtifact", )(function* (request: Request, artifactId: string) { diff --git a/apps/playground/src/server/codemode/browser-renderer.server.test.ts b/apps/playground/src/server/codemode/browser-renderer.server.test.ts index 77fa925d..3d59aa56 100644 --- a/apps/playground/src/server/codemode/browser-renderer.server.test.ts +++ b/apps/playground/src/server/codemode/browser-renderer.server.test.ts @@ -8,6 +8,7 @@ import { type TelemetrySpanEvent, } from "@sketchi/observability"; import { Cause, Effect, Exit, Fiber } from "effect"; +import type { RenderedDiagramScene } from "@sketchi/diagram-renderer"; import { TestClock } from "effect/testing"; import { beforeEach, expect, vi } from "vitest"; @@ -24,16 +25,24 @@ vi.mock("@cloudflare/playwright", () => ({ const browserBinding: CloudflareBrowserRunBinding = { fetch }; -function renderInput() { +function renderInput(): { + scene: RenderedDiagramScene; + excalidraw: { appState: Record; elements: never[] }; +} { return { scene: { + kind: "canvas", + version: 1, accentColor: "#000000", backgroundColor: "#ffffff", diagramId: "test-diagram", elements: [], + layers: [], + layouts: [], height: 120, title: "Test diagram", width: 180, + zOrder: [], }, excalidraw: { appState: {}, diff --git a/apps/playground/src/server/codemode/http-artifact-compatibility-corpus.test.ts b/apps/playground/src/server/codemode/http-artifact-compatibility-corpus.test.ts index 290fed7f..7f37fd25 100644 --- a/apps/playground/src/server/codemode/http-artifact-compatibility-corpus.test.ts +++ b/apps/playground/src/server/codemode/http-artifact-compatibility-corpus.test.ts @@ -202,6 +202,49 @@ function normalizeSketchiPaletteAgainstExactBase( .replaceAll("#1a1712", "#1e1e1e"); } +function normalizeCanvasMigrationAgainstExactBase( + value: unknown, + exactBase: unknown, +): unknown { + if (Array.isArray(value) && Array.isArray(exactBase)) { + return value.map((entry, index) => + normalizeCanvasMigrationAgainstExactBase(entry, exactBase[index]), + ); + } + if (isRecord(value) && isRecord(exactBase)) { + const isCanvasSpec = + value["kind"] === "canvas" && + value["version"] === 1 && + Array.isArray(value["elements"]); + const isCanvasElement = + typeof value["id"] === "string" && + !Object.hasOwn(value, "seed") && + ["node", "text", "arrow", "line", "frame"].includes( + String(value["type"]), + ); + return Object.fromEntries( + Object.entries(value).flatMap(([key, entry]) => + (isCanvasSpec && + ["kind", "version", "layers", "layouts", "zOrder"].includes(key)) || + (isCanvasElement && ["rendererRole", "strokeStyle"].includes(key)) + ? [] + : [ + [ + key, + key === "sizeBytes" && value["format"] === "scene" + ? exactBase[key] + : normalizeCanvasMigrationAgainstExactBase( + entry, + exactBase[key], + ), + ], + ], + ), + ); + } + return value; +} + async function jsonObservation( response: Response, replacements: ReadonlyMap, @@ -244,6 +287,39 @@ afterEach(() => { }); describe("exact-base Code Mode HTTP artifact compatibility corpus", () => { + it("does not hide unrelated HTTP contract drift", () => { + expect( + normalizeCanvasMigrationAgainstExactBase( + { + order: ["second", "first"], + scene: { + kind: "canvas", + version: 1, + diagramId: "legacy", + elements: [], + layers: [], + layouts: [], + zOrder: [], + unexpectedField: true, + }, + unexpectedTopLevel: true, + }, + { + order: ["first", "second"], + scene: { diagramId: "legacy", elements: [] }, + }, + ), + ).toEqual({ + order: ["second", "first"], + scene: { + diagramId: "legacy", + elements: [], + unexpectedField: true, + }, + unexpectedTopLevel: true, + }); + }); + it("captures build, patch, get, raw, mindmap, and persisted encodings", async () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-07-20T12:34:56.789Z")); @@ -373,7 +449,10 @@ describe("exact-base Code Mode HTTP artifact compatibility corpus", () => { const exactBase = JSON.parse(await readFile(fixturePath, "utf8")); expect( `${JSON.stringify( - normalizeSketchiPaletteAgainstExactBase(corpus, exactBase), + normalizeCanvasMigrationAgainstExactBase( + normalizeSketchiPaletteAgainstExactBase(corpus, exactBase), + exactBase, + ), null, 2, )}\n`, diff --git a/apps/playground/src/server/codemode/http-schema.server.ts b/apps/playground/src/server/codemode/http-schema.server.ts index 97f71a83..aed6d886 100644 --- a/apps/playground/src/server/codemode/http-schema.server.ts +++ b/apps/playground/src/server/codemode/http-schema.server.ts @@ -11,6 +11,8 @@ import { BuildSequenceDiagramResultSchema, GetArtifactRequestSchema, GetArtifactResultSchema, + CreateCanvasRequestSchema, + CreateCanvasResultSchema, } from "@sketchi/diagram-agent"; import type { Schema } from "effect"; @@ -33,6 +35,10 @@ export const CodeModeHttpSchemas = { input: toPlaygroundStandardSchema(BuildSequenceDiagramRequestSchema), output: toPlaygroundStandardSchema(BuildSequenceDiagramResultSchema), }, + createCanvas: { + input: toPlaygroundStandardSchema(CreateCanvasRequestSchema), + output: toPlaygroundStandardSchema(CreateCanvasResultSchema), + }, getArtifact: { input: toPlaygroundStandardSchema(GetArtifactRequestSchema), output: toPlaygroundStandardSchema(GetArtifactResultSchema), diff --git a/apps/playground/src/server/codemode/http-status-compatibility-corpus.test.ts b/apps/playground/src/server/codemode/http-status-compatibility-corpus.test.ts index 72dcce5e..991fcba6 100644 --- a/apps/playground/src/server/codemode/http-status-compatibility-corpus.test.ts +++ b/apps/playground/src/server/codemode/http-status-compatibility-corpus.test.ts @@ -34,8 +34,7 @@ const runtimeResults = vi.hoisted(() => { }); vi.mock("./service.server", async (importOriginal) => { - const actual = - await importOriginal(); + const actual = await importOriginal(); return { ...actual, PlaygroundCodeModeLive: Layer.succeed(actual.PlaygroundCodeMode, { @@ -51,6 +50,8 @@ vi.mock("./service.server", async (importOriginal) => { Effect.succeed(runtimeResults.getBuildMindmap() as BuildMindmapResult), buildSequenceDiagram: () => Effect.die("Sequence diagrams are outside the frozen v1 corpus."), + createCanvas: () => + Effect.die("Canvas creation is outside the frozen v1 corpus."), getArtifact: () => Effect.succeed(runtimeResults.getGetArtifact() as GetArtifactResult), readStoredArtifact: () => Effect.succeed(null), diff --git a/apps/playground/src/server/codemode/mcp-docs.server.test.ts b/apps/playground/src/server/codemode/mcp-docs.server.test.ts index 8d739447..e17413b5 100644 --- a/apps/playground/src/server/codemode/mcp-docs.server.test.ts +++ b/apps/playground/src/server/codemode/mcp-docs.server.test.ts @@ -37,7 +37,9 @@ describe("Code Mode MCP docs", () => { }); it("emits a complete semantic-builder public type contract", () => { - expect(SKETCHI_CODE_MODE_VERSION).toBe("2026-07-23"); + expect(SKETCHI_CODE_MODE_VERSION).toBe("2026-09-04"); + expect(SKETCHI_CODE_MODE_TYPES).toContain("interface CanvasSpec"); + expect(SKETCHI_CODE_MODE_TYPES).toContain("type CreateCanvasResult"); expect(SKETCHI_CODE_MODE_TYPES).toContain("interface BuildMindmapRequest"); expect(SKETCHI_CODE_MODE_TYPES).toContain("type BuildMindmapResult"); expect(SKETCHI_CODE_MODE_TYPES).toContain( @@ -47,7 +49,7 @@ describe("Code Mode MCP docs", () => { "type BuildSequenceDiagramResult", ); expect(SKETCHI_CODE_MODE_TYPES).toContain( - 'stage: "input" | "flowchart" | "mindmap"', + 'stage: "input" | "canvas" | "flowchart" | "mindmap"', ); expect(SKETCHI_CODE_MODE_TYPES).toContain("type CodeModeIssueCode"); expect(SKETCHI_CODE_MODE_TYPES).toContain("code: CodeModeIssueCode"); @@ -62,6 +64,7 @@ describe("Code Mode MCP docs", () => { "buildFlowchart", "buildMindmap", "buildSequenceDiagram", + "createCanvas", "getArtifact", "applyDiagramPatch", ]) { @@ -86,6 +89,9 @@ describe("Code Mode MCP docs", () => { expect(sequenceDocs.examples[0]?.code).toContain( "sketchi.buildSequenceDiagram", ); + const canvasDocs = getCodeModeDocs({ topic: "createCanvas" }); + expect(canvasDocs.content).toContain("never raw Excalidraw JSON"); + expect(canvasDocs.examples[0]?.code).toContain("sketchi.createCanvas"); }); it("keeps the published catalog complete for bounded build failures", () => { @@ -95,6 +101,8 @@ describe("Code Mode MCP docs", () => { "type BuildMindmapResult", "interface BuildSequenceDiagramRequest", "buildSequenceDiagram", + "createCanvas", + "CreateCanvasRequest", '"request_too_large"', '"nonterminating_node"', '"flowchart_too_large"', @@ -103,8 +111,8 @@ describe("Code Mode MCP docs", () => { ]) { expect(catalog).toContain(declaration); } - expect(catalog).toContain("flowchart, mindmap, or sequence artifact"); - expect(catalog).toContain("matching `buildFlowchart`,"); + expect(catalog).toContain("arbitrary typed canvas artifact"); + expect(catalog).toContain("CanvasSpec v1"); expect(catalog).toContain("24 nodes"); expect(catalog).toContain("64 edges"); expect(catalog).toContain("256 KiB"); diff --git a/apps/playground/src/server/codemode/mcp-docs.server.ts b/apps/playground/src/server/codemode/mcp-docs.server.ts index 1705db1a..eb2e7fbe 100644 --- a/apps/playground/src/server/codemode/mcp-docs.server.ts +++ b/apps/playground/src/server/codemode/mcp-docs.server.ts @@ -19,6 +19,7 @@ const CodeModeDocsTopicContract = Schema.Literals([ "buildFlowchart", "buildMindmap", "buildSequenceDiagram", + "createCanvas", "getArtifact", "applyDiagramPatch", "patchOperations", @@ -117,7 +118,7 @@ interface CatalogEntry { examples?: CodeExample[]; } -export const SKETCHI_CODE_MODE_VERSION = "2026-07-23"; +export const SKETCHI_CODE_MODE_VERSION = "2026-09-04"; const CODE_MODE_ISSUE_CODE_TYPE = CodeModeIssueCodeSchema.options .map((code, index) => `${index === 0 ? " " : " | "}"${code}"`) @@ -126,14 +127,17 @@ const CODE_MODE_ISSUE_CODE_TYPE = CodeModeIssueCodeSchema.options const PATCH_OPERATION_SUMMARY = [ "- setDefaultStyle: set fallback strokeColor, fillColor, textColor, or backgroundColor for the scene.", "- setStyle: style selected nodes, edges, labels, or scopes.", - "- setShape: change selected node shapes to rectangle, diamond, ellipse, or circle.", + "- setShape: change selected node shapes to rectangle, diamond, ellipse, circle, or polygon.", "- translate: move selected nodes/edges/text by dx and dy; connectivity is preserved by default.", "- replaceText: replace selected node labels, edge labels, or text elements. Use this for label edits.", "- rerouteEdges: reroute selected edges after movement or shape changes.", + "- insert/remove/replace: structurally edit elements while retaining stable ids.", + "- reorder: move existing ids in the explicit back-to-front zOrder.", + "- group/ungroup: add or remove stable composition group ids.", ].join("\n"); const PATCH_REQUEST_SHAPE = `interface ApplyDiagramPatchRequest { - source: { artifactId: string; format?: "scene" } | { scene: RenderedDiagramScene }; + source: { artifactId: string; format?: "scene" } | { scene: CanvasSpec }; operations: DiagramPatchOperation[]; intent?: string; options?: { inlineArtifacts?: ["scene", "excalidraw"]; artifactFormats?: ["scene", "excalidraw", "png"]; preserveConnectivity?: boolean }; @@ -142,10 +146,16 @@ const PATCH_REQUEST_SHAPE = `interface ApplyDiagramPatchRequest { type DiagramPatchOperation = | { op: "setDefaultStyle"; style: DiagramStylePatch } | { op: "setStyle"; selector: DiagramSelector; style: DiagramStylePatch } - | { op: "setShape"; selector: DiagramSelector; shape: "rectangle" | "diamond" | "ellipse" | "circle" } + | { op: "setShape"; selector: DiagramSelector; shape: DiagramShape } | { op: "translate"; selector: DiagramSelector; dx: number; dy: number } | { op: "replaceText"; selector: DiagramSelector; text: string } - | { op: "rerouteEdges"; selector?: DiagramSelector }; + | { op: "rerouteEdges"; selector?: DiagramSelector } + | { op: "insert"; elements: CanvasElement[]; beforeId?: string; afterId?: string } + | { op: "remove"; selector: DiagramSelector } + | { op: "replace"; id: string; element: CanvasElement } + | { op: "reorder"; ids: string[]; beforeId?: string; afterId?: string } + | { op: "group"; ids: string[]; groupId: string } + | { op: "ungroup"; ids: string[]; groupId?: string }; // Primary public path: { @@ -161,7 +171,13 @@ const PATCH_OPERATIONS_EXAMPLE = `[ { op: "setShape", selector: { nodeIds: ["gate"] }, shape: "diamond" }, { op: "translate", selector: { nodeIds: ["gate"] }, dx: 24, dy: -12 }, { op: "replaceText", selector: { nodeIds: ["ship"] }, text: "Ship to production" }, - { op: "rerouteEdges", selector: { scope: "edges" } } + { op: "rerouteEdges", selector: { scope: "edges" } }, + { op: "insert", afterId: "gate", elements: [{ type: "text", id: "note", text: "Review", x: 10, y: 10, fontSize: 18 }] }, + { op: "replace", id: "note", element: { type: "text", id: "note", text: "Approved", x: 10, y: 10, fontSize: 18 } }, + { op: "reorder", ids: ["note"], beforeId: "gate" }, + { op: "group", ids: ["gate", "note"], groupId: "approval-group" }, + { op: "ungroup", ids: ["note"], groupId: "approval-group" }, + { op: "remove", selector: { ids: ["note"] } } ]`; const FULL_PATCH_REQUEST_EXAMPLE = `{ @@ -185,6 +201,7 @@ export const SKETCHI_CODE_MODE_TYPES = `declare const sketchi: { buildFlowchart(input: BuildFlowchartRequest): Promise; buildMindmap(input: BuildMindmapRequest): Promise; buildSequenceDiagram(input: BuildSequenceDiagramRequest): Promise; + createCanvas(input: CreateCanvasRequest): Promise; getArtifact(input: GetArtifactRequest): Promise; applyDiagramPatch(input: ApplyDiagramPatchRequest): Promise; }; @@ -192,7 +209,25 @@ export const SKETCHI_CODE_MODE_TYPES = `declare const sketchi: { type FlowchartNodeKind = "start" | "process" | "decision" | "end"; type ArtifactFormat = "scene" | "excalidraw" | "png"; type InlineArtifactFormat = "scene" | "excalidraw"; -type DiagramShape = "rectangle" | "diamond" | "ellipse" | "circle"; +type DiagramShape = "rectangle" | "diamond" | "ellipse" | "circle" | "polygon"; +type CanvasPoint = { x: number; y: number }; +type CanvasComposition = { frameId?: string; groupIds?: string[]; layerId?: string; locked?: boolean; opacity?: number; zIndex?: number }; +type CanvasStyle = { strokeColor?: string; fillColor?: string; textColor?: string; strokeStyle?: "solid" | "dashed" | "dotted"; strokeWidth?: 1 | 2 | 4; fillStyle?: "hachure" | "cross-hatch" | "solid"; roughness?: 0 | 1 | 2 }; +type CanvasElement = + | (CanvasComposition & CanvasStyle & { type: "node"; id: string; nodeId: string; shape: DiagramShape; x: number; y: number; width: number; height: number; label: string; points?: CanvasPoint[] }) + | (CanvasComposition & { type: "text"; id: string; text: string; x: number; y: number; fontSize: number; containerId?: string; textColor?: string; fontFamily?: "hand" | "mono" | "sans"; maxWidth?: number; textAlign?: "left" | "center" | "right"; verticalAlign?: "top" | "middle" | "bottom" }) + | (CanvasComposition & CanvasStyle & { type: "arrow"; id: string; edgeId: string; sourceNodeId: string; targetNodeId: string; points: CanvasPoint[]; label?: string; startArrowhead?: CanvasArrowhead; endArrowhead?: CanvasArrowhead }) + | (CanvasComposition & CanvasStyle & { type: "line"; id: string; points: CanvasPoint[]; label?: string; startBinding?: CanvasBinding; endBinding?: CanvasBinding; startArrowhead?: CanvasArrowhead; endArrowhead?: CanvasArrowhead }) + | (CanvasComposition & CanvasStyle & { type: "frame"; id: string; name?: string; x: number; y: number; width: number; height: number }); +type CanvasArrowhead = "arrow" | "bar" | "circle" | "diamond" | "triangle" | null; +type CanvasBinding = { elementId: string; focus?: number; gap?: number }; +type CanvasLayout = + | { type: "row" | "column" | "stack"; ids: string[]; x?: number; y?: number; gap?: number } + | { type: "grid"; ids: string[]; columns: number; x?: number; y?: number; columnGap?: number; rowGap?: number } + | { type: "align"; ids: string[]; axis: "x" | "y"; alignment: "start" | "center" | "end" } + | { type: "distribute"; ids: string[]; axis: "x" | "y"; gap?: number }; +interface CanvasSpec { kind: "canvas"; version: 1; diagramId: string; title: string; width: number; height: number; accentColor: string; backgroundColor: string; elements: CanvasElement[]; layers?: Array<{ id: string; name?: string; locked?: boolean; visible?: boolean }>; layouts?: CanvasLayout[]; zOrder?: string[]; } +interface CreateCanvasRequest { requestId?: string; spec: CanvasSpec; options?: { artifactFormats?: ArtifactFormat[]; inlineArtifacts?: InlineArtifactFormat[] } } interface MindmapTopic { label: string; children?: MindmapTopic[]; } interface MindmapSpec { @@ -291,7 +326,13 @@ type DiagramPatchOperation = | { op: "setShape"; selector: DiagramSelector; shape: DiagramShape } | { op: "translate"; selector: DiagramSelector; dx: number; dy: number } | { op: "replaceText"; selector: DiagramSelector; text: string } - | { op: "rerouteEdges"; selector?: DiagramSelector }; + | { op: "rerouteEdges"; selector?: DiagramSelector } + | { op: "insert"; elements: CanvasElement[]; beforeId?: string; afterId?: string } + | { op: "remove"; selector: DiagramSelector } + | { op: "replace"; id: string; element: CanvasElement } + | { op: "reorder"; ids: string[]; beforeId?: string; afterId?: string } + | { op: "group"; ids: string[]; groupId: string } + | { op: "ungroup"; ids: string[]; groupId?: string }; interface DiagramStylePatch { strokeColor?: string; @@ -322,8 +363,8 @@ ${CODE_MODE_ISSUE_CODE_TYPE}; interface CodeModeIssue { code: CodeModeIssueCode; severity: "error" | "warning"; - stage: "input" | "flowchart" | "mindmap" | "quality" | "render" | "export" | "storage"; - ref?: { kind: "request" | "diagram" | "node" | "edge" | "artifact"; id?: string; path?: string }; + stage: "input" | "canvas" | "flowchart" | "mindmap" | "quality" | "render" | "export" | "storage"; + ref?: { kind: "request" | "diagram" | "element" | "layer" | "node" | "edge" | "artifact"; id?: string; path?: string }; message: string; hint: string; } @@ -340,6 +381,10 @@ type BuildSequenceDiagramResult = | { ok: true; status: "accepted"; buildId: string; normalizedSpec: unknown; quality: unknown; artifact: ArtifactBundle; issues: CodeModeIssue[] } | { ok: false; status: string; issues: CodeModeIssue[]; normalizedSpec?: unknown; quality?: unknown; partial?: unknown }; +type CreateCanvasResult = + | { ok: true; status: "accepted"; buildId: string; normalizedSpec: CanvasSpec; artifact: ArtifactBundle; issues: CodeModeIssue[] } + | { ok: false; status: string; issues: CodeModeIssue[]; normalizedSpec?: CanvasSpec; partial?: unknown }; + type GetArtifactResult = | { ok: true; artifactId: string; diagramId: string; format: ArtifactFormat; mimeType: string; inline?: unknown; sizeBytes?: number; url?: string; expiresAt?: string; provenance?: ArtifactProvenance } | { ok: false; status: string; issues: CodeModeIssue[] }; @@ -440,6 +485,24 @@ const SEQUENCE_DIAGRAM_EXAMPLE = `async () => sketchi.buildSequenceDiagram({ options: { artifactFormats: ["scene", "excalidraw", "png"], inlineArtifacts: ["excalidraw"] } })`; +const CREATE_CANVAS_EXAMPLE = `async () => sketchi.createCanvas({ + spec: { + kind: "canvas", version: 1, diagramId: "erd", title: "Commerce ERD", + width: 960, height: 560, accentColor: "#1f2937", backgroundColor: "#ffffff", + layers: [{ id: "entities", name: "Entities" }, { id: "relations", name: "Relations" }], + elements: [ + { type: "node", id: "users", nodeId: "users", shape: "rectangle", x: 80, y: 80, width: 220, height: 160, label: "users\\nid PK\\nemail UNIQUE", layerId: "entities", fillColor: "#dbeafe" }, + { type: "node", id: "orders", nodeId: "orders", shape: "rectangle", x: 380, y: 80, width: 220, height: 160, label: "orders\\nid PK\\nuser_id FK", layerId: "entities", fillColor: "#dcfce7" }, + { type: "node", id: "items", nodeId: "items", shape: "rectangle", x: 680, y: 80, width: 220, height: 160, label: "order_items\\norder_id FK\\nsku", layerId: "entities", fillColor: "#fef3c7" }, + { type: "arrow", id: "users-orders", edgeId: "users-orders", sourceNodeId: "users", targetNodeId: "orders", points: [{x:300,y:160},{x:380,y:160}], label: "1:N", layerId: "relations", startArrowhead: "bar", endArrowhead: "arrow" }, + { type: "arrow", id: "orders-items", edgeId: "orders-items", sourceNodeId: "orders", targetNodeId: "items", points: [{x:600,y:160},{x:680,y:160}], label: "1:N", layerId: "relations", startArrowhead: "bar", endArrowhead: "arrow" } + ], + layouts: [{ type: "row", ids: ["users", "orders", "items"], x: 80, y: 80, gap: 80 }], + zOrder: ["users", "orders", "items", "users-orders", "orders-items"] + }, + options: { artifactFormats: ["scene", "excalidraw", "png"], inlineArtifacts: ["excalidraw"] } +})`; + const CIRCLE_TO_DIAMOND_EXAMPLE = `async () => { const built = await sketchi.buildFlowchart({ spec: { @@ -564,7 +627,7 @@ const catalog: CatalogEntry[] = [ content: [ "Sketchi Code Mode MCP is for external agent harnesses: Codex, Claude Code, OpenCode, and similar clients.", "The server exposes a small contract: docs, search, and execute. execute runs JavaScript against a typed sketchi client.", - "The public sketchi client has five operations: buildFlowchart, buildMindmap, buildSequenceDiagram, getArtifact, and applyDiagramPatch.", + "The public sketchi client has six operations: buildFlowchart, buildMindmap, buildSequenceDiagram, createCanvas, getArtifact, and applyDiagramPatch.", "The final deliverable is the accepted Sketchi artifact bundle: return the artifactId, format list, and Excalidraw/PNG artifact URLs instead of creating a separate Markdown, Mermaid, or prose-only diagram artifact.", "Use docs({ topic }) for full request envelopes and examples. Use search({ query }) to discover operation-specific topics such as patchOperations.", "Studio chat, HTTP, and MCP share the canonical semantic builder request/result contracts. Convex threads and user artifact lineage remain outside this harness surface.", @@ -577,15 +640,15 @@ const catalog: CatalogEntry[] = [ topic: "execute", keywords: ["execute", "code", "javascript", "typescript", "sandbox"], snippet: - "Run an async JavaScript arrow function with sketchi.buildFlowchart, sketchi.buildMindmap, sketchi.buildSequenceDiagram, sketchi.getArtifact, and sketchi.applyDiagramPatch.", + "Run an async JavaScript arrow function with the typed sketchi builders, createCanvas, artifact retrieval, and patching operations.", content: [ "execute({ code }) runs an async JavaScript arrow function.", "This matches the Code Mode pattern: typed host tools are exposed as a namespace inside the sandbox, here sketchi.*.", - "Cloudflare Code Mode exposes typed namespace methods in generated code; this server follows that shape with sketchi.buildFlowchart, sketchi.buildMindmap, sketchi.buildSequenceDiagram, sketchi.getArtifact, and sketchi.applyDiagramPatch.", + "Cloudflare Code Mode exposes typed namespace methods in generated code; this server follows that shape with sketchi.buildFlowchart, sketchi.buildMindmap, sketchi.buildSequenceDiagram, sketchi.createCanvas, sketchi.getArtifact, and sketchi.applyDiagramPatch.", "Pass the function expression itself. A trailing semicolon and outer markdown code fence are accepted, but examples omit them so copied code is canonical.", "Write JavaScript only: no TypeScript annotations, interfaces, generics, imports, or named wrapper functions. Use the canonical shape async () => { const result = await sketchi.buildFlowchart(...); return result; }.", "Do not define a named function and then call it. Put the arrow function body directly in code.", - "Inside code, call sketchi.buildFlowchart(input) for process graphs, sketchi.buildMindmap(input) for topic hierarchies, or sketchi.buildSequenceDiagram(input) for chronological participant interactions, then sketchi.getArtifact(input) or sketchi.applyDiagramPatch(input) as needed.", + "Inside code, use a semantic builder for its supported diagram family, or sketchi.createCanvas(input) for arbitrary typed scenes such as ERDs, architecture maps, timelines, charts, dashboards, and wireframes; then retrieve or patch the accepted artifact as needed.", "The sandbox must not receive secrets, storage bindings, model credentials, or raw network access.", "Call sketchi methods sequentially when possible so a harness can inspect structured failures and retry deliberately.", "For user-facing completion, return the accepted Sketchi artifactId plus Excalidraw and PNG URLs from the MCP result. Do not synthesize a Mermaid or Markdown replacement after Sketchi accepts an artifact.", @@ -696,6 +759,45 @@ const catalog: CatalogEntry[] = [ }, ], }, + { + id: "createCanvas", + kind: "operation", + title: "createCanvas", + topic: "createCanvas", + keywords: [ + "canvas", + "arbitrary", + "erd", + "architecture", + "timeline", + "dashboard", + "chart", + "wireframe", + "grid", + "layers", + "groups", + "polygon", + ], + snippet: + "Create an arbitrary visualization from the versioned, renderer-independent CanvasSpec IR.", + content: [ + "createCanvas is the one general-purpose host function for agent-authored diagrams and visualizations. It accepts CanvasSpec v1, never raw Excalidraw JSON.", + "CanvasSpec supports bound and standalone text, rectangle/ellipse/diamond/circle/polygon shapes, lines and bound connectors with arrowheads, frames, groups, layers, explicit back-to-front zOrder, and deterministic row/column/grid/stack/align/distribute layouts.", + "Every element needs a unique stable id. Connectors bind through shape nodeId values; line bindings and text containers use element ids.", + "Generic validation enforces size, reference, geometry, and composition invariants. Intentional overlap is allowed.", + "CanvasSpec does not accept SVG, HTML, scripts, data URLs, external URLs, or executable payloads. Use only the typed primitives in the contract.", + "Limits: 600 elements, 64 layers, 128 layout primitives, 256 points per element, 16 groups per element, 4096 characters per text element, 16384 canvas units per dimension, and 1.5 MB serialized input.", + "Use applyDiagramPatch structural operations for iterative edits without regenerating the whole scene.", + "The repository examples include executable ERD, architecture, timeline, dashboard/chart, wireframe, and dense 120-element cases.", + ].join("\n"), + examples: [ + { + title: "Commerce ERD with layers, bindings, layout, and z-order", + language: "js", + code: CREATE_CANVAS_EXAMPLE, + }, + ], + }, { id: "getArtifact", kind: "operation", @@ -722,7 +824,7 @@ const catalog: CatalogEntry[] = [ "Hosted MCP/API responses include url fields for raw artifact downloads. Excalidraw URLs return importable JSON; PNG URLs return image bytes.", "Pass inline: true only when the harness needs scene or Excalidraw JSON in the MCP response.", "To fetch raw artifact bytes, request GET /api/v1/artifacts/{artifactId}?format=excalidraw&raw=true or format=png&raw=true from the Studio API.", - "Use the artifactId returned by buildFlowchart, buildMindmap, buildSequenceDiagram, or applyDiagramPatch.", + "Use the artifactId returned by buildFlowchart, buildMindmap, buildSequenceDiagram, createCanvas, or applyDiagramPatch.", ].join("\n"), }, { @@ -741,7 +843,7 @@ const catalog: CatalogEntry[] = [ "reroute", ], snippet: - "Apply deterministic non-structural visual changes to an accepted artifact.", + "Apply deterministic visual or structural changes to an accepted artifact.", content: [ "applyDiagramPatch modifies styling, shape, text, layout translation, and edge routes.", "Request envelope:", @@ -749,7 +851,7 @@ const catalog: CatalogEntry[] = [ "Selectors can target nodeIds, edgeIds, labels, element ids, kinds, or broad scopes.", `Supported operation names: ${DIAGRAM_PATCH_OPERATION_NAMES.join(", ")}.`, PATCH_OPERATION_SUMMARY, - "Patch operations do not create or delete structure. Re-run the matching semantic builder for process, hierarchy, or sequence structure changes.", + "For a CanvasSpec, insert, remove, replace, reorder, group, and ungroup support iterative structural editing. Stable ids are mandatory; connectivity is preserved unless options.preserveConnectivity is false.", "For color changes, use 6-digit hex strings such as #7c3aed.", "For hosted visual proof after a patch, include png in artifactFormats and fetch the raw Studio API artifact bytes.", "If export returns arrow_overlap, first rebuild the FlowchartSpec into a cleaner DAG. rerouteEdges preserves connectivity, but it cannot reliably fix a graph with a long upward return edge.", @@ -798,7 +900,7 @@ const catalog: CatalogEntry[] = [ "Use replaceText for label edits. Do not use setText, setLabel, rename, relabel, text, updateLabel, or setNodeLabel.", "Op-specific shapes:", PATCH_REQUEST_SHAPE, - "Every operation except setDefaultStyle needs a selector unless noted otherwise. A selector can use nodeIds, edgeIds, ids, labels, kinds, or scope.", + "Selection-based operations use nodeIds, edgeIds, ids, labels, kinds, or scope. Structural insert/replace/reorder/group operations use stable element ids directly.", "For style patches, node and edge colors use strokeColor, fillColor, textColor, and backgroundColor. FlowchartSpec top-level style uses accentColor and backgroundColor.", "If a shape change causes arrow_overlap or text_overflow during export, retry with rerouteEdges, translate, or rebuild the FlowchartSpec with more space.", "For complex flowcharts, the most reliable repair is usually structural: keep edges flowing in the declared layout direction and avoid connecting a bottom node back to an early terminal.", @@ -826,7 +928,7 @@ const catalog: CatalogEntry[] = [ "First get the semantic graph accepted, then apply visual patches.", content: [ "For mixed requests like 'circle connected to a purple decision diamond', split the task.", - "Step 1: choose buildFlowchart for a process graph, buildMindmap for a nested topic hierarchy, or buildSequenceDiagram for chronological participant interactions.", + "Step 1: choose buildFlowchart for a process graph, buildMindmap for a nested topic hierarchy, buildSequenceDiagram for chronological participant interactions, or createCanvas for any other diagram or visualization.", "Step 2: inspect issues. If not accepted, repair the spec and call the same build operation again.", "Step 3: once accepted, use applyDiagramPatch for circle, diamond, color, movement, rerouteEdges, or replaceText tweaks.", "For broad architecture prompts, keep the first build small and readable: one start, a mostly monotonic spine, a few decision or branch points, and separate terminal nodes for separate outcomes.", @@ -889,7 +991,7 @@ const catalog: CatalogEntry[] = [ kind: "example", title: "Executable examples", topic: "examples", - keywords: ["examples", "sample", "purple", "diamond", "approval"], + keywords: ["examples", "sample", "canvas", "erd", "dashboard", "wireframe"], snippet: "Runnable examples for accepted graph, patch, artifact retrieval, and repair feedback.", content: @@ -920,6 +1022,11 @@ const catalog: CatalogEntry[] = [ language: "js", code: SEQUENCE_DIAGRAM_EXAMPLE, }, + { + title: "Commerce ERD through createCanvas", + language: "js", + code: CREATE_CANVAS_EXAMPLE, + }, ], }, { @@ -931,7 +1038,7 @@ const catalog: CatalogEntry[] = [ snippet: "When Sketchi accepts an artifact, the final result is that artifact bundle, not a recreated Markdown diagram.", content: - "Do not create a Markdown, Mermaid, local file, Antigravity artifact, or prose-only artifact after buildFlowchart, buildMindmap, buildSequenceDiagram, or applyDiagramPatch succeeds. Do not call getArtifact for scene just to make a local summary. Paste execute.artifactDelivery.finalResponseText when present, or return the Sketchi artifactId, available formats, and raw Excalidraw/PNG URLs so the caller can open the actual generated artifact.", + "Do not create a Markdown, Mermaid, local file, Antigravity artifact, or prose-only artifact after buildFlowchart, buildMindmap, buildSequenceDiagram, createCanvas, or applyDiagramPatch succeeds. Do not call getArtifact for scene just to make a local summary. Paste execute.artifactDelivery.finalResponseText when present, or return the Sketchi artifactId, available formats, and raw Excalidraw/PNG URLs so the caller can open the actual generated artifact.", }, { id: "raw-excalidraw-non-goal", @@ -942,7 +1049,7 @@ const catalog: CatalogEntry[] = [ snippet: "Native Excalidraw is an output format. Prefer semantic FlowchartSpec, MindmapSpec, or SequenceDiagramSpec input.", content: - "Do not use coordinates, scene data, or native Excalidraw JSON as build input. Use FlowchartSpec, nested MindmapSpec, or SequenceDiagramSpec with its matching builder; scene and Excalidraw remain intentional output formats, and applyDiagramPatch handles deterministic visual changes.", + "Do not use native Excalidraw JSON as build input. Use FlowchartSpec, nested MindmapSpec, SequenceDiagramSpec, or the renderer-independent CanvasSpec v1; scene and Excalidraw remain intentional output formats, and applyDiagramPatch handles deterministic edits.", }, { id: "managed-thread-non-goal", diff --git a/apps/playground/src/server/codemode/mcp.server.test.ts b/apps/playground/src/server/codemode/mcp.server.test.ts index cd272022..71c9484d 100644 --- a/apps/playground/src/server/codemode/mcp.server.test.ts +++ b/apps/playground/src/server/codemode/mcp.server.test.ts @@ -174,6 +174,16 @@ const BUILD_SEQUENCE_CODE = `async () => sketchi.buildSequenceDiagram({ } })`; +const CREATE_CANVAS_CODE = `async () => sketchi.createCanvas({ + spec: { + kind: "canvas", version: 1, diagramId: "mcp-canvas", title: "MCP canvas", + width: 480, height: 320, accentColor: "#111827", backgroundColor: "#ffffff", + elements: [{ type: "node", id: "card", nodeId: "card", shape: "rectangle", x: 40, y: 40, width: 220, height: 100, label: "Canvas" }], + layers: [], layouts: [], zOrder: ["card"] + }, + options: { artifactFormats: ["scene", "excalidraw"], inlineArtifacts: ["scene"] } +})`; + const ACCEPTED_ARTIFACT_WITHOUT_URLS_CODE = `async () => ({ ok: true, status: "accepted", @@ -509,6 +519,21 @@ describe("Sketchi Code Mode MCP server", () => { }, }); }); + it("exposes createCanvas inside the Code Mode namespace", async () => { + const result = await executeSketchiCodeMode( + {}, + { code: CREATE_CANVAS_CODE }, + { executor: createInProcessExecutor() }, + ); + expect(result).toMatchObject({ + ok: true, + result: { + ok: true, + status: "accepted", + normalizedSpec: { kind: "canvas", version: 1, diagramId: "mcp-canvas" }, + }, + }); + }); it("normalizes common LLM execute input wrappers", () => { expect(normalizeSketchiExecuteCode("async () => { return 1; };")).toBe( "async () => { return 1; }", @@ -692,6 +717,7 @@ describe("Sketchi Code Mode MCP server", () => { "buildFlowchart", "buildMindmap", "buildSequenceDiagram", + "createCanvas", "getArtifact", "applyDiagramPatch", "patchOperations", diff --git a/apps/playground/src/server/codemode/mcp.server.ts b/apps/playground/src/server/codemode/mcp.server.ts index 1d74429e..0fdade5e 100644 --- a/apps/playground/src/server/codemode/mcp.server.ts +++ b/apps/playground/src/server/codemode/mcp.server.ts @@ -497,6 +497,10 @@ export function makeSketchiCodeModeProvider( correlation, ), ), + createCanvas: (request) => + runToolEffect( + withTelemetryCorrelation(codeMode.createCanvas(request), correlation), + ), getArtifact: (request) => runToolEffect( withTelemetryCorrelation(codeMode.getArtifact(request), correlation), @@ -557,7 +561,7 @@ export function createSketchiMcpServer( "Write JavaScript only: no TypeScript syntax, annotations, interfaces, generics, imports, or named wrapper functions.", "Use the canonical shape: async () => { const result = await sketchi.buildFlowchart(...); return result; }", "Code fences and trailing expression semicolons are normalized before execution.", - "The sandbox exposes sketchi.buildFlowchart, sketchi.buildMindmap, sketchi.buildSequenceDiagram, sketchi.getArtifact, and sketchi.applyDiagramPatch.", + "The sandbox exposes sketchi.createCanvas, sketchi.buildFlowchart, sketchi.buildMindmap, sketchi.buildSequenceDiagram, sketchi.getArtifact, and sketchi.applyDiagramPatch.", "First get the semantic graph accepted, then use patch operations for deterministic visual changes.", "For final user-facing output, return accepted Sketchi artifact ids, format refs, and Excalidraw/PNG URLs. Do not recreate accepted diagrams as Markdown or Mermaid artifacts.", "When artifactDelivery is available, the first text content block is the final user-facing answer; copy it verbatim and stop.", diff --git a/apps/playground/src/server/codemode/service.server.ts b/apps/playground/src/server/codemode/service.server.ts index 6a0734e6..60a11808 100644 --- a/apps/playground/src/server/codemode/service.server.ts +++ b/apps/playground/src/server/codemode/service.server.ts @@ -8,6 +8,7 @@ import { CodeModeArtifactStorage, CodeModeRuntimeEnvironment, getArtifact, + createCanvas, makeMemoryArtifactStorage, makeObjectBucketArtifactStorage, type ApplyDiagramPatchResult, @@ -18,6 +19,7 @@ import { type CodeModeArtifactStorageError, type CodeModeArtifactStorageShape, type CodeModeRuntimeOptions, + type CreateCanvasResult, type GetArtifactResult, type StoredArtifactFormat, } from "@sketchi/diagram-agent"; @@ -53,6 +55,9 @@ export interface PlaygroundCodeModeShape { readonly buildSequenceDiagram: ( input: unknown, ) => Effect.Effect; + readonly createCanvas: ( + input: unknown, + ) => Effect.Effect; readonly getArtifact: ( input: unknown, ) => Effect.Effect; @@ -175,6 +180,9 @@ export const PlaygroundCodeModeLive = Layer.effect( buildSequenceDiagram: Effect.fn( "playground.codeMode.buildSequenceDiagram", )((input) => provideRequestCodeMode(buildSequenceDiagram(input))), + createCanvas: Effect.fn("playground.codeMode.createCanvas")((input) => + provideRequestCodeMode(createCanvas(input)), + ), getArtifact: Effect.fn("playground.codeMode.getArtifact")((input) => provideRequestCodeMode(getArtifact(input)), ), diff --git a/apps/playground/src/server/codemode/usage-events.server.ts b/apps/playground/src/server/codemode/usage-events.server.ts index 9ee55dac..559ca74a 100644 --- a/apps/playground/src/server/codemode/usage-events.server.ts +++ b/apps/playground/src/server/codemode/usage-events.server.ts @@ -39,6 +39,7 @@ export type CodeModeUsageOperation = | "buildFlowchart" | "buildMindmap" | "buildSequenceDiagram" + | "createCanvas" | "execute" | "generateDiagram"; diff --git a/apps/playground/src/server/runtime/runtime.server.ts b/apps/playground/src/server/runtime/runtime.server.ts index 88d3de07..84627e72 100644 --- a/apps/playground/src/server/runtime/runtime.server.ts +++ b/apps/playground/src/server/runtime/runtime.server.ts @@ -218,6 +218,7 @@ function normalizedRequestRoute(pathname: string): string { "/api/studio/projects", "/api/studio/projects/from-artifact", "/api/v1/flowcharts/build", + "/api/v1/canvases/create", "/api/v1/generate", "/api/v1/mindmaps/build", "/api/v1/sequences/build", diff --git a/docs/canvas-spec.md b/docs/canvas-spec.md new file mode 100644 index 00000000..765d0de7 --- /dev/null +++ b/docs/canvas-spec.md @@ -0,0 +1,53 @@ +# CanvasSpec v1 + +`CanvasSpec` is Sketchi's canonical, renderer-independent scene IR. All +rendered flowchart, mindmap, sequence, and agent-authored canvas scenes compile +to this representation before Excalidraw, PNG, or scene persistence. + +Agents call `sketchi.createCanvas({ spec, options })` through the existing Code +Mode `execute` surface. There is no diagram-specific MCP tool and raw +Excalidraw JSON is never accepted as input. + +## Capabilities + +- Shapes: rectangle, ellipse, diamond, circle, and polygon. +- Standalone or bound text with font, alignment, color, and width controls. +- Lines, polylines, and bound connectors with independently selected endpoint + arrowheads. +- Frames, nested group identifiers, layers, locking, opacity, and explicit + back-to-front `zOrder`. +- Deterministic row, column, grid, stack, align, and distribute layout + primitives. +- Structural patches: insert, remove, replace, reorder, group, and ungroup. + Element ids remain stable across replacement and reordering. + +Layout primitives execute in request order. Connector endpoints and bound text +are synchronized after layout, and the persisted scene contains the resolved +geometry plus the original layout intent. + +A node's required `label` renders automatically when the scene has no explicit +text element bound to that node. Add a bound text element when the label needs +custom font, alignment, color, or width settings; it replaces the automatic +label rather than duplicating it. +The same override rule applies when text is explicitly bound to a connector +that also has a `label`. + +## Safety and limits + +Validation enforces reference integrity, unique ids, positive bounds, finite +typed values, polygon/point structure, and resource limits. Overlap is not an +error because dashboards, wireframes, annotations, and layered illustrations +use overlap intentionally. + +CanvasSpec has no image URL, SVG, HTML, script, data URL, or arbitrary payload +field. It therefore cannot execute markup or fetch external resources. This v1 +slice intentionally omits long-tail vector paths and asset references rather +than introducing an unreliable or unsafe partial contract. + +Limits are 600 elements, 64 layers, 128 layouts, 256 points per element, 16 +groups per element, 4,096 characters per text element, 16,384 units per canvas +dimension, 600 z-order entries, and 1.5 MB serialized input. + +Executable Code Mode examples for ERD, architecture map, timeline, +dashboard/chart, wireframe, and a dense 120-element matrix live in +`examples/code-mode/create-canvas-matrix.mjs`. diff --git a/docs/mcp-first-generation.md b/docs/mcp-first-generation.md index 828768c1..8d0b7be6 100644 --- a/docs/mcp-first-generation.md +++ b/docs/mcp-first-generation.md @@ -69,18 +69,19 @@ flowchart TB Internal --> Scenario["run scenario"] ``` -| Capability | First public shape | Notes | -| ----------------------- | ------------------------------------ | -------------------------------------------------------------------- | -| Build a flowchart spec | `buildFlowchart` | One call folds normalize, validate, grade, render, export, and store | -| Retrieve an artifact | `getArtifact` | Uses artifact id, not diagram id alone | -| Patch an artifact | `applyDiagramPatch` | Deterministic non-structural style, shape, text, and layout codemods | -| Normalize model output | internal to `buildFlowchart` | Public callers get structured `Issue[]` | -| Validate IR | internal to `buildFlowchart` | Not a standalone external tool | -| Grade artifact quality | internal to `buildFlowchart` | Returned as part of `BuildFlowchartResult` | -| Render proof/export | internal to `buildFlowchart` | Scene, Excalidraw, and hosted PNG artifacts | -| Draft from prompt | later | Needs free-prompt generation contract | -| Revise supplied diagram | later or internal eval | Caller owns state in this phase | -| Run scenarios | CLI/eval surface, not public MCP API | Useful for regression and examples | +| Capability | First public shape | Notes | +| ------------------------- | ------------------------------------ | -------------------------------------------------------------------- | +| Build a flowchart spec | `buildFlowchart` | One call folds normalize, validate, grade, render, export, and store | +| Build an arbitrary canvas | `createCanvas` | One versioned typed scene IR for general diagrams and visualizations | +| Retrieve an artifact | `getArtifact` | Uses artifact id, not diagram id alone | +| Patch an artifact | `applyDiagramPatch` | Deterministic style, geometry, and structural canvas operations | +| Normalize model output | internal to `buildFlowchart` | Public callers get structured `Issue[]` | +| Validate IR | internal to `buildFlowchart` | Not a standalone external tool | +| Grade artifact quality | internal to `buildFlowchart` | Returned as part of `BuildFlowchartResult` | +| Render proof/export | internal to `buildFlowchart` | Scene, Excalidraw, and hosted PNG artifacts | +| Draft from prompt | later | Needs free-prompt generation contract | +| Revise supplied diagram | later or internal eval | Caller owns state in this phase | +| Run scenarios | CLI/eval surface, not public MCP API | Useful for regression and examples | Do not expose internal pipeline steps as public MCP tools just because Code Mode can call code. Code Mode is still an external API boundary; keep it product diff --git a/docs/mcp-tool-catalog.md b/docs/mcp-tool-catalog.md index 753b2be1..7b896511 100644 --- a/docs/mcp-tool-catalog.md +++ b/docs/mcp-tool-catalog.md @@ -7,9 +7,9 @@ Mode sandbox calls curated host APIs. The host APIs are normal Worker APIs backe by the shared diagram packages. The goal is not to make every internal function callable. The goal is to let -Claude Code, Codex, OpenCode, and similar harnesses build a correct flowchart -artifact through one clear contract, get structured repair feedback when they -are wrong, and retrieve the finished artifact. +Claude Code, Codex, OpenCode, and similar harnesses build a correct semantic +diagram or arbitrary typed canvas artifact through one clear contract, get +structured repair feedback when they are wrong, and retrieve the result. ## Implementation Plan @@ -23,7 +23,8 @@ in [Code Mode Next PR Plan](codemode-next-pr-plan.md). - Do not expose `validate`, `grade`, `render`, or `export` as public operations. - Use normal host APIs as the source of truth for request and response shapes. - Register curated Code Mode functions over those host APIs. -- Start with `buildFlowchart` and `getArtifact`. +- Use the family-specific semantic builders when they fit; use one typed + `createCanvas` host function for arbitrary visualizations. - Define `applyDiagramPatch` up front as the first deterministic mutation operation for styling, shape, text, and layout changes. - Agents must get the semantic flow and connectivity accepted before applying @@ -49,6 +50,7 @@ flowchart LR McpServer --> Execute Execute --> Sandbox Sandbox -->|"sketchi.buildFlowchart(...)"| HostApi + Sandbox -->|"sketchi.createCanvas(...)"| HostApi Sandbox -->|"sketchi.applyDiagramPatch(...)"| HostApi HostApi --> Packages HostApi --> Artifact @@ -160,6 +162,7 @@ interface DocsRequest { | "buildFlowchart" | "buildMindmap" | "buildSequenceDiagram" + | "createCanvas" | "getArtifact" | "applyDiagramPatch" | "patchOperations" @@ -237,6 +240,7 @@ declare const sketchi: { buildSequenceDiagram( input: BuildSequenceDiagramRequest, ): Promise; + createCanvas(input: CreateCanvasRequest): Promise; getArtifact(input: GetArtifactRequest): Promise; applyDiagramPatch( input: ApplyDiagramPatchRequest, @@ -257,15 +261,18 @@ flowchart LR Code["sandbox
sketchi.*"] Dispatcher["host dispatcher"] Build["POST /api/v1/{flowcharts,mindmaps,sequences}/build"] + Canvas["POST /api/v1/canvases/create"] Artifact["GET /api/v1/artifacts/:artifactId"] Patch["POST /api/v1/artifacts/:artifactId/patch"] Runtime["shared runtime"] Code --> Dispatcher Dispatcher --> Build + Dispatcher --> Canvas Dispatcher --> Artifact Dispatcher --> Patch Build --> Runtime + Canvas --> Runtime Artifact --> Runtime Patch --> Runtime ``` @@ -275,6 +282,7 @@ flowchart LR | `POST /api/v1/flowcharts/build` | `sketchi.buildFlowchart(input)` | Yes | | `POST /api/v1/mindmaps/build` | `sketchi.buildMindmap(input)` | Yes | | `POST /api/v1/sequences/build` | `sketchi.buildSequenceDiagram(input)` | Yes | +| `POST /api/v1/canvases/create` | `sketchi.createCanvas(input)` | Yes | | `GET /api/v1/artifacts/:artifactId` | `sketchi.getArtifact(input)` | Yes | | `POST /api/v1/artifacts/:artifactId/patch` | `sketchi.applyDiagramPatch(input)` | Yes | | validate IR | none | No, internal to build | @@ -833,7 +841,7 @@ as a manifest plus one object per format. A patched artifact manifest records `provenance.sourceArtifactId`, so every stored format resolves to the same durable source reference; root build artifacts omit provenance. Studio Worker deployments bind `SKETCHI_ARTIFACTS` to R2 so -`buildFlowchart/buildMindmap/buildSequenceDiagram -> getArtifact -> applyDiagramPatch` can cross request +`buildFlowchart/buildMindmap/buildSequenceDiagram/createCanvas -> getArtifact -> applyDiagramPatch` can cross request boundaries. | Environment | Bucket | @@ -908,7 +916,7 @@ interface GetArtifactFailure { ## `applyDiagramPatch` `applyDiagramPatch` is the codemod-style operation for deterministic visual -changes after a flowchart, mindmap, or sequence artifact has already been accepted. It should handle +changes after a flowchart, mindmap, sequence, or canvas artifact has already been accepted. It should handle common user requests such as changing colors, switching node shapes, shifting a group, replacing text, or rerouting edges without asking the agent to edit raw Excalidraw JSON. @@ -1015,6 +1023,37 @@ type DiagramPatchOperation = | { op: "rerouteEdges"; selector?: DiagramSelector; + } + | { + op: "insert"; + elements: CanvasElement[]; + beforeId?: string; + afterId?: string; + } + | { + op: "remove"; + selector: DiagramSelector; + } + | { + op: "replace"; + id: string; + element: CanvasElement; + } + | { + op: "reorder"; + ids: string[]; + beforeId?: string; + afterId?: string; + } + | { + op: "group"; + ids: string[]; + groupId: string; + } + | { + op: "ungroup"; + ids: string[]; + groupId?: string; }; interface DiagramSelector { @@ -1033,14 +1072,13 @@ interface DiagramStylePatch { backgroundColor?: HexColor; } -type DiagramShape = "rectangle" | "diamond" | "ellipse" | "circle"; +type DiagramShape = "rectangle" | "diamond" | "ellipse" | "circle" | "polygon"; ``` -The first patch operation set is deliberately non-structural. It can restyle, -reshape, move, rename, and reroute existing elements, but it cannot create or -delete nodes or edges. If a user asks to change the graph itself, the agent -should repair the semantic spec and call the matching `buildFlowchart`, -`buildMindmap`, or `buildSequenceDiagram` operation again. +Canvas artifacts additionally support insert, remove, replace, reorder, group, +and ungroup operations with stable element ids. Connectivity remains protected +by default and may be changed only when `preserveConnectivity` is explicitly +false. See [CanvasSpec v1](canvas-spec.md) for the canonical scene contract. ```ts type ApplyDiagramPatchResult = @@ -1249,6 +1287,7 @@ flowchart TB Internal["internal runtime"] Public --> Build["buildFlowchart"] + Public --> Canvas["createCanvas"] Public --> Artifact["getArtifact"] Public --> Patch["applyDiagramPatch"] @@ -1271,7 +1310,6 @@ Out of scope for this document: - OpenAPI search/execute over a large generated spec. - Direct public tools for validation, grading, rendering, or export. - Agent-facing raw Excalidraw editing as the primary mutation contract. -- Structural patch operations that add or delete nodes and edges. ## References diff --git a/examples/code-mode/create-canvas-matrix.mjs b/examples/code-mode/create-canvas-matrix.mjs new file mode 100644 index 00000000..f84dafe8 --- /dev/null +++ b/examples/code-mode/create-canvas-matrix.mjs @@ -0,0 +1,299 @@ +const options = { + artifactFormats: ["scene", "excalidraw", "png"], + inlineArtifacts: ["excalidraw"], +}; + +const canvas = (diagramId, title, elements, layouts = [], layers = []) => ({ + kind: "canvas", + version: 1, + diagramId, + title, + width: 1200, + height: 720, + accentColor: "#1f2937", + backgroundColor: "#ffffff", + elements, + layers, + layouts, + zOrder: elements.map((element) => element.id), +}); + +const box = (id, label, x, y, fillColor = "#dbeafe") => ({ + type: "node", + id, + nodeId: id, + shape: "rectangle", + x, + y, + width: 220, + height: 120, + label, + fillColor, +}); + +const arrow = (id, source, target, points, label) => ({ + type: "arrow", + id, + edgeId: id, + sourceNodeId: source, + targetNodeId: target, + points, + label, + endArrowhead: "arrow", +}); + +export const createErd = (sketchi) => { + const elements = [ + box("users", "users\nid PK\nemail UNIQUE", 80, 100), + box("orders", "orders\nid PK\nuser_id FK", 420, 100, "#dcfce7"), + box("items", "order_items\norder_id FK\nsku", 760, 100, "#fef3c7"), + arrow( + "users-orders", + "users", + "orders", + [ + { x: 300, y: 160 }, + { x: 420, y: 160 }, + ], + "1:N", + ), + arrow( + "orders-items", + "orders", + "items", + [ + { x: 640, y: 160 }, + { x: 760, y: 160 }, + ], + "1:N", + ), + ]; + return sketchi.createCanvas({ + spec: canvas("erd", "Commerce ERD", elements, [ + { + type: "row", + ids: ["users", "orders", "items"], + x: 80, + y: 100, + gap: 120, + }, + ]), + options, + }); +}; + +export const createArchitectureMap = (sketchi) => { + const elements = [ + { + type: "frame", + id: "edge-frame", + name: "Edge", + x: 40, + y: 60, + width: 300, + height: 560, + strokeStyle: "dashed", + }, + { + type: "frame", + id: "data-frame", + name: "Data", + x: 820, + y: 60, + width: 320, + height: 560, + strokeStyle: "dashed", + }, + { + ...box("worker", "Cloudflare Worker", 80, 150), + frameId: "edge-frame", + groupIds: ["request-path"], + }, + { + ...box("service", "Effect services", 450, 150, "#ede9fe"), + groupIds: ["request-path"], + }, + { + ...box("storage", "R2 artifacts", 870, 150, "#dcfce7"), + frameId: "data-frame", + groupIds: ["request-path"], + }, + arrow( + "worker-service", + "worker", + "service", + [ + { x: 300, y: 210 }, + { x: 450, y: 210 }, + ], + "typed request", + ), + arrow( + "service-storage", + "service", + "storage", + [ + { x: 670, y: 210 }, + { x: 870, y: 210 }, + ], + "persist", + ), + ]; + return sketchi.createCanvas({ + spec: canvas("architecture", "Canvas request architecture", elements), + options, + }); +}; + +export const createTimeline = (sketchi) => { + const milestones = ["Discover", "Design", "Build", "Launch"].map( + (label, index) => ({ + type: "node", + id: `milestone-${index}`, + nodeId: `milestone-${index}`, + shape: "circle", + x: 120 + index * 260, + y: 260, + width: 90, + height: 90, + label, + fillColor: index === 3 ? "#dcfce7" : "#dbeafe", + }), + ); + const segments = milestones.slice(0, -1).map((item, index) => + arrow( + `phase-${index}`, + item.nodeId, + milestones[index + 1].nodeId, + [ + { x: item.x + 90, y: 305 }, + { x: milestones[index + 1].x, y: 305 }, + ], + `Q${index + 1}`, + ), + ); + return sketchi.createCanvas({ + spec: canvas("timeline", "Product timeline", [...milestones, ...segments]), + options, + }); +}; + +export const createDashboard = (sketchi) => { + const cards = [ + box("revenue", "Revenue\n$248k", 80, 80, "#dcfce7"), + box("users", "Active users\n18.4k", 340, 80, "#dbeafe"), + box("conversion", "Conversion\n7.2%", 600, 80, "#fef3c7"), + ]; + const bars = [120, 220, 160, 290, 250].map((height, index) => ({ + type: "node", + id: `bar-${index}`, + nodeId: `bar-${index}`, + shape: "rectangle", + x: 100 + index * 110, + y: 600 - height, + width: 70, + height, + label: `${height}`, + fillColor: "#bfdbfe", + })); + return sketchi.createCanvas({ + spec: canvas( + "dashboard", + "Growth dashboard", + [...cards, ...bars], + [ + { + type: "row", + ids: cards.map((card) => card.id), + x: 80, + y: 80, + gap: 40, + }, + { + type: "align", + ids: bars.map((bar) => bar.id), + axis: "y", + alignment: "end", + }, + ], + ), + options, + }); +}; + +export const createWireframe = (sketchi) => { + const elements = [ + { + type: "frame", + id: "browser", + name: "Account settings", + x: 80, + y: 60, + width: 1000, + height: 600, + }, + { + ...box("nav", "Logo Projects Settings", 120, 100, "#f3f4f6"), + width: 920, + height: 72, + frameId: "browser", + }, + { + ...box("sidebar", "Profile\nSecurity\nBilling", 120, 210, "#f9fafb"), + width: 220, + height: 360, + frameId: "browser", + }, + { + ...box( + "form", + "Display name\n[ Ada Lovelace ]\n\nEmail\n[ ada@example.com ]", + 390, + 210, + "#ffffff", + ), + width: 650, + height: 280, + frameId: "browser", + }, + { + ...box("save", "Save changes", 820, 520, "#dbeafe"), + width: 220, + height: 64, + frameId: "browser", + }, + ]; + return sketchi.createCanvas({ + spec: canvas("wireframe", "Settings wireframe", elements), + options, + }); +}; + +export const createDenseCanvas = (sketchi) => { + const elements = Array.from({ length: 120 }, (_, index) => ({ + type: "node", + id: `cell-${index}`, + nodeId: `cell-${index}`, + shape: index % 7 === 0 ? "diamond" : "rectangle", + x: 0, + y: 0, + width: 82, + height: 48, + label: `Cell ${index + 1}`, + fillColor: index % 2 === 0 ? "#dbeafe" : "#f3f4f6", + })); + return sketchi.createCanvas({ + spec: canvas("dense-120", "Dense 120 element matrix", elements, [ + { + type: "grid", + ids: elements.map((element) => element.id), + columns: 12, + x: 40, + y: 40, + columnGap: 12, + rowGap: 12, + }, + ]), + options, + }); +}; diff --git a/packages/diagram/agent/README.md b/packages/diagram/agent/README.md index 8f2aee1e..06a0b176 100644 --- a/packages/diagram/agent/README.md +++ b/packages/diagram/agent/README.md @@ -5,7 +5,7 @@ MCP. `buildFlowchart` owns the complete accepted-artifact vertical: request decode, FlowchartSpec normalization, semantic validation, quality assessment, deterministic rendering, export validation, and one artifact-storage service write. The package exports `buildFlowchart`, `buildMindmap`, -`buildSequenceDiagram`, `getArtifact`, and +`buildSequenceDiagram`, `createCanvas`, `getArtifact`, and `applyDiagramPatch` as Effect programs. ```mermaid diff --git a/packages/diagram/agent/src/lib/code-mode/compatibility-corpus.test.ts b/packages/diagram/agent/src/lib/code-mode/compatibility-corpus.test.ts index f252efa0..8f07c4f2 100644 --- a/packages/diagram/agent/src/lib/code-mode/compatibility-corpus.test.ts +++ b/packages/diagram/agent/src/lib/code-mode/compatibility-corpus.test.ts @@ -43,6 +43,68 @@ import { } from "./runtime"; const POST_BASELINE_SCENE_FIELDS = new Set(["rendererRole", "strokeStyle"]); +const CANVAS_SPEC_ADDED_FIELDS = new Set([ + "kind", + "version", + "layers", + "layouts", + "zOrder", +]); +const CANVAS_SHAPE_ADDITIONS = new Set(["polygon"]); +const CANVAS_SCHEMA_ENUM_ADDITIONS = new Set(["canvas", "element", "layer"]); +const CANVAS_ELEMENT_SCHEMA_ADDED_FIELDS = { + node: new Set([ + "frameId", + "groupIds", + "layerId", + "locked", + "opacity", + "zIndex", + "fillStyle", + "roughness", + "strokeWidth", + "points", + ]), + text: new Set([ + "frameId", + "groupIds", + "layerId", + "locked", + "opacity", + "zIndex", + "fontFamily", + "textAlign", + "verticalAlign", + ]), + arrow: new Set([ + "frameId", + "groupIds", + "layerId", + "locked", + "opacity", + "zIndex", + "fillColor", + "fillStyle", + "roughness", + "strokeWidth", + "startArrowhead", + "endArrowhead", + ]), +}; +const CANVAS_PATCH_OPERATION_ADDITIONS = new Set( + DIAGRAM_PATCH_OPERATION_NAMES.slice(6), +); +const CANVAS_ISSUE_CODE_ADDITIONS = new Set([ + "duplicate_element_id", + "duplicate_layer_id", + "invalid_canvas_binding", + "invalid_canvas_composition", + "invalid_canvas_geometry", + "invalid_polygon", + "canvas_limit_exceeded", + "invalid_z_order", + "unknown_layout_target", +]); const FROZEN_FIXTURE_HASHES = { v1: "c668b53ee90043a06c640d06cc28253496d50e7431c916b523fcd4157b91ae55", v2: "52858006d02386fa0aac993ce35afca219aaae9860144836b0884e7770f948d9", @@ -92,19 +154,174 @@ function normalizeBrandPaletteAgainstFrozen( .replaceAll("#1a1712", "#1e1e1e"); } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isCanvasSpecValue(value: Record): boolean { + return ( + value["kind"] === "canvas" && + value["version"] === 1 && + Array.isArray(value["elements"]) + ); +} + +function isCanvasSpecSchemaProperties(value: Record): boolean { + return [ + "diagramId", + "title", + "width", + "height", + "accentColor", + "backgroundColor", + "elements", + ].every((key) => Object.hasOwn(value, key)); +} + +function isCanvasElementValue(value: Record): boolean { + return ( + typeof value["id"] === "string" && + !Object.hasOwn(value, "seed") && + ["node", "text", "arrow", "line", "frame"].includes( + String(value["type"]), + ) + ); +} + +function isApprovedCanvasArrayAddition(value: string): boolean { + return ( + CANVAS_SPEC_ADDED_FIELDS.has(value) || + CANVAS_SHAPE_ADDITIONS.has(value) || + CANVAS_SCHEMA_ENUM_ADDITIONS.has(value) || + CANVAS_PATCH_OPERATION_ADDITIONS.has(value) || + CANVAS_ISSUE_CODE_ADDITIONS.has(value) + ); +} + +function canvasElementSchemaType( + value: Record, +): "arrow" | "node" | "text" | undefined { + const typeSchema = value["type"]; + if (!isRecord(typeSchema)) return undefined; + const type = typeSchema["const"]; + return type === "arrow" || type === "node" || type === "text" + ? type + : undefined; +} + +function isAddedCanvasElementSchema(value: unknown): boolean { + if (!isRecord(value) || !isRecord(value["properties"])) return false; + const typeSchema = value["properties"]["type"]; + return ( + isRecord(typeSchema) && + (typeSchema["const"] === "line" || typeSchema["const"] === "frame") + ); +} + +function isCanvasPatchOperationSchema(value: unknown): boolean { + if (!isRecord(value) || !isRecord(value["properties"])) return false; + const operation = value["properties"]["op"]; + return ( + isRecord(operation) && + typeof operation["const"] === "string" && + CANVAS_PATCH_OPERATION_ADDITIONS.has(operation["const"]) + ); +} + +function normalizeCanvasMigrationAgainstFrozen( + value: unknown, + frozen: unknown, +): unknown { + if (Array.isArray(value) && Array.isArray(frozen)) { + const normalized = value.filter((entry) => { + if (isCanvasPatchOperationSchema(entry)) return false; + return ( + typeof entry !== "string" || + frozen.includes(entry) || + !isApprovedCanvasArrayAddition(entry) + ); + }); + return normalized.map((entry, index) => + normalizeCanvasMigrationAgainstFrozen(entry, frozen[index]), + ); + } + if (isRecord(value) && isRecord(frozen)) { + const canvasSpecValue = isCanvasSpecValue(value); + const canvasSpecSchemaProperties = isCanvasSpecSchemaProperties(value); + const canvasElementValue = isCanvasElementValue(value); + return Object.fromEntries( + Object.entries(value).flatMap(([key, entry]) => { + if ( + (CANVAS_SPEC_ADDED_FIELDS.has(key) && + (canvasSpecValue || canvasSpecSchemaProperties)) || + (canvasElementValue && POST_BASELINE_SCENE_FIELDS.has(key)) + ) { + return []; + } + return [ + [ + key, + key === "sizeBytes" && value["format"] === "scene" + ? frozen[key] + : normalizeCanvasMigrationAgainstFrozen(entry, frozen[key]), + ], + ]; + }), + ); + } + if (typeof value === "string" && typeof frozen === "string") { + if ( + (frozen.startsWith("Invalid discriminator value. Expected") || + frozen.startsWith("Use one of: setDefaultStyle")) && + DIAGRAM_PATCH_OPERATION_NAMES.slice(0, 6).every((name) => + value.includes(name), + ) + ) { + return [...CANVAS_PATCH_OPERATION_ADDITIONS].reduce( + (message, name) => + message + .replaceAll(` | "${name}"`, "") + .replaceAll(` | '${name}'`, "") + .replaceAll(`, "${name}"`, "") + .replace(new RegExp(`, ${name}(?=,|\\.|$)`, "g"), ""), + value, + ); + } + try { + const currentJson: unknown = JSON.parse(value); + const frozenJson: unknown = JSON.parse(frozen); + return JSON.stringify( + normalizeCanvasMigrationAgainstFrozen(currentJson, frozenJson), + ); + } catch { + return value; + } + } + return value; +} + function withoutPostBaselineSceneFields( value: unknown, isSchemaProperties = false, ): unknown { if (Array.isArray(value)) { - return value.map((item) => withoutPostBaselineSceneFields(item)); + return value + .filter((item) => !isAddedCanvasElementSchema(item)) + .map((item) => withoutPostBaselineSceneFields(item)); } - if (value === null || typeof value !== "object") { + if (!isRecord(value)) { return value; } + const schemaType = isSchemaProperties + ? canvasElementSchemaType(value) + : undefined; + const addedElementFields = schemaType + ? CANVAS_ELEMENT_SCHEMA_ADDED_FIELDS[schemaType] + : undefined; return Object.fromEntries( Object.entries(value).flatMap(([key, nested]) => - isSchemaProperties && POST_BASELINE_SCENE_FIELDS.has(key) + isSchemaProperties && + (POST_BASELINE_SCENE_FIELDS.has(key) || addedElementFields?.has(key)) ? [] : [[key, withoutPostBaselineSceneFields(nested, key === "properties")]], ), @@ -1196,6 +1413,48 @@ afterEach(() => { }); describe("pre-Effect Code Mode compatibility corpus", () => { + it("normalizes only explicitly approved CanvasSpec compatibility additions", () => { + const frozen = { + codes: ["legacy_code"], + order: ["first", "second"], + scene: { diagramId: "legacy", elements: [] }, + }; + + expect( + normalizeCanvasMigrationAgainstFrozen( + { + codes: [ + "legacy_code", + "invalid_canvas_geometry", + "unexpected_code", + ], + order: ["second", "first"], + scene: { + kind: "canvas", + version: 1, + diagramId: "legacy", + elements: [], + layers: [], + layouts: [], + zOrder: [], + unexpectedField: true, + }, + unexpectedTopLevel: true, + }, + frozen, + ), + ).toEqual({ + codes: ["legacy_code", "unexpected_code"], + order: ["second", "first"], + scene: { + diagramId: "legacy", + elements: [], + unexpectedField: true, + }, + unexpectedTopLevel: true, + }); + }); + it("preserves the frozen v1 and v2 fixture lineage byte-for-byte", async () => { const fixtureRoot = new URL("./fixtures/", import.meta.url); const [v1, v2] = await Promise.all([ @@ -1299,8 +1558,11 @@ describe("pre-Effect Code Mode compatibility corpus", () => { artifactProvenance: toCodeModeJsonSchema(ArtifactProvenanceSchema), }); expect( - normalizeBrandPaletteAgainstFrozen( - currentSchemas, + normalizeCanvasMigrationAgainstFrozen( + normalizeBrandPaletteAgainstFrozen( + currentSchemas, + fixture.publicContract.mcpVisible.schemas, + ), fixture.publicContract.mcpVisible.schemas, ), ).toEqual(fixture.publicContract.mcpVisible.schemas); @@ -1330,7 +1592,10 @@ describe("pre-Effect Code Mode compatibility corpus", () => { }, }; expect( - normalizeBrandPaletteAgainstFrozen(normalizedCorpus, frozen), + normalizeCanvasMigrationAgainstFrozen( + normalizeBrandPaletteAgainstFrozen(normalizedCorpus, frozen), + frozen, + ), ).toEqual(frozen); }); @@ -1349,8 +1614,12 @@ describe("pre-Effect Code Mode compatibility corpus", () => { "request_too_large", "patch_preserve_connectivity_failed", ]; - expect([...directlyReachableCodes, ...boundaryOnlyCodes].sort()).toEqual( - [...CodeModeIssueCodeSchema.options].sort(), + expect( + CodeModeIssueCodeSchema.options + .filter((code) => !CANVAS_ISSUE_CODE_ADDITIONS.has(code)) + .toSorted(), + ).toEqual( + [...directlyReachableCodes, ...boundaryOnlyCodes].toSorted(), ); const corpus = { @@ -1376,6 +1645,11 @@ describe("pre-Effect Code Mode compatibility corpus", () => { const frozen = Schema.decodeUnknownSync(Schema.Unknown)( JSON.parse(await readFile(fixturePath, "utf8")), ); - expect(normalizeBrandPaletteAgainstFrozen(corpus, frozen)).toEqual(frozen); + expect( + normalizeCanvasMigrationAgainstFrozen( + normalizeBrandPaletteAgainstFrozen(corpus, frozen), + frozen, + ), + ).toEqual(frozen); }); }); diff --git a/packages/diagram/agent/src/lib/code-mode/contract.ts b/packages/diagram/agent/src/lib/code-mode/contract.ts index ecb6346e..94c43bd5 100644 --- a/packages/diagram/agent/src/lib/code-mode/contract.ts +++ b/packages/diagram/agent/src/lib/code-mode/contract.ts @@ -12,7 +12,11 @@ import type { StandardJSONSchemaV1, StandardSchemaV1, } from "@standard-schema/spec"; -import { SKETCHI_DIAGRAM_STYLE } from "@sketchi/diagram-core"; +import { + CANVAS_LIMITS, + CANVAS_SPEC_VERSION, + SKETCHI_DIAGRAM_STYLE, +} from "@sketchi/diagram-core"; import { cleanToolString } from "../clean-tool-string.js"; @@ -383,6 +387,15 @@ export const CODE_MODE_ISSUE_CODES: readonly [ "unsupported_patch_operation", "patch_preserve_connectivity_failed", "patch_output_invalid", + "duplicate_element_id", + "duplicate_layer_id", + "invalid_canvas_binding", + "invalid_canvas_composition", + "invalid_canvas_geometry", + "invalid_polygon", + "canvas_limit_exceeded", + "invalid_z_order", + "unknown_layout_target", ] = [ "missing_field", "invalid_type", @@ -425,6 +438,15 @@ export const CODE_MODE_ISSUE_CODES: readonly [ "unsupported_patch_operation", "patch_preserve_connectivity_failed", "patch_output_invalid", + "duplicate_element_id", + "duplicate_layer_id", + "invalid_canvas_binding", + "invalid_canvas_composition", + "invalid_canvas_geometry", + "invalid_polygon", + "canvas_limit_exceeded", + "invalid_z_order", + "unknown_layout_target", ]; export const CodeModeIssueCodeSchema = Object.assign( @@ -438,10 +460,13 @@ const CodeModeIssueKindSchema = literals([ "diagram", "node", "edge", + "element", + "layer", "artifact", ]); const CodeModeIssueStageSchema = literals([ "input", + "canvas", "flowchart", "mindmap", "quality", @@ -493,6 +518,12 @@ export const DIAGRAM_PATCH_OPERATION_NAMES: readonly [ "translate", "replaceText", "rerouteEdges", + "insert", + "remove", + "replace", + "reorder", + "group", + "ungroup", ] = [ "setDefaultStyle", "setStyle", @@ -500,6 +531,12 @@ export const DIAGRAM_PATCH_OPERATION_NAMES: readonly [ "translate", "replaceText", "rerouteEdges", + "insert", + "remove", + "replace", + "reorder", + "group", + "ungroup", ]; export const DiagramPatchOperationNameSchema = Object.assign( withParser(literals(DIAGRAM_PATCH_OPERATION_NAMES)), @@ -858,9 +895,7 @@ const mindmapStyleDefault = { }; const MindmapStyleWithDefault = Schema.Struct({ accentColor: hexColor(defaultAccentColor).pipe( - Schema.withDecodingDefault( - Effect.succeed(mindmapStyleDefault.accentColor), - ), + Schema.withDecodingDefault(Effect.succeed(mindmapStyleDefault.accentColor)), ), backgroundColor: hexColor(mindmapStyleDefault.backgroundColor).pipe( Schema.withDecodingDefault( @@ -905,20 +940,78 @@ export class ScenePoint extends Schema.Class("ScenePoint")( ) {} export const ScenePointSchema = withParser(ScenePoint); +const CanvasCompositionFields = { + frameId: optionalContract(NonEmptyString).pipe(Schema.mutableKey), + groupIds: optionalContract( + Schema.Array(NonEmptyString) + .pipe(Schema.mutable) + .annotate({ maxItems: CANVAS_LIMITS.maxGroupsPerElement }), + ).pipe(Schema.mutableKey), + layerId: optionalContract(NonEmptyString).pipe(Schema.mutableKey), + locked: optionalContract(Schema.Boolean).pipe(Schema.mutableKey), + opacity: optionalContract( + Schema.Number.annotate({ minimum: 0, maximum: 100 }).check( + Schema.isFinite(), + Schema.isBetween({ minimum: 0, maximum: 100 }), + ), + ).pipe(Schema.mutableKey), + zIndex: optionalContract(Schema.Int).pipe(Schema.mutableKey), +}; + +const CanvasStrokeFields = { + fillColor: optionalContract(HexColor).pipe(Schema.mutableKey), + fillStyle: optionalContract( + literals(["cross-hatch", "hachure", "solid"]), + ).pipe(Schema.mutableKey), + roughness: optionalContract(literals([0, 1, 2])).pipe(Schema.mutableKey), + strokeColor: optionalContract(HexColor).pipe(Schema.mutableKey), + strokeStyle: optionalContract(literals(["dashed", "dotted", "solid"])).pipe( + Schema.mutableKey, + ), + strokeWidth: optionalContract(literals([1, 2, 4])).pipe(Schema.mutableKey), +}; + +const CanvasArrowhead = Schema.NullOr( + literals(["arrow", "bar", "circle", "diamond", "triangle"]), +); + +const CanvasPointList = Schema.Array(ScenePoint) + .pipe(Schema.mutable) + .annotate({ + minItems: 2, + maxItems: CANVAS_LIMITS.maxPointsPerElement, + }) + .check( + Schema.makeFilter((value) => value.length >= 2, { + message: "Too small: expected array to have >=2 items", + }), + Schema.makeFilter( + (value) => value.length <= CANVAS_LIMITS.maxPointsPerElement, + { + message: `Too big: expected array to have <=${CANVAS_LIMITS.maxPointsPerElement} items`, + }, + ), + ); + export class NodeSceneElement extends Schema.Class( "NodeSceneElement", )( { + ...CanvasCompositionFields, + ...CanvasStrokeFields, type: stringLiteral("node"), id: RequiredNonEmptyString, nodeId: RequiredNonEmptyString, kind: optionalContract(NonEmptyString), rendererRole: optionalContract(literals(["sequence-lifeline"])), - shape: literals(["rectangle", "ellipse", "diamond", "circle"]).pipe( - Schema.mutableKey, - ), - fillColor: optionalContract(HexColor).pipe(Schema.mutableKey), - strokeColor: optionalContract(HexColor).pipe(Schema.mutableKey), + shape: literals([ + "rectangle", + "ellipse", + "diamond", + "circle", + "polygon", + ]).pipe(Schema.mutableKey), + points: optionalContract(CanvasPointList).pipe(Schema.mutableKey), textColor: optionalContract(HexColor).pipe(Schema.mutableKey), x: FiniteNumber.pipe(Schema.mutableKey), y: FiniteNumber.pipe(Schema.mutableKey), @@ -933,6 +1026,7 @@ export class TextSceneElement extends Schema.Class( "TextSceneElement", )( { + ...CanvasCompositionFields, type: stringLiteral("text"), id: RequiredNonEmptyString, containerId: optionalContract(NonEmptyString), @@ -941,7 +1035,10 @@ export class TextSceneElement extends Schema.Class( y: FiniteNumber.pipe(Schema.mutableKey), text: RequiredNonEmptyString.pipe(Schema.mutableKey), fontSize: PositiveNumber, + fontFamily: optionalContract(literals(["hand", "mono", "sans"])), maxWidth: optionalContract(PositiveNumber), + textAlign: optionalContract(literals(["center", "left", "right"])), + verticalAlign: optionalContract(literals(["bottom", "middle", "top"])), }, { identifier: undefined }, ) {} @@ -950,25 +1047,133 @@ export class ArrowSceneElement extends Schema.Class( "ArrowSceneElement", )( { + ...CanvasCompositionFields, + ...CanvasStrokeFields, type: stringLiteral("arrow"), id: RequiredNonEmptyString, edgeId: RequiredNonEmptyString, sourceNodeId: RequiredNonEmptyString, targetNodeId: RequiredNonEmptyString.pipe(Schema.mutableKey), - strokeColor: optionalContract(HexColor).pipe(Schema.mutableKey), - strokeStyle: optionalContract( - literals(["dashed", "dotted", "solid"]), - ).pipe(Schema.mutableKey), + startArrowhead: optionalContract(CanvasArrowhead).pipe(Schema.mutableKey), + endArrowhead: optionalContract(CanvasArrowhead).pipe(Schema.mutableKey), textColor: optionalContract(HexColor).pipe(Schema.mutableKey), - points: requiredArray(Schema.Array(ScenePoint).pipe(Schema.mutable)) - .annotate({ minItems: 2 }) - .check( - Schema.makeFilter((value) => value.length >= 2, { - message: "Too small: expected array to have >=2 items", - }), - ) - .pipe(Schema.mutableKey), + points: requiredArray(CanvasPointList).pipe(Schema.mutableKey), + label: optionalContract(NonEmptyString).pipe(Schema.mutableKey), + }, + { identifier: undefined }, +) {} + +export class CanvasLineBinding extends Schema.Class( + "CanvasLineBinding", +)( + { + elementId: RequiredNonEmptyString, + focus: optionalContract(FiniteNumber), + gap: optionalContract(FiniteNumber), + }, + { identifier: undefined }, +) {} + +export class LineSceneElement extends Schema.Class( + "LineSceneElement", +)( + { + ...CanvasCompositionFields, + ...CanvasStrokeFields, + type: stringLiteral("line"), + id: RequiredNonEmptyString, + points: requiredArray(CanvasPointList).pipe(Schema.mutableKey), + startBinding: optionalContract(CanvasLineBinding).pipe(Schema.mutableKey), + endBinding: optionalContract(CanvasLineBinding).pipe(Schema.mutableKey), + startArrowhead: optionalContract(CanvasArrowhead).pipe(Schema.mutableKey), + endArrowhead: optionalContract(CanvasArrowhead).pipe(Schema.mutableKey), label: optionalContract(NonEmptyString).pipe(Schema.mutableKey), + textColor: optionalContract(HexColor).pipe(Schema.mutableKey), + }, + { identifier: undefined }, +) {} + +export class FrameSceneElement extends Schema.Class( + "FrameSceneElement", +)( + { + ...CanvasCompositionFields, + ...CanvasStrokeFields, + type: stringLiteral("frame"), + id: RequiredNonEmptyString, + name: optionalContract(NonEmptyString).pipe(Schema.mutableKey), + x: FiniteNumber.pipe(Schema.mutableKey), + y: FiniteNumber.pipe(Schema.mutableKey), + width: PositiveNumber.pipe(Schema.mutableKey), + height: PositiveNumber.pipe(Schema.mutableKey), + }, + { identifier: undefined }, +) {} + +export class CanvasLayer extends Schema.Class("CanvasLayer")( + { + id: RequiredNonEmptyString, + name: optionalContract(NonEmptyString), + locked: optionalContract(Schema.Boolean), + visible: optionalContract(Schema.Boolean), + }, + { identifier: undefined }, +) {} + +const CanvasLayoutIds = requiredArray(nonEmptyArray(NonEmptyString)); + +export class CanvasFlowLayout extends Schema.Class( + "CanvasFlowLayout", +)( + { + type: literals(["row", "column", "stack"]), + ids: CanvasLayoutIds, + x: optionalContract(FiniteNumber), + y: optionalContract(FiniteNumber), + gap: optionalContract(FiniteNumber), + }, + { identifier: undefined }, +) {} + +export class CanvasGridLayout extends Schema.Class( + "CanvasGridLayout", +)( + { + type: stringLiteral("grid"), + ids: CanvasLayoutIds, + columns: Schema.Int.annotate({ minimum: 1 }).check( + Schema.makeFilter((value) => value >= 1, { + message: "Too small: expected number to be >=1", + }), + ), + x: optionalContract(FiniteNumber), + y: optionalContract(FiniteNumber), + columnGap: optionalContract(FiniteNumber), + rowGap: optionalContract(FiniteNumber), + }, + { identifier: undefined }, +) {} + +export class CanvasAlignLayout extends Schema.Class( + "CanvasAlignLayout", +)( + { + type: stringLiteral("align"), + ids: CanvasLayoutIds, + axis: literals(["x", "y"]), + alignment: literals(["center", "end", "start"]), + }, + { identifier: undefined }, +) {} + +export class CanvasDistributeLayout extends Schema.Class( + "CanvasDistributeLayout", +)( + { + type: stringLiteral("distribute"), + ids: CanvasLayoutIds, + axis: literals(["x", "y"]), + gap: optionalContract(FiniteNumber), }, { identifier: undefined }, ) {} @@ -976,15 +1181,46 @@ export class ArrowSceneElement extends Schema.Class( export const NodeSceneElementSchema = withParser(NodeSceneElement); export const TextSceneElementSchema = withParser(TextSceneElement); export const ArrowSceneElementSchema = withParser(ArrowSceneElement); +export const LineSceneElementSchema = withParser(LineSceneElement); +export const FrameSceneElementSchema = withParser(FrameSceneElement); export const SceneElementSchema = Schema.Union( - [NodeSceneElement, TextSceneElement, ArrowSceneElement], + [ + NodeSceneElement, + TextSceneElement, + ArrowSceneElement, + LineSceneElement, + FrameSceneElement, + ], { mode: "oneOf" }, ); -export class RenderedDiagramScene extends Schema.Class( - "RenderedDiagramScene", -)( +const CanvasLayoutSchema = Schema.Union( + [ + CanvasFlowLayout, + CanvasGridLayout, + CanvasAlignLayout, + CanvasDistributeLayout, + ], + { mode: "oneOf" }, +); + +const EmptyCanvasLayers = Schema.Array(CanvasLayer) + .pipe(Schema.mutable) + .annotate({ default: [], maxItems: CANVAS_LIMITS.maxLayers }) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))); +const EmptyCanvasLayouts = Schema.Array(CanvasLayoutSchema) + .pipe(Schema.mutable) + .annotate({ default: [], maxItems: CANVAS_LIMITS.maxLayouts }) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))); +const EmptyCanvasZOrder = Schema.Array(NonEmptyString) + .pipe(Schema.mutable) + .annotate({ default: [], maxItems: CANVAS_LIMITS.maxZOrderEntries }) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))); + +export class CanvasSpec extends Schema.Class("CanvasSpec")( { + kind: stringLiteral("canvas"), + version: numberLiteral(CANVAS_SPEC_VERSION), diagramId: RequiredNonEmptyString, title: RequiredNonEmptyString, width: PositiveNumber.pipe(Schema.mutableKey), @@ -992,13 +1228,32 @@ export class RenderedDiagramScene extends Schema.Class( accentColor: HexColor.pipe(Schema.mutableKey), backgroundColor: HexColor.pipe(Schema.mutableKey), elements: requiredArray( - Schema.Array(SceneElementSchema).pipe(Schema.mutable), + Schema.Array(SceneElementSchema) + .pipe(Schema.mutable) + .annotate({ maxItems: CANVAS_LIMITS.maxElements }), ), + layers: EmptyCanvasLayers, + layouts: EmptyCanvasLayouts, + zOrder: EmptyCanvasZOrder, }, { identifier: undefined }, ) {} -export const RenderedDiagramSceneSchema = withParser(RenderedDiagramScene); -export type PatchableScene = RenderedDiagramScene; +export const CanvasSpecSchema = withParser(CanvasSpec); +export type RenderedDiagramScene = CanvasSpec; +export const RenderedDiagramSceneSchema = CanvasSpecSchema; +export type PatchableScene = CanvasSpec; + +export class CreateCanvasRequest extends Schema.Class( + "CreateCanvasRequest", +)( + { + requestId: optionalContract(NonEmptyString), + spec: requiredObject(CanvasSpec), + options: optionalContract(BuildFlowchartOptions), + }, + { identifier: undefined }, +) {} +export const CreateCanvasRequestSchema = withParser(CreateCanvasRequest); const ExcalidrawElement = Schema.Record(Schema.String, Schema.Unknown).check( Schema.makeFilter( @@ -1087,7 +1342,8 @@ export const DIAGRAM_SHAPES: readonly [ "diamond", "ellipse", "circle", -] = ["rectangle", "diamond", "ellipse", "circle"]; + "polygon", +] = ["rectangle", "diamond", "ellipse", "circle", "polygon"]; export const DiagramShapeSchema = Object.assign( withParser(literals(DIAGRAM_SHAPES)), { options: DIAGRAM_SHAPES }, @@ -1154,6 +1410,75 @@ export class RerouteEdgesOperation extends Schema.Class( { identifier: undefined }, ) {} +const PatchElementIds = requiredArray(nonEmptyArray(NonEmptyString)); + +export class InsertElementsOperation extends Schema.Class( + "InsertElementsOperation", +)( + { + op: stringLiteral("insert"), + elements: requiredArray(nonEmptyArray(SceneElementSchema)), + beforeId: optionalContract(NonEmptyString), + afterId: optionalContract(NonEmptyString), + }, + { identifier: undefined }, +) {} + +export class RemoveElementsOperation extends Schema.Class( + "RemoveElementsOperation", +)( + { + op: stringLiteral("remove"), + selector: requiredObject(DiagramSelector), + }, + { identifier: undefined }, +) {} + +export class ReplaceElementOperation extends Schema.Class( + "ReplaceElementOperation", +)( + { + op: stringLiteral("replace"), + id: RequiredNonEmptyString, + element: requiredObject(SceneElementSchema), + }, + { identifier: undefined }, +) {} + +export class ReorderElementsOperation extends Schema.Class( + "ReorderElementsOperation", +)( + { + op: stringLiteral("reorder"), + ids: PatchElementIds, + beforeId: optionalContract(NonEmptyString), + afterId: optionalContract(NonEmptyString), + }, + { identifier: undefined }, +) {} + +export class GroupElementsOperation extends Schema.Class( + "GroupElementsOperation", +)( + { + op: stringLiteral("group"), + ids: PatchElementIds, + groupId: RequiredNonEmptyString, + }, + { identifier: undefined }, +) {} + +export class UngroupElementsOperation extends Schema.Class( + "UngroupElementsOperation", +)( + { + op: stringLiteral("ungroup"), + ids: PatchElementIds, + groupId: optionalContract(NonEmptyString), + }, + { identifier: undefined }, +) {} + export const DiagramPatchOperationSchema = Schema.Union( [ SetDefaultStyleOperation, @@ -1162,6 +1487,12 @@ export const DiagramPatchOperationSchema = Schema.Union( TranslateOperation, ReplaceTextOperation, RerouteEdgesOperation, + InsertElementsOperation, + RemoveElementsOperation, + ReplaceElementOperation, + ReorderElementsOperation, + GroupElementsOperation, + UngroupElementsOperation, ], { mode: "oneOf" }, ).annotate({ @@ -1185,7 +1516,7 @@ export class InlineScenePatchSource extends Schema.Class "InlineScenePatchSource", )( { - scene: requiredObject(RenderedDiagramScene), + scene: requiredObject(CanvasSpec), }, { identifier: undefined }, ) {} @@ -1593,3 +1924,46 @@ export const ApplyDiagramPatchResultSchema = Schema.Union([ ApplyDiagramPatchRejected, ]); export type ApplyDiagramPatchResult = typeof ApplyDiagramPatchResultSchema.Type; + +export class CreateCanvasAccepted extends Schema.Class( + "CreateCanvasAccepted", +)( + { + ok: booleanLiteral(true), + status: stringLiteral("accepted"), + buildId: Schema.String, + requestId: optionalContract(Schema.String), + normalizedSpec: CanvasSpec, + artifact: ArtifactBundleSchema, + issues: Schema.Array(CodeModeIssue).pipe(Schema.mutable), + }, + { identifier: undefined }, +) {} + +export class CreateCanvasRejected extends Schema.Class( + "CreateCanvasRejected", +)( + { + ok: booleanLiteral(false), + status: literals([ + "invalid_input", + "invalid_canvas", + "limit_exceeded", + "render_failed", + "export_failed", + "storage_failed", + ]), + buildId: optionalContract(Schema.String), + requestId: optionalContract(Schema.String), + normalizedSpec: optionalContract(CanvasSpec), + partial: optionalContract(PartialArtifactBundleSchema), + issues: Schema.Array(CodeModeIssue).pipe(Schema.mutable), + }, + { identifier: undefined }, +) {} + +export const CreateCanvasResultSchema = Schema.Union([ + CreateCanvasAccepted, + CreateCanvasRejected, +]); +export type CreateCanvasResult = typeof CreateCanvasResultSchema.Type; diff --git a/packages/diagram/agent/src/lib/code-mode/runtime.test.ts b/packages/diagram/agent/src/lib/code-mode/runtime.test.ts index ea1e3fc9..f253ccd8 100644 --- a/packages/diagram/agent/src/lib/code-mode/runtime.test.ts +++ b/packages/diagram/agent/src/lib/code-mode/runtime.test.ts @@ -23,6 +23,7 @@ import { type ApplyDiagramPatchResult, type ArtifactFormat, type BuildFlowchartResult, + type CreateCanvasResult, type GetArtifactResult, } from "./contract"; import { @@ -31,6 +32,7 @@ import { buildMindmap, buildSequenceDiagram, CodeModeRuntimeEnvironment, + createCanvas, getArtifact, type CodeModeRuntimeOptions, } from "./runtime"; @@ -66,6 +68,7 @@ function makeTestRuntime( buildFlowchart: (input: unknown) => run(buildFlowchart(input)), buildMindmap: (input: unknown) => run(buildMindmap(input)), buildSequenceDiagram: (input: unknown) => run(buildSequenceDiagram(input)), + createCanvas: (input: unknown) => run(createCanvas(input)), getArtifact: (input: unknown) => run(getArtifact(input)), }; } @@ -134,6 +137,8 @@ function spoofedInlineLifelineScene(options: { }) { const middleNodeId = options.useLifelineId ? "middle:lifeline" : "middle"; return { + kind: "canvas", + version: 1, diagramId: "spoofed-inline-lifeline", title: "Spoofed inline lifeline", width: 440, @@ -202,6 +207,15 @@ function spoofedInlineLifelineScene(options: { label: "End", }, ], + layers: [], + layouts: [], + zOrder: [ + "edge:start-end", + "node:start", + ...(options.useLifelineId ? ["node:middle"] : []), + `node:${middleNodeId}`, + "node:end", + ], }; } @@ -456,6 +470,17 @@ function expectBuildFailure( } } +function expectCanvasOk( + result: CreateCanvasResult, +): asserts result is Extract { + if (!result.ok) { + throw new Error( + `Expected canvas success: ${JSON.stringify(result.issues)}`, + ); + } + expect(result.ok).toBe(true); +} + function expectGetOk( result: GetArtifactResult, ): asserts result is Extract { @@ -1949,16 +1974,15 @@ describe("Code Mode runtime", () => { scene: spoofedInlineLifelineScene({ useLifelineId: false }), }, operations: [{ op: "setDefaultStyle", style: {} }], - options: { artifactFormats: ["excalidraw"] }, + options: { + artifactFormats: ["excalidraw"], + inlineArtifacts: ["excalidraw"], + }, }); - expectPatchFailure(result); - expect(result.status).toBe("export_failed"); - expect(result.issues).toContainEqual( - expect.objectContaining({ - ref: expect.objectContaining({ id: "edge:start-end" }), - message: 'Arrow "edge:start-end" passes through shape "node:middle".', - }), + expectPatchOk(result); + expect(JSON.stringify(result.artifact.formats[0]?.inline)).not.toContain( + "sketchiRendererRole", ); }); @@ -1968,17 +1992,15 @@ describe("Code Mode runtime", () => { scene: spoofedInlineLifelineScene({ useLifelineId: true }), }, operations: [{ op: "setDefaultStyle", style: {} }], - options: { artifactFormats: ["excalidraw"] }, + options: { + artifactFormats: ["excalidraw"], + inlineArtifacts: ["excalidraw"], + }, }); - expectPatchFailure(result); - expect(result.status).toBe("export_failed"); - expect(result.issues).toContainEqual( - expect.objectContaining({ - ref: expect.objectContaining({ id: "edge:start-end" }), - message: - 'Arrow "edge:start-end" passes through shape "node:middle:lifeline".', - }), + expectPatchOk(result); + expect(JSON.stringify(result.artifact.formats[0]?.inline)).not.toContain( + "sketchiRendererRole", ); }); @@ -2111,4 +2133,426 @@ describe("Code Mode runtime", () => { ], }); }); + + it("creates, renders, and persists a composed CanvasSpec artifact bundle", async () => { + let id = 0; + const runtime = makeTestRuntime({ + store: makeMemoryArtifactStorage(), + createId: (prefix) => `${prefix}-${(id += 1)}`, + renderer: { + renderPng: () => Effect.succeed(new Uint8Array([137, 80, 78, 71])), + }, + }); + const result = await runtime.createCanvas({ + requestId: "canvas-request", + spec: { + kind: "canvas", + version: 1, + diagramId: "universal-canvas", + title: "Universal canvas", + width: 900, + height: 500, + accentColor: "#1f2937", + backgroundColor: "#ffffff", + layers: [ + { id: "background", name: "Background" }, + { id: "content", name: "Content" }, + ], + elements: [ + { + type: "frame", + id: "frame", + name: "System", + x: 40, + y: 40, + width: 780, + height: 360, + layerId: "background", + }, + { + type: "node", + id: "source", + nodeId: "source", + shape: "polygon", + points: [ + { x: 0, y: 50 }, + { x: 50, y: 0 }, + { x: 100, y: 50 }, + { x: 50, y: 100 }, + ], + x: 100, + y: 140, + width: 100, + height: 100, + label: "Source", + frameId: "frame", + groupIds: ["pipeline"], + layerId: "content", + }, + { + type: "node", + id: "target", + nodeId: "target", + shape: "rectangle", + x: 520, + y: 140, + width: 180, + height: 100, + label: "Target", + frameId: "frame", + groupIds: ["pipeline"], + layerId: "content", + }, + { + type: "arrow", + id: "connector", + edgeId: "connector", + sourceNodeId: "source", + targetNodeId: "target", + points: [ + { x: 200, y: 190 }, + { x: 520, y: 190 }, + ], + startArrowhead: "circle", + endArrowhead: "triangle", + label: "typed", + layerId: "content", + }, + { + type: "line", + id: "baseline", + points: [ + { x: 80, y: 310 }, + { x: 740, y: 310 }, + ], + strokeStyle: "dashed", + layerId: "content", + }, + { + type: "text", + id: "caption", + text: "Renderer-independent scene", + x: 300, + y: 330, + fontSize: 24, + textAlign: "center", + layerId: "content", + }, + ], + layouts: [ + { + type: "row", + ids: ["source", "target"], + x: 100, + y: 140, + gap: 320, + }, + ], + zOrder: [ + "frame", + "baseline", + "source", + "target", + "connector", + "caption", + ], + }, + options: { + artifactFormats: ["scene", "excalidraw", "png"], + inlineArtifacts: ["scene", "excalidraw"], + }, + }); + + expectCanvasOk(result); + expect(result.requestId).toBe("canvas-request"); + expect(result.artifact.formats.map((format) => format.format)).toEqual([ + "scene", + "excalidraw", + "png", + ]); + expect(result.normalizedSpec.elements).toHaveLength(6); + expect(result.normalizedSpec.zOrder[0]).toBe("frame"); + }); + + it("rejects an empty CanvasSpec as a typed invalid canvas", async () => { + const result = await createTestRuntime().createCanvas({ + spec: { + kind: "canvas", + version: 1, + diagramId: "empty-canvas", + title: "Empty canvas", + width: 400, + height: 300, + accentColor: "#111827", + backgroundColor: "#ffffff", + elements: [], + layers: [], + layouts: [], + zOrder: [], + }, + }); + + expect(result).toMatchObject({ + ok: false, + status: "invalid_canvas", + issues: [ + expect.objectContaining({ + code: "invalid_canvas_geometry", + stage: "canvas", + ref: { kind: "diagram", path: "elements" }, + }), + ], + }); + }); + + it("accepts a dense 120-element CanvasSpec and applies deterministic grid layout", async () => { + const elements = Array.from({ length: 120 }, (_, index) => ({ + type: "node", + id: `cell-${index}`, + nodeId: `cell-${index}`, + shape: "rectangle", + x: 0, + y: 0, + width: 80, + height: 40, + label: `Cell ${index}`, + })); + const result = await createTestRuntime().createCanvas({ + spec: { + kind: "canvas", + version: 1, + diagramId: "dense-120", + title: "Dense matrix", + width: 1200, + height: 800, + accentColor: "#111827", + backgroundColor: "#ffffff", + elements, + layers: [], + layouts: [ + { + type: "grid", + ids: elements.map((element) => element.id), + columns: 12, + x: 20, + y: 20, + columnGap: 10, + rowGap: 10, + }, + ], + zOrder: elements.map((element) => element.id), + }, + options: { artifactFormats: ["scene"], inlineArtifacts: ["scene"] }, + }); + + expectCanvasOk(result); + expect(result.normalizedSpec.elements).toHaveLength(120); + expect(result.normalizedSpec.elements[119]).toMatchObject({ + x: 1010, + y: 470, + }); + }); + + it("supports stable structural canvas patches", async () => { + const runtime = createTestRuntime(); + const built = await runtime.createCanvas({ + spec: { + kind: "canvas", + version: 1, + diagramId: "patchable-canvas", + title: "Patchable canvas", + width: 600, + height: 300, + accentColor: "#111827", + backgroundColor: "#ffffff", + elements: [ + { + type: "node", + id: "a", + nodeId: "a", + shape: "rectangle", + x: 20, + y: 20, + width: 120, + height: 60, + label: "A", + }, + { + type: "node", + id: "b", + nodeId: "b", + shape: "rectangle", + x: 220, + y: 20, + width: 120, + height: 60, + label: "B", + }, + ], + layers: [], + layouts: [], + zOrder: ["a", "b"], + }, + options: { artifactFormats: ["scene"] }, + }); + expectCanvasOk(built); + + const patched = await runtime.applyDiagramPatch({ + source: { artifactId: built.artifact.artifactId }, + options: { + preserveConnectivity: false, + artifactFormats: ["scene"], + inlineArtifacts: ["scene"], + }, + operations: [ + { + op: "insert", + afterId: "a", + elements: [ + { + type: "node", + id: "c", + nodeId: "c", + shape: "ellipse", + x: 120, + y: 140, + width: 120, + height: 60, + label: "C", + }, + ], + }, + { op: "group", ids: ["a", "c"], groupId: "group-1" }, + { op: "reorder", ids: ["b"], beforeId: "a" }, + { + op: "replace", + id: "c", + element: { + type: "node", + id: "c", + nodeId: "c", + shape: "diamond", + x: 120, + y: 140, + width: 140, + height: 80, + label: "C updated", + groupIds: ["group-1"], + }, + }, + { op: "ungroup", ids: ["a"], groupId: "group-1" }, + { op: "remove", selector: { ids: ["a"] } }, + ], + }); + + expectPatchOk(patched); + const scene = parseInlineScene( + patched.artifact.formats.find((format) => format.format === "scene") + ?.inline, + ); + expect(scene.zOrder).toEqual(["b", "c"]); + expect(scene.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "c", + label: "C updated", + groupIds: ["group-1"], + }), + ]), + ); + }); + + it("returns typed CanvasSpec structural and limit failures", async () => { + const base = { + kind: "canvas", + version: 1, + diagramId: "invalid-canvas", + title: "Invalid canvas", + width: 600, + height: 300, + accentColor: "#111827", + backgroundColor: "#ffffff", + layers: [], + layouts: [], + }; + const invalid = await createTestRuntime().createCanvas({ + spec: { + ...base, + elements: [ + { + type: "node", + id: "same", + nodeId: "a", + shape: "rectangle", + x: 20, + y: 20, + width: 120, + height: 60, + label: "A", + }, + { + type: "node", + id: "same", + nodeId: "b", + shape: "rectangle", + x: 220, + y: 20, + width: 120, + height: 60, + label: "B", + }, + { + type: "line", + id: "line", + points: [ + { x: 0, y: 0 }, + { x: 20, y: 20 }, + ], + endBinding: { elementId: "missing" }, + }, + ], + zOrder: ["same", "line"], + }, + }); + expect(invalid).toMatchObject({ + ok: false, + status: "invalid_canvas", + issues: expect.arrayContaining([ + expect.objectContaining({ + code: "duplicate_element_id", + stage: "canvas", + }), + expect.objectContaining({ + code: "invalid_canvas_binding", + stage: "canvas", + }), + ]), + }); + + const limited = await createTestRuntime().createCanvas({ + spec: { + ...base, + width: 20_000, + elements: [ + { + type: "node", + id: "a", + nodeId: "a", + shape: "rectangle", + x: 20, + y: 20, + width: 120, + height: 60, + label: "A", + }, + ], + zOrder: ["a"], + }, + }); + expect(limited).toMatchObject({ + ok: false, + status: "limit_exceeded", + issues: [expect.objectContaining({ code: "canvas_limit_exceeded" })], + }); + }); }); diff --git a/packages/diagram/agent/src/lib/code-mode/runtime.ts b/packages/diagram/agent/src/lib/code-mode/runtime.ts index f0a57e6d..96ebfe69 100644 --- a/packages/diagram/agent/src/lib/code-mode/runtime.ts +++ b/packages/diagram/agent/src/lib/code-mode/runtime.ts @@ -1,14 +1,18 @@ import { + CANVAS_LIMITS, FLOWCHART_MAX_ISSUES, FlowchartDiagramSchema, SKETCHI_DIAGRAM_PALETTE, SKETCHI_DIAGRAM_STYLE, + compileCanvasSpec, + getCanvasValidationIssues, getFlowchartValidationIssues, parseMindmapDiagram, validateFlowchartDiagram, type FlowchartDiagram, type FlowchartValidationIssueRef, type MindmapDiagram, + type CanvasValidationIssue, } from "@sketchi/diagram-core"; import { convertSceneToExcalidraw, @@ -45,6 +49,7 @@ import { BuildFlowchartRequestSchema, BuildMindmapRequestSchema, BuildSequenceDiagramRequestSchema, + CreateCanvasRequestSchema, DIAGRAM_PATCH_OPERATION_NAMES, GetArtifactRequestSchema, RenderedDiagramSceneSchema, @@ -57,6 +62,8 @@ import { type BuildMindmapResult, type BuildSequenceDiagramRequest, type BuildSequenceDiagramResult, + type CreateCanvasRequest, + type CreateCanvasResult, type CodeModeIssue, type CodeModeIssueCode, type CodeModeIssueRef, @@ -151,6 +158,7 @@ type CodeModeBoundaryOperation = | "buildFlowchart" | "buildMindmap" | "buildSequenceDiagram" + | "createCanvas" | "getArtifact"; interface ObservableCodeModeResult { @@ -181,6 +189,7 @@ function artifactKindForOperation( if (operation === "buildFlowchart") return "flowchart"; if (operation === "buildMindmap") return "mindmap"; if (operation === "buildSequenceDiagram") return "sequence"; + if (operation === "createCanvas") return "canvas"; if (operation === "applyDiagramPatch") return "patch"; return undefined; } @@ -711,6 +720,47 @@ class BuildSequenceDiagramFailure extends Schema.TaggedErrorClass["status"]; + +interface CreateCanvasFailureContext { + readonly buildId?: string; + readonly requestId?: string; + readonly normalizedSpec?: CreateCanvasRequest["spec"]; + readonly partial?: PartialArtifactBundle; + readonly issues: CodeModeIssue[]; +} + +class CreateCanvasFailure extends Schema.TaggedErrorClass()( + "CreateCanvasFailure", + { + message: Schema.String, + status: Schema.Literals([ + "invalid_input", + "invalid_canvas", + "limit_exceeded", + "render_failed", + "export_failed", + "storage_failed", + ]), + }, +) { + readonly context: CreateCanvasFailureContext; + + constructor(input: { + readonly status: CreateCanvasFailureStatus; + readonly context: CreateCanvasFailureContext; + }) { + super({ + message: input.context.issues[0]?.message ?? input.status, + status: input.status, + }); + this.context = input.context; + } +} + type GetArtifactFailureStatus = Extract< GetArtifactResult, { ok: false } @@ -1146,10 +1196,70 @@ function exportIssues( }); } +function canvasIssueCode( + validationIssue: CanvasValidationIssue, +): CodeModeIssueCode { + switch (validationIssue.code) { + case "duplicate_element_id": + return "duplicate_element_id"; + case "duplicate_layer_id": + return "duplicate_layer_id"; + case "empty_canvas": + return "invalid_canvas_geometry"; + case "invalid_binding": + return "invalid_canvas_binding"; + case "invalid_composition": + return "invalid_canvas_composition"; + case "invalid_geometry": + return "invalid_canvas_geometry"; + case "invalid_polygon": + return "invalid_polygon"; + case "limit_exceeded": + return "canvas_limit_exceeded"; + case "missing_z_order_element": + case "unknown_z_order_element": + return "invalid_z_order"; + case "unknown_layout_target": + return "unknown_layout_target"; + } +} + +function canvasValidationIssues( + validationIssues: readonly CanvasValidationIssue[], +): CodeModeIssue[] { + return validationIssues.slice(0, MAX_INPUT_ISSUES).map((validationIssue) => + issue({ + code: canvasIssueCode(validationIssue), + stage: "canvas", + ref: validationIssue.elementId + ? { + kind: "element", + id: validationIssue.elementId, + path: validationIssue.path, + } + : { kind: "diagram", path: validationIssue.path }, + message: validationIssue.message, + hint: "Repair the referenced CanvasSpec field and call createCanvas again.", + }), + ); +} + +function canvasExportIssues( + validationIssues: ReturnType["issues"], +): CodeModeIssue[] { + return exportIssues( + validationIssues.filter( + (validationIssue) => + validationIssue.code !== "overlapping-arrow-segment" && + validationIssue.code !== "arrow-segment-through-node", + ), + ); +} + function normalizePatchableScene( scene: PatchableScene, ): RenderedDiagramScene | null { - const elements: RenderedDiagramScene["elements"] = []; + const elements: Array = []; for (const element of scene.elements) { if (element.type === "arrow") { @@ -1165,6 +1275,7 @@ function normalizePatchableScene( ...rest, ]; elements.push({ + ...element, type: "arrow", id: element.id, edgeId: element.edgeId, @@ -1180,42 +1291,82 @@ function normalizePatchableScene( } if (element.type === "node") { + const polygonPoints = element.points; + if ( + element.shape === "polygon" && + (!polygonPoints || + !polygonPoints[0] || + !polygonPoints[1] || + !polygonPoints[2]) + ) { + return null; + } elements.push({ type: "node", id: element.id, nodeId: element.nodeId, ...(element.kind ? { kind: element.kind } : {}), - ...(element.rendererRole === "sequence-lifeline" && - isStructurallyValidSequenceLifeline(scene, element) - ? { rendererRole: element.rendererRole } - : {}), shape: element.shape, ...(element.fillColor ? { fillColor: element.fillColor } : {}), ...(element.strokeColor ? { strokeColor: element.strokeColor } : {}), - ...(element.textColor ? { textColor: element.textColor } : {}), x: element.x, y: element.y, width: element.width, height: element.height, label: element.label, + ...(element.frameId ? { frameId: element.frameId } : {}), + ...(element.groupIds ? { groupIds: [...element.groupIds] } : {}), + ...(element.layerId ? { layerId: element.layerId } : {}), + ...(element.locked !== undefined ? { locked: element.locked } : {}), + ...(element.opacity !== undefined ? { opacity: element.opacity } : {}), + ...(element.zIndex !== undefined ? { zIndex: element.zIndex } : {}), + ...(element.fillStyle ? { fillStyle: element.fillStyle } : {}), + ...(element.roughness !== undefined + ? { roughness: element.roughness } + : {}), + ...(element.strokeStyle ? { strokeStyle: element.strokeStyle } : {}), + ...(element.strokeWidth !== undefined + ? { strokeWidth: element.strokeWidth } + : {}), + ...(element.rendererRole === "sequence-lifeline" && + isStructurallyValidSequenceLifeline(scene, element) + ? { rendererRole: element.rendererRole } + : { rendererRole: undefined }), + ...(element.textColor ? { textColor: element.textColor } : {}), + ...(element.shape === "polygon" && + polygonPoints?.[0] && + polygonPoints[1] && + polygonPoints[2] + ? { + points: [ + polygonPoints[0], + polygonPoints[1], + polygonPoints[2], + ...polygonPoints.slice(3), + ], + } + : { points: undefined }), }); continue; } - elements.push({ - type: "text", - id: element.id, - ...(element.containerId ? { containerId: element.containerId } : {}), - ...(element.textColor ? { textColor: element.textColor } : {}), - x: element.x, - y: element.y, - text: element.text, - fontSize: element.fontSize, - ...(element.maxWidth ? { maxWidth: element.maxWidth } : {}), - }); + if (element.type === "line") { + const first = element.points[0]; + const second = element.points[1]; + if (!first || !second) return null; + elements.push({ + ...element, + points: [first, second, ...element.points.slice(2)], + }); + continue; + } + + elements.push(structuredClone(element)); } return { + kind: scene.kind, + version: scene.version, diagramId: scene.diagramId, title: scene.title, width: scene.width, @@ -1223,6 +1374,9 @@ function normalizePatchableScene( accentColor: scene.accentColor, backgroundColor: scene.backgroundColor, elements, + layers: structuredClone(scene.layers), + layouts: structuredClone(scene.layouts), + zOrder: [...scene.zOrder], }; } @@ -1232,10 +1386,19 @@ function cloneScene(scene: PatchableScene): PatchableScene { function sourceConnectivity(scene: PatchableScene): string[] { return scene.elements - .filter((element) => element.type === "arrow") - .map( - (arrow) => `${arrow.edgeId}:${arrow.sourceNodeId}->${arrow.targetNodeId}`, - ) + .flatMap((element) => { + if (element.type === "arrow") { + return [ + `arrow:${element.id}:${element.sourceNodeId}->${element.targetNodeId}`, + ]; + } + if (element.type === "line") { + return [ + `line:${element.id}:${element.startBinding?.elementId ?? ""}->${element.endBinding?.elementId ?? ""}`, + ]; + } + return []; + }) .sort(); } @@ -1518,6 +1681,15 @@ function applyShape( const resizedNodeIds: string[] = []; for (const node of targets.nodes) { node.shape = operation.shape; + node.points = + operation.shape === "polygon" + ? [ + { x: node.width / 2, y: 0 }, + { x: node.width, y: node.height / 2 }, + { x: node.width / 2, y: node.height }, + { x: 0, y: node.height / 2 }, + ] + : undefined; if (operation.shape === "circle") { const size = Math.max(node.width, node.height); node.x -= (size - node.width) / 2; @@ -1711,10 +1883,233 @@ function recomputeSceneBounds(scene: PatchableScene): void { ys.push(point.y); } } + for (const element of scene.elements) { + if (element.type === "line") { + for (const point of element.points) { + xs.push(point.x); + ys.push(point.y); + } + } + if (element.type === "frame") { + xs.push(element.x + element.width); + ys.push(element.y + element.height); + } + } scene.width = Math.max(...xs) + SCENE_PADDING; scene.height = Math.max(...ys) + SCENE_PADDING; } +function patchTargetIds( + scene: PatchableScene, + selector: DiagramSelector, +): Set { + const targets = resolveTargets(scene, selector); + const ids = new Set([ + ...targets.nodes.map((element) => element.id), + ...targets.arrows.map((element) => element.id), + ...targets.texts.map((element) => element.id), + ]); + const explicitIds = new Set(selector.ids ?? []); + for (const element of scene.elements) { + if (explicitIds.has(element.id)) ids.add(element.id); + } + return ids; +} + +function anchoredIndex( + order: readonly string[], + beforeId: string | undefined, + afterId: string | undefined, +): number | undefined { + if (beforeId && afterId) return undefined; + if (beforeId) { + const index = order.indexOf(beforeId); + return index >= 0 ? index : undefined; + } + if (afterId) { + const index = order.indexOf(afterId); + return index >= 0 ? index + 1 : undefined; + } + return order.length; +} + +function structuralOperationIssue( + operation: DiagramPatchOperation, + message: string, +): CodeModeIssue[] { + return [ + issue({ + code: "patch_output_invalid", + stage: "canvas", + ref: { kind: "request", path: "operations" }, + message, + hint: `Repair the ${operation.op} operation and preserve unique, stable element ids.`, + }), + ]; +} + +function applyInsert( + scene: PatchableScene, + operation: Extract, +): CodeModeIssue[] { + const existingIds = new Set(scene.elements.map((element) => element.id)); + const insertedIds = operation.elements.map((element) => element.id); + if ( + new Set(insertedIds).size !== insertedIds.length || + insertedIds.some((id) => existingIds.has(id)) + ) { + return structuralOperationIssue( + operation, + "Inserted elements must use ids that are unique within the canvas.", + ); + } + const index = anchoredIndex( + scene.zOrder, + operation.beforeId, + operation.afterId, + ); + if (index === undefined) { + return structuralOperationIssue( + operation, + "Insert specifies an unknown anchor or both beforeId and afterId.", + ); + } + scene.elements.push( + ...operation.elements.map((element) => structuredClone(element)), + ); + scene.zOrder.splice(index, 0, ...insertedIds); + return []; +} + +function applyRemove( + scene: PatchableScene, + operation: Extract, +): CodeModeIssue[] { + const removedIds = patchTargetIds(scene, operation.selector); + if (removedIds.size === 0) return [targetIssue(operation)]; + const removedNodeIds = new Set( + scene.elements.flatMap((element) => + element.type === "node" && removedIds.has(element.id) + ? [element.nodeId] + : [], + ), + ); + for (const element of scene.elements) { + if ( + (element.type === "text" && + element.containerId && + removedIds.has(element.containerId)) || + (element.type === "arrow" && + (removedNodeIds.has(element.sourceNodeId) || + removedNodeIds.has(element.targetNodeId))) || + (element.type === "line" && + ((element.startBinding && + removedIds.has(element.startBinding.elementId)) || + (element.endBinding && removedIds.has(element.endBinding.elementId)))) + ) { + removedIds.add(element.id); + } + } + const retained = scene.elements + .filter((element) => !removedIds.has(element.id)) + .map((element) => + element.frameId && removedIds.has(element.frameId) + ? { ...element, frameId: undefined } + : element, + ); + scene.elements.splice(0, scene.elements.length, ...retained); + const retainedOrder = scene.zOrder.filter((id) => !removedIds.has(id)); + scene.zOrder.splice(0, scene.zOrder.length, ...retainedOrder); + const layouts = scene.layouts + .map((layout) => ({ + ...layout, + ids: layout.ids.filter((id) => !removedIds.has(id)), + })) + .filter((layout) => layout.ids.length > 0); + scene.layouts.splice(0, scene.layouts.length, ...layouts); + recomputeSceneBounds(scene); + return []; +} + +function applyReplace( + scene: PatchableScene, + operation: Extract, +): CodeModeIssue[] { + const index = scene.elements.findIndex( + (element) => element.id === operation.id, + ); + if (index < 0) return [targetIssue(operation)]; + if (operation.element.id !== operation.id) { + return structuralOperationIssue( + operation, + "Replacement element id must match the stable id being replaced.", + ); + } + scene.elements.splice(index, 1, structuredClone(operation.element)); + recomputeSceneBounds(scene); + return []; +} + +function applyReorder( + scene: PatchableScene, + operation: Extract, +): CodeModeIssue[] { + const selected = new Set(operation.ids); + if ( + selected.size !== operation.ids.length || + operation.ids.some((id) => !scene.zOrder.includes(id)) + ) { + return structuralOperationIssue( + operation, + "Reorder ids must be unique ids already present in zOrder.", + ); + } + const remaining = scene.zOrder.filter((id) => !selected.has(id)); + const index = anchoredIndex(remaining, operation.beforeId, operation.afterId); + if (index === undefined) { + return structuralOperationIssue( + operation, + "Reorder specifies an unknown anchor or both beforeId and afterId.", + ); + } + remaining.splice(index, 0, ...operation.ids); + scene.zOrder.splice(0, scene.zOrder.length, ...remaining); + return []; +} + +function applyGroup( + scene: PatchableScene, + operation: Extract, +): CodeModeIssue[] { + const ids = new Set(operation.ids); + if ( + operation.ids.some( + (id) => !scene.elements.some((element) => element.id === id), + ) + ) { + return [targetIssue(operation)]; + } + const grouped = scene.elements.map((element) => { + if (!ids.has(element.id)) return element; + if (operation.op === "group") { + return { + ...element, + groupIds: [ + ...new Set([...(element.groupIds ?? []), operation.groupId]), + ], + }; + } + return { + ...element, + groupIds: operation.groupId + ? (element.groupIds ?? []).filter((id) => id !== operation.groupId) + : [], + }; + }); + scene.elements.splice(0, scene.elements.length, ...grouped); + return []; +} + function applyPatchOperation( scene: PatchableScene, operation: DiagramPatchOperation, @@ -1731,6 +2126,17 @@ function applyPatchOperation( return applyReplaceText(scene, operation); case "rerouteEdges": return applyRerouteEdges(scene, operation); + case "insert": + return applyInsert(scene, operation); + case "remove": + return applyRemove(scene, operation); + case "replace": + return applyReplace(scene, operation); + case "reorder": + return applyReorder(scene, operation); + case "group": + case "ungroup": + return applyGroup(scene, operation); } } @@ -1921,6 +2327,22 @@ function buildSequenceDiagramFailureResult( }; } +function createCanvasFailureResult( + error: CreateCanvasFailure, +): Extract { + return { + ok: false, + status: error.status, + ...(error.context.buildId ? { buildId: error.context.buildId } : {}), + ...(error.context.requestId ? { requestId: error.context.requestId } : {}), + ...(error.context.normalizedSpec + ? { normalizedSpec: error.context.normalizedSpec } + : {}), + ...(error.context.partial ? { partial: error.context.partial } : {}), + issues: error.context.issues, + }; +} + function getArtifactFailureResult( error: GetArtifactFailure, ): Extract { @@ -2280,6 +2702,157 @@ const buildSequenceDiagramWorkflow = Effect.fn( } satisfies Extract; }); +const createCanvasWorkflow = Effect.fn("codeMode.createCanvas.workflow")( + function* (input: unknown) { + const parsed = yield* Effect.sync(() => + CreateCanvasRequestSchema.safeParse(input), + ).pipe(Effect.withSpan("codeMode.createCanvas.parse")); + if (!parsed.success) { + return yield* new CreateCanvasFailure({ + status: "invalid_input", + context: { issues: inputIssues(parsed.error) }, + }); + } + + const environment = yield* CodeModeRuntimeEnvironment; + const store = yield* CodeModeArtifactStorage; + const request = parsed.data; + const buildId = yield* Effect.sync(() => environment.createId("build")); + const baseContext = { + buildId, + ...responseRequestId(request.requestId), + }; + if (jsonSizeBytes(request.spec) > CANVAS_LIMITS.maxSerializedBytes) { + return yield* new CreateCanvasFailure({ + status: "limit_exceeded", + context: { + ...baseContext, + issues: [ + issue({ + code: "canvas_limit_exceeded", + stage: "canvas", + ref: { kind: "request", path: "spec" }, + message: `CanvasSpec exceeds ${CANVAS_LIMITS.maxSerializedBytes} serialized bytes.`, + hint: "Split the visualization into a smaller canvas or reduce repeated text and points.", + }), + ], + }, + }); + } + + const normalized = yield* Effect.sync(() => + normalizePatchableScene(request.spec), + ).pipe(Effect.withSpan("codeMode.createCanvas.normalize")); + if (!normalized) { + return yield* new CreateCanvasFailure({ + status: "invalid_canvas", + context: { + ...baseContext, + issues: [ + issue({ + code: "invalid_canvas_geometry", + stage: "canvas", + ref: { kind: "request", path: "spec.elements" }, + message: "CanvasSpec contains an invalid point list.", + hint: "Provide at least two points for lines/connectors and three points for polygons.", + }), + ], + }, + }); + } + + const scene = yield* Effect.sync(() => compileCanvasSpec(normalized)).pipe( + Effect.withSpan("codeMode.createCanvas.layout"), + ); + const validationIssues = yield* Effect.sync(() => + getCanvasValidationIssues(scene), + ).pipe(Effect.withSpan("codeMode.createCanvas.validate")); + if (validationIssues.length > 0) { + const issues = canvasValidationIssues(validationIssues); + return yield* new CreateCanvasFailure({ + status: issues.some((entry) => entry.code === "canvas_limit_exceeded") + ? "limit_exceeded" + : "invalid_canvas", + context: { ...baseContext, normalizedSpec: scene, issues }, + }); + } + + const { excalidraw, validation } = yield* Effect.sync(() => { + const excalidrawScene = convertSceneToExcalidraw(scene); + return { + excalidraw: excalidrawScene, + validation: validateExcalidrawScene(excalidrawScene), + }; + }).pipe(Effect.withSpan("codeMode.createCanvas.exportValidate")); + const exportValidationIssues = canvasExportIssues(validation.issues); + const exportContext = { + ...baseContext, + normalizedSpec: scene, + partial: scenePartial(scene), + }; + if (exportValidationIssues.length > 0) { + return yield* new CreateCanvasFailure({ + status: "export_failed", + context: { ...exportContext, issues: exportValidationIssues }, + }); + } + + const storedFormats = yield* storedArtifactsForFormats({ + formats: requestedFormats(request.options), + scene, + excalidraw, + renderer: environment.renderer, + }).pipe( + Effect.mapError( + (error) => + new CreateCanvasFailure({ + status: "export_failed", + context: { + ...exportContext, + issues: artifactExportIssues(error), + }, + }), + ), + ); + const artifactId = yield* Effect.sync(() => + environment.createId("artifact"), + ); + const artifact = yield* withTelemetryCorrelation( + store.write({ + artifactId, + diagramId: scene.diagramId, + formats: storedFormats, + inlineFormats: requestedInlineFormats(request.options), + }), + { + artifactId, + ...(request.requestId ? { requestId: request.requestId } : {}), + }, + ).pipe( + Effect.mapError( + (error) => + new CreateCanvasFailure({ + status: "storage_failed", + context: { + ...exportContext, + issues: [storageFailureIssue(error, "storage_write_failed")], + }, + }), + ), + ); + + return { + ok: true, + status: "accepted", + buildId, + ...responseRequestId(request.requestId), + normalizedSpec: scene, + artifact: withArtifactUrls(artifact, environment.artifactUrl), + issues: [], + } satisfies Extract; + }, +); + const buildFlowchartWorkflow = Effect.fn("codeMode.buildFlowchart.workflow")( function* (input: unknown) { const parsed = yield* Effect.sync(() => @@ -2645,6 +3218,20 @@ const applyDiagramPatchWorkflow = Effect.fn( }); } + const structuralIssues = yield* Effect.sync(() => + getCanvasValidationIssues(renderedScene), + ).pipe(Effect.withSpan("codeMode.applyDiagramPatch.validate")); + if (structuralIssues.length > 0) { + return yield* new ApplyDiagramPatchFailure({ + status: "render_failed", + context: { + ...sourceContext, + partial: scenePartial(renderedScene), + issues: canvasValidationIssues(structuralIssues), + }, + }); + } + const { excalidraw, validation } = yield* Effect.sync(() => { const excalidrawScene = convertSceneToExcalidraw(renderedScene); return { @@ -2656,10 +3243,11 @@ const applyDiagramPatchWorkflow = Effect.fn( ...sourceContext, partial: scenePartial(renderedScene), }; - if (!validation.ok) { + const exportValidationIssues = canvasExportIssues(validation.issues); + if (exportValidationIssues.length > 0) { return yield* new ApplyDiagramPatchFailure({ status: "export_failed", - context: { ...exportContext, issues: exportIssues(validation.issues) }, + context: { ...exportContext, issues: exportValidationIssues }, }); } @@ -2781,6 +3369,21 @@ export const buildSequenceDiagram: ( ), ); +export const createCanvas: ( + input: unknown, +) => CodeModeWorkflowEffect = Effect.fn( + "codeMode.createCanvas", +)((input: unknown) => + observeCodeModeBoundary( + "createCanvas", + input, + codeModeResultBoundary( + createCanvasWorkflow(input), + createCanvasFailureResult, + ), + ), +); + export const getArtifact: ( input: unknown, ) => CodeModeWorkflowEffect = Effect.fn( diff --git a/packages/diagram/core/src/canvas.test.ts b/packages/diagram/core/src/canvas.test.ts new file mode 100644 index 00000000..8b2b65b7 --- /dev/null +++ b/packages/diagram/core/src/canvas.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, it } from "vitest"; + +import { + CANVAS_SPEC_VERSION, + compileCanvasSpec, + getCanvasValidationIssues, + type CanvasSpec, +} from "./canvas"; + +function baseCanvas(overrides: Partial = {}): CanvasSpec { + return { + kind: "canvas", + version: CANVAS_SPEC_VERSION, + diagramId: "canvas-test", + title: "Canvas test", + width: 400, + height: 300, + accentColor: "#111827", + backgroundColor: "#ffffff", + elements: [], + layers: [], + layouts: [], + zOrder: [], + ...overrides, + }; +} + +describe("CanvasSpec", () => { + it("applies ordered layout primitives and synchronizes bindings deterministically", () => { + const elements: CanvasSpec["elements"] = [ + { + type: "node", + id: "a", + nodeId: "a", + shape: "rectangle", + x: 0, + y: 0, + width: 100, + height: 60, + label: "A", + }, + { + type: "node", + id: "b", + nodeId: "b", + shape: "rectangle", + x: 0, + y: 0, + width: 140, + height: 80, + label: "B", + }, + { + type: "text", + id: "label-a", + containerId: "a", + x: 0, + y: 0, + text: "A", + fontSize: 20, + }, + { + type: "arrow", + id: "a-b", + edgeId: "a-b", + sourceNodeId: "a", + targetNodeId: "b", + points: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + ], + }, + ]; + const compiled = compileCanvasSpec( + baseCanvas({ + elements, + layouts: [ + { type: "row", ids: ["a", "b"], x: 40, y: 60, gap: 30 }, + { type: "align", ids: ["a", "b"], axis: "y", alignment: "center" }, + ], + }), + ); + + expect( + compiled.elements.find((element) => element.id === "a"), + ).toMatchObject({ x: 40, y: 65 }); + expect( + compiled.elements.find((element) => element.id === "b"), + ).toMatchObject({ x: 170, y: 55 }); + expect( + compiled.elements.find((element) => element.id === "label-a"), + ).toMatchObject({ x: 90, y: 95 }); + expect( + compiled.elements.find((element) => element.id === "a-b"), + ).toMatchObject({ + points: [ + { x: 140, y: 95 }, + { x: 170, y: 95 }, + ], + }); + expect(compiled.zOrder).toEqual(["a", "b", "label-a", "a-b"]); + }); + + it("reports hard structural violations without treating overlap as invalid", () => { + const issues = getCanvasValidationIssues( + baseCanvas({ + layers: [{ id: "visible" }], + elements: [ + { + type: "node", + id: "same", + nodeId: "one", + shape: "rectangle", + x: 10, + y: 10, + width: 100, + height: 100, + label: "One", + }, + { + type: "node", + id: "same", + nodeId: "two", + shape: "polygon", + x: 10, + y: 10, + width: 100, + height: 100, + label: "Two", + layerId: "missing", + }, + { + type: "line", + id: "line", + points: [ + { x: 0, y: 0 }, + { x: 20, y: 20 }, + ], + endBinding: { elementId: "missing" }, + }, + ], + zOrder: ["same", "line"], + }), + ); + + expect(issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + "duplicate_element_id", + "invalid_polygon", + "invalid_composition", + "invalid_binding", + ]), + ); + expect(issues.every((issue) => !issue.message.includes("overlap"))).toBe( + true, + ); + }); + + it("lays out columns, grids, stacks, and distributed elements deterministically", () => { + const elements: CanvasSpec["elements"] = [ + { + type: "node", + id: "a", + nodeId: "a", + shape: "rectangle", + x: 0, + y: 0, + width: 10, + height: 10, + label: "A", + }, + { + type: "node", + id: "b", + nodeId: "b", + shape: "rectangle", + x: 30, + y: 20, + width: 20, + height: 15, + label: "B", + }, + { + type: "node", + id: "c", + nodeId: "c", + shape: "rectangle", + x: 80, + y: 60, + width: 30, + height: 12, + label: "C", + }, + ]; + const positions = (canvas: CanvasSpec) => + Object.fromEntries( + compileCanvasSpec(canvas).elements.flatMap((element) => + "x" in element && "y" in element + ? [[element.id, { x: element.x, y: element.y }]] + : [], + ), + ); + + expect( + positions( + baseCanvas({ + elements, + layouts: [ + { type: "column", ids: ["a", "b", "c"], x: 5, y: 7, gap: 5 }, + ], + }), + ), + ).toMatchObject({ + a: { x: 5, y: 7 }, + b: { x: 5, y: 22 }, + c: { x: 5, y: 42 }, + }); + expect( + positions( + baseCanvas({ + elements, + layouts: [ + { + type: "grid", + ids: ["a", "b", "c"], + columns: 2, + x: 10, + y: 20, + columnGap: 5, + rowGap: 7, + }, + ], + }), + ), + ).toMatchObject({ + a: { x: 10, y: 20 }, + b: { x: 45, y: 20 }, + c: { x: 10, y: 42 }, + }); + expect( + positions( + baseCanvas({ + elements, + layouts: [{ type: "stack", ids: ["a", "b", "c"], x: 9, y: 11 }], + }), + ), + ).toMatchObject({ + a: { x: 9, y: 11 }, + b: { x: 9, y: 11 }, + c: { x: 9, y: 11 }, + }); + expect( + positions( + baseCanvas({ + elements, + layouts: [ + { type: "distribute", ids: ["c", "a", "b"], axis: "x", gap: 7 }, + ], + }), + ), + ).toMatchObject({ + a: { x: 0, y: 0 }, + b: { x: 17, y: 20 }, + c: { x: 44, y: 60 }, + }); + }); + + it("rejects ambiguous stable IDs, duplicate bound text, and frame cycles", () => { + const issues = getCanvasValidationIssues( + baseCanvas({ + elements: [ + { + type: "frame", + id: "frame-a", + frameId: "frame-b", + x: 0, + y: 0, + width: 300, + height: 200, + }, + { + type: "frame", + id: "frame-b", + frameId: "frame-a", + x: 10, + y: 10, + width: 200, + height: 100, + }, + { + type: "node", + id: "one", + nodeId: "shared", + shape: "rectangle", + x: 20, + y: 20, + width: 80, + height: 40, + label: "One", + }, + { + type: "node", + id: "two", + nodeId: "shared", + shape: "rectangle", + x: 120, + y: 20, + width: 80, + height: 40, + label: "Two", + }, + { + type: "text", + id: "text-a", + containerId: "one", + x: 0, + y: 0, + text: "A", + fontSize: 16, + }, + { + type: "text", + id: "text-b", + containerId: "one", + x: 0, + y: 0, + text: "B", + fontSize: 16, + }, + ], + zOrder: ["frame-a", "frame-b", "one", "two", "text-a", "text-b"], + }), + ); + + expect(issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "invalid_binding", + message: expect.stringContaining("Duplicate nodeId"), + }), + expect.objectContaining({ + code: "invalid_binding", + message: expect.stringContaining("more than one bound text"), + }), + expect.objectContaining({ + code: "invalid_composition", + message: expect.stringContaining("nesting cycle"), + }), + ]), + ); + }); + + it("centers arrow-bound text after layout and endpoint synchronization", () => { + const compiled = compileCanvasSpec( + baseCanvas({ + elements: [ + { + type: "node", + id: "a", + nodeId: "a", + shape: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + label: "A", + }, + { + type: "node", + id: "b", + nodeId: "b", + shape: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + label: "B", + }, + { + type: "text", + id: "edge-text", + containerId: "edge", + x: 0, + y: 0, + text: "calls", + fontSize: 14, + }, + { + type: "arrow", + id: "edge", + edgeId: "edge", + sourceNodeId: "a", + targetNodeId: "b", + points: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + ], + }, + ], + layouts: [{ type: "row", ids: ["a", "b"], x: 0, y: 0, gap: 50 }], + }), + ); + + expect( + compiled.elements.find((element) => element.id === "edge"), + ).toMatchObject({ + points: [ + { x: 50, y: 25 }, + { x: 100, y: 25 }, + ], + }); + expect( + compiled.elements.find((element) => element.id === "edge-text"), + ).toMatchObject({ x: 75, y: 25 }); + }); + + it("synchronizes line bindings when layouts move nodes and frames", () => { + const compiled = compileCanvasSpec( + baseCanvas({ + elements: [ + { + type: "node", + id: "node", + nodeId: "node", + shape: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + label: "Node", + }, + { + type: "frame", + id: "frame", + x: 0, + y: 0, + width: 80, + height: 80, + }, + { + type: "line", + id: "bound-line", + points: [ + { x: 0, y: 0 }, + { x: 75, y: 10 }, + { x: 1, y: 1 }, + ], + startBinding: { elementId: "node" }, + endBinding: { elementId: "frame" }, + endArrowhead: "arrow", + }, + ], + layouts: [ + { + type: "row", + ids: ["node", "frame"], + x: 40, + y: 20, + gap: 100, + }, + ], + }), + ); + + expect( + compiled.elements.find((element) => element.id === "bound-line"), + ).toMatchObject({ + points: [ + { x: 90, y: 45 }, + { x: 75, y: 10 }, + { x: 190, y: 60 }, + ], + }); + }); + + it("rejects empty canvases before export", () => { + expect(getCanvasValidationIssues(baseCanvas())).toContainEqual({ + code: "empty_canvas", + message: "CanvasSpec must contain at least one element.", + path: "elements", + }); + }); +}); diff --git a/packages/diagram/core/src/canvas.ts b/packages/diagram/core/src/canvas.ts new file mode 100644 index 00000000..8bd4bd86 --- /dev/null +++ b/packages/diagram/core/src/canvas.ts @@ -0,0 +1,988 @@ +/** + * Canonical, renderer-independent scene IR used by every Sketchi diagram. + * + * The public contract deliberately describes drawing intent rather than raw + * Excalidraw elements. Adapters may compile this representation to Excalidraw, + * PNG, or another render target without changing the authored scene. + */ + +export const CANVAS_SPEC_VERSION: 1 = 1; + +export const CANVAS_LIMITS = Object.freeze({ + maxDimension: 16_384, + maxElements: 600, + maxGroupsPerElement: 16, + maxLayouts: 128, + maxLayers: 64, + maxPointsPerElement: 256, + maxSerializedBytes: 1_500_000, + maxTextLength: 4_096, + maxZOrderEntries: 600, +}); + +export type CanvasStrokeStyle = "dashed" | "dotted" | "solid"; +export type CanvasFillStyle = "cross-hatch" | "hachure" | "solid"; +export type CanvasArrowhead = + | "arrow" + | "bar" + | "circle" + | "diamond" + | "triangle" + | null; +export type CanvasShapeKind = + | "rectangle" + | "ellipse" + | "diamond" + | "circle" + | "polygon"; + +export interface CanvasPoint { + readonly x: number; + readonly y: number; +} + +export interface CanvasElementComposition { + readonly frameId?: string | undefined; + readonly groupIds?: string[] | undefined; + readonly layerId?: string | undefined; + readonly locked?: boolean | undefined; + readonly opacity?: number | undefined; + readonly zIndex?: number | undefined; +} + +export interface CanvasStrokeStyleFields { + readonly fillColor?: string | undefined; + readonly fillStyle?: CanvasFillStyle | undefined; + readonly roughness?: 0 | 1 | 2 | undefined; + readonly strokeColor?: string | undefined; + readonly strokeStyle?: CanvasStrokeStyle | undefined; + readonly strokeWidth?: 1 | 2 | 4 | undefined; +} + +export interface CanvasShapeElement + extends CanvasElementComposition, + CanvasStrokeStyleFields { + readonly type: "node"; + readonly id: string; + readonly nodeId: string; + readonly kind?: string | undefined; + readonly rendererRole?: "sequence-lifeline" | undefined; + readonly shape: CanvasShapeKind; + readonly points?: + | [CanvasPoint, CanvasPoint, CanvasPoint, ...CanvasPoint[]] + | undefined; + readonly textColor?: string | undefined; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly label: string; +} + +export interface CanvasTextElement extends CanvasElementComposition { + readonly type: "text"; + readonly id: string; + readonly containerId?: string | undefined; + readonly textColor?: string | undefined; + readonly x: number; + readonly y: number; + readonly text: string; + readonly fontSize: number; + readonly fontFamily?: "hand" | "mono" | "sans" | undefined; + readonly maxWidth?: number | undefined; + readonly textAlign?: "center" | "left" | "right" | undefined; + readonly verticalAlign?: "bottom" | "middle" | "top" | undefined; +} + +export interface CanvasConnectorElement + extends CanvasElementComposition, + CanvasStrokeStyleFields { + readonly type: "arrow"; + readonly id: string; + readonly edgeId: string; + readonly sourceNodeId: string; + readonly targetNodeId: string; + readonly startArrowhead?: CanvasArrowhead | undefined; + readonly endArrowhead?: CanvasArrowhead | undefined; + readonly textColor?: string | undefined; + readonly points: [CanvasPoint, ...CanvasPoint[]]; + readonly label?: string | undefined; +} + +export interface CanvasLineBinding { + readonly elementId: string; + readonly focus?: number | undefined; + readonly gap?: number | undefined; +} + +export interface CanvasLineElement + extends CanvasElementComposition, + CanvasStrokeStyleFields { + readonly type: "line"; + readonly id: string; + readonly points: [CanvasPoint, CanvasPoint, ...CanvasPoint[]]; + readonly startBinding?: CanvasLineBinding | undefined; + readonly endBinding?: CanvasLineBinding | undefined; + readonly startArrowhead?: CanvasArrowhead | undefined; + readonly endArrowhead?: CanvasArrowhead | undefined; + readonly label?: string | undefined; + readonly textColor?: string | undefined; +} + +export interface CanvasFrameElement + extends CanvasElementComposition, + CanvasStrokeStyleFields { + readonly type: "frame"; + readonly id: string; + readonly name?: string | undefined; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export type CanvasElement = + | CanvasShapeElement + | CanvasTextElement + | CanvasConnectorElement + | CanvasLineElement + | CanvasFrameElement; + +export interface CanvasLayer { + readonly id: string; + readonly name?: string | undefined; + readonly locked?: boolean | undefined; + readonly visible?: boolean | undefined; +} + +interface CanvasLayoutBase { + readonly ids: string[]; +} + +export interface CanvasFlowLayout extends CanvasLayoutBase { + readonly type: "row" | "column" | "stack"; + readonly x?: number | undefined; + readonly y?: number | undefined; + readonly gap?: number | undefined; +} + +export interface CanvasGridLayout extends CanvasLayoutBase { + readonly type: "grid"; + readonly columns: number; + readonly x?: number | undefined; + readonly y?: number | undefined; + readonly columnGap?: number | undefined; + readonly rowGap?: number | undefined; +} + +export interface CanvasAlignLayout extends CanvasLayoutBase { + readonly type: "align"; + readonly axis: "x" | "y"; + readonly alignment: "center" | "end" | "start"; +} + +export interface CanvasDistributeLayout extends CanvasLayoutBase { + readonly type: "distribute"; + readonly axis: "x" | "y"; + readonly gap?: number | undefined; +} + +export type CanvasLayout = + | CanvasFlowLayout + | CanvasGridLayout + | CanvasAlignLayout + | CanvasDistributeLayout; + +export interface CanvasSpec { + readonly kind: "canvas"; + readonly version: typeof CANVAS_SPEC_VERSION; + readonly diagramId: string; + readonly title: string; + readonly width: number; + readonly height: number; + readonly accentColor: string; + readonly backgroundColor: string; + readonly elements: CanvasElement[]; + readonly layers: CanvasLayer[]; + readonly layouts: CanvasLayout[]; + readonly zOrder: string[]; +} + +export interface CanvasValidationIssue { + readonly code: + | "duplicate_element_id" + | "duplicate_layer_id" + | "empty_canvas" + | "invalid_binding" + | "invalid_composition" + | "invalid_geometry" + | "invalid_polygon" + | "limit_exceeded" + | "missing_z_order_element" + | "unknown_layout_target" + | "unknown_z_order_element"; + readonly elementId?: string | undefined; + readonly message: string; + readonly path: string; +} + +type PositionedCanvasElement = Extract< + CanvasElement, + { readonly x: number; readonly y: number } +>; + +interface CanvasElementBounds { + readonly height: number; + readonly width: number; + readonly x: number; + readonly y: number; +} + +function findDuplicate(values: readonly string[]): string | undefined { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) return value; + seen.add(value); + } + return undefined; +} + +function isPositioned( + element: CanvasElement, +): element is PositionedCanvasElement { + return "x" in element && "y" in element; +} + +function hasPositiveBounds(element: CanvasElement): boolean { + return !( + "width" in element && + "height" in element && + (!(element.width > 0) || !(element.height > 0)) + ); +} + +function validateElementLimits( + element: CanvasElement, + index: number, +): CanvasValidationIssue[] { + const issues: CanvasValidationIssue[] = []; + const path = `elements[${index}]`; + if (!hasPositiveBounds(element)) { + issues.push({ + code: "invalid_geometry", + elementId: element.id, + message: `Element "${element.id}" must have positive width and height.`, + path, + }); + } + if ( + "points" in element && + element.points !== undefined && + element.points.length > CANVAS_LIMITS.maxPointsPerElement + ) { + issues.push({ + code: "limit_exceeded", + elementId: element.id, + message: `Element "${element.id}" exceeds ${CANVAS_LIMITS.maxPointsPerElement} points.`, + path: `${path}.points`, + }); + } + if ("text" in element && element.text.length > CANVAS_LIMITS.maxTextLength) { + issues.push({ + code: "limit_exceeded", + elementId: element.id, + message: `Text element "${element.id}" exceeds ${CANVAS_LIMITS.maxTextLength} characters.`, + path: `${path}.text`, + }); + } + if ( + element.type === "node" && + element.shape === "polygon" && + (!element.points || element.points.length < 3) + ) { + issues.push({ + code: "invalid_polygon", + elementId: element.id, + message: `Polygon "${element.id}" requires at least three points.`, + path: `${path}.points`, + }); + } + if ( + element.groupIds && + element.groupIds.length > CANVAS_LIMITS.maxGroupsPerElement + ) { + issues.push({ + code: "limit_exceeded", + elementId: element.id, + message: `Element "${element.id}" exceeds ${CANVAS_LIMITS.maxGroupsPerElement} groups.`, + path: `${path}.groupIds`, + }); + } + return issues; +} + +/** Validate only hard safety and structural invariants; overlap is intentional. */ +export function getCanvasValidationIssues( + canvas: CanvasSpec, +): CanvasValidationIssue[] { + const issues: CanvasValidationIssue[] = []; + if (canvas.elements.length === 0) { + issues.push({ + code: "empty_canvas", + message: "CanvasSpec must contain at least one element.", + path: "elements", + }); + } + if (canvas.elements.length > CANVAS_LIMITS.maxElements) { + issues.push({ + code: "limit_exceeded", + message: `Canvas exceeds ${CANVAS_LIMITS.maxElements} elements.`, + path: "elements", + }); + } + if (canvas.layers.length > CANVAS_LIMITS.maxLayers) { + issues.push({ + code: "limit_exceeded", + message: `Canvas exceeds ${CANVAS_LIMITS.maxLayers} layers.`, + path: "layers", + }); + } + if (canvas.layouts.length > CANVAS_LIMITS.maxLayouts) { + issues.push({ + code: "limit_exceeded", + message: `Canvas exceeds ${CANVAS_LIMITS.maxLayouts} layout primitives.`, + path: "layouts", + }); + } + if ( + canvas.width > CANVAS_LIMITS.maxDimension || + canvas.height > CANVAS_LIMITS.maxDimension + ) { + issues.push({ + code: "limit_exceeded", + message: `Canvas dimensions may not exceed ${CANVAS_LIMITS.maxDimension}.`, + path: "width", + }); + } + + const duplicateElementId = findDuplicate( + canvas.elements.map((element) => element.id), + ); + if (duplicateElementId) { + issues.push({ + code: "duplicate_element_id", + elementId: duplicateElementId, + message: `Duplicate element id "${duplicateElementId}" is not allowed.`, + path: "elements", + }); + } + const duplicateLayerId = findDuplicate( + canvas.layers.map((layer) => layer.id), + ); + if (duplicateLayerId) { + issues.push({ + code: "duplicate_layer_id", + message: `Duplicate layer id "${duplicateLayerId}" is not allowed.`, + path: "layers", + }); + } + const duplicateZOrderId = findDuplicate(canvas.zOrder); + if (duplicateZOrderId) { + issues.push({ + code: "unknown_z_order_element", + elementId: duplicateZOrderId, + message: `zOrder contains duplicate element "${duplicateZOrderId}".`, + path: "zOrder", + }); + } + if (canvas.zOrder.length > CANVAS_LIMITS.maxZOrderEntries) { + issues.push({ + code: "limit_exceeded", + message: `zOrder exceeds ${CANVAS_LIMITS.maxZOrderEntries} entries.`, + path: "zOrder", + }); + } + + const elementsById = new Map( + canvas.elements.map((element) => [element.id, element]), + ); + const shapes = canvas.elements.filter( + (element): element is CanvasShapeElement => element.type === "node", + ); + const shapesByNodeId = new Map( + shapes.map((element) => [element.nodeId, element]), + ); + const duplicateNodeId = findDuplicate( + shapes.map((element) => element.nodeId), + ); + if (duplicateNodeId) { + issues.push({ + code: "invalid_binding", + message: `Duplicate nodeId "${duplicateNodeId}" makes connector bindings ambiguous.`, + path: "elements", + }); + } + const duplicateEdgeId = findDuplicate( + canvas.elements.flatMap((element) => + element.type === "arrow" ? [element.edgeId] : [], + ), + ); + if (duplicateEdgeId) { + issues.push({ + code: "invalid_binding", + message: `Duplicate edgeId "${duplicateEdgeId}" makes connector selection ambiguous.`, + path: "elements", + }); + } + const duplicateTextContainer = findDuplicate( + canvas.elements.flatMap((element) => + element.type === "text" && element.containerId + ? [element.containerId] + : [], + ), + ); + if (duplicateTextContainer) { + issues.push({ + code: "invalid_binding", + elementId: duplicateTextContainer, + message: `Container "${duplicateTextContainer}" has more than one bound text element.`, + path: "elements", + }); + } + const layerIds = new Set(canvas.layers.map((layer) => layer.id)); + canvas.elements.forEach((element, index) => { + issues.push(...validateElementLimits(element, index)); + if (element.frameId) { + const frame = elementsById.get(element.frameId); + if (!frame || frame.type !== "frame") { + issues.push({ + code: "invalid_composition", + elementId: element.id, + message: `Element "${element.id}" references missing frame "${element.frameId}".`, + path: `elements[${index}].frameId`, + }); + } + } + if (element.layerId && !layerIds.has(element.layerId)) { + issues.push({ + code: "invalid_composition", + elementId: element.id, + message: `Element "${element.id}" references missing layer "${element.layerId}".`, + path: `elements[${index}].layerId`, + }); + } + if (element.groupIds && findDuplicate(element.groupIds)) { + issues.push({ + code: "invalid_composition", + elementId: element.id, + message: `Element "${element.id}" contains duplicate group ids.`, + path: `elements[${index}].groupIds`, + }); + } + if (element.type === "text" && element.containerId) { + const container = elementsById.get(element.containerId); + if ( + !container || + (container.type !== "node" && container.type !== "arrow") + ) { + issues.push({ + code: "invalid_binding", + elementId: element.id, + message: `Text "${element.id}" references unsupported container "${element.containerId}".`, + path: `elements[${index}].containerId`, + }); + } + } + if (element.type === "arrow") { + if (!shapesByNodeId.has(element.sourceNodeId)) { + issues.push({ + code: "invalid_binding", + elementId: element.id, + message: `Connector "${element.id}" references missing source "${element.sourceNodeId}".`, + path: `elements[${index}].sourceNodeId`, + }); + } + if (!shapesByNodeId.has(element.targetNodeId)) { + issues.push({ + code: "invalid_binding", + elementId: element.id, + message: `Connector "${element.id}" references missing target "${element.targetNodeId}".`, + path: `elements[${index}].targetNodeId`, + }); + } + } + if (element.type === "line") { + const bindings: Array< + readonly ["startBinding" | "endBinding", CanvasLineBinding | undefined] + > = [ + ["startBinding", element.startBinding], + ["endBinding", element.endBinding], + ]; + for (const [bindingName, binding] of bindings) { + const target = binding + ? elementsById.get(binding.elementId) + : undefined; + if ( + binding && + (!target || (target.type !== "node" && target.type !== "frame")) + ) { + issues.push({ + code: "invalid_binding", + elementId: element.id, + message: `Line "${element.id}" references missing binding "${binding.elementId}".`, + path: `elements[${index}].${bindingName}`, + }); + } + } + } + }); + + const zOrderIds = new Set(canvas.zOrder); + for (const element of canvas.elements) { + if (!zOrderIds.has(element.id)) { + issues.push({ + code: "missing_z_order_element", + elementId: element.id, + message: `Element "${element.id}" is missing from zOrder.`, + path: "zOrder", + }); + } + } + for (const id of canvas.zOrder) { + if (!elementsById.has(id)) { + issues.push({ + code: "unknown_z_order_element", + elementId: id, + message: `zOrder references missing element "${id}".`, + path: "zOrder", + }); + } + } + for (const [index, layout] of canvas.layouts.entries()) { + for (const id of layout.ids) { + const element = elementsById.get(id); + if (!element || !isPositioned(element)) { + issues.push({ + code: "unknown_layout_target", + elementId: id, + message: `Layout references missing or non-positioned element "${id}".`, + path: `layouts[${index}].ids`, + }); + } + } + } + for (const frame of canvas.elements.filter( + (element): element is CanvasFrameElement => element.type === "frame", + )) { + const visited = new Set([frame.id]); + let parentId = frame.frameId; + while (parentId) { + if (visited.has(parentId)) { + issues.push({ + code: "invalid_composition", + elementId: frame.id, + message: `Frame "${frame.id}" participates in a frame nesting cycle.`, + path: "elements", + }); + break; + } + visited.add(parentId); + const parent = elementsById.get(parentId); + parentId = parent?.type === "frame" ? parent.frameId : undefined; + } + } + return issues; +} + +function elementBounds(element: PositionedCanvasElement): CanvasElementBounds { + if (element.type === "text") { + const lines = element.text.split("\n"); + return { + x: element.x, + y: element.y, + width: + element.maxWidth ?? + Math.max(...lines.map((line) => line.length)) * element.fontSize * 0.62, + height: lines.length * element.fontSize * 1.35, + }; + } + return { + x: element.x, + y: element.y, + width: element.width, + height: element.height, + }; +} + +function repositionElement( + element: CanvasElement, + x: number, + y: number, +): CanvasElement { + if (!isPositioned(element)) return element; + return { ...element, x, y }; +} + +function layoutTargets( + elements: readonly CanvasElement[], + ids: readonly string[], +): PositionedCanvasElement[] { + const byId = new Map(elements.map((element) => [element.id, element])); + return ids.flatMap((id) => { + const element = byId.get(id); + return element && isPositioned(element) ? [element] : []; + }); +} + +function replacePositions( + elements: readonly CanvasElement[], + positions: ReadonlyMap, +): CanvasElement[] { + return elements.map((element) => { + const position = positions.get(element.id); + return position + ? repositionElement(element, position.x, position.y) + : element; + }); +} + +function compileFlowLayout( + elements: readonly CanvasElement[], + layout: CanvasFlowLayout, +): CanvasElement[] { + const targets = layoutTargets(elements, layout.ids); + const firstTarget = targets[0]; + if (!firstTarget) return [...elements]; + const firstBounds = elementBounds(firstTarget); + const originX = layout.x ?? firstBounds.x; + const originY = layout.y ?? firstBounds.y; + const gap = layout.type === "stack" ? (layout.gap ?? 0) : (layout.gap ?? 32); + const positions = new Map(); + let cursor = layout.type === "column" ? originY : originX; + for (const target of targets) { + const bounds = elementBounds(target); + positions.set(target.id, { + x: layout.type === "column" || layout.type === "stack" ? originX : cursor, + y: layout.type === "column" ? cursor : originY, + }); + if (layout.type !== "stack") { + cursor += (layout.type === "column" ? bounds.height : bounds.width) + gap; + } + } + return replacePositions(elements, positions); +} + +function compileGridLayout( + elements: readonly CanvasElement[], + layout: CanvasGridLayout, +): CanvasElement[] { + const targets = layoutTargets(elements, layout.ids); + if (targets.length === 0) return [...elements]; + const columns = Math.max(1, Math.floor(layout.columns)); + const bounds = targets.map(elementBounds); + const columnWidths = Array.from({ length: columns }, (_, column) => + Math.max( + 0, + ...bounds + .filter((_, index) => index % columns === column) + .map((entry) => entry.width), + ), + ); + const rows = Math.ceil(targets.length / columns); + const rowHeights = Array.from({ length: rows }, (_, row) => + Math.max( + 0, + ...bounds + .filter((_, index) => Math.floor(index / columns) === row) + .map((entry) => entry.height), + ), + ); + const firstBounds = bounds[0]; + if (!firstBounds) return [...elements]; + const originX = layout.x ?? firstBounds.x; + const originY = layout.y ?? firstBounds.y; + const columnGap = layout.columnGap ?? 32; + const rowGap = layout.rowGap ?? 32; + const positions = new Map(); + targets.forEach((target, index) => { + const column = index % columns; + const row = Math.floor(index / columns); + positions.set(target.id, { + x: + originX + + columnWidths.slice(0, column).reduce((sum, width) => sum + width, 0) + + columnGap * column, + y: + originY + + rowHeights.slice(0, row).reduce((sum, height) => sum + height, 0) + + rowGap * row, + }); + }); + return replacePositions(elements, positions); +} + +function axisStart(bounds: CanvasElementBounds, axis: "x" | "y"): number { + return axis === "x" ? bounds.x : bounds.y; +} + +function axisSize(bounds: CanvasElementBounds, axis: "x" | "y"): number { + return axis === "x" ? bounds.width : bounds.height; +} + +function compileAlignLayout( + elements: readonly CanvasElement[], + layout: CanvasAlignLayout, +): CanvasElement[] { + const targets = layoutTargets(elements, layout.ids); + if (targets.length === 0) return [...elements]; + const bounds = targets.map(elementBounds); + const starts = bounds.map((entry) => axisStart(entry, layout.axis)); + const ends = bounds.map( + (entry) => axisStart(entry, layout.axis) + axisSize(entry, layout.axis), + ); + const centers = bounds.map( + (entry) => axisStart(entry, layout.axis) + axisSize(entry, layout.axis) / 2, + ); + const anchor = + layout.alignment === "start" + ? Math.min(...starts) + : layout.alignment === "end" + ? Math.max(...ends) + : centers.reduce((sum, center) => sum + center, 0) / centers.length; + const positions = new Map(); + targets.forEach((target, index) => { + const boundsEntry = bounds[index]; + if (!boundsEntry) return; + const start = + layout.alignment === "start" + ? anchor + : layout.alignment === "end" + ? anchor - axisSize(boundsEntry, layout.axis) + : anchor - axisSize(boundsEntry, layout.axis) / 2; + positions.set(target.id, { + x: layout.axis === "x" ? start : boundsEntry.x, + y: layout.axis === "y" ? start : boundsEntry.y, + }); + }); + return replacePositions(elements, positions); +} + +function compileDistributeLayout( + elements: readonly CanvasElement[], + layout: CanvasDistributeLayout, +): CanvasElement[] { + const targets = layoutTargets(elements, layout.ids).sort( + (left, right) => + axisStart(elementBounds(left), layout.axis) - + axisStart(elementBounds(right), layout.axis), + ); + if (targets.length < 2) return [...elements]; + const bounds = targets.map(elementBounds); + const first = bounds[0]; + const last = bounds[bounds.length - 1]; + if (!first || !last) return [...elements]; + const firstStart = axisStart(first, layout.axis); + const lastEnd = axisStart(last, layout.axis) + axisSize(last, layout.axis); + const totalSize = bounds.reduce( + (sum, entry) => sum + axisSize(entry, layout.axis), + 0, + ); + const gap = + layout.gap ?? + Math.max(0, (lastEnd - firstStart - totalSize) / (targets.length - 1)); + const positions = new Map(); + let cursor = firstStart; + targets.forEach((target, index) => { + const boundsEntry = bounds[index]; + if (!boundsEntry) return; + positions.set(target.id, { + x: layout.axis === "x" ? cursor : boundsEntry.x, + y: layout.axis === "y" ? cursor : boundsEntry.y, + }); + cursor += axisSize(boundsEntry, layout.axis) + gap; + }); + return replacePositions(elements, positions); +} + +function compileLayout( + elements: readonly CanvasElement[], + layout: CanvasLayout, +): CanvasElement[] { + switch (layout.type) { + case "row": + case "column": + case "stack": + return compileFlowLayout(elements, layout); + case "grid": + return compileGridLayout(elements, layout); + case "align": + return compileAlignLayout(elements, layout); + case "distribute": + return compileDistributeLayout(elements, layout); + } +} + +type BoundableCanvasElement = CanvasShapeElement | CanvasFrameElement; + +function elementCenter(element: BoundableCanvasElement): CanvasPoint { + return { + x: element.x + element.width / 2, + y: element.y + element.height / 2, + }; +} + +function boundEndpointToward( + source: BoundableCanvasElement, + targetCenter: CanvasPoint, +): CanvasPoint { + const sourceCenter = elementCenter(source); + const dx = targetCenter.x - sourceCenter.x; + const dy = targetCenter.y - sourceCenter.y; + if (Math.abs(dx) >= Math.abs(dy)) { + return { + x: dx >= 0 ? source.x + source.width : source.x, + y: sourceCenter.y, + }; + } + return { + x: sourceCenter.x, + y: dy >= 0 ? source.y + source.height : source.y, + }; +} + +function synchronizeBindings( + elements: readonly CanvasElement[], +): CanvasElement[] { + const shapesByNodeId = new Map( + elements + .filter( + (element): element is CanvasShapeElement => element.type === "node", + ) + .map((element) => [element.nodeId, element]), + ); + const boundableElementsById = new Map(); + for (const element of elements) { + if (element.type === "node" || element.type === "frame") { + boundableElementsById.set(element.id, element); + } + } + const synchronizedConnectors = elements.map((element): CanvasElement => { + if (element.type === "arrow") { + const source = shapesByNodeId.get(element.sourceNodeId); + const target = shapesByNodeId.get(element.targetNodeId); + if (!source || !target) return element; + return { + ...element, + points: [ + boundEndpointToward(source, elementCenter(target)), + ...element.points.slice(1, -1), + boundEndpointToward(target, elementCenter(source)), + ] as [CanvasPoint, ...CanvasPoint[]], + }; + } + if (element.type === "line") { + const startTarget = element.startBinding + ? boundableElementsById.get(element.startBinding.elementId) + : undefined; + const endTarget = element.endBinding + ? boundableElementsById.get(element.endBinding.elementId) + : undefined; + if (!startTarget && !endTarget) return element; + const points: [CanvasPoint, CanvasPoint, ...CanvasPoint[]] = [ + element.points[0], + element.points[1], + ...element.points.slice(2), + ]; + if (startTarget) { + points[0] = boundEndpointToward( + startTarget, + endTarget + ? elementCenter(endTarget) + : (points[points.length - 1] ?? points[0]), + ); + } + if (endTarget) { + points[points.length - 1] = boundEndpointToward( + endTarget, + startTarget ? elementCenter(startTarget) : points[0], + ); + } + return { ...element, points }; + } + return element; + }); + const elementsById = new Map( + synchronizedConnectors.map((element) => [element.id, element]), + ); + return synchronizedConnectors.map((element) => { + if (element.type !== "text" || !element.containerId) return element; + const container = elementsById.get(element.containerId); + if (container?.type === "node") { + const center = elementCenter(container); + return { ...element, x: center.x, y: center.y }; + } + if (container?.type === "arrow") { + const start = container.points[0]; + const end = container.points[container.points.length - 1] ?? start; + return { + ...element, + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2, + }; + } + return element; + }); +} + +function computedCanvasBounds(elements: readonly CanvasElement[]): { + readonly height: number; + readonly width: number; +} { + const positioned = elements.filter(isPositioned).map(elementBounds); + const pointElements = elements.filter( + (element): element is CanvasConnectorElement | CanvasLineElement => + element.type === "arrow" || element.type === "line", + ); + const maxX = Math.max( + 1, + ...positioned.map((bounds) => bounds.x + bounds.width), + ...pointElements.flatMap((element) => + element.points.map((point) => point.x), + ), + ); + const maxY = Math.max( + 1, + ...positioned.map((bounds) => bounds.y + bounds.height), + ...pointElements.flatMap((element) => + element.points.map((point) => point.y), + ), + ); + return { width: maxX + 48, height: maxY + 48 }; +} + +/** Resolve layout primitives and bindings into a deterministic canonical scene. */ +export function compileCanvasSpec(canvas: CanvasSpec): CanvasSpec { + let elements = [...canvas.elements]; + for (const layout of canvas.layouts) { + elements = compileLayout(elements, layout); + } + elements = synchronizeBindings(elements); + const bounds = computedCanvasBounds(elements); + const orderedIds = canvas.zOrder.length + ? [...canvas.zOrder] + : elements + .map((element, index) => ({ element, index })) + .sort( + (left, right) => + (left.element.zIndex ?? left.index) - + (right.element.zIndex ?? right.index), + ) + .map(({ element }) => element.id); + return { + ...canvas, + width: Math.max(canvas.width, bounds.width), + height: Math.max(canvas.height, bounds.height), + elements, + zOrder: orderedIds, + }; +} diff --git a/packages/diagram/core/src/index.ts b/packages/diagram/core/src/index.ts index 7fb11aac..2f2dd41d 100644 --- a/packages/diagram/core/src/index.ts +++ b/packages/diagram/core/src/index.ts @@ -1,5 +1,6 @@ export * from "./types.js"; export * from "./fixtures.js"; export * from "./intermediate.js"; +export * from "./canvas.js"; export * from "./types/mindmap.js"; export * from "./types/flowchart.js"; diff --git a/packages/diagram/excalidraw/src/lib/convert.test.ts b/packages/diagram/excalidraw/src/lib/convert.test.ts index 02cd256f..e6a4eee6 100644 --- a/packages/diagram/excalidraw/src/lib/convert.test.ts +++ b/packages/diagram/excalidraw/src/lib/convert.test.ts @@ -7,6 +7,7 @@ import { mindmapFixture, pharmaBatchDispositionFlowchart, parseFlowchartDiagram, + type CanvasSpec, } from "@sketchi/diagram-core"; import { renderIntermediateDiagram, @@ -131,6 +132,421 @@ function expectFlowchartExportValid( } describe("convertSceneToExcalidraw", () => { + it("renders a node label when no explicit bound text is present", () => { + const scene: CanvasSpec = { + kind: "canvas", + version: 1, + diagramId: "automatic-label", + title: "Automatic label", + width: 320, + height: 200, + accentColor: "#2563eb", + backgroundColor: "#ffffff", + layers: [], + layouts: [], + elements: [ + { + type: "node", + id: "service", + nodeId: "service", + shape: "rectangle", + x: 40, + y: 60, + width: 180, + height: 80, + label: "API service", + }, + ], + zOrder: ["service"], + }; + + const converted = convertSceneToExcalidraw(scene); + const label = converted.elements.find( + (element) => element.id === "__sketchi_node_label__service", + ); + + expect(label).toMatchObject({ + type: "text", + text: "API service", + containerId: "service", + }); + expect(converted.elements.map((element) => element.type)).toEqual([ + "rectangle", + "text", + ]); + expect(validateExcalidrawScene(converted)).toEqual({ + ok: true, + issues: [], + }); + }); + + it("converts CanvasSpec polygons, frames, standalone text, bound lines, and z-order", () => { + const canvas: CanvasSpec = { + kind: "canvas", + version: 1, + diagramId: "converter-canvas", + title: "Converter canvas", + width: 600, + height: 400, + accentColor: "#111827", + backgroundColor: "#ffffff", + layers: [{ id: "content" }], + layouts: [], + elements: [ + { + type: "frame", + id: "frame", + name: "Group", + x: 20, + y: 20, + width: 500, + height: 300, + }, + { + type: "node", + id: "polygon", + nodeId: "polygon-node", + shape: "polygon", + points: [ + { x: 0, y: 50 }, + { x: 50, y: 0 }, + { x: 100, y: 50 }, + { x: 50, y: 100 }, + ], + x: 80, + y: 100, + width: 100, + height: 100, + label: "Polygon", + frameId: "frame", + groupIds: ["group"], + }, + { + type: "line", + id: "bound-line", + points: [ + { x: 180, y: 150 }, + { x: 520, y: 150 }, + ], + startBinding: { elementId: "polygon" }, + endBinding: { elementId: "frame" }, + endArrowhead: "triangle", + strokeStyle: "dotted", + }, + { + type: "text", + id: "note", + text: "Standalone", + x: 280, + y: 260, + fontSize: 22, + fontFamily: "mono", + textAlign: "left", + opacity: 80, + }, + ], + zOrder: ["frame", "polygon", "bound-line", "note"], + }; + + const exported = convertSceneToExcalidraw(canvas); + + expect(exported.elements.map((element) => element.id)).toEqual([ + "frame", + "polygon", + "__sketchi_node_label__polygon", + "bound-line", + "note", + ]); + expect( + exported.elements.find((element) => element.id === "polygon"), + ).toMatchObject({ + type: "line", + frameId: "frame", + groupIds: ["group"], + customData: { sketchiShape: "polygon" }, + }); + expect( + exported.elements.find((element) => element.id === "bound-line"), + ).toMatchObject({ + type: "arrow", + endArrowhead: "triangle", + strokeStyle: "dotted", + startBinding: { elementId: "polygon" }, + endBinding: { elementId: "frame" }, + }); + expect( + exported.elements.find((element) => element.id === "note"), + ).toMatchObject({ + type: "text", + containerId: null, + fontFamily: 3, + textAlign: "left", + opacity: 80, + }); + expect(validateExcalidrawScene(exported)).toEqual({ ok: true, issues: [] }); + }); + + it("preserves standalone arrows and bound polylines as valid exports", () => { + const canvas: CanvasSpec = { + kind: "canvas", + version: 1, + diagramId: "line-arrows", + title: "Line arrows", + width: 480, + height: 300, + accentColor: "#111827", + backgroundColor: "#ffffff", + layers: [], + layouts: [], + elements: [ + { + type: "node", + id: "left", + nodeId: "left", + shape: "rectangle", + x: 20, + y: 40, + width: 100, + height: 80, + label: "Left", + }, + { + type: "node", + id: "right", + nodeId: "right", + shape: "rectangle", + x: 340, + y: 40, + width: 100, + height: 80, + label: "Right", + }, + { + type: "line", + id: "standalone", + points: [ + { x: 40, y: 220 }, + { x: 200, y: 220 }, + ], + endArrowhead: "arrow", + }, + { + type: "line", + id: "partially-bound", + points: [ + { x: 120, y: 100 }, + { x: 240, y: 160 }, + ], + startBinding: { elementId: "left" }, + endArrowhead: "triangle", + }, + { + type: "line", + id: "bound-polyline", + points: [ + { x: 120, y: 80 }, + { x: 230, y: 80 }, + { x: 230, y: 100 }, + { x: 340, y: 100 }, + ], + startBinding: { elementId: "left" }, + endBinding: { elementId: "right" }, + endArrowhead: "arrow", + }, + ], + zOrder: [ + "left", + "right", + "standalone", + "partially-bound", + "bound-polyline", + ], + }; + + const exported = convertSceneToExcalidraw(canvas); + + expect( + exported.elements.find((element) => element.id === "standalone"), + ).toMatchObject({ + type: "arrow", + startBinding: null, + endBinding: null, + }); + expect( + exported.elements.find((element) => element.id === "partially-bound"), + ).toMatchObject({ + type: "arrow", + startBinding: { elementId: "left" }, + endBinding: null, + }); + expect( + exported.elements.find((element) => element.id === "bound-polyline"), + ).toMatchObject({ + type: "arrow", + elbowed: true, + fixedSegments: [], + roundness: null, + }); + expect(validateExcalidrawScene(exported)).toEqual({ ok: true, issues: [] }); + }); + + it("omits hidden layers and locks every exported element on locked layers", () => { + const canvas: CanvasSpec = { + kind: "canvas", + version: 1, + diagramId: "layer-semantics", + title: "Layer semantics", + width: 480, + height: 300, + accentColor: "#111827", + backgroundColor: "#ffffff", + layers: [ + { id: "hidden", visible: false }, + { id: "locked", locked: true }, + ], + layouts: [], + elements: [ + { + type: "node", + id: "hidden-node", + nodeId: "hidden-node", + shape: "rectangle", + x: 20, + y: 20, + width: 100, + height: 60, + label: "Hidden", + layerId: "hidden", + }, + { + type: "node", + id: "locked-node", + nodeId: "locked-node", + shape: "rectangle", + x: 180, + y: 20, + width: 120, + height: 60, + label: "Locked", + layerId: "locked", + locked: false, + }, + { + type: "line", + id: "locked-arrow", + points: [ + { x: 180, y: 160 }, + { x: 320, y: 160 }, + ], + endArrowhead: "arrow", + label: "Locked label", + layerId: "locked", + }, + ], + zOrder: ["hidden-node", "locked-node", "locked-arrow"], + }; + + const exported = convertSceneToExcalidraw(canvas); + + expect(exported.elements.map((element) => element.id)).not.toContain( + "hidden-node", + ); + expect(exported.elements.map((element) => element.id)).not.toContain( + "__sketchi_node_label__hidden-node", + ); + expect(exported.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "locked-node", locked: true }), + expect.objectContaining({ + id: "__sketchi_node_label__locked-node", + locked: true, + }), + expect.objectContaining({ id: "locked-arrow", locked: true }), + expect.objectContaining({ id: "locked-arrow:label", locked: true }), + ]), + ); + expect(validateExcalidrawScene(exported)).toEqual({ ok: true, issues: [] }); + }); + + it("uses explicit arrow-bound text instead of a derived connector label", () => { + const canvas: CanvasSpec = { + kind: "canvas", + version: 1, + diagramId: "bound-arrow-text", + title: "Bound arrow text", + width: 400, + height: 200, + accentColor: "#111827", + backgroundColor: "#ffffff", + layers: [], + layouts: [], + elements: [ + { + type: "node", + id: "source", + nodeId: "source", + shape: "rectangle", + x: 20, + y: 60, + width: 100, + height: 60, + label: "Source", + }, + { + type: "node", + id: "target", + nodeId: "target", + shape: "rectangle", + x: 280, + y: 60, + width: 100, + height: 60, + label: "Target", + }, + { + type: "arrow", + id: "request", + edgeId: "request", + sourceNodeId: "source", + targetNodeId: "target", + points: [ + { x: 120, y: 90 }, + { x: 280, y: 90 }, + ], + label: "derived fallback", + }, + { + type: "text", + id: "request-label", + containerId: "request", + x: 200, + y: 90, + text: "POST /events", + fontSize: 14, + }, + ], + zOrder: ["source", "target", "request", "request-label"], + }; + + const exported = convertSceneToExcalidraw(canvas); + expect( + exported.elements.find((element) => element.id === "request"), + ).toMatchObject({ + boundElements: [{ id: "request-label", type: "text" }], + }); + expect( + exported.elements.find((element) => element.id === "request-label"), + ).toMatchObject({ + type: "text", + containerId: "request", + text: "POST /events", + }); + expect( + exported.elements.some((element) => element.id === "request:label"), + ).toBe(false); + expect(validateExcalidrawScene(exported)).toEqual({ ok: true, issues: [] }); + }); + it("exports the horizontal mindmap fixture with valid arrow bindings", () => { const exported = convertSceneToExcalidraw( renderIntermediateDiagram(mindmapFixture), @@ -1137,6 +1553,8 @@ describe("convertSceneToExcalidraw", () => { it("reports routes through ordinary nodes claiming lifeline identity", () => { const scene = convertSceneToExcalidraw({ + kind: "canvas", + version: 1, diagramId: "through-node-route", title: "Through-node route", width: 420, @@ -1190,6 +1608,14 @@ describe("convertSceneToExcalidraw", () => { label: "End", }, ], + layers: [], + layouts: [], + zOrder: [ + "edge:start-end", + "node:start", + "node:middle:lifeline", + "node:end", + ], }); const validation = validateExcalidrawScene(scene); diff --git a/packages/diagram/excalidraw/src/lib/convert.ts b/packages/diagram/excalidraw/src/lib/convert.ts index 45c75bf7..3be8b98f 100644 --- a/packages/diagram/excalidraw/src/lib/convert.ts +++ b/packages/diagram/excalidraw/src/lib/convert.ts @@ -1,8 +1,11 @@ import { SEQUENCE_LIFELINE_ROLE, type ArrowSceneElement, + type FrameSceneElement, + type LineSceneElement, type NodeSceneElement, type RenderedDiagramScene, + type SceneElement, type TextSceneElement, } from "@sketchi/diagram-renderer"; import { SKETCHI_DIAGRAM_PALETTE } from "@sketchi/diagram-core"; @@ -147,23 +150,34 @@ function stableSeed(input: string): number { return Math.abs(hash) || 1; } -function elementBase(id: string, index: string) { +function elementBase( + id: string, + index: string, + element?: RenderedDiagramScene["elements"][number], +) { const seed = stableSeed(id); return { id, angle: 0, - fillStyle: "solid", - frameId: null, - groupIds: [], + fillStyle: + element && "fillStyle" in element + ? (element.fillStyle ?? "solid") + : "solid", + frameId: element?.frameId ?? null, + groupIds: [...(element?.groupIds ?? [])], index, isDeleted: false, link: null, - locked: false, - opacity: 100, - roughness: 1, + locked: element?.locked ?? false, + opacity: element?.opacity ?? 100, + roughness: element && "roughness" in element ? (element.roughness ?? 1) : 1, seed, - strokeStyle: "solid", - strokeWidth: 2, + strokeStyle: + element && "strokeStyle" in element + ? (element.strokeStyle ?? "solid") + : "solid", + strokeWidth: + element && "strokeWidth" in element ? (element.strokeWidth ?? 2) : 2, updated: 1, version: 1, versionNonce: seed + 1, @@ -183,10 +197,12 @@ function textHeight(text: string, fontSize: number): number { } function textElement(input: { - containerId: string; + containerId?: string; + element?: TextSceneElement; fontSize: number; id: string; index: string; + locked?: boolean; maxWidth: number; textColor?: string; text: string; @@ -200,7 +216,8 @@ function textElement(input: { const height = textHeight(input.text, input.fontSize); return { - ...elementBase(input.id, input.index), + ...elementBase(input.id, input.index, input.element), + ...(input.locked === undefined ? {} : { locked: input.locked }), type: "text", x: input.x - width / 2, y: input.y - height / 2, @@ -208,16 +225,21 @@ function textElement(input: { height, backgroundColor: "transparent", boundElements: null, - containerId: input.containerId, - fontFamily: 5, + containerId: input.containerId ?? null, + fontFamily: + input.element?.fontFamily === "mono" + ? 3 + : input.element?.fontFamily === "sans" + ? 2 + : 5, fontSize: input.fontSize, lineHeight: TEXT_LINE_HEIGHT, originalText: input.text, roundness: null, strokeColor: input.textColor ?? DEFAULT_TEXT_COLOR, text: input.text, - textAlign: "center", - verticalAlign: "middle", + textAlign: input.element?.textAlign ?? "center", + verticalAlign: input.element?.verticalAlign ?? "middle", autoResize: true, }; } @@ -236,12 +258,42 @@ function shapeElement(input: { const shapeType = input.shape.shape === "circle" ? "ellipse" : input.shape.shape; const boundElements = [ - ...(input.text ? [{ id: input.text.id, type: "text" }] : []), + ...(input.text && input.shape.shape !== "polygon" + ? [{ id: input.text.id, type: "text" }] + : []), ...input.arrowIds.map((id) => ({ id, type: "arrow" })), ]; + if (input.shape.shape === "polygon") { + const points = input.shape.points ?? [ + { x: input.shape.width / 2, y: 0 }, + { x: input.shape.width, y: input.shape.height }, + { x: 0, y: input.shape.height }, + ]; + const [first, ...rest] = points; + return { + ...elementBase(input.shape.id, input.index, input.shape), + type: "line", + x: input.shape.x, + y: input.shape.y, + width: input.shape.width, + height: input.shape.height, + backgroundColor: input.shape.fillColor ?? "transparent", + boundElements: input.arrowIds.map((id) => ({ id, type: "arrow" })), + customData: { sketchiShape: "polygon" }, + endArrowhead: null, + endBinding: null, + lastCommittedPoint: null, + points: [...points, first].map((point) => [point.x, point.y]), + roundness: null, + startArrowhead: null, + startBinding: null, + strokeColor: input.shape.strokeColor ?? input.scene.accentColor, + }; + } + return { - ...elementBase(input.shape.id, input.index), + ...elementBase(input.shape.id, input.index, input.shape), type: shapeType, x: input.shape.x, y: input.shape.y, @@ -267,24 +319,30 @@ function arrowElement(input: { scene: RenderedDiagramScene; sourceShape: ExcalidrawElement | undefined; targetShape: ExcalidrawElement | undefined; + text?: TextSceneElement; }): ExcalidrawElement { const start = input.arrow.points[0]; const end = lastArrowPoint(input.arrow); const elbowed = input.arrow.points.length > 2; return { - ...elementBase(input.arrow.id, input.index), + ...elementBase(input.arrow.id, input.index, input.arrow), type: "arrow", x: start.x, y: start.y, width: end.x - start.x, height: end.y - start.y, backgroundColor: "transparent", - boundElements: input.arrow.label - ? [{ id: `${input.arrow.id}:label`, type: "text" }] - : null, + boundElements: input.text + ? [{ id: input.text.id, type: "text" }] + : input.arrow.label + ? [{ id: `${input.arrow.id}:label`, type: "text" }] + : null, elbowed, - endArrowhead: "arrow", + endArrowhead: + input.arrow.endArrowhead === undefined + ? "arrow" + : input.arrow.endArrowhead, endBinding: bindingForShape(input.targetShape, end), ...(elbowed ? { @@ -298,13 +356,102 @@ function arrowElement(input: { point.y - start.y, ]), roundness: elbowed ? null : { type: 2 }, - startArrowhead: null, + startArrowhead: input.arrow.startArrowhead ?? null, startBinding: bindingForShape(input.sourceShape, start), strokeColor: input.arrow.strokeColor ?? input.scene.accentColor, strokeStyle: input.arrow.strokeStyle ?? "solid", }; } +function bindingForLine( + binding: LineSceneElement["startBinding"], + elementsById: ReadonlyMap, + point: { x: number; y: number }, +) { + if (!binding) return null; + const shape = elementsById.get(binding.elementId); + if (!shape) return null; + return { + elementId: binding.elementId, + focus: binding.focus ?? 0, + gap: binding.gap ?? 0, + fixedPoint: fixedPointForShape(shape, point), + }; +} + +function lineElement(input: { + element: LineSceneElement; + elementsById: ReadonlyMap; + index: string; + scene: RenderedDiagramScene; +}): ExcalidrawElement { + const [start, ...rest] = input.element.points; + const end = rest[rest.length - 1] ?? start; + const hasArrow = + input.element.startArrowhead !== undefined || + input.element.endArrowhead !== undefined || + input.element.startBinding !== undefined || + input.element.endBinding !== undefined; + const elbowed = hasArrow && input.element.points.length > 2; + return { + ...elementBase(input.element.id, input.index, input.element), + type: hasArrow ? "arrow" : "line", + x: start.x, + y: start.y, + width: end.x - start.x, + height: end.y - start.y, + backgroundColor: input.element.fillColor ?? "transparent", + boundElements: null, + ...(hasArrow ? { elbowed } : {}), + endArrowhead: input.element.endArrowhead ?? null, + endBinding: bindingForLine( + input.element.endBinding, + input.elementsById, + end, + ), + ...(elbowed + ? { + fixedSegments: [], + startIsSpecial: null, + endIsSpecial: null, + } + : {}), + points: input.element.points.map((point) => [ + point.x - start.x, + point.y - start.y, + ]), + roundness: + input.element.points.length > 2 && !elbowed ? { type: 2 } : null, + startArrowhead: input.element.startArrowhead ?? null, + startBinding: bindingForLine( + input.element.startBinding, + input.elementsById, + start, + ), + strokeColor: input.element.strokeColor ?? input.scene.accentColor, + }; +} + +function frameElement(input: { + element: FrameSceneElement; + index: string; + scene: RenderedDiagramScene; +}): ExcalidrawElement { + return { + ...elementBase(input.element.id, input.index, input.element), + type: "frame", + x: input.element.x, + y: input.element.y, + width: input.element.width, + height: input.element.height, + backgroundColor: input.element.fillColor ?? "transparent", + boundElements: null, + name: input.element.name ?? null, + roundness: null, + strokeColor: input.element.strokeColor ?? input.scene.accentColor, + }; +} + function arrowLabelElement(input: { arrow: ArrowSceneElement; index: string; @@ -320,6 +467,9 @@ function arrowLabelElement(input: { index: input.index, containerId: input.arrow.id, fontSize: 13, + ...(input.arrow.locked === undefined + ? {} + : { locked: input.arrow.locked }), maxWidth: ARROW_LABEL_WIDTH, ...(input.arrow.textColor ? { textColor: input.arrow.textColor } : {}), text: input.arrow.label, @@ -328,17 +478,32 @@ function arrowLabelElement(input: { }); } -function collectArrowsByNode( +function collectBoundArrowsByElement( + nodes: readonly NodeSceneElement[], arrows: readonly ArrowSceneElement[], + lines: readonly LineSceneElement[], ): Map { const result = new Map(); + const elementIdByNodeId = new Map( + nodes.map((node) => [node.nodeId, node.id]), + ); for (const arrow of arrows) { for (const nodeId of [arrow.sourceNodeId, arrow.targetNodeId]) { - const shapeId = `node:${nodeId}`; + const shapeId = elementIdByNodeId.get(nodeId); + if (!shapeId) continue; result.set(shapeId, [...(result.get(shapeId) ?? []), arrow.id]); } } + for (const line of lines) { + for (const binding of [line.startBinding, line.endBinding]) { + if (!binding) continue; + result.set(binding.elementId, [ + ...(result.get(binding.elementId) ?? []), + line.id, + ]); + } + } return result; } @@ -361,17 +526,114 @@ function isArrow( return element.type === "arrow"; } +function isLine( + element: RenderedDiagramScene["elements"][number], +): element is LineSceneElement { + return element.type === "line"; +} + +function isFrame( + element: RenderedDiagramScene["elements"][number], +): element is FrameSceneElement { + return element.type === "frame"; +} + +function applyLayerSemantics(scene: RenderedDiagramScene): SceneElement[] { + const layersById = new Map(scene.layers.map((layer) => [layer.id, layer])); + const layerVisibleElements = scene.elements.filter((element) => { + const layer = element.layerId ? layersById.get(element.layerId) : undefined; + return layer?.visible !== false; + }); + const visibleNodeIds = new Set( + layerVisibleElements.flatMap((element) => + element.type === "node" ? [element.nodeId] : [], + ), + ); + const connectionSafeElements = layerVisibleElements.filter( + (element) => + element.type !== "arrow" || + (visibleNodeIds.has(element.sourceNodeId) && + visibleNodeIds.has(element.targetNodeId)), + ); + const visibleContainerIds = new Set( + connectionSafeElements.flatMap((element) => + element.type === "text" ? [] : [element.id], + ), + ); + const visibleElements = connectionSafeElements.filter( + (element) => + element.type !== "text" || + !element.containerId || + visibleContainerIds.has(element.containerId), + ); + const visibleElementIds = new Set( + visibleElements.map((element) => element.id), + ); + + return visibleElements.map((element) => { + const layer = element.layerId ? layersById.get(element.layerId) : undefined; + return { + ...element, + ...(element.frameId && !visibleElementIds.has(element.frameId) + ? { frameId: undefined } + : {}), + ...(layer?.locked === true ? { locked: true } : {}), + }; + }); +} + export function convertSceneToExcalidraw( scene: RenderedDiagramScene, ): ExcalidrawScene { - const nodes = scene.elements.filter(isNode); + const sourceElements = applyLayerSemantics(scene); + const nodes = sourceElements.filter(isNode); + const textElements = sourceElements.filter(isText); + const usedElementIds = new Set(sourceElements.map((element) => element.id)); + const generatedLabelSourceIds = new Map(); + const explicitlyLabeledNodeIds = new Set( + textElements.flatMap((element) => + element.containerId ? [element.containerId] : [], + ), + ); + for (const node of nodes) { + if ( + explicitlyLabeledNodeIds.has(node.id) || + node.rendererRole === SEQUENCE_LIFELINE_ROLE + ) { + continue; + } + const baseId = `__sketchi_node_label__${node.id}`; + let id = baseId; + let suffix = 2; + while (usedElementIds.has(id)) { + id = `${baseId}:${suffix}`; + suffix += 1; + } + usedElementIds.add(id); + generatedLabelSourceIds.set(id, node.id); + textElements.push({ + type: "text", + id, + containerId: node.id, + ...(node.frameId ? { frameId: node.frameId } : {}), + ...(node.groupIds ? { groupIds: [...node.groupIds] } : {}), + ...(node.layerId ? { layerId: node.layerId } : {}), + ...(node.locked !== undefined ? { locked: node.locked } : {}), + ...(node.opacity !== undefined ? { opacity: node.opacity } : {}), + ...(node.textColor ? { textColor: node.textColor } : {}), + x: node.x + node.width / 2, + y: node.y + node.height / 2, + text: node.label, + fontSize: 16, + maxWidth: Math.max(1, node.width - TEXT_HORIZONTAL_PADDING), + }); + } const textByContainerId = new Map( - scene.elements - .filter(isText) - .map((element) => [element.containerId ?? "", element]), + textElements.map((element) => [element.containerId ?? "", element]), ); - const arrows = scene.elements.filter(isArrow); - const arrowsByNode = collectArrowsByNode(arrows); + const arrows = sourceElements.filter(isArrow); + const lines = sourceElements.filter(isLine); + const arrowsByElement = collectBoundArrowsByElement(nodes, arrows, lines); const shapeElementsByNodeId = new Map(); const elements: ExcalidrawElement[] = []; let previousIndex: string | null = null; @@ -386,7 +648,7 @@ export function convertSceneToExcalidraw( const shape = shapeElement({ scene, shape: node, - arrowIds: arrowsByNode.get(node.id) ?? [], + arrowIds: arrowsByElement.get(node.id) ?? [], index: nextIndex(), ...(text ? { text } : {}), }); @@ -395,15 +657,32 @@ export function convertSceneToExcalidraw( elements.push(shape); } - for (const text of scene.elements.filter(isText)) { - if (!text.containerId) { - continue; - } + for (const frame of sourceElements.filter(isFrame)) { + const renderedFrame = frameElement({ + element: frame, + index: nextIndex(), + scene, + }); + const arrowIds = arrowsByElement.get(frame.id) ?? []; + renderedFrame.boundElements = arrowIds.length + ? arrowIds.map((id) => ({ id, type: "arrow" })) + : null; + elements.push(renderedFrame); + } + + for (const text of textElements) { + const supportedContainer = + text.containerId && + (nodes.some( + (node) => node.id === text.containerId && node.shape !== "polygon", + ) || + arrows.some((arrow) => arrow.id === text.containerId)); elements.push( textElement({ + element: text, id: text.id, index: nextIndex(), - containerId: text.containerId, + ...(supportedContainer ? { containerId: text.containerId } : {}), fontSize: text.fontSize, maxWidth: text.maxWidth ?? 160, ...(text.textColor ? { textColor: text.textColor } : {}), @@ -415,6 +694,7 @@ export function convertSceneToExcalidraw( } for (const arrow of arrows) { + const text = textByContainerId.get(arrow.id); elements.push( arrowElement({ arrow, @@ -422,16 +702,70 @@ export function convertSceneToExcalidraw( index: nextIndex(), sourceShape: shapeElementsByNodeId.get(arrow.sourceNodeId), targetShape: shapeElementsByNodeId.get(arrow.targetNodeId), + ...(text ? { text } : {}), }), ); - const label = arrow.label - ? arrowLabelElement({ arrow, index: nextIndex() }) - : null; + const label = + arrow.label && !text + ? arrowLabelElement({ arrow, index: nextIndex() }) + : null; if (label) { elements.push(label); } } + const excalidrawElementsById = new Map( + elements.map((element) => [element.id, element]), + ); + for (const line of lines) { + elements.push( + lineElement({ + element: line, + elementsById: excalidrawElementsById, + index: nextIndex(), + scene, + }), + ); + if (line.label) { + const start = line.points[0]; + const end = line.points[line.points.length - 1] ?? start; + elements.push( + textElement({ + id: `${line.id}:label`, + index: nextIndex(), + fontSize: 13, + ...(line.locked === undefined ? {} : { locked: line.locked }), + maxWidth: ARROW_LABEL_WIDTH, + ...(line.textColor ? { textColor: line.textColor } : {}), + text: line.label, + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2 - 10, + }), + ); + } + } + + const zOrder = new Map(scene.zOrder.map((id, index) => [id, index])); + const sourceIdForElement = (element: ExcalidrawElement): string => { + if (zOrder.has(element.id)) return element.id; + const generatedLabelSourceId = generatedLabelSourceIds.get(element.id); + if (generatedLabelSourceId) return generatedLabelSourceId; + return element.id.endsWith(":label") + ? element.id.slice(0, -":label".length) + : element.id; + }; + elements.sort((left, right) => { + const leftOrder = + zOrder.get(sourceIdForElement(left)) ?? Number.MAX_SAFE_INTEGER; + const rightOrder = + zOrder.get(sourceIdForElement(right)) ?? Number.MAX_SAFE_INTEGER; + return leftOrder - rightOrder; + }); + previousIndex = null; + for (const element of elements) { + element.index = nextIndex(); + } + return { appState: { viewBackgroundColor: scene.backgroundColor, @@ -711,13 +1045,24 @@ function segmentCrossesShapeInterior( ); } +function isBindableShape(element: ExcalidrawElement): boolean { + if (SHAPE_TYPES.has(element.type) || element.type === "frame") return true; + const customData = element.customData; + return ( + element.type === "line" && + customData !== null && + typeof customData === "object" && + (customData as Record)["sketchiShape"] === "polygon" + ); +} + function arrowSegmentsThroughShapes( elements: readonly ExcalidrawElement[], ): ExcalidrawSceneValidationIssue[] { const segments = elements .filter((element) => element.type === "arrow") .flatMap(arrowSegments); - const shapes = elements.filter((element) => SHAPE_TYPES.has(element.type)); + const shapes = elements.filter(isBindableShape); const issues: ExcalidrawSceneValidationIssue[] = []; const seen = new Set(); @@ -868,12 +1213,10 @@ export function validateExcalidrawScene( scene.elements.map((element) => [element.id, element]), ); const shapeIds = new Set( - scene.elements - .filter((element) => SHAPE_TYPES.has(element.type)) - .map((element) => element.id), + scene.elements.filter(isBindableShape).map((element) => element.id), ); - if (shapeIds.size === 0) { + if (scene.elements.length === 0) { issues.push({ code: "empty-scene", message: "Excalidraw scene must contain at least one shape.", @@ -906,6 +1249,9 @@ export function validateExcalidrawScene( } for (const bindingKey of ["startBinding", "endBinding"] as const) { + if (element[bindingKey] === null || element[bindingKey] === undefined) { + continue; + } const shapeId = bindingElementId(element, bindingKey); if (!(shapeId && shapeIds.has(shapeId))) { issues.push({ @@ -975,7 +1321,7 @@ export function validateExcalidrawScene( typeof container.height === "number" ? container.height : 0; if ( - SHAPE_TYPES.has(container.type) && + isBindableShape(container) && (textWidth + TEXT_HORIZONTAL_PADDING > containerWidth || textHeightValue + TEXT_VERTICAL_PADDING > containerHeight) ) { diff --git a/packages/diagram/renderer/src/scene.ts b/packages/diagram/renderer/src/scene.ts index a5554d94..6f8fe1c0 100644 --- a/packages/diagram/renderer/src/scene.ts +++ b/packages/diagram/renderer/src/scene.ts @@ -1,73 +1,29 @@ import { + CANVAS_SPEC_VERSION, + type CanvasConnectorElement, + type CanvasElement, + type CanvasFrameElement, + type CanvasLineElement, + type CanvasPoint, + type CanvasShapeElement, + type CanvasShapeKind, + type CanvasSpec, + type CanvasTextElement, type DiagramEdge, type DiagramNode, type IntermediateDiagram, parseIntermediateDiagram, } from "@sketchi/diagram-core"; -export type NodeSceneShape = "rectangle" | "ellipse" | "diamond" | "circle"; - -export type SceneElement = - | NodeSceneElement - | TextSceneElement - | ArrowSceneElement; - -export interface NodeSceneElement { - type: "node"; - id: string; - nodeId: string; - kind?: string; - rendererRole?: "sequence-lifeline"; - shape: NodeSceneShape; - fillColor?: string; - strokeColor?: string; - textColor?: string; - x: number; - y: number; - width: number; - height: number; - label: string; -} - -export interface TextSceneElement { - type: "text"; - id: string; - containerId?: string; - textColor?: string; - x: number; - y: number; - text: string; - fontSize: number; - maxWidth?: number; -} - -export interface ArrowSceneElement { - type: "arrow"; - id: string; - edgeId: string; - sourceNodeId: string; - targetNodeId: string; - strokeColor?: string; - strokeStyle?: "dashed" | "dotted" | "solid"; - textColor?: string; - points: readonly [ScenePoint, ...ScenePoint[]]; - label?: string; -} - -export interface ScenePoint { - x: number; - y: number; -} - -export interface RenderedDiagramScene { - diagramId: string; - title: string; - width: number; - height: number; - accentColor: string; - backgroundColor: string; - elements: SceneElement[]; -} +export type NodeSceneShape = CanvasShapeKind; +export type SceneElement = CanvasElement; +export type NodeSceneElement = CanvasShapeElement; +export type TextSceneElement = CanvasTextElement; +export type ArrowSceneElement = CanvasConnectorElement; +export type LineSceneElement = CanvasLineElement; +export type FrameSceneElement = CanvasFrameElement; +export type ScenePoint = CanvasPoint; +export type RenderedDiagramScene = CanvasSpec; const MIN_NODE_WIDTH = 184; const MIN_NODE_HEIGHT = 72; @@ -1627,21 +1583,25 @@ function sceneMinimum(elements: readonly SceneElement[]): ScenePoint { function scenePoints(elements: readonly SceneElement[]): ScenePoint[] { return elements.flatMap((element): ScenePoint[] => { - if (element.type === "arrow") { + if (element.type === "arrow" || element.type === "line") { return [...element.points]; } + if (element.type === "text") { + return [ + { x: element.x, y: element.y }, + { + x: element.x + (element.maxWidth ?? element.text.length), + y: element.y + element.fontSize, + }, + ]; + } + return [ { x: element.x, y: element.y }, { - x: - element.x + - (element.type === "node" - ? element.width - : (element.maxWidth ?? element.text.length)), - y: - element.y + - (element.type === "node" ? element.height : element.fontSize), + x: element.x + element.width, + y: element.y + element.height, }, ]; }); @@ -1663,6 +1623,19 @@ function translatePoints( ]; } +function translateLinePoints( + points: readonly [ScenePoint, ScenePoint, ...ScenePoint[]], + dx: number, + dy: number, +): [ScenePoint, ScenePoint, ...ScenePoint[]] { + const [first, second, ...rest] = points; + return [ + translatePoint(first, dx, dy), + translatePoint(second, dx, dy), + ...rest.map((point) => translatePoint(point, dx, dy)), + ]; +} + function translateElement( element: SceneElement, dx: number, @@ -1675,6 +1648,13 @@ function translateElement( }; } + if (element.type === "line") { + return { + ...element, + points: translateLinePoints(element.points, dx, dy), + }; + } + return { ...element, x: element.x + dx, @@ -1728,8 +1708,15 @@ export function renderIntermediateDiagram( ...labels, ]); const bounds = sceneBounds(elements); + const zOrder = [ + ...nodeShapes.map((element) => element.id), + ...labels.map((element) => element.id), + ...edgeArrows.map((element) => element.id), + ]; return { + kind: "canvas", + version: CANVAS_SPEC_VERSION, diagramId: diagram.id, title: diagram.title, width: bounds.width, @@ -1737,5 +1724,8 @@ export function renderIntermediateDiagram( accentColor: diagram.style.accentColor, backgroundColor: diagram.style.backgroundColor, elements, + layers: [], + layouts: [], + zOrder, }; } diff --git a/packages/diagram/renderer/src/sequence.ts b/packages/diagram/renderer/src/sequence.ts index cf94e2f7..68d7daab 100644 --- a/packages/diagram/renderer/src/sequence.ts +++ b/packages/diagram/renderer/src/sequence.ts @@ -1,3 +1,4 @@ +import { CANVAS_SPEC_VERSION } from "@sketchi/diagram-core"; import type { ArrowSceneElement, NodeSceneElement, @@ -62,7 +63,10 @@ interface SequenceLifelineStructureNode { interface SequenceLifelineStructureScene { readonly elements: readonly ( | SequenceLifelineStructureNode - | { readonly type: "arrow" | "text"; readonly id: string } + | { + readonly type: "arrow" | "frame" | "line" | "text"; + readonly id: string; + } )[]; } @@ -208,7 +212,22 @@ export function renderSequenceDiagram( }, ); + const elements = [ + ...messageArrows, + ...lifelines, + ...headers, + ...headerLabels, + ]; + const zOrder = [ + ...lifelines.map((element) => element.id), + ...headers.map((element) => element.id), + ...headerLabels.map((element) => element.id), + ...messageArrows.map((element) => element.id), + ]; + return { + kind: "canvas", + version: CANVAS_SPEC_VERSION, diagramId: input.id, title: input.title, width: @@ -218,6 +237,9 @@ export function renderSequenceDiagram( height: lifelineY + lifelineHeight + PADDING, accentColor: input.style.accentColor, backgroundColor: input.style.backgroundColor, - elements: [...messageArrows, ...lifelines, ...headers, ...headerLabels], + elements, + layers: [], + layouts: [], + zOrder, }; } diff --git a/scripts/lib/worker-apps.test.mjs b/scripts/lib/worker-apps.test.mjs index e07123f5..e111c8ea 100644 --- a/scripts/lib/worker-apps.test.mjs +++ b/scripts/lib/worker-apps.test.mjs @@ -30,6 +30,7 @@ const tanstackFullPathSnapshot = [ "/api/studio/projects/from-artifact", "/api/v1/artifacts/$artifactId", "/api/v1/artifacts/$artifactId/patch", + "/api/v1/canvases/create", "/api/v1/flowcharts/build", "/api/v1/generate", "/api/v1/mindmaps/build", diff --git a/tools/project-graph.test.ts b/tools/project-graph.test.ts index fe6a5e85..265ccfa4 100644 --- a/tools/project-graph.test.ts +++ b/tools/project-graph.test.ts @@ -149,6 +149,7 @@ const approvedManagedPromiseSiteCounts: Record = { "apps/playground/src/routes/api/studio/projects_/$projectId.ts": 5, "apps/playground/src/routes/api/v1/artifacts/$artifactId.ts": 6, "apps/playground/src/routes/api/v1/artifacts/$artifactId/patch.ts": 6, + "apps/playground/src/routes/api/v1/canvases/create.ts": 6, "apps/playground/src/routes/api/v1/flowcharts/build.ts": 6, "apps/playground/src/routes/api/v1/generate.ts": 6, "apps/playground/src/routes/api/v1/mindmaps/build.ts": 6, @@ -164,11 +165,11 @@ const approvedManagedPromiseSiteCounts: Record = { "apps/playground/src/server/ai/model.server.ts": 2, "apps/playground/src/server/bindings/studio-env.server.ts": 1, "apps/playground/src/server/chat/agent.server.ts": 6, - "apps/playground/src/server/codemode/api.server.ts": 27, + "apps/playground/src/server/codemode/api.server.ts": 31, "apps/playground/src/server/codemode/browser-renderer.server.ts": 22, "apps/playground/src/server/codemode/effect-mcp-adapter.server.ts": 7, "apps/playground/src/server/codemode/http-schema.server.ts": 3, - "apps/playground/src/server/codemode/mcp.server.ts": 26, + "apps/playground/src/server/codemode/mcp.server.ts": 28, "apps/playground/src/server/codemode/usage-events.server.ts": 8, "apps/playground/src/server/generation/api.server.ts": 7, "apps/playground/src/server/runtime/runtime.server.ts": 14,