@principal-ai/subsystems-react 0.37.9 → 0.37.11

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.
@@ -245,6 +245,12 @@ export interface SubsystemComponentGraphProps {
245
245
  * @default true
246
246
  */
247
247
  zoomOnWalkthroughFocus?: boolean;
248
+ /**
249
+ * Duration (ms) of the camera pan/zoom when a walkthrough step or flow is
250
+ * focused. Higher = a slower, more legible flight between steps.
251
+ * @default 300
252
+ */
253
+ walkthroughFocusDurationMs?: number;
248
254
  /**
249
255
  * Subsystem title rendered as a non-interactive overlay chip on the graph
250
256
  * canvas (top-center). Does not trigger the sidebar — for graph-only
@@ -313,6 +319,13 @@ export interface SubsystemComponentGraphProps {
313
319
  * updates it as the focused step changes.
314
320
  */
315
321
  renderWalkthroughViewer?: (ctx: WalkthroughViewerContext) => ReactNode;
322
+ /**
323
+ * Suppress the bottom file/walkthrough drawer entirely. Focusing a step then
324
+ * only frames it on the canvas (and dims the rest) without dropping a snippet
325
+ * panel below — for embeds that want the camera to tell the story. Defaults
326
+ * to false.
327
+ */
328
+ hideDrawer?: boolean;
316
329
  /**
317
330
  * Legacy component-keyed variant, kept for backward compatibility. When
318
331
  * `renderFileViewer` is absent, drawer content resolves via the first
@@ -457,9 +470,9 @@ interface InnerProps extends SubsystemComponentGraphProps {
457
470
  measured: { w: number; h: number } | null;
458
471
  }
459
472
 
460
- function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, persistKey }: InnerProps) {
473
+ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, walkthroughFocusDurationMs = 300, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, hideDrawer = false, persistKey }: InnerProps) {
461
474
  const { theme } = useTheme();
462
- const { fitView } = useReactFlow();
475
+ const { fitView, fitBounds } = useReactFlow();
463
476
  const viewport = useViewport();
464
477
  // Restored once per mount from `localStorage` (see `readViewState`). Each
465
478
  // graph is its own tab/mount, so `persistKey` is stable for a mount.
@@ -468,9 +481,14 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
468
481
  () => deriveGraphEdges({ relations, walkthroughs }),
469
482
  [relations, walkthroughs],
470
483
  );
471
- const [built, setBuilt] = useState<{ nodes: Node[]; edges: Edge[] }>({
484
+ const [built, setBuilt] = useState<{
485
+ nodes: Node[];
486
+ edges: Edge[];
487
+ absoluteRects: Map<string, { x: number; y: number; width: number; height: number }>;
488
+ }>({
472
489
  nodes: [],
473
490
  edges: [],
491
+ absoluteRects: new Map(),
474
492
  });
475
493
  const [layoutReady, setLayoutReady] = useState(false);
476
494
  const [selected, setSelected] = useState<SubsystemComponent | null>(null);
@@ -727,7 +745,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
727
745
  showEdgeLabels,
728
746
  showSingletonFrames,
729
747
  })
730
- .then(({ nodes, edges: e }) => {
748
+ .then(({ nodes, edges: e, absoluteRects }) => {
731
749
  if (!alive) return;
732
750
  // Prune dims for removed leaves; keep measurements for stable ids so a
733
751
  // live update can finish Pass 2 without waiting on new `dimensions` events.
@@ -737,14 +755,14 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
737
755
  for (const id of measuredDimsRef.current.keys()) {
738
756
  if (!leafIds.has(id)) measuredDimsRef.current.delete(id);
739
757
  }
740
- setBuilt({ nodes, edges: e as Edge[] });
758
+ setBuilt({ nodes, edges: e as Edge[], absoluteRects });
741
759
  setLayoutReady(false);
742
760
  })
743
761
  .catch((err) => {
744
762
  console.warn('[subsystem-graph] initial layout failed:', err);
745
763
  if (!alive) return;
746
764
  // Reveal the cover even on failure so the UI is not stuck forever.
747
- setBuilt({ nodes: [], edges: [] });
765
+ setBuilt({ nodes: [], edges: [], absoluteRects: new Map() });
748
766
  setLayoutReady(true);
749
767
  });
750
768
  return () => { alive = false; };
@@ -777,9 +795,9 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
777
795
  { components, relations, walkthroughs },
778
796
  { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames },
779
797
  )
780
- .then(({ nodes, edges: e }) => {
798
+ .then(({ nodes, edges: e, absoluteRects }) => {
781
799
  if (gen !== pass2GenRef.current) return;
782
- setBuilt({ nodes, edges: e as Edge[] });
800
+ setBuilt({ nodes, edges: e as Edge[], absoluteRects });
783
801
  setLayoutReady(true);
784
802
  })
785
803
  .catch((err) => {
@@ -876,9 +894,21 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
876
894
  // without a stale closure value.
877
895
  const selectedEdgeIdRef = useRef<string | null>(null);
878
896
  selectedEdgeIdRef.current = selectedEdgeId;
897
+ // Autoplay focus: a sidebar-less embed has no expanded flow to "open", so
898
+ // branding every step as a selection would hide all other nodes. Instead the
899
+ // autoplay sets this (flow id + step) to frame the hop and dim the rest —
900
+ // same treatment as hover — without hiding anything.
901
+ const [autoPlayFocus, setAutoPlayFocus] = useState<
902
+ { walkthroughId: string; stepIndex: number } | null
903
+ >(null);
879
904
  // Edge ids in walkthrough focus (an active flow's edge set, or a single
880
905
  // step's edge). Used to frame the camera. `null` = no walkthrough focus.
881
906
  const focusEdgeIds = useMemo(() => {
907
+ if (autoPlayFocus) {
908
+ const tl = walkthroughs?.find((t) => t.id === autoPlayFocus.walkthroughId);
909
+ const step = tl?.steps[autoPlayFocus.stepIndex];
910
+ return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
911
+ }
882
912
  if (focusedWalkthroughId == null || !walkthroughs) return null;
883
913
  const tl = walkthroughs.find((t) => t.id === focusedWalkthroughId);
884
914
  if (!tl) return null;
@@ -887,7 +917,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
887
917
  return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
888
918
  }
889
919
  return new Set(tl.steps.map((s) => walkthroughStepGraphEdgeId(s)));
890
- }, [walkthroughs, focusedWalkthroughId, focusedStepIndex]);
920
+ }, [walkthroughs, focusedWalkthroughId, focusedStepIndex, autoPlayFocus]);
891
921
 
892
922
  // 1-based step numbers per edge of the active flow (focused or
893
923
  // hover/autoplay-highlighted). An edge can appear in more than one step.
@@ -941,12 +971,17 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
941
971
  // selected + hovered participants. Whole-flow focus (or no focus) keeps
942
972
  // the old hover-replace preview so a step hover still dims the rest.
943
973
  const previewEdgeIds = useMemo(() => {
974
+ // Autoplay drives focus without a hover, so its hop is the preview set.
975
+ if (autoPlayFocus) return focusEdgeIds;
944
976
  if (!hoverEdgeIds) return null;
977
+ // While hovering with a *step* already selected, brighten the union of
978
+ // selected + hovered participants; whole-flow focus keeps the old
979
+ // hover-replace preview so a step hover still dims the rest.
945
980
  if (focusedStepIndex == null || !focusEdgeIds) return hoverEdgeIds;
946
981
  const ids = new Set(hoverEdgeIds);
947
982
  for (const id of focusEdgeIds) ids.add(id);
948
983
  return ids;
949
- }, [hoverEdgeIds, focusEdgeIds, focusedStepIndex]);
984
+ }, [hoverEdgeIds, focusEdgeIds, focusedStepIndex, autoPlayFocus]);
950
985
  const previewNodeIds = useMemo(
951
986
  () => endpointsOf(previewEdgeIds),
952
987
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -988,22 +1023,26 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
988
1023
  // While a step/flow is hovered, selected + hovered participants stay
989
1024
  // bright; everything else among the currently-visible (opened-walkthrough)
990
1025
  // nodes is dimmed. Nodes outside the opened-walkthrough set stay hidden.
1026
+ // Exception: with no expanded flow at all (a sidebar-less embed), there is
1027
+ // nothing to hide toward — everything shows, and focus/hover only dims.
1028
+ const noOpenedFlow = openedEdgeIds == null;
991
1029
  return xyflowNodesBase.map((n) => {
992
1030
  // Boundary frames follow their members: hidden when no member is
993
1031
  // visible, dimmed when members are dimmed. Never selectable.
994
1032
  if (n.type === 'subsystem-group') {
995
1033
  const region = (n.data as { region?: { key?: string; memberAliases?: string[] } } | undefined)?.region;
996
1034
  const memberAliases = region?.memberAliases ?? [];
1035
+ // Hover/autoplay dim-only: never hide a frame (see the node branch).
997
1036
  const vis = flowElementVisibility({
998
1037
  inOpened: memberAliases.some((alias) => openedNodeIds?.has(alias) === true),
999
1038
  inSelected: memberAliases.some((alias) => brightNodeIds?.has(alias) === true),
1000
1039
  anyOpened: openedNodeIds != null,
1001
- anySelected: brightNodeIds != null,
1040
+ anySelected: brightNodeIds != null || previewNodeIds != null,
1002
1041
  });
1003
1042
  const dimmed = previewNodeIds
1004
1043
  ? vis.hidden || !memberAliases.some((alias) => previewNodeIds.has(alias))
1005
1044
  : vis.dimmed;
1006
- const hidden = vis.hidden;
1045
+ const hidden = noOpenedFlow ? false : vis.hidden;
1007
1046
  // Host override wins over the library's derived frame color.
1008
1047
  const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
1009
1048
  return {
@@ -1019,15 +1058,19 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1019
1058
  }
1020
1059
  const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
1021
1060
  const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
1061
+ // Hover/autoplay is a dim-only signal: never hide non-participants (there
1062
+ // may be no "opened" flow to reveal them, e.g. a sidebar-less autoplay
1063
+ // embed), only dim them. Bases selection on the focused step, not the
1064
+ // hover preview, so hovering doesn't strip the frame's `isSelected`.
1022
1065
  const isSelected = selected?.alias !== undefined && comp?.alias === selected.alias;
1023
1066
  const vis = flowElementVisibility({
1024
1067
  inOpened: openedNodeIds?.has(n.id) === true,
1025
1068
  inSelected: brightNodeIds?.has(n.id) === true,
1026
1069
  anyOpened: openedNodeIds != null,
1027
- anySelected: brightNodeIds != null,
1070
+ anySelected: brightNodeIds != null || previewNodeIds != null,
1028
1071
  });
1029
1072
  const dimmed = previewNodeIds ? (vis.hidden || !previewNodeIds.has(n.id)) : vis.dimmed;
1030
- const hidden = vis.hidden;
1073
+ const hidden = noOpenedFlow ? false : vis.hidden;
1031
1074
  if (fileMatch === undefined && !isSelected && !dimmed) {
1032
1075
  const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
1033
1076
  return { ...n, hidden, data: rest };
@@ -1242,12 +1285,9 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1242
1285
  // Boundary frame → frame its members on the canvas; double-click again to
1243
1286
  // zoom back out. User-initiated, so it ignores the walkthrough zoom gate.
1244
1287
  if (node.type === 'subsystem-group') {
1245
- const region =
1246
- (node.data as { region?: { key?: string; memberAliases?: string[] } } | undefined)
1247
- ?.region;
1248
- const key = region?.key;
1249
- const members = region?.memberAliases ?? [];
1250
- if (!key || members.length === 0) return;
1288
+ const key =
1289
+ (node.data as { region?: { key?: string } } | undefined)?.region?.key;
1290
+ if (!key) return;
1251
1291
  if (focusedBoundaryKey === key) {
1252
1292
  setFocusedBoundaryKey(null);
1253
1293
  fitView({
@@ -1260,8 +1300,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1260
1300
  return;
1261
1301
  }
1262
1302
  setFocusedBoundaryKey(key);
1303
+ // Frame the boundary node itself (not its members): the frame extends
1304
+ // above its topmost member for the label, and fitting members alone
1305
+ // clipped that top. The group node carries the full laid-out bounds.
1263
1306
  fitView({
1264
- nodes: members.map((id) => ({ id })),
1307
+ nodes: [{ id: node.id }],
1265
1308
  padding: 0.25,
1266
1309
  duration: 300,
1267
1310
  });
@@ -1317,27 +1360,70 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1317
1360
  return () => window.removeEventListener('keydown', onKey);
1318
1361
  }, [fileOverlayOpen, closeFileOverlay]);
1319
1362
 
1320
- // Camera helper shared by the walkthrough interactions: frames the focused
1321
- // edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
1322
- // live positions + measured dims — so the frame always includes BOTH
1323
- // components the edge attaches to (and therefore the edge line between them).
1363
+ // Camera helper shared by the walkthrough interactions. Fits the union of
1364
+ // the focused edges' endpoint rects AND their routed waypoints — the edge
1365
+ // carries its full polyline (`elkPathPoints`, absolute flow coords), so the
1366
+ // frame is the line the hop actually traces. That keeps a hop whose route
1367
+ // bulges out around intervening nodes from being clipped at the viewport
1368
+ // edge, and it is the edge's own geometry doing the guiding, not fudge
1369
+ // padding.
1324
1370
  const fitFocusBounds = useCallback(
1325
1371
  (ids: ReadonlySet<string>) => {
1326
1372
  if (!zoomOnWalkthroughFocus) return;
1327
1373
  const nodeIds = new Set<string>();
1374
+ const points: { x: number; y: number }[] = [];
1328
1375
  for (const e of baseEdges) {
1329
1376
  if (!ids.has(e.id)) continue;
1330
1377
  nodeIds.add(e.source);
1331
1378
  nodeIds.add(e.target);
1379
+ const pts = (e.data as { elkPathPoints?: { x: number; y: number }[] } | undefined)
1380
+ ?.elkPathPoints;
1381
+ if (pts?.length) points.push(...pts);
1332
1382
  }
1333
1383
  if (nodeIds.size === 0) return;
1334
- fitView({
1335
- nodes: [...nodeIds].map((id) => ({ id })),
1336
- padding: 0.25,
1337
- duration: 300,
1384
+
1385
+ // Box the focused edge(s): endpoint node rects UNION the routed line's
1386
+ // waypoints. Both come from the layout in absolute flow coords, so no
1387
+ // coordinate-space mixing (React Flow's grouped child `position`s are
1388
+ // parent-relative and would skew the box).
1389
+ const rects = built.absoluteRects;
1390
+ let minX = Infinity;
1391
+ let minY = Infinity;
1392
+ let maxX = -Infinity;
1393
+ let maxY = -Infinity;
1394
+ for (const id of nodeIds) {
1395
+ const r = rects.get(id);
1396
+ if (!r) continue;
1397
+ minX = Math.min(minX, r.x);
1398
+ minY = Math.min(minY, r.y);
1399
+ maxX = Math.max(maxX, r.x + r.width);
1400
+ maxY = Math.max(maxY, r.y + r.height);
1401
+ }
1402
+ for (const p of points) {
1403
+ minX = Math.min(minX, p.x);
1404
+ minY = Math.min(minY, p.y);
1405
+ maxX = Math.max(maxX, p.x);
1406
+ maxY = Math.max(maxY, p.y);
1407
+ }
1408
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
1409
+ const bounds = {
1410
+ x: minX,
1411
+ y: minY,
1412
+ width: Math.max(1, maxX - minX),
1413
+ height: Math.max(1, maxY - minY),
1414
+ };
1415
+ fitBounds(bounds, {
1416
+ padding: 0.15,
1417
+ duration: walkthroughFocusDurationMs,
1338
1418
  });
1339
1419
  },
1340
- [baseEdges, fitView, zoomOnWalkthroughFocus],
1420
+ [
1421
+ baseEdges,
1422
+ built.absoluteRects,
1423
+ fitBounds,
1424
+ zoomOnWalkthroughFocus,
1425
+ walkthroughFocusDurationMs,
1426
+ ],
1341
1427
  );
1342
1428
 
1343
1429
  // Zoom back out to the full diagram after the last expanded walkthrough
@@ -1511,8 +1597,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1511
1597
  prevPreviewEdgeIdsRef.current = previewEdgeIds;
1512
1598
 
1513
1599
  if (!zoomOnWalkthroughFocus || walkthroughStepMode === 'dim') return;
1514
- // Camera follows hover only when a specific step is already focused.
1515
- if (focusedWalkthroughId == null || focusedStepIndex == null) return;
1600
+ // Camera follows hover only when a specific step is already focused (or
1601
+ // autoplay is driving an auto-focus).
1602
+ if (!autoPlayFocus && (focusedWalkthroughId == null || focusedStepIndex == null)) {
1603
+ return;
1604
+ }
1516
1605
 
1517
1606
  if (previewEdgeIds) {
1518
1607
  fitFocusBounds(previewEdgeIds);
@@ -1526,46 +1615,93 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1526
1615
  focusEdgeIds,
1527
1616
  focusedWalkthroughId,
1528
1617
  focusedStepIndex,
1618
+ autoPlayFocus,
1529
1619
  fitFocusBounds,
1530
1620
  zoomOnWalkthroughFocus,
1531
1621
  walkthroughStepMode,
1532
1622
  ]);
1533
1623
 
1534
- // Graph-only embeds: cycle walkthrough steps with hover-style dimming.
1624
+ // Graph-only embeds: cycle walkthrough steps. In `focus` mode each step is
1625
+ // selected (camera frames it via fitFocusBounds) and, when a walkthrough
1626
+ // viewer is supplied, its snippet drawer opens; in `dim` mode it only
1627
+ // dim-highlights the step (hover-style) with no camera move.
1535
1628
  useEffect(() => {
1536
- if (!autoPlayWalkthroughs || !walkthroughs?.length || !layoutReady) return;
1629
+ if (!autoPlayWalkthroughs || walkthroughs == null || walkthroughs.length === 0) return;
1630
+ if (!layoutReady) return;
1537
1631
  const playable = walkthroughs.filter((tl) => tl.steps.length > 0);
1538
1632
  if (playable.length === 0) return;
1633
+ // Only run the cycles that can actually be shown in the current edge view.
1634
+ const inView = playable.filter((tl) =>
1635
+ tl.steps.some((s) =>
1636
+ resolvedEdgeView === 'relations'
1637
+ ? isRelationMechanism(s.mechanism)
1638
+ : isWalkthroughMechanism(s.mechanism),
1639
+ ),
1640
+ );
1641
+ if (inView.length === 0) return;
1642
+ const cyc = inView;
1539
1643
 
1540
1644
  let cancelled = false;
1541
1645
  let tlIdx = 0;
1542
1646
  let stepIdx = 0;
1543
1647
  let timer: number | null = null;
1544
1648
  const interval = Math.max(400, walkthroughAutoPlayIntervalMs);
1649
+ const focusMode = walkthroughStepMode !== 'dim';
1650
+ let cleanup = () => {};
1545
1651
 
1546
1652
  const tick = () => {
1547
1653
  if (cancelled) return;
1548
- const tl = playable[tlIdx]!;
1654
+ const tl = cyc[tlIdx]!;
1549
1655
  setSelected(null);
1550
1656
  setSelectedEdgeId(null);
1551
- setFocusedWalkthroughId(null);
1552
- setFocusedStepIndex(null);
1553
- setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: stepIdx });
1657
+ if (focusMode) {
1658
+ setHoveredWalkthroughStep(null);
1659
+ setFocusedWalkthroughId(null);
1660
+ setFocusedStepIndex(null);
1661
+ setAutoPlayFocus({ walkthroughId: tl.id, stepIndex: stepIdx });
1662
+ if (!hideDrawer) {
1663
+ const step = tl.steps[stepIdx];
1664
+ if (step) {
1665
+ if (renderWalkthroughViewer) {
1666
+ setDrawerTarget({ kind: 'walkthrough', walkthroughId: tl.id, stepIndex: stepIdx });
1667
+ } else {
1668
+ setDrawerTarget({ kind: 'file', file: step.file, startLine: step.line });
1669
+ }
1670
+ }
1671
+ }
1672
+ } else {
1673
+ setAutoPlayFocus(null);
1674
+ setFocusedWalkthroughId(null);
1675
+ setFocusedStepIndex(null);
1676
+ setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: stepIdx });
1677
+ }
1554
1678
  stepIdx += 1;
1555
1679
  if (stepIdx >= tl.steps.length) {
1556
1680
  stepIdx = 0;
1557
- tlIdx = (tlIdx + 1) % playable.length;
1681
+ tlIdx = (tlIdx + 1) % cyc.length;
1558
1682
  }
1559
1683
  timer = window.setTimeout(tick, interval);
1560
1684
  };
1561
1685
 
1562
1686
  tick();
1563
- return () => {
1687
+ cleanup = () => {
1564
1688
  cancelled = true;
1565
1689
  if (timer != null) window.clearTimeout(timer);
1566
1690
  setHoveredWalkthroughStep(null);
1691
+ setFocusedWalkthroughId(null);
1692
+ setFocusedStepIndex(null);
1693
+ setAutoPlayFocus(null);
1567
1694
  };
1568
- }, [autoPlayWalkthroughs, walkthroughs, walkthroughAutoPlayIntervalMs, layoutReady]);
1695
+ return cleanup;
1696
+ }, [
1697
+ autoPlayWalkthroughs,
1698
+ walkthroughs,
1699
+ walkthroughAutoPlayIntervalMs,
1700
+ layoutReady,
1701
+ walkthroughStepMode,
1702
+ renderWalkthroughViewer,
1703
+ hideDrawer,
1704
+ ]);
1569
1705
 
1570
1706
  // Arrow keys step through the focused walkthrough once a step is active
1571
1707
  // (sidebar click or drawer open). Ignores typing targets and chords.
@@ -2390,6 +2526,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
2390
2526
  onClose={closeDrawer}
2391
2527
  fillHeight={drawerFillHeight}
2392
2528
  suppressEscape={fileOverlayOpen}
2529
+ hidden={hideDrawer}
2393
2530
  >
2394
2531
  {drawerTarget?.kind === 'walkthrough' &&
2395
2532
  focusedWalkthrough &&
@@ -984,6 +984,13 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
984
984
  pathLength?: number;
985
985
  /** ELK-computed SVG edge path (overrides React Flow's default path). */
986
986
  elkPath?: string;
987
+ /**
988
+ * ELK-computed route waypoints (absolute flow coords) for this edge. Kept so
989
+ * the camera can include the whole routed line — not just the endpoint nodes
990
+ * — when framing a focused hop, which otherwise clips edges that bulge out
991
+ * around their endpoints.
992
+ */
993
+ elkPathPoints?: { x: number; y: number }[];
987
994
  }
