pmx-canvas 0.10.0 → 0.12.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 (343) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +3 -1
  2. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  3. package/.github/extensions/pmx-canvas/tools.json +688 -128
  4. package/CHANGELOG.md +69 -0
  5. package/dist/amp-plugin/pmx-canvas/LICENSE +21 -0
  6. package/dist/amp-plugin/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  7. package/dist/amp-plugin/pmx-canvas/index.js +5445 -0
  8. package/dist/amp-plugin/pmx-canvas/runtime.json +3 -0
  9. package/dist/amp-plugin/pmx-canvas/shared-runtime.mjs +194 -0
  10. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/SKILL.md +251 -0
  11. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/evals.json +1086 -0
  12. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/jwt.ts +17 -0
  13. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/login.ts +12 -0
  14. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/middleware.ts +13 -0
  15. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/routes/auth.ts +13 -0
  16. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/investigation-board/src/handlers/users.ts +27 -0
  17. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/mcp.json +36 -0
  18. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/ax-html-control-surface.md +101 -0
  19. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/codex-app-adapter.md +151 -0
  20. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +150 -0
  21. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/full-reference.md +1785 -0
  22. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/github-copilot-app-adapter.md +223 -0
  23. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/html-primitives.md +139 -0
  24. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/importing-context.md +132 -0
  25. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/installing-pmx-canvas.md +251 -0
  26. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/tours-and-recording.md +111 -0
  27. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/workbench-workflows.md +900 -0
  28. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas-orchestration/SKILL.md +141 -0
  29. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas-testing/SKILL.md +266 -0
  30. package/dist/amp-plugin/pmx-canvas/start-mcp.mjs +14857 -0
  31. package/dist/amp-plugin/pmx-canvas/tools.json +10963 -0
  32. package/dist/canvas/chunks/abnfDiagram-N423BO3Z-cwtsds5d.js +1 -0
  33. package/dist/canvas/chunks/architecture-TIHT7OUA-x63cwejs.js +1 -0
  34. package/dist/canvas/chunks/architectureDiagram-T3A2C74G-b4xc9tpz.js +36 -0
  35. package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-ahp3yxxs.js +132 -0
  36. package/dist/canvas/chunks/c4Diagram-5PPSVZJV-nappnay7.js +10 -0
  37. package/dist/canvas/chunks/classDiagram-JCYQIIEL-k1j2xc1t.js +1 -0
  38. package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-33x08xf2.js +1 -0
  39. package/dist/canvas/chunks/cose-bilkent-JH36ORCC-a428syda.js +1 -0
  40. package/dist/canvas/chunks/cynefin-VYW2F7L2-8v6jxfz8.js +1 -0
  41. package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-nspsqebj.js +62 -0
  42. package/dist/canvas/chunks/dagre-VZM6K2ZE-8y6177fh.js +4 -0
  43. package/dist/canvas/chunks/diagram-7IWD3JNH-vym028wn.js +30 -0
  44. package/dist/canvas/chunks/diagram-B4RE2ZJO-jddqb8sy.js +3 -0
  45. package/dist/canvas/chunks/diagram-LBJQPF4R-bjwbpkrn.js +24 -0
  46. package/dist/canvas/chunks/diagram-Q27KOJAE-j6ph6hcx.js +24 -0
  47. package/dist/canvas/chunks/diagram-UB23O5K3-p09w8jej.js +41 -0
  48. package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-j975ycq5.js +1 -0
  49. package/dist/canvas/chunks/erDiagram-JOGREHBK-sxp6hjn8.js +85 -0
  50. package/dist/canvas/chunks/eventmodeling-45OFAUF4-xzfgn6xg.js +1 -0
  51. package/dist/canvas/chunks/flowDiagram-UKHOOZJN-2nsmkskb.js +1 -0
  52. package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-4tv230y2.js +292 -0
  53. package/dist/canvas/chunks/gitGraph-TEB2WS4Q-macbhand.js +1 -0
  54. package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-mqgmnw9g.js +106 -0
  55. package/dist/canvas/chunks/index-037z5dz2.js +232 -0
  56. package/dist/canvas/chunks/index-081n3har.js +207 -0
  57. package/dist/canvas/chunks/index-5n03dmb9.js +2 -0
  58. package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
  59. package/dist/canvas/chunks/index-60pgzdqe.js +157 -0
  60. package/dist/canvas/chunks/index-6ceya556.js +332 -0
  61. package/dist/canvas/chunks/index-8g1vb57n.js +2 -0
  62. package/dist/canvas/chunks/index-8rjes9r6.js +2 -0
  63. package/dist/canvas/chunks/index-9022d1n9.js +16 -0
  64. package/dist/canvas/chunks/index-9b02042t.js +2 -0
  65. package/dist/canvas/chunks/index-ap4q4vez.js +2 -0
  66. package/dist/canvas/chunks/index-dyp2nhd3.js +2 -0
  67. package/dist/canvas/chunks/index-frvta4tm.js +2 -0
  68. package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
  69. package/dist/canvas/chunks/index-h9ycj8n2.js +2 -0
  70. package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
  71. package/dist/canvas/chunks/index-mergmj97.js +2 -0
  72. package/dist/canvas/chunks/index-nwsya6dj.js +301 -0
  73. package/dist/canvas/chunks/index-nz8qbd1p.js +2 -0
  74. package/dist/canvas/chunks/index-qhfrazv3.js +2 -0
  75. package/dist/canvas/chunks/index-v119nznk.js +4 -0
  76. package/dist/canvas/chunks/index-vpt37m3d.js +89 -0
  77. package/dist/canvas/chunks/index-y9ma6azy.js +2 -0
  78. package/dist/canvas/chunks/index-zx0j3a2z.js +179 -0
  79. package/dist/canvas/chunks/info-DKCQHKI2-a0ykzs9z.js +1 -0
  80. package/dist/canvas/chunks/infoDiagram-6WML65LV-qv97qq6a.js +2 -0
  81. package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-p9306gh0.js +70 -0
  82. package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-sv67zats.js +139 -0
  83. package/dist/canvas/chunks/kanban-definition-27J2QSJJ-k2pfam6p.js +89 -0
  84. package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
  85. package/dist/canvas/chunks/mermaid.core-83r0fdce.js +12 -0
  86. package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-vg2newm1.js +96 -0
  87. package/dist/canvas/chunks/packet-7NZHBO7P-tqnabcdv.js +1 -0
  88. package/dist/canvas/chunks/pegDiagram-VL7TDLO6-k2h7zc5n.js +1 -0
  89. package/dist/canvas/chunks/pie-RZYD4A2V-wke6gyhe.js +1 -0
  90. package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-hg169p1v.js +39 -0
  91. package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-bxtvsq0b.js +7 -0
  92. package/dist/canvas/chunks/radar-I7S5WNFK-w7ka0drw.js +1 -0
  93. package/dist/canvas/chunks/railroad-3IZDKUUU-y3vg318x.js +1 -0
  94. package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-6hqvcz3c.js +1 -0
  95. package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-vcae260m.js +1 -0
  96. package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-0dbyme23.js +1 -0
  97. package/dist/canvas/chunks/railroadDiagram-AXF67PYL-njyhcp4p.js +1 -0
  98. package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-2z6v09gm.js +84 -0
  99. package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-15mzam0e.js +40 -0
  100. package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-rbshyd2c.js +162 -0
  101. package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-sg26xwts.js +1 -0
  102. package/dist/canvas/chunks/stateDiagram-OKZ733FA-6eahp98e.js +1 -0
  103. package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-tty0ggn5.js +1 -0
  104. package/dist/canvas/chunks/swimlanes-SLNWSIFB-ze5db10t.js +1 -0
  105. package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-thze75gq.js +8 -0
  106. package/dist/canvas/chunks/timeline-definition-Z64GVDOM-vnp7jyy8.js +120 -0
  107. package/dist/canvas/chunks/treeView-QDETBFTQ-6ysffnm6.js +1 -0
  108. package/dist/canvas/chunks/treemap-6X3UGDF4-m3h9p066.js +1 -0
  109. package/dist/canvas/chunks/vennDiagram-T6HMQDX7-0qrnb7mj.js +34 -0
  110. package/dist/canvas/chunks/wardley-OPB4EBWU-dp37drwx.js +1 -0
  111. package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-8nxg8wqt.js +78 -0
  112. package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-nj921e7s.js +7 -0
  113. package/dist/canvas/global.css +483 -230
  114. package/dist/canvas/index.js +158 -219
  115. package/dist/canvas/mcp-app.html +4301 -548
  116. package/dist/canvas/mermaid-entry.js +338 -338
  117. package/dist/canvas/surface-theme.css +6 -6
  118. package/dist/json-render/index.css +1 -1
  119. package/dist/json-render/index.js +49 -49
  120. package/dist/types/client/canvas/BarHint.d.ts +5 -3
  121. package/dist/types/client/canvas/BoardMoveDialog.d.ts +2 -0
  122. package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
  123. package/dist/types/client/canvas/EdgeEditor.d.ts +19 -0
  124. package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
  125. package/dist/types/client/canvas/GroupNavigation.d.ts +2 -0
  126. package/dist/types/client/canvas/TextPrompt.d.ts +4 -0
  127. package/dist/types/client/canvas/auto-fit.d.ts +4 -5
  128. package/dist/types/client/mermaid-entry.d.ts +1 -2
  129. package/dist/types/client/mermaid-render.d.ts +1 -0
  130. package/dist/types/client/nodes/BoardNode.d.ts +1 -1
  131. package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
  132. package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
  133. package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
  134. package/dist/types/client/nodes/PromptNode.d.ts +1 -0
  135. package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
  136. package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
  137. package/dist/types/client/nodes/WorkCard.d.ts +7 -0
  138. package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
  139. package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
  140. package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +1 -0
  141. package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
  142. package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
  143. package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
  144. package/dist/types/client/state/canvas-store.d.ts +18 -1
  145. package/dist/types/client/state/human-store.d.ts +2 -0
  146. package/dist/types/client/state/intent-bridge.d.ts +5 -2
  147. package/dist/types/client/state/presentation.d.ts +17 -6
  148. package/dist/types/client/state/relation-store.d.ts +11 -0
  149. package/dist/types/client/types.d.ts +1 -1
  150. package/dist/types/json-render/catalog.d.ts +2 -2
  151. package/dist/types/json-render/server.d.ts +2 -1
  152. package/dist/types/server/agent-presence.d.ts +1 -0
  153. package/dist/types/server/ax-interaction.d.ts +1 -0
  154. package/dist/types/server/ax-state-manager.d.ts +10 -2
  155. package/dist/types/server/board-local.d.ts +4 -0
  156. package/dist/types/server/board-move.d.ts +38 -0
  157. package/dist/types/server/canvas-db.d.ts +12 -2
  158. package/dist/types/server/canvas-operations.d.ts +5 -6
  159. package/dist/types/server/canvas-provenance.d.ts +1 -1
  160. package/dist/types/server/canvas-serialization.d.ts +1 -0
  161. package/dist/types/server/canvas-state.d.ts +86 -20
  162. package/dist/types/server/card-relocation.d.ts +6 -0
  163. package/dist/types/server/context-reads.d.ts +3 -1
  164. package/dist/types/server/human-presence.d.ts +1 -1
  165. package/dist/types/server/index.d.ts +26 -10
  166. package/dist/types/server/intent-registry.d.ts +8 -4
  167. package/dist/types/server/knowledge-lifecycle.d.ts +4 -2
  168. package/dist/types/server/mutation-history.d.ts +7 -5
  169. package/dist/types/server/operations/ops/boards.d.ts +0 -1
  170. package/dist/types/server/operations/registry.d.ts +2 -0
  171. package/dist/types/server/tour-control.d.ts +13 -3
  172. package/dist/types/server/trace-manager.d.ts +1 -0
  173. package/dist/types/shared/ax-kinds.d.ts +6 -0
  174. package/dist/types/shared/ax-surface-protocol.d.ts +8 -9
  175. package/dist/types/shared/human-presence.d.ts +1 -0
  176. package/dist/types/shared/lifecycle-status.d.ts +2 -0
  177. package/dist/types/shared/tour.d.ts +8 -0
  178. package/docs/RELEASE.md +21 -14
  179. package/docs/agent-plugin.md +16 -2
  180. package/docs/amp-plugin.md +271 -0
  181. package/docs/cli.md +42 -3
  182. package/docs/design/Chrome.dc.html +32 -6
  183. package/docs/design/GuestName.dc.html +50 -0
  184. package/docs/design/PluginLogo.svg +7 -0
  185. package/docs/design/Types1.dc.html +3 -2
  186. package/docs/design/Types2.dc.html +1 -1
  187. package/docs/design/Types3.dc.html +1 -1
  188. package/docs/design/WorkCards.dc.html +39 -0
  189. package/docs/design/Zoom.dc.html +37 -60
  190. package/docs/design/canvas.json +17 -1
  191. package/docs/design/plugin-logo.png +0 -0
  192. package/docs/design.md +280 -12
  193. package/docs/evals/agents-md-sections.md +38 -0
  194. package/docs/http-api.md +179 -11
  195. package/docs/mcp.md +69 -9
  196. package/docs/node-types.md +109 -4
  197. package/docs/plans/plan-009-tech-debt-backlog.md +3 -3
  198. package/docs/plans/plan-019-post-0.9-roadmap.md +327 -31
  199. package/docs/plans/plan-020-wave-5-work-planning.md +553 -0
  200. package/docs/product-vision-2026-09.md +42 -17
  201. package/docs/sdk.md +18 -0
  202. package/package.json +7 -4
  203. package/plugins/pmx-canvas/assets/preview.png +0 -0
  204. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +3 -1
  205. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  206. package/plugins/pmx-canvas/plugin.json +2 -2
  207. package/plugins/pmx-canvas/runtime.json +1 -1
  208. package/plugins/pmx-canvas/tools.json +688 -128
  209. package/skills/data-analysis/SKILL.md +13 -26
  210. package/skills/frontend-design/LICENSE.txt +177 -0
  211. package/skills/frontend-design/SKILL.md +3 -0
  212. package/skills/pmx-canvas/SKILL.md +232 -849
  213. package/skills/pmx-canvas/evals/evals.json +26 -3
  214. package/skills/pmx-canvas/references/full-reference.md +89 -24
  215. package/skills/pmx-canvas/references/html-primitives.md +7 -0
  216. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  217. package/skills/pmx-canvas/references/tours-and-recording.md +29 -2
  218. package/skills/pmx-canvas/references/workbench-workflows.md +900 -0
  219. package/skills/pmx-canvas-testing/SKILL.md +7 -5
  220. package/skills/published-consumer-e2e/SKILL.md +25 -8
  221. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +51 -77
  222. package/skills/web-artifacts-builder/LICENSE.txt +202 -0
  223. package/skills/web-artifacts-builder/SKILL.md +6 -0
  224. package/skills/web-artifacts-builder/scripts/init-artifact.sh +2 -2
  225. package/src/cli/commands/apps.ts +1 -0
  226. package/src/cli/commands/boards.ts +30 -0
  227. package/src/client/App.tsx +20 -4
  228. package/src/client/canvas/AttentionHistory.tsx +10 -2
  229. package/src/client/canvas/BarHint.tsx +6 -3
  230. package/src/client/canvas/BoardMoveDialog.tsx +225 -0
  231. package/src/client/canvas/CanvasNode.tsx +79 -49
  232. package/src/client/canvas/CanvasViewport.tsx +42 -14
  233. package/src/client/canvas/ContextMenu.tsx +97 -15
  234. package/src/client/canvas/EdgeEditor.tsx +149 -0
  235. package/src/client/canvas/EdgeLayer.tsx +441 -174
  236. package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -2
  237. package/src/client/canvas/GroupNavigation.tsx +85 -0
  238. package/src/client/canvas/Presentation.tsx +53 -3
  239. package/src/client/canvas/RelationsPanel.tsx +9 -0
  240. package/src/client/canvas/SelectionBar.tsx +21 -0
  241. package/src/client/canvas/SessionPanel.tsx +35 -1
  242. package/src/client/canvas/TextPrompt.tsx +18 -1
  243. package/src/client/canvas/TopBar.tsx +54 -9
  244. package/src/client/canvas/auto-fit.ts +9 -11
  245. package/src/client/canvas/canvas-area.ts +2 -2
  246. package/src/client/canvas/use-focus-trap.ts +6 -1
  247. package/src/client/mermaid-entry.ts +6 -20
  248. package/src/client/mermaid-render.ts +39 -0
  249. package/src/client/nodes/AxStepControls.tsx +45 -69
  250. package/src/client/nodes/BoardNode.tsx +65 -9
  251. package/src/client/nodes/ContextNode.tsx +88 -396
  252. package/src/client/nodes/ExtAppFrame.tsx +302 -1465
  253. package/src/client/nodes/FileNode.tsx +92 -81
  254. package/src/client/nodes/HtmlNode.tsx +8 -36
  255. package/src/client/nodes/LedgerNode.tsx +31 -17
  256. package/src/client/nodes/MarkdownNode.tsx +14 -24
  257. package/src/client/nodes/McpAppNode.tsx +48 -154
  258. package/src/client/nodes/MermaidNode.tsx +80 -69
  259. package/src/client/nodes/PromptNode.tsx +44 -17
  260. package/src/client/nodes/ResponseNode.tsx +8 -6
  261. package/src/client/nodes/StatusNode.tsx +34 -7
  262. package/src/client/nodes/StatusSummary.tsx +16 -3
  263. package/src/client/nodes/TraceNode.tsx +8 -8
  264. package/src/client/nodes/ViewerFrame.tsx +273 -0
  265. package/src/client/nodes/ViewerHost.tsx +41 -0
  266. package/src/client/nodes/WebpageNode.tsx +22 -24
  267. package/src/client/nodes/WorkCard.tsx +175 -0
  268. package/src/client/nodes/iframe-document-url.ts +20 -27
  269. package/src/client/nodes/primitive-copy.ts +97 -0
  270. package/src/client/nodes/use-ax-surface-bridge.ts +18 -14
  271. package/src/client/nodes/use-iframe-content-height.ts +34 -10
  272. package/src/client/nodes/use-surface-frame.ts +22 -11
  273. package/src/client/nodes/viewer-runtime-state.ts +41 -35
  274. package/src/client/state/attention-bridge.ts +5 -0
  275. package/src/client/state/attention-store.ts +1 -0
  276. package/src/client/state/canvas-store.ts +202 -26
  277. package/src/client/state/human-store.ts +22 -2
  278. package/src/client/state/intent-bridge.ts +20 -5
  279. package/src/client/state/presentation.ts +135 -15
  280. package/src/client/state/relation-store.ts +127 -1
  281. package/src/client/state/sse-bridge.ts +18 -19
  282. package/src/client/theme/global.css +483 -230
  283. package/src/client/theme/surface-theme.css +6 -6
  284. package/src/client/types.ts +1 -0
  285. package/src/json-render/charts/tufte-components.tsx +44 -2
  286. package/src/json-render/renderer/index.css +62 -2
  287. package/src/json-render/renderer/index.tsx +31 -7
  288. package/src/json-render/server.ts +3 -2
  289. package/src/mcp/server.ts +13 -3
  290. package/src/server/agent-presence.ts +43 -23
  291. package/src/server/ax-flow-loop.ts +6 -0
  292. package/src/server/ax-gate-ttl.ts +8 -1
  293. package/src/server/ax-interaction.ts +16 -10
  294. package/src/server/ax-state-manager.ts +17 -4
  295. package/src/server/ax-wait.ts +20 -6
  296. package/src/server/board-export.ts +54 -30
  297. package/src/server/board-local.ts +20 -0
  298. package/src/server/board-move.ts +297 -0
  299. package/src/server/canvas-db.ts +249 -158
  300. package/src/server/canvas-geometry.ts +1 -0
  301. package/src/server/canvas-operations.ts +139 -109
  302. package/src/server/canvas-schema.ts +21 -0
  303. package/src/server/canvas-serialization.ts +2 -0
  304. package/src/server/canvas-state.ts +797 -159
  305. package/src/server/card-relocation.ts +53 -0
  306. package/src/server/code-graph.ts +4 -3
  307. package/src/server/context-reads.ts +83 -55
  308. package/src/server/demo-state.json +140 -114
  309. package/src/server/file-watcher.ts +30 -10
  310. package/src/server/html-primitives.ts +3 -9
  311. package/src/server/html-surface.ts +8 -0
  312. package/src/server/human-presence.ts +18 -5
  313. package/src/server/index.ts +54 -24
  314. package/src/server/intent-registry.ts +43 -5
  315. package/src/server/knowledge-lifecycle.ts +32 -9
  316. package/src/server/lifecycle-status.ts +29 -1
  317. package/src/server/mcp-app-runtime.ts +26 -7
  318. package/src/server/mutation-history.ts +37 -8
  319. package/src/server/operations/composites.ts +6 -3
  320. package/src/server/operations/mcp-contracts.ts +32 -2
  321. package/src/server/operations/mcp.ts +36 -9
  322. package/src/server/operations/ops/annotation.ts +22 -1
  323. package/src/server/operations/ops/ax-await.ts +1 -0
  324. package/src/server/operations/ops/ax-timeline.ts +28 -2
  325. package/src/server/operations/ops/batch.ts +25 -11
  326. package/src/server/operations/ops/boards.ts +45 -6
  327. package/src/server/operations/ops/nodes.ts +70 -0
  328. package/src/server/operations/ops/query.ts +32 -3
  329. package/src/server/operations/ops/snapshots.ts +1 -0
  330. package/src/server/operations/ops/viewport.ts +11 -9
  331. package/src/server/operations/registry.ts +49 -4
  332. package/src/server/server.ts +69 -75
  333. package/src/server/tour-control.ts +97 -12
  334. package/src/server/trace-manager.ts +4 -0
  335. package/src/server/web-artifacts/scripts/init-artifact.sh +2 -2
  336. package/src/shared/agent-context.ts +7 -1
  337. package/src/shared/auto-arrange.ts +5 -4
  338. package/src/shared/ax-kinds.ts +13 -0
  339. package/src/shared/ax-surface-protocol.ts +20 -9
  340. package/src/shared/content-height-reporter.ts +4 -3
  341. package/src/shared/human-presence.ts +1 -0
  342. package/src/shared/lifecycle-status.ts +2 -0
  343. package/src/shared/tour.ts +8 -0
