pmx-canvas 0.1.14 → 0.1.16

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 (56) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +124 -74
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/ContextNode.d.ts +11 -2
  7. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  8. package/dist/types/client/nodes/StatusNode.d.ts +1 -0
  9. package/dist/types/client/state/canvas-store.d.ts +11 -3
  10. package/dist/types/client/state/intent-bridge.d.ts +5 -1
  11. package/dist/types/client/types.d.ts +2 -2
  12. package/dist/types/json-render/catalog.d.ts +1 -1
  13. package/dist/types/mcp/canvas-access.d.ts +7 -1
  14. package/dist/types/server/agent-context.d.ts +1 -0
  15. package/dist/types/server/canvas-operations.d.ts +4 -2
  16. package/dist/types/server/canvas-provenance.d.ts +1 -1
  17. package/dist/types/server/canvas-serialization.d.ts +3 -0
  18. package/dist/types/server/canvas-state.d.ts +51 -4
  19. package/dist/types/server/demo.d.ts +5 -0
  20. package/dist/types/server/index.d.ts +13 -3
  21. package/dist/types/server/web-artifacts.d.ts +18 -0
  22. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  23. package/package.json +1 -1
  24. package/skills/pmx-canvas/SKILL.md +43 -0
  25. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  26. package/src/cli/agent.ts +52 -5
  27. package/src/cli/index.ts +2 -23
  28. package/src/client/canvas/AttentionHistory.tsx +14 -1
  29. package/src/client/canvas/CanvasNode.tsx +1 -1
  30. package/src/client/canvas/CanvasViewport.tsx +3 -0
  31. package/src/client/canvas/ContextPinBar.tsx +2 -1
  32. package/src/client/canvas/DockedNode.tsx +112 -13
  33. package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -0
  34. package/src/client/canvas/Minimap.tsx +1 -0
  35. package/src/client/icons.tsx +1 -0
  36. package/src/client/nodes/ContextNode.tsx +128 -6
  37. package/src/client/nodes/HtmlNode.tsx +151 -0
  38. package/src/client/nodes/StatusNode.tsx +16 -1
  39. package/src/client/nodes/StatusSummary.tsx +2 -1
  40. package/src/client/state/canvas-store.ts +37 -7
  41. package/src/client/state/intent-bridge.ts +9 -4
  42. package/src/client/state/sse-bridge.ts +2 -1
  43. package/src/client/theme/global.css +141 -0
  44. package/src/client/types.ts +3 -0
  45. package/src/mcp/canvas-access.ts +34 -7
  46. package/src/mcp/server.ts +178 -25
  47. package/src/server/agent-context.ts +50 -3
  48. package/src/server/canvas-operations.ts +20 -3
  49. package/src/server/canvas-provenance.ts +2 -1
  50. package/src/server/canvas-serialization.ts +38 -13
  51. package/src/server/canvas-state.ts +305 -34
  52. package/src/server/demo.ts +792 -0
  53. package/src/server/index.ts +33 -3
  54. package/src/server/server.ts +98 -14
  55. package/src/server/web-artifacts.ts +116 -3
  56. package/src/shared/canvas-node-kind.ts +14 -0
@@ -8,6 +8,7 @@ import { StatusNode } from '../nodes/StatusNode';
8
8
  import { ImageNode } from '../nodes/ImageNode';
9
9
  import { GroupNode } from '../nodes/GroupNode';
10
10
  import { WebpageNode } from '../nodes/WebpageNode';
11
+ import { HtmlNode } from '../nodes/HtmlNode';
11
12
  import { PromptNode } from '../nodes/PromptNode';
12
13
  import { ResponseNode } from '../nodes/ResponseNode';
13
14
  import { TraceNode } from '../nodes/TraceNode';
