@principal-ai/subsystems-react 0.37.10 → 0.37.12

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);
@@ -623,31 +641,50 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
623
641
  if (drawerTarget?.kind !== 'walkthrough' || !walkthroughs) return null;
624
642
  return walkthroughs.find((t) => t.id === drawerTarget.walkthroughId) ?? null;
625
643
  }, [drawerTarget, walkthroughs]);
644
+ // Autoplay focus: a sidebar-less embed has no expanded flow to "open", so
645
+ // branding every step as a selection would hide all other nodes. Instead the
646
+ // autoplay sets this (flow id + step) to frame the hop and dim the rest —
647
+ // same treatment as hover — without hiding anything. Declared before the
648
+ // overlay memos, which read it to title the canvas chip + step bar.
649
+ const [autoPlayFocus, setAutoPlayFocus] = useState<
650
+ { walkthroughId: string; stepIndex: number } | null
651
+ >(null);
626
652
  // Ref mirror of the drawer's walkthrough id so the symbol resolver stays
627
653
  // stable across graph re-renders (the drawer is memoized on callback identity).
628
654
  const drawerWalkthroughIdRef = useRef<string | null>(null);
629
655
  drawerWalkthroughIdRef.current = focusedWalkthrough?.id ?? null;
630
656
 
631
- // Walkthrough shown on the canvas title chip (focus or hover/autoplay highlight).
657
+ // Walkthrough shown on the canvas title chip (focus, hover highlight, or
658
+ // autoplay focus).
632
659
  const overlayWalkthroughTitle = useMemo(() => {
633
660
  if (!showWalkthroughTitle || !walkthroughs?.length) return null;
634
- const id = focusedWalkthroughId ?? hoveredWalkthroughStep?.walkthroughId;
661
+ const id =
662
+ focusedWalkthroughId ??
663
+ autoPlayFocus?.walkthroughId ??
664
+ hoveredWalkthroughStep?.walkthroughId;
635
665
  if (!id) return null;
636
666
  return walkthroughs.find((t) => t.id === id)?.title ?? null;
637
667
  }, [
638
668
  showWalkthroughTitle,
639
669
  walkthroughs,
640
670
  focusedWalkthroughId,
671
+ autoPlayFocus,
641
672
  hoveredWalkthroughStep,
642
673
  ]);
643
674
 
644
675
  // Active step for the bottom-of-title progress + annotation chip.
645
676
  const overlayWalkthroughStep = useMemo(() => {
646
677
  if (!showWalkthroughTitle || !walkthroughs?.length) return null;
647
- const tlId = focusedWalkthroughId ?? hoveredWalkthroughStep?.walkthroughId ?? null;
678
+ const tlId =
679
+ focusedWalkthroughId ??
680
+ autoPlayFocus?.walkthroughId ??
681
+ hoveredWalkthroughStep?.walkthroughId ??
682
+ null;
648
683
  let stepIndex: number | null = null;
649
684
  if (focusedWalkthroughId != null) {
650
685
  stepIndex = focusedStepIndex;
686
+ } else if (autoPlayFocus != null) {
687
+ stepIndex = autoPlayFocus.stepIndex;
651
688
  } else if (hoveredWalkthroughStep != null) {
652
689
  stepIndex = hoveredWalkthroughStep.stepIndex;
653
690
  }
@@ -665,6 +702,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
665
702
  walkthroughs,
666
703
  focusedWalkthroughId,
667
704
  focusedStepIndex,
705
+ autoPlayFocus,
668
706
  hoveredWalkthroughStep,
669
707
  ]);
670
708
 
@@ -727,7 +765,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
727
765
  showEdgeLabels,
728
766
  showSingletonFrames,
729
767
  })
