pmx-canvas 0.9.3 → 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 (237) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +9418 -2016
  3. package/CHANGELOG.md +54 -1
  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/mcp/canvas-access.d.ts +1 -12
  114. package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
  115. package/dist/types/mcp/server.d.ts +2 -0
  116. package/dist/types/server/ax-wait.d.ts +1 -0
  117. package/dist/types/server/canvas-operations.d.ts +1 -0
  118. package/dist/types/server/canvas-state.d.ts +2 -0
  119. package/dist/types/server/index.d.ts +11 -6
  120. package/dist/types/server/operations/http.d.ts +10 -0
  121. package/dist/types/server/operations/index.d.ts +2 -2
  122. package/dist/types/server/operations/invoker.d.ts +4 -3
  123. package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
  124. package/dist/types/server/operations/mcp.d.ts +2 -0
  125. package/dist/types/server/operations/ops/batch.d.ts +1 -0
  126. package/dist/types/server/operations/ops/boards.d.ts +0 -1
  127. package/dist/types/server/operations/registry.d.ts +2 -2
  128. package/dist/types/server/operations/types.d.ts +12 -2
  129. package/dist/types/server/server.d.ts +2 -0
  130. package/dist/types/server/tour-control.d.ts +13 -3
  131. package/dist/types/server/webpage-node.d.ts +1 -0
  132. package/dist/types/shared/ax-surface-protocol.d.ts +0 -9
  133. package/dist/types/shared/tour.d.ts +8 -0
  134. package/docs/api-reference.md +2 -2
  135. package/docs/cli.md +1 -1
  136. package/docs/design/Chrome.dc.html +32 -6
  137. package/docs/design/Types1.dc.html +3 -2
  138. package/docs/design/Types2.dc.html +1 -1
  139. package/docs/design/Types3.dc.html +1 -1
  140. package/docs/design/Zoom.dc.html +37 -60
  141. package/docs/design/canvas.json +1 -1
  142. package/docs/design.md +169 -4
  143. package/docs/http-api.md +82 -1
  144. package/docs/mcp.md +101 -21
  145. package/docs/node-types.md +38 -3
  146. package/docs/plans/plan-009-tech-debt-backlog.md +2 -2
  147. package/docs/plans/plan-019-post-0.9-roadmap.md +402 -13
  148. package/docs/product-vision-2026-09.md +9 -0
  149. package/docs/sdk.md +5 -2
  150. package/package.json +2 -2
  151. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  152. package/plugins/pmx-canvas/plugin.json +1 -1
  153. package/plugins/pmx-canvas/runtime.json +1 -1
  154. package/plugins/pmx-canvas/tools.json +9418 -2016
  155. package/skills/pmx-canvas/SKILL.md +19 -21
  156. package/skills/pmx-canvas/references/full-reference.md +44 -6
  157. package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
  158. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +3 -5
  159. package/src/client/canvas/BarHint.tsx +6 -3
  160. package/src/client/canvas/CanvasNode.tsx +34 -47
  161. package/src/client/canvas/CanvasViewport.tsx +6 -7
  162. package/src/client/canvas/ContextMenu.tsx +63 -3
  163. package/src/client/canvas/EdgeLayer.tsx +365 -169
  164. package/src/client/canvas/ExpandedNodeOverlay.tsx +1 -2
  165. package/src/client/canvas/Presentation.tsx +53 -3
  166. package/src/client/canvas/ToolRail.tsx +2 -0
  167. package/src/client/canvas/TopBar.tsx +24 -9
  168. package/src/client/canvas/auto-fit.ts +9 -11
  169. package/src/client/canvas/canvas-area.ts +1 -1
  170. package/src/client/canvas/use-focus-trap.ts +6 -1
  171. package/src/client/mermaid-entry.ts +6 -20
  172. package/src/client/mermaid-render.ts +39 -0
  173. package/src/client/nodes/AxStepControls.tsx +45 -69
  174. package/src/client/nodes/BoardNode.tsx +65 -9
  175. package/src/client/nodes/ContextNode.tsx +88 -396
  176. package/src/client/nodes/ExtAppFrame.tsx +283 -1462
  177. package/src/client/nodes/FileNode.tsx +92 -81
  178. package/src/client/nodes/HtmlNode.tsx +8 -36
  179. package/src/client/nodes/LedgerNode.tsx +31 -17
  180. package/src/client/nodes/MarkdownNode.tsx +8 -17
  181. package/src/client/nodes/McpAppNode.tsx +48 -154
  182. package/src/client/nodes/MermaidNode.tsx +80 -69
  183. package/src/client/nodes/PromptNode.tsx +12 -14
  184. package/src/client/nodes/ResponseNode.tsx +8 -6
  185. package/src/client/nodes/StatusNode.tsx +22 -7
  186. package/src/client/nodes/StatusSummary.tsx +5 -3
  187. package/src/client/nodes/TraceNode.tsx +8 -8
  188. package/src/client/nodes/ViewerFrame.tsx +262 -0
  189. package/src/client/nodes/ViewerHost.tsx +41 -0
  190. package/src/client/nodes/WebpageNode.tsx +22 -24
  191. package/src/client/nodes/iframe-document-url.ts +20 -27
  192. package/src/client/nodes/primitive-copy.ts +97 -0
  193. package/src/client/nodes/use-iframe-content-height.ts +34 -10
  194. package/src/client/nodes/use-surface-frame.ts +22 -11
  195. package/src/client/nodes/viewer-runtime-state.ts +41 -35
  196. package/src/client/state/canvas-store.ts +18 -12
  197. package/src/client/state/intent-bridge.ts +1 -0
  198. package/src/client/state/presentation.ts +135 -15
  199. package/src/client/state/sse-bridge.ts +18 -19
  200. package/src/client/theme/global.css +389 -229
  201. package/src/client/theme/surface-theme.css +6 -6
  202. package/src/json-render/charts/tufte-components.tsx +44 -2
  203. package/src/json-render/renderer/index.css +62 -2
  204. package/src/json-render/renderer/index.tsx +31 -7
  205. package/src/json-render/server.ts +3 -2
  206. package/src/mcp/canvas-access.ts +6 -47
  207. package/src/mcp/resource-subscriptions.ts +181 -0
  208. package/src/mcp/server.ts +185 -219
  209. package/src/server/ax-wait.ts +21 -6
  210. package/src/server/board-export.ts +35 -24
  211. package/src/server/canvas-geometry.ts +1 -0
  212. package/src/server/canvas-operations.ts +6 -2
  213. package/src/server/canvas-state.ts +5 -0
  214. package/src/server/demo-state.json +106 -100
  215. package/src/server/html-primitives.ts +3 -9
  216. package/src/server/html-surface.ts +8 -0
  217. package/src/server/index.ts +30 -12
  218. package/src/server/operations/http.ts +69 -21
  219. package/src/server/operations/index.ts +8 -1
  220. package/src/server/operations/invoker.ts +53 -5
  221. package/src/server/operations/mcp-contracts.ts +354 -0
  222. package/src/server/operations/mcp.ts +153 -18
  223. package/src/server/operations/ops/ax-await.ts +18 -10
  224. package/src/server/operations/ops/ax-read.ts +2 -0
  225. package/src/server/operations/ops/ax-timeline.ts +2 -2
  226. package/src/server/operations/ops/batch.ts +27 -8
  227. package/src/server/operations/ops/boards.ts +1 -3
  228. package/src/server/operations/ops/nodes.ts +18 -3
  229. package/src/server/operations/ops/viewport.ts +11 -9
  230. package/src/server/operations/registry.ts +24 -4
  231. package/src/server/operations/types.ts +16 -2
  232. package/src/server/server.ts +17 -59
  233. package/src/server/tour-control.ts +66 -6
  234. package/src/server/webpage-node.ts +9 -4
  235. package/src/shared/ax-surface-protocol.ts +0 -11
  236. package/src/shared/content-height-reporter.ts +4 -3
  237. package/src/shared/tour.ts +8 -0
