agent-canvas 0.1.3 → 0.1.5

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.
@@ -75,10 +75,10 @@ async function handleInstall(args: string[]) {
75
75
 
76
76
  if (!mode) {
77
77
  // Interactive prompt
78
- process.stderr.write("Install canvas skill and hooks for Claude Code.\n\n");
79
- process.stderr.write(" local — install to .claude/ in current project\n");
80
- process.stderr.write(" global — install to ~/.claude/ for all projects\n\n");
81
- process.stderr.write("Choose [local/global]: ");
78
+ process.stdout.write("Install canvas skill and hooks for Claude Code.\n\n");
79
+ process.stdout.write(" local — install to .claude/ in current project\n");
80
+ process.stdout.write(" global — install to ~/.claude/ for all projects\n\n");
81
+ process.stdout.write("Choose [local/global]: ");
82
82
 
83
83
  const input = await readLine();
84
84
  mode = input.trim().toLowerCase() as "local" | "global";
@@ -105,7 +105,7 @@ async function handleInstall(args: string[]) {
105
105
  }
106
106
  }
107
107
 
108
- console.error(` Skill installed to ${skillTarget}`);
108
+ console.log(` Skill installed to ${skillTarget}`);
109
109
 
110
110
  // Install hook script
111
111
  const hooksDir = join(targetBase, "agent-canvas");
@@ -114,7 +114,7 @@ async function handleInstall(args: string[]) {
114
114
  const hookDest = join(hooksDir, "session-start.sh");
115
115
  cpSync(hookSrc, hookDest);
116
116
  try { require("fs").chmodSync(hookDest, 0o755); } catch {}
117
- console.error(` Hook script installed to ${hookDest}`);
117
+ console.log(` Hook script installed to ${hookDest}`);
118
118
 
119
119
  // Register hook in settings.json
120
120
  const settingsPath = join(targetBase, "settings.json");
@@ -141,12 +141,12 @@ async function handleInstall(args: string[]) {
141
141
 
142
142
  mkdirSync(dirname(settingsPath), { recursive: true });
143
143
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
144
- console.error(` Hook registered in ${settingsPath}`);
144
+ console.log(` Hook registered in ${settingsPath}`);
145
145
  } else {
146
- console.error(` Hook already registered in ${settingsPath}`);
146
+ console.log(` Hook already registered in ${settingsPath}`);
147
147
  }
148
148
 
149
- console.error(`\nInstalled! The /canvas command is now available in Claude Code.`);
149
+ console.log(`\nInstalled! The /canvas command is now available in Claude Code.`);
150
150
  }
151
151
 
152
152
  // ── push ──
@@ -285,10 +285,6 @@ async function ensureDaemon(): Promise<void> {
285
285
 
286
286
  child.unref();
287
287
 
288
- if (child.pid) {
289
- writeFileSync(PID_FILE, String(child.pid));
290
- }
291
-
292
288
  for (let i = 0; i < 30; i++) {
293
289
  await new Promise((r) => setTimeout(r, 100));
294
290
  if (await isDaemonRunning()) {
@@ -143,6 +143,7 @@ function App() {
143
143
  const [openFiles, setOpenFiles] = useState<string[]>([]);
144
144
  const wsRef = useRef<WebSocket | null>(null);
145
145
 
146
+ const [projectRoot, setProjectRoot] = useState<string | undefined>();
146
147
  const [mobileSidebar, setMobileSidebar] = useState(false);
147
148
  const [leftCollapsed, setLeftCollapsed] = useState(false);
148
149
  const [rightCollapsed, setRightCollapsed] = useState(false);
@@ -150,14 +151,19 @@ function App() {
150
151
  // Dynamic document title
151
152
  useEffect(() => {
152
153
  const parts = ["Agent Canvas"];
153
- if (sessionId) parts.push(sessionId);
154
+ if (projectRoot) {
155
+ const segments = projectRoot.replace(/\/+$/, "").split("/");
156
+ parts.push(segments[segments.length - 1] || sessionId);
157
+ } else if (sessionId) {
158
+ parts.push(sessionId);
159
+ }
154
160
  if (activeView.type === "file") {
155
161
  parts.push(activeView.path.split("/").pop() || activeView.path);
156
162
  } else if (compareRevision) {
157
163
  parts.push("Compare");
158
164
  }
159
165
  document.title = parts.join(" — ");
160
- }, [sessionId, activeView, compareRevision]);
166
+ }, [sessionId, projectRoot, activeView, compareRevision]);
161
167
 
162
168
  // Scroll position restore per view
163
169
  const scrollPositions = useRef<Map<string, number>>(new Map());
@@ -206,6 +212,7 @@ function App() {
206
212
  setSelectedRevision(data.currentRevision);
207
213
  setRevisions(data.revisions || []);
208
214
  }
215
+ if (data.projectRoot) setProjectRoot(data.projectRoot);
209
216
  })
210
217
  .catch(() => {});
211
218
  }, [sessionId]);
