pmx-canvas 0.10.0 → 0.11.0

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 (201) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +246 -108
  3. package/CHANGELOG.md +32 -0
  4. package/dist/canvas/chunks/abnfDiagram-N423BO3Z-p8dtp6va.js +1 -0
  5. package/dist/canvas/chunks/architecture-TIHT7OUA-9tffvpsv.js +1 -0
  6. package/dist/canvas/chunks/architectureDiagram-T3A2C74G-5jhmxrzf.js +36 -0
  7. package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-pw1fa9yb.js +132 -0
  8. package/dist/canvas/chunks/c4Diagram-5PPSVZJV-x07nq0tt.js +10 -0
  9. package/dist/canvas/chunks/classDiagram-JCYQIIEL-nc6g5x1r.js +1 -0
  10. package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-0qnbpwvh.js +1 -0
  11. package/dist/canvas/chunks/cose-bilkent-JH36ORCC-nm39npq0.js +1 -0
  12. package/dist/canvas/chunks/cynefin-VYW2F7L2-ys8p46ts.js +1 -0
  13. package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-vdxrwahz.js +62 -0
  14. package/dist/canvas/chunks/dagre-VZM6K2ZE-bqdm7wpd.js +4 -0
  15. package/dist/canvas/chunks/diagram-7IWD3JNH-gs5gpr93.js +30 -0
  16. package/dist/canvas/chunks/diagram-B4RE2ZJO-cgwjg47s.js +3 -0
  17. package/dist/canvas/chunks/diagram-LBJQPF4R-w3vahpav.js +24 -0
  18. package/dist/canvas/chunks/diagram-Q27KOJAE-42837z6h.js +24 -0
  19. package/dist/canvas/chunks/diagram-UB23O5K3-4fz83q59.js +41 -0
  20. package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-zphtgegj.js +1 -0
  21. package/dist/canvas/chunks/erDiagram-JOGREHBK-zvbbdxp4.js +85 -0
  22. package/dist/canvas/chunks/eventmodeling-45OFAUF4-v9q6dn4c.js +1 -0
  23. package/dist/canvas/chunks/flowDiagram-UKHOOZJN-yrr5z8tc.js +1 -0
  24. package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-282jzycm.js +292 -0
  25. package/dist/canvas/chunks/gitGraph-TEB2WS4Q-kdwb8m0a.js +1 -0
  26. package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-vqqy6p1b.js +106 -0
  27. package/dist/canvas/chunks/index-0rk4gp1g.js +2 -0
  28. package/dist/canvas/chunks/index-194m3hc3.js +2 -0
  29. package/dist/canvas/chunks/index-1r72hdzb.js +301 -0
  30. package/dist/canvas/chunks/index-353m7b0p.js +2 -0
  31. package/dist/canvas/chunks/index-3cy1bzwh.js +2 -0
  32. package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
  33. package/dist/canvas/chunks/index-6ceya556.js +332 -0
  34. package/dist/canvas/chunks/index-6qmsnxwy.js +4 -0
  35. package/dist/canvas/chunks/index-d3b3gqe9.js +2 -0
  36. package/dist/canvas/chunks/index-fjtdggjn.js +2 -0
  37. package/dist/canvas/chunks/index-g3pwnqeb.js +2 -0
  38. package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
  39. package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
  40. package/dist/canvas/chunks/index-nprm96xp.js +16 -0
  41. package/dist/canvas/chunks/index-qj8fxwsf.js +2 -0
  42. package/dist/canvas/chunks/index-r2fyc9w1.js +89 -0
  43. package/dist/canvas/chunks/index-rft2hcz5.js +2 -0
  44. package/dist/canvas/chunks/index-rh7nv68n.js +2 -0
  45. package/dist/canvas/chunks/index-s4j678gj.js +2 -0
  46. package/dist/canvas/chunks/index-tb1wf525.js +179 -0
  47. package/dist/canvas/chunks/index-vv56h019.js +2 -0
  48. package/dist/canvas/chunks/index-w2mra17t.js +207 -0
  49. package/dist/canvas/chunks/index-wgv7en21.js +232 -0
  50. package/dist/canvas/chunks/index-yw8h9vha.js +157 -0
  51. package/dist/canvas/chunks/info-DKCQHKI2-wpzs6sz3.js +1 -0
  52. package/dist/canvas/chunks/infoDiagram-6WML65LV-v1yvggqj.js +2 -0
  53. package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-5hnq1a9p.js +70 -0
  54. package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-zjcffveh.js +139 -0
  55. package/dist/canvas/chunks/kanban-definition-27J2QSJJ-dakqtfp1.js +89 -0
  56. package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
  57. package/dist/canvas/chunks/mermaid.core-j2geswrd.js +12 -0
  58. package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-142xhr3k.js +96 -0
  59. package/dist/canvas/chunks/packet-7NZHBO7P-xn439f0s.js +1 -0
  60. package/dist/canvas/chunks/pegDiagram-VL7TDLO6-5wvk7vsd.js +1 -0
  61. package/dist/canvas/chunks/pie-RZYD4A2V-0kf16rwh.js +1 -0
  62. package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-ztdhwycc.js +39 -0
  63. package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-c5p9gmg6.js +7 -0
  64. package/dist/canvas/chunks/radar-I7S5WNFK-0g2pz36t.js +1 -0
  65. package/dist/canvas/chunks/railroad-3IZDKUUU-fcaran6j.js +1 -0
  66. package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-wgdya41q.js +1 -0
  67. package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-gy07a4ka.js +1 -0
  68. package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-ewk6wtkh.js +1 -0
  69. package/dist/canvas/chunks/railroadDiagram-AXF67PYL-xkmqkjwf.js +1 -0
  70. package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-9wm2dyaw.js +84 -0
  71. package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-2w19etgb.js +40 -0
  72. package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-0jn0vwmz.js +162 -0
  73. package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-x75epwdq.js +1 -0
  74. package/dist/canvas/chunks/stateDiagram-OKZ733FA-schjpy6d.js +1 -0
  75. package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-k8sfp2e7.js +1 -0
  76. package/dist/canvas/chunks/swimlanes-SLNWSIFB-gkw4xce3.js +1 -0
  77. package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-nqysw67j.js +8 -0
  78. package/dist/canvas/chunks/timeline-definition-Z64GVDOM-x7nkcy01.js +120 -0
  79. package/dist/canvas/chunks/treeView-QDETBFTQ-cesw6gp0.js +1 -0
  80. package/dist/canvas/chunks/treemap-6X3UGDF4-zdv4knp9.js +1 -0
  81. package/dist/canvas/chunks/vennDiagram-T6HMQDX7-x8dqn9yf.js +34 -0
  82. package/dist/canvas/chunks/wardley-OPB4EBWU-44pdddez.js +1 -0
  83. package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-t1j52t9r.js +78 -0
  84. package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-38t16pme.js +7 -0
  85. package/dist/canvas/global.css +389 -229
  86. package/dist/canvas/index.js +141 -211
  87. package/dist/canvas/mcp-app.html +4205 -554
  88. package/dist/canvas/mermaid-entry.js +338 -338
  89. package/dist/canvas/surface-theme.css +6 -6
  90. package/dist/json-render/index.css +1 -1
  91. package/dist/json-render/index.js +49 -49
  92. package/dist/types/client/canvas/BarHint.d.ts +5 -3
  93. package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
  94. package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
  95. package/dist/types/client/canvas/auto-fit.d.ts +4 -5
  96. package/dist/types/client/mermaid-entry.d.ts +1 -2
  97. package/dist/types/client/mermaid-render.d.ts +1 -0
  98. package/dist/types/client/nodes/BoardNode.d.ts +1 -1
  99. package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
  100. package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
  101. package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
  102. package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
  103. package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
  104. package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
  105. package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
  106. package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
  107. package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
  108. package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
  109. package/dist/types/client/state/canvas-store.d.ts +3 -1
  110. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  111. package/dist/types/client/state/presentation.d.ts +17 -6
  112. package/dist/types/json-render/server.d.ts +2 -1
  113. package/dist/types/server/canvas-state.d.ts +2 -0
  114. package/dist/types/server/index.d.ts +10 -5
  115. package/dist/types/server/operations/ops/boards.d.ts +0 -1
  116. package/dist/types/server/tour-control.d.ts +13 -3
  117. package/dist/types/shared/ax-surface-protocol.d.ts +0 -9
  118. package/dist/types/shared/tour.d.ts +8 -0
  119. package/docs/design/Chrome.dc.html +32 -6
  120. package/docs/design/Types1.dc.html +3 -2
  121. package/docs/design/Types2.dc.html +1 -1
  122. package/docs/design/Types3.dc.html +1 -1
  123. package/docs/design/Zoom.dc.html +37 -60
  124. package/docs/design/canvas.json +1 -1
  125. package/docs/design.md +169 -4
  126. package/docs/http-api.md +51 -1
  127. package/docs/mcp.md +8 -0
  128. package/docs/node-types.md +38 -3
  129. package/docs/plans/plan-009-tech-debt-backlog.md +2 -2
  130. package/docs/plans/plan-019-post-0.9-roadmap.md +283 -11
  131. package/docs/product-vision-2026-09.md +9 -0
  132. package/package.json +2 -2
  133. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  134. package/plugins/pmx-canvas/plugin.json +1 -1
  135. package/plugins/pmx-canvas/runtime.json +1 -1
  136. package/plugins/pmx-canvas/tools.json +246 -108
  137. package/skills/pmx-canvas/SKILL.md +11 -18
  138. package/skills/pmx-canvas/references/full-reference.md +9 -4
  139. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  140. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +3 -5
  141. package/src/client/canvas/BarHint.tsx +6 -3
  142. package/src/client/canvas/CanvasNode.tsx +34 -47
  143. package/src/client/canvas/CanvasViewport.tsx +6 -7
  144. package/src/client/canvas/ContextMenu.tsx +63 -3
  145. package/src/client/canvas/EdgeLayer.tsx +365 -169
  146. package/src/client/canvas/ExpandedNodeOverlay.tsx +1 -2
  147. package/src/client/canvas/Presentation.tsx +53 -3
  148. package/src/client/canvas/TopBar.tsx +24 -9
  149. package/src/client/canvas/auto-fit.ts +9 -11
  150. package/src/client/canvas/canvas-area.ts +1 -1
  151. package/src/client/canvas/use-focus-trap.ts +6 -1
  152. package/src/client/mermaid-entry.ts +6 -20
  153. package/src/client/mermaid-render.ts +39 -0
  154. package/src/client/nodes/AxStepControls.tsx +45 -69
  155. package/src/client/nodes/BoardNode.tsx +65 -9
  156. package/src/client/nodes/ContextNode.tsx +88 -396
  157. package/src/client/nodes/ExtAppFrame.tsx +283 -1462
  158. package/src/client/nodes/FileNode.tsx +92 -81
  159. package/src/client/nodes/HtmlNode.tsx +8 -36
  160. package/src/client/nodes/LedgerNode.tsx +31 -17
  161. package/src/client/nodes/MarkdownNode.tsx +8 -17
  162. package/src/client/nodes/McpAppNode.tsx +48 -154
  163. package/src/client/nodes/MermaidNode.tsx +80 -69
  164. package/src/client/nodes/PromptNode.tsx +12 -14
  165. package/src/client/nodes/ResponseNode.tsx +8 -6
  166. package/src/client/nodes/StatusNode.tsx +22 -7
  167. package/src/client/nodes/StatusSummary.tsx +5 -3
  168. package/src/client/nodes/TraceNode.tsx +8 -8
  169. package/src/client/nodes/ViewerFrame.tsx +262 -0
  170. package/src/client/nodes/ViewerHost.tsx +41 -0
  171. package/src/client/nodes/WebpageNode.tsx +22 -24
  172. package/src/client/nodes/iframe-document-url.ts +20 -27
  173. package/src/client/nodes/primitive-copy.ts +97 -0
  174. package/src/client/nodes/use-iframe-content-height.ts +34 -10
  175. package/src/client/nodes/use-surface-frame.ts +22 -11
  176. package/src/client/nodes/viewer-runtime-state.ts +41 -35
  177. package/src/client/state/canvas-store.ts +18 -12
  178. package/src/client/state/intent-bridge.ts +1 -0
  179. package/src/client/state/presentation.ts +135 -15
  180. package/src/client/state/sse-bridge.ts +18 -19
  181. package/src/client/theme/global.css +389 -229
  182. package/src/client/theme/surface-theme.css +6 -6
  183. package/src/json-render/charts/tufte-components.tsx +44 -2
  184. package/src/json-render/renderer/index.css +62 -2
  185. package/src/json-render/renderer/index.tsx +31 -7
  186. package/src/json-render/server.ts +3 -2
  187. package/src/server/board-export.ts +35 -24
  188. package/src/server/canvas-geometry.ts +1 -0
  189. package/src/server/canvas-state.ts +5 -0
  190. package/src/server/demo-state.json +106 -100
  191. package/src/server/html-primitives.ts +3 -9
  192. package/src/server/html-surface.ts +8 -0
  193. package/src/server/index.ts +19 -9
  194. package/src/server/operations/mcp-contracts.ts +17 -2
  195. package/src/server/operations/ops/boards.ts +1 -3
  196. package/src/server/operations/ops/viewport.ts +11 -9
  197. package/src/server/server.ts +7 -59
  198. package/src/server/tour-control.ts +66 -6
  199. package/src/shared/ax-surface-protocol.ts +0 -11
  200. package/src/shared/content-height-reporter.ts +4 -3
  201. package/src/shared/tour.ts +8 -0