@@ -1,5 +1,5 @@
1
- import { type Signal, signal } from '@preact/signals';
2
- import { useEffect, useRef } from 'preact/hooks';
1
+ import { type Signal, computed, signal } from '@preact/signals';
2
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
3
3
  import {
4
4
  draggingEdge,
5
5
  edges,
@@ -7,15 +7,23 @@ import {
7
7
  selectedEdgeId,
8
8
  viewport,
9
9
  visibleNodeFor,
10
+ groupContents,
10
11
  } from '../state/canvas-store';
11
12
  import { linkMarks, writerName } from '../state/context-status-store';
12
13
  import { HUMAN_STARTED_SESSION_LABEL } from '../../shared/agent-presence';
13
14
  import { activeSession } from '../state/presence-store';
14
- import { activeBoard } from '../state/boards-store';
15
+ import { activeBoard, boardIncarnation } from '../state/boards-store';
15
16
  import { reportClientViewportSize } from '../state/intent-bridge';
16
17
  import { relationDefinition, relationKind, relationText } from '../../shared/relations.js';
17
18
  import type { CanvasEdge, CanvasNodeState } from '../types';
18
- import { relationFilter, relationFocus, relationHighlight } from '../state/relation-store';
19
+ import {
20
+ dependencyAnalysis,
21
+ relationWarning,
22
+ relationFilter,
23
+ relationFocus,
24
+ relationHighlight,
25
+ } from '../state/relation-store';
26
+ import { edgeEditorAnchor } from './EdgeEditor';
19
27
 
20
28
  /** Filter only the kinds on this board; shared presets are managed in Home. */
21
29
  export function RelationLegend() {
@@ -71,6 +79,17 @@ export function RelationLegend() {
71
79
  </button>
72
80
  );
73
81
  })}
