pmx-canvas 0.1.20 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/dist/canvas/global.css +71 -0
  3. package/dist/canvas/index.js +94 -60
  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/canvas-serialization.d.ts +1 -0
  7. package/dist/types/server/html-node-summary.d.ts +2 -0
  8. package/dist/types/server/html-primitives.d.ts +9 -1
  9. package/dist/types/server/index.d.ts +8 -1
  10. package/docs/http-api.md +1 -1
  11. package/docs/mcp.md +4 -0
  12. package/docs/node-types.md +27 -5
  13. package/docs/screenshot.png +0 -0
  14. package/docs/sdk.md +1 -0
  15. package/package.json +1 -1
  16. package/skills/pmx-canvas/SKILL.md +10 -4
  17. package/skills/pmx-canvas/references/html-primitives.md +132 -0
  18. package/src/cli/agent.ts +34 -1
  19. package/src/cli/index.ts +3 -1
  20. package/src/client/App.tsx +1 -1
  21. package/src/client/canvas/CommandPalette.tsx +1 -1
  22. package/src/client/canvas/ExpandedNodeOverlay.tsx +115 -2
  23. package/src/client/canvas/auto-fit.ts +5 -1
  24. package/src/client/nodes/HtmlNode.tsx +125 -13
  25. package/src/client/state/sse-bridge.ts +1 -1
  26. package/src/client/theme/global.css +71 -0
  27. package/src/mcp/canvas-access.ts +31 -1
  28. package/src/mcp/server.ts +17 -3
  29. package/src/server/agent-context.ts +23 -1
  30. package/src/server/canvas-operations.ts +18 -5
  31. package/src/server/canvas-provenance.ts +8 -6
  32. package/src/server/canvas-schema.ts +11 -0
  33. package/src/server/canvas-serialization.ts +36 -5
  34. package/src/server/html-node-summary.ts +141 -0
  35. package/src/server/html-primitives.ts +328 -8
  36. package/src/server/index.ts +22 -3
  37. package/src/server/server.ts +27 -9
  38. package/src/server/spatial-analysis.ts +4 -2
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from 'preact/hooks';
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
2
2
  import { ContextNode } from '../nodes/ContextNode';
3
3
  import { FileNode } from '../nodes/FileNode';
4
4
  import { LedgerNode } from '../nodes/LedgerNode';
@@ -7,7 +7,7 @@ import { McpAppNode } from '../nodes/McpAppNode';
7
7
  import { StatusNode } from '../nodes/StatusNode';
8
8
  import { ImageNode } from '../nodes/ImageNode';
9
9
  import { WebpageNode } from '../nodes/WebpageNode';
10
- import { HtmlNode } from '../nodes/HtmlNode';
10
+ import { HtmlNode, shouldShowPresentationControls } from '../nodes/HtmlNode';
11
11
  import { PromptNode } from '../nodes/PromptNode';
12
12
  import { ResponseNode } from '../nodes/ResponseNode';
13
13
  import { TraceNode } from '../nodes/TraceNode';
@@ -81,15 +81,74 @@ function wordCount(text: string): number {
81
81
  return text.split(/\s+/).filter(Boolean).length;
82
82
  }
83
83
 