@@ -60,6 +61,8 @@ function renderNodeContent(node: CanvasNodeState) {
60
61
  return <FileNode node={node} />;
61
62
  case 'image':
62
63
  return <ImageNode node={node} />;
64
+ case 'html':
65
+ return <HtmlNode node={node} />;
63
66
  case 'group':
64
67
  return <GroupNode node={node} />;
65
68
  default:
@@ -2,10 +2,11 @@ import {
2
2
  clearContextPins,
3
3
  contextPinnedNodeIds,
4
4
  } from '../state/canvas-store';
5
+ import { attentionHistoryOpen } from '../state/attention-store';
5
6
 
6
7
  export function ContextPinBar() {
7
8
  const count = contextPinnedNodeIds.value.size;
8
- if (count === 0) return null;
9
+ if (count === 0 || attentionHistoryOpen.value) return null;
9
10
 
10
11
  return (
11
12
  <div class="context-pin-bar">
@@ -2,7 +2,8 @@ import { ContextNode } from '../nodes/ContextNode';
2
2
  import { LedgerNode } from '../nodes/LedgerNode';
3
3
  import { StatusNode } from '../nodes/StatusNode';
4
4
  import { StatusSummary } from '../nodes/StatusSummary';
5
- import { toggleCollapsed, undockNode } from '../state/canvas-store';
5
+ import { attentionHistoryOpen, closeAttentionHistory } from '../state/attention-store';
6
+ import { getContextPinnedNodes, toggleCollapsed, undockNode } from '../state/canvas-store';
6
7
  import { TYPE_LABELS } from '../types';
7
8
  import type { CanvasNodeState } from '../types';
8
9
 
@@ -19,20 +20,120 @@ function renderDockedContent(node: CanvasNodeState) {
19
20
  }
20
21
  }
21
22
 
23
+ function getContextItemCount(node: CanvasNodeState): number {
24
+ const cards = Array.isArray(node.data.cards) ? (node.data.cards as unknown[]) : [];
25
+ const auxTabs = Array.isArray(node.data.auxTabs) ? (node.data.auxTabs as unknown[]) : [];
26
+ return cards.length + auxTabs.length;
27
+ }
28
+
29
+ function ContextDockedNode({ node }: { node: CanvasNodeState }) {
30
+ const pinnedNodes = getContextPinnedNodes();
31
+ const count = pinnedNodes.length > 0 ? pinnedNodes.length : getContextItemCount(node);
32
+ const hasItems = count > 0;
33
+ const collapsed = node.collapsed === true;
34
+
35
+ const expand = () => {
36
+ // Mutual exclusion with the Updates panel — only one side panel open at a
37
+ // time. They share the same right-edge anchor, so opening both at once
38
+ // would visually collide.
39
+ closeAttentionHistory();
40
+ toggleCollapsed(node.id);
41
+ };
42
+
43
+ // Hide the collapsed Context pill while the Updates side panel is open.
44
+ // Mutual exclusion guarantees both panels can't be expanded simultaneously,
45
+ // but the pill itself would otherwise sit beneath/beside the Updates panel
46
+ // at the same right edge — better to hide until Updates is closed.
47
+ if (collapsed && attentionHistoryOpen.value) return null;
48
+
49
+ if (collapsed) {
50
+ return (
51
+ <button
52
+ type="button"
53
+ class="context-dock-tab"
54
+ data-docked-node="true"
55
+ onClick={expand}
56
+ aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Context'}
57
+ title={hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Agent context'}
58
+ >
59
+ <svg
60
+ width="14"
61
+ height="14"
62
+ viewBox="0 0 16 16"
63
+ fill="none"
64
+ stroke="currentColor"
65
+ stroke-width="1.5"
66
+ stroke-linecap="round"
67
+ stroke-linejoin="round"
68
+ aria-hidden="true"
69
+ >
70
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
71
+ <line x1="1.5" y1="6" x2="14.5" y2="6" />
72
+ <circle cx="4" cy="4.25" r="0.6" fill="currentColor" stroke="none" />
73
+ </svg>
74
+ <span class="context-dock-tab-label">Context</span>
75
+ {hasItems && (
76
+ <span class="context-dock-tab-badge" aria-hidden="true">
77
+ {count > 99 ? '99+' : count}
78
+ </span>
79
+ )}
80
+ </button>
81
+ );
82
+ }
83
+
84
+ return (
85
+ <aside class="context-dock-panel" data-docked-node="true" aria-label="Agent context">
86
+ <div class="context-dock-header">
87
+ <div class="context-dock-header-text">
88
+ <span class="context-dock-title">Context</span>
89
+ <span class="context-dock-subtitle">
90
+ {hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Active agent context'}
91
+ </span>
92
+ </div>
93
+ <div class="context-dock-controls">
94
+ <button
95
+ type="button"
96
+ class="context-dock-icon-button"
97
+ onClick={(e) => {
98
+ e.stopPropagation();
99
+ undockNode(node.id);
100
+ }}
101
+ aria-label="Undock to canvas"
102
+ title="Undock to canvas"
103
+ >
104
+ {'\u2299'}
105
+ </button>
106
+ <button
107
+ type="button"
108
+ class="context-dock-icon-button"
109
+ onClick={(e) => {
110
+ e.stopPropagation();
111
+ toggleCollapsed(node.id);
112
+ }}
113
+ aria-label="Collapse context panel"
114
+ title="Collapse"
115
+ >
116
+ ×
117
+ </button>
118
+ </div>
119
+ </div>
120
+ <div class="context-dock-body">
121
+ <ContextNode node={node} pinnedNodes={pinnedNodes} />
122
+ </div>
123
+ </aside>
124
+ );
125
+ }
126
+
22
127
  export function DockedNode({ node }: { node: CanvasNodeState }) {
128
+ if (node.type === 'context') {
129
+ return <ContextDockedNode node={node} />;
130
+ }
131
+
23
132
  return (
24
- <div class="docked-node">
133
+ <div class="docked-node" data-docked-node="true">
25
134
  <div class="docked-node-header">
26
135
  <span class="node-type-badge">{TYPE_LABELS[node.type] ?? node.type}</span>
27
136
  {node.type === 'status' && node.collapsed && <StatusSummary node={node} />}
28
- {node.type === 'context' && node.collapsed && (
29
- <span style={{ fontSize: '11px', color: 'var(--c-muted)' }}>
30
- Active Agent Context
31
- {typeof node.data.utilization === 'number' && (
32
- <> · {Math.round(Number(node.data.utilization) * 100)}%</>
33
- )}
34
- </span>
35
- )}
36
137
  <div class="docked-node-controls">
37
138
  <button
38
139
  type="button"
@@ -57,9 +158,7 @@ export function DockedNode({ node }: { node: CanvasNodeState }) {
57
158
  </div>
58
159
  </div>
59
160
  {!node.collapsed && (
60
- <div class={`docked-node-body${node.type === 'context' ? ' context-body' : ''}`}>
61
- {renderDockedContent(node)}
62
- </div>
161
+ <div class="docked-node-body">{renderDockedContent(node)}</div>
63
162
  )}
64
163
  </div>
65
164
  );
@@ -7,6 +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
11
  import { PromptNode } from '../nodes/PromptNode';
11
12
  import { ResponseNode } from '../nodes/ResponseNode';
12
13
  import { TraceNode } from '../nodes/TraceNode';
@@ -49,6 +50,8 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
49
50
  return <FileNode node={node} expanded={expanded} />;
50
51
  case 'image':
51
52
  return <ImageNode node={node} expanded={expanded} />;
53
+ case 'html':
54
+ return <HtmlNode node={node} expanded={expanded} />;
52
55
  default:
53
56
  return <div>Unknown node type</div>;
54
57
  }
@@ -63,6 +66,8 @@ function getNodeTextContent(node: CanvasNodeState): string {
63
66
  return (node.data.fileContent as string) || '';
64
67
  case 'webpage':
65
68
  return (node.data.content as string) || '';
69
+ case 'html':
70
+ return (node.data.html as string) || (node.data.content as string) || '';
66
71
  case 'json-render':
67
72
  case 'graph':
68
73
  return JSON.stringify(node.data.spec ?? node.data.graphConfig ?? {}, null, 2);
@@ -37,6 +37,7 @@ function getNodeColors(): Record<CanvasNodeState['type'], string> {
37
37
  trace: t.purple,
38
38
  file: t.accent,
39
39
  image: t.ok,
40
+ html: t.warn,
40
41
  group: t.dim,
41
42
  };
42
43
  }
@@ -419,6 +419,7 @@ export function getNodeIcon(type: string): (p: IconProps) => JSX.Element {
419
419
  case 'ext-app': return IconNodeExtApp;
420
420
  case 'json-render': return IconNodeJsonRender;
421
421
  case 'graph': return IconNodeGraph;
422
+ case 'html': return IconNodeWebpage;
422
423
  default: return IconNodeMarkdown;
423
424
  }
424
425
  }
@@ -1,5 +1,5 @@
1
1
  import { openWorkbenchFile } from '../state/intent-bridge';
2
- import type { CanvasNodeState } from '../types';
2
+ import { TYPE_LABELS, type CanvasNodeState } from '../types';
3
3
 
4
4
  interface ContextCard {
5
5
  key?: string;
@@ -30,6 +30,14 @@ export interface ContextNodeFallbackDisplay {
30
30
  path: string;
31
31
  }
32
32
 
33
+ export interface PinnedContextDisplay {
34
+ id: string;
35
+ title: string;
36
+ summary: string;
37
+ kind: string;
38
+ path: string;
39
+ }
40
+
33
41
  export function normalizeContextCardDisplay(card: ContextCard): ContextCardDisplay {
34
42
  const title = card.title || card.label || card.key || 'Context';
35
43
  const summary = card.summary?.trim() || 'Available in startup context.';
@@ -90,6 +98,24 @@ export function normalizeContextNodeFallback(
90
98
  };
91
99
  }
92
100
 
101
+ export function normalizePinnedContextDisplay(node: CanvasNodeState): PinnedContextDisplay {
102
+ const title = asTrimmedString(node.data.title) || node.id;
103
+ const summary =
104
+ asTrimmedString(node.data.content) ||
105
+ asTrimmedString(node.data.excerpt) ||
106
+ asTrimmedString(node.data.description) ||
107
+ asTrimmedString(node.data.pageTitle) ||
108
+ '';
109
+ const path = asTrimmedString(node.data.path) || asTrimmedString(node.data.url);
110
+ return {
111
+ id: node.id,
112
+ title,
113
+ summary,
114
+ kind: TYPE_LABELS[node.type] ?? node.type,
115
+ path,
116
+ };
117
+ }
118
+
93
119
  function formatTokens(n: number | null): string {
94
120
  if (n === null || !Number.isFinite(n) || n < 0) return '0';
95
121
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`;
@@ -107,9 +133,12 @@ function usageBarColor(utilization: number): string {
107
133
  export function ContextNode({
108
134
  node,
109
135
  expanded = false,
110
- }: { node: CanvasNodeState; expanded?: boolean }) {
136
+ pinnedNodes = [],
137
+ }: { node: CanvasNodeState; expanded?: boolean; pinnedNodes?: CanvasNodeState[] }) {
111
138
  const cards = (node.data.cards as ContextCard[]) ?? [];
112
139
  const auxTabs = (node.data.auxTabs as Array<{ id: string; url: string; reason?: string }>) ?? [];
140
+ const pinnedContext = pinnedNodes.map(normalizePinnedContextDisplay);
141
+ const hasPinnedContext = pinnedContext.length > 0;
113
142
  const currentTokens =
114
143
  typeof node.data.currentTokens === 'number' ? node.data.currentTokens : null;
115
144
  const tokenLimit = typeof node.data.tokenLimit === 'number' ? node.data.tokenLimit : null;
@@ -120,7 +149,9 @@ export function ContextNode({
120
149
  utilization !== null ? Math.max(0, Math.min(100, Math.round(utilization * 100))) : null;
121
150
  const barColor = usageBarColor(utilization ?? 0);
122
151
  const fallback =
123
- cards.length === 0 && auxTabs.length === 0 ? normalizeContextNodeFallback(node.data) : null;
152
+ !hasPinnedContext && cards.length === 0 && auxTabs.length === 0
153
+ ? normalizeContextNodeFallback(node.data)
154
+ : null;
124
155
 
125
156
  const openCard = async (card: ContextCard): Promise<void> => {
126
157
  const path = typeof card.path === 'string' ? card.path.trim() : '';
@@ -181,7 +212,98 @@ export function ContextNode({
181
212
  </div>
182
213
  )}
183
214
 
184
- {cards.length > 0 && (
215
+ {hasPinnedContext && (
216
+ <div>
217
+ <div
218
+ style={{
219
+ fontSize: '10px',
220
+ fontWeight: 600,
221
+ color: 'var(--c-muted)',
222
+ textTransform: 'uppercase',
223
+ letterSpacing: '0.04em',
224
+ marginBottom: '6px',
225
+ }}
226
+ >
227
+ Pinned Context ({pinnedContext.length})
228
+ </div>
229
+ {pinnedContext.map((display) => (
230
+ <div
231
+ key={display.id}
232
+ style={{
233
+ padding: '6px 8px',
234
+ background: 'var(--c-surface-subtle)',
235
+ borderRadius: '6px',
236
+ marginBottom: '4px',
237
+ borderLeft: '2px solid var(--c-accent)',
238
+ }}
239
+ >
240
+ <div style={{ fontWeight: 600, color: 'var(--c-text)', marginBottom: '2px' }}>
241
+ {display.title}
242
+ </div>
243
+ {display.summary && (
244
+ <div
245
+ style={{
246
+ color: 'var(--c-muted)',
247
+ fontSize: '10px',
248
+ lineHeight: 1.45,
249
+ marginBottom: '4px',
250
+ whiteSpace: 'pre-wrap',
251
+ }}
252
+ >
253
+ {display.summary}
254
+ </div>
255
+ )}
256
+ <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '4px' }}>
257
+ <span
258
+ style={{
259
+ fontSize: '9px',
260
+ padding: '1px 4px',
261
+ background: 'var(--c-surface-hover)',
262
+ color: 'var(--c-text-soft)',
263
+ borderRadius: '3px',
264
+ display: 'inline-block',
265
+ }}
266
+ >
267
+ {display.kind}
268
+ </span>
269
+ </div>
270
+ {display.path && (
271
+ <div style={{ marginTop: '6px' }}>
272
+ <div
273
+ style={{
274
+ color: 'var(--c-dim)',
275
+ fontSize: '10px',
276
+ wordBreak: 'break-all',
277
+ marginBottom: '6px',
278
+ }}
279
+ >
280
+ {display.path}
281
+ </div>
282
+ {display.path.startsWith('/') && (
283
+ <button
284
+ type="button"
285
+ onClick={() => void openWorkbenchFile(display.path)}
286
+ style={{
287
+ padding: '4px 8px',
288
+ fontSize: '10px',
289
+ background: 'var(--c-accent-12)',
290
+ border: '1px solid var(--c-accent-25)',
291
+ borderRadius: '4px',
292
+ color: 'var(--c-text-soft)',
293
+ cursor: 'pointer',
294
+ }}
295
+ >
296
+ Open in canvas
297
+ </button>
298
+ )}
299
+ </div>
300
+ )}
301
+ </div>
302
+ ))}
303
+ </div>
304
+ )}
305
+
306
+ {!hasPinnedContext && cards.length > 0 && (
185
307
  <div>
186
308
  <div
187
309
  style={{
@@ -305,7 +427,7 @@ export function ContextNode({
305
427
  </div>
306
428
  )}
307
429
 
308
- {auxTabs.length > 0 && (
430
+ {!hasPinnedContext && auxTabs.length > 0 && (
309
431
  <div>
310
432
  <div
311
433
  style={{
@@ -404,7 +526,7 @@ export function ContextNode({
404
526
  </div>
405
527
  )}
406
528
 
407
- {!fallback && cards.length === 0 && auxTabs.length === 0 && (
529
+ {!hasPinnedContext && !fallback && cards.length === 0 && auxTabs.length === 0 && (
408
530
  <div style={{ color: 'var(--c-dim)', fontStyle: 'italic' }}>No context loaded</div>
409
531
  )}
410
532
  </div>
@@ -0,0 +1,151 @@
1
+ import { useMemo } from 'preact/hooks';
2
+ import { getCanvasTokens } from '../theme/tokens';
3
+ import type { CanvasNodeState } from '../types';
4
+
5
+ /**
6
+ * Strip characters that could break out of a CSS custom-property value context
7
+ * before interpolating into a `<style>` block. The expected token shape is a
8
+ * CSS color (`#abc`, `rgb(...)`) or font-family list, neither of which needs
9
+ * `<`, `>`, `{`, `}`, `;`, or backticks. Defense-in-depth against a future
10
+ * scenario where theme tokens become runtime-editable.
11
+ */
12
+ function sanitizeCssTokenValue(value: string): string {
13
+ return value.replace(/[<>{};`\\]/g, '').trim();
14
+ }
15
+
16
+ /**
17
+ * Build a `<style>` block that exposes canvas theme tokens to the iframe under
18
+ * both the canonical `--c-*` names and common `--color-*` aliases. Also sets
19
+ * sensible body defaults (font, bg, color) so authored HTML inherits the look.
20
+ */
21
+ function buildThemeStyleBlock(): string {
22
+ const raw = getCanvasTokens();
23
+ const t = {
24
+ bg: sanitizeCssTokenValue(raw.bg),
25
+ panel: sanitizeCssTokenValue(raw.panel),
26
+ panelSoft: sanitizeCssTokenValue(raw.panelSoft),
27
+ line: sanitizeCssTokenValue(raw.line),
28
+ text: sanitizeCssTokenValue(raw.text),
29
+ textSoft: sanitizeCssTokenValue(raw.textSoft),
30
+ muted: sanitizeCssTokenValue(raw.muted),
31
+ dim: sanitizeCssTokenValue(raw.dim),
32
+ accent: sanitizeCssTokenValue(raw.accent),
33
+ ok: sanitizeCssTokenValue(raw.ok),
34
+ warn: sanitizeCssTokenValue(raw.warn),
35
+ warnAlt: sanitizeCssTokenValue(raw.warnAlt),
36
+ danger: sanitizeCssTokenValue(raw.danger),
37
+ purple: sanitizeCssTokenValue(raw.purple),
38
+ font: sanitizeCssTokenValue(raw.font),
39
+ mono: sanitizeCssTokenValue(raw.mono),
40
+ };
41
+ return `
42
+ :root {
43
+ --c-bg: ${t.bg};
44
+ --c-panel: ${t.panel};
45
+ --c-panel-soft: ${t.panelSoft};
46
+ --c-line: ${t.line};
47
+ --c-text: ${t.text};
48
+ --c-text-soft: ${t.textSoft};
49
+ --c-muted: ${t.muted};
50
+ --c-dim: ${t.dim};
51
+ --c-accent: ${t.accent};
52
+ --c-ok: ${t.ok};
53
+ --c-warn: ${t.warn};
54
+ --c-warn-alt: ${t.warnAlt};
55
+ --c-danger: ${t.danger};
56
+ --c-purple: ${t.purple};
57
+
58
+ /* Common aliases authored HTML might use. */
59
+ --color-bg: ${t.bg};
60
+ --color-panel: ${t.panel};
61
+ --color-surface: ${t.panelSoft};
62
+ --color-border: ${t.line};
63
+ --color-text: ${t.text};
64
+ --color-text-primary: ${t.text};
65
+ --color-text-secondary: ${t.textSoft};
66
+ --color-text-muted: ${t.muted};
67
+ --color-text-dim: ${t.dim};
68
+ --color-accent: ${t.accent};
69
+ --color-success: ${t.ok};
70
+ --color-warning: ${t.warn};
71
+ --color-danger: ${t.danger};
72
+
73
+ --font: ${t.font};
74
+ --font-sans: ${t.font};
75
+ --font-mono: ${t.mono};
76
+
77
+ color-scheme: dark light;
78
+ }
79
+ html, body {
80
+ margin: 0;
81
+ padding: 0;
82
+ background: ${t.bg};
83
+ color: ${t.text};
84
+ font-family: ${t.font || 'system-ui, sans-serif'};
85
+ font-size: 14px;
86
+ line-height: 1.5;
87
+ }
88
+ body { padding: 16px; box-sizing: border-box; }
89
+ a { color: ${t.accent}; }
90
+ `;
91
+ }
92
+
93
+ /**
94
+ * Inject the theme style block into the user-supplied HTML. If the document has
95
+ * a `<head>`, inject at the top of head; otherwise wrap the content in a full
96
+ * document. Returns a complete HTML string suitable for `srcdoc`.
97
+ */
98
+ function buildSrcDoc(userHtml: string): string {
99
+ const styleBlock = `<style data-pmx-canvas-theme>${buildThemeStyleBlock()}</style>`;
100
+ const trimmed = userHtml.trim();
101
+ const isFullDoc = /<html[\s>]/i.test(trimmed);
102
+ 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>`);
108
+ }
109
+ // Fragment — wrap in full document.
110
+ return `<!doctype html><html><head><meta charset="utf-8">${styleBlock}</head><body>${userHtml}</body></html>`;
111
+ }
112
+
113
+ export function HtmlNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
114
+ const html = typeof node.data.html === 'string'
115
+ ? node.data.html
116
+ : typeof node.data.content === 'string'
117
+ ? node.data.content
118
+ : '';
119
+ const srcDoc = useMemo(() => (html ? buildSrcDoc(html) : ''), [html]);
120
+
121
+ if (!html) {
122
+ return (
123
+ <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>
124
+ No HTML content set
125
+ </div>
126
+ );
127
+ }
128
+
129
+ // SECURITY: sandbox is intentionally `allow-scripts` ONLY. Do NOT add
130
+ // `allow-same-origin` (would grant the iframe access to parent localStorage
131
+ // and credentialed requests to the canvas origin), `allow-top-navigation`
132
+ // (would let scripts redirect the parent window), or `allow-forms` (would
133
+ // let the iframe POST back to the host). The whole html-node tier assumes
134
+ // arbitrary author code runs inside this exact sandbox.
135
+ return (
136
+ <iframe
137
+ title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
138
+ sandbox="allow-scripts"
139
+ srcdoc={srcDoc}
140
+ style={{
141
+ width: '100%',
142
+ height: '100%',
143
+ minHeight: expanded ? '70vh' : '300px',
144
+ border: 'none',
145
+ background: 'var(--c-bg)',
146
+ borderRadius: '6px',
147
+ display: 'block',
148
+ }}
149
+ />
150
+ );
151
+ }
@@ -1,8 +1,23 @@
1
1
  import { PHASE_COLORS } from '../theme/tokens';
2
2
  import type { CanvasNodeState } from '../types';
3
3
 
4
+ export function getStatusDisplayPhase(node: CanvasNodeState): string {
5
+ const phase = typeof node.data.phase === 'string' && node.data.phase.trim().length > 0
6
+ ? node.data.phase.trim()
7
+ : '';
8
+ if (phase) return phase;
9
+ const content = typeof node.data.content === 'string' && node.data.content.trim().length > 0
10
+ ? node.data.content.trim()
11
+ : '';
12
+ if (content) return content;
13
+ const status = typeof node.data.status === 'string' && node.data.status.trim().length > 0
14
+ ? node.data.status.trim()
15
+ : '';
16
+ return status || 'idle';
17
+ }
18
+
4
19
  export function StatusNode({ node }: { node: CanvasNodeState }) {
5
- const phase = (node.data.phase as string) || 'idle';
20
+ const phase = getStatusDisplayPhase(node);
6
21
  const detail = (node.data.detail as string) || '';
7
22
  const message = (node.data.message as string) || '';
8
23
  const level = (node.data.level as string) || 'ok';
@@ -1,8 +1,9 @@
1
1
  import { PHASE_COLORS } from '../theme/tokens';
2
2
  import type { CanvasNodeState } from '../types';
3
+ import { getStatusDisplayPhase } from './StatusNode';
3
4
 
4
5
  export function StatusSummary({ node }: { node: CanvasNodeState }) {
5
- const phase = (node.data.phase as string) || 'idle';
6
+ const phase = getStatusDisplayPhase(node);
6
7
  const activeTool = node.data.activeTool as string | null;
7
8
  const subagent = node.data.subagent as { state: string; name: string } | undefined;
8
9
  const phaseColor = PHASE_COLORS[phase] ?? 'var(--c-muted)';