82
+ {dependencyAnalysis.value.hasDependencies && (
83
+ <>
84
+ <span class={dependencyAnalysis.value.cycles ? 'relation-warning' : ''}>
85
+ {dependencyAnalysis.value.cycles ? '⚠ ' : ''}
86
+ {dependencyAnalysis.value.cycles} dependency cycles
87
+ </span>
88
+ {dependencyAnalysis.value.focused && (
89
+ <span>Change selection → {dependencyAnalysis.value.affected.size} affected</span>
90
+ )}
91
+ </>
92
+ )}
74
93
  </div>
75
94
  );
76
95
  }
@@ -146,6 +165,8 @@ export function edgeChromeScale(scale: number): number {
146
165
  return scale < 1 ? 1 / scale : 1;
147
166
  }
148
167
 
168
+ const edgeZoom = computed(() => viewport.value.scale);
169
+
149
170
  function dashArray(edge: CanvasEdge, scale: number): string | undefined {
150
171
  const dashed = `${8 * scale} ${4 * scale}`;
151
172
  if (edge.style === 'dashed') return dashed;
@@ -238,13 +259,56 @@ function bezierMidpoint(
238
259
  };
239
260
  }
240
261
 
262
+ interface LabelObstacle {
263
+ x: number;
264
+ y: number;
265
+ width: number;
266
+ height: number;
267
+ }
268
+
269
+ /** Nearest clear position along either axis through the original curve midpoint.
270
+ * Expanded obstacles describe forbidden pill centres. Boundary candidates include
271
+ * the outside of the whole occupied area, so even a crowded board has a solution.
272
+ * World-space placement is independent of pan; all three layers use the same order.
273
+ */
274
+ function placeLabel(
275
+ mid: { x: number; y: number },
276
+ width: number,
277
+ height: number,
278
+ obstacles: LabelObstacle[],
279
+ gap: number,
280
+ ) {
281
+ const forbidden = obstacles.map((box) => ({
282
+ left: box.x - width / 2 - gap,
283
+ right: box.x + box.width + width / 2 + gap,
284
+ top: box.y - height / 2 - gap,
285
+ bottom: box.y + box.height + height / 2 + gap,
286
+ }));
287
+ const clear = (point: { x: number; y: number }) =>
288
+ forbidden.every(
289
+ (box) => point.x <= box.left || point.x >= box.right || point.y <= box.top || point.y >= box.bottom,
290
+ );
291
+ if (clear(mid)) return mid;
292
+ const candidates = [mid];
293
+ for (const box of forbidden) {
294
+ if (mid.x > box.left && mid.x < box.right) {
295
+ candidates.push({ x: mid.x, y: box.top }, { x: mid.x, y: box.bottom });
296
+ }
297
+ if (mid.y > box.top && mid.y < box.bottom) {
298
+ candidates.push({ x: box.left, y: mid.y }, { x: box.right, y: mid.y });
299
+ }
300
+ }
301
+ const distance = (point: { x: number; y: number }) => (point.x - mid.x) ** 2 + (point.y - mid.y) ** 2;
302
+ candidates.sort((a, b) => distance(a) - distance(b));
303
+ return candidates.find(clear)!;
304
+ }
305
+
241
306
  // ── EdgePath component ───────────────────────────────────────