@@ -22,21 +22,35 @@ export function useIframeContentHeight(
22
22
  node: CanvasNodeState,
23
23
  iframeRef: { current: HTMLIFrameElement | null },
24
24
  frameToken: string,
25
- ): void {
25
+ ): () => void {
26
26
  useEffect(() => {
27
27
  if (!frameToken) return undefined;
28
28
  let timer: ReturnType<typeof setTimeout> | null = null;
29
- let pendingHeight: number | null = null;
29
+ let pending: { frame: HTMLIFrameElement; height: number } | null = null;
30
+ let lastMeasurement: { frame: HTMLIFrameElement; height: number; width: number; chrome: number } | null = null;
30
31
  const held = () =>
31
32
  grabbingNodeId.value === node.id || otherHumans.value.some((human) => human.grabbingNodeId === node.id);
32
33
  function apply() {
33
34
  timer = null;
34
- if (held() || pendingHeight === null) return;
35
- const reported = pendingHeight;
36
- pendingHeight = null;
35
+ if (held() || pending === null) return;
36
+ const { frame, height: reported } = pending;
37
+ pending = null;
38
+ if (frame !== iframeRef.current) return;
37
39
  const current = nodes.value.get(node.id);
38
- if (!current) return;
39
- const target = computeContentGrowHeight(current, reported);
40
+ const card = frame.closest<HTMLElement>('.canvas-node');
41
+ if (!current || !card) return;
42
+ const chrome = card.offsetHeight - frame.clientHeight;
43
+ // Undo changes the viewport, which makes the reporter resend unchanged
44
+ // content. A replacement document owns a fresh fit even at the same size.
45
+ if (
46
+ lastMeasurement?.frame === frame &&
47
+ lastMeasurement.height === reported &&
48
+ lastMeasurement.width === current.size.width &&
49
+ lastMeasurement.chrome === chrome
50
+ )
51
+ return;
52
+ lastMeasurement = { frame, height: reported, width: current.size.width, chrome };
53
+ const target = computeContentGrowHeight(current, reported, chrome);
40
54
  if (target === null) return;
41
55
  const position = contentFitPosition(current, target, Array.from(nodes.value.values()));
42
56
  const moved = position.x !== current.position.x || position.y !== current.position.y;
@@ -49,18 +63,19 @@ export function useIframeContentHeight(
49
63
  }
50
64
  function schedule() {
51
65
  if (timer !== null) clearTimeout(timer);
52
- timer = pendingHeight !== null && !held() ? setTimeout(apply, 300) : null;
66
+ timer = pending !== null && !held() ? setTimeout(apply, 300) : null;
53
67
  }
54
68
  const dispose = effect(() => {
55
69
  held();
56
70
  schedule();
57
71
  });
58
72
  function onMessage(event: MessageEvent) {
59
- if (event.source !== iframeRef.current?.contentWindow) return;
73
+ const frame = iframeRef.current;
74
+ if (!frame || event.source !== frame.contentWindow) return;
60
75
  const d = event.data as { source?: string; type?: string; token?: string; height?: unknown } | null;
61
76
  if (!d || d.source !== 'pmx-canvas-frame' || d.type !== 'content-height' || d.token !== frameToken) return;
62
77
  if (typeof d.height !== 'number' || !Number.isFinite(d.height) || d.height <= 0) return;
63
- pendingHeight = d.height;
78
+ pending = { frame, height: d.height };
64
79
  schedule();
65
80
  }
66
81
  window.addEventListener('message', onMessage);
@@ -70,4 +85,13 @@ export function useIframeContentHeight(
70
85
  if (timer !== null) clearTimeout(timer);
71
86
  };
72
87
  }, [node.id, frameToken]);
88
+ // Pending documents can report before promotion, when they are deliberately
89
+ // not the active ref. Ask the promoted document for a fresh measurement.
90
+ return () => {
91
+ if (!frameToken) return;
92
+ iframeRef.current?.contentWindow?.postMessage(
93
+ { source: 'pmx-canvas-frame', type: 'measure-content', token: frameToken },
94
+ '*',
95
+ );
96
+ };
73
97
  }
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from 'preact/hooks';
2
2
  import { iframeMode } from '../state/iframe-mode';
3
3
  import { isHostedWorkbench, workbenchFetch } from '../state/workbench-transport';
4
+ import { VIEWER_LOAD_DEADLINE_MS, type ViewerSource } from './ViewerFrame';
4
5
 
5
6
  function isSameOriginUrl(url: string): boolean {
6
7
  if (isHostedWorkbench()) return url.startsWith('/') && !url.startsWith('//');
@@ -17,34 +18,45 @@ function isSameOriginUrl(url: string): boolean {
17
18
  * iframes are blocked by the embedding context (nested-iframe hosts like Amp
18
19
  * orb portals). Cross-origin URLs always stay `src` — they cannot be fetched
19
20
  * from here. While the probe is pending, same-origin frames stay empty rather
20
- * than loading a src that may show the broken placeholder; a failed srcdoc
21
- * fetch falls back to `src` (no worse than before).
21
+ * than loading a src that may show the broken placeholder. Preparation failures
22
+ * use the shared host's Retry, never a second load path the embed probe rejected.
22
23
  */
23
- export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string } {
24
+ export function useSurfaceFrame(url: string): ViewerSource {
24
25
  const mode = iframeMode.value;
25
26
  const [doc, setDoc] = useState<{ url: string; html: string } | null>(null);
27
+ const [error, setError] = useState(false);
28
+ const [attempt, setAttempt] = useState(0);
26
29
  const sameOrigin = Boolean(url) && isSameOriginUrl(url);
27
30
  const wantsSrcdoc = mode === 'srcdoc' && sameOrigin;
28
31
 
29
32
  useEffect(() => {
33
+ setError(false);
30
34
  if (!wantsSrcdoc) return;
31
35
  let cancelled = false;
36
+ const controller = new AbortController();
37
+ const deadline = setTimeout(() => controller.abort(), VIEWER_LOAD_DEADLINE_MS);
32
38
  // Ask the server to inline bundle assets (the mermaid renderer): hosts
33
39
  // that force srcdoc mode block sub-frame subresource requests too.
34
- void workbenchFetch(`${url}${url.includes('?') ? '&' : '?'}inline-assets=1`, { credentials: 'same-origin' })
40
+ void workbenchFetch(`${url}${url.includes('?') ? '&' : '?'}inline-assets=1`, {
41
+ credentials: 'same-origin',
42
+ signal: controller.signal,
43
+ })
35
44
  .then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
36
45
  .then((html) => {
37
46
  if (!cancelled) setDoc({ url, html });
38
47
  })
39
48
  .catch((error) => {
40
- console.error('[surface-frame] srcdoc fetch failed, falling back to src:', url, error);
41
- // Empty html marks the src fallback below.
42
- if (!cancelled) setDoc({ url, html: '' });
43
- });
49
+ if (cancelled) return;
50
+ console.error('[surface-frame] document fetch failed:', url, error);
51
+ setError(true);
52
+ })
53
+ .finally(() => clearTimeout(deadline));
44
54
  return () => {
45
55
  cancelled = true;
56
+ clearTimeout(deadline);
57
+ controller.abort();
46
58
  };
47
- }, [wantsSrcdoc, url]);
59
+ }, [wantsSrcdoc, url, attempt]);
48
60
 
