codedebrief 0.11.0__py3-none-any.whl

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 (48) hide show
  1. codedebrief/__init__.py +12 -0
  2. codedebrief/analysis/__init__.py +16 -0
  3. codedebrief/analysis/common.py +527 -0
  4. codedebrief/analysis/discovery.py +100 -0
  5. codedebrief/analysis/languages/__init__.py +6 -0
  6. codedebrief/analysis/languages/_common.py +68 -0
  7. codedebrief/analysis/languages/c.py +96 -0
  8. codedebrief/analysis/languages/cpp.py +146 -0
  9. codedebrief/analysis/languages/csharp.py +137 -0
  10. codedebrief/analysis/languages/go.py +157 -0
  11. codedebrief/analysis/languages/java.py +158 -0
  12. codedebrief/analysis/languages/php.py +83 -0
  13. codedebrief/analysis/languages/ruby.py +75 -0
  14. codedebrief/analysis/languages/rust.py +96 -0
  15. codedebrief/analysis/project.py +373 -0
  16. codedebrief/analysis/python.py +939 -0
  17. codedebrief/analysis/registry.py +320 -0
  18. codedebrief/analysis/treesitter.py +884 -0
  19. codedebrief/analysis/typescript.py +1019 -0
  20. codedebrief/artifacts.py +49 -0
  21. codedebrief/cli.py +585 -0
  22. codedebrief/config.py +226 -0
  23. codedebrief/doctor.py +175 -0
  24. codedebrief/install.py +441 -0
  25. codedebrief/mcp_server.py +2720 -0
  26. codedebrief/model.py +189 -0
  27. codedebrief/py.typed +1 -0
  28. codedebrief/quality.py +392 -0
  29. codedebrief/query.py +641 -0
  30. codedebrief/render/__init__.py +6 -0
  31. codedebrief/render/assets/generated/codedebrief-viewer-runtime.iife.js +10 -0
  32. codedebrief/render/assets/panels.js +462 -0
  33. codedebrief/render/assets/shell.js +1649 -0
  34. codedebrief/render/assets/styles.css +1715 -0
  35. codedebrief/render/assets/tree.js +616 -0
  36. codedebrief/render/html.py +191 -0
  37. codedebrief/render/markdown.py +153 -0
  38. codedebrief/render/payload.py +326 -0
  39. codedebrief/render/snapshot.py +769 -0
  40. codedebrief/schema/codedebrief.schema.json +449 -0
  41. codedebrief/util.py +65 -0
  42. codedebrief/validation.py +214 -0
  43. codedebrief-0.11.0.dist-info/METADATA +426 -0
  44. codedebrief-0.11.0.dist-info/RECORD +48 -0
  45. codedebrief-0.11.0.dist-info/WHEEL +4 -0
  46. codedebrief-0.11.0.dist-info/entry_points.txt +2 -0
  47. codedebrief-0.11.0.dist-info/licenses/LICENSE +176 -0
  48. codedebrief-0.11.0.dist-info/licenses/NOTICE +9 -0