@@ -17,7 +17,7 @@ const STATUS_ICONS: Record<string, string> = {
17
17
  };
18
18
 
19
19
  const STATUS_COLORS: Record<string, string> = {
20
- running: 'var(--c-accent)',
20
+ running: 'var(--c-agent)',
21
21
  success: 'var(--c-ok)',
22
22
  failed: 'var(--c-danger)',
23
23
  };
@@ -71,9 +71,9 @@ export function TraceNode({ node }: { node: CanvasNodeState }) {
71
71
  <div
72
72
  style={{
73
73
  fontFamily: 'var(--font-code)',
74
- fontSize: '11px',
75
- fontWeight: 600,
76
- color: catColor,
74
+ fontSize: 'var(--fs-ui)',
75
+ fontWeight: 500,
76
+ color: category === 'subagent' ? catColor : 'var(--c-text)',
77
77
  whiteSpace: 'nowrap',
78
78
  overflow: 'hidden',
79
79
  textOverflow: 'ellipsis',
@@ -84,7 +84,7 @@ export function TraceNode({ node }: { node: CanvasNodeState }) {
84
84
  {summary && (
85
85
  <div
86
86
  style={{
87
- fontSize: '10px',
87
+ fontSize: 'var(--fs-meta)',
88
88
  color: error ? 'var(--c-danger)' : 'var(--c-muted)',
89
89
  whiteSpace: 'nowrap',
90
90
  overflow: 'hidden',
@@ -101,10 +101,10 @@ export function TraceNode({ node }: { node: CanvasNodeState }) {
101
101
  {duration && (
102
102
  <span
103
103
  style={{
104
- fontSize: '9px',
104
+ fontSize: 'var(--fs-meta)',
105
105
  padding: '1px 5px',
106
- borderRadius: '3px',
107
- background: 'rgba(255,255,255,0.06)',
106
+ borderRadius: 'var(--r-control)',
107
+ background: 'var(--c-surface-hover)',
108
108
  color: 'var(--c-muted)',
109
109
  flexShrink: 0,
110
110
  whiteSpace: 'nowrap',
@@ -0,0 +1,262 @@
1
+ import type { JSX, RefObject } from 'preact';
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
3
+ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
4
+
5
+ export const VIEWER_LOAD_DEADLINE_MS = 10_000;
6
+ export type ViewerSource = { src?: string; srcdoc?: string; error?: boolean; retry?: () => void };
7
+ type LoadDocument = (
8
+ frame: HTMLIFrameElement,
9
+ signal: AbortSignal,
10
+ ) => Promise<boolean | 'cancelled' | undefined> | undefined;
11
+ type Entry = { key: string; source: ViewerSource; attempt: number; load?: LoadDocument };
12
+ type MountFrame = (frame: HTMLIFrameElement, fail: () => void) => () => void;
13
+
14
+ /** The document acknowledges mounting before its frame-gated paint signal. */
15
+ export function waitForViewerPaint(frame: HTMLIFrameElement, token: string, signal: AbortSignal): Promise<boolean> {
16
+ if (signal.aborted) return Promise.resolve(false);
17
+ return new Promise((resolve) => {
18
+ const finish = (ready: boolean) => {
19
+ clearTimeout(timer);
20
+ window.removeEventListener('message', receive);
21
+ signal.removeEventListener('abort', cancel);
22
+ resolve(ready);
23
+ };
24
+ const cancel = () => finish(false);
25
+ const receive = (event: MessageEvent) => {
26
+ if (
27
+ event.source !== frame.contentWindow ||
28
+ event.data?.source !== HTML_SURFACE_PUSH_SOURCE ||
29
+ event.data?.token !== token
30
+ )
31
+ return;
32
+ if (event.data.type === 'mounted') {
33
+ clearTimeout(timer);
34
+ }
35
+ if (event.data.type === 'ready') finish(true);
36
+ };
37
+ const timer = setTimeout(cancel, VIEWER_LOAD_DEADLINE_MS);
38
+ window.addEventListener('message', receive);
39
+ signal.addEventListener('abort', cancel, { once: true });
40
+ frame.contentWindow?.postMessage({ source: HTML_SURFACE_PUSH_SOURCE, type: 'check-ready', token }, '*');
41
+ });
42
+ }
43
+
44
+ /** Bind protocol adapters before document scripts run, once per document. */
45
+ function ViewerDocument({
46
+ mount,
47
+ iframeRef,
48
+ ...attributes
49
+ }: JSX.IntrinsicElements['iframe'] & {
50
+ mount?: (frame: HTMLIFrameElement) => () => void;
51
+ iframeRef?: RefObject<HTMLIFrameElement>;
52
+ }) {
53
+ const frame = useRef<HTMLIFrameElement>(null);
54
+ useLayoutEffect(() => mount?.(frame.current!), []);
55
+ useLayoutEffect(() => {
56
+ if (iframeRef) iframeRef.current = frame.current;
57
+ }, [iframeRef]);
58
+ return <iframe {...attributes} ref={frame} />;
59
+ }
60
+
61
+ /** Preserve the painted document until its replacement restores and paints. */
62
+ export function RefreshingViewerFrame({
63
+ source,
64
+ identity = '',
65
+ iframeRef,
66
+ onLoad = () => {},
67
+ onMount,
68
+ onOpen,
69
+ onDocumentLoad,
70
+ title,
71
+ className = 'mcp-app-frame',
72
+ sandbox = 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
73
+ allow = '',
74
+ tabIndex,
75
+ referrerPolicy,
76
+ }: {
77
+ source: ViewerSource;
78
+ identity?: string;
79
+ iframeRef: RefObject<HTMLIFrameElement>;
80
+ onLoad?: () => void;
81
+ onMount?: MountFrame;
82
+ onOpen?: () => void;
83
+ onDocumentLoad?: LoadDocument;
84
+ title: string;
85
+ className?: string;
86
+ sandbox?: string;
87
+ allow?: string;
88
+ tabIndex?: number;
89
+ referrerPolicy?: JSX.IntrinsicElements['iframe']['referrerPolicy'];
90
+ }) {
91
+ const documentSource = source.src ?? source.srcdoc ?? '';
92
+ const sourceKey = documentSource ? `${identity}\0${documentSource}` : '';
93
+ const [painted, setPainted] = useState<Entry>({ key: sourceKey, source, attempt: 0, load: onDocumentLoad });
94
+ const [pending, setPending] = useState<Entry | null>(null);
95
+ const [failure, setFailure] = useState<Entry | null>(null);
96
+ const [ready, setReady] = useState(false);
97
+ const promotion = useRef(0);
98
+ const readiness = useRef(new Map<AbortController, Entry>());
99
+ const currentEntries = useRef({ painted, pending });
100
+ currentEntries.current = { painted, pending };
101
+ const networkTimer = useRef<{ entry: Entry; timer: ReturnType<typeof setTimeout> }>();
102
+ const loadedEntries = useRef(new WeakSet<Entry>());
103
+
104
+ const fail = (entry: Entry) => {
105
+ const current = currentEntries.current;
106
+ if (entry === (current.pending ?? current.painted)) setFailure(entry);
107
+ };
108
+
109
+ useLayoutEffect(() => {
110
+ promotion.current += 1;
111
+ setFailure(null);
112
+ if (!sourceKey || sourceKey === painted.key) {
113
+ setPending(null);
114
+ return;
115
+ }
116
+ if (!painted.key) {
117
+ setPainted({ key: sourceKey, source, attempt: 0, load: onDocumentLoad });
118
+ return;
119
+ }
120
+ setPending({ key: sourceKey, source, attempt: 0, load: onDocumentLoad });
121
+ }, [sourceKey, painted.key]);
122
+
123
+ useLayoutEffect(() => {
124
+ // Retry retires an entry without changing its source key. Keep the retained
125
+ // foreground's first-paint wait, but cancel every removed document's work.
126
+ for (const [controller, entry] of readiness.current) {
127
+ if (entry === painted || entry === pending) continue;
128
+ controller.abort();
129
+ readiness.current.delete(controller);
130
+ }
131
+ }, [painted, pending]);
132
+
133
+ useLayoutEffect(() => {
134
+ const entry = pending ?? painted;
135
+ if (!entry.key || loadedEntries.current.has(entry)) return;
136
+ const timer = setTimeout(() => fail(entry), VIEWER_LOAD_DEADLINE_MS);
137
+ networkTimer.current = { entry, timer };
138
+ return () => clearTimeout(timer);
139
+ }, [pending, painted]);
140
+
141
+ useEffect(
142
+ () => () => {
143
+ promotion.current += 1;
144
+ for (const controller of readiness.current.keys()) controller.abort();
145
+ readiness.current.clear();
146
+ },
147
+ [],
148
+ );
149
+
150
+ const frame = (entry: Entry, isPending: boolean) =>
151
+ entry.key && (
152
+ <ViewerDocument
153
+ key={`${entry.key}:${entry.attempt}`}
154
+ iframeRef={isPending ? undefined : iframeRef}
155
+ mount={onMount ? (frame) => onMount(frame, () => fail(entry)) : undefined}
156
+ src={entry.source.src}
157
+ srcdoc={entry.source.srcdoc}
158
+ class={className}
159
+ sandbox={sandbox}
160
+ allow={allow}
161
+ tabIndex={tabIndex}
162
+ referrerPolicy={referrerPolicy}
163
+ inert={isPending}
164
+ aria-hidden={isPending ? true : undefined}
165
+ onError={() => fail(entry)}
166
+ onLoad={async (event) => {
167
+ loadedEntries.current.add(entry);
168
+ if (networkTimer.current?.entry === entry) clearTimeout(networkTimer.current.timer);
169
+ const token = isPending ? ++promotion.current : promotion.current;
170
+ const loadedFrame = event.currentTarget;
171
+ const controller = new AbortController();
172
+ readiness.current.set(controller, entry);
173
+ const result = entry.load?.(loadedFrame, controller.signal);
174
+ const loaded = result ? await result : undefined;
175
+ readiness.current.delete(controller);
176
+ const current = currentEntries.current;
177
+ if (
178
+ (isPending && promotion.current !== token) ||
179
+ (entry !== current.painted && entry !== current.pending) ||
180
+ loaded === 'cancelled' ||
181
+ controller.signal.aborted
182
+ )
183
+ return;
184
+ if (loaded === false) {
185
+ fail(entry);
186
+ return;
187
+ }
188
+ setFailure((failed) => (failed === entry ? null : failed));
189
+ if (!isPending) {
190
+ setReady(true);
191
+ onLoad();
192
+ return;
193
+ }
194
+ requestAnimationFrame(() =>
195
+ requestAnimationFrame(() => {
196
+ if (promotion.current !== token) return;
197
+ iframeRef.current = loadedFrame;
198
+ setPainted(entry);
199
+ setPending(null);
200
+ setReady(true);
201
+ onLoad();
202
+ }),
203
+ );
204
+ }}
205
+ style={{
206
+ position: 'absolute',
207
+ inset: 0,
208
+ width: '100%',
209
+ height: '100%',
210
+ border: 0,
211
+ visibility: 'visible',
212
+ zIndex: isPending ? 0 : 1,
213
+ background: 'var(--c-panel)',
214
+ }}
215
+ title={title}
216
+ />
217
+ );
218
+
219
+ return (
220
+ <div class="mcp-app-frame-stack">
221
+ {frame(painted, false)}
222
+ {pending && frame(pending, true)}
223
+ {!ready && !failure && !source.error && (
224
+ <div class="viewer-fallback" role="status">
225
+ Loading viewer…
226
+ </div>
227
+ )}
228
+ {(failure || source.error) && (
229
+ <div class={`viewer-fallback${ready ? ' viewer-fallback-retained' : ''}`} role="status">
230
+ <span>Viewer could not load.{ready ? ' Keeping the last view.' : ''}</span>
231
+ <button
232
+ type="button"
233
+ onClick={() => {
234
+ if (source.error) {
235
+ source.retry?.();
236
+ return;
237
+ }
238
+ if (!failure) return;
239
+ const retry = { ...failure, attempt: failure.attempt + 1 };
240
+ setFailure(null);
241
+ if (pending) setPending(retry);
242
+ else setPainted(retry);
243
+ }}
244
+ >
245
+ Retry
246
+ </button>
247
+ </div>
248
+ )}
249
+ {onOpen && !failure && !source.error && (
250
+ <button
251
+ type="button"
252
+ class="ext-app-preview-catcher"
253
+ aria-label="Open full view to edit"
254
+ onClick={(event) => {
255
+ event.stopPropagation();
256
+ onOpen();
257
+ }}
258
+ />
259
+ )}
260
+ </div>
261
+ );
262
+ }
@@ -0,0 +1,41 @@
1
+ import type { ComponentChildren } from 'preact';
2
+ import { useEffect, useRef, useState } from 'preact/hooks';
3
+ import { expandNode } from '../state/canvas-store';
4
+ import type { CanvasNodeState } from '../types';
5
+
6
+ // Mount on first approach at any zoom, then retain the document's arbitrary
7
+ // live state. A lifetime cap starves later regions of a populated board.
8
+ export function ViewerHost({ node, children }: { node: CanvasNodeState; children: ComponentChildren }) {
9
+ const anchor = useRef<HTMLDivElement>(null);
10
+ const [mounted, setMounted] = useState(false);
11
+
12
+ useEffect(() => {
13
+ if (!anchor.current) return;
14
+ const observer = new IntersectionObserver(
15
+ ([entry]) => {
16
+ if (entry!.isIntersecting) {
17
+ setMounted(true);
18
+ observer.disconnect();
19
+ }
20
+ },
21
+ { rootMargin: '200px' },
22
+ );
23
+ observer.observe(anchor.current);
24
+ return () => observer.disconnect();
25
+ }, []);
26
+
27
+ return (
28
+ <div ref={anchor} class="viewer-host" data-viewer-mounted={mounted}>
29
+ {mounted ? (
30
+ children
31
+ ) : (
32
+ <div class="viewer-placeholder">
33
+ <span>{String(node.data.title || node.type)}</span>
34
+ <button type="button" onClick={() => expandNode(node.id)}>
35
+ Open viewer
36
+ </button>
37
+ </div>
38
+ )}
39
+ </div>
40
+ );
41
+ }
@@ -1,6 +1,7 @@
1
- import { useCallback, useEffect, useState } from 'preact/hooks';
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { refreshWebpageNodeFromClient } from '../state/intent-bridge';
3
3
  import type { CanvasNodeState } from '../types';
4
+ import { RefreshingViewerFrame } from './ViewerFrame';
4
5
 
5
6
  function formatHost(url: string): string {
6
7
  try {
@@ -35,6 +36,7 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
35
36
  const frameBlockedReason = typeof node.data.frameBlockedReason === 'string' ? node.data.frameBlockedReason : '';
36
37
  const [refreshing, setRefreshing] = useState(false);
37
38
  const [showEmbed, setShowEmbed] = useState(expanded);
39
+ const iframeRef = useRef<HTMLIFrameElement>(null);
38
40
 
39
41
  useEffect(() => {
40
42
  if (expanded) {
@@ -63,9 +65,9 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
63
65
  <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px' }}>
64
66
  <div style={{ minWidth: 0, flex: 1 }}>
65
67
  <div style={{ fontSize: '12px', color: 'var(--c-muted)' }}>{formatHost(url)}</div>
66
- <div style={{ fontSize: expanded ? '18px' : '15px', fontWeight: 700, color: 'var(--c-text)' }}>
67
- {pageTitle || (node.data.title as string) || url}
68
- </div>
68
+ {pageTitle && pageTitle !== node.data.title && (
69
+ <div style={{ fontSize: 'var(--fs-title)', fontWeight: 600, color: 'var(--c-text)' }}>{pageTitle}</div>
70
+ )}
69
71
  <a
70
72
  href={url}
71
73
  target="_blank"
@@ -82,7 +84,7 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
82
84
  gap: '6px',
83
85
  padding: '4px 8px',
84
86
  borderRadius: '999px',
85
- background: 'rgba(255,255,255,0.04)',
87
+ background: 'var(--c-panel-soft)',
86
88
  color: statusTone,
87
89
  fontSize: '11px',
88
90
  textTransform: 'uppercase',
@@ -194,7 +196,7 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
194
196
  fontSize: '11px',
195
197
  color: 'var(--c-muted)',
196
198
  borderBottom: '1px solid var(--c-line)',
197
- background: 'rgba(255,255,255,0.03)',
199
+ background: 'var(--c-panel)',
198
200
  }}
199
201
  >
200
202
  Live preview unavailable. This site refuses embedding, so PMX Canvas cannot show it inline.
@@ -216,9 +218,13 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
216
218
  <div
217
219
  style={{
218
220
  border: '1px solid var(--c-line)',
219
- borderRadius: '12px',
221
+ borderRadius: 'var(--r-control)',
220
222
  overflow: 'hidden',
221
- background: 'var(--c-panel-soft)',
223
+ background: 'var(--c-panel)',
224
+ height: '320px',
225
+ flexShrink: 0,
226
+ display: 'flex',
227
+ flexDirection: 'column',
222
228
  }}
223
229
  >
224
230
  <div
@@ -227,26 +233,20 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
227
233
  fontSize: '11px',
228
234
  color: 'var(--c-muted)',
229
235
  borderBottom: '1px solid var(--c-line)',
230
- background: 'rgba(255,255,255,0.03)',
236
+ background: 'var(--c-panel)',
231
237
  }}
232
238
  >
233
239
  Live preview (best effort). If this stays blank, the site likely blocks framing. The cached text snapshot
234
240
  below still works.
235
241
  </div>
236
- <iframe
237
- class="webpage-node-iframe"
242
+ <RefreshingViewerFrame
243
+ iframeRef={iframeRef}
244
+ onLoad={() => {}}
245
+ className="webpage-node-iframe"
238
246
  title={pageTitle || (node.data.title as string) || url}
239
- src={url}
240
- loading="lazy"
247
+ source={{ src: url }}
241
248
  referrerPolicy="no-referrer"
242
249
  sandbox="allow-downloads allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-scripts"
243
- style={{
244
- display: 'block',
245
- width: '100%',
246
- height: expanded ? '320px' : '180px',
247
- border: 'none',
248
- background: '#fff',
249
- }}
250
250
  />
251
251
  </div>
252
252
  )}
@@ -266,10 +266,8 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
266
266
  flex: 1,
267
267
  minHeight: 0,
268
268
  overflow: 'auto',
269
- border: '1px solid var(--c-line)',
270
- borderRadius: '10px',
271
- background: 'var(--c-panel-soft)',
272
- padding: expanded ? '14px' : '10px',
269
+ borderTop: '1px solid var(--c-line)',
270
+ paddingTop: '12px',
273
271
  }}
274
272
  >
275
273
  {excerpt ? (
@@ -1,8 +1,8 @@
1
- import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { workbenchConnectionEpoch } from '../state/canvas-store';
3
3
  import { iframeMode } from '../state/iframe-mode';
4
- import { surfaceContentHash } from './surface-url';
5
4
  import { workbenchFetch } from '../state/workbench-transport';
5
+ import { VIEWER_LOAD_DEADLINE_MS, type ViewerSource } from './ViewerFrame';
6
6
 
7
7
  interface FrameDocumentCreateResponse {
8
8
  ok: boolean;
@@ -21,11 +21,12 @@ function isFrameDocumentCreateResponse(value: unknown): value is FrameDocumentCr
21
21
  );
22
22
  }
23
23
 
24
- export async function createIframeDocumentUrl(html: string, sandbox: string): Promise<string> {
24
+ export async function createIframeDocumentUrl(html: string, sandbox: string, signal?: AbortSignal): Promise<string> {
25
25
  const response = await workbenchFetch('/api/canvas/frame-documents', {
26
26
  method: 'POST',
27
27
  headers: { 'Content-Type': 'application/json' },
28
28
  body: JSON.stringify({ html, sandbox }),
29
+ signal,
29
30
  });
30
31
  const json = (await response.json()) as unknown;
31
32
  if (!response.ok || !isFrameDocumentCreateResponse(json) || !json.ok || typeof json.url !== 'string') {
@@ -38,32 +39,37 @@ export async function createIframeDocumentUrl(html: string, sandbox: string): Pr
38
39
  return json.url;
39
40
  }
40
41
 
41
- export function useIframeDocument(
42
- html: string,
43
- sandbox: string,
44
- ): { attributes: { src?: string; srcdoc?: string }; ready: boolean; key: string } {
42
+ export function useIframeDocument(html: string, sandbox: string): ViewerSource {
45
43
  // Boot-wide embed probe (state/iframe-mode.ts): srcdoc mode means src-URL
46
44
  // iframes are blocked here (nested-iframe hosts like Amp orb portals), so the
47
45
  // frame-documents round-trip would produce a URL the browser refuses to load.
48
46
  const mode = iframeMode.value;
49
47
  const [src, setSrc] = useState<string | null>(null);
48
+ const [error, setError] = useState(false);
50
49
  const [remintNonce, setRemintNonce] = useState(0);
51
50
  const mintEpochRef = useRef(0);
52
51
 
53
52
  useEffect(() => {
54
- setSrc(null);
53
+ setError(false);
55
54
  if (!html || mode !== 'src') return;
56
55
  let cancelled = false;
56
+ const controller = new AbortController();
57
+ const deadline = setTimeout(() => controller.abort(), VIEWER_LOAD_DEADLINE_MS);
57
58
  mintEpochRef.current = workbenchConnectionEpoch.value;
58
- void createIframeDocumentUrl(html, sandbox)
59
+ void createIframeDocumentUrl(html, sandbox, controller.signal)
59
60
  .then((url) => {
60
61
  if (!cancelled) setSrc(url);
61
62
  })
62
63
  .catch((error) => {
64
+ if (cancelled) return;
63
65
  console.error('[iframe-document] failed to create frame document:', error);
64
- });
66
+ setError(true);
67
+ })
68
+ .finally(() => clearTimeout(deadline));
65
69
  return () => {
66
70
  cancelled = true;
71
+ clearTimeout(deadline);
72
+ controller.abort();
67
73
  };
68
74
  }, [html, sandbox, mode, remintNonce]);
69
75
 
@@ -101,21 +107,8 @@ export function useIframeDocument(
101
107
  };
102
108
  }, [epoch, src, mode]);
103
109
 
104
- return useMemo(() => {
105
- if (mode === 'srcdoc' && html) {
106
- // The document already lives client-side, so serve it inline. Consumers
107
- // always render the iframe `sandbox` attribute, which supplies the
108
- // sandboxing the frame-document CSP header would have.
109
- return {
110
- attributes: { srcdoc: html },
111
- ready: true,
112
- key: `srcdoc-${surfaceContentHash(html)}`,
113
- };
114
- }
115
- return {
116
- attributes: src ? { src } : {},
117
- ready: Boolean(src),
118
- key: src ?? '',
119
- };
120
- }, [src, mode, html]);
110
+ if (!html || mode === null) return {};
111
+ // The host always supplies the sandbox, including on inline documents.
112
+ if (mode === 'srcdoc') return { srcdoc: html };
113
+ return { src: src ?? undefined, error, retry: () => setRemintNonce((value) => value + 1) };
121
114
  }
@@ -0,0 +1,97 @@
1
+ import type { RefObject } from 'preact';
2
+ import { useLayoutEffect } from 'preact/hooks';
3
+ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
4
+ import { showToast } from '../state/attention-bridge';
5
+ import type { CanvasNodeState } from '../types';
6
+
7
+ type Owner = { nodeId: string; token: string; frame: RefObject<HTMLIFrameElement>; expanded: boolean };
8
+ const owners = new Set<Owner>();
9
+
10
+ export function canCopyPrimitiveJson(node: CanvasNodeState): boolean {
11
+ return (
12
+ node.type === 'html' && typeof node.data.htmlPrimitive === 'string' && node.data.htmlPrimitive !== 'presentation'
13
+ );
14
+ }
15
+
16
+ export function usePrimitiveJson(
17
+ node: CanvasNodeState,
18
+ token: string,
19
+ frame: RefObject<HTMLIFrameElement>,
20
+ expanded: boolean,
21
+ ): void {
22
+ const enabled = canCopyPrimitiveJson(node);
23
+ useLayoutEffect(() => {
24
+ if (!enabled) return;
25
+ const owner = { nodeId: node.id, token, frame, expanded };
26
+ owners.add(owner);
27
+ return () => {
28
+ owners.delete(owner);
29
+ };
30
+ }, [enabled, node.id, token, expanded]);
31
+ }
32
+
33
+ function readCurrentJson(nodeId: string): Promise<string> {
34
+ // The expanded document, when present, owns the latest interactive state.
35
+ const connected = [...owners].filter((owner) => owner.nodeId === nodeId && owner.frame.current?.isConnected);
36
+ const owner = connected.find((owner) => owner.expanded) ?? connected[0];
37
+ const frame = owner?.frame.current;
38
+ if (!owner || !frame?.contentWindow) return Promise.reject(new Error('Open the viewer before copying JSON.'));
39
+ const requestId = crypto.randomUUID();
40
+ return new Promise((resolve, reject) => {
41
+ const finish = (json?: string) => {
42
+ clearTimeout(timer);
43
+ window.removeEventListener('message', receive);
44
+ frame.removeEventListener('load', changed);
45
+ if (json !== undefined) resolve(json);
46
+ else reject(new Error('The viewer is unavailable or changed. Wait for it to load, then try again.'));
47
+ };
48
+ const changed = () => finish();
49
+ const receive = (event: MessageEvent) => {
50
+ const message = event.data;
51
+ if (
52
+ event.source !== frame.contentWindow ||
53
+ event.origin !== 'null' ||
54
+ message?.source !== HTML_SURFACE_PUSH_SOURCE ||
55
+ message.type !== 'copy-json-result' ||
56
+ message.token !== owner.token ||
57
+ message.requestId !== requestId
58
+ )
59
+ return;
60
+ finish(
61
+ owners.has(owner) && owner.frame.current === frame && frame.isConnected && typeof message.json === 'string'
62
+ ? message.json
63
+ : undefined,
64
+ );
65
+ };
66
+ const timer = setTimeout(changed, 3000);
67
+ window.addEventListener('message', receive);
68
+ frame.addEventListener('load', changed, { once: true });
69
+ // Opaque-origin sandbox: exact source + per-document nonce bind the reply.
70
+ frame.contentWindow!.postMessage(
71
+ { source: HTML_SURFACE_PUSH_SOURCE, type: 'copy-json', token: owner.token, requestId },
72
+ '*',
73
+ );
74
+ });
75
+ }
76
+
77
+ export async function copyPrimitiveJson(nodeId: string): Promise<void> {
78
+ try {
79
+ if (!navigator.clipboard) throw new Error('Clipboard access is unavailable in this browser.');
80
+ const json = readCurrentJson(nodeId);
81
+ // Keep clipboard.write inside the user's activation, including WebKit. The
82
+ // iframe supplies bytes, never clipboard permission or a stale server export.
83
+ void json.catch(() => {});
84
+ if (typeof ClipboardItem !== 'undefined' && navigator.clipboard.write) {
85
+ await navigator.clipboard.write([
86
+ new ClipboardItem({ 'text/plain': json.then((text) => new Blob([text], { type: 'text/plain' })) }),
87
+ ]);
88
+ } else {
89
+ await navigator.clipboard.writeText(await json);
90
+ }
91
+ showToast('context', 'JSON copied', '', [nodeId]);
92
+ } catch (error) {
93
+ showToast('remove', 'Could not copy JSON', error instanceof Error ? error.message : 'Clipboard access failed.', [
94
+ nodeId,
95
+ ]);
96
+ }
97
+ }