pmx-canvas 0.1.20 → 0.1.21

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 (37) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/canvas/global.css +88 -0
  3. package/dist/canvas/index.js +87 -53
  4. package/dist/types/client/nodes/HtmlNode.d.ts +12 -1
  5. package/dist/types/client/types.d.ts +1 -1
  6. package/dist/types/server/html-node-summary.d.ts +2 -0
  7. package/dist/types/server/html-primitives.d.ts +9 -1
  8. package/dist/types/server/index.d.ts +8 -1
  9. package/docs/http-api.md +1 -1
  10. package/docs/mcp.md +4 -0
  11. package/docs/node-types.md +27 -5
  12. package/docs/screenshot.png +0 -0
  13. package/docs/sdk.md +1 -0
  14. package/package.json +1 -1
  15. package/skills/pmx-canvas/SKILL.md +10 -4
  16. package/skills/pmx-canvas/references/html-primitives.md +132 -0
  17. package/src/cli/agent.ts +9 -0
  18. package/src/cli/index.ts +1 -1
  19. package/src/client/App.tsx +1 -1
  20. package/src/client/canvas/CommandPalette.tsx +1 -1
  21. package/src/client/canvas/ExpandedNodeOverlay.tsx +105 -2
  22. package/src/client/canvas/auto-fit.ts +5 -1
  23. package/src/client/nodes/HtmlNode.tsx +125 -13
  24. package/src/client/state/sse-bridge.ts +1 -1
  25. package/src/client/theme/global.css +88 -0
  26. package/src/mcp/canvas-access.ts +31 -1
  27. package/src/mcp/server.ts +17 -3
  28. package/src/server/agent-context.ts +23 -1
  29. package/src/server/canvas-operations.ts +10 -2
  30. package/src/server/canvas-provenance.ts +8 -6
  31. package/src/server/canvas-schema.ts +11 -0
  32. package/src/server/canvas-serialization.ts +10 -5
  33. package/src/server/html-node-summary.ts +141 -0
  34. package/src/server/html-primitives.ts +318 -8
  35. package/src/server/index.ts +22 -3
  36. package/src/server/server.ts +17 -4
  37. package/src/server/spatial-analysis.ts +4 -2
@@ -1,4 +1,5 @@
1
- import { useMemo } from 'preact/hooks';
1
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
2
+ import { canvasTheme } from '../state/canvas-store';
2
3
  import { getCanvasTokens } from '../theme/tokens';
3
4
  import type { CanvasNodeState } from '../types';
4
5
 
