pmx-canvas 0.1.28 → 0.1.30

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 (67) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/Readme.md +20 -10
  3. package/dist/canvas/global.css +13 -0
  4. package/dist/canvas/index.js +80 -163
  5. package/dist/canvas/surface-theme.css +142 -0
  6. package/dist/json-render/index.js +103 -103
  7. package/dist/types/client/nodes/HtmlNode.d.ts +0 -7
  8. package/dist/types/client/nodes/ax-node-actions.d.ts +18 -0
  9. package/dist/types/client/nodes/surface-url.d.ts +22 -0
  10. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  11. package/dist/types/client/state/intent-bridge.d.ts +17 -0
  12. package/dist/types/json-render/renderer/index.d.ts +2 -0
  13. package/dist/types/json-render/schema.d.ts +2 -0
  14. package/dist/types/json-render/server.d.ts +2 -0
  15. package/dist/types/mcp/canvas-access.d.ts +47 -0
  16. package/dist/types/server/ax-interaction.d.ts +210 -0
  17. package/dist/types/server/ax-state.d.ts +67 -1
  18. package/dist/types/server/canvas-db.d.ts +4 -0
  19. package/dist/types/server/canvas-serialization.d.ts +2 -0
  20. package/dist/types/server/canvas-state.d.ts +47 -2
  21. package/dist/types/server/html-surface.d.ts +40 -0
  22. package/dist/types/server/index.d.ts +56 -2
  23. package/dist/types/server/mutation-history.d.ts +1 -1
  24. package/dist/types/server/placement.d.ts +1 -1
  25. package/dist/types/shared/surface.d.ts +19 -0
  26. package/docs/cli.md +30 -0
  27. package/docs/http-api.md +55 -0
  28. package/docs/mcp.md +40 -2
  29. package/docs/node-types.md +26 -0
  30. package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
  31. package/docs/sdk.md +20 -0
  32. package/package.json +2 -2
  33. package/skills/pmx-canvas/SKILL.md +107 -9
  34. package/src/cli/agent.ts +190 -0
  35. package/src/client/canvas/CanvasNode.tsx +8 -4
  36. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
  37. package/src/client/nodes/ContextNode.tsx +17 -0
  38. package/src/client/nodes/ExtAppFrame.tsx +40 -3
  39. package/src/client/nodes/FileNode.tsx +26 -0
  40. package/src/client/nodes/HtmlNode.tsx +60 -188
  41. package/src/client/nodes/LedgerNode.tsx +39 -5
  42. package/src/client/nodes/McpAppNode.tsx +47 -2
  43. package/src/client/nodes/StatusNode.tsx +20 -0
  44. package/src/client/nodes/ax-node-actions.ts +39 -0
  45. package/src/client/nodes/surface-url.ts +48 -0
  46. package/src/client/state/attention-bridge.ts +5 -0
  47. package/src/client/state/intent-bridge.ts +33 -0
  48. package/src/client/theme/global.css +13 -0
  49. package/src/client/theme/surface-theme.css +142 -0
  50. package/src/json-render/renderer/index.tsx +31 -0
  51. package/src/json-render/schema.ts +4 -0
  52. package/src/json-render/server.ts +31 -1
  53. package/src/mcp/canvas-access.ts +212 -1
  54. package/src/mcp/server.ts +238 -5
  55. package/src/server/ax-context.ts +3 -0
  56. package/src/server/ax-interaction.ts +549 -0
  57. package/src/server/ax-state.ts +188 -2
  58. package/src/server/canvas-db.ts +20 -0
  59. package/src/server/canvas-operations.ts +11 -0
  60. package/src/server/canvas-serialization.ts +9 -0
  61. package/src/server/canvas-state.ts +177 -16
  62. package/src/server/html-surface.ts +170 -0
  63. package/src/server/index.ts +105 -1
  64. package/src/server/mutation-history.ts +5 -0
  65. package/src/server/placement.ts +5 -1
  66. package/src/server/server.ts +305 -0
  67. package/src/shared/surface.ts +38 -0
@@ -1,181 +1,9 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
2
  import { canvasTheme } from '../state/canvas-store';
3
- import { getCanvasTokens } from '../theme/tokens';
3
+ import { submitAxInteractionFromClient } from '../state/intent-bridge';
4
+ import { showToast } from '../state/attention-bridge';
4
5
  import type { CanvasNodeState } from '../types';
