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
@@ -0,0 +1,447 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import {
3
+ IconArrange,
4
+ IconClearTrace,
5
+ IconCursorTool,
6
+ IconEraser,
7
+ IconHandTool,
8
+ IconLogo,
9
+ IconMinimap,
10
+ IconMoon,
11
+ IconNodeFile,
12
+ IconNodeGroup,
13
+ IconNodeHtml,
14
+ IconNodeImage,
15
+ IconNodeMarkdown,
16
+ IconNodeWebpage,
17
+ IconPen,
18
+ IconSearch,
19
+ IconShortcuts,
20
+ IconSnapshot,
21
+ IconSun,
22
+ IconTextAnnotation,
23
+ IconTrace,
24
+ } from '../icons';
25
+ import {
26
+ autoArrange,
27
+ canvasTheme,
28
+ canvasTool,
29
+ edges,
30
+ forceDirectedArrange,
31
+ nodes,
32
+ traceEnabled,
33
+ } from '../state/canvas-store';
34
+ import { saveCanvasTheme } from '../state/intent-bridge';
35
+ import { createNodeInView } from './create-in-view';
36
+ import { askText } from './TextPrompt';
37
+ import { clearThemeOverride } from '../state/theme-override';
38
+ import { invalidateTokenCache } from '../theme/tokens';
39
+ import type { AnnotationTool } from '../types';
40
+ import { modChord } from '../utils/platform';
41
+ import {
42
+ CANVAS_THEMES,
43
+ CANVAS_THEME_META,
44
+ type CanvasThemeName,
45
+ canvasThemeScheme,
46
+ normalizeCanvasThemeName,
47
+ } from '../../shared/themes.js';
48
+
49
+ function logRailError(action: string, error: unknown): void {
50
+ console.error(`[tool-rail] ${action} failed`, error);
51
+ }
52
+
53
+ /** Prompt-driven creates for node kinds that need a source (url / path). */
54
+ export function promptedCreate(kind: 'image' | 'file' | 'webpage'): void {
55
+ const ask: Record<typeof kind, { message: string; placeholder: string }> = {
56
+ image: { message: 'Image URL (https://… or data:image/…)', placeholder: 'https://example.com/diagram.png' },
57
+ file: { message: 'Workspace file path', placeholder: 'src/server/server.ts' },
58
+ webpage: { message: 'Page URL', placeholder: 'https://example.com' },
59
+ };
60
+ // In-canvas prompt — `window.prompt` is silently a no-op in embedded panes.
61
+ void askText(ask[kind].message, ask[kind].placeholder).then((value) => {
62
+ if (!value) return;
63
+ void createNodeInView({ type: kind, content: value }).catch((error) => logRailError(`create ${kind}`, error));
64
+ });
65
+ }
66
+
67
+ /**
68
+ * A rail button with its hover/focus tooltip (label + shortcut key cap, the
69
+ * top bar's `BarHint` look). The rail scrolls (overflow-y:auto), so a CSS-only
70
+ * tooltip would be clipped inside the 52px column — like the side menus, the
71
+ * tooltip is positioned FIXED from the button's rect. No native `title`: it
72
+ * would double the tooltip a second later (and never shows in some embedded
73
+ * browser panes at all). The accessible name keeps the "Label (Shortcut)" form.
74
+ */
75
+ function RailButton({
76
+ label,
77
+ shortcut,
78
+ detail,
79
+ ariaLabel,
80
+ active,
81
+ menuOpen,
82
+ onClick,
83
+ btnRef,
84
+ children,
85
+ }: {
86
+ label: string;
87
+ shortcut?: string;
88
+ detail?: string;
89
+ ariaLabel?: string;
90
+ active?: boolean;
91
+ /** The button's own popover is open — its tooltip would sit on top of it. */
92
+ menuOpen?: boolean;
93
+ onClick: (e: MouseEvent) => void;
94
+ btnRef?: { current: HTMLButtonElement | null };
95
+ children: preact.ComponentChildren;
96
+ }) {
97
+ const [hint, setHint] = useState<{ left: number; top: number } | null>(null);
98
+ const show = (e: Event) => {
99
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
100
+ setHint({ left: rect.right + 10, top: rect.top + rect.height / 2 });
101
+ };
102
+ const hide = () => setHint(null);
103
+ const name = ariaLabel ?? (shortcut ? `${label} (${shortcut})` : detail ? `${label} (${detail})` : label);
104
+ return (
105
+ <>
106
+ <button
107
+ ref={btnRef}
108
+ type="button"
109
+ class={`rail-btn${active ? ' active' : ''}`}
110
+ aria-label={name}
111
+ aria-pressed={active === undefined ? undefined : active}
112
+ onClick={(e) => {
113
+ hide();
114
+ onClick(e);
115
+ }}
116
+ onPointerEnter={show}
117
+ onPointerLeave={hide}
118
+ onFocus={show}
119
+ onBlur={hide}
120
+ >
121
+ {children}
122
+ </button>
123
+ {hint && !menuOpen && (
124
+ <span
125
+ class="toolbar-tooltip rail-tooltip"
126
+ role="tooltip"
127
+ data-testid="rail-tooltip"
128
+ style={{ left: `${hint.left}px`, top: `${hint.top}px` }}
129
+ >
130
+ <span class="toolbar-tooltip-label">{label}</span>
131
+ {(shortcut || detail) && (
132
+ <span class="toolbar-tooltip-meta">
133
+ {shortcut && <kbd class="toolbar-tooltip-shortcut">{shortcut}</kbd>}
134
+ {detail && <span>{detail}</span>}
135
+ </span>
136
+ )}
137
+ </span>
138
+ )}
139
+ </>
140
+ );
141
+ }
142
+
143
+ /**
144
+ * The persistent 52px left tool rail (rail-chrome-v2 phase 1): brand → tools →
145
+ * node palette → utilities. Every button shows a hover/focus tooltip with its
146
+ * shortcut (`RailButton`) — the rail is the shortcut discovery surface.
147
+ */
148
+ export function ToolRail({
149
+ minimapVisible,
150
+ onToggleMinimap,
151
+ snapshotOpen,
152
+ onToggleSnapshot,
153
+ snapshotBtnRef,
154
+ onOpenPalette,
155
+ onOpenShortcuts,
156
+ annotationTool,
157
+ onSetAnnotationTool,
158
+ }: {
159
+ minimapVisible: boolean;
160
+ onToggleMinimap: () => void;
161
+ snapshotOpen: boolean;
162
+ onToggleSnapshot: () => void;
163
+ snapshotBtnRef: { current: HTMLButtonElement | null };
164
+ onOpenPalette: () => void;
165
+ onOpenShortcuts: () => void;
166
+ annotationTool: AnnotationTool;
167
+ onSetAnnotationTool: (tool: AnnotationTool) => void;
168
+ }) {
169
+ const tool = canvasTool.value;
170
+ const isTraceOn = traceEnabled.value;
171
+ const traceNodeCount = Array.from(nodes.value.values()).filter((n) => n.type === 'trace').length;
172
+ const edgeCount = edges.value.size;
173
+ const [openMenu, setOpenMenu] = useState<null | 'theme' | 'annotate'>(null);
174
+ const [menuAnchor, setMenuAnchor] = useState<{ top: number; bottom: number; right: number } | null>(null);
175
+ const railRef = useRef<HTMLDivElement>(null);
176
+
177
+ const toggleMenu = (menu: 'theme' | 'annotate') => (e: MouseEvent) => {
178
+ if (openMenu === menu) {
179
+ setOpenMenu(null);
180
+ return;
181
+ }
182
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
183
+ setMenuAnchor({ top: rect.top, bottom: rect.bottom, right: rect.right });
184
+ setOpenMenu(menu);
185
+ };
186
+
187
+ // The rail scrolls (overflow-y:auto), so an absolutely-positioned popover
188
+ // would be clipped by it. Fixed positioning from the trigger's rect escapes
189
+ // the scroll container; the annotate menu top-aligns with its trigger, the
190
+ // theme menu (bottom cluster) bottom-aligns so it grows upward.
191
+ const sideMenuStyle = (alignBottom: boolean) =>
192
+ menuAnchor
193
+ ? alignBottom
194
+ ? {
195
+ position: 'fixed' as const,
196
+ left: `${menuAnchor.right + 8}px`,
197
+ bottom: `${window.innerHeight - menuAnchor.bottom}px`,
198
+ top: 'auto',
199
+ right: 'auto',
200
+ }
201
+ : { position: 'fixed' as const, left: `${menuAnchor.right + 8}px`, top: `${menuAnchor.top}px`, right: 'auto' }
202
+ : undefined;
203
+
204
+ useEffect(() => {
205
+ if (!openMenu) return;
206
+ const onPointerDown = (e: PointerEvent) => {
207
+ if (railRef.current && e.target instanceof Node && !railRef.current.contains(e.target)) setOpenMenu(null);
208
+ };
209
+ const onKeyDown = (e: KeyboardEvent) => {
210
+ if (e.key === 'Escape') setOpenMenu(null);
211
+ };
212
+ document.addEventListener('pointerdown', onPointerDown);
213
+ document.addEventListener('keydown', onKeyDown);
214
+ return () => {
215
+ document.removeEventListener('pointerdown', onPointerDown);
216
+ document.removeEventListener('keydown', onKeyDown);
217
+ };
218
+ }, [openMenu]);
219
+
220
+ const applyTheme = (next: CanvasThemeName) => {
221
+ clearThemeOverride();
222
+ document.documentElement.setAttribute('data-theme', next);
223
+ invalidateTokenCache();
224
+ canvasTheme.value = next;
225
+ void saveCanvasTheme(next);
226
+ setOpenMenu(null);
227
+ };
228
+ const activeTheme = normalizeCanvasThemeName(canvasTheme.value);
229
+
230
+ const sendIntent = (type: string, payload: Record<string, unknown> = {}) => {
231
+ fetch(`/api/workbench/intent?_ts=${Date.now()}`, {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({ type, payload }),
235
+ }).catch((error) => logRailError('sendIntent', error));
236
+ };
237
+
238
+ return (
239
+ <div class="tool-rail" ref={railRef} role="toolbar" aria-label="Canvas tools" aria-orientation="vertical">
240
+ <span class="rail-brand" title="PMX Canvas" aria-label="PMX Canvas">
241
+ <IconLogo size={22} />
242
+ </span>
243
+
244
+ <div class="rail-divider" />
245
+
246
+ <RailButton label="Select" shortcut="V" active={tool === 'select'} onClick={() => (canvasTool.value = 'select')}>
247
+ <IconCursorTool />
248
+ </RailButton>
249
+ <RailButton label="Pan" shortcut="Space" active={tool === 'pan'} onClick={() => (canvasTool.value = 'pan')}>
250
+ <IconHandTool />
251
+ </RailButton>
252
+ <RailButton
253
+ label="Connect"
254
+ shortcut="C"
255
+ active={tool === 'connect'}
256
+ onClick={() => (canvasTool.value = 'connect')}
257
+ >
258
+ <svg
259
+ width="15"
260
+ height="15"
261
+ viewBox="0 0 16 16"
262
+ fill="none"
263
+ stroke="currentColor"
264
+ stroke-width="1.5"
265
+ stroke-linecap="round"
266
+ aria-hidden="true"
267
+ >
268
+ <circle cx="4" cy="4" r="2" />
269
+ <circle cx="12" cy="12" r="2" />
270
+ <path d="M5.5 5.5 C 8 8, 8 8, 10.5 10.5" />
271
+ </svg>
272
+ </RailButton>
273
+
274
+ <div class="rail-divider" />
275
+
276
+ <RailButton
277
+ label="Markdown note"
278
+ shortcut="M"
279
+ onClick={() =>
280
+ void createNodeInView({ type: 'markdown', title: 'New note', width: 520, height: 360 }).catch((error) =>
281
+ logRailError('create markdown', error),
282
+ )
283
+ }
284
+ >
285
+ <IconNodeMarkdown size={15} />
286
+ </RailButton>
287
+ <RailButton label="Image" shortcut="I" onClick={() => promptedCreate('image')}>
288
+ <IconNodeImage size={15} />
289
+ </RailButton>
290
+ <RailButton label="File" shortcut="Shift+F" onClick={() => promptedCreate('file')}>
291
+ <IconNodeFile size={15} />
292
+ </RailButton>
293
+ <RailButton label="Webpage" shortcut="W" onClick={() => promptedCreate('webpage')}>
294
+ <IconNodeWebpage size={15} />
295
+ </RailButton>
296
+ <RailButton
297
+ label="HTML surface"
298
+ shortcut="H"
299
+ onClick={() =>
300
+ void createNodeInView({ type: 'html', title: 'HTML surface' }).catch((error) =>
301
+ logRailError('create html', error),
302
+ )
303
+ }
304
+ >
305
+ <IconNodeHtml />
306
+ </RailButton>
307
+ <RailButton
308
+ label="Group"
309
+ shortcut="G"
310
+ onClick={() =>
311
+ void createNodeInView({ type: 'group', title: 'Group' }).catch((error) => logRailError('create group', error))
312
+ }
313
+ >
314
+ <IconNodeGroup size={15} />
315
+ </RailButton>
316
+ <span class="toolbar-menu-anchor">
317
+ <RailButton
318
+ label="Annotate"
319
+ shortcut="A"
320
+ detail="Draw · Text note · Eraser"
321
+ active={annotationTool !== null}
322
+ menuOpen={openMenu === 'annotate'}
323
+ onClick={toggleMenu('annotate')}
324
+ >
325
+ {annotationTool === 'eraser' ? (
326
+ <IconEraser />
327
+ ) : annotationTool === 'text' ? (
328
+ <IconTextAnnotation />
329
+ ) : (
330
+ <IconPen />
331
+ )}
332
+ </RailButton>
333
+ {openMenu === 'annotate' && (
334
+ <div class="toolbar-menu" style={sideMenuStyle(false)} role="menu" aria-label="Annotate">
335
+ <button
336
+ type="button"
337
+ class={`toolbar-menu-item${annotationTool === 'pen' ? ' active' : ''}`}
338
+ onClick={() => {
339
+ onSetAnnotationTool(annotationTool === 'pen' ? null : 'pen');
340
+ setOpenMenu(null);
341
+ }}
342
+ >
343
+ <IconPen />
344
+ <span>{annotationTool === 'pen' ? 'Stop annotating' : 'Draw (A)'}</span>
345
+ </button>
346
+ <button
347
+ type="button"
348
+ class={`toolbar-menu-item${annotationTool === 'text' ? ' active' : ''}`}
349
+ onClick={() => {
350
+ onSetAnnotationTool(annotationTool === 'text' ? null : 'text');
351
+ setOpenMenu(null);
352
+ }}
353
+ >
354
+ <IconTextAnnotation />
355
+ <span>{annotationTool === 'text' ? 'Stop text notes' : 'Text note'}</span>
356
+ </button>
357
+ <button
358
+ type="button"
359
+ class={`toolbar-menu-item${annotationTool === 'eraser' ? ' active' : ''}`}
360
+ onClick={() => {
361
+ onSetAnnotationTool(annotationTool === 'eraser' ? null : 'eraser');
362
+ setOpenMenu(null);
363
+ }}
364
+ >
365
+ <IconEraser />
366
+ <span>{annotationTool === 'eraser' ? 'Stop erasing' : 'Eraser'}</span>
367
+ </button>
368
+ </div>
369
+ )}
370
+ </span>
371
+
372
+ <div class="rail-spacer" />
373
+
374
+ <RailButton label="Search & commands" shortcut={modChord('K')} onClick={onOpenPalette}>
375
+ <IconSearch />
376
+ </RailButton>
377
+ <RailButton
378
+ label="Arrange"
379
+ detail={edgeCount > 0 ? 'graph-aware' : 'grid'}
380
+ onClick={() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange())}
381
+ >
382
+ <IconArrange />
383
+ </RailButton>
384
+ <RailButton
385
+ label={isTraceOn ? 'Disable trace' : 'Enable trace'}
386
+ active={isTraceOn}
387
+ onClick={() => sendIntent('trace-toggle', { enabled: !isTraceOn })}
388
+ >
389
+ <IconTrace />
390
+ </RailButton>
391
+ {(isTraceOn || traceNodeCount > 0) && (
392
+ <RailButton label="Clear trace" onClick={() => sendIntent('trace-clear')}>
393
+ <IconClearTrace />
394
+ </RailButton>
395
+ )}
396
+ <RailButton
397
+ label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
398
+ active={minimapVisible}
399
+ onClick={onToggleMinimap}
400
+ >
401
+ <IconMinimap />
402
+ </RailButton>
403
+ <RailButton label="Snapshots" active={snapshotOpen} onClick={onToggleSnapshot} btnRef={snapshotBtnRef}>
404
+ <IconSnapshot />
405
+ </RailButton>
406
+ <span class="toolbar-menu-anchor">
407
+ <RailButton
408
+ label="Theme"
409
+ detail={CANVAS_THEME_META[activeTheme].label}
410
+ menuOpen={openMenu === 'theme'}
411
+ ariaLabel="Choose theme"
412
+ active={openMenu === 'theme'}
413
+ onClick={toggleMenu('theme')}
414
+ >
415
+ {canvasThemeScheme(activeTheme) === 'dark' ? <IconSun /> : <IconMoon />}
416
+ </RailButton>
417
+ {openMenu === 'theme' && (
418
+ <div class="toolbar-menu" style={sideMenuStyle(true)} role="menu" aria-label="Theme">
419
+ {CANVAS_THEMES.map((name) => (
420
+ <button
421
+ key={name}
422
+ type="button"
423
+ role="menuitemradio"
424
+ aria-checked={activeTheme === name}
425
+ class={`toolbar-menu-item${activeTheme === name ? ' active' : ''}`}
426
+ onClick={() => applyTheme(name)}
427
+ >
428
+ <span class="theme-swatch" style={{ background: CANVAS_THEME_META[name].swatchBg }}>
429
+ <span class="theme-swatch-dot" style={{ background: CANVAS_THEME_META[name].swatchAccent }} />
430
+ </span>
431
+ <span>{CANVAS_THEME_META[name].label}</span>
432
+ {activeTheme === name && (
433
+ <span class="toolbar-menu-check" aria-hidden="true">
434
+ ✓
435
+ </span>
436
+ )}
437
+ </button>
438
+ ))}
439
+ </div>
440
+ )}
441
+ </span>
442
+ <RailButton label="Shortcuts" shortcut="?" onClick={onOpenShortcuts}>
443
+ <IconShortcuts />
444
+ </RailButton>
445
+ </div>
446
+ );
447
+ }