pmx-canvas 0.1.19 → 0.1.20

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/Readme.md +19 -6
  3. package/dist/canvas/global.css +35 -2
  4. package/dist/canvas/index.js +70 -69
  5. package/dist/json-render/index.js +109 -109
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  7. package/dist/types/client/icons.d.ts +2 -0
  8. package/dist/types/client/state/canvas-store.d.ts +2 -0
  9. package/dist/types/client/types.d.ts +2 -1
  10. package/dist/types/json-render/charts/components.d.ts +5 -1
  11. package/dist/types/json-render/renderer/index.d.ts +1 -0
  12. package/dist/types/json-render/server.d.ts +1 -0
  13. package/dist/types/mcp/canvas-access.d.ts +3 -0
  14. package/dist/types/server/canvas-operations.d.ts +4 -0
  15. package/dist/types/server/canvas-schema.d.ts +19 -3
  16. package/dist/types/server/canvas-serialization.d.ts +1 -0
  17. package/dist/types/server/canvas-state.d.ts +6 -2
  18. package/dist/types/server/html-primitives.d.ts +34 -0
  19. package/dist/types/server/index.d.ts +19 -0
  20. package/docs/cli.md +4 -1
  21. package/docs/http-api.md +10 -0
  22. package/docs/mcp.md +6 -4
  23. package/docs/node-types.md +30 -2
  24. package/docs/screenshot.png +0 -0
  25. package/docs/sdk.md +11 -0
  26. package/package.json +1 -1
  27. package/skills/pmx-canvas/SKILL.md +8 -0
  28. package/src/cli/agent.ts +150 -5
  29. package/src/client/App.tsx +20 -1
  30. package/src/client/canvas/AnnotationLayer.tsx +33 -12
  31. package/src/client/canvas/CanvasViewport.tsx +88 -7
  32. package/src/client/canvas/CommandPalette.tsx +1 -1
  33. package/src/client/canvas/ContextMenu.tsx +2 -2
  34. package/src/client/canvas/ExpandedNodeOverlay.tsx +7 -1
  35. package/src/client/icons.tsx +13 -0
  36. package/src/client/nodes/McpAppNode.tsx +12 -4
  37. package/src/client/state/canvas-store.ts +15 -5
  38. package/src/client/state/sse-bridge.ts +4 -3
  39. package/src/client/theme/global.css +35 -2
  40. package/src/client/types.ts +2 -1
  41. package/src/json-render/charts/components.tsx +41 -7
  42. package/src/json-render/charts/extra-components.tsx +13 -12
  43. package/src/json-render/renderer/index.tsx +1 -0
  44. package/src/json-render/server.ts +3 -1
  45. package/src/mcp/canvas-access.ts +23 -0
  46. package/src/mcp/server.ts +83 -27
  47. package/src/server/agent-context.ts +17 -0
  48. package/src/server/canvas-operations.ts +91 -38
  49. package/src/server/canvas-schema.ts +83 -3
  50. package/src/server/canvas-serialization.ts +9 -2
  51. package/src/server/canvas-state.ts +9 -4
  52. package/src/server/demo-state.json +1143 -0
  53. package/src/server/demo.ts +25 -777
  54. package/src/server/html-primitives.ts +990 -0
  55. package/src/server/index.ts +43 -2
  56. package/src/server/server.ts +138 -14
  57. package/src/server/spatial-analysis.ts +3 -3
@@ -173,10 +173,18 @@ export function addNode(node: CanvasNodeState): void {
173
173
  }
174
174
 