@@ -0,0 +1,1649 @@
1
+
2
+ const model = JSON.parse(document.getElementById("codedebrief-data").textContent);
3
+ const flows = model.flows || [];
4
+ const byId = new Map(flows.map(flow => [flow.id, flow]));
5
+
6
+ // Shared surface other inlined scripts (tree.js, future panels) bind to. The left
7
+ // rail is owned by tree.js now, so the app shell exposes flow selection here.
8
+ const codeDebrief = (window.CodeDebrief = window.CodeDebrief || {});
9
+ codeDebrief.model = model;
10
+ codeDebrief.flows = flows;
11
+ codeDebrief.byId = byId;
12
+ // The generated HTML now has one official chart renderer: the typed React runtime.
13
+ // The shell still owns shared selection, rails, tree, source details, and toolbar
14
+ // buttons, but it no longer routes users into the retired static renderer.
15
+ codeDebrief.mode = "react";
16
+
17
+ // --- Shared selection store (Phase 4) ---------------------------------------
18
+ // ONE selection model, ONE accent color. Every surface -- a canvas decision block,
19
+ // a source line, a tree file/flow row -- both PUBLISHES into
20
+ // this store (via codeDebrief.select) and SUBSCRIBES to it (via codeDebrief.onSelection) so selecting
21
+ // any one highlights the others. The store holds only ids; each surface maps ids to
22
+ // its own DOM. shell.js drives the canvas highlight (its existing job); panels.js
23
+ // renders the details panels and the tree reflects the active file/flow.
24
+ const selection = {
25
+ path: null,
26
+ flowId: null,
27
+ nodeId: null,
28
+ scope: null,
29
+ edgeId: null,
30
+ line: null,
31
+ endLine: null,
32
+ };
33
+ const selectionSubscribers = [];
34
+ // Re-entrancy guard: a subscriber that calls back into select() must not recurse the
35
+ // notify loop; coalesce to one pass.
36
+ let notifyingSelection = false;
37
+ codeDebrief.selection = selection;
38
+ codeDebrief.onSelection = function (fn) {
39
+ if (typeof fn === "function") selectionSubscribers.push(fn);
40
+ };
41
+ // Merge a partial selection and notify every surface. Passing a key as `undefined`
42
+ // leaves it untouched; pass `null` to explicitly clear it. Always carries the full
43
+ // resolved selection object to subscribers.
44
+ codeDebrief.select = function (partial) {
45
+ partial = partial || {};
46
+ const keys = ["path", "flowId", "nodeId", "scope", "edgeId", "line", "endLine"];
47
+ const explicitEdge = Object.prototype.hasOwnProperty.call(partial, "edgeId");
48
+ const clearsEdge = !explicitEdge && keys.some(key =>
49
+ key !== "edgeId" && Object.prototype.hasOwnProperty.call(partial, key)
50
+ );
51
+ keys.forEach(key => {
52
+ if (Object.prototype.hasOwnProperty.call(partial, key) && partial[key] !== undefined) {
53
+ selection[key] = partial[key];
54
+ }
55
+ });
56
+ if (clearsEdge) selection.edgeId = null;
57
+ if (notifyingSelection) return;
58
+ notifyingSelection = true;
59
+ try {
60
+ selectionSubscribers.forEach(fn => {
61
+ try { fn(selection); } catch (_) {}
62
+ });
63
+ } finally {
64
+ notifyingSelection = false;
65
+ }
66
+ };
67
+
68
+ const svg =
69
+ document.getElementById("canvas") ||
70
+ document.createElementNS("http://www.w3.org/2000/svg", "svg");
71
+ const rightRail = document.getElementById("rightRail");
72
+ const leftRail = document.getElementById("leftRail");
73
+ const detailButton = document.getElementById("detailButton");
74
+ const detailsClose = document.getElementById("detailsClose");
75
+ const detailsCollapseAll = document.getElementById("detailsCollapseAll");
76
+ const detailsExpandAll = document.getElementById("detailsExpandAll");
77
+ const menuButton = document.getElementById("menuButton");
78
+ const typedViewerHost = document.getElementById("typedViewerHost");
79
+ const exportPngButton = document.getElementById("exportPng");
80
+ const exportJpgButton = document.getElementById("exportJpg");
81
+ const railWidths = { left: 312, right: 336 };
82
+ const railConfig = {
83
+ left: {
84
+ css: "--left-rail-width",
85
+ storage: "codedebrief-left-rail-width",
86
+ handle: document.getElementById("leftRailResizer"),
87
+ min: 240,
88
+ max: 560,
89
+ },
90
+ right: {
91
+ css: "--right-rail-width",
92
+ storage: "codedebrief-right-rail-width",
93
+ handle: document.getElementById("rightRailResizer"),
94
+ min: 280,
95
+ max: 640,
96
+ },
97
+ };
98
+ let activeFlow = null;
99
+ let view = { x: 0, y: 0, width: 1000, height: 800 };
100
+ let drag = null;
101
+ let railResize = null;
102
+ let railRefreshFrame = 0;
103
+ // Per-flow hand-placed node positions: flowId -> Map(nodeId -> {x, y}). Survives
104
+ // navigating away and back within the session.
105
+ const manualPositions = new Map();
106
+ // Element references for the currently rendered flow, for selection highlighting.
107
+ let currentRender = null;
108
+ const FLOW_NODE_HALF_W = 145;
109
+ const FLOW_RECT_HALF_H = 43;
110
+ const FLOW_DECISION_HALF_H = 58;
111
+ const FLOW_META_BOTTOM = 78;
112
+ const FLOW_NODE_HALF_H = 92;
113
+ const FLOW_LAYER_Y = 230;
114
+ const FLOW_SIBLING_X = 360;
115
+ const FLOW_MIN_X_GAP = 330;
116
+ const EDGE_START_CLEARANCE = 30;
117
+ const EDGE_END_CLEARANCE = 10;
118
+
119
+ function setCanvasLevel(level) {
120
+ const value = String(level);
121
+ svg.setAttribute("data-level", value);
122
+ document.body.dataset.canvasLevel = value;
123
+ }
124
+
125
+ function setLeftRailOpen(open) {
126
+ leftRail.classList.toggle("open", !!open);
127
+ document.body.toggleAttribute("data-nav-open", !!open);
128
+ document.body.toggleAttribute("data-nav-closed", !open);
129
+ syncRailControls();
130
+ scheduleCanvasLayoutRefresh();
131
+ }
132
+
133
+ function setRightRailOpen(open) {
134
+ rightRail.classList.toggle("open", !!open);
135
+ document.body.toggleAttribute("data-detail-open", !!open);
136
+ document.body.toggleAttribute("data-detail-closed", !open);
137
+ if (detailButton) {
138
+ detailButton.setAttribute("aria-pressed", open ? "true" : "false");
139
+ detailButton.title = open ? "Hide source and details" : "Show source and details";
140
+ }
141
+ syncRailControls();
142
+ scheduleCanvasLayoutRefresh();
143
+ }
144
+
145
+ function leftRailOpen() {
146
+ if (window.innerWidth <= 700) return leftRail.classList.contains("open");
147
+ return !document.body.hasAttribute("data-nav-closed");
148
+ }
149
+
150
+ function rightRailOpen() {
151
+ if (window.innerWidth <= 1050) return rightRail.classList.contains("open");
152
+ return !document.body.hasAttribute("data-detail-closed");
153
+ }
154
+
155
+ function syncRailControls() {
156
+ const navOpen = leftRailOpen();
157
+ const detailOpen = rightRailOpen();
158
+ if (menuButton) {
159
+ menuButton.setAttribute("aria-pressed", navOpen ? "true" : "false");
160
+ menuButton.title = navOpen ? "Hide codebase tree" : "Show codebase tree";
161
+ }
162
+ if (detailButton) {
163
+ detailButton.setAttribute("aria-pressed", detailOpen ? "true" : "false");
164
+ detailButton.title = detailOpen ? "Hide source and details" : "Show source and details";
165
+ }
166
+ }
167
+
168
+ function eventTargetIsTextInput(event) {
169
+ const target = event.target;
170
+ return !!(target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName || ""));
171
+ }
172
+
173
+ function railViewportMax(side) {
174
+ const cfg = railConfig[side];
175
+ if (!cfg) return 0;
176
+ if (window.innerWidth <= 1050) return cfg.max;
177
+ const other = side === "left" ? railWidths.right : railWidths.left;
178
+ const maxFromViewport = window.innerWidth - other - 460;
179
+ return Math.max(cfg.min, Math.min(cfg.max, maxFromViewport));
180
+ }
181
+
182
+ function clampRailWidth(side, value) {
183
+ const cfg = railConfig[side];
184
+ if (!cfg) return 0;
185
+ const max = railViewportMax(side);
186
+ return Math.min(max, Math.max(cfg.min, Math.round(value)));
187
+ }
188
+
189
+ function applyRailWidth(side, value, persist) {
190
+ const cfg = railConfig[side];
191
+ if (!cfg) return;
192
+ const width = clampRailWidth(side, value);
193
+ railWidths[side] = width;
194
+ document.documentElement.style.setProperty(cfg.css, `${width}px`);
195
+ if (cfg.handle) {
196
+ cfg.handle.setAttribute("aria-valuenow", String(width));
197
+ cfg.handle.setAttribute("aria-valuemax", String(railViewportMax(side)));
198
+ }
199
+ if (persist) {
200
+ try { localStorage.setItem(cfg.storage, String(width)); } catch (_) {}
201
+ }
202
+ }
203
+
204
+ function loadStoredRailWidths() {
205
+ Object.keys(railConfig).forEach(side => {
206
+ const cfg = railConfig[side];
207
+ let stored = null;
208
+ try { stored = localStorage.getItem(cfg.storage); } catch (_) {}
209
+ const parsed = stored === null ? NaN : Number(stored);
210
+ applyRailWidth(side, Number.isFinite(parsed) ? parsed : railWidths[side], false);
211
+ });
212
+ }
213
+
214
+ function scheduleCanvasLayoutRefresh() {
215
+ if (railRefreshFrame) return;
216
+ railRefreshFrame = requestAnimationFrame(() => {
217
+ railRefreshFrame = 0;
218
+ if (codeDebrief.refreshCanvasLayout) codeDebrief.refreshCanvasLayout();
219
+ else if (codeDebrief.updateViewBox) codeDebrief.updateViewBox();
220
+ });
221
+ }
222
+
223
+ function resizeRailFromPointer(event) {
224
+ if (!railResize) return;
225
+ const dx = event.clientX - railResize.startX;
226
+ const next = railResize.startWidth + (railResize.side === "left" ? dx : -dx);
227
+ applyRailWidth(railResize.side, next, true);
228
+ scheduleCanvasLayoutRefresh();
229
+ event.preventDefault();
230
+ }
231
+
232
+ function endRailResize() {
233
+ if (!railResize) return;
234
+ const cfg = railConfig[railResize.side];
235
+ if (cfg && cfg.handle) cfg.handle.removeAttribute("aria-grabbed");
236
+ railResize = null;
237
+ document.body.removeAttribute("data-rail-resizing");
238
+ window.removeEventListener("pointermove", resizeRailFromPointer);
239
+ window.removeEventListener("pointerup", endRailResize);
240
+ window.removeEventListener("pointercancel", endRailResize);
241
+ scheduleCanvasLayoutRefresh();
242
+ }
243
+
244
+ function beginRailResize(side, event) {
245
+ if (event.button !== 0) return;
246
+ const cfg = railConfig[side];
247
+ if (!cfg || !cfg.handle) return;
248
+ railResize = { side, startX: event.clientX, startWidth: railWidths[side] };
249
+ document.body.dataset.railResizing = side;
250
+ cfg.handle.setAttribute("aria-grabbed", "true");
251
+ window.addEventListener("pointermove", resizeRailFromPointer);
252
+ window.addEventListener("pointerup", endRailResize);
253
+ window.addEventListener("pointercancel", endRailResize);
254
+ event.preventDefault();
255
+ }
256
+
257
+ function resizeRailFromKeyboard(side, event) {
258
+ const cfg = railConfig[side];
259
+ if (!cfg) return;
260
+ let next = railWidths[side];
261
+ if (event.key === "Home") next = cfg.min;
262
+ else if (event.key === "End") next = railViewportMax(side);
263
+ else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
264
+ const physical = event.key === "ArrowRight" ? 1 : -1;
265
+ next += (side === "left" ? physical : -physical) * 24;
266
+ } else {
267
+ return;
268
+ }
269
+ applyRailWidth(side, next, true);
270
+ scheduleCanvasLayoutRefresh();
271
+ event.preventDefault();
272
+ }
273
+
274
+ function fitRailsToViewport() {
275
+ applyRailWidth("left", railWidths.left, false);
276
+ applyRailWidth("right", railWidths.right, false);
277
+ }
278
+
279
+ function initRailResizers() {
280
+ Object.keys(railConfig).forEach(side => {
281
+ const cfg = railConfig[side];
282
+ if (!cfg.handle) return;
283
+ cfg.handle.addEventListener("pointerdown", event => beginRailResize(side, event));
284
+ cfg.handle.addEventListener("keydown", event => resizeRailFromKeyboard(side, event));
285
+ });
286
+ window.addEventListener("resize", () => {
287
+ fitRailsToViewport();
288
+ syncRailControls();
289
+ scheduleCanvasLayoutRefresh();
290
+ });
291
+ }
292
+
293
+ function panelTitle(panel) {
294
+ const title = panel && panel.querySelector ? panel.querySelector(".rail-title") : null;
295
+ return (title && title.textContent ? title.textContent.trim() : "section") || "section";
296
+ }
297
+
298
+ function setPanelCollapsed(panel, button, body, collapsed, persist) {
299
+ const title = panelTitle(panel);
300
+ const heading = panel.querySelector("[data-panel-heading]");
301
+ panel.toggleAttribute("data-collapsed", collapsed);
302
+ button.setAttribute("aria-expanded", collapsed ? "false" : "true");
303
+ button.title = (collapsed ? "Expand " : "Collapse ") + title;
304
+ button.setAttribute("aria-label", (collapsed ? "Expand " : "Collapse ") + title);
305
+ if (heading) {
306
+ heading.setAttribute("aria-expanded", collapsed ? "false" : "true");
307
+ heading.title = (collapsed ? "Expand " : "Collapse ") + title;
308
+ }
309
+ if (body) body.hidden = collapsed;
310
+ if (persist) {
311
+ const key = panel.getAttribute("data-panel-state") || panel.id || body && body.id;
312
+ if (key) {
313
+ try { localStorage.setItem("codedebrief-panel-collapsed-" + key, collapsed ? "true" : "false"); } catch (_) {}
314
+ }
315
+ }
316
+ }
317
+
318
+ function initCollapsiblePanels() {
319
+ const panels = Array.from(document.querySelectorAll("[data-collapsible-panel]"));
320
+ function panelParts(panel) {
321
+ const button = panel.querySelector("[data-panel-toggle]");
322
+ if (!button) return null;
323
+ const bodyId = button.getAttribute("aria-controls");
324
+ const body = bodyId ? document.getElementById(bodyId) : null;
325
+ return { body, bodyId, button };
326
+ }
327
+ function setAllPanelsCollapsed(collapsed) {
328
+ panels.forEach(panel => {
329
+ const parts = panelParts(panel);
330
+ if (!parts) return;
331
+ setPanelCollapsed(panel, parts.button, parts.body, collapsed, true);
332
+ });
333
+ }
334
+ panels.forEach(panel => {
335
+ const parts = panelParts(panel);
336
+ if (!parts) return;
337
+ const { body, bodyId, button } = parts;
338
+ const heading = panel.querySelector("[data-panel-heading]");
339
+ const key = panel.getAttribute("data-panel-state") || panel.id || bodyId;
340
+ let stored = null;
341
+ if (heading) {
342
+ heading.setAttribute("role", "button");
343
+ heading.setAttribute("tabindex", "0");
344
+ if (bodyId) heading.setAttribute("aria-controls", bodyId);
345
+ heading.setAttribute("aria-label", "Toggle " + panelTitle(panel));
346
+ }
347
+ if (key) {
348
+ try { stored = localStorage.getItem("codedebrief-panel-collapsed-" + key); } catch (_) {}
349
+ }
350
+ setPanelCollapsed(panel, button, body, stored === "true", false);
351
+ button.addEventListener("click", event => {
352
+ event.preventDefault();
353
+ setPanelCollapsed(panel, button, body, !panel.hasAttribute("data-collapsed"), true);
354
+ });
355
+ if (heading) {
356
+ heading.addEventListener("click", event => {
357
+ const target = event.target;
358
+ if (target && target.closest && target.closest("button, a, input, select, textarea")) return;
359
+ setPanelCollapsed(panel, button, body, !panel.hasAttribute("data-collapsed"), true);
360
+ });
361
+ heading.addEventListener("keydown", event => {
362
+ const target = event.target;
363
+ if (target && target.closest && target.closest("button, a, input, select, textarea")) return;
364
+ if (event.key !== "Enter" && event.key !== " ") return;
365
+ event.preventDefault();
366
+ setPanelCollapsed(panel, button, body, !panel.hasAttribute("data-collapsed"), true);
367
+ });
368
+ }
369
+ });
370
+ if (detailsCollapseAll) {
371
+ detailsCollapseAll.addEventListener("click", () => setAllPanelsCollapsed(true));
372
+ }
373
+ if (detailsExpandAll) {
374
+ detailsExpandAll.addEventListener("click", () => setAllPanelsCollapsed(false));
375
+ }
376
+ }
377
+
378
+ loadStoredRailWidths();
379
+ initRailResizers();
380
+ initCollapsiblePanels();
381
+ setRightRailOpen(false);
382
+ syncRailControls();
383
+
384
+ document.getElementById("flowCount").textContent = flows.length;
385
+ document.getElementById("entryCount").textContent = flows.filter(item => item.is_entrypoint).length;
386
+
387
+ function headerFlowKind(flow) {
388
+ return `${flow.entry_kind} · ${flow.language} · ${flow.framework}`;
389
+ }
390
+
391
+ function setHeaderFlow(flow) {
392
+ activeFlow = flow || null;
393
+ if (!flow) return;
394
+ document.getElementById("flowTitle").textContent = flow.name;
395
+ document.getElementById("flowKind").textContent = headerFlowKind(flow);
396
+ }
397
+
398
+ function setHeaderScope(scope) {
399
+ activeFlow = null;
400
+ document.getElementById("flowTitle").textContent = scope || "codebase";
401
+ document.getElementById("flowKind").textContent = "scope";
402
+ }
403
+
404
+ function setHeaderRoot() {
405
+ activeFlow = null;
406
+ document.getElementById("flowTitle").textContent = "Codebase";
407
+ document.getElementById("flowKind").textContent = "progressive flowchart";
408
+ }
409
+
410
+ function selectionForFlow(flow) {
411
+ return {
412
+ edgeId: null,
413
+ endLine: flow.location?.end_line ?? flow.location?.start_line ?? null,
414
+ flowId: flow.id,
415
+ line: flow.location?.start_line ?? null,
416
+ nodeId: null,
417
+ path: flow.location?.path || null,
418
+ scope: null,
419
+ };
420
+ }
421
+
422
+ // Entry points first, then by name. Shared so the tree lists a file's flows in the
423
+ // same order the old flat list used.
424
+ codeDebrief.sortFlows = list =>
425
+ [...list].sort(
426
+ (a, b) => Number(b.is_entrypoint) - Number(a.is_entrypoint) || a.name.localeCompare(b.name)
427
+ );
428
+
429
+ // Updates the header + the active-flow bookkeeping shared by the tree and details.
430
+ // Rendering belongs to the typed React runtime; the shell only delegates selection and
431
+ // keeps the surrounding HTML controls synchronized.
432
+ function selectFlow(flowId) {
433
+ const flow = byId.get(flowId);
434
+ if (!flow) return;
435
+ setHeaderFlow(flow);
436
+ const typed = activeTypedViewer();
437
+ if (typed && typeof typed.selectFlow === "function") {
438
+ typed.selectFlow(flow.id);
439
+ } else {
440
+ location.hash = "flow=" + encodeURIComponent(flow.id);
441
+ codeDebrief.select(selectionForFlow(flow));
442
+ }
443
+ // On phones the tree is a drawer, so a selection should clear the canvas. On
444
+ // desktop/tablet the tree is working context; keep it open unless the user closes it.
445
+ if (window.innerWidth <= 700) setLeftRailOpen(false);
446
+ // Let other inlined scripts (e.g. tree.js) reflect the active flow.
447
+ if (window.CodeDebrief.onFlowSelected) window.CodeDebrief.onFlowSelected(flow);
448
+ }
449
+
450
+ function flowLayoutNodes(flow, opts) {
451
+ opts = opts || {};
452
+ return (flow.nodes || []).filter(node => !(opts.omitEntry && node.kind === "entry"));
453
+ }
454
+
455
+ function flowLayers(nodes, edges, incoming, outgoing, order) {
456
+ const nodeById = new Map(nodes.map(node => [node.id, node]));
457
+ const indegree = new Map(nodes.map(node => [node.id, 0]));
458
+ const layerById = new Map(nodes.map(node => [node.id, 0]));
459
+ edges.forEach(edge => {
460
+ if (edge.source === edge.target) return;
461
+ indegree.set(edge.target, (indegree.get(edge.target) || 0) + 1);
462
+ });
463
+
464
+ const queue = nodes
465
+ .filter(node => (indegree.get(node.id) || 0) === 0)
466
+ .sort((a, b) => order.get(a.id) - order.get(b.id));
467
+ const visited = new Set();
468
+
469
+ while (queue.length) {
470
+ const node = queue.shift();
471
+ if (!node || visited.has(node.id)) continue;
472
+ visited.add(node.id);
473
+ (outgoing.get(node.id) || []).forEach(edge => {
474
+ const nextLayer = (layerById.get(node.id) || 0) + 1;
475
+ layerById.set(edge.target, Math.max(layerById.get(edge.target) || 0, nextLayer));
476
+ indegree.set(edge.target, (indegree.get(edge.target) || 0) - 1);
477
+ if ((indegree.get(edge.target) || 0) === 0) {
478
+ const target = nodeById.get(edge.target);
479
+ if (target) queue.push(target);
480
+ }
481
+ });
482
+ queue.sort((a, b) => order.get(a.id) - order.get(b.id));
483
+ }
484
+
485
+ // Cycles/backedges are valid in real control flow. Keep those nodes visible by
486
+ // assigning a stable fallback layer from any already-known parents instead of assuming
487
+ // the payload was topologically sorted.
488
+ nodes.forEach(node => {
489
+ if (visited.has(node.id)) return;
490
+ const parentLayers = (incoming.get(node.id) || [])
491
+ .map(edge => layerById.get(edge.source))
492
+ .filter(value => Number.isFinite(value));
493
+ if (parentLayers.length) {
494
+ layerById.set(node.id, Math.max(layerById.get(node.id) || 0, Math.max(...parentLayers) + 1));
495
+ }
496
+ });
497
+
498
+ return layerById;
499
+ }
500
+
501
+ function layoutFlow(flow, opts) {
502
+ opts = opts || {};
503
+ const nodes = flowLayoutNodes(flow, opts);
504
+ const visibleIds = new Set(nodes.map(node => node.id));
505
+ const order = new Map(nodes.map((node, index) => [node.id, index]));
506
+ const incoming = new Map(nodes.map(node => [node.id, []]));
507
+ const outgoing = new Map(nodes.map(node => [node.id, []]));
508
+ const edges = (flow.edges || []).filter(edge =>
509
+ visibleIds.has(edge.source) && visibleIds.has(edge.target)
510
+ );
511
+ edges.forEach(edge => incoming.get(edge.target)?.push(edge));
512
+ edges.forEach(edge => outgoing.get(edge.source)?.push(edge));
513
+ const layerById = flowLayers(nodes, edges, incoming, outgoing, order);
514
+ const positions = new Map();
515
+ const layerCounts = new Map();
516
+
517
+ [...nodes]
518
+ .sort((a, b) =>
519
+ (layerById.get(a.id) || 0) - (layerById.get(b.id) || 0) ||
520
+ order.get(a.id) - order.get(b.id)
521
+ )
522
+ .forEach(node => {
523
+ const parents = (incoming.get(node.id) || []).filter(edge => positions.has(edge.source));
524
+ const layer = layerById.get(node.id) || 0;
525
+ let x = 0;
526
+ if (parents.length) {
527
+ const parentXs = parents.map(edge => positions.get(edge.source)?.x || 0);
528
+ x = parentXs.reduce((sum, value) => sum + value, 0) / parentXs.length;
529
+ if (parents.length === 1) {
530
+ const parentEdge = parents[0];
531
+ const siblings = outgoing.get(parentEdge.source) || [];
532
+ if (siblings.length > 1) {
533
+ const siblingIndex = siblings.findIndex(edge => edge.target === node.id);
534
+ const centeredIndex = siblingIndex - (siblings.length - 1) / 2;
535
+ x = (positions.get(parentEdge.source)?.x || 0) + centeredIndex * FLOW_SIBLING_X;
536
+ } else {
537
+ const branch = parentEdge.label?.toLowerCase();
538
+ if (["yes", "success"].includes(branch)) x -= FLOW_SIBLING_X / 2;
539
+ if (["no", "error"].includes(branch)) x += FLOW_SIBLING_X / 2;
540
+ }
541
+ }
542
+ }
543
+ const occupied = layerCounts.get(layer) || [];
544
+ while (occupied.some(value => Math.abs(value - x) < FLOW_MIN_X_GAP)) x += FLOW_SIBLING_X;
545
+ occupied.push(x);
546
+ layerCounts.set(layer, occupied);
547
+ positions.set(node.id, { x, y: layer * FLOW_LAYER_Y, layer, order: order.get(node.id) || 0 });
548
+ });
549
+
550
+ // Apply any hand-placed overrides for this flow before measuring bounds.
551
+ const overrides = manualPositions.get(flow.id);
552
+ if (overrides) {
553
+ overrides.forEach((point, nodeId) => {
554
+ const position = positions.get(nodeId);
555
+ if (position) { position.x = point.x; position.y = point.y; position.moved = true; }
556
+ });
557
+ }
558
+
559
+ const values = [...positions.values()];
560
+ if (!values.length) {
561
+ return { positions, bounds: { minX: 0, maxX: 0, minY: 0, maxY: 0 }, nodes, edges };
562
+ }
563
+ const minX = Math.min(...values.map(item => item.x), 0);
564
+ const maxX = Math.max(...values.map(item => item.x), 0);
565
+ const minY = Math.min(...values.map(item => item.y), 0);
566
+ const maxY = Math.max(...values.map(item => item.y), 0);
567
+ return { positions, bounds: { minX, maxX, minY, maxY }, nodes, edges };
568
+ }
569
+
570
+ function nodeHalfHeight(kind) {
571
+ return kind === "decision" ? FLOW_DECISION_HALF_H : FLOW_RECT_HALF_H;
572
+ }
573
+
574
+ function horizontalLabelX(startX, endX) {
575
+ const dx = endX - startX;
576
+ if (Math.abs(dx) < 24) return startX + 7;
577
+ return startX + dx * 0.34;
578
+ }
579
+
580
+ // Single source for an edge's orthogonal path + label anchor, reused on first render
581
+ // and live during a node drag so connected edges follow. The path is intentionally
582
+ // flowchart-like: leave through a lower port, travel on a horizontal branch lane, then
583
+ // enter the target from above.
584
+ function edgeGeometry(start, end, startKind, endKind) {
585
+ const startPortY = start.y + nodeHalfHeight(startKind);
586
+ const endPortY = end.y - nodeHalfHeight(endKind);
587
+ const verticalRoom = endPortY - startPortY;
588
+ const branchY = verticalRoom > EDGE_START_CLEARANCE + EDGE_END_CLEARANCE + 20
589
+ ? startPortY + EDGE_START_CLEARANCE
590
+ : startPortY + verticalRoom / 2;
591
+ const labelIsExitChip = startKind === "decision";
592
+ const curveY = Math.max(90, Math.abs(endPortY - startPortY) * 0.55);
593
+ return {
594
+ d: `M ${start.x} ${startPortY} L ${start.x} ${branchY} L ${end.x} ${branchY} L ${end.x} ${endPortY}`,
595
+ focusD: `M ${start.x} ${startPortY} C ${start.x} ${startPortY + curveY}, ${end.x} ${endPortY - curveY}, ${end.x} ${endPortY}`,
596
+ points: [
597
+ { x: start.x, y: startPortY },
598
+ { x: start.x, y: branchY },
599
+ { x: end.x, y: branchY },
600
+ { x: end.x, y: endPortY },
601
+ ],
602
+ labelX: labelIsExitChip ? horizontalLabelX(start.x, end.x) : (start.x + end.x) / 2 + 7,
603
+ labelY: branchY - 8,
604
+ exitChip: labelIsExitChip,
605
+ };
606
+ }
607
+
608
+ function bindEdgeActivationParts(group, activate) {
609
+ if (!group || !activate) return;
610
+ group.querySelectorAll("*").forEach(part => {
611
+ part.addEventListener("click", activate);
612
+ });
613
+ }
614
+
615
+ function setEdgeHitGeometry(hit, geometry, activate) {
616
+ hit.replaceChildren();
617
+ const points = geometry.points || [];
618
+ const pad = 10;
619
+ for (let index = 0; index < points.length - 1; index += 1) {
620
+ const a = points[index];
621
+ const b = points[index + 1];
622
+ const rect = svgEl("rect");
623
+ rect.setAttribute("class", "edge-hit-segment");
624
+ rect.setAttribute("x", String(Math.min(a.x, b.x) - pad));
625
+ rect.setAttribute("y", String(Math.min(a.y, b.y) - pad));
626
+ rect.setAttribute("width", String(Math.max(Math.abs(a.x - b.x), 1) + pad * 2));
627
+ rect.setAttribute("height", String(Math.max(Math.abs(a.y - b.y), 1) + pad * 2));
628
+ rect.setAttribute("rx", "10");
629
+ if (activate) rect.addEventListener("click", activate);
630
+ hit.appendChild(rect);
631
+ }
632
+ }
633
+
634
+ function edgeLabel(text, geometry) {
635
+ const value = String(text);
636
+ const width = Math.max(30, value.length * 7 + 18);
637
+ const group = svgEl("g");
638
+ group.setAttribute("class", `edge-label-wrap${geometry.exitChip ? " branch-exit-chip" : ""}`);
639
+ group.setAttribute("transform", `translate(${geometry.labelX} ${geometry.labelY})`);
640
+ const bg = svgEl("rect");
641
+ bg.setAttribute("class", "edge-label-bg");
642
+ bg.setAttribute("x", String(-width / 2));
643
+ bg.setAttribute("y", "-12");
644
+ bg.setAttribute("width", String(width));
645
+ bg.setAttribute("height", "20");
646
+ bg.setAttribute("rx", "10");
647
+ const label = svgEl("text");
648
+ label.setAttribute("class", "edge-label");
649
+ label.setAttribute("text-anchor", "middle");
650
+ label.setAttribute("y", "4");
651
+ label.textContent = value;
652
+ group.append(bg, label);
653
+ return group;
654
+ }
655
+
656
+ function nodeKindBadge(kind) {
657
+ const labelText = kind === "terminal" ? "outcome" : String(kind || "node");
658
+ const width = Math.max(48, labelText.length * 6 + 16);
659
+ const group = svgEl("g");
660
+ group.setAttribute("class", "node-kind-badge");
661
+ group.setAttribute("transform", "translate(0 -30)");
662
+ const bg = svgEl("rect");
663
+ bg.setAttribute("x", String(-width / 2));
664
+ bg.setAttribute("y", "-9");
665
+ bg.setAttribute("width", String(width));
666
+ bg.setAttribute("height", "18");
667
+ bg.setAttribute("rx", "9");
668
+ const text = svgEl("text");
669
+ text.setAttribute("text-anchor", "middle");
670
+ text.setAttribute("y", "4");
671
+ text.textContent = labelText;
672
+ group.append(bg, text);
673
+ return group;
674
+ }
675
+
676
+ // Decision-flow defs (shadow filters + arrow marker). The visible chart is rendered
677
+ // by the React runtime; these helpers remain for shared source/export compatibility.
678
+ function flowDefs() {
679
+ const defs = svgEl("defs");
680
+ defs.innerHTML = `
681
+ <filter id="nodeShadow" x="-30%" y="-30%" width="160%" height="180%">
682
+ <feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".10"/>
683
+ </filter>
684
+ <filter id="nodeLift" x="-45%" y="-45%" width="190%" height="210%">
685
+ <feDropShadow dx="0" dy="16" stdDeviation="14" flood-color="#000" flood-opacity=".22"/>
686
+ </filter>
687
+ <marker id="arrow" markerWidth="6.5" markerHeight="6.5" refX="5.7" refY="3.25" viewBox="0 0 6.5 6.5" orient="auto">
688
+ <path class="arrow" d="M0,0 L6.5,3.25 L0,6.5 z"></path>
689
+ </marker>
690
+ <marker id="arrowFocus" markerWidth="6.5" markerHeight="6.5" refX="5.7" refY="3.25" viewBox="0 0 6.5 6.5" orient="auto">
691
+ <path class="arrow-focus" d="M0,0 L6.5,3.25 L0,6.5 z"></path>
692
+ </marker>`;
693
+ return defs;
694
+ }
695
+
696
+ // Reusable decision-graph renderer retained for shared source/export compatibility.
697
+ // It draws `flow`'s nodes/edges into a fresh <g> layer and returns it without touching
698
+ // the SVG, the global `view`, or data-level.
699
+ //
700
+ // opts.originX/originY translate the whole sub-graph (layoutFlow is origin-relative;
701
+ // the caller places it). opts.spine adds the decision spine (full-screen only). Drag
702
+ // and the bidirectional highlight keep working in both modes: drag uses the shared
703
+ // `view` scale (same SVG world units inline or full), and the returned record is set
704
+ // as `currentRender` so inspectNode -> highlightNode lights up incident edges/nodes.
705
+ function drawFlowGraph(flow, opts) {
706
+ opts = opts || {};
707
+ const originX = opts.originX || 0;
708
+ const originY = opts.originY || 0;
709
+ const { positions, bounds, nodes, edges } = layoutFlow(flow, opts);
710
+ const layer = svgEl("g");
711
+ if (opts.layerClass) layer.setAttribute("class", opts.layerClass);
712
+
713
+ if (opts.spine) {
714
+ const spine = svgEl("line");
715
+ spine.setAttribute("class", "decision-spine");
716
+ spine.setAttribute("x1", String(originX));
717
+ spine.setAttribute("y1", String(originY - 20));
718
+ spine.setAttribute("x2", String(originX));
719
+ spine.setAttribute("y2", String(originY + bounds.maxY + 100));
720
+ layer.appendChild(spine);
721
+ }
722
+
723
+ const at = id => {
724
+ const p = positions.get(id);
725
+ return p ? { x: p.x + originX, y: p.y + originY } : null;
726
+ };
727
+ const flowNodeById = new Map(nodes.map(node => [node.id, node]));
728
+ const edgeRecordId = edge => edge.id || `${edge.source}->${edge.target}`;
729
+
730
+ // Keep edge element references per node so dragging a block re-routes its edges live,
731
+ // and a flat list so selecting a node can highlight its incident edges.
732
+ const nodeEdges = new Map(nodes.map(node => [node.id, []]));
733
+ const edgeRecords = [];
734
+ const edgeLayer = svgEl("g");
735
+ const edgePathLayer = svgEl("g");
736
+ const edgeLabelLayer = svgEl("g");
737
+ edges.forEach(edge => {
738
+ const start = at(edge.source);
739
+ const end = at(edge.target);
740
+ if (!start || !end) return;
741
+ const sourceNode = flowNodeById.get(edge.source);
742
+ const targetNode = flowNodeById.get(edge.target);
743
+ const geometry = edgeGeometry(start, end, sourceNode?.kind, targetNode?.kind);
744
+ let hit = svgEl("g");
745
+ let label = null;
746
+ const activateEdge = event => {
747
+ event.stopPropagation();
748
+ if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
749
+ if (codeDebrief.openDetails) codeDebrief.openDetails();
750
+ codeDebrief.select({
751
+ flowId: flow.id,
752
+ path: flow.location.path,
753
+ nodeId: null,
754
+ edgeId: edgeRecordId(edge),
755
+ line: flow.location.start_line || null,
756
+ endLine: flow.location.end_line || flow.location.start_line || null,
757
+ });
758
+ };
759
+ hit.setAttribute("class", "edge-hit");
760
+ setEdgeHitGeometry(hit, geometry, activateEdge);
761
+ hit.setAttribute("data-edge-id", edgeRecordId(edge));
762
+ hit.setAttribute("data-source-node-id", edge.source);
763
+ hit.setAttribute("data-target-node-id", edge.target);
764
+ const path = svgEl("path");
765
+ path.setAttribute("class", "edge");
766
+ path.setAttribute("d", geometry.d);
767
+ path.setAttribute("tabindex", "0");
768
+ path.setAttribute("role", "button");
769
+ path.setAttribute("aria-label", `link from ${sourceNode?.label || edge.source} to ${targetNode?.label || edge.target}`);
770
+ path.setAttribute("data-edge-id", edgeRecordId(edge));
771
+ path.setAttribute("data-source-node-id", edge.source);
772
+ path.setAttribute("data-target-node-id", edge.target);
773
+ edgePathLayer.appendChild(path);
774
+ const focusPath = svgEl("path");
775
+ focusPath.setAttribute("class", "edge-focus");
776
+ focusPath.setAttribute("d", geometry.focusD || geometry.d);
777
+ edgePathLayer.appendChild(focusPath);
778
+ edgePathLayer.appendChild(hit);
779
+ if (edge.label) {
780
+ label = edgeLabel(edge.label, geometry);
781
+ label.setAttribute("role", "button");
782
+ label.setAttribute("tabindex", "0");
783
+ label.setAttribute("data-edge-id", edgeRecordId(edge));
784
+ label.setAttribute("data-source-node-id", edge.source);
785
+ label.setAttribute("data-target-node-id", edge.target);
786
+ label.setAttribute("aria-label", `link ${edge.label} from ${sourceNode?.label || edge.source} to ${targetNode?.label || edge.target}`);
787
+ edgeLabelLayer.appendChild(label);
788
+ }
789
+ const record = { edge, flow, hit, path, focusPath, label, activateEdge };
790
+ hit.addEventListener("click", activateEdge);
791
+ path.addEventListener("click", activateEdge);
792
+ path.addEventListener("keydown", event => {
793
+ if (event.key === "Enter" || event.key === " ") {
794
+ event.preventDefault();
795
+ activateEdge(event);
796
+ }
797
+ });
798
+ if (label) {
799
+ label.addEventListener("click", activateEdge);
800
+ bindEdgeActivationParts(label, activateEdge);
801
+ label.addEventListener("keydown", event => {
802
+ if (event.key === "Enter" || event.key === " ") {
803
+ event.preventDefault();
804
+ activateEdge(event);
805
+ }
806
+ });
807
+ }
808
+ edgeRecords.push(record);
809
+ nodeEdges.get(edge.source)?.push(record);
810
+ nodeEdges.get(edge.target)?.push(record);
811
+ });
812
+ edgeLayer.append(edgePathLayer, edgeLabelLayer);
813
+ layer.appendChild(edgeLayer);
814
+
815
+ function rerouteFrom(nodeId) {
816
+ (nodeEdges.get(nodeId) || []).forEach(({ edge, hit, path, focusPath, label, activateEdge }) => {
817
+ const start = at(edge.source);
818
+ const end = at(edge.target);
819
+ if (!start || !end) return;
820
+ const sourceNode = flowNodeById.get(edge.source);
821
+ const targetNode = flowNodeById.get(edge.target);
822
+ const geometry = edgeGeometry(start, end, sourceNode?.kind, targetNode?.kind);
823
+ setEdgeHitGeometry(hit, geometry, activateEdge);
824
+ path.setAttribute("d", geometry.d);
825
+ if (focusPath) focusPath.setAttribute("d", geometry.focusD || geometry.d);
826
+ if (label) label.setAttribute("transform", `translate(${geometry.labelX} ${geometry.labelY})`);
827
+ });
828
+ }
829
+
830
+ const nodeLayer = svgEl("g");
831
+ const nodeGroups = new Map();
832
+ const nodeDraggable = opts.draggable !== false;
833
+ nodes.forEach(node => {
834
+ const position = positions.get(node.id);
835
+ // Live world-space position of this node (origin-translated). Drag mutates it.
836
+ const place = { x: position.x + originX, y: position.y + originY };
837
+ const group = svgEl("g");
838
+ nodeGroups.set(node.id, group);
839
+ group.setAttribute(
840
+ "class",
841
+ `node ${node.kind}${nodeDraggable ? "" : " static"}`
842
+ );
843
+ group.setAttribute("transform", `translate(${place.x} ${place.y})`);
844
+ group.setAttribute("tabindex", "0");
845
+ group.setAttribute("role", "button");
846
+ group.setAttribute("aria-label", `${node.kind}: ${node.label}`);
847
+ function activateNode() {
848
+ const targetFlow = node.kind === "call" && node.metadata
849
+ ? node.metadata.target_flow
850
+ : null;
851
+ if (targetFlow && byId.has(targetFlow) && codeDebrief.expandCallTarget) {
852
+ codeDebrief.expandCallTarget(flow.id, targetFlow, node.id);
853
+ } else {
854
+ inspectNode(flow, node);
855
+ }
856
+ }
857
+ // Drag to rearrange the block in both full-screen and inline decision charts.
858
+ // A renderer may explicitly pass draggable:false for a read-only preview.
859
+ if (nodeDraggable) {
860
+ let nodeDrag = null;
861
+ const moveNodeDrag = event => {
862
+ if (!nodeDrag) return;
863
+ const dx = (event.clientX - nodeDrag.x) * nodeDrag.scaleX;
864
+ const dy = (event.clientY - nodeDrag.y) * nodeDrag.scaleY;
865
+ nodeDrag.moved = Math.max(nodeDrag.moved, Math.abs(dx) + Math.abs(dy));
866
+ place.x = nodeDrag.ox + dx;
867
+ place.y = nodeDrag.oy + dy;
868
+ // positions is origin-relative; mirror the drag back so rerouteFrom (which
869
+ // re-adds the origin) and any later override store both stay consistent.
870
+ position.x = place.x - originX;
871
+ position.y = place.y - originY;
872
+ group.setAttribute("transform", `translate(${place.x} ${place.y})`);
873
+ rerouteFrom(node.id);
874
+ };
875
+ const endNodeDrag = event => {
876
+ if (!nodeDrag) return;
877
+ group.classList.remove("dragging");
878
+ window.removeEventListener("pointermove", moveNodeDrag);
879
+ window.removeEventListener("pointerup", endNodeDrag);
880
+ window.removeEventListener("pointercancel", endNodeDrag);
881
+ try { group.releasePointerCapture(event.pointerId); } catch (_) {}
882
+ if (nodeDrag.moved < 4 && event.type === "pointerup") {
883
+ activateNode();
884
+ } else {
885
+ const store = manualPositions.get(flow.id) || new Map();
886
+ store.set(node.id, { x: position.x, y: position.y });
887
+ manualPositions.set(flow.id, store);
888
+ }
889
+ nodeDrag = null;
890
+ };
891
+ const startNodeDrag = event => {
892
+ if (event.button !== 0) return;
893
+ event.stopPropagation();
894
+ if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
895
+ nodeDrag = {
896
+ x: event.clientX,
897
+ y: event.clientY,
898
+ ox: place.x,
899
+ oy: place.y,
900
+ scaleX: view.width / svg.clientWidth,
901
+ scaleY: view.height / svg.clientHeight,
902
+ moved: 0
903
+ };
904
+ group.classList.add("dragging");
905
+ window.addEventListener("pointermove", moveNodeDrag);
906
+ window.addEventListener("pointerup", endNodeDrag);
907
+ window.addEventListener("pointercancel", endNodeDrag);
908
+ group.setPointerCapture(event.pointerId);
909
+ };
910
+ group.addEventListener("pointerdown", startNodeDrag);
911
+ } else {
912
+ group.addEventListener("click", event => {
913
+ event.stopPropagation();
914
+ if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
915
+ activateNode();
916
+ });
917
+ }
918
+ group.addEventListener("keydown", event => {
919
+ if (event.key === "Enter" || event.key === " ") {
920
+ event.preventDefault();
921
+ activateNode();
922
+ }
923
+ });
924
+ const shape = nodeShape(node.kind);
925
+ shape.setAttribute("class", "shape");
926
+ group.appendChild(shape);
927
+ group.appendChild(nodeKindBadge(node.kind));
928
+ const lines = wrapLabel(node.label, node.kind === "decision" ? 25 : 31);
929
+ lines.forEach((line, index) => {
930
+ const text = svgEl("text");
931
+ text.setAttribute("text-anchor", "middle");
932
+ text.setAttribute("y", String((index - (lines.length - 1) / 2) * 17 + 1));
933
+ text.textContent = line;
934
+ group.appendChild(text);
935
+ });
936
+ const meta = svgEl("text");
937
+ meta.setAttribute("class", "meta");
938
+ meta.setAttribute("text-anchor", "middle");
939
+ meta.setAttribute("y", String(node.kind === "decision" ? FLOW_META_BOTTOM : 62));
940
+ meta.textContent = `${node.location.path}:${node.location.start_line}`;
941
+ group.appendChild(meta);
942
+ nodeLayer.appendChild(group);
943
+ });
944
+ layer.appendChild(nodeLayer);
945
+
946
+ // World-space bounds of the drawn sub-graph, so the caller can reserve room / fit.
947
+ const worldBounds = {
948
+ minX: bounds.minX + originX - FLOW_NODE_HALF_W,
949
+ maxX: bounds.maxX + originX + FLOW_NODE_HALF_W,
950
+ minY: bounds.minY + originY - FLOW_NODE_HALF_H,
951
+ maxY: bounds.maxY + originY + FLOW_NODE_HALF_H,
952
+ };
953
+ return { layer, nodeGroups, edgeRecords, bounds: worldBounds };
954
+ }
955
+
956
+ // Bind a freshly drawn decision graph as the active highlight target.
957
+ function setCurrentRender(render) {
958
+ currentRender = render ? { nodeGroups: render.nodeGroups, edgeRecords: render.edgeRecords } : null;
959
+ }
960
+
961
+ function renderFlow(flow) {
962
+ svg.replaceChildren();
963
+ // The L2 decision chart is canvas level 2; keep the level attribute correct so a
964
+ // reader (or test) can tell which level is on screen (L0 scopes / L1 flows / L2).
965
+ setCanvasLevel("2");
966
+ if (!flow.nodes.length) {
967
+ document.getElementById("emptyState").style.display = "grid";
968
+ currentRender = null;
969
+ return;
970
+ }
971
+ document.getElementById("emptyState").style.display = "none";
972
+
973
+ svg.appendChild(flowDefs());
974
+ const render = drawFlowGraph(flow, { spine: true });
975
+ const bounds = render.bounds;
976
+ const padding = 170;
977
+ const top = Math.min(-90, bounds.minY - 70);
978
+ view = {
979
+ x: bounds.minX - padding,
980
+ y: top,
981
+ width: Math.max(760, bounds.maxX - bounds.minX + padding * 2),
982
+ height: Math.max(600, bounds.maxY - top + 250)
983
+ };
984
+ updateViewBox();
985
+ svg.appendChild(render.layer);
986
+ currentRender = { nodeGroups: render.nodeGroups, edgeRecords: render.edgeRecords };
987
+ }
988
+
989
+ function clearHighlight() {
990
+ if (!currentRender) return;
991
+ currentRender.nodeGroups.forEach(group =>
992
+ group.classList.remove("selected", "dimmed", "edge-source", "edge-target")
993
+ );
994
+ currentRender.edgeRecords.forEach(record => {
995
+ if (record.hit) record.hit.classList.remove("selected-link", "dimmed");
996
+ record.path.classList.remove("incident", "selected-link", "dimmed", "focus-hidden");
997
+ if (record.focusPath) record.focusPath.classList.remove("selected-link");
998
+ if (record.label) record.label.classList.remove("selected-link", "dimmed");
999
+ });
1000
+ }
1001
+
1002
+ function highlightEdge(targetRecord) {
1003
+ if (!currentRender) return;
1004
+ currentRender.edgeRecords.forEach(record => {
1005
+ const selected = record === targetRecord;
1006
+ if (record.hit) {
1007
+ record.hit.classList.toggle("selected-link", selected);
1008
+ record.hit.classList.toggle("dimmed", !selected);
1009
+ }
1010
+ record.path.classList.remove("selected-link", "incident");
1011
+ record.path.classList.toggle("focus-hidden", selected);
1012
+ record.path.classList.toggle("dimmed", !selected);
1013
+ if (record.focusPath) {
1014
+ record.focusPath.classList.toggle("selected-link", selected);
1015
+ }
1016
+ if (record.label) {
1017
+ record.label.classList.toggle("selected-link", selected);
1018
+ record.label.classList.toggle("dimmed", !selected);
1019
+ }
1020
+ });
1021
+ const endpoints = new Set([targetRecord.edge.source, targetRecord.edge.target]);
1022
+ currentRender.nodeGroups.forEach((group, id) => {
1023
+ group.classList.toggle("selected", endpoints.has(id));
1024
+ group.classList.toggle("dimmed", !endpoints.has(id));
1025
+ group.classList.toggle("edge-source", id === targetRecord.edge.source);
1026
+ group.classList.toggle("edge-target", id === targetRecord.edge.target);
1027
+ });
1028
+ }
1029
+
1030
+ function highlightNode(nodeId) {
1031
+ if (!currentRender) return;
1032
+ const connected = new Set([nodeId]);
1033
+ currentRender.edgeRecords.forEach(record => {
1034
+ const incident = record.edge.source === nodeId || record.edge.target === nodeId;
1035
+ if (record.hit) {
1036
+ record.hit.classList.remove("selected-link");
1037
+ record.hit.classList.toggle("dimmed", !incident);
1038
+ }
1039
+ record.path.classList.remove("selected-link", "focus-hidden");
1040
+ if (record.focusPath) record.focusPath.classList.remove("selected-link");
1041
+ record.path.classList.toggle("incident", incident);
1042
+ record.path.classList.toggle("dimmed", !incident);
1043
+ if (record.label) {
1044
+ record.label.classList.remove("selected-link");
1045
+ record.label.classList.toggle("dimmed", !incident);
1046
+ }
1047
+ if (incident) { connected.add(record.edge.source); connected.add(record.edge.target); }
1048
+ });
1049
+ currentRender.nodeGroups.forEach((group, id) => {
1050
+ group.classList.toggle("selected", id === nodeId);
1051
+ group.classList.toggle("dimmed", !connected.has(id));
1052
+ group.classList.remove("edge-source", "edge-target");
1053
+ });
1054
+ }
1055
+
1056
+ function decisionEdgeRecordFromElement(element) {
1057
+ if (!currentRender || !element || !element.closest) return null;
1058
+ if (element.closest(".progressive-call-hit, .progressive-call-edge, .progressive-call-label")) {
1059
+ return null;
1060
+ }
1061
+ const target = element.closest(".edge-hit, .edge, .edge-label-wrap");
1062
+ if (!target) return null;
1063
+ const id = target.getAttribute("data-edge-id");
1064
+ const source = target.getAttribute("data-source-node-id");
1065
+ const destination = target.getAttribute("data-target-node-id");
1066
+ return currentRender.edgeRecords.find(item => {
1067
+ const recordId = item.edge.id || `${item.edge.source}->${item.edge.target}`;
1068
+ return (id && recordId === id) ||
1069
+ (source && destination && item.edge.source === source && item.edge.target === destination);
1070
+ }) || null;
1071
+ }
1072
+
1073
+ function activateDecisionEdgeRecord(event) {
1074
+ const record = decisionEdgeRecordFromElement(event.target);
1075
+ if (!record || !record.flow) return;
1076
+ event.preventDefault();
1077
+ event.stopPropagation();
1078
+ if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
1079
+ if (codeDebrief.openDetails) codeDebrief.openDetails();
1080
+ codeDebrief.select({
1081
+ flowId: record.flow.id,
1082
+ path: record.flow.location.path,
1083
+ nodeId: null,
1084
+ edgeId: record.edge.id || `${record.edge.source}->${record.edge.target}`,
1085
+ line: record.flow.location.start_line || null,
1086
+ endLine: record.flow.location.end_line || record.flow.location.start_line || null,
1087
+ });
1088
+ }
1089
+
1090
+ function nodeShape(kind) {
1091
+ if (kind === "decision") {
1092
+ const polygon = svgEl("polygon");
1093
+ polygon.setAttribute("points", "0,-58 145,0 0,58 -145,0");
1094
+ return polygon;
1095
+ }
1096
+ const rect = svgEl("rect");
1097
+ rect.setAttribute("x", "-145");
1098
+ rect.setAttribute("y", "-43");
1099
+ rect.setAttribute("width", "290");
1100
+ rect.setAttribute("height", "86");
1101
+ rect.setAttribute("rx", kind === "entry" || kind === "terminal" ? "43" : kind === "call" ? "5" : "12");
1102
+ return rect;
1103
+ }
1104
+
1105
+ // Inspecting a flow: clear the per-node canvas highlight (no single node is active)
1106
+ // and publish the flow selection so the details panels and tree reflect it. shell.js
1107
+ // keeps only its canvas-highlight responsibility plus publishing the shared selection.
1108
+ // nodeId is cleared so the source panel shows the whole flow snippet.
1109
+ function inspectFlow(flow) {
1110
+ if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
1111
+ clearHighlight();
1112
+ setRightRailOpen(true);
1113
+ codeDebrief.select({
1114
+ flowId: flow.id,
1115
+ path: flow.location.path,
1116
+ nodeId: null,
1117
+ line: flow.location.start_line || null,
1118
+ endLine: flow.location.end_line || flow.location.start_line || null,
1119
+ });
1120
+ }
1121
+
1122
+ // Inspecting a decision/call node: publish the node selection. The block highlight is
1123
+ // applied by the single shared-selection subscriber below (one accent path, not
1124
+ // duplicated here); the source panel highlights the node's source line(s) via the same
1125
+ // store.
1126
+ function inspectNode(flow, node) {
1127
+ if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
1128
+ setRightRailOpen(true);
1129
+ codeDebrief.select({
1130
+ flowId: flow.id,
1131
+ nodeId: node.id,
1132
+ path: node.location.path,
1133
+ line: node.location.start_line || null,
1134
+ endLine: node.location.end_line || node.location.start_line || null,
1135
+ });
1136
+ }
1137
+
1138
+ function element(tag, className, text) {
1139
+ const item = document.createElement(tag);
1140
+ if (className) item.className = className;
1141
+ item.textContent = text;
1142
+ return item;
1143
+ }
1144
+
1145
+ function svgEl(tag) {
1146
+ return document.createElementNS("http://www.w3.org/2000/svg", tag);
1147
+ }
1148
+
1149
+ function safeDecodeHashValue(value) {
1150
+ try {
1151
+ return decodeURIComponent(value);
1152
+ } catch (_) {
1153
+ return null;
1154
+ }
1155
+ }
1156
+
1157
+ function wrapLabel(value, width) {
1158
+ const words = value.split(/\s+/);
1159
+ const lines = [];
1160
+ let current = "";
1161
+ words.forEach(word => {
1162
+ if (!current || `${current} ${word}`.length <= width) current = current ? `${current} ${word}` : word;
1163
+ else { lines.push(current); current = word; }
1164
+ });
1165
+ if (current) lines.push(current);
1166
+ return lines.slice(0, 3);
1167
+ }
1168
+
1169
+ function updateViewBox() {
1170
+ svg.setAttribute("viewBox", `${view.x} ${view.y} ${view.width} ${view.height}`);
1171
+ }
1172
+
1173
+ function activeTypedViewer() {
1174
+ if (document.body.dataset.runtime !== "react") return null;
1175
+ const viewer = window.codedebriefTypedViewer;
1176
+ return viewer && typeof viewer === "object" ? viewer : null;
1177
+ }
1178
+
1179
+ function cssVar(name, fallback) {
1180
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
1181
+ return value || fallback;
1182
+ }
1183
+
1184
+ function canvasContentBounds() {
1185
+ const nodes = [...svg.children].filter(child => child.tagName.toLowerCase() !== "defs");
1186
+ if (!nodes.length) {
1187
+ return { x: view.x, y: view.y, width: view.width, height: view.height };
1188
+ }
1189
+ const hitboxes = [...svg.querySelectorAll(".edge-hit")];
1190
+ const previousDisplays = hitboxes.map(node => node.style.display);
1191
+ hitboxes.forEach(node => {
1192
+ node.style.display = "none";
1193
+ });
1194
+ let minX = Infinity;
1195
+ let minY = Infinity;
1196
+ let maxX = -Infinity;
1197
+ let maxY = -Infinity;
1198
+ try {
1199
+ nodes.forEach(node => {
1200
+ if (node.classList && node.classList.contains("edge-hit")) return;
1201
+ try {
1202
+ const box = node.getBBox();
1203
+ if (!box || !Number.isFinite(box.width) || !Number.isFinite(box.height)) return;
1204
+ minX = Math.min(minX, box.x);
1205
+ minY = Math.min(minY, box.y);
1206
+ maxX = Math.max(maxX, box.x + box.width);
1207
+ maxY = Math.max(maxY, box.y + box.height);
1208
+ } catch (_) {}
1209
+ });
1210
+ } finally {
1211
+ hitboxes.forEach((node, index) => {
1212
+ node.style.display = previousDisplays[index] || "";
1213
+ });
1214
+ }
1215
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
1216
+ return { x: view.x, y: view.y, width: view.width, height: view.height };
1217
+ }
1218
+ const padding = 90;
1219
+ return {
1220
+ x: minX - padding,
1221
+ y: minY - padding,
1222
+ width: Math.max(1, maxX - minX + padding * 2),
1223
+ height: Math.max(1, maxY - minY + padding * 2),
1224
+ };
1225
+ }
1226
+
1227
+ function exportCurrentCanvas(format) {
1228
+ const typed = activeTypedViewer();
1229
+ if (typed && typeof typed.exportImage === "function") {
1230
+ typed.exportImage(format);
1231
+ return;
1232
+ }
1233
+ const bounds = canvasContentBounds();
1234
+ const preferredScale = 2;
1235
+ const maxPixelSide = 16384;
1236
+ const maxPixelArea = 96000000;
1237
+ const boundedWidth = Math.max(1, bounds.width);
1238
+ const boundedHeight = Math.max(1, bounds.height);
1239
+ const scale = Math.max(
1240
+ 0.1,
1241
+ Math.min(
1242
+ preferredScale,
1243
+ maxPixelSide / Math.max(boundedWidth, boundedHeight),
1244
+ Math.sqrt(maxPixelArea / (boundedWidth * boundedHeight)),
1245
+ ),
1246
+ );
1247
+ const width = Math.max(1, Math.round(bounds.width * scale));
1248
+ const height = Math.max(1, Math.round(bounds.height * scale));
1249
+ const clone = svg.cloneNode(true);
1250
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
1251
+ clone.setAttribute("width", String(width));
1252
+ clone.setAttribute("height", String(height));
1253
+ clone.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`);
1254
+ clone.setAttribute("data-theme", document.documentElement.dataset.theme || "dark");
1255
+ clone.querySelectorAll(".edge-hit").forEach(node => node.remove());
1256
+
1257
+ const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
1258
+ style.textContent = document.querySelector("style")?.textContent || "";
1259
+ clone.prepend(style);
1260
+ const background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
1261
+ background.setAttribute("x", String(bounds.x));
1262
+ background.setAttribute("y", String(bounds.y));
1263
+ background.setAttribute("width", String(bounds.width));
1264
+ background.setAttribute("height", String(bounds.height));
1265
+ background.setAttribute("fill", cssVar("--paper", "#ffffff"));
1266
+ clone.insertBefore(background, style.nextSibling);
1267
+
1268
+ const serialized = new XMLSerializer().serializeToString(clone);
1269
+ const svgBlob = new Blob([serialized], { type: "image/svg+xml;charset=utf-8" });
1270
+ const imageUrl = URL.createObjectURL(svgBlob);
1271
+ const image = new Image();
1272
+ image.onload = () => {
1273
+ const canvas = document.createElement("canvas");
1274
+ canvas.width = width;
1275
+ canvas.height = height;
1276
+ const ctx = canvas.getContext("2d");
1277
+ if (!ctx) {
1278
+ URL.revokeObjectURL(imageUrl);
1279
+ return;
1280
+ }
1281
+ ctx.fillStyle = cssVar("--paper", "#ffffff");
1282
+ ctx.fillRect(0, 0, width, height);
1283
+ ctx.drawImage(image, 0, 0, width, height);
1284
+ URL.revokeObjectURL(imageUrl);
1285
+ const mime = format === "jpg" ? "image/jpeg" : "image/png";
1286
+ canvas.toBlob(blob => {
1287
+ if (!blob) return;
1288
+ const link = document.createElement("a");
1289
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1290
+ link.download = `codedebrief-flowchart-${stamp}.${format}`;
1291
+ link.href = URL.createObjectURL(blob);
1292
+ document.body.appendChild(link);
1293
+ link.click();
1294
+ link.remove();
1295
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
1296
+ }, mime, format === "jpg" ? 0.92 : undefined);
1297
+ };
1298
+ image.onerror = () => URL.revokeObjectURL(imageUrl);
1299
+ image.src = imageUrl;
1300
+ }
1301
+
1302
+ function zoom(factor) {
1303
+ const typed = activeTypedViewer();
1304
+ if (typed && typeof typed.zoom === "function") {
1305
+ typed.zoom(factor);
1306
+ return;
1307
+ }
1308
+ const nextWidth = view.width * factor;
1309
+ const nextHeight = view.height * factor;
1310
+ view.x += (view.width - nextWidth) / 2;
1311
+ view.y += (view.height - nextHeight) / 2;
1312
+ view.width = nextWidth;
1313
+ view.height = nextHeight;
1314
+ updateViewBox();
1315
+ }
1316
+
1317
+ function fitView() {
1318
+ const typed = activeTypedViewer();
1319
+ if (typed && typeof typed.fitView === "function") {
1320
+ typed.fitView();
1321
+ return;
1322
+ }
1323
+ view = canvasContentBounds();
1324
+ updateViewBox();
1325
+ }
1326
+
1327
+ function expandView() {
1328
+ const typed = activeTypedViewer();
1329
+ if (typed && typeof typed.expandAll === "function") {
1330
+ typed.expandAll();
1331
+ }
1332
+ }
1333
+
1334
+ document.getElementById("resetView").addEventListener("click", () => {
1335
+ const typed = activeTypedViewer();
1336
+ if (typed && typeof typed.resetView === "function") {
1337
+ typed.resetView();
1338
+ return;
1339
+ }
1340
+ if (codeDebrief.mode === "flow") {
1341
+ if (!activeFlow) return;
1342
+ manualPositions.delete(activeFlow.id); // discard hand-placed positions, re-layout
1343
+ renderFlow(activeFlow);
1344
+ }
1345
+ });
1346
+ document.getElementById("expandView").addEventListener("click", expandView);
1347
+ document.getElementById("fitView").addEventListener("click", fitView);
1348
+ document.getElementById("zoomOut").addEventListener("click", () => zoom(1.22));
1349
+ document.getElementById("zoomIn").addEventListener("click", () => zoom(.82));
1350
+ if (exportPngButton) {
1351
+ exportPngButton.addEventListener("click", () => exportCurrentCanvas("png"));
1352
+ }
1353
+ if (exportJpgButton) {
1354
+ exportJpgButton.addEventListener("click", () => exportCurrentCanvas("jpg"));
1355
+ }
1356
+ if (menuButton) {
1357
+ menuButton.addEventListener("click", () => setLeftRailOpen(!leftRailOpen()));
1358
+ }
1359
+ if (detailButton) {
1360
+ detailButton.addEventListener("click", () => setRightRailOpen(!rightRailOpen()));
1361
+ }
1362
+ if (detailsClose) {
1363
+ detailsClose.addEventListener("click", () => setRightRailOpen(false));
1364
+ }
1365
+ document.addEventListener("keydown", event => {
1366
+ if (event.key !== "Escape" || eventTargetIsTextInput(event)) return;
1367
+ if (rightRailOpen()) {
1368
+ setRightRailOpen(false);
1369
+ event.stopImmediatePropagation();
1370
+ return;
1371
+ }
1372
+ if (leftRailOpen()) {
1373
+ setLeftRailOpen(false);
1374
+ event.stopImmediatePropagation();
1375
+ }
1376
+ });
1377
+
1378
+ svg.addEventListener("wheel", event => {
1379
+ event.preventDefault();
1380
+ zoom(event.deltaY > 0 ? 1.08 : .92);
1381
+ }, { passive: false });
1382
+ if (typedViewerHost) {
1383
+ typedViewerHost.addEventListener("wheel", event => {
1384
+ const typed = activeTypedViewer();
1385
+ if (!typed || typeof typed.zoom !== "function") return;
1386
+ event.preventDefault();
1387
+ typed.zoom(event.deltaY > 0 ? 1.08 : .92);
1388
+ }, { passive: false });
1389
+ }
1390
+ svg.addEventListener("pointerdown", activateDecisionEdgeRecord, true);
1391
+ svg.addEventListener("mousedown", activateDecisionEdgeRecord, true);
1392
+ svg.addEventListener("click", activateDecisionEdgeRecord, true);
1393
+ document.addEventListener("pointerdown", activateDecisionEdgeRecord, true);
1394
+ document.addEventListener("mousedown", activateDecisionEdgeRecord, true);
1395
+ document.addEventListener("click", activateDecisionEdgeRecord, true);
1396
+ svg.addEventListener("keydown", event => {
1397
+ if (event.key !== "Enter" && event.key !== " ") return;
1398
+ const record = decisionEdgeRecordFromElement(event.target);
1399
+ if (!record) return;
1400
+ event.preventDefault();
1401
+ activateDecisionEdgeRecord(event);
1402
+ }, true);
1403
+ document.addEventListener("keydown", event => {
1404
+ if (event.key !== "Enter" && event.key !== " ") return;
1405
+ const record = decisionEdgeRecordFromElement(event.target);
1406
+ if (!record) return;
1407
+ event.preventDefault();
1408
+ activateDecisionEdgeRecord(event);
1409
+ }, true);
1410
+ svg.addEventListener("pointerdown", event => {
1411
+ if (event.button !== 0) return;
1412
+ // Pan only from the empty canvas background. If the press lands on an interactive
1413
+ // node group (scope, flow, decision block all carry role="button"), do not start a pan or
1414
+ // capture the pointer, so the node's own click handler fires (expand/toggle).
1415
+ if (event.target.closest('[role="button"], .edge-hit, .edge-hit-segment, .edge-label-wrap')) return;
1416
+ drag = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y, moved: 0 };
1417
+ svg.classList.add("dragging");
1418
+ svg.setPointerCapture(event.pointerId);
1419
+ });
1420
+ svg.addEventListener("pointermove", event => {
1421
+ if (!drag) return;
1422
+ const scaleX = view.width / svg.clientWidth;
1423
+ const scaleY = view.height / svg.clientHeight;
1424
+ drag.moved = Math.max(
1425
+ drag.moved,
1426
+ Math.abs(event.clientX - drag.x) + Math.abs(event.clientY - drag.y)
1427
+ );
1428
+ view.x = drag.vx - (event.clientX - drag.x) * scaleX;
1429
+ view.y = drag.vy - (event.clientY - drag.y) * scaleY;
1430
+ updateViewBox();
1431
+ });
1432
+ svg.addEventListener("pointerup", () => {
1433
+ if (drag && drag.moved < 4) {
1434
+ if (codeDebrief.clearProgressiveLinkHighlight) codeDebrief.clearProgressiveLinkHighlight();
1435
+ clearHighlight();
1436
+ }
1437
+ drag = null;
1438
+ svg.classList.remove("dragging");
1439
+ });
1440
+
1441
+ document.documentElement.dataset.theme = "dark";
1442
+
1443
+ // Expose flow/scope selection so the directory tree and details panel can drive the
1444
+ // official React chart without knowing about its implementation.
1445
+ codeDebrief.selectFlow = selectFlow;
1446
+ codeDebrief.selectScope = scope => {
1447
+ const typed = activeTypedViewer();
1448
+ setHeaderScope(scope);
1449
+ if (typed && typeof typed.selectScope === "function") {
1450
+ typed.selectScope(scope);
1451
+ } else {
1452
+ location.hash = "scope=" + encodeURIComponent(scope);
1453
+ codeDebrief.select({
1454
+ edgeId: null,
1455
+ endLine: null,
1456
+ flowId: null,
1457
+ line: null,
1458
+ nodeId: null,
1459
+ path: null,
1460
+ scope,
1461
+ });
1462
+ }
1463
+ };
1464
+ codeDebrief.resetGraph = () => {
1465
+ const typed = activeTypedViewer();
1466
+ if (typed && typeof typed.resetView === "function") typed.resetView();
1467
+ else {
1468
+ location.hash = "root";
1469
+ setHeaderRoot();
1470
+ codeDebrief.select({
1471
+ edgeId: null,
1472
+ endLine: null,
1473
+ flowId: null,
1474
+ line: null,
1475
+ nodeId: null,
1476
+ path: null,
1477
+ scope: null,
1478
+ });
1479
+ }
1480
+ };
1481
+ codeDebrief.activeFlowId = () => activeFlow?.id || null;
1482
+
1483
+ // Viewport primitives retained for shared shell helpers.
1484
+ codeDebrief.renderFlow = renderFlow;
1485
+ codeDebrief.svg = svg;
1486
+ codeDebrief.openDetails = () => setRightRailOpen(true);
1487
+ codeDebrief.setCanvasLevel = setCanvasLevel;
1488
+ codeDebrief.setView = v => { view = v; updateViewBox(); };
1489
+ codeDebrief.updateViewBox = updateViewBox;
1490
+ codeDebrief.getView = () => view;
1491
+ // Shared decision-render helpers retained for source/export compatibility.
1492
+ codeDebrief.drawFlowGraph = drawFlowGraph;
1493
+ codeDebrief.flowDefs = flowDefs;
1494
+ codeDebrief.setCurrentRender = setCurrentRender;
1495
+ codeDebrief.inspectFlow = inspectFlow;
1496
+ codeDebrief.inspectNode = inspectNode;
1497
+ // Highlight surface panels.js reuses (one accent path, never duplicated): when a source
1498
+ // row resolves to a node, light up that node on the active decision graph exactly as a
1499
+ // direct block click would, without rebuilding the inspector.
1500
+ codeDebrief.highlightNode = highlightNode;
1501
+ codeDebrief.clearHighlight = clearHighlight;
1502
+ // Resolve a node object by (flowId, nodeId) so a source-line click can recover
1503
+ // the FlowNode without panels.js re-walking the model.
1504
+ codeDebrief.nodeById = (flowId, nodeId) => {
1505
+ const flow = byId.get(flowId);
1506
+ if (!flow || !nodeId) return null;
1507
+ return flow.nodes.find(n => n.id === nodeId) || null;
1508
+ };
1509
+ // Origin-relative bounds of a flow's decision layout. layoutFlow returns bounds over
1510
+ // node CENTERS only; inflate by the node half-extents so callers reserve the visual
1511
+ // footprint, not just center points.
1512
+ codeDebrief.measureFlow = (flow, opts) => {
1513
+ if (!flow || !flow.nodes || !flow.nodes.length) {
1514
+ return { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0 };
1515
+ }
1516
+ const { bounds, nodes } = layoutFlow(flow, opts || {});
1517
+ if (!nodes.length) {
1518
+ return { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0 };
1519
+ }
1520
+ const inflated = {
1521
+ minX: bounds.minX - FLOW_NODE_HALF_W,
1522
+ maxX: bounds.maxX + FLOW_NODE_HALF_W,
1523
+ minY: bounds.minY - FLOW_NODE_HALF_H,
1524
+ maxY: bounds.maxY + FLOW_NODE_HALF_H,
1525
+ };
1526
+ return {
1527
+ ...inflated,
1528
+ width: inflated.maxX - inflated.minX,
1529
+ height: inflated.maxY - inflated.minY,
1530
+ };
1531
+ };
1532
+ // Drop a flow's hand-placed decision-node positions, so the canvas reset (0) restores
1533
+ // the automatic layout of an inline-expanded sub-graph just like it does full screen.
1534
+ codeDebrief.clearFlowPositions = id => {
1535
+ if (id == null) manualPositions.clear();
1536
+ else manualPositions.delete(id);
1537
+ };
1538
+
1539
+ // Reconcile the CANVAS block highlight from the shared selection. A node selected on
1540
+ // ANY surface (a source line or tree row) lights up its block here, on whatever
1541
+ // decision graph is currently drawn -- the same single highlight path a direct block
1542
+ // click uses. Guarded by currentRender: when no decision graph is on screen (L0, or a
1543
+ // flow whose decisions are not expanded) there is no block to light, and that is fine
1544
+ // (the source/tree highlights still apply via panels.js / tree.js). This keeps
1545
+ // ONE accent path for the block instead of duplicating highlightNode in panels.js.
1546
+ codeDebrief.onSelection(sel => {
1547
+ if (!currentRender) return;
1548
+ if (sel.edgeId) {
1549
+ const record = currentRender.edgeRecords.find(item =>
1550
+ (item.edge.id || `${item.edge.source}->${item.edge.target}`) === sel.edgeId
1551
+ );
1552
+ if (record) highlightEdge(record);
1553
+ else clearHighlight();
1554
+ } else if (sel.nodeId && currentRender.nodeGroups.has(sel.nodeId)) {
1555
+ highlightNode(sel.nodeId);
1556
+ } else if (!sel.nodeId) {
1557
+ clearHighlight();
1558
+ }
1559
+ });
1560
+
1561
+ // Keep the HTML shell in sync with the hash the React runtime owns. This deliberately
1562
+ // does not render canvas content; it only updates header/tree/source state.
1563
+ function syncShellFromHash() {
1564
+ const raw = location.hash.slice(1);
1565
+ const eq = raw.indexOf("=");
1566
+ const scopes = model.scopes || {};
1567
+ if (eq !== -1) {
1568
+ const key = raw.slice(0, eq);
1569
+ const value = safeDecodeHashValue(raw.slice(eq + 1));
1570
+ if (value == null) { setHeaderRoot(); return; }
1571
+ if (key === "flow" && byId.has(value)) {
1572
+ const flow = byId.get(value);
1573
+ setHeaderFlow(flow);
1574
+ codeDebrief.select(selectionForFlow(flow));
1575
+ if (codeDebrief.openDetails) codeDebrief.openDetails();
1576
+ if (window.CodeDebrief.onFlowSelected) window.CodeDebrief.onFlowSelected(flow);
1577
+ return;
1578
+ }
1579
+ if (key === "scope" && Object.prototype.hasOwnProperty.call(scopes, value)) {
1580
+ setHeaderScope(value);
1581
+ codeDebrief.select({
1582
+ edgeId: null,
1583
+ endLine: null,
1584
+ flowId: null,
1585
+ line: null,
1586
+ nodeId: null,
1587
+ path: null,
1588
+ scope: value,
1589
+ });
1590
+ return;
1591
+ }
1592
+ if (key === "path" && value) {
1593
+ const scope = value.split("/").filter(Boolean)[0] || null;
1594
+ if (scope) setHeaderScope(scope);
1595
+ codeDebrief.select({
1596
+ edgeId: null,
1597
+ endLine: null,
1598
+ flowId: null,
1599
+ line: null,
1600
+ nodeId: null,
1601
+ path: value,
1602
+ scope,
1603
+ });
1604
+ if (codeDebrief.openDetails) codeDebrief.openDetails();
1605
+ return;
1606
+ }
1607
+ if (key === "edge") return;
1608
+ if (key === "node" && value === "codebase") {
1609
+ setHeaderRoot();
1610
+ codeDebrief.select({
1611
+ edgeId: null,
1612
+ endLine: null,
1613
+ flowId: null,
1614
+ line: null,
1615
+ nodeId: null,
1616
+ path: null,
1617
+ scope: null,
1618
+ });
1619
+ return;
1620
+ }
1621
+ } else if (raw) {
1622
+ const decoded = safeDecodeHashValue(raw);
1623
+ if (decoded == null) { setHeaderRoot(); return; }
1624
+ if (byId.has(decoded)) {
1625
+ const flow = byId.get(decoded);
1626
+ setHeaderFlow(flow);
1627
+ codeDebrief.select(selectionForFlow(flow));
1628
+ if (codeDebrief.openDetails) codeDebrief.openDetails();
1629
+ if (window.CodeDebrief.onFlowSelected) window.CodeDebrief.onFlowSelected(flow);
1630
+ return;
1631
+ }
1632
+ if (decoded === "root") {
1633
+ setHeaderRoot();
1634
+ codeDebrief.select({
1635
+ edgeId: null,
1636
+ endLine: null,
1637
+ flowId: null,
1638
+ line: null,
1639
+ nodeId: null,
1640
+ path: null,
1641
+ scope: null,
1642
+ });
1643
+ }
1644
+ }
1645
+ if (!raw) setHeaderRoot();
1646
+ }
1647
+ codeDebrief.syncShellFromHash = syncShellFromHash;
1648
+ window.addEventListener("hashchange", syncShellFromHash);
1649
+ syncShellFromHash();