pmx-canvas 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,7 @@ export declare function getCanvasNodeTitle(node: CanvasNodeState): string | null
35
35
  export declare function getCanvasNodeContent(node: CanvasNodeState): string | null;
36
36
  export declare function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode;
37
37
  export declare function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCanvasNode;
38
+ export declare function serializeCanvasNodeCompact(node: CanvasNodeState): SerializedCanvasNode;
38
39
  export declare function serializeCanvasNodeWithBlobSummaries(node: CanvasNodeState): SerializedCanvasNode;
39
40
  export declare function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLayout;
40
41
  export declare function serializeCanvasLayoutForAgent(layout: CanvasLayout): SerializedCanvasLayout;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
package/src/cli/agent.ts CHANGED
@@ -164,7 +164,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
164
164
  // Boolean-only flags (never take a value argument)
165
165
  const BOOL_FLAGS = new Set([
166
166
  'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run', 'all',
167
- 'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact', 'summary',
167
+ 'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact',
168
168
  'verbose', 'include-logs', 'no-pan', 'schema', 'example', 'examples', 'strict-size', 'scroll-overflow',
169
169
  ]);
170
170
  for (let i = 0; i < args.length; i++) {
@@ -1723,6 +1723,16 @@ cmd('external-app add', 'Create a hosted external app node', [
1723
1723
  : result);
1724
1724
  });
1725
1725
 
1726
+ cmd('diagram add', 'Create an Excalidraw diagram node', [
1727
+ 'pmx-canvas diagram add --title "Architecture"',
1728
+ 'pmx-canvas diagram add --title "Architecture" --elements \'[{"type":"rectangle","id":"r1","x":0,"y":0,"width":120,"height":80}]\'',
1729
+ ], async (args) => {
1730
+ const { flags } = parseFlags(args);
1731
+ if (flags.help || flags.h) return showCommandHelp('diagram add');
1732
+ const externalAppAdd = COMMANDS['external-app add'];
1733
+ await externalAppAdd.run([...args, '--kind', 'excalidraw']);
1734
+ });
1735
+
1726
1736
  // ── pin ──────────────────────────────────────────────────────
