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.
- package/.github/extensions/pmx-canvas/extension.mjs +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +156 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3069 -796
- package/dist/canvas/index.js +104 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +25 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +23 -1
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +54 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +109 -18
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +57 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +252 -0
- package/src/cli/index.ts +1 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +41 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/ExtAppFrame.tsx +79 -2
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +273 -84
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3069 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +237 -61
- package/src/server/canvas-operations.ts +17 -8
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +107 -4
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +65 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +16 -0
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- package/src/client/canvas/DockedNode.tsx +0 -165
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { Signal } from '@preact/signals';
|
|
2
|
-
import {
|
|
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.
|
|
145
|
+
const curvature = Math.min(Math.max(dist * 0.35, 32), 160);
|
|
113
146
|
|
|
114
|
-
|
|
115
|
-
const
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
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=
|
|
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) *
|
|
178
|
-
y={-10 *
|
|
179
|
-
width={(edge.label.length * 7 + 16) *
|
|
180
|
-
height={20 *
|
|
181
|
-
rx={4 *
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
{/*
|
|
278
|
-
<div
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
314
|
-
</
|
|
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
|
|
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
|
-
{/*
|
|
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}
|