175
175
  export function updateNode(id: string, patch: Partial<CanvasNodeState>): void {
176
+ updateNodeWithOptions(id, patch);
177
+ }
178
+
179
+ function updateNodeWithOptions(
180
+ id: string,
181
+ patch: Partial<CanvasNodeState>,
182
+ options: { skipGroupChildTranslation?: boolean } = {},
183
+ ): void {
176
184
  const existing = nodes.value.get(id);
177
185
  if (!existing) return;
178
186
  const next = new Map(nodes.value);
179
- if (existing.type === 'group' && patch.position) {
187
+ if (existing.type === 'group' && patch.position && options.skipGroupChildTranslation !== true) {
180
188
  const deltaX = patch.position.x - existing.position.x;
181
189
  const deltaY = patch.position.y - existing.position.y;
182
190
  if (deltaX !== 0 || deltaY !== 0) {
@@ -272,9 +280,11 @@ export function removeAnnotation(id: string): void {
272
280
  }
273
281
 
274
282
  export async function createAnnotationFromClient(input: {
283
+ type?: CanvasAnnotation['type'];
275
284
  points: CanvasAnnotation['points'];
276
285
  color: string;
277
286
  width: number;
287
+ text?: string;
278
288
  label?: string;
279
289
  }): Promise<{ ok: boolean }> {
280
290
  try {
@@ -748,10 +758,10 @@ export function autoArrange(): void {
748
758
  updateNode(id, { position });
749
759
  }
750
760
  for (const [groupId, bounds] of result.groupBounds.entries()) {
751
- updateNode(groupId, {
761
+ updateNodeWithOptions(groupId, {
752
762
  position: { x: bounds.x, y: bounds.y },
753
763
  size: { width: bounds.width, height: bounds.height },
754
- });
764
+ }, { skipGroupChildTranslation: true });
755
765
  }
756
766
  });
757
767
  persistLayout();
@@ -766,10 +776,10 @@ export function forceDirectedArrange(): void {
766
776
  updateNode(id, { position });
767
777
  }
768
778
  for (const [groupId, bounds] of result.groupBounds.entries()) {
769
- updateNode(groupId, {
779
+ updateNodeWithOptions(groupId, {
770
780
  position: { x: bounds.x, y: bounds.y },
771
781
  size: { width: bounds.width, height: bounds.height },
772
- });
782
+ }, { skipGroupChildTranslation: true });
773
783
  }
774
784
  });
775
785
  persistLayout();
@@ -388,21 +388,22 @@ function parseCanvasEdge(raw: Record<string, unknown>): CanvasEdge | null {
388
388
 
389
389
  function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation | null {
390
390
  if (typeof raw.id !== 'string' || !raw.id) return null;
391
- if (raw.type !== 'freehand') return null;
391
+ if (raw.type !== 'freehand' && raw.type !== 'text') return null;
392
392
  if (!Array.isArray(raw.points)) return null;
393
393
  const points = raw.points
394
394
  .map((point) => parseCanvasPosition(point))
395
395
  .filter((point): point is { x: number; y: number } => point !== null);
396
396
  const bounds = parseCanvasRect(raw.bounds);
397
- if (points.length < 2 || !bounds) return null;
397
+ if (points.length < (raw.type === 'text' ? 1 : 2) || !bounds) return null;
398
398
 
399
399
  return {
400
400
  id: raw.id,
401
- type: 'freehand',
401
+ type: raw.type,
402
402
  points,
403
403
  bounds,
404
404
  color: typeof raw.color === 'string' ? raw.color : '#f97316',
405
405
  width: typeof raw.width === 'number' ? raw.width : 4,
406
+ ...(typeof raw.text === 'string' ? { text: raw.text } : {}),
406
407
  ...(typeof raw.label === 'string' ? { label: raw.label } : {}),
407
408
  createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
408
409
  };
@@ -392,6 +392,19 @@ body,
392
392
  margin: 0.4em 0;
393
393
  color: var(--c-muted);
394
394
  }
395
+
396
+ .canvas-node .node-body ul,
397
+ .canvas-node .node-body ol {
398
+ margin: 0.4em 0;
399
+ padding-left: 0.25em;
400
+ list-style-position: inside;
401
+ }
402
+
403
+ .canvas-node .node-body li {
404
+ margin: 0.2em 0;
405
+ padding-left: 0.15em;
406
+ }
407
+
395
408
  .canvas-node .node-body a {
396
409
  color: var(--c-accent);
397
410
  text-decoration: none;
@@ -1481,14 +1494,14 @@ body,
1481
1494
  height: 1px;
1482
1495
  overflow: visible;
1483
1496
  pointer-events: none;
1484
- z-index: 45;
1497
+ z-index: 9000;
1485
1498
  }
1486
1499
 
1487
1500
  .annotation-capture-layer {
1488
1501
  position: absolute;
1489
1502
  inset: 0;
1490
1503
  z-index: 9996;
1491
- pointer-events: none;
1504
+ pointer-events: auto;
1492
1505
  background: color-mix(in srgb, var(--c-accent) 5%, transparent);
1493
1506
  }
1494
1507
 
@@ -1496,6 +1509,26 @@ body,
1496
1509
  background: color-mix(in srgb, var(--c-danger) 6%, transparent);
1497
1510
  }
1498
1511
 
1512
+ .annotation-capture-layer.text {
1513
+ background: color-mix(in srgb, var(--c-annotation) 4%, transparent);
1514
+ }
1515
+
1516
+ .annotation-text-input {
1517
+ position: absolute;
1518
+ z-index: 9997;
1519
+ min-width: 120px;
1520
+ max-width: min(560px, calc(100% - 24px));
1521
+ padding: 2px 4px;
1522
+ border: 1px solid var(--c-annotation);
1523
+ border-radius: 4px;
1524
+ background: color-mix(in srgb, var(--c-bg) 72%, transparent);
1525
+ color: var(--c-annotation);
1526
+ font-family: var(--font);
1527
+ font-weight: 700;
1528
+ line-height: 1.15;
1529
+ outline: none;
1530
+ }
1531
+
1499
1532
  /* ── Drop Zone (file drag-and-drop) ─────────────────────────── */
1500
1533
  .drop-zone-overlay {
1501
1534
  position: absolute;
@@ -48,11 +48,12 @@ export interface CanvasAnnotationPoint {
48
48
 
49
49
  export interface CanvasAnnotation {
50
50
  id: string;
51
- type: 'freehand';
51
+ type: 'freehand' | 'text';
52
52
  points: CanvasAnnotationPoint[];
53
53
  bounds: { x: number; y: number; width: number; height: number };
54
54
  color: string;
55
55
  width: number;
56
+ text?: string;
56
57
  label?: string;
57
58
  createdAt: string;
58
59
  }
@@ -8,7 +8,7 @@
8
8
  * a responsive chart inside a styled container.
9
9
  */
10
10
 
11
- import type { ReactNode } from 'react';
11
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
12
12
  import type { BaseComponentProps } from '@json-render/react';
13
13
  import {
14
14
  BarChart as RechartsBarChart,
@@ -106,6 +106,40 @@ export const polarChartMargin = { top: 18, right: 40, bottom: 30, left: 40 };
106
106
  export const axisTickMargin = 8;
107
107
  export const legendMargin = { top: 10 };
108
108
 
109
+ export function useChartFrameHeight(explicitHeight: number | null | undefined, fallbackHeight = 300) {
110
+ const frameRef = useRef<HTMLDivElement>(null);
111
+ const [autoHeight, setAutoHeight] = useState(fallbackHeight);
112
+
113
+ useEffect(() => {
114
+ const frame = frameRef.current;
115
+ if (!frame) return;
116
+
117
+ const updateHeight = () => {
118
+ const rect = frame.getBoundingClientRect();
119
+ const doc = document.documentElement;
120
+ const currentHeight = frame.getBoundingClientRect().height;
121
+ const overflow = Math.max(0, doc.scrollHeight - doc.clientHeight);
122
+ const available = overflow > 0 ? currentHeight - overflow : window.innerHeight - rect.top - 24;
123
+ setAutoHeight(Math.max(220, Math.round(available)));
124
+ };
125
+
126
+ updateHeight();
127
+ const observer = new ResizeObserver(updateHeight);
128
+ observer.observe(document.documentElement);
129
+ observer.observe(frame);
130
+ window.addEventListener('resize', updateHeight);
131
+ return () => {
132
+ observer.disconnect();
133
+ window.removeEventListener('resize', updateHeight);
134
+ };
135
+ }, [explicitHeight]);
136
+
137
+ return {
138
+ frameRef,
139
+ height: typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight,
140
+ };
141
+ }
142
+
109
143
  /** Shared wrapper for cartesian charts (Line + Bar). */
110
144
  export function CartesianChart({
111
145
  props,
@@ -117,12 +151,12 @@ export function CartesianChart({
117
151
  className?: string;
118
152
  }) {
119
153
  const chartData = processChartData(props.data ?? [], props.xKey, props.yKey, props.aggregate);
120
- const h = props.height ?? 300;
154
+ const { frameRef, height } = useChartFrameHeight(props.height, 300);
121
155
 
122
156
  return (
123
- <div className={`pmx-chart${className ? ` ${className}` : ''}`}>
157
+ <div ref={frameRef} className={`pmx-chart${className ? ` ${className}` : ''}`}>
124
158
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
125
- <ResponsiveContainer width="100%" height={h}>
159
+ <ResponsiveContainer width="100%" height={height}>
126
160
  {children(chartData)}
127
161
  </ResponsiveContainer>
128
162
  </div>
@@ -172,12 +206,12 @@ function ChartBarChart({ props }: BaseComponentProps<CartesianChartProps>) {
172
206
 
173
207
  function ChartPieChart({ props }: BaseComponentProps<PieChartProps>) {
174
208
  const data = props.data ?? [];
175
- const h = props.height ?? 300;
209
+ const { frameRef, height } = useChartFrameHeight(props.height, 300);
176
210
 
177
211
  return (
178
- <div className="pmx-chart pmx-chart--pie">
212
+ <div ref={frameRef} className="pmx-chart pmx-chart--pie">
179
213
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
180
- <ResponsiveContainer width="100%" height={h}>
214
+ <ResponsiveContainer width="100%" height={height}>
181
215
  <RechartsPieChart margin={polarChartMargin}>
182
216
  <Tooltip contentStyle={tooltipStyle} />
183
217
  {props.showLegend !== false && <Legend wrapperStyle={legendMargin} />}
@@ -40,6 +40,7 @@ import {
40
40
  legendMargin,
41
41
  polarChartMargin,
42
42
  tooltipStyle,
43
+ useChartFrameHeight,
43
44
  type CartesianChartProps,
44
45
  } from './components';
45
46
 
@@ -89,12 +90,12 @@ interface ScatterChartProps {
89
90
  function ChartScatterChart({ props }: BaseComponentProps<ScatterChartProps>) {
90
91
  const fill = props.color ?? CHART_COLORS[0];
91
92
  const data = props.data ?? [];
92
- const h = props.height ?? 300;
93
+ const { frameRef, height } = useChartFrameHeight(props.height, 300);
93
94
 
94
95
  return (
95
- <div className="pmx-chart pmx-chart--scatter">
96
+ <div ref={frameRef} className="pmx-chart pmx-chart--scatter">
96
97
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
97
- <ResponsiveContainer width="100%" height={h}>
98
+ <ResponsiveContainer width="100%" height={height}>
98
99
  <RechartsScatterChart margin={chartMargin}>
99
100
  <CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
100
101
  <XAxis type="number" dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} name={props.xKey} />
@@ -120,12 +121,12 @@ interface RadarChartProps {
120
121
  function ChartRadarChart({ props }: BaseComponentProps<RadarChartProps>) {
121
122
  const data = props.data ?? [];
122
123
  const metrics = (props.metrics ?? []).filter((m) => typeof m === 'string' && m.length > 0);
123
- const h = props.height ?? 320;
124
+ const { frameRef, height } = useChartFrameHeight(props.height, 320);
124
125
 
125
126
  return (
126
- <div className="pmx-chart pmx-chart--radar">
127
+ <div ref={frameRef} className="pmx-chart pmx-chart--radar">
127
128
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
128
- <ResponsiveContainer width="100%" height={h}>
129
+ <ResponsiveContainer width="100%" height={height}>
129
130
  <RechartsRadarChart data={data} outerRadius="66%" margin={polarChartMargin}>
130
131
  <PolarGrid stroke="var(--border, #e5e5e5)" />
131
132
  <PolarAngleAxis dataKey={props.axisKey} tick={axisStyle} />
@@ -166,12 +167,12 @@ function ChartStackedBarChart({ props }: BaseComponentProps<StackedBarChartProps
166
167
  const chartData = props.aggregate
167
168
  ? mergeAggregated(props.data ?? [], props.xKey, series, props.aggregate)
168
169
  : props.data ?? [];
169
- const h = props.height ?? 300;
170
+ const { frameRef, height } = useChartFrameHeight(props.height, 300);
170
171
 
171
172
  return (
172
- <div className="pmx-chart pmx-chart--stacked-bar">
173
+ <div ref={frameRef} className="pmx-chart pmx-chart--stacked-bar">
173
174
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
174
- <ResponsiveContainer width="100%" height={h}>
175
+ <ResponsiveContainer width="100%" height={height}>
175
176
  <RechartsBarChart data={chartData} margin={chartMargin}>
176
177
  <CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
177
178
  <XAxis dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} />
@@ -238,12 +239,12 @@ function ChartComposedChart({ props }: BaseComponentProps<ComposedChartProps>) {
238
239
  const data = props.data ?? [];
239
240
  const barFill = props.barColor ?? CHART_COLORS[0];
240
241
  const lineStroke = props.lineColor ?? CHART_COLORS[3];
241
- const h = props.height ?? 300;
242
+ const { frameRef, height } = useChartFrameHeight(props.height, 300);
242
243
 
243
244
  return (
244
- <div className="pmx-chart pmx-chart--composed">
245
+ <div ref={frameRef} className="pmx-chart pmx-chart--composed">
245
246
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
246
- <ResponsiveContainer width="100%" height={h}>
247
+ <ResponsiveContainer width="100%" height={height}>
247
248
  <RechartsComposedChart data={data} margin={chartMargin}>
248
249
  <CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
249
250
  <XAxis dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} />
@@ -49,6 +49,7 @@ declare global {
49
49
  interface Window {
50
50
  __PMX_CANVAS_JSON_RENDER_SPEC__?: Spec & { state?: Record<string, unknown> };
51
51
  __PMX_CANVAS_JSON_RENDER_THEME__?: string;
52
+ __PMX_CANVAS_JSON_RENDER_DISPLAY__?: string;
52
53
  }
53
54
  }
54
55
 
@@ -511,7 +511,7 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
511
511
 
512
512
  const chartProps: Record<string, unknown> = {
513
513
  data: input.data,
514
- height: input.height ?? 320,
514
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
515
515
  };
516
516
 
517
517
  switch (chartType) {
@@ -648,6 +648,7 @@ export async function buildJsonRenderViewerHtml(options: {
648
648
  title: string;
649
649
  spec: JsonRenderSpec;
650
650
  theme?: 'dark' | 'light' | 'high-contrast';
651
+ display?: 'expanded';
651
652
  }): Promise<string> {
652
653
  try {
653
654
  await ensureJsonRenderBundle();
@@ -661,6 +662,7 @@ export async function buildJsonRenderViewerHtml(options: {
661
662
  const boot = [
662
663
  `window.__PMX_CANVAS_JSON_RENDER_SPEC__ = ${JSON.stringify(options.spec)};`,
663
664
  ...(options.theme ? [`window.__PMX_CANVAS_JSON_RENDER_THEME__ = ${JSON.stringify(options.theme)};`] : []),
665
+ ...(options.display ? [`window.__PMX_CANVAS_JSON_RENDER_DISPLAY__ = ${JSON.stringify(options.display)};`] : []),
664
666
  jsBundle,
665
667
  ].join('\n');
666
668
  return buildAppHtml({
@@ -19,6 +19,8 @@ type AddDiagramInput = Parameters<PmxCanvas['addDiagram']>[0];
19
19
  type AddJsonRenderNodeInput = Parameters<PmxCanvas['addJsonRenderNode']>[0];
20
20
  type AddJsonRenderNodeResult = ReturnType<PmxCanvas['addJsonRenderNode']>;
21
21
  type AddHtmlNodeInput = Parameters<PmxCanvas['addHtmlNode']>[0];
22
+ type AddHtmlPrimitiveInput = Parameters<PmxCanvas['addHtmlPrimitive']>[0];
23
+ type AddHtmlPrimitiveResult = ReturnType<PmxCanvas['addHtmlPrimitive']>;
22
24
  type AddGraphNodeInput = Parameters<PmxCanvas['addGraphNode']>[0];
23
25
  type AddGraphNodeResult = ReturnType<PmxCanvas['addGraphNode']>;
24
26
  type UpdateNodePatch = Parameters<PmxCanvas['updateNode']>[1];
@@ -102,6 +104,7 @@ export interface CanvasAccess {
102
104
  addDiagram(input: AddDiagramInput): Promise<OpenMcpAppResult>;
103
105
  addJsonRenderNode(input: AddJsonRenderNodeInput): Promise<AddJsonRenderNodeResult>;
104
106
  addHtmlNode(input: AddHtmlNodeInput): Promise<string>;
107
+ addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult>;
105
108
  addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult>;
106
109
  buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
107
110
  updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
@@ -188,6 +191,10 @@ class LocalCanvasAccess implements CanvasAccess {
188
191
  return this.canvas.addHtmlNode(input);
189
192
  }
190
193
 
194
+ async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
195
+ return this.canvas.addHtmlPrimitive(input);
196
+ }
197
+
191
198
  async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
192
199
  return this.canvas.addGraphNode(input);
193
200
  }
@@ -438,6 +445,22 @@ class RemoteCanvasAccess implements CanvasAccess {
438
445
  return await this.requestNodeId('POST', '/api/canvas/node', { type: 'html', ...input });
439
446
  }
440
447
 
448
+ async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
449
+ const response = await this.requestJson<{
450
+ id?: string;
451
+ node?: { id?: string };
452
+ primitive?: { kind?: string; title?: string; htmlBytes?: number };
453
+ }>('POST', '/api/canvas/node', { type: 'html', ...input, primitive: input.kind });
454
+ const id = typeof response.id === 'string' ? response.id : response.node?.id;
455
+ if (!id) throw new Error('html primitive response did not include a node id.');
456
+ return {
457
+ id,
458
+ kind: input.kind,
459
+ title: response.primitive?.title ?? input.title ?? input.kind,
460
+ htmlBytes: response.primitive?.htmlBytes ?? 0,
461
+ };
462
+ }
463
+
441
464
  async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
442
465
  const response = await this.requestJson<GraphNodeResponse>('POST', '/api/canvas/graph', {
443
466
  ...input,
package/src/mcp/server.ts CHANGED
@@ -25,6 +25,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
25
25
  import { isAbsolute, relative, resolve } from 'node:path';
26
26
  import { z } from 'zod';
27
27
  import { canvasState, describeCanvasSchema, validateStructuredCanvasPayload } from '../server/index.js';
28
+ import { isHtmlPrimitiveKind } from '../server/html-primitives.js';
29
+ import type { HtmlPrimitiveKind } from '../server/html-primitives.js';
28
30
  import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
29
31
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
30
32
  import { wrapCanvasAutomationScript } from '../server/server.js';
@@ -56,6 +58,8 @@ const jsonRenderSpecSchema = z.union([
56
58
  }).passthrough(),
57
59
  ]);
58
60
 
61
+ const htmlPrimitiveKindSchema = z.string().refine(isHtmlPrimitiveKind, 'Unknown HTML primitive kind');
62
+
59
63
  function structuredSchemaDescription(): string {
60
64
  const routing = describeCanvasSchema().mcp.nodeTypeRouting;
61
65
  return Object.entries(routing)
@@ -428,6 +432,46 @@ export async function startMcpServer(): Promise<void> {
428
432
  },
429
433
  );
430
434
 
435
+ server.tool(
436
+ 'canvas_add_html_primitive',
437
+ 'Create a reusable HTML communication primitive as a normal sandboxed html node. Use this instead of long markdown for side-by-side choices, implementation plans, PR review sheets, module maps, design sheets, component galleries, flowcharts, slide decks, explainers, status reports, and throwaway editors with export/copy paths.',
438
+ {
439
+ kind: htmlPrimitiveKindSchema.describe('Primitive kind. Call canvas_describe_schema and read htmlPrimitives for data shapes and examples.'),
440
+ title: z.string().optional().describe('Node title shown in the canvas titlebar.'),
441
+ data: z.record(z.string(), z.unknown()).optional().describe('Primitive-specific data payload. See canvas_describe_schema.htmlPrimitives for each shape.'),
442
+ x: z.number().optional().describe('X position (auto-placed if omitted).'),
443
+ y: z.number().optional().describe('Y position (auto-placed if omitted).'),
444
+ width: z.number().optional().describe('Width in pixels (defaults per primitive).'),
445
+ height: z.number().optional().describe('Height in pixels (defaults per primitive).'),
446
+ strictSize: z.boolean().optional().describe('Keep explicit width/height fixed; iframe scrolls overflow internally.'),
447
+ full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
448
+ verbose: z.boolean().optional().describe('Alias for full:true.'),
449
+ },
450
+ async (input) => {
451
+ const c = await ensureCanvas();
452
+ const kind = input.kind as HtmlPrimitiveKind;
453
+ const result = await c.addHtmlPrimitive({
454
+ kind,
455
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
456
+ ...(input.data ? { data: input.data } : {}),
457
+ ...(typeof input.x === 'number' ? { x: input.x } : {}),
458
+ ...(typeof input.y === 'number' ? { y: input.y } : {}),
459
+ ...(typeof input.width === 'number' ? { width: input.width } : {}),
460
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
461
+ ...(input.strictSize === true ? { strictSize: true } : {}),
462
+ });
463
+ return {
464
+ content: [{
465
+ type: 'text',
466
+ text: JSON.stringify({
467
+ ...(await createdNodePayload(c, result.id, input)),
468
+ primitive: { kind: result.kind, title: result.title, htmlBytes: result.htmlBytes },
469
+ }, null, 2),
470
+ }],
471
+ };
472
+ },
473
+ );
474
+
431
475
  server.tool(
432
476
  'canvas_open_mcp_app',
433
477
  'Connect to an external MCP server that declares a ui:// app resource, call the specified tool, and open the resulting MCP App inside a canvas mcp-app node. This is a full external-MCP transport call, not the CLI kind shortcut; use canvas_add_diagram for the built-in Excalidraw preset.',
@@ -543,10 +587,13 @@ export async function startMcpServer(): Promise<void> {
543
587
 
544
588
  server.tool(
545
589
  'canvas_validate_spec',
546
- 'Validate a json-render spec or graph payload without creating a node. Returns the normalized json-render spec that the server would accept.',
590
+ 'Validate a json-render spec, graph payload, or HTML primitive payload without creating a node. Returns normalized metadata the server would accept.',
547
591
  {
548
- type: z.enum(['json-render', 'graph']).describe('Structured payload type to validate'),
592
+ type: z.enum(['json-render', 'graph', 'html-primitive']).describe('Structured payload type to validate'),
549
593
  spec: jsonRenderSpecSchema.optional().describe('json-render spec to validate when type="json-render"'),
594
+ kind: htmlPrimitiveKindSchema.optional().describe('HTML primitive kind when type="html-primitive"'),
595
+ primitive: htmlPrimitiveKindSchema.optional().describe('Alias for kind when type="html-primitive"'),
596
+ primitiveData: z.record(z.string(), z.unknown()).optional().describe('HTML primitive data payload when type="html-primitive"'),
550
597
  title: z.string().optional().describe('Optional graph title'),
551
598
  graphType: z.string().optional().describe('Graph type when type="graph"'),
552
599
  data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when type="graph"'),
@@ -573,29 +620,38 @@ export async function startMcpServer(): Promise<void> {
573
620
  type: 'json-render',
574
621
  spec: input.spec,
575
622
  })
576
- : validateStructuredCanvasPayload({
577
- type: 'graph',
578
- graph: {
579
- title: input.title,
580
- graphType: input.graphType ?? 'line',
581
- data: input.data ?? [],
582
- ...(typeof input.xKey === 'string' ? { xKey: input.xKey } : {}),
583
- ...(typeof input.yKey === 'string' ? { yKey: input.yKey } : {}),
584
- ...(typeof input.zKey === 'string' ? { zKey: input.zKey } : {}),
585
- ...(typeof input.nameKey === 'string' ? { nameKey: input.nameKey } : {}),
586
- ...(typeof input.valueKey === 'string' ? { valueKey: input.valueKey } : {}),
587
- ...(typeof input.axisKey === 'string' ? { axisKey: input.axisKey } : {}),
588
- ...(Array.isArray(input.metrics) ? { metrics: input.metrics } : {}),
589
- ...(Array.isArray(input.series) ? { series: input.series } : {}),
590
- ...(typeof input.barKey === 'string' ? { barKey: input.barKey } : {}),
591
- ...(typeof input.lineKey === 'string' ? { lineKey: input.lineKey } : {}),
592
- ...(typeof input.aggregate === 'string' ? { aggregate: input.aggregate } : {}),
593
- ...(typeof input.color === 'string' ? { color: input.color } : {}),
594
- ...(typeof input.barColor === 'string' ? { barColor: input.barColor } : {}),
595
- ...(typeof input.lineColor === 'string' ? { lineColor: input.lineColor } : {}),
596
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
597
- },
598
- });
623
+ : input.type === 'html-primitive'
624
+ ? validateStructuredCanvasPayload({
625
+ type: 'html-primitive',
626
+ primitive: {
627
+ kind: (input.kind ?? input.primitive ?? '') as HtmlPrimitiveKind | '',
628
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
629
+ ...(input.primitiveData ? { data: input.primitiveData } : {}),
630
+ },
631
+ })
632
+ : validateStructuredCanvasPayload({
633
+ type: 'graph',
634
+ graph: {
635
+ title: input.title,
636
+ graphType: input.graphType ?? 'line',
637
+ data: input.data ?? [],
638
+ ...(typeof input.xKey === 'string' ? { xKey: input.xKey } : {}),
639
+ ...(typeof input.yKey === 'string' ? { yKey: input.yKey } : {}),
640
+ ...(typeof input.zKey === 'string' ? { zKey: input.zKey } : {}),
641
+ ...(typeof input.nameKey === 'string' ? { nameKey: input.nameKey } : {}),
642
+ ...(typeof input.valueKey === 'string' ? { valueKey: input.valueKey } : {}),
643
+ ...(typeof input.axisKey === 'string' ? { axisKey: input.axisKey } : {}),
644
+ ...(Array.isArray(input.metrics) ? { metrics: input.metrics } : {}),
645
+ ...(Array.isArray(input.series) ? { series: input.series } : {}),
646
+ ...(typeof input.barKey === 'string' ? { barKey: input.barKey } : {}),
647
+ ...(typeof input.lineKey === 'string' ? { lineKey: input.lineKey } : {}),
648
+ ...(typeof input.aggregate === 'string' ? { aggregate: input.aggregate } : {}),
649
+ ...(typeof input.color === 'string' ? { color: input.color } : {}),
650
+ ...(typeof input.barColor === 'string' ? { barColor: input.barColor } : {}),
651
+ ...(typeof input.lineColor === 'string' ? { lineColor: input.lineColor } : {}),
652
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
653
+ },
654
+ });
599
655
 
600
656
  return {
601
657
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -736,7 +792,7 @@ export async function startMcpServer(): Promise<void> {
736
792
  content: [{
737
793
  type: 'text',
738
794
  text: JSON.stringify({
739
- ...await createdNodePayload(c, result.id),
795
+ ...(await createdNodePayload(c, result.id)),
740
796
  url: result.url,
741
797
  spec: result.spec,
742
798
  }, null, 2),
@@ -816,7 +872,7 @@ export async function startMcpServer(): Promise<void> {
816
872
  content: [{
817
873
  type: 'text',
818
874
  text: JSON.stringify({
819
- ...await createdNodePayload(c, result.id),
875
+ ...(await createdNodePayload(c, result.id)),
820
876
  url: result.url,
821
877
  spec: result.spec,
822
878
  }, null, 2),
@@ -155,6 +155,17 @@ function summarizeWebArtifactData(data: Record<string, unknown>, maxLength: numb
155
155
  return parts.length > 0 ? truncateContextText(parts.join('\n'), maxLength) : 'Web artifact node';
156
156
  }
157
157
 
158
+ function summarizeHtmlPrimitiveData(data: Record<string, unknown>, maxLength: number): string {
159
+ const parts: string[] = [];
160
+ const primitive = typeof data.htmlPrimitive === 'string' ? data.htmlPrimitive : '';
161
+ const description = typeof data.description === 'string' ? data.description : '';
162
+ const primitiveData = data.primitiveData;
163
+ if (primitive) parts.push(`HTML primitive: ${primitive}`);
164
+ if (description) parts.push(description);
165
+ if (primitiveData !== undefined) parts.push(`Data: ${stringifyContextValue(primitiveData, maxLength)}`);
166
+ return truncateContextText(parts.join('\n'), maxLength);
167
+ }
168
+
158
169
  function metadataForNode(node: CanvasNodeState): Record<string, unknown> | undefined {
159
170
  switch (node.type) {
160
171
  case 'webpage': {
@@ -233,6 +244,12 @@ export function summarizeNodeForAgentContext(
233
244
  if (graphCfg) return truncateContextText(`Graph: ${JSON.stringify(graphCfg)}`, defaultTextLength);
234
245
  return stringifyContextValue(node.data.spec ?? {}, defaultTextLength);
235
246
  }
247
+ case 'html': {
248
+ if (typeof node.data.htmlPrimitive === 'string') {
249
+ return summarizeHtmlPrimitiveData(node.data, defaultTextLength);
250
+ }
251
+ return stringifyContextValue({ title: node.data.title, description: node.data.description }, defaultTextLength);
252
+ }
236
253
  case 'prompt':
237
254
  case 'response': {
238
255
  const text = (node.data.text as string) || (node.data.content as string) || '';