730
- .then(({ nodes, edges: e }) => {
768
+ .then(({ nodes, edges: e, absoluteRects }) => {
731
769
  if (!alive) return;
732
770
  // Prune dims for removed leaves; keep measurements for stable ids so a
733
771
  // live update can finish Pass 2 without waiting on new `dimensions` events.
@@ -737,14 +775,14 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
737
775
  for (const id of measuredDimsRef.current.keys()) {
738
776
  if (!leafIds.has(id)) measuredDimsRef.current.delete(id);
739
777
  }
740
- setBuilt({ nodes, edges: e as Edge[] });
778
+ setBuilt({ nodes, edges: e as Edge[], absoluteRects });
741
779
  setLayoutReady(false);
742
780
  })
743
781
  .catch((err) => {
744
782
  console.warn('[subsystem-graph] initial layout failed:', err);
745
783
  if (!alive) return;
746
784
  // Reveal the cover even on failure so the UI is not stuck forever.
747
- setBuilt({ nodes: [], edges: [] });
785
+ setBuilt({ nodes: [], edges: [], absoluteRects: new Map() });
748
786
  setLayoutReady(true);
749
787
  });
750
788
  return () => { alive = false; };
@@ -777,9 +815,9 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
777
815
  { components, relations, walkthroughs },
778
816
  { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames },
779
817
  )
780
- .then(({ nodes, edges: e }) => {
818
+ .then(({ nodes, edges: e, absoluteRects }) => {
781
819
  if (gen !== pass2GenRef.current) return;
782
- setBuilt({ nodes, edges: e as Edge[] });
820
+ setBuilt({ nodes, edges: e as Edge[], absoluteRects });
783
821
  setLayoutReady(true);
784
822
  })
785
823
  .catch((err) => {
@@ -879,6 +917,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
879
917
  // Edge ids in walkthrough focus (an active flow's edge set, or a single
880
918
  // step's edge). Used to frame the camera. `null` = no walkthrough focus.
881
919
  const focusEdgeIds = useMemo(() => {
920
+ if (autoPlayFocus) {
921
+ const tl = walkthroughs?.find((t) => t.id === autoPlayFocus.walkthroughId);
922
+ const step = tl?.steps[autoPlayFocus.stepIndex];
923
+ return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
924
+ }
882
925
  if (focusedWalkthroughId == null || !walkthroughs) return null;
883
926
  const tl = walkthroughs.find((t) => t.id === focusedWalkthroughId);
884
927
  if (!tl) return null;
@@ -887,7 +930,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
887
930
  return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
888
931
  }
889
932
  return new Set(tl.steps.map((s) => walkthroughStepGraphEdgeId(s)));
890
- }, [walkthroughs, focusedWalkthroughId, focusedStepIndex]);
933
+ }, [walkthroughs, focusedWalkthroughId, focusedStepIndex, autoPlayFocus]);
891
934
 
892
935
  // 1-based step numbers per edge of the active flow (focused or
893
936
  // hover/autoplay-highlighted). An edge can appear in more than one step.
@@ -941,12 +984,17 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
941
984
  // selected + hovered participants. Whole-flow focus (or no focus) keeps
942
985
  // the old hover-replace preview so a step hover still dims the rest.
943
986
  const previewEdgeIds = useMemo(() => {
987
+ // Autoplay drives focus without a hover, so its hop is the preview set.
988
+ if (autoPlayFocus) return focusEdgeIds;
944
989
  if (!hoverEdgeIds) return null;
990
+ // While hovering with a *step* already selected, brighten the union of
991
+ // selected + hovered participants; whole-flow focus keeps the old
992
+ // hover-replace preview so a step hover still dims the rest.
945
993
  if (focusedStepIndex == null || !focusEdgeIds) return hoverEdgeIds;
946
994
  const ids = new Set(hoverEdgeIds);
947
995
  for (const id of focusEdgeIds) ids.add(id);
948
996
  return ids;
949
- }, [hoverEdgeIds, focusEdgeIds, focusedStepIndex]);
997
+ }, [hoverEdgeIds, focusEdgeIds, focusedStepIndex, autoPlayFocus]);
950
998
  const previewNodeIds = useMemo(
951
999
  () => endpointsOf(previewEdgeIds),
952
1000
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -988,22 +1036,26 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
988
1036
  // While a step/flow is hovered, selected + hovered participants stay
989
1037
  // bright; everything else among the currently-visible (opened-walkthrough)
990
1038
  // nodes is dimmed. Nodes outside the opened-walkthrough set stay hidden.
1039
+ // Exception: with no expanded flow at all (a sidebar-less embed), there is
1040
+ // nothing to hide toward — everything shows, and focus/hover only dims.
1041
+ const noOpenedFlow = openedEdgeIds == null;
991
1042
  return xyflowNodesBase.map((n) => {
992
1043
  // Boundary frames follow their members: hidden when no member is
993
1044
  // visible, dimmed when members are dimmed. Never selectable.
994
1045
  if (n.type === 'subsystem-group') {
995
1046
  const region = (n.data as { region?: { key?: string; memberAliases?: string[] } } | undefined)?.region;
996
1047
  const memberAliases = region?.memberAliases ?? [];
1048
+ // Hover/autoplay dim-only: never hide a frame (see the node branch).
997
1049
  const vis = flowElementVisibility({
998
1050
  inOpened: memberAliases.some((alias) => openedNodeIds?.has(alias) === true),
999
1051
  inSelected: memberAliases.some((alias) => brightNodeIds?.has(alias) === true),
1000
1052
  anyOpened: openedNodeIds != null,
1001
- anySelected: brightNodeIds != null,
1053
+ anySelected: brightNodeIds != null || previewNodeIds != null,
1002
1054
  });
1003
1055
  const dimmed = previewNodeIds
1004
1056
  ? vis.hidden || !memberAliases.some((alias) => previewNodeIds.has(alias))
1005
1057
  : vis.dimmed;
1006
- const hidden = vis.hidden;
1058
+ const hidden = noOpenedFlow ? false : vis.hidden;
1007
1059
  // Host override wins over the library's derived frame color.
1008
1060
  const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
1009
1061
  return {
@@ -1019,15 +1071,19 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1019
1071
  }
1020
1072
  const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
1021
1073
  const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
1074
+ // Hover/autoplay is a dim-only signal: never hide non-participants (there
1075
+ // may be no "opened" flow to reveal them, e.g. a sidebar-less autoplay
1076
+ // embed), only dim them. Bases selection on the focused step, not the
1077
+ // hover preview, so hovering doesn't strip the frame's `isSelected`.
1022
1078
  const isSelected = selected?.alias !== undefined && comp?.alias === selected.alias;
1023
1079
  const vis = flowElementVisibility({
1024
1080
  inOpened: openedNodeIds?.has(n.id) === true,
1025
1081
  inSelected: brightNodeIds?.has(n.id) === true,
1026
1082
  anyOpened: openedNodeIds != null,
1027
- anySelected: brightNodeIds != null,
1083
+ anySelected: brightNodeIds != null || previewNodeIds != null,
1028
1084
  });
1029
1085
  const dimmed = previewNodeIds ? (vis.hidden || !previewNodeIds.has(n.id)) : vis.dimmed;
1030
- const hidden = vis.hidden;
1086
+ const hidden = noOpenedFlow ? false : vis.hidden;
1031
1087
  if (fileMatch === undefined && !isSelected && !dimmed) {
1032
1088
  const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
1033
1089
  return { ...n, hidden, data: rest };
@@ -1317,27 +1373,70 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1317
1373
  return () => window.removeEventListener('keydown', onKey);
1318
1374
  }, [fileOverlayOpen, closeFileOverlay]);
1319
1375
 
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).
1376
+ // Camera helper shared by the walkthrough interactions. Fits the union of
1377
+ // the focused edges' endpoint rects AND their routed waypoints — the edge
1378
+ // carries its full polyline (`elkPathPoints`, absolute flow coords), so the
1379
+ // frame is the line the hop actually traces. That keeps a hop whose route
1380
+ // bulges out around intervening nodes from being clipped at the viewport
1381
+ // edge, and it is the edge's own geometry doing the guiding, not fudge
1382
+ // padding.
1324
1383
  const fitFocusBounds = useCallback(
1325
1384
  (ids: ReadonlySet<string>) => {
1326
1385
  if (!zoomOnWalkthroughFocus) return;
1327
1386
  const nodeIds = new Set<string>();
1387
+ const points: { x: number; y: number }[] = [];
1328
1388
  for (const e of baseEdges) {
1329
1389
  if (!ids.has(e.id)) continue;
1330
1390
  nodeIds.add(e.source);
1331
1391
  nodeIds.add(e.target);
1392
+ const pts = (e.data as { elkPathPoints?: { x: number; y: number }[] } | undefined)
1393
+ ?.elkPathPoints;
1394
+ if (pts?.length) points.push(...pts);
1332
1395
  }
1333
1396
  if (nodeIds.size === 0) return;
1334
- fitView({
1335
- nodes: [...nodeIds].map((id) => ({ id })),
1336
- padding: 0.25,
1337
- duration: 300,
1397
+
1398
+ // Box the focused edge(s): endpoint node rects UNION the routed line's
1399
+ // waypoints. Both come from the layout in absolute flow coords, so no
1400
+ // coordinate-space mixing (React Flow's grouped child `position`s are
1401
+ // parent-relative and would skew the box).
1402
+ const rects = built.absoluteRects;
1403
+ let minX = Infinity;
1404
+ let minY = Infinity;
1405
+ let maxX = -Infinity;
1406
+ let maxY = -Infinity;
1407
+ for (const id of nodeIds) {
1408
+ const r = rects.get(id);
1409
+ if (!r) continue;
1410
+ minX = Math.min(minX, r.x);
1411
+ minY = Math.min(minY, r.y);
1412
+ maxX = Math.max(maxX, r.x + r.width);
1413
+ maxY = Math.max(maxY, r.y + r.height);
1414
+ }
1415
+ for (const p of points) {
1416
+ minX = Math.min(minX, p.x);
1417
+ minY = Math.min(minY, p.y);
1418
+ maxX = Math.max(maxX, p.x);
1419
+ maxY = Math.max(maxY, p.y);
1420
+ }
1421
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
1422
+ const bounds = {
1423
+ x: minX,
1424
+ y: minY,
1425
+ width: Math.max(1, maxX - minX),
1426
+ height: Math.max(1, maxY - minY),
1427
+ };
1428
+ fitBounds(bounds, {
1429
+ padding: 0.15,
1430
+ duration: walkthroughFocusDurationMs,
1338
1431
  });
1339
1432
  },
1340
- [baseEdges, fitView, zoomOnWalkthroughFocus],
1433
+ [
1434
+ baseEdges,
1435
+ built.absoluteRects,
1436
+ fitBounds,
1437
+ zoomOnWalkthroughFocus,
1438
+ walkthroughFocusDurationMs,
1439
+ ],
1341
1440
  );
1342
1441
 
1343
1442
  // Zoom back out to the full diagram after the last expanded walkthrough
@@ -1511,8 +1610,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1511
1610
  prevPreviewEdgeIdsRef.current = previewEdgeIds;
1512
1611
 
1513
1612
  if (!zoomOnWalkthroughFocus || walkthroughStepMode === 'dim') return;
1514
- // Camera follows hover only when a specific step is already focused.
1515
- if (focusedWalkthroughId == null || focusedStepIndex == null) return;
1613
+ // Camera follows hover only when a specific step is already focused (or
1614
+ // autoplay is driving an auto-focus).
1615
+ if (!autoPlayFocus && (focusedWalkthroughId == null || focusedStepIndex == null)) {
1616
+ return;
1617
+ }
1516
1618
 
1517
1619
  if (previewEdgeIds) {
1518
1620
  fitFocusBounds(previewEdgeIds);
@@ -1526,46 +1628,93 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1526
1628
  focusEdgeIds,
1527
1629
  focusedWalkthroughId,
1528
1630
  focusedStepIndex,
1631
+ autoPlayFocus,
1529
1632
  fitFocusBounds,
1530
1633
  zoomOnWalkthroughFocus,
1531
1634
  walkthroughStepMode,
1532
1635
  ]);
1533
1636
 
1534
- // Graph-only embeds: cycle walkthrough steps with hover-style dimming.
1637
+ // Graph-only embeds: cycle walkthrough steps. In `focus` mode each step is
1638
+ // selected (camera frames it via fitFocusBounds) and, when a walkthrough
1639
+ // viewer is supplied, its snippet drawer opens; in `dim` mode it only
1640
+ // dim-highlights the step (hover-style) with no camera move.
1535
1641
  useEffect(() => {
1536
- if (!autoPlayWalkthroughs || !walkthroughs?.length || !layoutReady) return;
1642
+ if (!autoPlayWalkthroughs || walkthroughs == null || walkthroughs.length === 0) return;
1643
+ if (!layoutReady) return;
1537
1644
  const playable = walkthroughs.filter((tl) => tl.steps.length > 0);
1538
1645
  if (playable.length === 0) return;
1646
+ // Only run the cycles that can actually be shown in the current edge view.
1647
+ const inView = playable.filter((tl) =>
1648
+ tl.steps.some((s) =>
1649
+ resolvedEdgeView === 'relations'
1650
+ ? isRelationMechanism(s.mechanism)
1651
+ : isWalkthroughMechanism(s.mechanism),
1652
+ ),
1653
+ );
1654
+ if (inView.length === 0) return;
1655
+ const cyc = inView;
1539
1656
 
1540
1657
  let cancelled = false;
1541
1658
  let tlIdx = 0;
1542
1659
  let stepIdx = 0;
1543
1660
  let timer: number | null = null;
1544
1661
  const interval = Math.max(400, walkthroughAutoPlayIntervalMs);
1662
+ const focusMode = walkthroughStepMode !== 'dim';
1663
+ let cleanup = () => {};
1545
1664
 
1546
1665
  const tick = () => {
1547
1666
  if (cancelled) return;
1548
- const tl = playable[tlIdx]!;
1667
+ const tl = cyc[tlIdx]!;
1549
1668
  setSelected(null);
1550
1669
  setSelectedEdgeId(null);
1551
- setFocusedWalkthroughId(null);
1552
- setFocusedStepIndex(null);
1553
- setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: stepIdx });
1670
+ if (focusMode) {
1671
+ setHoveredWalkthroughStep(null);
1672
+ setFocusedWalkthroughId(null);
1673
+ setFocusedStepIndex(null);
1674
+ setAutoPlayFocus({ walkthroughId: tl.id, stepIndex: stepIdx });
1675
+ if (!hideDrawer) {
1676
+ const step = tl.steps[stepIdx];
1677
+ if (step) {
1678
+ if (renderWalkthroughViewer) {
1679
+ setDrawerTarget({ kind: 'walkthrough', walkthroughId: tl.id, stepIndex: stepIdx });
1680
+ } else {
1681
+ setDrawerTarget({ kind: 'file', file: step.file, startLine: step.line });
1682
+ }
1683
+ }
1684
+ }
1685
+ } else {
1686
+ setAutoPlayFocus(null);
1687
+ setFocusedWalkthroughId(null);
1688
+ setFocusedStepIndex(null);
1689
+ setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: stepIdx });
1690
+ }
1554
1691
  stepIdx += 1;
1555
1692
  if (stepIdx >= tl.steps.length) {
1556
1693
  stepIdx = 0;
1557
- tlIdx = (tlIdx + 1) % playable.length;
1694
+ tlIdx = (tlIdx + 1) % cyc.length;
1558
1695
  }
1559
1696
  timer = window.setTimeout(tick, interval);
1560
1697
  };
1561
1698
 
1562
1699
  tick();
1563
- return () => {
1700
+ cleanup = () => {
1564
1701
  cancelled = true;
1565
1702
  if (timer != null) window.clearTimeout(timer);
1566
1703
  setHoveredWalkthroughStep(null);
1704
+ setFocusedWalkthroughId(null);
1705
+ setFocusedStepIndex(null);
1706
+ setAutoPlayFocus(null);
1567
1707
  };
1568
- }, [autoPlayWalkthroughs, walkthroughs, walkthroughAutoPlayIntervalMs, layoutReady]);
1708
+ return cleanup;
1709
+ }, [
1710
+ autoPlayWalkthroughs,
1711
+ walkthroughs,
1712
+ walkthroughAutoPlayIntervalMs,
1713
+ layoutReady,
1714
+ walkthroughStepMode,
1715
+ renderWalkthroughViewer,
1716
+ hideDrawer,
1717
+ ]);
1569
1718
 
1570
1719
  // Arrow keys step through the focused walkthrough once a step is active
1571
1720
  // (sidebar click or drawer open). Ignores typing targets and chords.
@@ -2390,6 +2539,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
2390
2539
  onClose={closeDrawer}
2391
2540
  fillHeight={drawerFillHeight}
2392
2541
  suppressEscape={fileOverlayOpen}
2542
+ hidden={hideDrawer}
2393
2543
  >
2394
2544
  {drawerTarget?.kind === 'walkthrough' &&
2395
2545
  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