pmx-canvas 0.4.7 → 0.5.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 (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3069 -796
  6. package/dist/canvas/index.js +104 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +25 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +23 -1
  57. package/dist/types/server/canvas-operations.d.ts +1 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +54 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +1 -1
  93. package/skills/pmx-canvas/SKILL.md +109 -18
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +57 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +252 -0
  103. package/src/cli/index.ts +1 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +41 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +79 -2
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +273 -84
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3069 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +237 -61
  172. package/src/server/canvas-operations.ts +17 -8
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +107 -4
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +65 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +16 -0
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -1,5 +1,12 @@
1
1
  import type { Signal } from '@preact/signals';
2
- import { activeNodeId, draggingEdge, searchHighlightIds, viewport } from '../state/canvas-store';
2
+ import {
3
+ activeNodeId,
4
+ draggingEdge,
5
+ searchHighlightIds,
6
+ selectedEdgeId,
7
+ viewport,
8
+ visibleNodeFor,
9
+ } from '../state/canvas-store';
3
10
  import type { CanvasEdge, CanvasNodeState } from '../types';
4
11
 
5
12
  // ── Edge type visual styles ──────────────────────────────────
@@ -35,9 +42,12 @@ function dashArray(edge: CanvasEdge, scale: number): string | undefined {
35
42
  }
36
43
 
37
44
  // ── Anchor computation ───────────────────────────────────────
45
+ type Side = 'top' | 'bottom' | 'left' | 'right';
46
+
38
47
  interface Anchor {
39
48
  x: number;
40
49
  y: number;
50
+ side: Side;
41
51
  }
42
52
 
43
53
  function computeAnchor(node: CanvasNodeState, target: CanvasNodeState): Anchor {
@@ -62,6 +72,7 @@ function computeAnchor(node: CanvasNodeState, target: CanvasNodeState): Anchor {
62
72
  return {
63
73
  x: cx + (hh / tanAngle) * (dx > 0 ? 1 : -1),
64
74
  y: cy + hh * sign,
75
+ side: sign > 0 ? 'bottom' : 'top',
65
76
  };
66
77
  }
67
78
 
@@ -70,9 +81,29 @@ function computeAnchor(node: CanvasNodeState, target: CanvasNodeState): Anchor {
70
81
  return {
71
82
  x: cx + hw * sign,
72
83
  y: cy + tanAngle * hw * (dy > 0 ? 1 : -1),
84
+ side: sign > 0 ? 'right' : 'left',
73
85
  };
74
86
  }
75
87
 
88
+ /**
89
+ * Control point extending from an anchor along its side's outward normal —
90
+ * the edge leaves/enters a card perpendicular to the border and bends toward
91
+ * the other endpoint, instead of degenerating into a straight segment (which
92
+ * is what control points placed along the direct line produce).
93
+ */
94
+ function sideControlPoint(anchor: Anchor, curvature: number): { x: number; y: number } {
95
+ switch (anchor.side) {
96
+ case 'top':
97
+ return { x: anchor.x, y: anchor.y - curvature };
98
+ case 'bottom':
99
+ return { x: anchor.x, y: anchor.y + curvature };
100
+ case 'left':
101
+ return { x: anchor.x - curvature, y: anchor.y };
102
+ case 'right':
103
+ return { x: anchor.x + curvature, y: anchor.y };
104
+ }
105
+ }
106
+
76
107
  // ── Bezier midpoint at t=0.5 ─────────────────────────────────
77
108
  function bezierMidpoint(
78
109
  x1: number,
@@ -99,49 +130,67 @@ interface EdgePathProps {
99
130
  toNode: CanvasNodeState;
100
131
  focused: boolean; // connected to the active node
101
132
  dimmed: boolean; // active node exists but this edge is NOT connected
133
+ selected: boolean; // click-selected — Delete removes it
102
134
  scale: number; // inverse-viewport compensation, see edgeChromeScale()
135
+ onContextMenu?: (e: MouseEvent, edgeId: string) => void;
103
136
  }
104
137
 
105
- function EdgePath({ edge, fromNode, toNode, focused, dimmed, scale }: EdgePathProps) {
138
+ function EdgePath({ edge, fromNode, toNode, focused, dimmed, selected, scale, onContextMenu }: EdgePathProps) {
106
139
  const start = computeAnchor(fromNode, toNode);
107
140
  const end = computeAnchor(toNode, fromNode);
108
141
 
109
142
  const dx = end.x - start.x;
110
143
  const dy = end.y - start.y;
111
144
  const dist = Math.sqrt(dx * dx + dy * dy);
112
- const curvature = Math.min(dist * 0.25, 80);
145
+ const curvature = Math.min(Math.max(dist * 0.35, 32), 160);
113
146
 
114
- // Control points: offset perpendicular to direct line
115
- const nx = dx / (dist || 1);
116
- const ny = dy / (dist || 1);
117
- const cx1 = start.x + nx * curvature;
118
- const cy1 = start.y + ny * curvature;
119
- const cx2 = end.x - nx * curvature;
120
- const cy2 = end.y - ny * curvature;
147
+ const cp1 = sideControlPoint(start, curvature);
148
+ const cp2 = sideControlPoint(end, curvature);
121
149
 
122
- const d = `M ${start.x} ${start.y} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${end.x} ${end.y}`;
150
+ const d = `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
123
151
  const color = EDGE_COLORS[edge.type];
124
152
  const directed = DIRECTED_TYPES.has(edge.type);
125
153
  const dash = dashArray(edge, scale);
126
154
 
127
- const mid = edge.label ? bezierMidpoint(start.x, start.y, cx1, cy1, cx2, cy2, end.x, end.y) : null;
155
+ // Labels keep a constant screen size only while nodes are still readable.
156
+ // The uncapped chrome compensation made "brief" dwarf its own nodes at
157
+ // overview zoom — cap label chrome like node chrome (2.2), and drop labels
158
+ // entirely once the board is slivers (chrome scale > 3.4 ≈ zoom < 30%).
159
+ const labelScale = Math.min(scale, 2.2);
160
+ const mid =
161
+ edge.label && scale <= 3.4 ? bezierMidpoint(start.x, start.y, cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y) : null;
128
162
 
129
163
  const pathId = `edge-path-${edge.id}`;
130
164
 
131
165
  return (
132
166
  <g>
133
- {/* Invisible wide hitbox for hover/click */}
134
- <path d={d} fill="none" stroke="transparent" stroke-width={12 * scale} style={{ cursor: 'pointer' }} />
167
+ {/* Invisible wide hitbox — the ONE interactive part of the edge layer
168
+ (the svg itself is pointer-events:none): click selects the edge
169
+ (Delete then removes it), right-click opens the edge menu. */}
170
+ <path
171
+ d={d}
172
+ fill="none"
173
+ stroke="transparent"
174
+ stroke-width={12 * scale}
175
+ style={{ cursor: 'pointer', pointerEvents: 'stroke' }}
176
+ onClick={(e) => {
177
+ // The viewport's background click clears the selection — a click
178
+ // that LANDED on an edge must not immediately undo itself.
179
+ e.stopPropagation();
180
+ selectedEdgeId.value = edge.id;
181
+ }}
182
+ onContextMenu={onContextMenu ? (e) => onContextMenu(e as unknown as MouseEvent, edge.id) : undefined}
183
+ />
135
184
 
136
- {/* Glow layer for focused edges */}
137
- {focused && (
185
+ {/* Glow layer for focused or selected edges */}
186
+ {(focused || selected) && (
138
187
  <path
139
188
  d={d}
140
189
  fill="none"
141
190
  stroke={color}
142
191
  stroke-width={6 * scale}
143
192
  stroke-dasharray={dash}
144
- opacity="0.15"
193
+ opacity={selected ? 0.3 : 0.15}
145
194
  style={{ filter: 'blur(3px)' }}
146
195
  />
147
196
  )}
@@ -151,12 +200,13 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed, scale }: EdgePathPr
151
200
  <path
152
201
  id={pathId}
153
202
  d={d}
203
+ class={selected ? 'edge-selected' : undefined}
154
204
  fill="none"
155
205
  stroke={color}
156
- stroke-width={(focused ? 2.5 : 1.5) * scale}
206
+ stroke-width={(selected ? 3 : focused ? 2.5 : 1.5) * scale}
157
207
  stroke-dasharray={dash}
158
208
  marker-end={directed ? 'url(#edge-arrow)' : undefined}
159
- opacity={dimmed ? 0.2 : focused ? 1 : 0.85}
209
+ opacity={selected ? 1 : dimmed ? 0.2 : focused ? 1 : 0.85}
160
210
  style={{ transition: 'opacity 0.2s, stroke-width 0.2s' }}
161
211
  />
162
212
 
@@ -174,11 +224,11 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed, scale }: EdgePathPr
174
224
  <g transform={`translate(${mid.x}, ${mid.y})`}>
175
225
  <rect
176
226
  class="edge-label-bg"
177
- x={-(edge.label.length * 3.5 + 8) * scale}
178
- y={-10 * scale}
179
- width={(edge.label.length * 7 + 16) * scale}
180
- height={20 * scale}
181
- rx={4 * scale}
227
+ x={-(edge.label.length * 3.5 + 8) * labelScale}
228
+ y={-10 * labelScale}
229
+ width={(edge.label.length * 7 + 16) * labelScale}
230
+ height={20 * labelScale}
231
+ rx={4 * labelScale}
182
232
  />
183
233
  <text class="edge-label" text-anchor="middle" dominant-baseline="central" fill="var(--c-text)">
184
234
  {edge.label}
@@ -193,9 +243,10 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed, scale }: EdgePathPr
193
243
  interface EdgeLayerProps {
194
244
  nodes: Signal<Map<string, CanvasNodeState>>;
195
245
  edges: Signal<Map<string, CanvasEdge>>;
246
+ onEdgeContextMenu?: (e: MouseEvent, edgeId: string) => void;
196
247
  }
197
248
 
198
- export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
249
+ export function EdgeLayer({ nodes, edges, onEdgeContextMenu }: EdgeLayerProps) {
199
250
  const nodeMap = nodes.value;
200
251
  const edgeList = Array.from(edges.value.values());
201
252
  const focusId = activeNodeId.value;
@@ -204,10 +255,12 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
204
255
  const hasSearch = searchSet !== null;
205
256
  const scale = edgeChromeScale(viewport.value.scale);
206
257
 
207
- if (edgeList.length === 0) return null;
258
+ // A drag-to-connect preview must draw on a board with no edges yet.
259
+ if (edgeList.length === 0 && !draggingEdge.value) return null;
208
260
 
209
261
  const PAD = 96;
210
262
  const worldNodes = Array.from(nodeMap.values());
263
+ if (worldNodes.length === 0) return null;
211
264
  const minX = Math.min(...worldNodes.map((node) => node.position.x)) - PAD;
212
265
  const minY = Math.min(...worldNodes.map((node) => node.position.y)) - PAD;
213
266
  const maxX = Math.max(...worldNodes.map((node) => node.position.x + node.size.width)) + PAD;
@@ -229,26 +282,33 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
229
282
  pointerEvents: 'none',
230
283
  overflow: 'visible',
231
284
  '--edge-chrome-scale': scale.toFixed(3),
285
+ '--edge-label-scale': Math.min(scale, 2.2).toFixed(3),
232
286
  }}
233
287
  >
234
288
  <title>Canvas connections</title>
235
289
  <defs>
290
+ {/* userSpaceOnUse decouples the arrowhead from the UNCAPPED stroke
291
+ compensation: the line stays a visible hairline at any zoom, but
292
+ the head caps at the node-chrome scale (2.2) instead of dwarfing
293
+ sliver nodes at overview zoom. */}
236
294
  <marker
237
295
  id="edge-arrow"
238
296
  viewBox="0 0 10 10"
239
297
  refX="9"
240
298
  refY="5"
241
- markerWidth="8"
242
- markerHeight="8"
299
+ markerUnits="userSpaceOnUse"
300
+ markerWidth={8 * Math.min(scale, 2.2)}
301
+ markerHeight={8 * Math.min(scale, 2.2)}
243
302
  orient="auto-start-reverse"
244
303
  >
245
304
  <path d="M 0 1 L 10 5 L 0 9 z" fill="currentColor" opacity="0.75" />
246
305
  </marker>
247
306
  </defs>
248
307
  {edgeList.map((edge) => {
249
- const fromNode = nodeMap.get(edge.from);
250
- const toNode = nodeMap.get(edge.to);
251
- if (!fromNode || !toNode) return null;
308
+ // An endpoint hidden behind a collapsed group's chip is drawn to the chip.
309
+ const fromNode = visibleNodeFor(edge.from);
310
+ const toNode = visibleNodeFor(edge.to);
311
+ if (!fromNode || !toNode || fromNode.id === toNode.id) return null;
252
312
  const isConnected = hasFocus && (edge.from === focusId || edge.to === focusId);
253
313
  const searchDimmed = hasSearch && !(searchSet.has(edge.from) || searchSet.has(edge.to));
254
314
  return (
@@ -259,7 +319,9 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
259
319
  toNode={toNode}
260
320
  focused={isConnected}
261
321
  dimmed={(hasFocus && !isConnected) || searchDimmed}
322
+ selected={selectedEdgeId.value === edge.id}
262
323
  scale={scale}
324
+ onContextMenu={onEdgeContextMenu}
263
325
  />
264
326
  );
265
327
  })}
@@ -267,13 +329,34 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
267
329
  {draggingEdge.value &&
268
330
  (() => {
269
331
  const de = draggingEdge.value;
270
- const dx = de.cursorX - de.fromX;
271
- const dy = de.cursorY - de.fromY;
272
- const dist = Math.sqrt(dx * dx + dy * dy);
273
- const curve = Math.min(dist * 0.25, 80);
274
- const nx = dx / (dist || 1);
275
- const ny = dy / (dist || 1);
276
- const 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}`;
332
+ // Route the preview EXACTLY like the committed edge will route
333
+ // (side-tangent anchors against a 1px rect at the cursor) — the old
334
+ // collinear preview made the line visibly JUMP on release.
335
+ const fromNode = visibleNodeFor(de.fromId);
336
+ const cursorRect = {
337
+ position: { x: de.cursorX, y: de.cursorY },
338
+ size: { width: 1, height: 1 },
339
+ } as CanvasNodeState;
340
+ let previewD: string;
341
+ if (fromNode) {
342
+ const start = computeAnchor(fromNode, cursorRect);
343
+ const end = computeAnchor(cursorRect, fromNode);
344
+ const pdx = end.x - start.x;
345
+ const pdy = end.y - start.y;
346
+ const pdist = Math.sqrt(pdx * pdx + pdy * pdy);
347
+ const curvature = Math.min(Math.max(pdist * 0.35, 32), 160);
348
+ const cp1 = sideControlPoint(start, curvature);
349
+ const cp2 = sideControlPoint(end, curvature);
350
+ previewD = `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
351
+ } else {
352
+ const dx = de.cursorX - de.fromX;
353
+ const dy = de.cursorY - de.fromY;
354
+ const dist = Math.sqrt(dx * dx + dy * dy);
355
+ const curve = Math.min(dist * 0.25, 80);
356
+ const nx = dx / (dist || 1);
357
+ const ny = dy / (dist || 1);
358
+ 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}`;
359
+ }
277
360
  return (
278
361
  <g>
279
362
  <path
@@ -0,0 +1,93 @@
1
+ import { useRef } from 'preact/hooks';
2
+ import { IconNodeMarkdown, IconNodeWebpage, IconSteer } from '../icons';
3
+ import { viewport } from '../state/canvas-store';
4
+ import { createNodeInView } from './create-in-view';
5
+ import { startSession } from '../state/session-store';
6
+ import { modChord } from '../utils/platform';
7
+ import { canvasAreaCenter } from './canvas-area';
8
+ import { importFiles } from './import-files';
9
+ import { promptedCreate } from './ToolRail';
10
+
11
+ /**
12
+ * Empty board (rail-chrome-v2 phase 7, design item 11): centered onboarding
13
+ * with the ghost mark, a 2×2 grid of starter actions, and the shortcut hint.
14
+ * Every action is real: New note creates the same blank note as M, Drop files
15
+ * opens a picker onto the viewport's import path, Paste a link asks for a URL,
16
+ * Start agent session attaches a browser-keyed session. Tokens only, so it
17
+ * reads the same in dark and light.
18
+ */
19
+ export function EmptyState({ onOpenPalette }: { onOpenPalette: () => void }) {
20
+ const fileInput = useRef<HTMLInputElement>(null);
21
+ const newNote = () => {
22
+ void createNodeInView({ type: 'markdown', title: 'New note', width: 520, height: 360 }).catch(() => {});
23
+ };
24
+ const pickFiles = async (files: FileList | null) => {
25
+ if (!files || files.length === 0) return;
26
+ const centre = canvasAreaCenter();
27
+ const v = viewport.value;
28
+ await importFiles(Array.from(files), (centre.x - v.x) / v.scale, (centre.y - v.y) / v.scale);
29
+ };
30
+ return (
31
+ <div class="empty-state" data-testid="empty-state">
32
+ <svg class="empty-state-mark" width="52" height="52" viewBox="0 0 64 64" fill="none" aria-hidden="true">
33
+ <rect x="8" y="8" width="48" height="48" rx="7" />
34
+ <rect x="20" y="20" width="24" height="24" rx="4" stroke-dasharray="4 4" />
35
+ </svg>
36
+ <div>
37
+ <div class="empty-state-title">Nothing on this board yet</div>
38
+ <div class="empty-state-sub">Drop files anywhere, paste a link, or start from a note.</div>
39
+ </div>
40
+ <div class="empty-state-grid">
41
+ <button type="button" class="empty-state-action" onClick={newNote}>
42
+ <IconNodeMarkdown size={16} class="tone-accent" />
43
+ <span class="empty-state-action-label">New markdown note</span>
44
+ <kbd>M</kbd>
45
+ </button>
46
+ <button type="button" class="empty-state-action" onClick={() => fileInput.current?.click()}>
47
+ <svg
48
+ width="16"
49
+ height="16"
50
+ viewBox="0 0 16 16"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ stroke-width="1.5"
54
+ stroke-linecap="round"
55
+ class="tone-warn"
56
+ aria-hidden="true"
57
+ >
58
+ <path d="M8 10 V2 M8 2 L5 5 M8 2 L11 5" />
59
+ <path d="M2 10 V13 H14 V10" />
60
+ </svg>
61
+ <span class="empty-state-action-label">Drop files</span>
62
+ <span class="empty-state-action-hint">or pick</span>
63
+ </button>
64
+ <button type="button" class="empty-state-action" onClick={() => promptedCreate('webpage')}>
65
+ <IconNodeWebpage size={16} class="tone-ok" />
66
+ <span class="empty-state-action-label">Paste a link</span>
67
+ <kbd>{modChord('V')}</kbd>
68
+ </button>
69
+ <button type="button" class="empty-state-action is-agent" onClick={() => void startSession()}>
70
+ <IconSteer size={16} class="tone-purple" />
71
+ <span class="empty-state-action-label">Start agent session</span>
72
+ </button>
73
+ </div>
74
+ <button type="button" class="empty-state-hint" onClick={onOpenPalette}>
75
+ ? shortcuts · {modChord('K')} palette
76
+ </button>
77
+ <input
78
+ ref={fileInput}
79
+ type="file"
80
+ multiple
81
+ hidden
82
+ aria-hidden="true"
83
+ tabIndex={-1}
84
+ onChange={(e) => {
85
+ const input = e.currentTarget;
86
+ void pickFiles(input.files).finally(() => {
87
+ input.value = '';
88
+ });
89
+ }}
90
+ />
91
+ </div>
92
+ );
93
+ }
@@ -24,6 +24,8 @@ import {
24
24
  pendingExpandedNodeCloseId,
25
25
  toggleContextPin,
26
26
  } from '../state/canvas-store';
27
+ import { getNodeIcon } from '../icons';
28
+ import { useFocusTrap } from './use-focus-trap';
27
29
  import { TYPE_LABELS } from '../types';
28
30
  import type { CanvasNodeState } from '../types';
29
31
 
@@ -231,6 +233,15 @@ export function ExpandedNodeOverlay() {
231
233
  };
232
234
  }, [handlePresentationKeyDown, presentationExitToken, presenting]);
233
235
 
236
+ // Focus trap (item 18): restore to the node this overlay opened from — its
237
+ // world element is unmounted while expanded and remounted on close.
238
+ const panelRef = useRef<HTMLDivElement>(null);
239
+ const restoreToNode = useCallback(
240
+ () => (nodeId ? document.querySelector<HTMLElement>(`.canvas-node[data-node-id="${CSS.escape(nodeId)}"]`) : null),
241
+ [nodeId],
242
+ );
243
+ useFocusTrap(panelRef, node !== undefined, { restoreTo: restoreToNode });
244
+
234
245
  if (!node) return null;
235
246
 
236
247
  const title = (node.data.title as string) || (node.data.path as string)?.split('/').pop() || TYPE_LABELS[node.type];
@@ -243,95 +254,58 @@ export function ExpandedNodeOverlay() {
243
254
  node.type === 'mcp-app' || node.type === 'webpage' || node.type === 'json-render' || node.type === 'graph';
244
255
  const canPresent = shouldShowPresentationControls(node);
245
256
 
257
+ const NodeIcon = getNodeIcon(node.type);
258
+ const provenance =
259
+ node.data.provenance && typeof node.data.provenance === 'object'
260
+ ? (node.data.provenance as { sourceKind?: string; sourceUri?: string; syncedAt?: string })
261
+ : null;
262
+ const provenanceLabel = provenance
263
+ ? [
264
+ provenance.sourceKind,
265
+ provenance.sourceUri,
266
+ provenance.syncedAt ? `synced ${provenance.syncedAt.slice(11, 16)}` : null,
267
+ ]
268
+ .filter((part): part is string => typeof part === 'string' && part.length > 0)
269
+ .join(' · ')
270
+ : null;
271
+
246
272
  return (
247
273
  <div
248
274
  class="expanded-overlay-backdrop"
249
275
  onPointerDown={handleBackdropPointerDown}
250
- style={{
251
- position: 'fixed',
252
- inset: 0,
253
- zIndex: 10001,
254
- background: 'rgba(10,14,30,0.85)',
255
- backdropFilter: 'blur(8px)',
256
- display: 'flex',
257
- alignItems: 'stretch',
258
- justifyContent: 'center',
259
- padding: '32px',
260
- pointerEvents: pendingClose ? 'none' : 'auto',
261
- }}
276
+ style={{ pointerEvents: pendingClose ? 'none' : 'auto' }}
262
277
  >
263
278
  <div
264
- class="expanded-overlay-panel"
265
- style={{
266
- flex: 1,
267
- maxWidth: '1200px',
268
- display: 'flex',
269
- flexDirection: 'column',
270
- background: 'var(--c-panel)',
271
- border: `1px solid ${isCtxPinned ? 'var(--c-warn)' : 'var(--c-accent)'}`,
272
- borderRadius: 'var(--radius)',
273
- boxShadow: `0 0 0 1px ${isCtxPinned ? 'var(--c-warn)' : 'var(--c-accent)'}, 0 24px 80px rgba(0,0,0,0.6)`,
274
- overflow: 'hidden',
275
- }}
279
+ ref={panelRef}
280
+ class={`expanded-overlay-panel${isCtxPinned ? ' is-pinned' : ''}`}
281
+ role="dialog"
282
+ aria-modal="true"
283
+ aria-label={title}
284
+ data-testid="expanded-node"
276
285
  >
277
- {/* Title bar */}
278
- <div
279
- style={{
280
- display: 'flex',
281
- alignItems: 'center',
282
- gap: '10px',
283
- padding: '10px 16px',
284
- background: 'var(--c-panel-glass)',
285
- borderBottom: '1px solid var(--c-line)',
286
- flexShrink: 0,
287
- }}
288
- >
289
- <span
290
- style={{
291
- fontSize: '10px',
292
- padding: '1px 6px',
293
- borderRadius: '4px',
294
- background: 'var(--c-accent-12)',
295
- color: 'var(--c-accent)',
296
- textTransform: 'uppercase',
297
- letterSpacing: '0.04em',
298
- }}
299
- >
300
- {TYPE_LABELS[node.type]}
286
+ {/* Header: kind icon · title · kind pill · pin state · actions · close */}
287
+ <div class="expanded-header">
288
+ <span class="expanded-kind-icon" aria-hidden="true">
289
+ <NodeIcon size={15} />
301
290
  </span>
302
- <span
303
- style={{
304
- flex: 1,
305
- fontSize: '13px',
306
- fontWeight: 600,
307
- color: 'var(--c-text)',
308
- whiteSpace: 'nowrap',
309
- overflow: 'hidden',
310
- textOverflow: 'ellipsis',
311
- }}
291
+ <span class="expanded-title">{title}</span>
292
+ <span class="expanded-kind-pill">{TYPE_LABELS[node.type]}</span>
293
+ <button
294
+ type="button"
295
+ class={`expanded-pin${isCtxPinned ? ' is-on' : ''}`}
296
+ onClick={handleToggleCtxPin}
297
+ title={isCtxPinned ? 'In agent context — click to unpin' : 'Pin as agent context'}
298
+ aria-pressed={isCtxPinned}
312
299
  >
313
- {title}
314
- </span>
315
-
316
- {/* Action buttons */}
300
+ ✦
301
+ </button>
302
+ <span class="expanded-spacer" />
317
303
  <div class="expanded-actions">
318
- {/* Context pin toggle */}
319
- <button
320
- type="button"
321
- class={`expanded-action-btn ${isCtxPinned ? 'expanded-action-active' : ''}`}
322
- onClick={handleToggleCtxPin}
323
- title={isCtxPinned ? 'Remove from context' : 'Pin as context'}
324
- >
325
- {isCtxPinned ? '\u2726 In context' : '\u2726 Pin as context'}
326
- </button>
327
-
328
- {/* Copy content */}
329
304
  {hasText && (
330
305
  <button type="button" class="expanded-action-btn" onClick={handleCopy} title="Copy content to clipboard">
331
306
  {copied ? 'Copied!' : 'Copy'}
332
307
  </button>
333
308
  )}
334
-
335
309
  {canOpenAsSite(node) && (
336
310
  <button
337
311
  type="button"
@@ -339,10 +313,9 @@ export function ExpandedNodeOverlay() {
339
313
  onClick={() => void openNodeAsSite(node)}
340
314
  title="Open as a full-page site in the system browser"
341
315
  >
342
- Open as site
316
+ Open in tab ↗
343
317
  </button>
344
318
  )}
345
-
346
319
  {canPresent && (
347
320
  <button
348
321
  type="button"
@@ -353,53 +326,14 @@ export function ExpandedNodeOverlay() {
353
326
  Present
354
327
  </button>
355
328
  )}
356
-
357
- {/* Word count */}
358
- {words > 0 && (
359
- <span class="expanded-meta">
360
- {words.toLocaleString()} word{words !== 1 ? 's' : ''}
361
- </span>
362
- )}
363
329
  </div>
364
-
365
- <span style={{ fontSize: '10px', color: pendingClose ? 'var(--c-warn)' : 'var(--c-muted)' }}>
366
- {pendingClose ? 'Saving edits...' : 'Esc to close'}
367
- </span>
368
- <button
369
- type="button"
370
- onClick={handleClose}
371
- style={{
372
- background: 'none',
373
- border: 'none',
374
- color: 'var(--c-muted)',
375
- cursor: 'pointer',
376
- padding: '2px 6px',
377
- fontSize: '16px',
378
- lineHeight: 1,
379
- borderRadius: '4px',
380
- }}
381
- onMouseEnter={(e) => {
382
- (e.target as HTMLElement).style.color = 'var(--c-text)';
383
- }}
384
- onMouseLeave={(e) => {
385
- (e.target as HTMLElement).style.color = 'var(--c-muted)';
386
- }}
387
- title="Close (Esc)"
388
- >
330
+ <button type="button" class="expanded-close" onClick={handleClose} title="Close (Esc)" aria-label="Close">
389
331
  ×
390
332
  </button>
391
333
  </div>
392
334
 
393
- {/* Content area — full height */}
394
- <div
395
- style={{
396
- flex: 1,
397
- overflow: 'auto',
398
- padding: '16px',
399
- minHeight: 0,
400
- ...(isEmbeddedViewer ? { display: 'flex', flexDirection: 'column' } : {}),
401
- }}
402
- >
335
+ {/* Body: the surface at full size */}
336
+ <div class={`expanded-body${isEmbeddedViewer ? ' is-embedded' : ''}`}>
403
337
  {isEmbeddedViewer ? (
404
338
  <div style={{ flex: 1, minHeight: 0, display: 'flex' }}>{renderContent(node, true)}</div>
405
339
  ) : (
@@ -416,6 +350,18 @@ export function ExpandedNodeOverlay() {
416
350
  <div style={{ '--ax-dock-bleed': '0px', '--ax-dock-pad': '16px' } as Record<string, string>}>
417
351
  <AxStepControls node={node} />
418
352
  </div>
353
+ {/* Footer strip: provenance + how to close */}
354
+ <div class="expanded-footer">
355
+ <span class="expanded-footer-meta">
356
+ {[provenanceLabel, words > 0 ? `${words.toLocaleString()} word${words !== 1 ? 's' : ''}` : null]
357
+ .filter(Boolean)
358
+ .join(' · ') || TYPE_LABELS[node.type].toLowerCase()}
359
+ </span>
360
+ <span class="expanded-spacer" />
361
+ <span class={`expanded-footer-hint${pendingClose ? ' is-saving' : ''}`}>
362
+ {pendingClose ? 'Saving edits…' : 'esc or click outside to close'}
363
+ </span>
364
+ </div>
419
365
  {canPresent && presenting && (
420
366
  <div
421
367
  ref={presentationOverlayRef}