@@ -259,7 +266,7 @@ function App() {
259
266
  <ActiveViewCtx.Provider value={{ setActiveView }}>
260
267
  <div className="min-h-screen bg-bg-base">
261
268
  {/* Left panel — fixed to viewport */}
262
- <LeftPanel sessionId={sessionId} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
269
+ <LeftPanel sessionId={sessionId} projectRoot={projectRoot} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
263
270
 
264
271
  {/* Right panel — fixed to viewport, resizable (hidden in compare mode) */}
265
272
  {compareRevision === null && (
@@ -283,7 +290,7 @@ function App() {
283
290
  {/* Mobile top bar — only visible < lg */}
284
291
  <div className="lg:hidden sticky top-0 z-20 flex items-center justify-between px-4 py-2 border-b border-border-subtle bg-bg-surface">
285
292
  <div className="flex items-center gap-2">
286
- <SessionSwitcher currentSessionId={sessionId} />
293
+ <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />
287
294
  <span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
288
295
  </div>
289
296
  <div className="flex items-center gap-1">
@@ -367,7 +374,7 @@ function App() {
367
374
  );
368
375
  }
369
376
 
370
- function LeftPanel({ sessionId, connected, onMobileSidebar, collapsed, onToggle }: { sessionId: string; connected: boolean; onMobileSidebar: () => void; collapsed: boolean; onToggle: () => void }) {
377
+ function LeftPanel({ sessionId, projectRoot, connected, onMobileSidebar, collapsed, onToggle }: { sessionId: string; projectRoot?: string; connected: boolean; onMobileSidebar: () => void; collapsed: boolean; onToggle: () => void }) {
371
378
  return (
372
379
  <>
373
380
  {/* Collapsed toggle button */}
@@ -390,7 +397,7 @@ function LeftPanel({ sessionId, connected, onMobileSidebar, collapsed, onToggle
390
397
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-text-tertiary flex-shrink-0">
391
398
  <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
392
399
  </svg>
393
- <SessionSwitcher currentSessionId={sessionId} />
400
+ <SessionSwitcher currentSessionId={sessionId} projectRoot={projectRoot} />
394
401
  <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
395
402
  <button
396
403
  onClick={onToggle}
@@ -2,15 +2,25 @@ import React, { useEffect, useState, useRef } from "react";
2
2
 
3
3
  interface SessionInfo {
4
4
  id: string;
5
- version: number;
5
+ projectRoot?: string;
6
+ currentRevision: number;
6
7
  updatedAt: string;
7
8
  }
8
9
 
10
+ function sessionDisplayName(id: string, projectRoot?: string): string {
11
+ if (projectRoot) {
12
+ const parts = projectRoot.replace(/\/+$/, "").split("/");
13
+ return parts[parts.length - 1] || id;
14
+ }
15
+ return id.length > 30 ? id.slice(0, 30) + "..." : id;
16
+ }
17
+
9
18
  interface SessionSwitcherProps {
10
19
  currentSessionId: string;
20
+ projectRoot?: string;
11
21
  }
12
22
 
13
- export function SessionSwitcher({ currentSessionId }: SessionSwitcherProps) {
23
+ export function SessionSwitcher({ currentSessionId, projectRoot }: SessionSwitcherProps) {
14
24
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
15
25
  const [open, setOpen] = useState(false);
16
26
  const ref = useRef<HTMLDivElement>(null);
@@ -37,7 +47,7 @@ export function SessionSwitcher({ currentSessionId }: SessionSwitcherProps) {
37
47
  }, [open]);
38
48
 
39
49
  const otherSessions = sessions.filter((s) => s.id !== currentSessionId);
40
- const displayName = currentSessionId.length > 30 ? currentSessionId.slice(0, 30) + "..." : currentSessionId;
50
+ const displayName = sessionDisplayName(currentSessionId, projectRoot);
41
51
 
42
52
  return (
43
53
  <div className="relative" ref={ref}>
@@ -62,7 +72,7 @@ export function SessionSwitcher({ currentSessionId }: SessionSwitcherProps) {
62
72
  className="flex items-center gap-2.5 px-4 py-2.5 text-body font-body text-text-secondary hover:bg-bg-surface hover:text-text-primary transition-colors"
63
73
  >
64
74
  <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${s.currentRevision > 0 ? "bg-accent-green" : "bg-border-hover"}`} />
65
- <span className="truncate">{s.id}</span>
75
+ <span className="truncate">{sessionDisplayName(s.id, s.projectRoot)}</span>
66
76
  </a>
67
77
  ))}
68
78
  </div>
@@ -1,4 +1,5 @@
1
1
  import React, { useEffect, useRef, useState, useCallback } from "react";
2
+ import ReactDOM from "react-dom";
2
3
  import { useAnnotations, Annotation } from "#canvas/runtime";
3
4
  import { AnnotationCreatePopover } from "../Popover";
4
5
 
@@ -12,12 +13,154 @@ interface NodePopoverState {
12
13
  prefix: string;
13
14
  }
14
15
 
16
+ const ZOOM_STEP = 0.2;
17
+ const ZOOM_MIN = 0.25;
18
+ const ZOOM_MAX = 4;
19
+
20
+ function ZoomToolbar({ zoom, onZoomIn, onZoomOut, onZoomReset, onFullscreen }: {
21
+ zoom: number;
22
+ onZoomIn: () => void;
23
+ onZoomOut: () => void;
24
+ onZoomReset: () => void;
25
+ onFullscreen?: () => void;
26
+ }) {
27
+ const btnClass = "flex items-center justify-center w-7 h-7 rounded hover:bg-bg-elevated text-text-secondary hover:text-text-primary transition-colors";
28
+ return (
29
+ <div className="flex items-center gap-0.5 bg-bg-surface border border-border-subtle rounded-md px-1 py-0.5" style={{ fontSize: "12px" }}>
30
+ <button className={btnClass} onClick={onZoomOut} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
31
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
32
+ </button>
33
+ <button className="px-1.5 h-7 rounded hover:bg-bg-elevated text-text-tertiary hover:text-text-primary transition-colors tabular-nums" onClick={onZoomReset} title="Reset zoom">
34
+ {Math.round(zoom * 100)}%
35
+ </button>
36
+ <button className={btnClass} onClick={onZoomIn} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
37
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
38
+ </button>
39
+ {onFullscreen && (
40
+ <>
41
+ <div className="w-px h-4 bg-border-subtle mx-0.5" />
42
+ <button className={btnClass} onClick={onFullscreen} title="Fullscreen">
43
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
44
+ </button>
45
+ </>
46
+ )}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ function useZoom(initialZoom = 1) {
52
+ const [zoom, setZoom] = useState(initialZoom);
53
+ const zoomIn = useCallback(() => setZoom(z => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2))), []);
54
+ const zoomOut = useCallback(() => setZoom(z => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2))), []);
55
+ const zoomReset = useCallback(() => setZoom(1), []);
56
+ return { zoom, zoomIn, zoomOut, zoomReset, setZoom };
57
+ }
58
+
59
+ function MermaidFullscreenModal({ svgHtml, source, onClose }: {
60
+ svgHtml: string;
61
+ source: string;
62
+ onClose: () => void;
63
+ }) {
64
+ const modalContentRef = useRef<HTMLDivElement>(null);
65
+ const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1);
66
+ const [pan, setPan] = useState({ x: 0, y: 0 });
67
+ const dragging = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);
68
+
69
+ useEffect(() => {
70
+ const onKeyDown = (e: KeyboardEvent) => {
71
+ if (e.key === "Escape") onClose();
72
+ };
73
+ window.addEventListener("keydown", onKeyDown);
74
+ return () => window.removeEventListener("keydown", onKeyDown);
75
+ }, [onClose]);
76
+
77
+ // Wheel zoom
78
+ const handleWheel = useCallback((e: React.WheelEvent) => {
79
+ e.preventDefault();
80
+ const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
81
+ setZoom(z => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(z + delta).toFixed(2))));
82
+ }, [setZoom]);
83
+
84
+ // Pan via mouse drag
85
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
86
+ if (e.button !== 0) return;
87
+ dragging.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
88
+ e.preventDefault();
89
+ }, [pan]);
90
+
91
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
92
+ if (!dragging.current) return;
93
+ setPan({
94
+ x: dragging.current.panX + (e.clientX - dragging.current.startX),
95
+ y: dragging.current.panY + (e.clientY - dragging.current.startY),
96
+ });
97
+ }, []);
98
+
99
+ const handleMouseUp = useCallback(() => {
100
+ dragging.current = null;
101
+ }, []);
102
+
103
+ const handleResetView = useCallback(() => {
104
+ zoomReset();
105
+ setPan({ x: 0, y: 0 });
106
+ }, [zoomReset]);
107
+
108
+ return ReactDOM.createPortal(
109
+ <div
110
+ style={{ position: "fixed", inset: 0, zIndex: 9999, background: "var(--color-bg-base)", display: "flex", flexDirection: "column" }}
111
+ onMouseMove={handleMouseMove}
112
+ onMouseUp={handleMouseUp}
113
+ onMouseLeave={handleMouseUp}
114
+ >
115
+ {/* Header bar */}
116
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border-subtle" style={{ flexShrink: 0, background: "var(--color-bg-surface)" }}>
117
+ <span className="text-body text-text-secondary" style={{ fontSize: "13px" }}>Mermaid Diagram</span>
118
+ <div className="flex items-center gap-2">
119
+ <ZoomToolbar zoom={zoom} onZoomIn={zoomIn} onZoomOut={zoomOut} onZoomReset={handleResetView} />
120
+ <button
121
+ className="flex items-center justify-center w-8 h-8 rounded hover:bg-bg-elevated text-text-secondary hover:text-text-primary transition-colors"
122
+ onClick={onClose}
123
+ title="Close (Esc)"
124
+ >
125
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
126
+ </button>
127
+ </div>
128
+ </div>
129
+ {/* Diagram area */}
130
+ <div
131
+ style={{ flex: 1, overflow: "hidden", cursor: dragging.current ? "grabbing" : "grab" }}
132
+ onWheel={handleWheel}
133
+ onMouseDown={handleMouseDown}
134
+ >
135
+ <div
136
+ ref={modalContentRef}
137
+ style={{
138
+ width: "100%",
139
+ height: "100%",
140
+ display: "flex",
141
+ alignItems: "center",
142
+ justifyContent: "center",
143
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
144
+ transformOrigin: "center center",
145
+ transition: dragging.current ? "none" : "transform 0.1s ease-out",
146
+ }}
147
+ dangerouslySetInnerHTML={{ __html: svgHtml }}
148
+ />
149
+ </div>
150
+ </div>,
151
+ document.body,
152
+ );
153
+ }
154
+
15
155
  export function Mermaid({ children }: MermaidProps) {
16
156
  const containerRef = useRef<HTMLDivElement>(null);
17
157
  const [error, setError] = useState<string | null>(null);
158
+ const [showFullscreen, setShowFullscreen] = useState(false);
159
+ const [svgHtml, setSvgHtml] = useState("");
18
160
  const source = typeof children === "string" ? children : String(children ?? "");
19
161
  const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
20
162
  const [nodePopover, setNodePopover] = useState<NodePopoverState | null>(null);
163
+ const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1);
21
164
 
22
165
  const annotationsRef = useRef(annotations);
23
166
  annotationsRef.current = annotations;
@@ -47,6 +190,7 @@ export function Mermaid({ children }: MermaidProps) {
47
190
  const { svg } = await mermaid.render(id, source.trim());
48
191
  if (!cancelled && containerRef.current) {
49
192
  containerRef.current.innerHTML = svg;
193
+ setSvgHtml(svg);
50
194
  setupNodeAnnotations(containerRef.current, (el, snippet, prefix) => handleNodeClickRef.current(el, snippet, prefix));
51
195
  highlightAnnotatedNodes(containerRef.current, annotationsRef.current);
52
196
  }
@@ -75,6 +219,14 @@ export function Mermaid({ children }: MermaidProps) {
75
219
  }
76
220
  }, [annotations, activeAnnotationId]);
77
221
 
222
+ // Wheel zoom on inline diagram
223
+ const handleWheel = useCallback((e: React.WheelEvent) => {
224
+ if (!e.ctrlKey && !e.metaKey) return;
225
+ e.preventDefault();
226
+ const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
227
+ setZoom(z => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(z + delta).toFixed(2))));
228
+ }, [setZoom]);
229
+
78
230
  if (error) {
79
231
  return (
80
232
  <div className="bg-accent-red-muted rounded-md p-4 mt-3 text-body text-accent-red">
@@ -88,7 +240,31 @@ export function Mermaid({ children }: MermaidProps) {
88
240
 
89
241
  return (
90
242
  <>
91
- <div ref={containerRef} className="mt-3 flex justify-center overflow-x-auto mermaid-container" data-md="mermaid" data-md-source={source.trim()} />
243
+ <div className="mt-3 mermaid-container" data-md="mermaid" data-md-source={source.trim()}>
244
+ {/* Zoom toolbar */}
245
+ <div className="flex justify-end mb-1">
246
+ <ZoomToolbar
247
+ zoom={zoom}
248
+ onZoomIn={zoomIn}
249
+ onZoomOut={zoomOut}
250
+ onZoomReset={zoomReset}
251
+ onFullscreen={() => setShowFullscreen(true)}
252
+ />
253
+ </div>
254
+ {/* Zoomable diagram */}
255
+ <div style={{ overflow: "auto", maxHeight: "70vh" }} onWheel={handleWheel}>
256
+ <div
257
+ ref={containerRef}
258
+ className="flex justify-center"
259
+ style={{
260
+ transform: `scale(${zoom})`,
261
+ transformOrigin: "top center",
262
+ transition: "transform 0.1s ease-out",
263
+ minHeight: zoom < 1 ? undefined : undefined,
264
+ }}
265
+ />
266
+ </div>
267
+ </div>
92
268
  {nodePopover && (
93
269
  <AnnotationCreatePopover
94
270
  anchorEl={nodePopover.anchorEl}
@@ -108,6 +284,13 @@ export function Mermaid({ children }: MermaidProps) {
108
284
  onCancel={() => setNodePopover(null)}
109
285
  />
110
286
  )}
287
+ {showFullscreen && svgHtml && (
288
+ <MermaidFullscreenModal
289
+ svgHtml={svgHtml}
290
+ source={source}
291
+ onClose={() => setShowFullscreen(false)}
292
+ />
293
+ )}
111
294
  </>
112
295
  );
113
296
  }
@@ -188,6 +371,57 @@ function setupNodeAnnotations(
188
371
  if (!text) continue;
189
372
  makeClickable(msgText as SVGElement, () => text, "Edge");
190
373
  }
374
+
375
+ // ER diagram: entities
376
+ for (const entity of container.querySelectorAll(".entityBox")) {
377
+ const group = entity.closest("g") as SVGElement | null;
378
+ if (!group || group.hasAttribute("data-clickable")) continue;
379
+ const labelEl = group.querySelector(".entityLabel");
380
+ if (!labelEl) continue;
381
+ group.setAttribute("data-clickable", "true");
382
+ makeClickable(group, () => labelEl.textContent?.trim() || "Entity", "Node");
383
+ }
384
+
385
+ // ER diagram: relationship labels
386
+ for (const label of container.querySelectorAll(".relationshipLabel")) {
387
+ const text = label.textContent?.trim();
388
+ if (!text) continue;
389
+ const group = label.closest("g") as SVGElement | null;
390
+ const target = group || label as SVGElement;
391
+ if (target.hasAttribute("data-clickable")) continue;
392
+ target.setAttribute("data-clickable", "true");
393
+ makeClickable(target, () => text, "Edge");
394
+ }
395
+
396
+ // Class diagram: class groups
397
+ for (const classGroup of container.querySelectorAll(".classGroup")) {
398
+ const el = classGroup as SVGElement;
399
+ if (el.hasAttribute("data-clickable")) continue;
400
+ const labelEl = el.querySelector(".classTitle, .classTitleText, text");
401
+ if (!labelEl) continue;
402
+ el.setAttribute("data-clickable", "true");
403
+ makeClickable(el, () => labelEl.textContent?.trim() || "Class", "Node");
404
+ }
405
+
406
+ // State diagram: states
407
+ for (const state of container.querySelectorAll(".statediagram-state")) {
408
+ const el = state as SVGElement;
409
+ if (el.hasAttribute("data-clickable")) continue;
410
+ const labelEl = el.querySelector(".state-title, text");
411
+ if (!labelEl) continue;
412
+ el.setAttribute("data-clickable", "true");
413
+ makeClickable(el, () => labelEl.textContent?.trim() || "State", "Node");
414
+ }
415
+
416
+ // Mindmap: nodes
417
+ for (const node of container.querySelectorAll(".mindmap-node")) {
418
+ const el = node as SVGElement;
419
+ if (el.hasAttribute("data-clickable")) continue;
420
+ const labelEl = el.querySelector(".mindmap-node-label, text");
421
+ if (!labelEl) continue;
422
+ el.setAttribute("data-clickable", "true");
423
+ makeClickable(el, () => labelEl.textContent?.trim() || "Node", "Node");
424
+ }
191
425
  }
192
426
 
193
427
  function highlightAnnotatedNodes(container: HTMLElement, annotations: Annotation[], activeId?: string | null) {
@@ -274,4 +508,57 @@ function highlightAnnotatedNodes(container: HTMLElement, annotations: Annotation
274
508
  el.style.outlineOffset = "2px";
275
509
  el.style.borderRadius = "4px";
276
510
  }
511
+
512
+ // Helper for group-based highlights (ER entities, class groups, states, mindmap nodes)
513
+ const highlightGroups = (
514
+ selector: string,
515
+ labelSelector: string,
516
+ findGroup: (el: Element) => SVGElement | null,
517
+ ) => {
518
+ for (const el of container.querySelectorAll(selector)) {
519
+ const group = findGroup(el);
520
+ if (!group || group.hasAttribute("data-annotated")) continue;
521
+ const labelEl = group.querySelector(labelSelector);
522
+ const text = labelEl?.textContent?.trim();
523
+ if (!text || !annotatedSnippets.has(text)) continue;
524
+ const annId = annotatedSnippets.get(text)!;
525
+ group.setAttribute("data-annotated", annId);
526
+ const isActive = annId === activeId;
527
+ const target = group.querySelector("rect, circle, ellipse, polygon") as SVGElement || group;
528
+ target.style.outline = isActive
529
+ ? "2px solid var(--color-highlight-border)"
530
+ : "2px solid var(--color-highlight-annotation)";
531
+ target.style.outlineOffset = "2px";
532
+ target.style.borderRadius = "4px";
533
+ }
534
+ };
535
+
536
+ // ER diagram: entities
537
+ highlightGroups(".entityBox", ".entityLabel", (el) => el.closest("g") as SVGElement | null);
538
+
539
+ // ER diagram: relationship labels
540
+ for (const label of container.querySelectorAll(".relationshipLabel")) {
541
+ const group = label.closest("g") as SVGElement | null;
542
+ const target = group || label as SVGElement;
543
+ if (target.hasAttribute("data-annotated")) continue;
544
+ const text = label.textContent?.trim();
545
+ if (!text || !annotatedSnippets.has(text)) continue;
546
+ const annId = annotatedSnippets.get(text)!;
547
+ target.setAttribute("data-annotated", annId);
548
+ const isActive = annId === activeId;
549
+ (target as SVGElement).style.outline = isActive
550
+ ? "2px solid var(--color-highlight-border)"
551
+ : "2px solid var(--color-highlight-annotation)";
552
+ (target as SVGElement).style.outlineOffset = "2px";
553
+ (target as SVGElement).style.borderRadius = "4px";
554
+ }
555
+
556
+ // Class diagram
557
+ highlightGroups(".classGroup", ".classTitle, .classTitleText, text", (el) => el as SVGElement);
558
+
559
+ // State diagram
560
+ highlightGroups(".statediagram-state", ".state-title, text", (el) => el as SVGElement);
561
+
562
+ // Mindmap
563
+ highlightGroups(".mindmap-node", ".mindmap-node-label, text", (el) => el as SVGElement);
277
564
  }