49
61
  if (!url) return {};
50
62
  if (!wantsSrcdoc) {
@@ -52,6 +64,5 @@ export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string }
52
64
  }
53
65
  // Keep the previous document visible while a newer URL is being fetched —
54
66
  // same UX as src mode, where the old document stays until the new one loads.
55
- if (doc) return doc.html ? { srcdoc: doc.html } : { src: doc.url };
56
- return {};
67
+ return { srcdoc: doc?.html, error, retry: () => setAttempt((value) => value + 1) };
57
68
  }
@@ -1,13 +1,15 @@
1
1
  import { effect } from '@preact/signals';
2
2
  import { JSON_VIEWER_STATE_SOURCE } from '../../shared/ax-surface-protocol.js';
3
3
  import { canvasInteractionEpoch, nodes } from '../state/canvas-store';
4
+ import type { CanvasNodeState } from '../types';
5
+ import { VIEWER_LOAD_DEADLINE_MS } from './ViewerFrame';
4
6
 
5
7
  type Snapshot = {
6
8
  epoch: number;
7
9
  version: unknown;
8
10
  documentUrl: unknown;
9
11
  state: Record<string, unknown> | null;
10
- owners: Array<{ token: string; source: Window; mounted: () => void; ready: () => void; cancel: () => void }>;
12
+ owners: Array<{ token: string; source: Window }>;
11
13
  };