1727
1737
  cmd('pin', 'Manage context pins', [
1728
1738
  'pmx-canvas pin node1 node2 node3',
@@ -2431,11 +2441,19 @@ function showCommandHelp(name: string): void {
2431
2441
  if (name === 'node add') {
2432
2442
  console.log('\nSchema help:');
2433
2443
  console.log(' pmx-canvas node add --help --type webpage');
2444
+ console.log(' pmx-canvas node add --help --type html');
2434
2445
  console.log(' pmx-canvas node add --help --type json-render --component Table');
2435
2446
  console.log(' pmx-canvas node add --help --type graph');
2436
2447
  console.log(' pmx-canvas html primitive schema --summary');
2437
2448
  console.log(' pmx-canvas node add --help --type webpage --json');
2438
2449
  console.log(' Use --strict-size to keep explicit width/height fixed and scroll overflowing content.');
2450
+ console.log('\nHTML sidecar flags:');
2451
+ console.log(' --summary <text> Explicit human/agent-readable summary');
2452
+ console.log(' --agent-summary <text> Semantic summary for search, pinned context, and spatial context');
2453
+ console.log(' --description <text> Optional longer semantic description');
2454
+ console.log(' --presentation true Mark raw HTML as an explicit presentation deck');
2455
+ console.log(' --slide-title <text> Add a presentation slide title sidecar');
2456
+ console.log(' --embedded-node-id <id> Link represented/embedded canvas node ID');
2439
2457
  }
2440
2458
  if (name === 'html primitive add' || name === 'html primitive schema') {
2441
2459
  console.log('\nPrimitive flags:');
@@ -2534,6 +2552,10 @@ function showCommandHelp(name: string): void {
2534
2552
  console.log(' --initial-file <path> Alias for --elements-file');
2535
2553
  console.log(' --timeout-ms <number> Optional downstream MCP timeout for cold starts');
2536
2554
  }
2555
+ if (name === 'diagram add') {
2556
+ console.log('\nAlias:');
2557
+ console.log(' Equivalent to: pmx-canvas external-app add --kind excalidraw ...');
2558
+ }
2537
2559
  console.log('');
2538
2560
  }
2539
2561
 
@@ -2561,6 +2583,7 @@ Node commands:
2561
2583
  pmx-canvas graph add [options] Add a graph node
2562
2584
  pmx-canvas html primitive add Add an HTML communication primitive
2563
2585
  pmx-canvas html primitive schema List HTML primitive kinds and shapes
2586
+ pmx-canvas diagram add Add an Excalidraw diagram node
2564
2587
 
2565
2588
  Edge commands:
2566
2589
  pmx-canvas edge add [options] Add an edge between nodes
@@ -2636,6 +2659,7 @@ Examples:
2636
2659
  pmx-canvas graph add --graph-type bar --data-file ./metrics.json --x-key label --y-key value
2637
2660
  pmx-canvas html primitive add --kind choice-grid --data-file ./options.json --title "Options"
2638
2661
  pmx-canvas html primitive schema --summary
2662
+ pmx-canvas diagram add --title "Architecture"
2639
2663
  pmx-canvas node add --help --type webpage
2640
2664
  pmx-canvas node schema --type json-render
2641
2665
  pmx-canvas node schema --type json-render --component Table --summary
package/src/cli/index.ts CHANGED
@@ -32,7 +32,7 @@ if (args.includes('--version') || args.includes('-v')) {
32
32
  const AGENT_COMMANDS = new Set([
33
33
  'node', 'edge', 'json-render', 'search', 'layout', 'status', 'arrange', 'focus',
34
34
  'fit', 'screenshot', 'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
35
- 'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'graph', 'html', 'batch', 'validate', 'serve',
35
+ 'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'diagram', 'graph', 'html', 'batch', 'validate', 'serve',
36
36
  ]);
37
37
 
38
38
  const firstArg = args[0] ?? '';
@@ -506,6 +506,7 @@ Agent CLI (works against running server):
506
506
  watch [--json] [--events ...] Watch low-token semantic canvas changes
507
507
  focus <node-id> Pan to node
508
508
  external-app add Add hosted external apps like Excalidraw
509
+ diagram add Add an Excalidraw diagram node
509
510
  pin <ids...> | --list | --clear Manage context pins
510
511
  undo / redo / history Time travel
511
512
  snapshot save|list|restore|diff|delete
@@ -550,6 +551,7 @@ Examples:
550
551
  pmx-canvas node schema --type json-render Show running-server schema info
551
552
  pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx
552
553
  pmx-canvas external-app add --kind excalidraw --title "Diagram"
554
+ pmx-canvas diagram add --title "Diagram"
553
555
  pmx-canvas validate spec --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value
554
556
  pmx-canvas open Open the workbench in a browser
555
557
  pmx-canvas webview status Show WebView automation status
@@ -93,6 +93,10 @@ function isPresentationNavigationKey(key: string): boolean {
93
93
  return key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'ArrowLeft' || key === 'PageUp' || key === 'Home' || key === 'End';
94
94
  }
95
95
 
96
+ function isPresentationExitButtonTarget(target: EventTarget | null): boolean {
97
+ return target instanceof HTMLElement && Boolean(target.closest('.html-presentation-exit'));
98
+ }
99
+
96
100
  export function ExpandedNodeOverlay() {
97
101
  const nodeId = expandedNodeId.value;
98
102
  const node = nodeId ? nodes.value.get(nodeId) : undefined;
@@ -100,6 +104,7 @@ export function ExpandedNodeOverlay() {
100
104
  const [presenting, setPresenting] = useState(false);
101
105
  const [presentationExitToken, setPresentationExitToken] = useState('');
102
106
  const presentationOverlayRef = useRef<HTMLDivElement>(null);
107
+ const presentationExitButtonRef = useRef<HTMLButtonElement>(null);
103
108
 
104
109
  const handleClose = useCallback(() => {
105
110
  setPresenting(false);
@@ -131,6 +136,13 @@ export function ExpandedNodeOverlay() {
131
136
  setPresenting(false);
132
137
  return;
133
138
  }
139
+ if (event.key === 'Tab' && !isPresentationExitButtonTarget(event.target)) {
140
+ event.preventDefault();
141
+ event.stopPropagation();
142
+ presentationExitButtonRef.current?.focus();
143
+ return;
144
+ }
145
+ if ((event.key === ' ' || event.key === 'Enter') && isPresentationExitButtonTarget(event.target)) return;
134
146
  if (!isPresentationNavigationKey(event.key)) return;
135
147
  event.preventDefault();
136
148
  event.stopPropagation();
@@ -165,7 +177,9 @@ export function ExpandedNodeOverlay() {
165
177
  useLayoutEffect(() => {
166
178
  if (!presenting) return;
167
179
  const focusPresentationOverlay = () => {
168
- presentationOverlayRef.current?.focus();
180
+ const overlay = presentationOverlayRef.current;
181
+ if (!overlay || overlay.contains(document.activeElement)) return;
182
+ overlay.focus();
169
183
  };
170
184
  const focusTimers = [0, 50, 150].map((delay) => window.setTimeout(focusPresentationOverlay, delay));
171
185
  const handleMessage = (event: MessageEvent) => {
@@ -354,20 +368,16 @@ export function ExpandedNodeOverlay() {
354
368
  </div>
355
369
  {canPresent && presenting && (
356
370
  <div ref={presentationOverlayRef} class="html-presentation-overlay" role="dialog" aria-modal="true" aria-label={`Present ${title}`} tabIndex={-1} onKeyDownCapture={handlePresentationKeyDown}>
357
- <div class="html-presentation-toolbar">
358
- <div>
359
- <div class="html-presentation-kicker">HTML presentation</div>
360
- <div class="html-presentation-title">{title}</div>
361
- </div>
362
- <button
363
- type="button"
364
- class="html-presentation-exit"
365
- onClick={handleExitPresentation}
366
- title="Exit presentation (Esc)"
367
- >
368
- Exit presentation
369
- </button>
370
- </div>
371
+ <button
372
+ ref={presentationExitButtonRef}
373
+ type="button"
374
+ class="html-presentation-exit"
375
+ onClick={handleExitPresentation}
376
+ title="Exit presentation (Esc)"
377
+ aria-label="Exit presentation"
378
+ >
379
+ Exit presentation
380
+ </button>
371
381
  <div class="html-presentation-stage">
372
382
  <HtmlNode node={node} expanded presentation presentationExitToken={presentationExitToken} />
373
383
  </div>
@@ -255,7 +255,7 @@ export function HtmlNode({
255
255
  minHeight: presentation ? 0 : expanded ? '70vh' : '300px',
256
256
  border: 'none',
257
257
  background: 'var(--c-bg)',
258
- borderRadius: presentation ? '18px' : '6px',
258
+ borderRadius: presentation ? 0 : '6px',
259
259
  display: 'block',
260
260
  }}
261
261
  />
@@ -2472,75 +2472,58 @@ body,
2472
2472
  inset: 0;
2473
2473
  z-index: 10050;
2474
2474
  display: flex;
2475
- flex-direction: column;
2476
- gap: 14px;
2477
- padding: clamp(12px, 2vw, 28px);
2475
+ padding: 0;
2478
2476
  background:
2479
2477
  radial-gradient(circle at top left, var(--c-accent-25), transparent 36rem),
2480
2478
  rgba(3, 7, 18, 0.96);
2481
2479
  color: var(--c-text);
2482
2480
  }
2483
2481
 
2484
- .html-presentation-toolbar {
2485
- display: flex;
2486
- align-items: center;
2487
- justify-content: space-between;
2488
- gap: 16px;
2489
- flex-shrink: 0;
2490
- padding: 10px 12px;
2491
- border: 1px solid var(--c-line);
2492
- border-radius: 16px;
2493
- background: var(--c-panel-glass);
2494
- box-shadow: 0 18px 50px var(--c-shadow-heavy);
2495
- }
2496
-
2497
- .html-presentation-kicker {
2498
- color: var(--c-accent);
2499
- font-size: 10px;
2500
- font-weight: 800;
2501
- letter-spacing: 0.14em;
2502
- text-transform: uppercase;
2503
- }
2504
-
2505
- .html-presentation-title {
2506
- max-width: min(72vw, 900px);
2507
- overflow: hidden;
2508
- color: var(--c-text);
2509
- font-size: 14px;
2510
- font-weight: 700;
2511
- text-overflow: ellipsis;
2512
- white-space: nowrap;
2513
- }
2514
-
2515
2482
  .html-presentation-exit {
2516
- flex-shrink: 0;
2517
- padding: 8px 12px;
2483
+ position: fixed;
2484
+ top: 12px;
2485
+ right: 12px;
2486
+ z-index: 1;
2487
+ padding: 10px 14px;
2518
2488
  border: 1px solid var(--c-line);
2519
2489
  border-radius: 999px;
2520
- background: var(--c-panel-soft);
2490
+ background: var(--c-panel-glass);
2491
+ box-shadow: 0 18px 50px var(--c-shadow-heavy);
2521
2492
  color: var(--c-text-soft);
2522
2493
  cursor: pointer;
2523
2494
  font: 600 12px/1 var(--font);
2495
+ opacity: 0;
2496
+ pointer-events: none;
2497
+ transform: translateY(-6px);
2498
+ transition: opacity 0.15s ease, transform 0.15s ease, border-color 0.15s ease, color 0.15s ease;
2524
2499
  }
2525
2500
 
2526
- .html-presentation-exit:hover {
2501
+ .html-presentation-exit:hover,
2502
+ .html-presentation-exit:focus-visible {
2527
2503
  border-color: var(--c-accent);
2528
2504
  color: var(--c-text);
2529
2505
  }
2530
2506
 
2507
+ .html-presentation-exit:focus,
2508
+ .html-presentation-exit:focus-visible {
2509
+ opacity: 1;
2510
+ pointer-events: auto;
2511
+ transform: translateY(0);
2512
+ }
2513
+
2531
2514
  .html-presentation-stage {
2532
2515
  flex: 1;
2533
2516
  min-height: 0;
2534
2517
  display: flex;
2535
- border-radius: 22px;
2518
+ border-radius: 0;
2536
2519
  background: var(--c-bg);
2537
- box-shadow: 0 24px 90px rgba(0, 0, 0, 0.55);
2538
2520
  overflow: hidden;
2539
2521
  }
2540
2522
 
2541
2523
  .html-node-frame-presentation {
2542
2524
  flex: 1;
2543
2525
  min-height: 0;
2526
+ border-radius: 0 !important;
2544
2527
  }
2545
2528
 
2546
2529
  /* ── Context pin button on node title bar ────────────────────── */
@@ -19,7 +19,7 @@ import {
19
19
  import { mutationHistory } from './mutation-history.js';
20
20
  import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
21
21
  import { searchNodes } from './spatial-analysis.js';
22
- import { getCanvasNodeTitle, serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
22
+ import { getCanvasNodeTitle, serializeCanvasNodeCompact, type SerializedCanvasNode } from './canvas-serialization.js';
23
23
  import { computeAutoArrange } from '../shared/auto-arrange.js';
24
24
  import {
25
25
  buildGraphSpec,
@@ -1490,7 +1490,7 @@ function resolveBatchRefs(value: unknown, refs: Record<string, unknown>): unknow
1490
1490
  }
1491
1491
 
1492
1492
  function serializeCreatedNode(node: CanvasNodeState): SerializedCanvasNode {
1493
- return serializeCanvasNode(node);
1493
+ return serializeCanvasNodeCompact(node);
1494
1494
  }
1495
1495
 
1496
1496
  export async function executeCanvasBatch(
@@ -1527,10 +1527,15 @@ export async function executeCanvasBatch(
1527
1527
  throw new Error('Batch html-primitive creation is not supported yet. Use node.add with type "html" and generated html, or create the primitive through MCP/HTTP/CLI first.');
1528
1528
  }
1529
1529
  if (type === 'webpage') {
1530
+ const content = typeof args.url === 'string' && args.url.trim().length > 0
1531
+ ? args.url
1532
+ : typeof args.content === 'string'
1533
+ ? args.content
1534
+ : undefined;
1530
1535
  const created = addCanvasNode({
1531
1536
  type: 'webpage',
1532
1537
  ...(typeof args.title === 'string' ? { title: args.title } : {}),
1533
- ...(typeof args.content === 'string' ? { content: args.content } : {}),
1538
+ ...(content ? { content } : {}),
1534
1539
  ...(isPlainRecord(args.data) ? { data: args.data } : {}),
1535
1540
  ...(typeof args.x === 'number' ? { x: args.x } : {}),
1536
1541
  ...(typeof args.y === 'number' ? { y: args.y } : {}),
@@ -244,11 +244,11 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
244
244
  fields: [
245
245
  { name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
246
246
  { name: 'summary', type: 'string', required: false, description: 'Explicit agent-readable summary. If omitted, PMX derives one from visible HTML text.' },
247
- { name: 'agentSummary', type: 'string', required: false, description: 'Explicit semantic sidecar used by search, pinned context, and spatial context.' },
248
- { name: 'embeddedNodeIds', type: 'string[]', required: false, description: 'Canvas node IDs represented or iframe-embedded by this HTML surface.' },
249
- { name: 'embeddedUrls', type: 'string[]', required: false, description: 'URLs represented or iframe-embedded by this HTML surface.' },
247
+ { name: 'agentSummary', type: 'string', required: false, description: 'Explicit semantic sidecar used by search, pinned context, and spatial context.', aliases: ['agent-summary'] },
248
+ { name: 'embeddedNodeIds', type: 'string[]', required: false, description: 'Canvas node IDs represented or iframe-embedded by this HTML surface.', aliases: ['embedded-node-id'] },
249
+ { name: 'embeddedUrls', type: 'string[]', required: false, description: 'URLs represented or iframe-embedded by this HTML surface.', aliases: ['embedded-url'] },
250
250
  { name: 'presentation', type: 'boolean', required: false, description: 'Marks this HTML surface as a fullscreen presentation/deck.' },
251
- { name: 'slideTitles', type: 'string[]', required: false, description: 'Agent-readable slide titles for presentation HTML.' },
251
+ { name: 'slideTitles', type: 'string[]', required: false, description: 'Agent-readable slide titles for presentation HTML.', aliases: ['slide-title'] },
252
252
  { name: 'primitive', type: 'HtmlPrimitiveKind', required: false, description: 'Generate HTML from a built-in communication primitive instead of passing raw HTML.', aliases: ['kind'] },
253
253
  { name: 'data', type: 'record<string, unknown>', required: false, description: 'Primitive data when --primitive is used, or arbitrary node metadata.' },
254
254
  { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
@@ -56,6 +56,13 @@ interface ExternalMcpAppHtmlSummary {
56
56
  sha256: string;
57
57
  }
58
58
 
59
+ interface FileContentSummary {
60
+ omitted: 'file-content';
61
+ bytes: number;
62
+ lineCount: number;
63
+ sha256: string;
64
+ }
65
+
59
66
  function pickString(value: unknown): string | null {
60
67
  return typeof value === 'string' && value.length > 0 ? value : null;
61
68
  }
@@ -142,6 +149,25 @@ export function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCa
142
149
  };
143
150
  }
144
151
 
152
+ export function serializeCanvasNodeCompact(node: CanvasNodeState): SerializedCanvasNode {
153
+ const serialized = serializeCanvasNode(node);
154
+ if (serialized.type !== 'file' || typeof serialized.data.fileContent !== 'string') return serialized;
155
+ const fileContent = serialized.data.fileContent;
156
+ return {
157
+ ...serialized,
158
+ content: serialized.path,
159
+ data: {
160
+ ...serialized.data,
161
+ fileContent: {
162
+ omitted: 'file-content',
163
+ bytes: Buffer.byteLength(fileContent, 'utf-8'),
164
+ lineCount: fileContent.split('\n').length,
165
+ sha256: createHash('sha256').update(fileContent).digest('hex'),
166
+ } satisfies FileContentSummary,
167
+ },
168
+ };
169
+ }
170
+
145
171
  function summarizeBlobValue(value: unknown): unknown {
146
172
  if (!canvasState.isBlobReference(value)) return value;
147
173
  return {
@@ -511,14 +511,23 @@ const PRESENTATION_THEMES: Record<PresentationThemeName, PresentationThemeTokens
511
511
  },
512
512
  };
513
513
 
514
+ function isPresentationThemeName(value: string): value is PresentationThemeName {
515
+ return value === 'canvas' || value === 'midnight' || value === 'paper' || value === 'aurora';
516
+ }
517
+
518
+ function parsePresentationThemeName(value: string, field = 'theme'): PresentationThemeName {
519
+ if (isPresentationThemeName(value)) return value;
520
+ throw new Error(`Invalid presentation ${field} "${value}". Use canvas, midnight, paper, aurora, or a custom theme object.`);
521
+ }
522
+
514
523
  function presentationTheme(data: Record<string, unknown>): PresentationThemeTokens {
515
524
  const raw = data.theme ?? data.presentationTheme;
516
525
  if (typeof raw === 'string') {
517
- return PRESENTATION_THEMES[raw as PresentationThemeName] ?? PRESENTATION_THEMES.canvas;
526
+ return PRESENTATION_THEMES[parsePresentationThemeName(raw)];
518
527
  }
519
528
  if (!isRecord(raw)) return PRESENTATION_THEMES.canvas;
520
- const baseName = typeof raw.base === 'string' && raw.base in PRESENTATION_THEMES
521
- ? raw.base as PresentationThemeName
529
+ const baseName = typeof raw.base === 'string'
530
+ ? parsePresentationThemeName(raw.base, 'theme base')
522
531
  : 'canvas';
523
532
  const base = PRESENTATION_THEMES[baseName];
524
533
  const readColor = (key: string, fallback: string): string => {
@@ -544,8 +553,9 @@ function presentationTheme(data: Record<string, unknown>): PresentationThemeToke
544
553
 
545
554
  function presentationThemeMetadata(data: Record<string, unknown>): string | Record<string, string> | undefined {
546
555
  const raw = data.theme ?? data.presentationTheme;
547
- if (typeof raw === 'string') return raw;
556
+ if (typeof raw === 'string') return parsePresentationThemeName(raw);
548
557
  if (!isRecord(raw)) return undefined;
558
+ if (typeof raw.base === 'string') parsePresentationThemeName(raw.base, 'theme base');
549
559
  const result: Record<string, string> = {};
550
560
  for (const [key, value] of Object.entries(raw)) {
551
561
  if (typeof value === 'string') result[key] = value;
@@ -1541,11 +1541,16 @@ function createCanvasHtmlPrimitiveNode(body: Record<string, unknown>): Response
1541
1541
  return responseJson({ ok: false, error: `Unknown HTML primitive: ${String(rawKind)}.` }, 400);
1542
1542
  }
1543
1543
  const data = isRecord(body.data) ? body.data : {};
1544
- const built = buildHtmlPrimitive({
1545
- kind: rawKind,
1546
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1547
- data,
1548
- });
1544
+ let built: ReturnType<typeof buildHtmlPrimitive>;
1545
+ try {
1546
+ built = buildHtmlPrimitive({
1547
+ kind: rawKind,
1548
+ ...(typeof body.title === 'string' ? { title: body.title } : {}),
1549
+ data,
1550
+ });
1551
+ } catch (error) {
1552
+ return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
1553
+ }
1549
1554
  const geometry = resolveCreateGeometry(body);
1550
1555
  const { node } = addCanvasNode({
1551
1556
  type: 'html',