242
307
  interface EdgePathProps {
243
308
  edge: CanvasEdge;
244
309
  fromNode: CanvasNodeState;
245
310
  toNode: CanvasNodeState;
246
- start: Anchor;
247
- end: Anchor;
311
+ layout: ReturnType<typeof edgeLayout>;
248
312
  focused: boolean; // connected to the active node
249
313
  dimmed: boolean; // active node exists but this edge is NOT connected
250
314
  selected: boolean; // click-selected — Delete removes it
@@ -260,7 +324,15 @@ interface EdgePathProps {
260
324
  */
261
325
  export type EdgeLayerPart = 'lines' | 'pills' | 'pill-hits';
262
326
 
263
- function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selected, scale, part }: EdgePathProps) {
327
+ function edgeLayout(
328
+ edge: CanvasEdge,
329
+ start: Anchor,
330
+ end: Anchor,
331
+ scale: number,
332
+ obstacles: LabelObstacle[],
333
+ textWidth: (text: string, weight?: number) => number,
334
+ parallelCount: number,
335
+ ) {
264
336
  const dx = end.x - start.x;
265
337
  const dy = end.y - start.y;
266
338
  const dist = Math.sqrt(dx * dx + dy * dy);
@@ -279,26 +351,85 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
279
351
  // label chrome caps like node chrome (2.2). Below 35% zoom the label goes and
280
352
  // only the link's marks stay (LinkAuthorship.dc.html).
281
353
  const labelScale = Math.min(scale, 2.2);
282
- const zoom = viewport.value.scale;
354
+ const zoom = edgeZoom.value;
283
355
  const marks = linkMarks(edge);
284
356
  // Reasons belong in the hint/inspector, not a long pill covering both endpoints.
285
- const label = edge.label ?? edge.kind;
357
+ const baseLabel = edge.label ?? edge.kind;
358
+ const label = parallelCount > 1 ? `${baseLabel ?? relationKind(edge)} · ×${parallelCount}` : baseLabel;
359
+ const warning = [
360
+ dependencyAnalysis.value.cycleEdges.has(edge.id) ? 'Cycle' : '',
361
+ relationWarning(edge).length ? 'Source needs review' : '',
362
+ ]
363
+ .filter(Boolean)
364
+ .join(' · ');
365
+ const warningText = warning ? `⚠ ${warning}` : '';
366
+ const warningWidth = warning ? textWidth(warningText, 500) + 12 : 0;
286
367
  const showLabel = !!label && zoom >= 0.35;
287
368
  // Pill layout in label units (× labelScale): the label, then a hairline and
288
369
  // "not seen by …", which keeps only its glyph on a short line or below 60% zoom.
289
370
  const pad = showLabel ? 9 : 5;
290
- const labelWidth = showLabel ? (label?.length ?? 0) * 7 : 0;
371
+ const labelWidth = showLabel ? textWidth(label) : 0;
291
372
  const notSeenText = `not seen by ${agentName()}`;
292
- const wordedNotSeenWidth = 12 + notSeenText.length * 6.2 + 4;
373
+ const wordedNotSeenWidth = 12 + textWidth(notSeenText, 500) + 4;
293
374
  const fullWidth = pad * 2 + labelWidth + (marks.notSeen ? (labelWidth ? 12 : 0) + wordedNotSeenWidth : 0);
294
375
  // A short line: the full pill plus some line on either side would not fit.
295
376
  const notSeenWords = marks.notSeen && zoom >= 0.6 && dist >= (fullWidth + 60) * labelScale;
296
377
  const notSeenWidth = marks.notSeen ? (labelWidth ? 12 : 0) + (notSeenWords ? wordedNotSeenWidth : 12) : 0;
297
- const pillWidth = pad * 2 + labelWidth + notSeenWidth;
378
+ const pillWidth = pad * 2 + labelWidth + notSeenWidth + warningWidth;
298
379
  const labelX = -pillWidth / 2 + pad;
299
380
  const notSeenX = labelX + labelWidth + (labelWidth ? 12 : 0);
300
- const hasPill = showLabel || marks.notSeen;
301
- const mid = hasPill ? bezierMidpoint(start.x, start.y, cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y) : null;
381
+ const hasPill = showLabel || marks.notSeen || warningWidth > 0;
382
+ const curveMid = bezierMidpoint(start.x, start.y, cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y);
383
+ const mid = hasPill ? placeLabel(curveMid, pillWidth * labelScale, 20 * labelScale, obstacles, 4 * scale) : null;
384
+ if (mid) {
385
+ obstacles.push({
386
+ x: mid.x - (pillWidth / 2) * labelScale,
387
+ y: mid.y - 10 * labelScale,
388
+ width: pillWidth * labelScale,
389
+ height: 20 * labelScale,
390
+ });
391
+ }
392
+ return {
393
+ d,
394
+ color,
395
+ directed,
396
+ dash,
397
+ labelScale,
398
+ marks,
399
+ label,
400
+ showLabel,
401
+ labelWidth,
402
+ notSeenText,
403
+ notSeenWords,
404
+ pillWidth,
405
+ labelX,
406
+ notSeenX,
407
+ curveMid,
408
+ mid,
409
+ warningText,
410
+ warningX: labelX + labelWidth + notSeenWidth + 6,
411
+ };
412
+ }
413
+
414
+ function EdgePath({ edge, fromNode, toNode, layout, focused, dimmed, selected, scale, part }: EdgePathProps) {
415
+ const {
416
+ d,
417
+ color,
418
+ directed,
419
+ dash,
420
+ labelScale,
421
+ marks,
422
+ label,
423
+ showLabel,
424
+ labelWidth,
425
+ notSeenText,
426
+ notSeenWords,
427
+ pillWidth,
428
+ labelX,
429
+ notSeenX,
430
+ curveMid,
431
+ mid,
432
+ } = layout;
302
433
 
303
434
  const pathId = `edge-path-${edge.id}`;
304
435
  const select = (e: MouseEvent) => {
@@ -306,12 +437,15 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
306
437
  // that LANDED on an edge must not immediately undo itself.
307
438
  e.stopPropagation();
308
439
  selectedEdgeId.value = edge.id;
440
+ edgeHint.value = null;
309
441
  };
310
442
  const showHint = (anchor: Element, point?: { x: number; y: number }) => {
443
+ if (selectedEdgeId.value === edge.id) return;
311
444
  const text = [
312
445
  `${String(fromNode.data.title ?? fromNode.id)} → ${String(toNode.data.title ?? toNode.id)}`,
313
446
  relationText(edge),
314
447
  linkHint(edge, marks.notSeen),
448
+ ...relationWarning(edge),
315
449
  ]
316
450
  .filter(Boolean)
317
451
  .join('\n');
@@ -355,6 +489,7 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
355
489
  transform={`translate(${mid.x}, ${mid.y})`}
356
490
  class="edge-pill"
357
491
  data-testid="edge-pill"
492
+ data-edge-id={edge.id}
358
493
  opacity={dimmed && !selected ? 0.35 : 1}
359
494
  >
360
495
  <rect
@@ -410,12 +545,39 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
410
545
  )}
411
546
  </g>
412
547
  )}