12
14
 
13
15
  // Tab-local UI state only: never persist form drafts in canvas data or AX. Keep
@@ -18,11 +20,12 @@ const snapshots = new Map<string, Snapshot>();
18
20
  let listening = false;
19
21
 
20
22
  export function restoreViewerRuntimeState(
21
- nodeId: string,
23
+ authored: CanvasNodeState,
22
24
  token: string,
23
25
  source: Window,
24
26
  signal?: AbortSignal,
25
27
  ): Promise<boolean | 'cancelled'> {
28
+ const nodeId = authored.id;
26
29
  if (!listening) {
27
30
  listening = true;
28
31
  effect(() => {
@@ -36,7 +39,6 @@ export function restoreViewerRuntimeState(
36
39
  snapshot.version !== node.data.specVersion ||
37
40
  snapshot.documentUrl !== node.data.url
38
41
  ) {
39
- for (const owner of snapshot.owners) owner.cancel();
40
42
  snapshots.delete(id);
41
43
  }
42
44
  }
@@ -54,57 +56,61 @@ export function restoreViewerRuntimeState(
54
56
  const ownerIndex =
55
57
  snapshot?.owners.findIndex((owner) => owner.token === message.token && owner.source === event.source) ?? -1;
56
58
  if (!snapshot || ownerIndex < 0) return;
57
- if (message.type === 'mounted') {
58
- snapshot.owners[ownerIndex]!.mounted();
59
- return;
60
- }
61
- if (message.type === 'ready') {
62
- snapshot.owners[ownerIndex]!.ready();
63
- return;
64
- }
65
59
  if (message.type !== 'snapshot') return;
66
60
  if (!message.state || typeof message.state !== 'object' || Array.isArray(message.state)) return;
67
61
  const { ax: _ax, ...state } = message.state as Record<string, unknown>;
68
62
  snapshot.state = state;
69
63
  // Once the replacement emits, the retired document cannot overwrite it.
70
- const retired = snapshot.owners.slice(0, ownerIndex);
71
64
  snapshot.owners = snapshot.owners.slice(ownerIndex);
72
- for (const owner of retired) owner.cancel();
73
65
  });
74
66
  }
75
67
  const node = nodes.value.get(nodeId);
76
68
  if (!node || signal?.aborted) return Promise.resolve('cancelled');
77
- const snapshot: Snapshot = snapshots.get(nodeId) ?? {
78
- epoch: canvasInteractionEpoch.value,
79
- version: node.data.specVersion,
80
- documentUrl: node.data.url,
81
- state: null,
82
- owners: [],
83
- };
84
- snapshots.set(nodeId, snapshot);
69
+ // A retained document may load after its authored spec was replaced. It can
70
+ // still acknowledge paint, but cannot own or restore the new spec's drafts.
71
+ const current = node.data.specVersion === authored.data.specVersion && node.data.url === authored.data.url;
72
+ const snapshot: Snapshot | undefined = current
73
+ ? (snapshots.get(nodeId) ?? {
74
+ epoch: canvasInteractionEpoch.value,
75
+ version: authored.data.specVersion,
76
+ documentUrl: authored.data.url,
77
+ state: null,
78
+ owners: [],
79
+ })
80
+ : undefined;
81
+ if (snapshot) snapshots.set(nodeId, snapshot);
85
82
  const ready = new Promise<boolean | 'cancelled'>((resolve) => {
86
83
  const finish = (loaded: boolean | 'cancelled') => {
87
84
  clearTimeout(timer);
88
- signal?.removeEventListener('abort', owner.cancel);
89
- if (loaded !== true) snapshot.owners = snapshot.owners.filter((entry) => entry !== owner);
85
+ window.removeEventListener('message', receive);
86
+ signal?.removeEventListener('abort', cancel);
87
+ if (loaded !== true && snapshot) snapshot.owners = snapshot.owners.filter((entry) => entry !== owner);
90
88
  resolve(loaded);
91
89
  };
92
- const owner = {
93
- token,
94
- source,
95
- mounted: () => clearTimeout(timer),
96
- ready: () => finish(true),
97
- cancel: () => finish('cancelled'),
90
+ const cancel = () => finish('cancelled');
91
+ const receive = (event: MessageEvent) => {
92
+ if (
93
+ event.source !== source ||
94
+ event.data?.source !== JSON_VIEWER_STATE_SOURCE ||
95
+ event.data?.token !== token ||
96
+ event.data?.nodeId !== nodeId
97
+ )
98
+ return;
99
+ if (event.data.type === 'mounted') clearTimeout(timer);
100
+ if (event.data.type === 'ready') finish(true);
98
101
  };
102
+ const owner = { token, source };
99
103
  // A missing runtime fails, but a healthy offscreen document can wait for
100
104
  // paint indefinitely. Its synchronous mount acknowledgement ends the deadline;
101
105
  // only the later frame-gated ready message may promote it.
102
- const timer = setTimeout(() => finish(false), 10_000);
103
- for (const previous of snapshot.owners.filter((entry) => entry.source === source)) previous.cancel();
104
- while (snapshot.owners.length > 1) snapshot.owners[0]!.cancel();
105
- snapshot.owners.push(owner);
106
- signal?.addEventListener('abort', owner.cancel, { once: true });
106
+ const timer = setTimeout(() => finish(false), VIEWER_LOAD_DEADLINE_MS);
107
+ if (snapshot) {
108
+ snapshot.owners = snapshot.owners.filter((entry) => entry.source !== source).slice(-1);
109
+ snapshot.owners.push(owner);
110
+ }
111
+ window.addEventListener('message', receive);
112
+ signal?.addEventListener('abort', cancel, { once: true });
107
113
  });
108
- source.postMessage({ source: JSON_VIEWER_STATE_SOURCE, type: 'restore', token, state: snapshot.state }, '*');
114
+ source.postMessage({ source: JSON_VIEWER_STATE_SOURCE, type: 'restore', token, state: snapshot?.state ?? null }, '*');
109
115
  return ready;
110
116
  }
@@ -637,18 +637,22 @@ export function resizeNode(id: string, size: { width: number; height: number }):
637
637
  updateNode(id, { size });
638
638
  }