@@ -95,28 +96,135 @@ function buildThemeStyleBlock(): string {
95
96
  * a `<head>`, inject at the top of head; otherwise wrap the content in a full
96
97
  * document. Returns a complete HTML string suitable for `srcdoc`.
97
98
  */
98
- function buildSrcDoc(userHtml: string): string {
99
- const styleBlock = `<style data-pmx-canvas-theme>${buildThemeStyleBlock()}</style>`;
99
+ function buildPresentationEscapeBridge(exitToken?: string): string {
100
+ const token = JSON.stringify(exitToken ?? '');
101
+ return `<script data-pmx-canvas-presentation-bridge>
102
+ const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
103
+ document.addEventListener('keydown', (event) => {
104
+ if (event.key === 'Escape') {
105
+ window.parent.postMessage({ source: 'pmx-canvas-html-node', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
106
+ }
107
+ }, true);
108
+ window.addEventListener('message', (event) => {
109
+ const message = event.data;
110
+ if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
111
+ if (typeof message.key !== 'string') return;
112
+ if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
113
+ window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
114
+ return;
115
+ }
116
+ document.dispatchEvent(new CustomEvent('pmx-presentation-key', { detail: { key: message.key }, bubbles: true, cancelable: true }));
117
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: message.key, bubbles: true, cancelable: true }));
118
+ });
119
+ </script>`;
120
+ }
121
+
122
+ function buildThemeBridge(themeToken: string): string {
123
+ const token = JSON.stringify(themeToken);
124
+ return `<script data-pmx-canvas-theme-bridge>
125
+ const PMX_CANVAS_THEME_TOKEN = ${token};
126
+ window.addEventListener('message', (event) => {
127
+ const message = event.data;
128
+ if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
129
+ if (typeof message.css !== 'string' || typeof message.theme !== 'string') return;
130
+ let style = document.querySelector('style[data-pmx-canvas-theme]');
131
+ if (!style) {
132
+ style = document.createElement('style');
133
+ style.setAttribute('data-pmx-canvas-theme', '');
134
+ document.head.prepend(style);
135
+ }
136
+ style.textContent = message.css;
137
+ document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
138
+ document.documentElement.setAttribute('data-theme', message.theme);
139
+ });
140
+ </script>`;
141
+ }
142
+
143
+ function injectIntoHead(html: string, content: string): string {
144
+ if (/<head[\s>]/i.test(html)) {
145
+ return html.replace(/<head([^>]*)>/i, `<head$1>${content}`);
146
+ }
147
+ if (/<html[\s>]/i.test(html)) {
148
+ return html.replace(/<html([^>]*)>/i, `<html$1><head>${content}</head>`);
149
+ }
150
+ return html;
151
+ }
152
+
153
+ function buildSrcDoc(userHtml: string, options: { presentation?: boolean; presentationExitToken?: string; themeToken: string; themeCss: string; theme: string }): string {
154
+ const styleBlock = `<style data-pmx-canvas-theme>${options.themeCss}</style>`;
155
+ const themeBridge = buildThemeBridge(options.themeToken);
156
+ const presentationBridge = options.presentation ? buildPresentationEscapeBridge(options.presentationExitToken) : '';
157
+ const injectedHeadContent = `${styleBlock}${themeBridge}${presentationBridge}`;
158
+ const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
100
159
  const trimmed = userHtml.trim();
101
160
  const isFullDoc = /<html[\s>]/i.test(trimmed);
102
161
  if (isFullDoc) {
103
- if (/<head[\s>]/i.test(trimmed)) {
104
- return trimmed.replace(/<head([^>]*)>/i, `<head$1>${styleBlock}`);
105
- }
106
- // Has <html> but no <head> — inject one.
107
- return trimmed.replace(/<html([^>]*)>/i, `<html$1><head>${styleBlock}</head>`);
162
+ const withTheme = trimmed.replace(/<html([^>]*)>/i, `<html$1 data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}>`);
163
+ return injectIntoHead(withTheme, injectedHeadContent);
108
164
  }
109
165
  // Fragment — wrap in full document.
110
- return `<!doctype html><html><head><meta charset="utf-8">${styleBlock}</head><body>${userHtml}</body></html>`;
166
+ return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${injectedHeadContent}</head><body>${userHtml}</body></html>`;
167
+ }
168
+
169
+ export function createHtmlNodeSrcDocForTest(userHtml: string, options: { theme: string; themeCss: string; themeToken?: string; presentation?: boolean; presentationExitToken?: string }): string {
170
+ return buildSrcDoc(userHtml, {
171
+ themeToken: options.themeToken ?? 'test-theme-token',
172
+ theme: options.theme,
173
+ themeCss: options.themeCss,
174
+ presentation: options.presentation,
175
+ presentationExitToken: options.presentationExitToken,
176
+ });
177
+ }
178
+
179
+ export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
180
+ return node.type === 'html' && node.data.presentation === true;
111
181
  }
112
182
 
113
- export function HtmlNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
183
+ export function HtmlNode({
184
+ node,
185
+ expanded = false,
186
+ presentation = false,
187
+ presentationExitToken,
188
+ autoFocus = false,
189
+ }: { node: CanvasNodeState; expanded?: boolean; presentation?: boolean; presentationExitToken?: string; autoFocus?: boolean }) {
190
+ const iframeRef = useRef<HTMLIFrameElement>(null);
191
+ const theme = canvasTheme.value;
192
+ const themeToken = useMemo(() => `theme-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, []);
193
+ const themeCss = useMemo(() => buildThemeStyleBlock(), [theme]);
114
194
  const html = typeof node.data.html === 'string'
115
195
  ? node.data.html
116
196
  : typeof node.data.content === 'string'
117
197
  ? node.data.content
118
198
  : '';
119
- const srcDoc = useMemo(() => (html ? buildSrcDoc(html) : ''), [html]);
199
+ const srcDoc = useMemo(() => (html ? buildSrcDoc(html, { presentation, presentationExitToken, themeToken, themeCss, theme }) : ''), [html, presentation, presentationExitToken, themeToken]);
200
+
201
+ useEffect(() => {
202
+ iframeRef.current?.contentWindow?.postMessage({
203
+ source: 'pmx-canvas-html-node',
204
+ type: 'theme-update',
205
+ token: themeToken,
206
+ theme,
207
+ css: themeCss,
208
+ }, '*');
209
+ if (autoFocus) iframeRef.current?.focus();
210
+ }, [theme, themeCss, themeToken]);
211
+
212
+ useEffect(() => {
213
+ if (!autoFocus) return;
214
+ const id = window.setTimeout(() => iframeRef.current?.focus(), 0);
215
+ return () => window.clearTimeout(id);
216
+ }, [autoFocus, srcDoc]);
217
+
218
+ const handleFrameLoad = () => {
219
+ iframeRef.current?.contentWindow?.postMessage({
220
+ source: 'pmx-canvas-html-node',
221
+ type: 'theme-update',
222
+ token: themeToken,
223
+ theme,
224
+ css: themeCss,
225
+ }, '*');
226
+ if (autoFocus) iframeRef.current?.focus();
227
+ };
120
228
 
121
229
  if (!html) {
122
230
  return (
@@ -134,16 +242,20 @@ export function HtmlNode({ node, expanded = false }: { node: CanvasNodeState; ex
134
242
  // arbitrary author code runs inside this exact sandbox.
135
243
  return (
136
244
  <iframe
245
+ ref={iframeRef}
246
+ class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
137
247
  title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
138
248
  sandbox="allow-scripts"
139
249
  srcdoc={srcDoc}
250
+ tabIndex={autoFocus ? 0 : undefined}
251
+ onLoad={handleFrameLoad}
140
252
  style={{
141
253
  width: '100%',
142
254
  height: '100%',
143
- minHeight: expanded ? '70vh' : '300px',
255
+ minHeight: presentation ? 0 : expanded ? '70vh' : '300px',
144
256
  border: 'none',
145
257
  background: 'var(--c-bg)',
146
- borderRadius: '6px',
258
+ borderRadius: presentation ? '18px' : '6px',
147
259
  display: 'block',
148
260
  }}
149
261
  />
@@ -291,9 +291,9 @@ function ensureLedgerNode(summary: Record<string, unknown>): void {
291
291
  function applyCanvasTheme(theme: string): void {
292
292
  const valid = theme === 'dark' || theme === 'light' || theme === 'high-contrast';
293
293
  if (!valid || canvasTheme.value === theme) return;
294
- canvasTheme.value = theme;
295
294
  document.documentElement.setAttribute('data-theme', theme);
296
295
  invalidateTokenCache();
296
+ canvasTheme.value = theme;
297
297
  }
298
298
 
299
299
  function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
@@ -2438,6 +2438,18 @@ body,
2438
2438
  border-color: var(--c-muted);
2439
2439
  }
2440
2440
 
2441
+ .expanded-action-btn.expanded-action-primary {
2442
+ background: var(--c-accent-12);
2443
+ border-color: var(--c-accent-30);
2444
+ color: var(--c-accent);
2445
+ }
2446
+
2447
+ .expanded-action-btn.expanded-action-primary:hover {
2448
+ background: var(--c-accent-25);
2449
+ border-color: var(--c-accent);
2450
+ color: var(--c-text);
2451
+ }
2452
+
2441
2453
  .expanded-action-btn.expanded-action-active {
2442
2454
  background: var(--c-warn-12);
2443
2455
  border-color: var(--c-warn-30);
@@ -2455,6 +2467,82 @@ body,
2455
2467
  padding: 0 4px;
2456
2468
  }
2457
2469
 
2470
+ .html-presentation-overlay {
2471
+ position: fixed;
2472
+ inset: 0;
2473
+ z-index: 10050;
2474
+ display: flex;
2475
+ flex-direction: column;
2476
+ gap: 14px;
2477
+ padding: clamp(12px, 2vw, 28px);
2478
+ background:
2479
+ radial-gradient(circle at top left, var(--c-accent-25), transparent 36rem),
2480
+ rgba(3, 7, 18, 0.96);
2481
+ color: var(--c-text);
2482
+ }
2483
+
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
+ .html-presentation-exit {
2516
+ flex-shrink: 0;
2517
+ padding: 8px 12px;
2518
+ border: 1px solid var(--c-line);
2519
+ border-radius: 999px;
2520
+ background: var(--c-panel-soft);
2521
+ color: var(--c-text-soft);
2522
+ cursor: pointer;
2523
+ font: 600 12px/1 var(--font);
2524
+ }
2525
+
2526
+ .html-presentation-exit:hover {
2527
+ border-color: var(--c-accent);
2528
+ color: var(--c-text);
2529
+ }
2530
+
2531
+ .html-presentation-stage {
2532
+ flex: 1;
2533
+ min-height: 0;
2534
+ display: flex;
2535
+ border-radius: 22px;
2536
+ background: var(--c-bg);
2537
+ box-shadow: 0 24px 90px rgba(0, 0, 0, 0.55);
2538
+ overflow: hidden;
2539
+ }
2540
+
2541
+ .html-node-frame-presentation {
2542
+ flex: 1;
2543
+ min-height: 0;
2544
+ }
2545
+
2458
2546
  /* ── Context pin button on node title bar ────────────────────── */
2459
2547
  .node-controls .ctx-pin-btn {
2460
2548
  color: var(--c-muted);
@@ -442,7 +442,37 @@ class RemoteCanvasAccess implements CanvasAccess {
442
442
  }
443
443
 
444
444
  async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
445
- return await this.requestNodeId('POST', '/api/canvas/node', { type: 'html', ...input });
445
+ const {
446
+ summary,
447
+ agentSummary,
448
+ description,
449
+ presentation,
450
+ slideTitles,
451
+ embeddedNodeIds,
452
+ embeddedUrls,
453
+ ...rest
454
+ } = input as AddHtmlNodeInput & {
455
+ summary?: string;
456
+ agentSummary?: string;
457
+ description?: string;
458
+ presentation?: boolean;
459
+ slideTitles?: string[];
460
+ embeddedNodeIds?: string[];
461
+ embeddedUrls?: string[];
462
+ };
463
+ return await this.requestNodeId('POST', '/api/canvas/node', {
464
+ type: 'html',
465
+ ...rest,
466
+ data: {
467
+ ...(typeof summary === 'string' ? { summary } : {}),
468
+ ...(typeof agentSummary === 'string' ? { agentSummary } : {}),
469
+ ...(typeof description === 'string' ? { description } : {}),
470
+ ...(presentation === true ? { presentation: true } : {}),
471
+ ...(Array.isArray(slideTitles) ? { slideTitles } : {}),
472
+ ...(Array.isArray(embeddedNodeIds) ? { embeddedNodeIds } : {}),
473
+ ...(Array.isArray(embeddedUrls) ? { embeddedUrls } : {}),
474
+ },
475
+ });
446
476
  }
447
477
 
448
478
  async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
package/src/mcp/server.ts CHANGED
@@ -403,10 +403,17 @@ export async function startMcpServer(): Promise<void> {
403
403
  // ── canvas_add_html_node ────────────────────────────────────────
404
404
  server.tool(
405
405
  'canvas_add_html_node',
406
- 'Add an html node: a self-contained HTML document (with optional inline <script> and CDN <script src="...">) rendered inside a sandboxed iframe (sandbox="allow-scripts"). Use this for moderate-complexity visualizations or interactive widgets that need real JS but do not warrant a full React/shadcn build. The iframe inherits canvas theme tokens via injected CSS custom properties (both --c-* and common --color-* aliases) so authored HTML using var(--color-text-secondary), var(--color-bg), etc. renders cohesively. No same-origin access; no top-navigation; no forms. For declarative-only views with zero JS, prefer canvas_add_json_render_node. For React + shadcn + routing or multi-component apps, use canvas_build_web_artifact.',
406
+ 'Add a normal html node: a self-contained HTML document (with optional inline <script> and CDN <script src="...">) rendered inside a sandboxed iframe (sandbox="allow-scripts"). This is the default HTML surface for reports, widgets, and bespoke visualizations. Presentation mode is opt-in: only pass presentation:true when the user explicitly asks for a deck/fullscreen presentation, or use canvas_add_html_primitive with kind="presentation". The iframe inherits live canvas theme tokens via injected CSS custom properties (both --c-* and common --color-* aliases) so authored HTML using var(--color-text-secondary), var(--color-bg), etc. renders cohesively. No same-origin access; no top-navigation; no forms. For declarative-only views with zero JS, prefer canvas_add_json_render_node. For React + shadcn + routing or multi-component apps, use canvas_build_web_artifact.',
407
407
  {
408
408
  html: z.string().describe('HTML document or fragment. Full <html>...</html> documents are passed through with theme styles injected into <head>; bare fragments are wrapped in a minimal document. Inline <script> and remote CDN <script src="..."> are allowed.'),
409
409
  title: z.string().optional().describe('Node title shown in the canvas titlebar.'),
410
+ summary: z.string().optional().describe('Agent-readable semantic summary for this HTML node. If omitted, PMX derives one from visible HTML text.'),
411
+ agentSummary: z.string().optional().describe('Explicit agent-readable summary. Alias for summary with higher priority when both are provided.'),
412
+ description: z.string().optional().describe('Short description included in search and pinned/spatial context.'),
413
+ presentation: z.boolean().optional().describe('Marks this HTML surface as a fullscreen presentation/deck. Omit unless the user explicitly requested presentation mode.'),
414
+ slideTitles: z.array(z.string()).optional().describe('Agent-readable slide titles for presentation HTML.'),
415
+ embeddedNodeIds: z.array(z.string()).optional().describe('Canvas node IDs embedded or represented by this HTML surface.'),
416
+ embeddedUrls: z.array(z.string()).optional().describe('URLs embedded or represented by this HTML surface.'),
410
417
  x: z.number().optional().describe('X position (auto-placed if omitted).'),
411
418
  y: z.number().optional().describe('Y position (auto-placed if omitted).'),
412
419
  width: z.number().optional().describe('Width in pixels (default: 720).'),
@@ -420,6 +427,13 @@ export async function startMcpServer(): Promise<void> {
420
427
  const id = await c.addHtmlNode({
421
428
  html: input.html,
422
429
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
430
+ ...(typeof input.summary === 'string' ? { summary: input.summary } : {}),
431
+ ...(typeof input.agentSummary === 'string' ? { agentSummary: input.agentSummary } : {}),
432
+ ...(typeof input.description === 'string' ? { description: input.description } : {}),
433
+ ...(input.presentation === true ? { presentation: true } : {}),
434
+ ...(Array.isArray(input.slideTitles) ? { slideTitles: input.slideTitles } : {}),
435
+ ...(Array.isArray(input.embeddedNodeIds) ? { embeddedNodeIds: input.embeddedNodeIds } : {}),
436
+ ...(Array.isArray(input.embeddedUrls) ? { embeddedUrls: input.embeddedUrls } : {}),
423
437
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
424
438
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
425
439
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
@@ -434,11 +448,11 @@ export async function startMcpServer(): Promise<void> {
434
448
 
435
449
  server.tool(
436
450
  '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.',
451
+ '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, explainers, status reports, and throwaway editors with export/copy paths. Use kind="presentation" only when the user explicitly asks for a PowerPoint-like deck, pitch, briefing, workshop walkthrough, or fullscreen story.',
438
452
  {
439
453
  kind: htmlPrimitiveKindSchema.describe('Primitive kind. Call canvas_describe_schema and read htmlPrimitives for data shapes and examples.'),
440
454
  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.'),
455
+ data: z.record(z.string(), z.unknown()).optional().describe('Primitive-specific data payload. For kind="presentation", data may include theme:"canvas"|"midnight"|"paper"|"aurora" or a custom color object. See canvas_describe_schema.htmlPrimitives for each shape.'),
442
456
  x: z.number().optional().describe('X position (auto-placed if omitted).'),
443
457
  y: z.number().optional().describe('Y position (auto-placed if omitted).'),
444
458
  width: z.number().optional().describe('Width in pixels (defaults per primitive).'),
@@ -192,6 +192,18 @@ function metadataForNode(node: CanvasNodeState): Record<string, unknown> | undef
192
192
  }
193
193
  return Object.keys(metadata).length > 0 ? metadata : undefined;
194
194
  }
195
+ case 'html': {
196
+ const metadata: Record<string, unknown> = {};
197
+ for (const key of ['summary', 'description', 'agentSummary', 'contentSummary', 'htmlPrimitive', 'presentation', 'slideCount', 'slideTitles', 'speakerNotes', 'embeddedNodeIds', 'embeddedUrls']) {
198
+ const value = node.data[key];
199
+ if (Array.isArray(value)) {
200
+ if (value.length > 0) metadata[key] = value;
201
+ } else if (value !== undefined && value !== null && value !== '') {
202
+ metadata[key] = value;
203
+ }
204
+ }
205
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
206
+ }
195
207
  case 'mcp-app': {
196
208
  const metadata: Record<string, unknown> = {};
197
209
  for (const key of ['url', 'path', 'mode', 'hostMode', 'viewerType', 'serverName', 'toolName', 'resourceUri', 'sessionStatus', 'projectPath', 'artifactBytes', 'sourceFiles', 'sourceFileCount', 'deps']) {
@@ -245,10 +257,20 @@ export function summarizeNodeForAgentContext(
245
257
  return stringifyContextValue(node.data.spec ?? {}, defaultTextLength);
246
258
  }
247
259
  case 'html': {
260
+ if (typeof node.data.agentSummary === 'string') {
261
+ return truncateContextText(node.data.agentSummary, defaultTextLength);
262
+ }
248
263
  if (typeof node.data.htmlPrimitive === 'string') {
249
264
  return summarizeHtmlPrimitiveData(node.data, defaultTextLength);
250
265
  }
251
- return stringifyContextValue({ title: node.data.title, description: node.data.description }, defaultTextLength);
266
+ return stringifyContextValue({
267
+ title: node.data.title,
268
+ description: node.data.description,
269
+ summary: node.data.summary,
270
+ contentSummary: node.data.contentSummary,
271
+ embeddedNodeIds: node.data.embeddedNodeIds,
272
+ embeddedUrls: node.data.embeddedUrls,
273
+ }, defaultTextLength);
252
274
  }
253
275
  case 'prompt':
254
276
  case 'response': {
@@ -1552,8 +1552,16 @@ export async function executeCanvasBatch(
1552
1552
  };
1553
1553
  } else {
1554
1554
  const data = isPlainRecord(args.data) ? args.data : {};
1555
- const htmlData = type === 'html' && typeof args.html === 'string'
1556
- ? { ...data, html: args.html }
1555
+ const htmlData = type === 'html'
1556
+ ? {
1557
+ ...data,
1558
+ ...(typeof args.html === 'string' ? { html: args.html } : {}),
1559
+ ...(typeof args.summary === 'string' ? { summary: args.summary } : {}),
1560
+ ...(typeof args.agentSummary === 'string' ? { agentSummary: args.agentSummary } : {}),
1561
+ ...(typeof args.description === 'string' ? { description: args.description } : {}),
1562
+ ...(Array.isArray(args.embeddedNodeIds) ? { embeddedNodeIds: args.embeddedNodeIds } : {}),
1563
+ ...(Array.isArray(args.embeddedUrls) ? { embeddedUrls: args.embeddedUrls } : {}),
1564
+ }
1557
1565
  : data;
1558
1566
  const created = addCanvasNode({
1559
1567
  type: type as CanvasNodeState['type'],
@@ -1,4 +1,5 @@
1
1
  import { pathToFileURL } from 'node:url';
2
+ import { normalizeHtmlNodeSemanticData } from './html-node-summary.js';
2
3
 
3
4
  export type CanvasNodeType =
4
5
  | 'markdown'
@@ -228,17 +229,18 @@ export function normalizeCanvasNodeData<T extends Record<string, unknown>>(
228
229
  nodeType: CanvasNodeType,
229
230
  data: T,
230
231
  ): T {
231
- const existing = normalizeExistingProvenance(data.provenance);
232
- const inferred = inferCanvasNodeProvenance(nodeType, data);
232
+ const semanticData = nodeType === 'html' ? normalizeHtmlNodeSemanticData(data) : data;
233
+ const existing = normalizeExistingProvenance(semanticData.provenance);
234
+ const inferred = inferCanvasNodeProvenance(nodeType, semanticData);
233
235
  const provenance = mergeProvenance(existing, inferred);
234
236
 
235
237
  if (provenance) {
236
- return { ...data, provenance } as T;
238
+ return { ...semanticData, provenance } as T;
237
239
  }
238
- if ('provenance' in data) {
239
- const nextData = { ...data };
240
+ if ('provenance' in semanticData) {
241
+ const nextData = { ...semanticData };
240
242
  delete nextData.provenance;
241
243
  return nextData as T;
242
244
  }
243
- return data;
245
+ return semanticData;
244
246
  }
@@ -243,6 +243,12 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
243
243
  mcpTool: 'canvas_add_html_node',
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
+ { 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.' },
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.' },
246
252
  { name: 'primitive', type: 'HtmlPrimitiveKind', required: false, description: 'Generate HTML from a built-in communication primitive instead of passing raw HTML.', aliases: ['kind'] },
247
253
  { name: 'data', type: 'record<string, unknown>', required: false, description: 'Primitive data when --primitive is used, or arbitrary node metadata.' },
248
254
  { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
@@ -259,6 +265,9 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
259
265
  },
260
266
  notes: [
261
267
  'The CLI accepts --content as an alias and stores it as data.html so the renderer can load it.',
268
+ 'Normal html nodes are the default. Presentation mode is opt-in via presentation:true or the presentation primitive.',
269
+ 'HTML nodes persist data.contentSummary and data.agentSummary so agents can understand rich visual HTML without parsing the full iframe payload.',
270
+ 'Only presentation-marked HTML nodes expose a browser Present button for fullscreen review; use the presentation primitive for PowerPoint-like decks.',
262
271
  'Use `primitive` / `kind` with `data` to create reusable agent communication artifacts such as choice grids, plans, review sheets, explainers, and editors.',
263
272
  'HTML runs in a sandboxed iframe without same-origin access to the canvas host.',
264
273
  ],
@@ -291,6 +300,8 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
291
300
  },
292
301
  notes: [
293
302
  'HTTP callers may POST { type: "html-primitive", kind, data } or { type: "html", primitive: kind, data }; both create a normal html node with primitive metadata.',
303
+ 'Use kind "presentation" only when a PowerPoint-like deck is requested; created nodes persist presentation, slideCount, slideTitles, and optional presentationTheme metadata for agents.',
304
+ 'Presentation primitive data supports theme: "canvas" | "midnight" | "paper" | "aurora" or a custom color object with bg, panel, surface, border, text, textSecondary, textMuted, accent, and colorScheme.',
294
305
  'Interactive editor primitives include copy/export controls so the human can send edited state back to the agent.',
295
306
  ],
296
307
  },
@@ -76,10 +76,14 @@ export function getCanvasNodeTitle(node: CanvasNodeState): string | null {
76
76
  }
77
77
 
78
78
  export function getCanvasNodeContent(node: CanvasNodeState): string | null {
79
- if (node.type === 'html' && typeof node.data.htmlPrimitive === 'string') {
80
- const primitive = node.data.htmlPrimitive;
79
+ if (node.type === 'html') {
80
+ const primitive = typeof node.data.htmlPrimitive === 'string' ? node.data.htmlPrimitive : null;
81
81
  const description = pickString(node.data.description);
82
- return description ? `${primitive}: ${description}` : primitive;
82
+ return pickString(node.data.agentSummary)
83
+ ?? pickString(node.data.contentSummary)
84
+ ?? (primitive
85
+ ? (description ? `${primitive}: ${description}` : primitive)
86
+ : null);
83
87
  }
84
88
  return pickString(node.data.content)
85
89
  ?? pickString(node.data.fileContent)
@@ -92,12 +96,13 @@ export function getCanvasNodeContent(node: CanvasNodeState): string | null {
92
96
 
93
97
  export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode {
94
98
  const data = normalizeCanvasNodeData(node.type, node.data);
99
+ const normalizedNode = { ...node, data };
95
100
  return {
96
101
  ...node,
97
102
  data,
98
103
  kind: getCanvasNodeKind(node, data),
99
- title: getCanvasNodeTitle(node),
100
- content: getCanvasNodeContent(node),
104
+ title: getCanvasNodeTitle(normalizedNode),
105
+ content: getCanvasNodeContent(normalizedNode),
101
106
  path: pickString(data.path),
102
107
  url: pickString(data.url),
103
108
  provenance: pickProvenance(data.provenance),