988
995
 
989
996
  export type SubsystemGraphEdge = Edge<SubsystemGraphEdgeData>;
@@ -1544,6 +1551,12 @@ export async function buildSubsystemGraph(
1544
1551
  nodes: SubsystemGraphNode[];
1545
1552
  edges: SubsystemGraphEdge[];
1546
1553
  regions: SubsystemProcessRegion[];
1554
+ /**
1555
+ * Absolute flow-coord rect per node id (leaves + groups), same space as each
1556
+ * edge's `elkPathPoints`. Lets the camera union node rects with a routed edge
1557
+ * without mixing React Flow's parent-relative child positions in.
1558
+ */
1559
+ absoluteRects: Map<string, { x: number; y: number; width: number; height: number }>;
1547
1560
  }> {
1548
1561
  const {
1549
1562
  maxNodeWidth,
@@ -1637,6 +1650,10 @@ export async function buildSubsystemGraph(
1637
1650
  let labelPositions = new Map<string, { x: number; y: number }>();
1638
1651
  let elkPathStrings = new Map<string, string>();
1639
1652
  let elkPathPoints = new Map<string, { x: number; y: number }[]>();
1653
+ let absoluteRects = new Map<
1654
+ string,
1655
+ { x: number; y: number; width: number; height: number }
1656
+ >();
1640
1657
  if (nodes.length > 0) {
1641
1658
  try {
1642
1659
  const result = await computeElkLayout(nodes, edges, {
@@ -1711,6 +1728,7 @@ export async function buildSubsystemGraph(
1711
1728
  labelPositions = result.edgeLabelPositions;
1712
1729
  elkPathStrings = result.edgePaths;
1713
1730
  elkPathPoints = result.edgePathPoints;
1731
+ absoluteRects = result.absoluteRects;
1714
1732
  } catch (err) {
1715
1733
  // Fall back to the (unpositioned) grid if ELK is unavailable — still
1716
1734
  // emit multi-member frames so parentId targets exist.
@@ -1764,8 +1782,9 @@ export async function buildSubsystemGraph(
1764
1782
  if (pts && pts.length > 1) {
1765
1783
  const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
1766
1784
  d.pathLength = calculatePathLength(pts);
1785
+ d.elkPathPoints = pts;
1767
1786
  }
1768
1787
  }
1769
1788
 
1770
- return { nodes: placedNodes, edges, regions };
1789
+ return { nodes: placedNodes, edges, regions, absoluteRects };
1771
1790
  }
@@ -116,6 +116,13 @@ export interface ElkLayoutResult {
116
116
  edgePathPoints: Map<string, Point[]>;
117
117
  /** Compound parent bounds from ELK (absolute flow coords), keyed by group id. */
118
118
  groupBounds: Map<string, { x: number; y: number; width: number; height: number }>;
119
+ /**
120
+ * Absolute flow-coord rect for every laid-out node (leaves and groups),
121
+ * including grouped children whose React Flow `position` is parent-relative.
122
+ * Same space as `edgePathPoints`, so callers can union node rects with a
123
+ * route without mixing coordinate systems.
124
+ */
125
+ absoluteRects: Map<string, { x: number; y: number; width: number; height: number }>;
119
126
  }
120
127
 
121
128
  /** Point in 2D space */
@@ -744,10 +751,22 @@ export async function computeElkLayout(
744
751
  const elkPositions = new Map<string, { x: number; y: number }>();
745
752
  const elkRelativePositions = new Map<string, { x: number; y: number }>();
746
753
  const groupBounds = new Map<string, { x: number; y: number; width: number; height: number }>();
754
+ // Absolute rect per node, in the same space as `edgePathPoints`. Uses ELK's
755
+ // own sizes so it's valid before React Flow has measured anything.
756
+ const absoluteRects = new Map<
757
+ string,
758
+ { x: number; y: number; width: number; height: number }
759
+ >();
747
760
  const walkElk = (n: ElkNode, ox: number, oy: number) => {
748
761
  const ax = ox + (n.x ?? 0);
749
762
  const ay = oy + (n.y ?? 0);
750
763
  elkAbsOffsets.set(n.id, { x: ax, y: ay });
764
+ absoluteRects.set(n.id, {
765
+ x: ax,
766
+ y: ay,
767
+ width: n.width ?? 0,
768
+ height: n.height ?? 0,
769
+ });
751
770
  for (const c of n.children ?? []) walkElk(c, ax, ay);
752
771
  };
753
772
  walkElk(layoutedGraph, 0, 0);
@@ -969,6 +988,7 @@ export async function computeElkLayout(
969
988
  edgeLabelPositions,
970
989
  edgePathPoints,
971
990
  groupBounds,
991
+ absoluteRects,
972
992
  };
973
993
  }
974
994