639
639
 
640
- export function bringToFront(id: string): void {
640
+ export function bringToFront(id: string, persist = true): void {
641
641
  const existing = nodes.value.get(id);
642
642
  if (!existing) return;
643
- batch(() => {
644
- updateNode(id, { zIndex: maxZ++ });
645
- // A group is a frame: it must never stack above its own children.
646
- if (existing.type === 'group' && Array.isArray(existing.data.children)) {
647
- for (const childId of existing.data.children) {
648
- if (typeof childId === 'string' && nodes.value.has(childId)) updateNode(childId, { zIndex: maxZ++ });
649
- }
643
+ const updates = [{ id, zIndex: maxZ++ }];
644
+ // A group is a frame: it must never stack above its own children.
645
+ if (existing.type === 'group' && Array.isArray(existing.data.children)) {
646
+ for (const childId of existing.data.children) {
647
+ if (typeof childId === 'string' && nodes.value.has(childId)) updates.push({ id: childId, zIndex: maxZ++ });
650
648
  }
649
+ }
650
+ batch(() => {
651
+ for (const update of updates) updateNode(update.id, { zIndex: update.zIndex });
651
652
  });
653
+ // Title-bar gestures commit stacking with geometry at pointer-up. A separate
654
+ // drag-start write would broadcast an old layout over the in-progress move.
655
+ if (persist) void pushCanvasUpdate(updates, { recordHistory: false });
652
656
  activeNodeId.value = id;
653
657
  }
654
658
 
@@ -820,14 +824,17 @@ export function cancelViewportAnimation(): void {
820
824
  // ── Persistence ───────────────────────────────────────────────
821
825
  const STORAGE_KEY = 'pmx-canvas-layout';
822
826
 
823
- export function persistLayout(options: { recordHistory?: boolean } = {}): void {
827
+ export function persistLayout(options: { recordHistory?: boolean; raisedNodeId?: string } = {}): void {
824
828
  try {
825
829
  const allNodes = Array.from(nodes.value.values());
830
+ const raised = options.raisedNodeId ? nodes.value.get(options.raisedNodeId) : undefined;
831
+ const raisedChildren = raised?.type === 'group' && Array.isArray(raised.data.children) ? raised.data.children : [];
826
832
  const nodeUpdates = allNodes.map((n) => ({
827
833
  id: n.id,
828
834
  position: n.position,
829
835
  size: n.size,
830
836
  collapsed: n.collapsed,
837
+ ...(n.id === raised?.id || raisedChildren.includes(n.id) ? { zIndex: n.zIndex } : {}),
831
838
  }));
832
839
  const layout = {
833
840
  viewport: viewport.value,
@@ -931,7 +938,7 @@ export function fitAll(containerW: number, containerH: number): void {
931
938
  }
932
939
 
933
940
  // ── Focus node ────────────────────────────────────────────────
934
- export function focusNode(id: string, options: { recordHistory?: boolean } = {}): void {
941
+ export function focusNode(id: string, options: { recordHistory?: boolean; persistStacking?: boolean } = {}): void {
935
942
  const node = nodes.value.get(id);
936
943
  if (!node) return;
937
944
  const v = viewport.value;
@@ -959,7 +966,7 @@ export function focusNode(id: string, options: { recordHistory?: boolean } = {})
959
966
  300,
960
967
  options,
961
968
  );
962
- bringToFront(id);
969
+ bringToFront(id, options.persistStacking !== false);
963
970
  }
964
971
 
965
972
  // ── Cycle focus ───────────────────────────────────────────────
@@ -969,7 +976,6 @@ export function cycleActiveNode(direction: 1 | -1 = 1): void {
969
976
  const currentIdx = activeNodeId.value ? all.indexOf(activeNodeId.value) : -1;
970
977
  const nextIdx = (currentIdx + direction + all.length) % all.length;
971
978
  const nextId = all[nextIdx];
972
- bringToFront(nextId);
973
979
  focusNode(nextId);
974
980
  }
975
981
 
@@ -250,6 +250,7 @@ export async function pushCanvasUpdate(
250
250
  position?: { x: number; y: number };
251
251
  size?: { width: number; height: number };
252
252
  collapsed?: boolean;
253
+ zIndex?: number;
253
254
  }>,
254
255
  options: { recordHistory?: boolean } = {},
255
256
  ): Promise<void> {
@@ -1,14 +1,23 @@
1
1
  import { signal } from '@preact/signals';
2
- import { interpolateCamera, resolveStop, type TourStop } from '../../shared/tour.js';
2
+ import {
3
+ interpolateCamera,
4
+ resolveStop,
5
+ tourStopSchema,
6
+ type TourPresenter,
7
+ type TourStop,
8
+ } from '../../shared/tour.js';
3
9
  import { canvasArea } from '../canvas/canvas-area';
4
10
  import type { ViewportState } from '../types';
5
- import { animateCameraPath, nodes, viewport } from './canvas-store';
11
+ import { boardGeneration, catalogEpoch } from './boards-store';
12
+ import { animateCameraPath, cancelViewportAnimation, nodes, viewport } from './canvas-store';
6
13
  import { requestBestEffort, requestJson } from './intent-bridge';
7
14
 
8
15
  export const presenting = signal(false);
9
16
  export const recordingCamera = signal(false);
10
17
  /** Why the current stop cannot be shown (missing target, no stops, server unreachable). */
11
18
  export const presentationError = signal('');
19
+ /** Read-only mirror of the server's shared cursor, never advanced optimistically. */
20
+ export const presentationCursor = signal<{ index: number; total: number } | null>(null);
12
21
 
13
22
  export function ownsCamera(): boolean {
14
23
  return presenting.value || recordingCamera.value;
@@ -24,7 +33,21 @@ function prefersReducedMotion(): boolean {
24
33
  */
25
34
  export function playStop(stop: TourStop, onDone?: (end: ViewportState) => void): void {
26
35
  const area = canvasArea();
27
- const target = resolveStop(stop, [...nodes.value.values()], area.width, area.height);
36
+ let target: ViewportState;
37
+ if (presenting.value && !recordingCamera.value && !('viewport' in stop.target)) {
38
+ const padding = stop.padding ?? 40;
39
+ const controls = document.querySelector('.presentation-controls')?.getBoundingClientRect();
40
+ const top = Math.max(padding, controls ? controls.bottom - area.top + 12 : 0);
41
+ target = resolveStop(
42
+ { ...stop, padding: 0 },
43
+ [...nodes.value.values()],
44
+ area.width - 2 * padding,
45
+ area.height - top - padding,
46
+ );
47
+ target = { ...target, x: target.x + padding, y: target.y + top };
48
+ } else {
49
+ target = resolveStop(stop, [...nodes.value.values()], area.width, area.height);
50
+ }
28
51
  const from = { ...viewport.value };
29
52
  const duration = prefersReducedMotion() ? 0 : (stop.duration ?? 1) * 1000;
30
53
  animateCameraPath(
@@ -35,6 +58,7 @@ export function playStop(stop: TourStop, onDone?: (end: ViewportState) => void):
35
58
  }
36
59
 
37
60
  let stepSeq = 0;
61
+ let presentationRun = 0;
38
62
 
39
63
  /**
40
64
  * Show a tour stop, entering presentation if needed. Entering hides the
@@ -58,41 +82,137 @@ export function presentStop(stop: TourStop): void {
58
82
  else play();
59
83
  }
60
84
 
61
- /**
62
- * Move the server's shared tour cursor. The `canvas-tour-step` echo does the
63
- * animating, here and in every other presenting viewer, so a human's keys and
64
- * an agent's `tour.go` always agree on where the tour is.
65
- */
66
- export async function stepTour(to: { stop: number } | { step: 'next' | 'previous' }): Promise<void> {
67
- const result = await requestJson<{ ok: boolean; error?: string }>(
85
+ let requestSeq = 0;
86
+ const presenterId = crypto.randomUUID();
87
+ let lastDelivery: { catalogEpoch: string; sequence: number; applied: boolean } | null = null;
88
+
89
+ function nextPresenter(): TourPresenter | null {
90
+ if (catalogEpoch === null || boardGeneration === null) {
91
+ presentationError.value = 'The board is still connecting. Try presenting again when it is ready.';
92
+ return null;
93
+ }
94
+ return { clientId: presenterId, intent: ++requestSeq, catalogEpoch, boardGeneration };
95
+ }
96
+
97
+ function isCurrentPresenter(presenter: TourPresenter): boolean {
98
+ return (
99
+ presenter.intent === requestSeq &&
100
+ presenter.catalogEpoch === catalogEpoch &&
101
+ presenter.boardGeneration === boardGeneration
102
+ );
103
+ }
104
+
105
+ function acceptDelivery(data: Record<string, unknown>, apply: boolean): boolean {
106
+ if (
107
+ typeof data.catalogEpoch !== 'string' ||
108
+ data.catalogEpoch !== catalogEpoch ||
109
+ data.boardGeneration !== boardGeneration ||
110
+ typeof data.sequence !== 'number' ||
111
+ (lastDelivery?.catalogEpoch === catalogEpoch &&
112
+ (data.sequence < lastDelivery.sequence ||
113
+ (data.sequence === lastDelivery.sequence && (!apply || lastDelivery.applied))))
114
+ )
115
+ return false;
116
+ lastDelivery = { catalogEpoch: data.catalogEpoch, sequence: data.sequence, applied: apply };
117
+ return true;
118
+ }
119
+
120
+ /** Apply either delivery of the server's shared cursor, without replaying its echo. */
121
+ export function applyTourStep(data: Record<string, unknown>): void {
122
+ if (recordingCamera.value) return;
123
+ const stop = tourStopSchema.safeParse(data.stop);
124
+ const display = presenting.value || data.present === true;
125
+ // Observe hidden steps too. The same operation's HTTP acknowledgement may
126
+ // authorize local presentation later, but an older acknowledgement must not.
127
+ if (!stop.success || !acceptDelivery(data, display) || !display) return;
128
+ if (typeof data.index === 'number' && typeof data.total === 'number') {
129
+ presentationCursor.value = { index: data.index, total: data.total };
130
+ }
131
+ presentStop(stop.data);
132
+ }
133
+
134
+ export function applyTourExit(data: Record<string, unknown>): void {
135
+ if (!recordingCamera.value && acceptDelivery(data, true)) hidePresentation();
136
+ }
137
+
138
+ /** Move the shared cursor. A successful response also works while events are delayed or disconnected. */
139
+ export async function stepTour(
140
+ to: { stop: number } | { step: 'next' | 'previous' },
141
+ presenter = nextPresenter(),
142
+ ): Promise<void> {
143
+ if (!presenter) return;
144
+ const run = presentationRun;
145
+ const previous = lastDelivery;
146
+ const result = await requestJson<
147
+ | {
148
+ ok: true;
149
+ catalogEpoch: string;
150
+ boardGeneration: number;
151
+ sequence: number;
152
+ index: number;
153
+ total: number;
154
+ stop: TourStop;
155
+ }
156
+ | { ok: true; superseded: true }
157
+ | { ok: false; error?: string }
158
+ >(
68
159
  'step tour',
69
160
  '/api/canvas/tour/go',
70
161
  { ok: false, error: 'Could not reach the canvas server.' },
71
162
  {
72
163
  method: 'POST',
73
164
  headers: { 'Content-Type': 'application/json' },
74
- body: JSON.stringify({ ...to, present: false }),
165
+ body: JSON.stringify({ ...to, present: false, presenter }),
75
166
  },
76
167
  );
77
- if (!result.ok) presentationError.value = result.error ?? 'Could not change the tour stop.';
168
+ // Only a local cancellation invalidates this intent. Remote steps/exits are
169
+ // ordered by the server stamp, even if an older exit hid the pending restart.
170
+ if (run !== presentationRun || !isCurrentPresenter(presenter)) return;
171
+ if (result.ok) {
172
+ if ('superseded' in result) {
173
+ if (previous === lastDelivery)
174
+ presentationError.value = 'The presentation request was superseded. Exit and present again.';
175
+ } else applyTourStep({ ...result, present: true });
176
+ } else if (previous === lastDelivery) presentationError.value = result.error ?? 'Could not change the tour stop.';
78
177
  }
79
178
 
80
179
  /** Present from the first stop, or resume where the tour already is (`?present=1`). */
81
180
  export async function startPresentation(resume = false): Promise<void> {
181
+ const run = ++presentationRun;
82
182
  presenting.value = true;
83
183
  presentationError.value = '';
184
+ presentationCursor.value = null;
185
+ // Reserve the gesture before the read: an older resume cannot overtake Next.
186
+ const presenter = nextPresenter();
187
+ if (!presenter) return;
84
188
  const at = resume
85
189
  ? (await requestJson<{ position?: number | null }>('read tour', '/api/canvas/tour', {})).position
86
190
  : null;
87
- await stepTour({ stop: typeof at === 'number' ? at : 0 });
191
+ if (run !== presentationRun || !isCurrentPresenter(presenter)) return;
192
+ await stepTour({ stop: typeof at === 'number' ? at : 0 }, presenter);
88
193
  }
89
194
 
90
- export function endPresentation(): void {
195
+ function hidePresentation(): void {
196
+ stepSeq++;
197
+ cancelViewportAnimation();
91
198
  presenting.value = false;
92
199
  presentationError.value = '';
200
+ presentationCursor.value = null;
201
+ }
202
+
203
+ /** Local cancellation also fences pending requests from this presentation. */
204
+ export function dismissPresentation(): void {
205
+ presentationRun++;
206
+ hidePresentation();
207
+ }
208
+
209
+ export function endPresentation(): void {
210
+ dismissPresentation();
211
+ const presenter = nextPresenter();
212
+ if (!presenter) return;
93
213
  void requestBestEffort('exit tour', '/api/canvas/tour/exit', {
94
214
  method: 'POST',
95
215
  headers: { 'Content-Type': 'application/json' },
96
- body: '{}',
216
+ body: JSON.stringify({ presenter }),
97
217
  });
98
218
  }