pmx-canvas 0.4.6 → 0.4.8

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 (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/Readme.md +56 -33
  3. package/dist/canvas/global.css +101 -3
  4. package/dist/canvas/index.js +93 -89
  5. package/dist/types/cli/shared.d.ts +13 -1
  6. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  7. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  8. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  9. package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
  10. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  11. package/dist/types/client/state/canvas-store.d.ts +10 -0
  12. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  13. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  14. package/dist/types/server/ax-interaction.d.ts +21 -2
  15. package/dist/types/server/canvas-db.d.ts +14 -0
  16. package/dist/types/server/canvas-operations.d.ts +5 -0
  17. package/dist/types/server/canvas-state.d.ts +8 -0
  18. package/dist/types/server/canvas-validation.d.ts +15 -21
  19. package/dist/types/server/file-content.d.ts +46 -0
  20. package/dist/types/server/html-primitives.d.ts +13 -1
  21. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  22. package/dist/types/server/server.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +14 -0
  24. package/dist/types/shared/ax-flow.d.ts +51 -0
  25. package/dist/types/shared/node-sizes.d.ts +33 -0
  26. package/docs/RELEASE.md +48 -0
  27. package/docs/cli.md +19 -2
  28. package/docs/environment.md +1 -0
  29. package/docs/http-api.md +17 -0
  30. package/docs/mcp.md +12 -0
  31. package/docs/node-types.md +45 -1
  32. package/docs/screenshot.png +0 -0
  33. package/package.json +2 -2
  34. package/skills/pmx-canvas/SKILL.md +74 -9
  35. package/skills/pmx-canvas/evals/evals.json +54 -3
  36. package/skills/pmx-canvas/references/full-reference.md +11 -6
  37. package/skills/pmx-canvas-testing/SKILL.md +40 -0
  38. package/src/cli/commands/history.ts +1 -1
  39. package/src/cli/commands/nodes.ts +2 -2
  40. package/src/cli/commands/query.ts +1 -1
  41. package/src/cli/commands/view.ts +1 -1
  42. package/src/cli/index.ts +6 -0
  43. package/src/cli/shared.ts +34 -5
  44. package/src/client/App.tsx +28 -15
  45. package/src/client/canvas/CanvasNode.tsx +54 -5
  46. package/src/client/canvas/EdgeLayer.tsx +44 -24
  47. package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
  48. package/src/client/canvas/auto-fit.ts +10 -1
  49. package/src/client/canvas/use-pan-zoom.ts +16 -1
  50. package/src/client/nodes/AxStepControls.tsx +226 -0
  51. package/src/client/nodes/ExtAppFrame.tsx +79 -2
  52. package/src/client/nodes/FileNode.tsx +249 -51
  53. package/src/client/state/canvas-store.ts +19 -0
  54. package/src/client/state/intent-bridge.ts +32 -0
  55. package/src/client/state/sse-bridge.ts +4 -1
  56. package/src/client/theme/global.css +101 -3
  57. package/src/server/ax-flow-loop.ts +114 -0
  58. package/src/server/ax-interaction.ts +299 -4
  59. package/src/server/canvas-db.ts +138 -32
  60. package/src/server/canvas-operations.ts +44 -17
  61. package/src/server/canvas-state.ts +20 -10
  62. package/src/server/canvas-validation.ts +50 -41
  63. package/src/server/demo-state.json +4605 -523
  64. package/src/server/demo.ts +24 -0
  65. package/src/server/file-content.ts +152 -0
  66. package/src/server/file-watcher.ts +7 -10
  67. package/src/server/html-primitives.ts +636 -0
  68. package/src/server/operations/ops/ax-work.ts +5 -0
  69. package/src/server/operations/ops/nodes.ts +15 -1
  70. package/src/server/operations/ops/viewport.ts +7 -1
  71. package/src/server/server.ts +106 -5
  72. package/src/server/web-artifacts.ts +109 -0
  73. package/src/shared/ax-flow.ts +102 -0
  74. package/src/shared/node-sizes.ts +52 -0
  75. package/dist/types/server/chart-template.d.ts +0 -29
  76. package/src/server/chart-template.ts +0 -448
@@ -36,10 +36,11 @@ import {
36
36
  traceEnabled,
37
37
  viewport,
38
38
  walkGraph,
39
+ zoomByFactor,
39
40
  } from './state/canvas-store';
40
41
  import { connectSSE } from './state/sse-bridge';
41
42
  import { intents } from './state/intent-store';
42
- import { saveCanvasTheme } from './state/intent-bridge';
43
+ import { reportClientViewportSize, saveCanvasTheme } from './state/intent-bridge';
43
44
  import {
44
45
  IconArrange,
45
46
  IconClearTrace,
@@ -258,20 +259,12 @@ function Toolbar({
258
259
  </button>
259
260
  </ToolbarHint>
260
261
  <ToolbarHint label="Zoom in" shortcut={`${MOD_KEY}++`}>
261
- <button
262
- type="button"
263
- onClick={() => animateViewport({ ...v, scale: Math.min(4, v.scale * 1.25) }, 150)}
264
- aria-label="Zoom in"
265
- >
262
+ <button type="button" onClick={() => zoomByFactor(1.25)} aria-label="Zoom in">
266
263
  <IconZoomIn />
267
264
  </button>
268
265
  </ToolbarHint>
269
266
  <ToolbarHint label="Zoom out" shortcut={`${MOD_KEY}+-`}>
270
- <button
271
- type="button"
272
- onClick={() => animateViewport({ ...v, scale: Math.max(0.1, v.scale / 1.25) }, 150)}
273
- aria-label="Zoom out"
274
- >
267
+ <button type="button" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
275
268
  <IconZoomOut />
276
269
  </button>
277
270
  </ToolbarHint>
@@ -585,6 +578,25 @@ export function App() {
585
578
  return connectSSE();
586
579
  }, []);
587
580
 
581
+ // Keep the server's idea of this window's size current (0.4.6 orb feedback
582
+ // #2) so an agent `fit` computes a scale that actually fits the human's
583
+ // window. Connect-time reporting lives in the SSE bridge; this covers resizes.
584
+ useEffect(() => {
585
+ let timer: number | null = null;
586
+ const schedule = () => {
587
+ if (timer !== null) window.clearTimeout(timer);
588
+ timer = window.setTimeout(() => {
589
+ timer = null;
590
+ void reportClientViewportSize();
591
+ }, 400);
592
+ };
593
+ window.addEventListener('resize', schedule);
594
+ return () => {
595
+ if (timer !== null) window.clearTimeout(timer);
596
+ window.removeEventListener('resize', schedule);
597
+ };
598
+ }, []);
599
+
588
600
  useEffect(() => {
589
601
  const handleKeyDown = (e: KeyboardEvent) => {
590
602
  const mod = e.metaKey || e.ctrlKey;
@@ -640,12 +652,13 @@ export function App() {
640
652
  animateViewport({ x: 0, y: 0, scale: 1 }, 250);
641
653
  } else if (mod && (e.key === '=' || e.key === '+')) {
642
654
  e.preventDefault();
643
- const cur = viewport.value;
644
- animateViewport({ ...cur, scale: Math.min(4, cur.scale * 1.25) }, 150);
655
+ // Same centre-anchored zoom as the toolbar +/- buttons these shortcuts
656
+ // are advertised on (the tooltip names this key) — a raw scale change
657
+ // is origin-anchored and drifts the board away under the cursor.
658
+ zoomByFactor(1.25);
645
659
  } else if (mod && e.key === '-') {
646
660
  e.preventDefault();
647
- const cur = viewport.value;
648
- animateViewport({ ...cur, scale: Math.max(0.1, cur.scale / 1.25) }, 150);
661
+ zoomByFactor(1 / 1.25);
649
662
  } else if (e.key === 'Escape') {
650
663
  if (selectedNodeIds.value.size > 0) {
651
664
  clearSelection();
@@ -21,15 +21,54 @@ import {
21
21
  viewport,
22
22
  } from '../state/canvas-store';
23
23
  import { removeNodeFromClient, updateNodeFromClient } from '../state/intent-bridge';
24
+ import { AxStepControls } from '../nodes/AxStepControls';
24
25
  import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
25
26
  import { getNodeIcon } from '../icons';
26
27
  import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
27
28
  import type { CanvasNodeState } from '../types';
28
- import { computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
29
+ import { AUTO_FIT_TITLEBAR_HEIGHT, computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
29
30
  import { activeGuides, buildSnapCache, clearSnapCache, snapToGuides } from './snap-guides';
30
31
  import { useNodeDrag } from './use-node-drag';
31
32
  import { useNodeResize } from './use-node-resize';
32
33
 
34
+ /** Fraction of a node's height the title bar may occupy before it starts
35
+ * crowding out the content it is labelling. */
36
+ const MAX_TITLEBAR_HEIGHT_RATIO = 0.4;
37
+ /** Fraction of a node's width the fixed title-bar chrome may occupy before it
38
+ * starts eating the node's NAME. */
39
+ const MAX_TITLEBAR_CHROME_WIDTH_RATIO = 0.55;
40
+ /** Unscaled width of everything in the bar that is not the title: type icon,
41
+ * status/type pill, the control cluster, and the gaps between them. */
42
+ const TITLEBAR_CHROME_BASE_WIDTH = 175;
43
+
44
+ /**
45
+ * How much to enlarge node chrome (title bar, badges, icons) when zoomed out so
46
+ * it stays legible. Full inverse compensation is capped at 2.2x — but the cap
47
+ * alone ignored the node it is drawn on, so on SHORT nodes the growing bar ate
48
+ * the body: a 116px-tall section label at 46% zoom gave the title bar 72% of the
49
+ * node and left 14px for text that needed 41, so the markdown was simply cut off.
50
+ * The scale is therefore also bounded by the node's own height, keeping the bar
51
+ * under MAX_TITLEBAR_HEIGHT_RATIO of it. Tall nodes are unaffected (their height
52
+ * never binds); pass height 0 for collapsed/auto-height nodes to skip the bound.
53
+ */
54
+ export function nodeChromeScale(viewportScale: number, nodeHeight: number, nodeWidth = 0): number {
55
+ if (!Number.isFinite(viewportScale) || viewportScale >= 1) return 1;
56
+ const zoomScale = Math.min(2.2, 1 / Math.max(viewportScale, 0.01));
57
+ let bounded = zoomScale;
58
+ if (Number.isFinite(nodeHeight) && nodeHeight > 0) {
59
+ bounded = Math.min(bounded, (nodeHeight * MAX_TITLEBAR_HEIGHT_RATIO) / AUTO_FIT_TITLEBAR_HEIGHT);
60
+ }
61
+ // Width matters as much as height: the icon, pill and control cluster are all
62
+ // flex-shrink: 0, so the NAME is the only thing that gives way. Unbounded, a
63
+ // 360px step node at 52% zoom spent 100 of 187 bar pixels on controls alone and
64
+ // the title collapsed to nothing. Bound the scale so the fixed chrome can never
65
+ // take more than MAX_TITLEBAR_CHROME_WIDTH_RATIO of the node.
66
+ if (Number.isFinite(nodeWidth) && nodeWidth > 0) {
67
+ bounded = Math.min(bounded, (nodeWidth * MAX_TITLEBAR_CHROME_WIDTH_RATIO) / TITLEBAR_CHROME_BASE_WIDTH);
68
+ }
69
+ return Math.max(1, bounded);
70
+ }
71
+
33
72
  interface CanvasNodeProps {
34
73
  node: CanvasNodeState;
35
74
  children: preact.ComponentChildren;
@@ -220,7 +259,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
220
259
  const isGroup = node.type === 'group';
221
260
  const isStrictSize = node.data.strictSize === true;
222
261
  const viewportScale = Math.max(viewport.value.scale, 0.01);
223
- const chromeScale = viewportScale < 1 ? Math.min(2.2, 1 / viewportScale) : 1;
262
+ const chromeScale = nodeChromeScale(viewportScale, node.collapsed ? 0 : node.size.height, node.size.width);
224
263
 
225
264
  const groupColor = isGroup && node.data.color ? (node.data.color as string) : undefined;
226
265
  const nodeStyle: Record<string, string | number> = {
@@ -261,9 +300,13 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
261
300
  return <NodeIcon size={Math.round(14 * chromeScale)} />;
262
301
  })()}
263
302
  </span>
264
- <span class="node-type-badge">{TYPE_LABELS[node.type]}</span>
265
- {typeof node.data.axWorkStatus === 'string' && (
303
+ {/* The icon already says what type this is, so the type badge yields to
304
+ the status chip rather than stacking two pills in front of the name —
305
+ on a narrow node that combination pushed the title to an ellipsis. */}
306
+ {typeof node.data.axWorkStatus === 'string' ? (
266
307
  <span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
308
+ ) : (
309
+ <span class="node-type-badge">{TYPE_LABELS[node.type]}</span>
267
310
  )}
268
311
  {renaming ? (
269
312
  <input
@@ -278,7 +321,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
278
321
  onClick={(e) => e.stopPropagation()}
279
322
  />
280
323
  ) : (
281
- <span class="node-title" onDblClick={handleTitleDblClick} title="Double-click to rename">
324
+ <span class="node-title" onDblClick={handleTitleDblClick} title={`${title} — double-click to rename`}>
282
325
  {title}
283
326
  </span>
284
327
  )}
@@ -356,6 +399,12 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
356
399
  {!node.collapsed && (
357
400
  <div ref={bodyRef} class="node-body">
358
401
  {children}
402
+ {/* AX flow step controls — rendered here rather than in a node renderer
403
+ because a materialized step can be ANY node type, and because the
404
+ body is what auto-fit measures (a footer outside it would be clipped
405
+ on a content-fitted node). Renders nothing unless the node carries a
406
+ `data.axStep` stamp. */}
407
+ <AxStepControls node={node} />
359
408
  </div>
360
409
  )}
361
410
  {!node.collapsed && (
@@ -1,5 +1,5 @@
1
1
  import type { Signal } from '@preact/signals';
2
- import { activeNodeId, draggingEdge, searchHighlightIds } from '../state/canvas-store';
2
+ import { activeNodeId, draggingEdge, searchHighlightIds, viewport } from '../state/canvas-store';
3
3
  import type { CanvasEdge, CanvasNodeState } from '../types';
4
4
 
5
5
  // ── Edge type visual styles ──────────────────────────────────
@@ -7,15 +7,30 @@ const EDGE_COLORS: Record<CanvasEdge['type'], string> = {
7
7
  relation: 'var(--c-muted)',
8
8
  'depends-on': 'var(--c-warn)',
9
9
  flow: 'var(--c-accent)',
10
- references: 'var(--c-dim)',
10
+ // --c-dim is a very low-contrast hairline in dark palettes (forest: #5D7566
11
+ // on #0C1712); the dashed style is what distinguishes `references`.
12
+ references: 'var(--c-muted)',
11
13
  };
12
14
 
13
15
  const DIRECTED_TYPES = new Set<CanvasEdge['type']>(['depends-on', 'flow']);
14
16
 
15
- function dashArray(edge: CanvasEdge): string | undefined {
16
- if (edge.style === 'dashed') return '8 4';
17
- if (edge.style === 'dotted') return '3 3';
18
- if (edge.type === 'references' && !edge.style) return '8 4';
17
+ /**
18
+ * Edges are drawn in world space, so a 1.5px stroke renders as 0.4 screen px at
19
+ * 26% zoom. Full inverse compensation keeps edge chrome at a constant SCREEN
20
+ * size while zoomed out (standard graph-editor behaviour). Deliberately
21
+ * uncapped — the 2.2 cap used for node chrome still leaves hairlines invisible
22
+ * at overview zoom.
23
+ */
24
+ export function edgeChromeScale(scale: number): number {
25
+ if (!Number.isFinite(scale) || scale <= 0) return 1;
26
+ return scale < 1 ? 1 / scale : 1;
27
+ }
28
+
29
+ function dashArray(edge: CanvasEdge, scale: number): string | undefined {
30
+ const dashed = `${8 * scale} ${4 * scale}`;
31
+ if (edge.style === 'dashed') return dashed;
32
+ if (edge.style === 'dotted') return `${3 * scale} ${3 * scale}`;
33
+ if (edge.type === 'references' && !edge.style) return dashed;
19
34
  return undefined;
20
35
  }
21
36
 
@@ -84,9 +99,10 @@ interface EdgePathProps {
84
99
  toNode: CanvasNodeState;
85
100
  focused: boolean; // connected to the active node
86
101
  dimmed: boolean; // active node exists but this edge is NOT connected
102
+ scale: number; // inverse-viewport compensation, see edgeChromeScale()
87
103
  }
88
104
 
89
- function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
105
+ function EdgePath({ edge, fromNode, toNode, focused, dimmed, scale }: EdgePathProps) {
90
106
  const start = computeAnchor(fromNode, toNode);
91
107
  const end = computeAnchor(toNode, fromNode);
92
108
 
@@ -106,7 +122,7 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
106
122
  const d = `M ${start.x} ${start.y} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${end.x} ${end.y}`;
107
123
  const color = EDGE_COLORS[edge.type];
108
124
  const directed = DIRECTED_TYPES.has(edge.type);
109
- const dash = dashArray(edge);
125
+ const dash = dashArray(edge, scale);
110
126
 
111
127
  const mid = edge.label ? bezierMidpoint(start.x, start.y, cx1, cy1, cx2, cy2, end.x, end.y) : null;
112
128
 
@@ -115,7 +131,7 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
115
131
  return (
116
132
  <g>
117
133
  {/* Invisible wide hitbox for hover/click */}
118
- <path d={d} fill="none" stroke="transparent" stroke-width="12" style={{ cursor: 'pointer' }} />
134
+ <path d={d} fill="none" stroke="transparent" stroke-width={12 * scale} style={{ cursor: 'pointer' }} />
119
135
 
120
136
  {/* Glow layer for focused edges */}
121
137
  {focused && (
@@ -123,29 +139,30 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
123
139
  d={d}
124
140
  fill="none"
125
141
  stroke={color}
126
- stroke-width="6"
142
+ stroke-width={6 * scale}
127
143
  stroke-dasharray={dash}
128
144
  opacity="0.15"
129
145
  style={{ filter: 'blur(3px)' }}
130
146
  />
131
147
  )}
132
148
 
133
- {/* Visible edge */}
149
+ {/* Visible edge — the `edge-arrow` marker uses the default
150
+ markerUnits="strokeWidth", so the arrowhead scales with this. */}
134
151
  <path
135
152
  id={pathId}
136
153
  d={d}
137
154
  fill="none"
138
155
  stroke={color}
139
- stroke-width={focused ? 2.5 : 1.5}
156
+ stroke-width={(focused ? 2.5 : 1.5) * scale}
140
157
  stroke-dasharray={dash}
141
158
  marker-end={directed ? 'url(#edge-arrow)' : undefined}
142
- opacity={dimmed ? 0.2 : focused ? 1 : 0.75}
159
+ opacity={dimmed ? 0.2 : focused ? 1 : 0.85}
143
160
  style={{ transition: 'opacity 0.2s, stroke-width 0.2s' }}
144
161
  />
145
162
 
146
163
  {/* Animated pulse dot */}
147
164
  {edge.animated && (
148
- <circle r="3" fill={color} opacity="0.9">
165
+ <circle r={3 * scale} fill={color} opacity="0.9">
149
166
  <animateMotion dur="2s" repeatCount="indefinite">
150
167
  <mpath href={`#${pathId}`} />
151
168
  </animateMotion>
@@ -157,13 +174,13 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
157
174
  <g transform={`translate(${mid.x}, ${mid.y})`}>
158
175
  <rect
159
176
  class="edge-label-bg"
160
- x={-(edge.label.length * 3.5 + 8)}
161
- y="-10"
162
- width={edge.label.length * 7 + 16}
163
- height="20"
164
- rx="4"
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}
165
182
  />
166
- <text class="edge-label" text-anchor="middle" dominant-baseline="central" fill="var(--c-text)" font-size="11">
183
+ <text class="edge-label" text-anchor="middle" dominant-baseline="central" fill="var(--c-text)">
167
184
  {edge.label}
168
185
  </text>
169
186
  </g>
@@ -185,6 +202,7 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
185
202
  const hasFocus = focusId !== null;
186
203
  const searchSet = searchHighlightIds.value;
187
204
  const hasSearch = searchSet !== null;
205
+ const scale = edgeChromeScale(viewport.value.scale);
188
206
 
189
207
  if (edgeList.length === 0) return null;
190
208
 
@@ -210,6 +228,7 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
210
228
  left: `${minX}px`,
211
229
  pointerEvents: 'none',
212
230
  overflow: 'visible',
231
+ '--edge-chrome-scale': scale.toFixed(3),
213
232
  }}
214
233
  >
215
234
  <title>Canvas connections</title>
@@ -240,6 +259,7 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
240
259
  toNode={toNode}
241
260
  focused={isConnected}
242
261
  dimmed={(hasFocus && !isConnected) || searchDimmed}
262
+ scale={scale}
243
263
  />
244
264
  );
245
265
  })}
@@ -260,7 +280,7 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
260
280
  d={previewD}
261
281
  fill="none"
262
282
  stroke="var(--c-accent)"
263
- stroke-width="6"
283
+ stroke-width={6 * scale}
264
284
  opacity="0.1"
265
285
  style={{ filter: 'blur(3px)' }}
266
286
  />
@@ -268,11 +288,11 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
268
288
  d={previewD}
269
289
  fill="none"
270
290
  stroke="var(--c-accent)"
271
- stroke-width="2"
272
- stroke-dasharray="6 4"
291
+ stroke-width={2 * scale}
292
+ stroke-dasharray={`${6 * scale} ${4 * scale}`}
273
293
  opacity="0.8"
274
294
  />
275
- <circle cx={de.cursorX} cy={de.cursorY} r="5" fill="var(--c-accent)" opacity="0.5" />
295
+ <circle cx={de.cursorX} cy={de.cursorY} r={5 * scale} fill="var(--c-accent)" opacity="0.5" />
276
296
  </g>
277
297
  );
278
298
  })()}
@@ -13,6 +13,7 @@ import { WebpageNode } from '../nodes/WebpageNode';
13
13
  import { HtmlNode, shouldShowPresentationControls } from '../nodes/HtmlNode';
14
14
  import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
15
15
  import { PromptNode } from '../nodes/PromptNode';
16
+ import { AxStepControls } from '../nodes/AxStepControls';
16
17
  import { ResponseNode } from '../nodes/ResponseNode';
17
18
  import { TraceNode } from '../nodes/TraceNode';
18
19
  import {
@@ -405,6 +406,16 @@ export function ExpandedNodeOverlay() {
405
406
  renderContent(node, true)
406
407
  )}
407
408
  </div>
409
+ {/* AX step controls follow the node into focus mode. Without this,
410
+ expanding a materialized flow step showed only its text — the
411
+ Start/Done/Blocked and loop controls live in CanvasNode's body,
412
+ which the overlay does not render. */}
413
+ {/* Here the dock is a SIBLING of the padded content area, so it is
414
+ already flush with the panel: no bleed, and 16px of its own padding
415
+ to line its labels up with the content above. */}
416
+ <div style={{ '--ax-dock-bleed': '0px', '--ax-dock-pad': '16px' } as Record<string, string>}>
417
+ <AxStepControls node={node} />
418
+ </div>
408
419
  {canPresent && presenting && (
409
420
  <div
410
421
  ref={presentationOverlayRef}
@@ -1,4 +1,5 @@
1
1
  import type { CanvasNodeState } from '../types';
2
+ import { nodeMinSize } from '../../shared/node-sizes.js';
2
3
 
3
4
  export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
4
5
  export const AUTO_FIT_MAX_HEIGHT = 600;
@@ -60,7 +61,15 @@ export function shouldAutoFitNode(node: CanvasNodeState): boolean {
60
61
 
61
62
  export function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null {
62
63
  if (!shouldAutoFitNode(node) || contentHeight <= 0) return null;
63
- return Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT, AUTO_FIT_MAX_HEIGHT);
64
+ const fitted = Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT, AUTO_FIT_MAX_HEIGHT);
65
+ // Honor the shared readability floor (0.4.6 report Finding AA): the server
66
+ // clamps an undersized create up to the floor, then this auto-fit measured
67
+ // the short content and persisted a SMALLER height — undoing the clamp on
68
+ // the most common path (a connected workbench). Auto-fit may grow a node and
69
+ // may shrink it toward its content, but never below the type's floor.
70
+ // strictSize nodes never reach here (isAutoSizeExempt).
71
+ const min = nodeMinSize(node.type);
72
+ return min ? Math.max(fitted, min.height) : fitted;
64
73
  }
65
74
 
66
75
  /** Iframe surfaces that should GROW to fit their reported content height. */
@@ -44,13 +44,28 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
44
44
  [onViewportCommit],
45
45
  );
46
46
 
47
+ /**
48
+ * A mouse wheel should zoom the canvas; a trackpad two-finger scroll should still
49
+ * pan. The events are the same type, so distinguish them by shape: wheels emit
50
+ * coarse, whole-number vertical deltas with no horizontal component, while
51
+ * trackpads emit small and/or fractional deltas and usually some deltaX. Pinch
52
+ * arrives as ctrl+wheel and is handled alongside this.
53
+ */
54
+ function isMouseWheel(e: WheelEvent): boolean {
55
+ // Line/page deltaMode is only ever produced by a real wheel (Firefox emits
56
+ // DOM_DELTA_LINE with deltaY ~3 per notch, which the pixel heuristic below
57
+ // would never match — wheel zoom simply never fired there).
58
+ if (e.deltaMode !== 0) return e.deltaX === 0;
59
+ return e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 50;
60
+ }
61
+
47
62
  const handleWheel = useCallback(
48
63
  (e: WheelEvent) => {
49
64
  if (disabled) return;
50
65
  e.preventDefault();
51
66
  const v = viewport.value;
52
67
 
53
- if (e.ctrlKey || e.metaKey) {
68
+ if (e.ctrlKey || e.metaKey || isMouseWheel(e)) {
54
69
  // Zoom centered on pointer
55
70
  const rect = containerRef.current?.getBoundingClientRect();
56
71
  if (!rect) return;
@@ -0,0 +1,226 @@
1
+ import { useState } from 'preact/hooks';
2
+ import { readAxFlow, readAxStep, type AxFlowStamp } from '../../shared/ax-flow.js';
3
+ import { nodes, updateNodeData } from '../state/canvas-store';
4
+ import { updateNodeFromClient } from '../state/intent-bridge';
5
+ import type { CanvasNodeState } from '../types';
6
+ import { axNodeActionButtonStyle, runNodeAxInteraction } from './ax-node-actions';
7
+
8
+ /**
9
+ * Native AX controls for a materialized flow step — the same capability the
10
+ * ax-flow HTML panel has, on an ORDINARY canvas node.
11
+ *
12
+ * Rendered by CanvasNode for any node carrying `data.axStep`, whatever its type,
13
+ * so the flow is driven from the board itself: Start/Done/Blocked emit
14
+ * `ax.work.update` for the step's work item through the trusted `native-node`
15
+ * surface, and the anchor step additionally runs/stops the durable server loop
16
+ * (ax-flow-loop.ts) and steers the agent.
17
+ */
18
+
19
+ /**
20
+ * The rows sit in a dock stuck to the bottom of the scrollable node body: a step
21
+ * node is short (360x200 as materialized), so a control row that simply followed
22
+ * the content scrolled out of sight. Sticky keeps it reachable at any node height
23
+ * while still being INSIDE the body, which is what auto-fit measures.
24
+ */
25
+ /**
26
+ * The dock reads as a real footer toolbar rather than stray text at the bottom
27
+ * edge. It bleeds to the container's edges via negative margins (`.node-body`
28
+ * has 12px padding; the expanded overlay's content area has 16px, and the dock
29
+ * is a sibling of that area, so it starts flush) and then re-applies its own
30
+ * padding — otherwise the first label sits hard against the left edge and looks
31
+ * clipped. `--c-panel-overlay` separates it from the content above it.
32
+ */
33
+ const dockStyle = {
34
+ position: 'sticky',
35
+ // Bleed OUT of the container's padding so the bar spans edge to edge, then
36
+ // re-apply padding inside it. These are two different numbers: in a node body
37
+ // the dock sits INSIDE 12px of padding and must pull back out of it, while in
38
+ // the expanded overlay it is a sibling of the padded content area and is
39
+ // already flush — bleeding there would push it off the panel.
40
+ bottom: 'calc(-1 * var(--ax-dock-bleed, 12px))',
41
+ marginTop: '12px',
42
+ marginLeft: 'calc(-1 * var(--ax-dock-bleed, 12px))',
43
+ marginRight: 'calc(-1 * var(--ax-dock-bleed, 12px))',
44
+ marginBottom: 'calc(-1 * var(--ax-dock-bleed, 12px))',
45
+ padding: '9px var(--ax-dock-pad, 12px) 10px',
46
+ background: 'var(--c-panel-overlay)',
47
+ borderTop: '1px solid var(--c-line)',
48
+ backdropFilter: 'blur(6px)',
49
+ } as const;
50
+
51
+ const rowStyle = {
52
+ display: 'flex',
53
+ alignItems: 'center',
54
+ gap: '6px',
55
+ flexWrap: 'wrap',
56
+ } as const;
57
+
58
+ const labelStyle = {
59
+ fontSize: '10px',
60
+ color: 'var(--c-muted)',
61
+ textTransform: 'uppercase',
62
+ letterSpacing: '0.04em',
63
+ marginRight: 'auto',
64
+ } as const;
65
+
66
+ const activeButtonStyle = {
67
+ ...axNodeActionButtonStyle,
68
+ background: 'var(--c-accent-25)',
69
+ color: 'var(--c-text)',
70
+ } as const;
71
+
72
+ const STEP_ACTIONS = [
73
+ { status: 'in-progress', label: 'Start', toast: 'Step started' },
74
+ { status: 'done', label: 'Done', toast: 'Step done' },
75
+ { status: 'blocked', label: 'Blocked', toast: 'Step blocked' },
76
+ ] as const;
77
+
78
+ /** Persist a new loop state on the anchor node (optimistic locally, durable on the server). */
79
+ async function persistLoop(node: CanvasNodeState, flow: AxFlowStamp, loop: AxFlowStamp['loop']): Promise<void> {
80
+ const axFlow: AxFlowStamp = { ...flow, loop };
81
+ updateNodeData(node.id, { axFlow });
82
+ await updateNodeFromClient(node.id, { data: { axFlow } });
83
+ }
84
+
85
+ /** The work-item status mirrored onto a node by the server (`data.axWorkStatus`). */
86
+ function mirroredStatus(data: Record<string, unknown> | undefined): string {
87
+ return typeof data?.axWorkStatus === 'string' ? data.axWorkStatus : 'todo';
88
+ }
89
+
90
+ function FlowLoopControls({ node, flow }: { node: CanvasNodeState; flow: AxFlowStamp }) {
91
+ const [message, setMessage] = useState('');
92
+ const { running, run, maxRuns } = flow.loop;
93
+
94
+ const handleRun = async () => {
95
+ // A finished loop restarts from zero rather than stopping on its first check.
96
+ await persistLoop(node, flow, { running: true, run: run >= maxRuns ? 0 : run, maxRuns });
97
+ // The server loop only advances on a WORK-ITEM change, so starting it has to
98
+ // produce one: open the first unfinished step, or re-assert the last step's
99
+ // `done` when the flow is already complete (which closes the run and opens
100
+ // the next one server-side).
101
+ const next = flow.steps.find((step) => mirroredStatus(nodes.value.get(step.nodeId)?.data) !== 'done');
102
+ const target = next ?? flow.steps[flow.steps.length - 1];
103
+ await runNodeAxInteraction(
104
+ node,
105
+ 'ax.work.update',
106
+ { id: target.workItemId, status: next ? 'in-progress' : 'done' },
107
+ 'Loop running',
108
+ );
109
+ };
110
+
111
+ const handleSteer = async () => {
112
+ const text = message.trim();
113
+ if (!text) return;
114
+ await runNodeAxInteraction(node, 'ax.steer', { message: text }, 'Steering sent');
115
+ setMessage('');
116
+ };
117
+
118
+ return (
119
+ <div class="ax-flow-controls" style={{ ...rowStyle, marginTop: '6px', rowGap: '4px' }}>
120
+ <span class="ax-flow-runs" style={labelStyle}>
121
+ {running ? `Loop running · run ${run + 1}/${maxRuns}` : `Loop idle · ${run}/${maxRuns} runs`}
122
+ </span>
123
+ {running ? (
124
+ <button
125
+ type="button"
126
+ class="ax-node-action"
127
+ style={activeButtonStyle}
128
+ title="Stop the loop now. The stop is persisted on this node, so it survives a refresh and no further step is opened."
129
+ onClick={(e) => {
130
+ e.stopPropagation();
131
+ void persistLoop(node, flow, { ...flow.loop, running: false });
132
+ }}
133
+ >
134
+ Stop
135
+ </button>
136
+ ) : (
137
+ <button
138
+ type="button"
139
+ class="ax-node-action"
140
+ style={axNodeActionButtonStyle}
141
+ title="Run this flow as a loop: each completed step opens the next one, and the last step starts the next run — until the run cap is reached or you press Stop."
142
+ onClick={(e) => {
143
+ e.stopPropagation();
144
+ void handleRun();
145
+ }}
146
+ >
147
+ Run loop
148
+ </button>
149
+ )}
150
+ <input
151
+ class="ax-flow-steer-input"
152
+ value={message}
153
+ placeholder="Steer the agent…"
154
+ aria-label="Steer the agent"
155
+ onInput={(e) => setMessage((e.target as HTMLInputElement).value)}
156
+ onClick={(e) => e.stopPropagation()}
157
+ onKeyDown={(e) => {
158
+ if (e.key !== 'Enter') return;
159
+ e.stopPropagation();
160
+ void handleSteer();
161
+ }}
162
+ style={{
163
+ flex: '1 1 120px',
164
+ minWidth: '90px',
165
+ fontSize: '10px',
166
+ padding: '3px 6px',
167
+ borderRadius: '4px',
168
+ border: '1px solid var(--c-line)',
169
+ background: 'var(--c-panel-overlay)',
170
+ color: 'var(--c-text)',
171
+ }}
172
+ />
173
+ <button
174
+ type="button"
175
+ class="ax-node-action"
176
+ style={axNodeActionButtonStyle}
177
+ title="Send a steering message to the agent working this flow."
178
+ onClick={(e) => {
179
+ e.stopPropagation();
180
+ void handleSteer();
181
+ }}
182
+ >
183
+ Steer
184
+ </button>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ export function AxStepControls({ node }: { node: CanvasNodeState }) {
190
+ const step = readAxStep(node.data);
191
+ if (!step) return null;
192
+ const flow = readAxFlow(node.data);
193
+ const status = mirroredStatus(node.data);
194
+
195
+ return (
196
+ <div class="ax-step-dock" style={dockStyle}>
197
+ <div class="ax-step-controls" style={rowStyle}>
198
+ <span class="ax-step-position" style={labelStyle}>
199
+ Step {step.index}/{step.total}
200
+ </span>
201
+ {STEP_ACTIONS.map((action) => (
202
+ <button
203
+ key={action.status}
204
+ type="button"
205
+ class="ax-node-action"
206
+ aria-pressed={status === action.status}
207
+ style={status === action.status ? activeButtonStyle : axNodeActionButtonStyle}
208
+ title={`Set this step's work item to "${action.status}".`}
209
+ onClick={(e) => {
210
+ e.stopPropagation();
211
+ void runNodeAxInteraction(
212
+ node,
213
+ 'ax.work.update',
214
+ { id: step.workItemId, status: action.status },
215
+ action.toast,
216
+ );
217
+ }}
218
+ >
219
+ {action.label}
220
+ </button>
221
+ ))}
222
+ </div>
223
+ {flow && <FlowLoopControls node={node} flow={flow} />}
224
+ </div>
225
+ );
226
+ }