84
+ function isPresentationExitMessage(value: unknown, token: string): boolean {
85
+ return value !== null &&
86
+ typeof value === 'object' &&
87
+ (value as { source?: unknown }).source === 'pmx-canvas-html-node' &&
88
+ (value as { type?: unknown }).type === 'presentation-exit' &&
89
+ (value as { token?: unknown }).token === token;
90
+ }
91
+
92
+ function isPresentationNavigationKey(key: string): boolean {
93
+ return key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'ArrowLeft' || key === 'PageUp' || key === 'Home' || key === 'End';
94
+ }
95
+
96
+ function isPresentationExitButtonTarget(target: EventTarget | null): boolean {
97
+ return target instanceof HTMLElement && Boolean(target.closest('.html-presentation-exit'));
98
+ }
99
+
84
100
  export function ExpandedNodeOverlay() {
85
101
  const nodeId = expandedNodeId.value;
86
102
  const node = nodeId ? nodes.value.get(nodeId) : undefined;
87
103
  const [copied, setCopied] = useState(false);
104
+ const [presenting, setPresenting] = useState(false);
105
+ const [presentationExitToken, setPresentationExitToken] = useState('');
106
+ const presentationOverlayRef = useRef<HTMLDivElement>(null);
107
+ const presentationExitButtonRef = useRef<HTMLButtonElement>(null);
88
108
 
89
109
  const handleClose = useCallback(() => {
110
+ setPresenting(false);
90
111
  collapseExpandedNode();
91
112
  }, []);
92
113
 
114
+ const handlePresent = useCallback(() => {
115
+ setPresentationExitToken(`presentation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`);
116
+ setPresenting(true);
117
+ }, []);
118
+
119
+ const postPresentationMessage = useCallback((message: Record<string, unknown>) => {
120
+ const frame = document.querySelector<HTMLIFrameElement>('.html-presentation-overlay iframe.html-node-frame-presentation');
121
+ frame?.contentWindow?.postMessage({
122
+ source: 'pmx-canvas-html-node',
123
+ token: presentationExitToken,
124
+ ...message,
125
+ }, '*');
126
+ }, [presentationExitToken]);
127
+
128
+ const handleExitPresentation = useCallback(() => {
129
+ setPresenting(false);
130
+ }, []);
131
+
132
+ const handlePresentationKeyDown = useCallback((event: KeyboardEvent) => {
133
+ if (event.key === 'Escape') {
134
+ event.preventDefault();
135
+ event.stopPropagation();
136
+ setPresenting(false);
137
+ return;
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;
146
+ if (!isPresentationNavigationKey(event.key)) return;
147
+ event.preventDefault();
148
+ event.stopPropagation();
149
+ postPresentationMessage({ type: 'presentation-key', key: event.key });
150
+ }, [postPresentationMessage]);
151
+
93
152
  const handleBackdropPointerDown = useCallback((e: PointerEvent) => {
94
153
  if ((e.target as HTMLElement).classList.contains('expanded-overlay-backdrop')) {
95
154
  collapseExpandedNode();
@@ -111,6 +170,31 @@ export function ExpandedNodeOverlay() {
111
170
  toggleContextPin(nodeId);
112
171
  }, [nodeId]);
113
172
 
173
+ useEffect(() => {
174
+ setPresenting(false);
175
+ }, [nodeId]);
176
+
177
+ useLayoutEffect(() => {
178
+ if (!presenting) return;
179
+ const focusPresentationOverlay = () => {
180
+ const overlay = presentationOverlayRef.current;
181
+ if (!overlay || overlay.contains(document.activeElement)) return;
182
+ overlay.focus();
183
+ };
184
+ const focusTimers = [0, 50, 150].map((delay) => window.setTimeout(focusPresentationOverlay, delay));
185
+ const handleMessage = (event: MessageEvent) => {
186
+ if (!isPresentationExitMessage(event.data, presentationExitToken)) return;
187
+ setPresenting(false);
188
+ };
189
+ document.addEventListener('keydown', handlePresentationKeyDown, true);
190
+ window.addEventListener('message', handleMessage);
191
+ return () => {
192
+ focusTimers.forEach((timer) => window.clearTimeout(timer));
193
+ document.removeEventListener('keydown', handlePresentationKeyDown, true);
194
+ window.removeEventListener('message', handleMessage);
195
+ };
196
+ }, [handlePresentationKeyDown, presentationExitToken, presenting]);
197
+
114
198
  if (!node) return null;
115
199
 
116
200
  const title =
@@ -123,6 +207,7 @@ export function ExpandedNodeOverlay() {
123
207
  const hasText = textContent.length > 0;
124
208
  const pendingClose = pendingExpandedNodeCloseId.value === nodeId;
125
209
  const isEmbeddedViewer = node.type === 'mcp-app' || node.type === 'webpage' || node.type === 'json-render' || node.type === 'graph';
210
+ const canPresent = shouldShowPresentationControls(node);
126
211
 
127
212
  return (
128
213
  <div
@@ -218,6 +303,17 @@ export function ExpandedNodeOverlay() {
218
303
  </button>
219
304
  )}
220
305
 
306
+ {canPresent && (
307
+ <button
308
+ type="button"
309
+ class="expanded-action-btn expanded-action-primary"
310
+ onClick={handlePresent}
311
+ title="Present this HTML node fullscreen"
312
+ >
313
+ Present
314
+ </button>
315
+ )}
316
+
221
317
  {/* Word count */}
222
318
  {words > 0 && (
223
319
  <span class="expanded-meta">
@@ -270,6 +366,23 @@ export function ExpandedNodeOverlay() {
270
366
  </div>
271
367
  ) : renderContent(node, true)}
272
368
  </div>
369
+ {canPresent && presenting && (
370
+ <div ref={presentationOverlayRef} class="html-presentation-overlay" role="dialog" aria-modal="true" aria-label={`Present ${title}`} tabIndex={-1} onKeyDownCapture={handlePresentationKeyDown}>
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>
381
+ <div class="html-presentation-stage">
382
+ <HtmlNode node={node} expanded presentation presentationExitToken={presentationExitToken} />
383
+ </div>
384
+ </div>
385
+ )}
273
386
  </div>
274
387
  </div>
275
388
  );
@@ -11,8 +11,12 @@ function hasExplicitStructuredFrame(node: CanvasNodeState): boolean {
11
11
  return node.type === 'graph' || node.type === 'json-render';
12
12
  }
13
13
 
14
+ function isPresentationHtmlNode(node: CanvasNodeState): boolean {
15
+ return node.type === 'html' && node.data.presentation === true;
16
+ }
17
+
14
18
  export function shouldAutoFitNode(node: CanvasNodeState): boolean {
15
- return !node.collapsed && !node.dockPosition && node.data.strictSize !== true && node.type !== 'group' && !isExtAppNode(node) && !hasExplicitStructuredFrame(node);
19
+ return !node.collapsed && !node.dockPosition && node.data.strictSize !== true && node.type !== 'group' && !isExtAppNode(node) && !hasExplicitStructuredFrame(node) && !isPresentationHtmlNode(node);
16
20
  }
17
21
 
18
22
  export function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null {
@@ -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 ? 0 : '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,65 @@ 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
+ padding: 0;
2476
+ background:
2477
+ radial-gradient(circle at top left, var(--c-accent-25), transparent 36rem),
2478
+ rgba(3, 7, 18, 0.96);
2479
+ color: var(--c-text);
2480
+ }
2481
+
2482
+ .html-presentation-exit {
2483
+ position: fixed;
2484
+ top: 12px;
2485
+ right: 12px;
2486
+ z-index: 1;
2487
+ padding: 10px 14px;
2488
+ border: 1px solid var(--c-line);
2489
+ border-radius: 999px;
2490
+ background: var(--c-panel-glass);
2491
+ box-shadow: 0 18px 50px var(--c-shadow-heavy);
2492
+ color: var(--c-text-soft);
2493
+ cursor: pointer;
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;
2499
+ }
2500
+
2501
+ .html-presentation-exit:hover,
2502
+ .html-presentation-exit:focus-visible {
2503
+ border-color: var(--c-accent);
2504
+ color: var(--c-text);
2505
+ }
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
+
2514
+ .html-presentation-stage {
2515
+ flex: 1;
2516
+ min-height: 0;
2517
+ display: flex;
2518
+ border-radius: 0;
2519
+ background: var(--c-bg);
2520
+ overflow: hidden;
2521
+ }
2522
+
2523
+ .html-node-frame-presentation {
2524
+ flex: 1;
2525
+ min-height: 0;
2526
+ border-radius: 0 !important;
2527
+ }
2528
+
2458
2529
  /* ── Context pin button on node title bar ────────────────────── */
2459
2530
  .node-controls .ctx-pin-btn {
2460
2531
  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': {
@@ -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 } : {}),
@@ -1552,8 +1557,16 @@ export async function executeCanvasBatch(
1552
1557
  };
1553
1558
  } else {
1554
1559
  const data = isPlainRecord(args.data) ? args.data : {};
1555
- const htmlData = type === 'html' && typeof args.html === 'string'
1556
- ? { ...data, html: args.html }
1560
+ const htmlData = type === 'html'
1561
+ ? {
1562
+ ...data,
1563
+ ...(typeof args.html === 'string' ? { html: args.html } : {}),
1564
+ ...(typeof args.summary === 'string' ? { summary: args.summary } : {}),
1565
+ ...(typeof args.agentSummary === 'string' ? { agentSummary: args.agentSummary } : {}),
1566
+ ...(typeof args.description === 'string' ? { description: args.description } : {}),
1567
+ ...(Array.isArray(args.embeddedNodeIds) ? { embeddedNodeIds: args.embeddedNodeIds } : {}),
1568
+ ...(Array.isArray(args.embeddedUrls) ? { embeddedUrls: args.embeddedUrls } : {}),
1569
+ }
1557
1570
  : data;
1558
1571
  const created = addCanvasNode({
1559
1572
  type: type as CanvasNodeState['type'],