548
+ {layout.warningText && (
549
+ <text
550
+ class="edge-label"
551
+ x={layout.warningX * labelScale}
552
+ text-anchor="start"
553
+ dominant-baseline="central"
554
+ fill="var(--c-warn)"
555
+ >
556
+ {layout.warningText}
557
+ </text>
558
+ )}
413
559
  </g>
414
560
  );
415
561
  }
416
562
 
417
563
  return (
418
564
  <g>
565
+ {/* A displaced label remains attached to its original curve, not a new
566
+ relation. No arrow; same semantic ink, below cards like the curve. */}
567
+ {mid && curveMid && (mid.x !== curveMid.x || mid.y !== curveMid.y) && (
568
+ <line
569
+ data-testid="edge-label-leader"
570
+ data-edge-id={edge.id}
571
+ x1={curveMid.x}
572
+ y1={curveMid.y}
573
+ x2={mid.x}
574
+ y2={mid.y}
575
+ stroke={color}
576
+ stroke-width={scale}
577
+ stroke-dasharray={`${3 * scale} ${3 * scale}`}
578
+ opacity={dimmed && !selected ? 0.2 : 0.85}
579
+ />
580
+ )}
419
581
  {/* Invisible wide hitbox — the ONE interactive part of the edge layer
420
582
  (the svg itself is pointer-events:none): click selects the edge
421
583
  (Delete then removes it); usePanZoom owns right-click versus drag. */}
@@ -462,52 +624,146 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
462
624
  interface EdgeLayerProps {
463
625
  nodes: Signal<Map<string, CanvasNodeState>>;
464
626
  edges: Signal<Map<string, CanvasEdge>>;
465
- part: EdgeLayerPart;
466
627
  }
467
628
 
468
- export function EdgeLayer({ nodes, edges, part }: EdgeLayerProps) {
469
- const nodeMap = nodes.value;
470
- const edgeList = Array.from(edges.value.values());
629
+ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
630
+ const textContext = useMemo(() => document.createElement('canvas').getContext('2d'), []);
631
+ const font = useMemo(
632
+ () =>
633
+ signal({
634
+ family: getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif',
635
+ version: 0,
636
+ }),
637
+ [],
638
+ );
639
+ useLayoutEffect(() => {
640
+ // Re-measure when Plex replaces the fallback font, without waiting for a
641
+ // pan/zoom. All three paint/hit layers share the same glyph advances.
642
+ const refresh = () => {
643
+ font.value = {
644
+ family: getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif',
645
+ version: font.value.version + 1,
646
+ };
647
+ };
648
+ document.fonts?.addEventListener('loadingdone', refresh);
649
+ return () => document.fonts?.removeEventListener('loadingdone', refresh);
650
+ }, []);
651
+ const geometry = useMemo(
652
+ () =>
653
+ computed(() => {
654
+ const family = font.value.family;
655
+ const textWidth = (text: string, weight = 400) => {
656
+ // Non-rendering DOMs have no canvas context. Keep their bounds conservative.
657
+ if (!textContext) return text.length * 11;
658
+ textContext.font = `${weight} 11px ${family}`;
659
+ const metrics = textContext.measureText(text);
660
+ return Math.ceil(Math.max(metrics.width, metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight));
661
+ };
662
+ const nodeMap = nodes.value;
663
+ const inside = groupContents.value;
664
+ const edgeList = Array.from(edges.value.values()).filter(
665
+ (edge) => !inside || inside.has(edge.from) || inside.has(edge.to),
666
+ );
667
+ const resolve = (id: string) => {
668
+ const visible = visibleNodeFor(id);
669
+ return nodeMap.get(visible?.id ?? id) ?? nodeMap.get(id);
670
+ };
671
+ const pairs = new Map<string, number>();
672
+ const pairKey = (edge: CanvasEdge) => JSON.stringify([edge.from, edge.to].sort());
673
+ for (const edge of edgeList) if (!edge.toBoardId) pairs.set(pairKey(edge), (pairs.get(pairKey(edge)) ?? 0) + 1);
674
+ const scale = edgeChromeScale(edgeZoom.value);
675
+
676
+ // Allocate a port to every incident link, including parallel and reverse links.
677
+ const ports = new Map<string, { start: Anchor; end: Anchor }>();
678
+ const sides = new Map<string, Array<{ anchor: Anchor; node: CanvasNodeState; order: number; id: string }>>();
679
+ for (const edge of edgeList) {
680
+ if (edge.toBoardId) continue;
681
+ const from = resolve(edge.from);
682
+ const to = resolve(edge.to);
683
+ if (!from || !to || from.id === to.id) continue;
684
+ const start = computeAnchor(from, to);
685
+ const end = computeAnchor(to, from);
686
+ ports.set(edge.id, { start, end });
687
+ for (const [anchor, node, other] of [
688
+ [start, from, to],
689
+ [end, to, from],
690
+ ] as const) {
691
+ const key = `${node.id}:${anchor.side}`;
692
+ const vertical = anchor.side === 'left' || anchor.side === 'right';
693
+ const group = sides.get(key) ?? [];
694
+ group.push({ anchor, node, order: vertical ? other.position.y : other.position.x, id: edge.id });
695
+ sides.set(key, group);
696
+ }
697
+ }
698
+ for (const group of sides.values()) {
699
+ if (group.length < 2) continue;
700
+ group.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
701
+ group.forEach(({ anchor, node }, index) => {
702
+ const fraction = (index + 1) / (group.length + 1);
703
+ if (anchor.side === 'left' || anchor.side === 'right')
704
+ anchor.y = node.position.y + node.size.height * fraction;
705
+ else anchor.x = node.position.x + node.size.width * fraction;
706
+ });
707
+ }
708
+
709
+ // Reserve the actual four outward handle rectangles, not a padded card:
710
+ // padding the whole card falsely closes row gaps at fractional zoom.
711
+ // Expanded groups have only their 24px edge row, and no ports.
712
+ const obstacles: LabelObstacle[] = [];
713
+ const portSize = 32 / edgeZoom.value;
714
+ for (const node of nodeMap.values()) {
715
+ if (resolve(node.id)?.id !== node.id) continue;
716
+ const frame = node.type === 'group' && !node.collapsed;
717
+ const { x, y } = node.position;
718
+ const { width, height } = node.size;
719
+ obstacles.push({
720
+ x,
721
+ y: y - (frame ? 12 : 0),
722
+ width,
723
+ height: frame ? 24 : height,
724
+ });
725
+ if (node.type !== 'group') {
726
+ for (const [px, py] of [
727
+ [x + width / 2 - portSize / 2, y - portSize],
728
+ [x + width / 2 - portSize / 2, y + height],
729
+ [x - portSize, y + height / 2 - portSize / 2],
730
+ [x + width, y + height / 2 - portSize / 2],
731
+ ]) {
732
+ obstacles.push({ x: px, y: py, width: portSize, height: portSize });
733
+ }
734
+ }
735
+ }
736
+ const layouts = new Map<string, ReturnType<typeof edgeLayout>>();
737
+ for (const edge of edgeList) {
738
+ const anchors = ports.get(edge.id);
739
+ if (anchors)
740
+ layouts.set(
741
+ edge.id,
742
+ edgeLayout(edge, anchors.start, anchors.end, scale, obstacles, textWidth, pairs.get(pairKey(edge)) ?? 1),
743
+ );
744
+ }
745
+ return { nodeMap, edgeList, layouts, scale, resolve };
746
+ }),
747
+ [nodes, edges, textContext, font],
748
+ );
749
+
750
+ const { nodeMap, edgeList, layouts, scale, resolve } = geometry.value;
751
+ const selected = selectedEdgeId.value;
752
+ const layout = selected ? layouts.get(selected) : null;
753
+ const point = layout?.mid ?? layout?.curveMid;
754
+ useLayoutEffect(() => {
755
+ edgeEditorAnchor.value = selected && point ? { id: selected, incarnation: boardIncarnation, point } : null;
756
+ }, [selected, point?.x, point?.y, boardIncarnation]);
757
+ useEffect(
758
+ () => () => {
759
+ edgeEditorAnchor.value = null;
760
+ },
761
+ [],
762
+ );
471
763
  const focusId = relationFocus.value;
472
764
  const hasFocus = focusId !== null;
473
765
  const searchSet = searchHighlightIds.value;
474
766
  const hasSearch = searchSet !== null;
475
- const scale = edgeChromeScale(viewport.value.scale);
476
-
477
- // Allocate a port to every incident link, including parallel and reverse links.
478
- const ports = new Map<string, { start: Anchor; end: Anchor }>();
479
- const sides = new Map<string, Array<{ anchor: Anchor; node: CanvasNodeState; order: number; id: string }>>();
480
- for (const edge of edgeList) {
481
- if (edge.toBoardId) continue;
482
- const from = visibleNodeFor(edge.from);
483
- const to = visibleNodeFor(edge.to);
484
- if (!from || !to || from.id === to.id) continue;
485
- const start = computeAnchor(from, to);
486
- const end = computeAnchor(to, from);
487
- ports.set(edge.id, { start, end });
488
- for (const [anchor, node, other] of [
489
- [start, from, to],
490
- [end, to, from],
491
- ] as const) {
492
- const key = `${node.id}:${anchor.side}`;
493
- const vertical = anchor.side === 'left' || anchor.side === 'right';
494
- const group = sides.get(key) ?? [];
495
- group.push({ anchor, node, order: vertical ? other.position.y : other.position.x, id: edge.id });
496
- sides.set(key, group);
497
- }
498
- }
499
- for (const group of sides.values()) {
500
- if (group.length < 2) continue;
501
- group.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
502
- group.forEach(({ anchor, node }, index) => {
503
- const fraction = (index + 1) / (group.length + 1);
504
- if (anchor.side === 'left' || anchor.side === 'right') anchor.y = node.position.y + node.size.height * fraction;
505
- else anchor.x = node.position.x + node.size.width * fraction;
506
- });
507
- }
508
-
509
- // A drag-to-connect preview must draw on a board with no edges yet.
510
- if (edgeList.length === 0 && (part !== 'lines' || !draggingEdge.value)) return null;
511
767
 
512
768
  const PAD = 96;
513
769
  const worldNodes = Array.from(nodeMap.values());
@@ -520,128 +776,139 @@ export function EdgeLayer({ nodes, edges, part }: EdgeLayerProps) {
520
776
  const height = Math.max(1, maxY - minY);
521
777
 
522
778
  return (
523
- <svg
524
- aria-label={part === 'lines' ? 'Canvas connections' : undefined}
525
- aria-hidden={part !== 'lines' ? 'true' : undefined}
526
- role={part === 'lines' ? 'img' : undefined}
527
- viewBox={`${minX} ${minY} ${width} ${height}`}
528
- width={width}
529
- height={height}
530
- style={{
531
- position: 'absolute',
532
- top: `${minY}px`,
533
- left: `${minX}px`,
534
- // Painting stays unchanged. Hit priority is card > port > pill > group
535
- // interior > line, including raised and nested group frames.
536
- zIndex:
537
- part === 'pills' ? 1 : part === 'pill-hits' ? 'calc(var(--node-port-z, -1) - 1)' : 'var(--edge-line-z, -5)',
538
- pointerEvents: 'none',
539
- overflow: 'visible',
540
- '--edge-chrome-scale': scale.toFixed(3),
541
- '--edge-label-scale': Math.min(scale, 2.2).toFixed(3),
542
- }}
543
- >
544
- {part === 'lines' && <title>Canvas connections</title>}
545
- <defs>
546
- {/* userSpaceOnUse decouples the arrowhead from the UNCAPPED stroke
779
+ <>
780
+ {(['lines', 'pills', 'pill-hits'] as const).map((part) =>
781
+ // A drag-to-connect preview must draw on a board with no edges yet.
782
+ edgeList.length === 0 && (part !== 'lines' || !draggingEdge.value) ? null : (
783
+ <svg
784
+ key={part}
785
+ aria-label={part === 'lines' ? 'Canvas connections' : undefined}
786
+ aria-hidden={part !== 'lines' ? 'true' : undefined}
787
+ role={part === 'lines' ? 'img' : undefined}
788
+ viewBox={`${minX} ${minY} ${width} ${height}`}
789
+ width={width}
790
+ height={height}
791
+ style={{
792
+ position: 'absolute',
793
+ top: `${minY}px`,
794
+ left: `${minX}px`,
795
+ // Painting stays unchanged. Hit priority is card > port > pill > group
796
+ // interior > line, including raised and nested group frames.
797
+ zIndex:
798
+ part === 'pills'
799
+ ? 1
800
+ : part === 'pill-hits'
801
+ ? 'calc(var(--node-port-z, -1) - 1)'
802
+ : 'var(--edge-line-z, -5)',
803
+ pointerEvents: 'none',
804
+ overflow: 'visible',
805
+ '--edge-chrome-scale': scale.toFixed(3),
806
+ '--edge-label-scale': Math.min(scale, 2.2).toFixed(3),
807
+ }}
808
+ >
809
+ {part === 'lines' && <title>Canvas connections</title>}
810
+ <defs>
811
+ {/* userSpaceOnUse decouples the arrowhead from the UNCAPPED stroke
547
812
  compensation: the line stays a visible hairline at any zoom, but
548
813
  the head caps at the node-chrome scale (2.2) instead of dwarfing
549
814
  sliver nodes at overview zoom. */}
550
- <marker
551
- id="edge-arrow"
552
- viewBox="0 0 10 10"
553
- refX="9"
554
- refY="5"
555
- markerUnits="userSpaceOnUse"
556
- markerWidth={8 * Math.min(scale, 2.2)}
557
- markerHeight={8 * Math.min(scale, 2.2)}
558
- orient="auto-start-reverse"
559
- >
560
- <path d="M 0 1 L 10 5 L 0 9 z" fill="context-stroke" opacity="0.75" />
561
- </marker>
562
- </defs>
563
- {edgeList.map((edge) => {
564
- // Remote endpoints are navigable links, never a same-ID card on this board.
565
- if (edge.toBoardId) return null;
566
- // An endpoint hidden behind a collapsed group's chip is drawn to the chip.
567
- const fromNode = visibleNodeFor(edge.from);
568
- const toNode = visibleNodeFor(edge.to);
569
- const anchors = ports.get(edge.id);
570
- if (!fromNode || !toNode || !anchors) return null;
571
- const isConnected = hasFocus && (edge.from === focusId || edge.to === focusId);
572
- const searchDimmed = hasSearch && !(searchSet.has(edge.from) || searchSet.has(edge.to));
573
- const highlight = relationHighlight.value;
574
- return (
575
- <EdgePath
576
- key={edge.id}
577
- edge={edge}
578
- fromNode={fromNode}
579
- toNode={toNode}
580
- start={anchors.start}
581
- end={anchors.end}
582
- focused={isConnected}
583
- dimmed={(highlight !== null && !highlight.links.has(edge.id)) || searchDimmed}
584
- selected={selectedEdgeId.value === edge.id}
585
- scale={scale}
586
- part={part}
587
- />
588
- );
589
- })}
590
- {/* Live preview edge while drag-connecting */}
591
- {part === 'lines' &&
592
- draggingEdge.value &&
593
- (() => {
594
- const de = draggingEdge.value;
595
- // Route the preview EXACTLY like the committed edge will route
596
- // (side-tangent anchors against a 1px rect at the cursor) — the old
597
- // collinear preview made the line visibly JUMP on release.
598
- const fromNode = visibleNodeFor(de.fromId);
599
- const cursorRect = {
600
- position: { x: de.cursorX, y: de.cursorY },
601
- size: { width: 1, height: 1 },
602
- } as CanvasNodeState;
603
- let previewD: string;
604
- if (fromNode) {
605
- const start = computeAnchor(fromNode, cursorRect);
606
- const end = computeAnchor(cursorRect, fromNode);
607
- const pdx = end.x - start.x;
608
- const pdy = end.y - start.y;
609
- const pdist = Math.sqrt(pdx * pdx + pdy * pdy);
610
- const curvature = Math.min(Math.max(pdist * 0.35, 32), 160);
611
- const cp1 = sideControlPoint(start, curvature);
612
- const cp2 = sideControlPoint(end, curvature);
613
- previewD = `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
614
- } else {
615
- const dx = de.cursorX - de.fromX;
616
- const dy = de.cursorY - de.fromY;
617
- const dist = Math.sqrt(dx * dx + dy * dy);
618
- const curve = Math.min(dist * 0.25, 80);
619
- const nx = dx / (dist || 1);
620
- const ny = dy / (dist || 1);
621
- previewD = `M ${de.fromX} ${de.fromY} C ${de.fromX + nx * curve} ${de.fromY + ny * curve}, ${de.cursorX - nx * curve} ${de.cursorY - ny * curve}, ${de.cursorX} ${de.cursorY}`;
622
- }
623
- return (
624
- <g>
625
- <path
626
- d={previewD}
627
- fill="none"
628
- stroke="var(--c-accent)"
629
- stroke-width={6 * scale}
630
- opacity="0.1"
631
- style={{ filter: 'blur(3px)' }}
632
- />
633
- <path
634
- d={previewD}
635
- fill="none"
636
- stroke="var(--c-accent)"
637
- stroke-width={2 * scale}
638
- stroke-dasharray={`${6 * scale} ${4 * scale}`}
639
- opacity="0.8"
640
- />
641
- <circle cx={de.cursorX} cy={de.cursorY} r={5 * scale} fill="var(--c-accent)" opacity="0.5" />
642
- </g>
643
- );
644
- })()}
645
- </svg>
815
+ <marker
816
+ id="edge-arrow"
817
+ viewBox="0 0 10 10"
818
+ refX="9"
819
+ refY="5"
820
+ markerUnits="userSpaceOnUse"
821
+ markerWidth={8 * Math.min(scale, 2.2)}
822
+ markerHeight={8 * Math.min(scale, 2.2)}
823
+ orient="auto-start-reverse"
824
+ >
825
+ <path d="M 0 1 L 10 5 L 0 9 z" fill="context-stroke" opacity="0.75" />
826
+ </marker>
827
+ </defs>
828
+ {edgeList.map((edge) => {
829
+ // Remote endpoints are navigable links, never a same-ID card on this board.
830
+ if (edge.toBoardId) return null;
831
+ // An endpoint hidden behind a collapsed group's chip is drawn to the chip.
832
+ const fromNode = resolve(edge.from);
833
+ const toNode = resolve(edge.to);
834
+ const layout = layouts.get(edge.id);
835
+ if (!fromNode || !toNode || !layout) return null;
836
+ const isConnected = hasFocus && (edge.from === focusId || edge.to === focusId);
837
+ const searchDimmed = hasSearch && !(searchSet.has(edge.from) || searchSet.has(edge.to));
838
+ const highlight = relationHighlight.value;
839
+ return (
840
+ <EdgePath
841
+ key={edge.id}
842
+ edge={edge}
843
+ fromNode={fromNode}
844
+ toNode={toNode}
845
+ layout={layout}
846
+ focused={isConnected}
847
+ dimmed={(highlight !== null && !highlight.links.has(edge.id)) || searchDimmed}
848
+ selected={selectedEdgeId.value === edge.id}
849
+ scale={scale}
850
+ part={part}
851
+ />
852
+ );
853
+ })}
854
+ {/* Live preview edge while drag-connecting */}
855
+ {part === 'lines' &&
856
+ draggingEdge.value &&
857
+ (() => {
858
+ const de = draggingEdge.value;
859
+ // Route the preview EXACTLY like the committed edge will route
860
+ // (side-tangent anchors against a 1px rect at the cursor) — the old
861
+ // collinear preview made the line visibly JUMP on release.
862
+ const fromNode = visibleNodeFor(de.fromId);
863
+ const cursorRect = {
864
+ position: { x: de.cursorX, y: de.cursorY },
865
+ size: { width: 1, height: 1 },
866
+ } as CanvasNodeState;
867
+ let previewD: string;
868
+ if (fromNode) {
869
+ const start = computeAnchor(fromNode, cursorRect);
870
+ const end = computeAnchor(cursorRect, fromNode);
871
+ const pdx = end.x - start.x;
872
+ const pdy = end.y - start.y;
873
+ const pdist = Math.sqrt(pdx * pdx + pdy * pdy);
874
+ const curvature = Math.min(Math.max(pdist * 0.35, 32), 160);
875
+ const cp1 = sideControlPoint(start, curvature);
876
+ const cp2 = sideControlPoint(end, curvature);
877
+ previewD = `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
878
+ } else {
879
+ const dx = de.cursorX - de.fromX;
880
+ const dy = de.cursorY - de.fromY;
881
+ const dist = Math.sqrt(dx * dx + dy * dy);
882
+ const curve = Math.min(dist * 0.25, 80);
883
+ const nx = dx / (dist || 1);
884
+ const ny = dy / (dist || 1);
885
+ previewD = `M ${de.fromX} ${de.fromY} C ${de.fromX + nx * curve} ${de.fromY + ny * curve}, ${de.cursorX - nx * curve} ${de.cursorY - ny * curve}, ${de.cursorX} ${de.cursorY}`;
886
+ }
887
+ return (
888
+ <g>
889
+ <path
890
+ d={previewD}
891
+ fill="none"
892
+ stroke="var(--c-accent)"
893
+ stroke-width={6 * scale}
894
+ opacity="0.1"
895
+ style={{ filter: 'blur(3px)' }}
896
+ />
897
+ <path
898
+ d={previewD}
899
+ fill="none"
900
+ stroke="var(--c-accent)"
901
+ stroke-width={2 * scale}
902
+ stroke-dasharray={`${6 * scale} ${4 * scale}`}
903
+ opacity="0.8"
904
+ />
905
+ <circle cx={de.cursorX} cy={de.cursorY} r={5 * scale} fill="var(--c-accent)" opacity="0.5" />
906
+ </g>
907
+ );
908
+ })()}
909
+ </svg>
910
+ ),
911
+ )}
912
+ </>
646
913
  );
647
914
  }