5
- import { useIframeDocument } from './iframe-document-url';
6
-
7
- /**
8
- * Strip characters that could break out of a CSS custom-property value context
9
- * before interpolating into a `<style>` block. The expected token shape is a
10
- * CSS color (`#abc`, `rgb(...)`) or font-family list, neither of which needs
11
- * `<`, `>`, `{`, `}`, `;`, or backticks. Defense-in-depth against a future
12
- * scenario where theme tokens become runtime-editable.
13
- */
14
- function sanitizeCssTokenValue(value: string): string {
15
- return value.replace(/[<>{};`\\]/g, '').trim();
16
- }
17
-
18
- /**
19
- * Build a `<style>` block that exposes canvas theme tokens to the iframe under
20
- * both the canonical `--c-*` names and common `--color-*` aliases. Also sets
21
- * sensible body defaults (font, bg, color) so authored HTML inherits the look.
22
- */
23
- function buildThemeStyleBlock(): string {
24
- const raw = getCanvasTokens();
25
- const t = {
26
- bg: sanitizeCssTokenValue(raw.bg),
27
- panel: sanitizeCssTokenValue(raw.panel),
28
- panelSoft: sanitizeCssTokenValue(raw.panelSoft),
29
- line: sanitizeCssTokenValue(raw.line),
30
- text: sanitizeCssTokenValue(raw.text),
31
- textSoft: sanitizeCssTokenValue(raw.textSoft),
32
- muted: sanitizeCssTokenValue(raw.muted),
33
- dim: sanitizeCssTokenValue(raw.dim),
34
- accent: sanitizeCssTokenValue(raw.accent),
35
- ok: sanitizeCssTokenValue(raw.ok),
36
- warn: sanitizeCssTokenValue(raw.warn),
37
- warnAlt: sanitizeCssTokenValue(raw.warnAlt),
38
- danger: sanitizeCssTokenValue(raw.danger),
39
- purple: sanitizeCssTokenValue(raw.purple),
40
- font: sanitizeCssTokenValue(raw.font),
41
- mono: sanitizeCssTokenValue(raw.mono),
42
- };
43
- return `
44
- :root {
45
- --c-bg: ${t.bg};
46
- --c-panel: ${t.panel};
47
- --c-panel-soft: ${t.panelSoft};
48
- --c-line: ${t.line};
49
- --c-text: ${t.text};
50
- --c-text-soft: ${t.textSoft};
51
- --c-muted: ${t.muted};
52
- --c-dim: ${t.dim};
53
- --c-accent: ${t.accent};
54
- --c-ok: ${t.ok};
55
- --c-warn: ${t.warn};
56
- --c-warn-alt: ${t.warnAlt};
57
- --c-danger: ${t.danger};
58
- --c-purple: ${t.purple};
59
-
60
- /* Common aliases authored HTML might use. */
61
- --color-bg: ${t.bg};
62
- --color-panel: ${t.panel};
63
- --color-surface: ${t.panelSoft};
64
- --color-border: ${t.line};
65
- --color-text: ${t.text};
66
- --color-text-primary: ${t.text};
67
- --color-text-secondary: ${t.textSoft};
68
- --color-text-muted: ${t.muted};
69
- --color-text-dim: ${t.dim};
70
- --color-accent: ${t.accent};
71
- --color-success: ${t.ok};
72
- --color-warning: ${t.warn};
73
- --color-danger: ${t.danger};
74
-
75
- --font: ${t.font};
76
- --font-sans: ${t.font};
77
- --font-mono: ${t.mono};
78
-
79
- color-scheme: dark light;
80
- }
81
- html, body {
82
- margin: 0;
83
- padding: 0;
84
- background: ${t.bg};
85
- color: ${t.text};
86
- font-family: ${t.font || 'system-ui, sans-serif'};
87
- font-size: 14px;
88
- line-height: 1.5;
89
- }
90
- body { padding: 16px; box-sizing: border-box; }
91
- a { color: ${t.accent}; }
92
- `;
93
- }
94
-
95
- /**
96
- * Inject the theme style block into the user-supplied HTML. If the document has
97
- * a `<head>`, inject at the top of head; otherwise wrap the content in a full
98
- * document. Returns a complete HTML string suitable for `srcdoc`.
99
- */
100
- function buildPresentationEscapeBridge(exitToken?: string): string {
101
- const token = JSON.stringify(exitToken ?? '');
102
- return `<script data-pmx-canvas-presentation-bridge>
103
- const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
104
- document.addEventListener('keydown', (event) => {
105
- if (event.key === 'Escape') {
106
- window.parent.postMessage({ source: 'pmx-canvas-html-node', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
107
- }
108
- }, true);
109
- window.addEventListener('message', (event) => {
110
- const message = event.data;
111
- if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
112
- if (typeof message.key !== 'string') return;
113
- if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
114
- window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
115
- return;
116
- }
117
- document.dispatchEvent(new CustomEvent('pmx-presentation-key', { detail: { key: message.key }, bubbles: true, cancelable: true }));
118
- document.dispatchEvent(new KeyboardEvent('keydown', { key: message.key, bubbles: true, cancelable: true }));
119
- });
120
- </script>`;
121
- }
122
-
123
- function buildThemeBridge(themeToken: string): string {
124
- const token = JSON.stringify(themeToken);
125
- return `<script data-pmx-canvas-theme-bridge>
126
- const PMX_CANVAS_THEME_TOKEN = ${token};
127
- window.addEventListener('message', (event) => {
128
- const message = event.data;
129
- if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
130
- if (typeof message.css !== 'string' || typeof message.theme !== 'string') return;
131
- let style = document.querySelector('style[data-pmx-canvas-theme]');
132
- if (!style) {
133
- style = document.createElement('style');
134
- style.setAttribute('data-pmx-canvas-theme', '');
135
- document.head.prepend(style);
136
- }
137
- style.textContent = message.css;
138
- document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
139
- document.documentElement.setAttribute('data-theme', message.theme);
140
- });
141
- </script>`;
142
- }
143
-
144
- function injectIntoHead(html: string, content: string): string {
145
- if (/<head[\s>]/i.test(html)) {
146
- return html.replace(/<head([^>]*)>/i, `<head$1>${content}`);
147
- }
148
- if (/<html[\s>]/i.test(html)) {
149
- return html.replace(/<html([^>]*)>/i, `<html$1><head>${content}</head>`);
150
- }
151
- return html;
152
- }
153
-
154
- function buildSrcDoc(userHtml: string, options: { presentation?: boolean; presentationExitToken?: string; themeToken: string; themeCss: string; theme: string }): string {
155
- const styleBlock = `<style data-pmx-canvas-theme>${options.themeCss}</style>`;
156
- const themeBridge = buildThemeBridge(options.themeToken);
157
- const presentationBridge = options.presentation ? buildPresentationEscapeBridge(options.presentationExitToken) : '';
158
- const injectedHeadContent = `${styleBlock}${themeBridge}${presentationBridge}`;
159
- const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
160
- const trimmed = userHtml.trim();
161
- const isFullDoc = /<html[\s>]/i.test(trimmed);
162
- if (isFullDoc) {
163
- const withTheme = trimmed.replace(/<html([^>]*)>/i, `<html$1 data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}>`);
164
- return injectIntoHead(withTheme, injectedHeadContent);
165
- }
166
- // Fragment — wrap in full document.
167
- 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>`;
168
- }
169
-
170
- export function createHtmlNodeSrcDocForTest(userHtml: string, options: { theme: string; themeCss: string; themeToken?: string; presentation?: boolean; presentationExitToken?: string }): string {
171
- return buildSrcDoc(userHtml, {
172
- themeToken: options.themeToken ?? 'test-theme-token',
173
- theme: options.theme,
174
- themeCss: options.themeCss,
175
- presentation: options.presentation,
176
- presentationExitToken: options.presentationExitToken,
177
- });
178
- }
6
+ import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
179
7
 
180
8
  export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
181
9
  return node.type === 'html' && node.data.presentation === true;
@@ -190,16 +18,59 @@ export function HtmlNode({
190
18
  }: { node: CanvasNodeState; expanded?: boolean; presentation?: boolean; presentationExitToken?: string; autoFocus?: boolean }) {
191
19
  const iframeRef = useRef<HTMLIFrameElement>(null);
192
20
  const theme = canvasTheme.value;
193
- const themeToken = useMemo(() => `theme-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, []);
194
- const themeCss = useMemo(() => buildThemeStyleBlock(), [theme]);
21
+ // Stable per-mount nonce that authorizes parent → iframe theme-update messages.
22
+ const themeToken = useMemo(() => `theme-${crypto.randomUUID()}`, []);
23
+ // Per-mount nonce authorizing iframe → parent AX emits (Phase 3 HTML bridge).
24
+ const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
195
25
  const html = typeof node.data.html === 'string'
196
26
  ? node.data.html
197
27
  : typeof node.data.content === 'string'
198
28
  ? node.data.content
199
29
  : '';
200
- const srcDoc = useMemo(() => (html ? buildSrcDoc(html, { presentation, presentationExitToken, themeToken, themeCss, theme }) : ''), [html, presentation, presentationExitToken, themeToken]);
201
- const iframeSandbox = 'allow-scripts';
202
- const iframeDocument = useIframeDocument(srcDoc, iframeSandbox);
30
+ const v = useMemo(() => surfaceContentHash(html), [html]);
31
+
32
+ // The in-canvas iframe and the "Open as site" tab load the SAME server-rendered
33
+ // surface URL (/api/canvas/surface/:id) — one render path, no content fork.
34
+ // `theme` is intentionally excluded from the deps: live theme changes are pushed
35
+ // via postMessage below (no reload), while `v` reloads the frame when the HTML
36
+ // itself changes.
37
+ const surfaceSrc = useMemo(
38
+ () => (html
39
+ ? nodeSurfaceUrl(node.id, { theme, themeToken, present: presentation, presentToken: presentationExitToken, v, axToken })
40
+ : ''),
41
+ [html, presentation, presentationExitToken, themeToken, v, node.id, axToken],
42
+ );
43
+
44
+ // Phase 3 HTML bridge: receive window.PMX_AX.emit(...) messages from the
45
+ // sandboxed iframe, validate the nonce + node id, and submit the interaction
46
+ // through the capability-gated endpoint (the server re-validates capabilities).
47
+ useEffect(() => {
48
+ function onAxMessage(event: MessageEvent) {
49
+ // Bind to THIS node's own iframe (matches the ext-app bridge); the nonce +
50
+ // nodeId are a second gate, not the only one.
51
+ if (event.source !== iframeRef.current?.contentWindow) return;
52
+ const data = event.data as {
53
+ source?: string; token?: string; nodeId?: string;
54
+ interaction?: { type?: unknown; payload?: unknown };
55
+ } | null;
56
+ if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
57
+ const interaction = data.interaction;
58
+ if (!interaction || typeof interaction.type !== 'string') return;
59
+ void submitAxInteractionFromClient({
60
+ type: interaction.type,
61
+ sourceNodeId: node.id,
62
+ sourceSurface: 'html-node',
63
+ ...(interaction.payload && typeof interaction.payload === 'object'
64
+ ? { payload: interaction.payload as Record<string, unknown> }
65
+ : {}),
66
+ }).then((res) => {
67
+ if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [node.id]);
68
+ else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
69
+ });
70
+ }
71
+ window.addEventListener('message', onAxMessage);
72
+ return () => window.removeEventListener('message', onAxMessage);
73
+ }, [axToken, node.id]);
203
74
 
204
75
  useEffect(() => {
205
76
  iframeRef.current?.contentWindow?.postMessage({
@@ -207,16 +78,15 @@ export function HtmlNode({
207
78
  type: 'theme-update',
208
79
  token: themeToken,
209
80
  theme,
210
- css: themeCss,
211
81
  }, '*');
212
82
  if (autoFocus) iframeRef.current?.focus();
213
- }, [theme, themeCss, themeToken]);
83
+ }, [theme, themeToken]);
214
84
 
215
85
  useEffect(() => {
216
86
  if (!autoFocus) return;
217
87
  const id = window.setTimeout(() => iframeRef.current?.focus(), 0);
218
88
  return () => window.clearTimeout(id);
219
- }, [autoFocus, srcDoc]);
89
+ }, [autoFocus, surfaceSrc]);
220
90
 
221
91
  const handleFrameLoad = () => {
222
92
  iframeRef.current?.contentWindow?.postMessage({
@@ -224,7 +94,6 @@ export function HtmlNode({
224
94
  type: 'theme-update',
225
95
  token: themeToken,
226
96
  theme,
227
- css: themeCss,
228
97
  }, '*');
229
98
  if (autoFocus) iframeRef.current?.focus();
230
99
  };
@@ -241,15 +110,18 @@ export function HtmlNode({
241
110
  // `allow-same-origin` (would grant the iframe access to parent localStorage
242
111
  // and credentialed requests to the canvas origin), `allow-top-navigation`
243
112
  // (would let scripts redirect the parent window), or `allow-forms` (would
244
- // let the iframe POST back to the host). The whole html-node tier assumes
245
- // arbitrary author code runs inside this exact sandbox.
113
+ // let the iframe POST back to the host). The surface route reinforces this
114
+ // with a matching `Content-Security-Policy: sandbox allow-scripts` response
115
+ // header, so the document stays on an opaque origin even when opened as a
116
+ // standalone tab. The whole html-node tier assumes arbitrary author code runs
117
+ // inside this exact sandbox.
246
118
  return (
247
119
  <iframe
248
120
  ref={iframeRef}
249
121
  class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
250
122
  title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
251
- sandbox={iframeSandbox}
252
- {...iframeDocument.attributes}
123
+ sandbox="allow-scripts"
124
+ src={surfaceSrc}
253
125
  tabIndex={autoFocus ? 0 : undefined}
254
126
  onLoad={handleFrameLoad}
255
127
  style={{
@@ -1,12 +1,25 @@
1
1
  import type { CanvasNodeState } from '../types';
2
2
 
3
+ // Layout/internal metadata that lives in node.data but is not ledger content.
4
+ const HIDDEN_LEDGER_KEYS = new Set(['title', '__type', 'content', 'strictSize', 'arrangeLocked']);
5
+
3
6
  export function LedgerNode({ node }: { node: CanvasNodeState }) {
4
7
  const data = node.data as Record<string, unknown>;
5
8
 
6
- // Render key-value pairs from ledger summary
7
- const entries = Object.entries(data).filter(([key]) => key !== 'title' && key !== '__type');
9
+ // Body text renders as a log: one line per entry. CLI flags frequently deliver
10
+ // a literal "\n" (backslash-n, the shell does not expand it inside quotes)
11
+ // rather than a real newline, so split on both — plus CR/CRLF — instead of
12
+ // dropping the whole string on one wrapped line.
13
+ const rawContent = typeof data.content === 'string' ? data.content : '';
14
+ const lines = rawContent
15
+ .split(/\r\n|\r|\n|\\n/)
16
+ .map((line) => line.trimEnd())
17
+ .filter((line) => line.length > 0);
18
+
19
+ // Any remaining non-internal keys render as structured key/value rows.
20
+ const entries = Object.entries(data).filter(([key]) => !HIDDEN_LEDGER_KEYS.has(key));
8
21
 
9
- if (entries.length === 0) {
22
+ if (lines.length === 0 && entries.length === 0) {
10
23
  return (
11
24
  <div style={{ color: 'var(--c-dim)', fontSize: '12px', fontStyle: 'italic' }}>No ledger data</div>
12
25
  );
@@ -14,20 +27,41 @@ export function LedgerNode({ node }: { node: CanvasNodeState }) {
14
27
 
15
28
  return (
16
29
  <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px' }}>
30
+ {lines.length > 0 && (
31
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
32
+ {lines.map((line, i) => (
33
+ <div
34
+ key={i}
35
+ style={{
36
+ padding: '3px 0',
37
+ borderBottom: i < lines.length - 1 ? '1px solid rgba(45,55,90,0.3)' : 'none',
38
+ color: 'var(--c-text)',
39
+ fontFamily: 'var(--mono)',
40
+ fontSize: '11px',
41
+ whiteSpace: 'pre-wrap',
42
+ wordBreak: 'break-word',
43
+ }}
44
+ >
45
+ {line}
46
+ </div>
47
+ ))}
48
+ </div>
49
+ )}
17
50
  {entries.map(([key, value]) => (
18
51
  <div
19
52
  key={key}
20
53
  style={{
21
54
  display: 'flex',
22
55
  justifyContent: 'space-between',
56
+ gap: '8px',
23
57
  padding: '3px 0',
24
58
  borderBottom: '1px solid rgba(45,55,90,0.3)',
25
59
  }}
26
60
  >
27
- <span style={{ color: 'var(--c-muted)', fontSize: '11px' }}>
61
+ <span style={{ color: 'var(--c-muted)', fontSize: '11px', flexShrink: 0 }}>
28
62
  {key.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())}
29
63
  </span>
30
- <span style={{ color: 'var(--c-text)', fontFamily: 'var(--mono)', fontSize: '11px' }}>
64
+ <span style={{ color: 'var(--c-text)', fontFamily: 'var(--mono)', fontSize: '11px', textAlign: 'right', wordBreak: 'break-word' }}>
31
65
  {typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
32
66
  </span>
33
67
  </div>
@@ -1,8 +1,11 @@
1
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
1
2
  import type { CanvasNodeState } from '../types';
2
3
  import { canvasTheme } from '../state/canvas-store';
4
+ import { submitAxInteractionFromClient } from '../state/intent-bridge';
5
+ import { showToast } from '../state/attention-bridge';
3
6
  import { ExtAppFrame } from './ExtAppFrame';
4
7
 
5
- function withViewerParams(url: string, expanded: boolean, specVersion?: number): string {
8
+ function withViewerParams(url: string, expanded: boolean, specVersion?: number, axToken?: string): string {
6
9
  if (!url) return url;
7
10
  try {
8
11
  const resolved = new URL(url, window.location.origin);
@@ -11,6 +14,8 @@ function withViewerParams(url: string, expanded: boolean, specVersion?: number):
11
14
  // Streaming json-render nodes bump specVersion as patches accumulate; including
12
15
  // it in the src reloads the iframe so it re-reads the latest accumulated spec.
13
16
  if (typeof specVersion === 'number') resolved.searchParams.set('v', String(specVersion));
17
+ // AX bridge nonce for json-render/graph viewer nodes (Phase 6 follow-up).
18
+ if (axToken) resolved.searchParams.set('axToken', axToken);
14
19
  return resolved.toString();
15
20
  } catch {
16
21
  return url;
@@ -33,9 +38,48 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
33
38
  if (node.data.mode === 'ext-app') {
34
39
  return <ExtAppFrame node={node} expanded={expanded} />;
35
40
  }
41
+ return <McpAppViewer node={node} expanded={expanded} />;
42
+ }
43
+
44
+ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boolean }) {
45
+ const iframeRef = useRef<HTMLIFrameElement>(null);
46
+ // json-render / graph viewers run the json-render bundle, which can forward
47
+ // spec actions named ax.* to us. Other viewers (web-artifact, hosted URLs) do not.
48
+ const isAxViewer = node.type === 'json-render' || node.type === 'graph';
49
+ const axToken = useMemo(() => (isAxViewer ? `ax-${crypto.randomUUID()}` : ''), [isAxViewer]);
50
+
51
+ // Receive AX emits forwarded by the json-render viewer; validate (bound to this
52
+ // node's iframe + nonce + node id) and submit through the capability-gated
53
+ // endpoint, which re-validates server-side.
54
+ useEffect(() => {
55
+ if (!isAxViewer || !axToken) return;
56
+ function onAxMessage(event: MessageEvent) {
57
+ if (event.source !== iframeRef.current?.contentWindow) return;
58
+ const data = event.data as {
59
+ source?: string; token?: string; nodeId?: string;
60
+ interaction?: { type?: unknown; payload?: unknown };
61
+ } | null;
62
+ if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
63
+ const interaction = data.interaction;
64
+ if (!interaction || typeof interaction.type !== 'string') return;
65
+ void submitAxInteractionFromClient({
66
+ type: interaction.type,
67
+ sourceNodeId: node.id,
68
+ sourceSurface: 'json-render',
69
+ ...(interaction.payload && typeof interaction.payload === 'object'
70
+ ? { payload: interaction.payload as Record<string, unknown> }
71
+ : {}),
72
+ }).then((res) => {
73
+ if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [node.id]);
74
+ else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
75
+ });
76
+ }
77
+ window.addEventListener('message', onAxMessage);
78
+ return () => window.removeEventListener('message', onAxMessage);
79
+ }, [isAxViewer, axToken, node.id]);
36
80
 
37
81
  const specVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : undefined;
38
- const url = withViewerParams((node.data.url as string) || '', expanded, specVersion);
82
+ const url = withViewerParams((node.data.url as string) || '', expanded, specVersion, axToken || undefined);
39
83
  const sourceServer = (node.data.sourceServer as string) || '';
40
84
  const hostMode = (node.data.hostMode as string) || 'hosted';
41
85
  const fallbackReason = node.data.fallbackReason as string | undefined;
@@ -96,6 +140,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
96
140
  the explicit postMessage bridge instead, which is the only path that needs
97
141
  app/host RPC and broader capabilities. */}
98
142
  <iframe
143
+ ref={iframeRef}
99
144
  src={url}
100
145
  class="mcp-app-frame"
101
146
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
@@ -1,5 +1,6 @@
1
1
  import { PHASE_COLORS } from '../theme/tokens';
2
2
  import type { CanvasNodeState } from '../types';
3
+ import { axNodeActionButtonStyle, runNodeAxInteraction } from './ax-node-actions';
3
4
 
4
5
  export function getStatusDisplayPhase(node: CanvasNodeState): string {
5
6
  const phase = typeof node.data.phase === 'string' && node.data.phase.trim().length > 0
@@ -94,6 +95,25 @@ export function StatusNode({ node }: { node: CanvasNodeState }) {
94
95
  {message}
95
96
  </div>
96
97
  )}
98
+
99
+ {/* AX: turn this status into a tracked work item */}
100
+ <button
101
+ type="button"
102
+ class="ax-node-action"
103
+ title="Create an AX work item tied to this node"
104
+ style={axNodeActionButtonStyle}
105
+ onClick={(e) => {
106
+ e.stopPropagation();
107
+ void runNodeAxInteraction(
108
+ node,
109
+ 'ax.work.create',
110
+ { title: (node.data.title as string) || message || phase || 'Status update' },
111
+ 'Tracked as work',
112
+ );
113
+ }}
114
+ >
115
+ Track as work
116
+ </button>
97
117
  </div>
98
118
  );
99
119
  }
@@ -0,0 +1,39 @@
1
+ import { submitAxInteractionFromClient } from '../state/intent-bridge';
2
+ import { showToast } from '../state/attention-bridge';
3
+ import type { CanvasNodeState } from '../types';
4
+
5
+ /**
6
+ * Submit a native-node AX interaction (plan-004 Phase 2) and surface the outcome
7
+ * as a transient toast. Inline node controls call this; the server enforces the
8
+ * node's capabilities, so a denied interaction simply shows an error toast.
9
+ */
10
+ export async function runNodeAxInteraction(
11
+ node: CanvasNodeState,
12
+ type: string,
13
+ payload: Record<string, unknown> | undefined,
14
+ successTitle: string,
15
+ ): Promise<void> {
16
+ const res = await submitAxInteractionFromClient({
17
+ type,
18
+ sourceNodeId: node.id,
19
+ sourceSurface: 'native-node',
20
+ ...(payload ? { payload } : {}),
21
+ });
22
+ if (res.ok) {
23
+ showToast('context', successTitle, '', [node.id]);
24
+ } else {
25
+ showToast('remove', 'AX action failed', res.error ?? res.code ?? 'Unknown error', [node.id]);
26
+ }
27
+ }
28
+
29
+ /** Shared style for the small inline AX action button on native nodes. */
30
+ export const axNodeActionButtonStyle = {
31
+ padding: '3px 8px',
32
+ fontSize: '10px',
33
+ background: 'var(--c-accent-12)',
34
+ border: '1px solid var(--c-accent-25)',
35
+ borderRadius: '4px',
36
+ color: 'var(--c-text-soft)',
37
+ cursor: 'pointer',
38
+ flexShrink: 0,
39
+ } as const;
@@ -0,0 +1,48 @@
1
+ import { canvasTheme } from '../state/canvas-store';
2
+ import { canOpenNodeAsSurface } from '../../shared/surface.js';
3
+ import type { CanvasNodeState } from '../types';
4
+
5
+ /**
6
+ * Stable content hash (djb2) used to cache-bust the surface iframe `src` when a
7
+ * node's HTML changes. The server always serves current state, but a same `src`
8
+ * string won't reload the iframe on its own — bumping `?v=` does.
9
+ */
10
+ export function surfaceContentHash(input: string): string {
11
+ let h = 5381;
12
+ for (let i = 0; i < input.length; i += 1) {
13
+ h = ((h << 5) + h + input.charCodeAt(i)) | 0;
14
+ }
15
+ return (h >>> 0).toString(36);
16
+ }
17
+
18
+ export interface SurfaceUrlOptions {
19
+ theme?: string;
20
+ themeToken?: string;
21
+ present?: boolean;
22
+ presentToken?: string;
23
+ v?: string;
24
+ /** Nonce authorizing iframe → parent AX emits (html bridge). */
25
+ axToken?: string;
26
+ }
27
+
28
+ /** Build the stable per-node surface URL (/api/canvas/surface/:id) the iframe and "Open as site" both use. */
29
+ export function nodeSurfaceUrl(nodeId: string, opts: SurfaceUrlOptions = {}): string {
30
+ const params = new URLSearchParams();
31
+ params.set('theme', opts.theme ?? canvasTheme.value);
32
+ if (opts.themeToken) params.set('themeToken', opts.themeToken);
33
+ if (opts.present) params.set('present', '1');
34
+ if (opts.presentToken) params.set('presentToken', opts.presentToken);
35
+ if (opts.v) params.set('v', opts.v);
36
+ if (opts.axToken) params.set('axToken', opts.axToken);
37
+ return `/api/canvas/surface/${encodeURIComponent(nodeId)}?${params.toString()}`;
38
+ }
39
+
40
+ /** Whether a node can be opened as a standalone site (shared with the server). */
41
+ export function canOpenAsSite(node: CanvasNodeState): boolean {
42
+ return canOpenNodeAsSurface(node.type, node.data as Record<string, unknown>);
43
+ }
44
+
45
+ /** Open the node's surface in a new browser tab. */
46
+ export function openNodeAsSite(node: CanvasNodeState): void {
47
+ window.open(nodeSurfaceUrl(node.id), '_blank', 'noopener');
48
+ }
@@ -275,6 +275,11 @@ function enqueueToast(entry: AttentionEntry): void {
275
275
  flushToastQueue();
276
276
  }
277
277
 
278
+ /** Show a transient toast from arbitrary client code (e.g. AX action feedback). */
279
+ export function showToast(tone: AttentionTone, title: string, detail = '', nodeIds: string[] = []): void {
280
+ enqueueToast(makeEntry(tone, title, detail, nodeIds));
281
+ }
282
+
278
283
  function pulseNodes(nodeIds: string[]): void {
279
284
  cancelTimer(pulseTimer);
280
285
  pulseTimer = null;
@@ -236,6 +236,39 @@ export async function removeNodeFromClient(id: string): Promise<{ ok: boolean; r
236
236
  });
237
237
  }
238
238
 
239
+ // ── PMX-AX node interactions ──────────────────────────────────
240
+
241
+ export interface AxInteractionRequest {
242
+ type: string;
243
+ sourceNodeId: string;
244
+ payload?: Record<string, unknown>;
245
+ sourceSurface?: 'native-node' | 'json-render' | 'html-node' | 'mcp-app' | 'adapter';
246
+ }
247
+
248
+ export interface AxInteractionResponse {
249
+ ok: boolean;
250
+ type?: string;
251
+ sourceNodeId?: string;
252
+ primitive?: unknown;
253
+ status?: number;
254
+ code?: string;
255
+ error?: string;
256
+ }
257
+
258
+ /** Submit a capability-gated AX interaction from a native node control. */
259
+ export async function submitAxInteractionFromClient(input: AxInteractionRequest): Promise<AxInteractionResponse> {
260
+ return requestJson<AxInteractionResponse>(
261
+ 'submitAxInteractionFromClient',
262
+ '/api/canvas/ax/interaction',
263
+ { ok: false, code: 'request-failed', error: 'Request failed' },
264
+ {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({ sourceSurface: 'native-node', ...input, source: 'browser' }),
268
+ },
269
+ );
270
+ }
271
+
239
272
  /** Commit the current viewport to the authoritative server state. */
240
273
  export async function updateViewportFromClient(
241
274
  viewport: { x: number; y: number; scale: number },
@@ -1473,6 +1473,19 @@ html.is-node-resizing .ext-app-preview-catcher {
1473
1473
  border-radius: 0 0 calc(var(--radius) - 1px) calc(var(--radius) - 1px);
1474
1474
  }
1475
1475
 
1476
+ /* Promote canvas iframes to their own GPU compositing layer. An iframe embedded
1477
+ in the zoom/pan-transformed canvas — especially near the heavy backdrop-filter
1478
+ blur used across the chrome and behind group frames — can intermittently paint
1479
+ blank until a resize/zoom forces a repaint (reported for grouped HTML nodes;
1480
+ the same class of compositor glitch as the earlier Excalidraw flicker). A no-op
1481
+ 3D transform forces a stable layer so the iframe keeps painting through
1482
+ surrounding layout/stacking changes. */
1483
+ .mcp-app-frame,
1484
+ .html-node-frame {
1485
+ transform: translateZ(0);
1486
+ backface-visibility: hidden;
1487
+ }
1488
+
1476
1489
  /* ── Prompt nodes ──────────────────────────────────────────── */
1477
1490
  .canvas-node:has(.prompt-node-inner) {
1478
1491
  border-color: var(--c-accent-30);