@principal-ai/subsystems-react 0.18.0 → 0.19.1

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.
@@ -15,7 +15,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
15
15
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import { ReactFlow, ReactFlowProvider, Background, BackgroundVariant, Controls, useReactFlow, useViewport, applyNodeChanges, } from '@xyflow/react';
17
17
  import { useTheme } from '@principal-ade/industry-theme';
18
- import { ChevronDown, ChevronUp, X } from 'lucide-react';
18
+ import { ChevronDown, ChevronUp, Pause, Play, X } from 'lucide-react';
19
19
  import { IndustryMarkdownSlide } from 'themed-markdown';
20
20
  import { buildSubsystemGraph, MECHANISM_COLOR, MECHANISM_DESCRIPTIONS, subsystemGraphLayoutKey, } from './model';
21
21
  import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
@@ -29,6 +29,8 @@ const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
29
29
  /** Rough monospace width at fontSize 10 + horizontal padding/border. */
30
30
  const EDGE_LABEL_CHAR_PX = 6.2;
31
31
  const EDGE_LABEL_PAD_PX = 18;
32
+ /** Pause (ms) between steps when a throughline autoplays. */
33
+ const THROUGHLINE_PLAY_PAUSE_MS = 2500;
32
34
  const nodeTypes = {
33
35
  'subsystem-component': SubsystemComponentNode,
34
36
  'subsystem-group': SubsystemGroupNode,
@@ -46,7 +48,7 @@ const FileDrawerContent = memo(function FileDrawerContent({ render, file, startL
46
48
  const ThroughlineDrawerContent = memo(function ThroughlineDrawerContent({ render, throughline, stepIndex, }) {
47
49
  return _jsx(_Fragment, { children: render({ throughline, stepIndex }) });
48
50
  });
49
- function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }) {
51
+ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, throughlineStepMode = 'focus', autoPlayThroughlines = false, throughlineAutoPlayIntervalMs = THROUGHLINE_PLAY_PAUSE_MS, zoomOnThroughlineFocus = true, graphTitle, showThroughlineTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }) {
50
52
  const { theme } = useTheme();
51
53
  const { fitView } = useReactFlow();
52
54
  const viewport = useViewport();
@@ -65,6 +67,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
65
67
  const [focusedThroughlineId, setFocusedThroughlineId] = useState(null);
66
68
  // `null` = whole flow focused; a number = that single step's edge focused.
67
69
  const [focusedStepIndex, setFocusedStepIndex] = useState(null);
70
+ // Hovered step in the flows panel: dims every canvas node/edge not involved
71
+ // with that step (transient — no camera move, no drawer, no focus change).
72
+ const [hoveredThroughlineStep, setHoveredThroughlineStep] = useState(null);
68
73
  // Sidebar bottom half: which panel is shown when throughlines exist.
69
74
  const [sidebarView, setSidebarView] = useState(() => throughlines?.length ? 'flows' : 'files');
70
75
  // Throughline flows the user has expanded (via the title row). Closed by
@@ -76,6 +81,42 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
76
81
  const [descToggleHover, setDescToggleHover] = useState(false);
77
82
  // `true` only when a description exists AND the user opened it.
78
83
  const showDesc = !!description && descriptionVisible;
84
+ // Sidebar width (px). Draggable via the resize handle between the sidebar
85
+ // and the graph canvas; clamps to sensible bounds while dragging.
86
+ const [sidebarWidth, setSidebarWidth] = useState(340);
87
+ const [sidebarDrag, setSidebarDrag] = useState(false);
88
+ const sidebarDragStartX = useRef(0);
89
+ const sidebarDragStartWidth = useRef(340);
90
+ const sidebarMinWidth = 240;
91
+ const sidebarMaxWidth = useMemo(() => Math.max(Math.min((_measured?.w ?? 680) * 0.5, 600), sidebarMinWidth), [_measured?.w]);
92
+ // Resize drag: capture the drag state so document-level move/up listeners
93
+ // stay attached for the duration of the gesture, then release on mouseup.
94
+ const onSidebarResizeStart = useCallback((e) => {
95
+ e.preventDefault();
96
+ sidebarDragStartX.current = e.clientX;
97
+ sidebarDragStartWidth.current = sidebarWidth;
98
+ setSidebarDrag(true);
99
+ }, [sidebarWidth]);
100
+ useEffect(() => {
101
+ if (!sidebarDrag)
102
+ return;
103
+ const onMove = (e) => {
104
+ const delta = e.clientX - sidebarDragStartX.current;
105
+ const next = Math.min(Math.max(sidebarDragStartWidth.current + delta, sidebarMinWidth), sidebarMaxWidth);
106
+ setSidebarWidth(next);
107
+ };
108
+ const onUp = () => {
109
+ setSidebarDrag(false);
110
+ // Re-fit the canvas so the graph re-centers in the new available space.
111
+ requestAnimationFrame(() => fitView());
112
+ };
113
+ document.addEventListener('mousemove', onMove);
114
+ document.addEventListener('mouseup', onUp);
115
+ return () => {
116
+ document.removeEventListener('mousemove', onMove);
117
+ document.removeEventListener('mouseup', onUp);
118
+ };
119
+ }, [sidebarDrag, sidebarMaxWidth, fitView]);
79
120
  // Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
80
121
  // closure over the effect deps) can toggle without a stale value.
81
122
  const selectedRef = useRef(null);
@@ -92,6 +133,50 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
92
133
  return null;
93
134
  return throughlines.find((t) => t.id === drawerTarget.throughlineId) ?? null;
94
135
  }, [drawerTarget, throughlines]);
136
+ // Throughline shown on the canvas title chip (focus or hover/autoplay highlight).
137
+ const overlayThroughlineTitle = useMemo(() => {
138
+ if (!showThroughlineTitle || !throughlines?.length)
139
+ return null;
140
+ const id = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
141
+ if (!id)
142
+ return null;
143
+ return throughlines.find((t) => t.id === id)?.title ?? null;
144
+ }, [
145
+ showThroughlineTitle,
146
+ throughlines,
147
+ focusedThroughlineId,
148
+ hoveredThroughlineStep,
149
+ ]);
150
+ // Active step for the bottom-of-title progress + annotation chip.
151
+ const overlayThroughlineStep = useMemo(() => {
152
+ if (!showThroughlineTitle || !throughlines?.length)
153
+ return null;
154
+ const tlId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId ?? null;
155
+ let stepIndex = null;
156
+ if (focusedThroughlineId != null) {
157
+ stepIndex = focusedStepIndex;
158
+ }
159
+ else if (hoveredThroughlineStep != null) {
160
+ stepIndex = hoveredThroughlineStep.stepIndex;
161
+ }
162
+ if (tlId == null || stepIndex == null)
163
+ return null;
164
+ const tl = throughlines.find((t) => t.id === tlId);
165
+ const step = tl?.steps[stepIndex];
166
+ if (!step || !tl)
167
+ return null;
168
+ return {
169
+ index: stepIndex + 1,
170
+ total: tl.steps.length,
171
+ annotation: step.annotation,
172
+ };
173
+ }, [
174
+ showThroughlineTitle,
175
+ throughlines,
176
+ focusedThroughlineId,
177
+ focusedStepIndex,
178
+ hoveredThroughlineStep,
179
+ ]);
95
180
  const drawerTitle = useMemo(() => {
96
181
  if (!drawerTarget)
97
182
  return null;
@@ -253,12 +338,13 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
253
338
  }
254
339
  return new Set(tl.steps.map((s) => s.edgeId));
255
340
  }, [throughlines, focusedThroughlineId, focusedStepIndex]);
256
- // 1-based step numbers per edge of the selected flow (an edge can appear
257
- // in more than one step).
341
+ // 1-based step numbers per edge of the active flow (focused or
342
+ // hover/autoplay-highlighted). An edge can appear in more than one step.
258
343
  const selectedFlowStepNos = useMemo(() => {
259
- if (focusedThroughlineId == null || !throughlines)
344
+ const activeId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
345
+ if (activeId == null || !throughlines)
260
346
  return null;
261
- const tl = throughlines.find((t) => t.id === focusedThroughlineId);
347
+ const tl = throughlines.find((t) => t.id === activeId);
262
348
  if (!tl)
263
349
  return null;
264
350
  const map = new Map();
@@ -268,7 +354,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
268
354
  map.set(s.edgeId, list);
269
355
  });
270
356
  return map;
271
- }, [throughlines, focusedThroughlineId]);
357
+ }, [throughlines, focusedThroughlineId, hoveredThroughlineStep]);
272
358
  // Union of every expanded (opened) throughline's edges — the visible set.
273
359
  const openedEdgeIds = useMemo(() => {
274
360
  if (!throughlines || expandedThroughlines.size === 0)
@@ -294,6 +380,19 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
294
380
  }
295
381
  return ids.size > 0 ? ids : null;
296
382
  };
383
+ // Edge/nodes involved in the hovered step — dim everything else.
384
+ const hoverEdgeIds = useMemo(() => {
385
+ if (!hoveredThroughlineStep || !throughlines)
386
+ return null;
387
+ const tl = throughlines.find((t) => t.id === hoveredThroughlineStep.throughlineId);
388
+ if (!tl)
389
+ return null;
390
+ const step = tl.steps[hoveredThroughlineStep.stepIndex];
391
+ return step ? new Set([step.edgeId]) : null;
392
+ }, [throughlines, hoveredThroughlineStep]);
393
+ const hoverNodeIds = useMemo(() => endpointsOf(hoverEdgeIds),
394
+ // eslint-disable-next-line react-hooks/exhaustive-deps
395
+ [baseEdges, hoverEdgeIds]);
297
396
  const openedNodeIds = useMemo(() => endpointsOf(openedEdgeIds),
298
397
  // eslint-disable-next-line react-hooks/exhaustive-deps
299
398
  [baseEdges, openedEdgeIds]);
@@ -323,6 +422,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
323
422
  // `isSelected` rides in data because the node's stopPropagation() keeps
324
423
  // React Flow's own selection state from updating.
325
424
  const dispNodes = useMemo(() => {
425
+ // While a step is hovered, only the hovered step's endpoints stay bright;
426
+ // everything else is dimmed (never hidden) so the step's context survives.
326
427
  return xyflowNodesBase.map((n) => {
327
428
  // Boundary frames follow their members: hidden when no member is
328
429
  // visible, dimmed when members are dimmed. Never selectable.
@@ -334,13 +435,17 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
334
435
  anyOpened: openedNodeIds != null,
335
436
  anySelected: brightNodeIds != null,
336
437
  });
438
+ const dimmed = hoverNodeIds
439
+ ? !memberIds.some((id) => hoverNodeIds.has(id))
440
+ : vis.dimmed;
441
+ const hidden = hoverNodeIds ? false : vis.hidden;
337
442
  return {
338
443
  ...n,
339
- hidden: vis.hidden,
444
+ hidden,
340
445
  selectable: false,
341
446
  data: {
342
447
  ...n.data,
343
- ...(vis.dimmed && { dimmed: true }),
448
+ ...(dimmed && { dimmed: true }),
344
449
  },
345
450
  };
346
451
  }
@@ -353,22 +458,24 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
353
458
  anyOpened: openedNodeIds != null,
354
459
  anySelected: brightNodeIds != null,
355
460
  });
356
- if (fileMatch === undefined && !isSelected && !vis.dimmed) {
461
+ const dimmed = hoverNodeIds ? !hoverNodeIds.has(n.id) : vis.dimmed;
462
+ const hidden = hoverNodeIds ? false : vis.hidden;
463
+ if (fileMatch === undefined && !isSelected && !dimmed) {
357
464
  const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data;
358
- return { ...n, hidden: vis.hidden, data: rest };
465
+ return { ...n, hidden, data: rest };
359
466
  }
360
467
  return {
361
468
  ...n,
362
- hidden: vis.hidden,
469
+ hidden,
363
470
  data: {
364
471
  ...n.data,
365
472
  ...(fileMatch !== undefined && { fileMatch }),
366
473
  ...(isSelected && { isSelected }),
367
- ...(vis.dimmed && { dimmed: true }),
474
+ ...(dimmed && { dimmed: true }),
368
475
  },
369
476
  };
370
477
  });
371
- }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
478
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, hoverNodeIds]);
372
479
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
373
480
  const baseEdgesKey = useMemo(() => convertedEdges.map((e) => e.id).sort().join(','), [convertedEdges]);
374
481
  const dispEdges = useMemo(() => {
@@ -383,7 +490,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
383
490
  markerEnd: nextMarker,
384
491
  };
385
492
  };
386
- if (openedEdgeIds || focusEdgeIds) {
493
+ if (openedEdgeIds || focusEdgeIds || hoverEdgeIds) {
387
494
  return baseEdges.map((e) => {
388
495
  const vis = flowElementVisibility({
389
496
  inOpened: openedEdgeIds?.has(e.id) === true,
@@ -391,13 +498,15 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
391
498
  anyOpened: openedEdgeIds != null,
392
499
  anySelected: focusEdgeIds != null,
393
500
  });
394
- return { ...paint(e, vis.dimmed), hidden: vis.hidden };
501
+ const dimmed = hoverEdgeIds ? !hoverEdgeIds.has(e.id) : vis.dimmed;
502
+ const hidden = hoverEdgeIds ? false : vis.hidden;
503
+ return { ...paint(e, dimmed), hidden };
395
504
  });
396
505
  }
397
506
  if (!selectedEdgeId)
398
507
  return baseEdges;
399
508
  return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
400
- }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
509
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, hoverEdgeIds]);
401
510
  const onNodesChange = useCallback((changes) => {
402
511
  // Capture dimension changes (React Flow's measurement callback).
403
512
  // Group parents are ELK-sized — ignore their measurements.
@@ -492,6 +601,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
492
601
  // live positions + measured dims — so the frame always includes BOTH
493
602
  // components the edge attaches to (and therefore the edge line between them).
494
603
  const fitFocusBounds = useCallback((ids) => {
604
+ if (!zoomOnThroughlineFocus)
605
+ return;
495
606
  const nodeIds = new Set();
496
607
  for (const e of baseEdges) {
497
608
  if (!ids.has(e.id))
@@ -506,35 +617,55 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
506
617
  padding: 0.25,
507
618
  duration: 300,
508
619
  });
509
- }, [baseEdges, fitView]);
620
+ }, [baseEdges, fitView, zoomOnThroughlineFocus]);
510
621
  // Focus an entire flow: hide everything but the flow's nodes and edges, and
511
622
  // frame the flow on the canvas. Selection state is cleared — the graph now
512
623
  // reads as the narrative. No drawer: the code view only opens on a step
513
624
  // click. A stale throughline drawer (from a previously focused flow's step)
514
625
  // closes; an explicitly opened file drawer stays.
626
+ // In `dim` mode: clear step highlight and leave the full graph visible
627
+ // (no zoom / hide) — matching "step away from a hovered step".
515
628
  const focusThroughlineEdges = useCallback((tl) => {
516
629
  setSelected(null);
517
630
  setSelectedEdgeId(null);
631
+ setHoveredThroughlineStep(null);
632
+ if (throughlineStepMode === 'dim') {
633
+ setFocusedStepIndex(null);
634
+ setFocusedThroughlineId(null);
635
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
636
+ return;
637
+ }
518
638
  setFocusedStepIndex(null);
519
639
  setFocusedThroughlineId(tl.id);
520
640
  fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
521
641
  setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
522
- }, [fitFocusBounds]);
642
+ }, [fitFocusBounds, throughlineStepMode]);
523
643
  const clearThroughlineFocus = useCallback(() => {
524
644
  setFocusedThroughlineId(null);
525
645
  setFocusedStepIndex(null);
646
+ setHoveredThroughlineStep(null);
526
647
  setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
527
648
  }, []);
528
649
  // Focus a single step's edge on the canvas and open/scroll the throughline
529
650
  // drawer to that step's snippet.
651
+ // In `dim` mode: only dim non-participants (same as hovering a step) —
652
+ // no camera move, no drawer, no hide.
530
653
  const focusThroughlineStep = useCallback((tl, stepIndex) => {
531
654
  const step = tl.steps[stepIndex];
532
655
  if (!step)
533
656
  return;
534
657
  setSelected(null);
535
658
  setSelectedEdgeId(null);
659
+ if (throughlineStepMode === 'dim') {
660
+ setFocusedStepIndex(null);
661
+ setFocusedThroughlineId(null);
662
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex });
663
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
664
+ return;
665
+ }
536
666
  setFocusedStepIndex(stepIndex);
537
667
  setFocusedThroughlineId(tl.id);
668
+ setHoveredThroughlineStep(null);
538
669
  fitFocusBounds(new Set([step.edgeId]));
539
670
  if (renderThroughlineViewer) {
540
671
  setDrawerTarget({
@@ -550,7 +681,43 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
550
681
  startLine: step.line,
551
682
  });
552
683
  }
553
- }, [fitFocusBounds, renderThroughlineViewer]);
684
+ }, [fitFocusBounds, renderThroughlineViewer, throughlineStepMode]);
685
+ // Graph-only embeds: cycle throughline steps with hover-style dimming.
686
+ useEffect(() => {
687
+ if (!autoPlayThroughlines || !throughlines?.length || !layoutReady)
688
+ return;
689
+ const playable = throughlines.filter((tl) => tl.steps.length > 0);
690
+ if (playable.length === 0)
691
+ return;
692
+ let cancelled = false;
693
+ let tlIdx = 0;
694
+ let stepIdx = 0;
695
+ let timer = null;
696
+ const interval = Math.max(400, throughlineAutoPlayIntervalMs);
697
+ const tick = () => {
698
+ if (cancelled)
699
+ return;
700
+ const tl = playable[tlIdx];
701
+ setSelected(null);
702
+ setSelectedEdgeId(null);
703
+ setFocusedThroughlineId(null);
704
+ setFocusedStepIndex(null);
705
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: stepIdx });
706
+ stepIdx += 1;
707
+ if (stepIdx >= tl.steps.length) {
708
+ stepIdx = 0;
709
+ tlIdx = (tlIdx + 1) % playable.length;
710
+ }
711
+ timer = window.setTimeout(tick, interval);
712
+ };
713
+ tick();
714
+ return () => {
715
+ cancelled = true;
716
+ if (timer != null)
717
+ window.clearTimeout(timer);
718
+ setHoveredThroughlineStep(null);
719
+ };
720
+ }, [autoPlayThroughlines, throughlines, throughlineAutoPlayIntervalMs, layoutReady]);
554
721
  // Arrow keys step through the focused throughline once a step is active
555
722
  // (sidebar click or drawer open). Ignores typing targets and chords.
556
723
  useEffect(() => {
@@ -679,9 +846,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
679
846
  const throughlineViewerRef = useRef(renderThroughlineViewer);
680
847
  throughlineViewerRef.current = renderThroughlineViewer;
681
848
  const renderThroughlineDrawerContent = useCallback((ctx) => throughlineViewerRef.current?.(ctx) ?? null, []);
682
- return (_jsxs("div", { style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }, children: [!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (_jsxs("div", { style: {
683
- width: 340,
684
- minWidth: 340,
849
+ return (_jsxs("div", { style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'row', userSelect: sidebarDrag ? 'none' : undefined }, children: [!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (_jsxs("div", { style: {
850
+ width: sidebarWidth,
851
+ minWidth: sidebarWidth,
685
852
  borderRight: `1px solid ${theme.colors.border}`,
686
853
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
687
854
  display: 'flex',
@@ -749,8 +916,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
749
916
  flex: 1,
750
917
  minHeight: 0,
751
918
  overflowY: 'auto',
752
- padding: '0 4px 12px',
753
- }, children: throughlines.map((tl) => (_jsx(ThroughlineFlow, { throughline: tl, edges: edges, collapsed: !expandedThroughlines.has(tl.id), active: focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null, onToggleCollapsed: toggleThroughlineCollapsed, onFocusFlow: focusThroughlineEdges, onClearFocus: clearThroughlineFocus, onFocusStep: focusThroughlineStep }, tl.id))) })) : treeFilePaths.length > 0 ? (_jsx(_Fragment, { children: repoGroups.groups.map((group, i) => {
919
+ padding: '0 0 12px',
920
+ }, children: throughlines.map((tl) => (_jsx(ThroughlineFlow, { throughline: tl, edges: edges, collapsed: !expandedThroughlines.has(tl.id), active: focusedThroughlineId === tl.id
921
+ ? { stepIndex: focusedStepIndex }
922
+ : hoveredThroughlineStep?.throughlineId === tl.id
923
+ ? { stepIndex: hoveredThroughlineStep.stepIndex }
924
+ : null, onToggleCollapsed: toggleThroughlineCollapsed, onFocusFlow: focusThroughlineEdges, onClearFocus: clearThroughlineFocus, onFocusStep: focusThroughlineStep, onHoverStep: (tl, i) => setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: i }), onLeaveStep: () => setHoveredThroughlineStep(null) }, tl.id))) })) : treeFilePaths.length > 0 ? (_jsx(_Fragment, { children: repoGroups.groups.map((group, i) => {
754
925
  const groupKey = group.repoKey ?? '__no-repo__';
755
926
  const collapsed = collapsedRepos.has(groupKey);
756
927
  return (_jsxs("div", { style: {
@@ -760,7 +931,14 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
760
931
  flexDirection: 'column',
761
932
  borderTop: i > 0 ? `1px solid ${theme.colors.border}` : undefined,
762
933
  }, children: [repoGroups.multiRepo && (_jsx(RepoGroupHeader, { group: group, collapsed: collapsed, onToggle: () => toggleRepoCollapsed(groupKey) })), !collapsed && (_jsx(SubsystemFileTree, { files: group.entries.map((e) => e.displayPath), selectedFile: selected?.file ?? openFile, hoveredFile: hoveredFile, onSelectFile: onTreeSelectFile, headerless: repoGroups.multiRepo }))] }, groupKey));
763
- }) })) : null] }))] })), _jsxs("div", { style: { flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }, children: [_jsxs("div", { style: { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }, children: [showEdgeLabels !== false && (_jsx("div", { style: {
934
+ }) })) : null] }))] })), !hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (_jsx("div", { onMouseDown: onSidebarResizeStart, "aria-label": "Resize sidebar", title: "Drag to resize", style: {
935
+ width: 3,
936
+ flexShrink: 0,
937
+ cursor: 'col-resize',
938
+ background: theme.colors.border,
939
+ transition: 'background 120ms ease',
940
+ zIndex: 1,
941
+ } })), _jsxs("div", { style: { flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }, children: [_jsxs("div", { style: { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }, children: [showEdgeLabels !== false && (_jsx("div", { style: {
764
942
  position: 'absolute',
765
943
  inset: 0,
766
944
  overflow: 'hidden',
@@ -816,27 +994,88 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
816
994
  flex: 1,
817
995
  minHeight: 0,
818
996
  background: theme.colors.background,
819
- }, children: [_jsx(Background, { variant: BackgroundVariant.Dots, gap: 16, size: 1 }), _jsx(Controls, { showZoom: true, showFitView: true, showInteractive: true })] }, `${baseNodesKey}-${baseEdgesKey}`), graphTitle && (_jsx("div", { style: {
997
+ }, children: [_jsx(Background, { variant: BackgroundVariant.Dots, gap: 16, size: 1 }), _jsx(Controls, { showZoom: true, showFitView: true, showInteractive: true })] }, `${baseNodesKey}-${baseEdgesKey}`), (graphTitle || overlayThroughlineTitle || overlayThroughlineStep) && (_jsxs("div", { style: {
820
998
  position: 'absolute',
821
999
  top: 10,
822
1000
  left: '50%',
823
1001
  transform: 'translateX(-50%)',
824
1002
  zIndex: 6,
825
- maxWidth: '60%',
826
- overflow: 'hidden',
827
- textOverflow: 'ellipsis',
828
- whiteSpace: 'nowrap',
829
- padding: '6px 18px',
830
- fontSize: theme.fontSizes[3],
831
- fontWeight: 600,
832
- fontFamily: theme.fonts.monospace,
833
- color: theme.colors.text,
834
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
835
- border: `1px solid ${theme.colors.border}`,
836
- borderRadius: 6,
837
- boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1003
+ display: 'flex',
1004
+ flexDirection: 'column',
1005
+ alignItems: 'center',
1006
+ gap: 6,
1007
+ maxWidth: '70%',
838
1008
  pointerEvents: 'none',
839
- }, children: graphTitle })), selected && (_jsx("div", { style: {
1009
+ }, children: [graphTitle && (_jsx("div", { style: {
1010
+ maxWidth: '100%',
1011
+ overflow: 'hidden',
1012
+ textOverflow: 'ellipsis',
1013
+ whiteSpace: 'nowrap',
1014
+ padding: '6px 18px',
1015
+ fontSize: theme.fontSizes[3],
1016
+ fontWeight: 600,
1017
+ fontFamily: theme.fonts.monospace,
1018
+ color: theme.colors.text,
1019
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1020
+ border: `1px solid ${theme.colors.border}`,
1021
+ borderRadius: 6,
1022
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1023
+ }, children: graphTitle })), overlayThroughlineTitle && (_jsxs("div", { style: {
1024
+ maxWidth: '100%',
1025
+ minWidth: overlayThroughlineStep ? 160 : undefined,
1026
+ display: 'flex',
1027
+ flexDirection: 'column',
1028
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1029
+ border: `1px solid ${theme.colors.border}`,
1030
+ borderRadius: 6,
1031
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1032
+ overflow: 'hidden',
1033
+ opacity: 0.95,
1034
+ }, "aria-label": overlayThroughlineStep
1035
+ ? `${overlayThroughlineTitle}, step ${overlayThroughlineStep.index} of ${overlayThroughlineStep.total}`
1036
+ : overlayThroughlineTitle, children: [_jsx("div", { style: {
1037
+ padding: '5px 14px',
1038
+ fontSize: theme.fontSizes[2] ?? theme.fontSizes[1],
1039
+ fontWeight: 600,
1040
+ fontFamily: theme.fonts.monospace,
1041
+ color: theme.colors.text,
1042
+ overflow: 'hidden',
1043
+ textOverflow: 'ellipsis',
1044
+ whiteSpace: 'nowrap',
1045
+ textAlign: 'center',
1046
+ }, children: overlayThroughlineTitle }), overlayThroughlineStep && overlayThroughlineStep.total > 0 && (_jsx("div", { style: {
1047
+ display: 'flex',
1048
+ gap: 3,
1049
+ padding: '0 6px 5px',
1050
+ }, "aria-hidden": "true", children: Array.from({ length: overlayThroughlineStep.total }, (_, i) => {
1051
+ const n = i + 1;
1052
+ const active = n === overlayThroughlineStep.index;
1053
+ const done = n < overlayThroughlineStep.index;
1054
+ return (_jsx("span", { style: {
1055
+ flex: 1,
1056
+ height: 2,
1057
+ borderRadius: 1,
1058
+ background: active
1059
+ ? (theme.colors.accent ?? theme.colors.primary ?? theme.colors.text)
1060
+ : done
1061
+ ? (theme.colors.textSecondary ?? theme.colors.text)
1062
+ : (theme.colors.border ?? 'rgba(127,127,127,0.45)'),
1063
+ opacity: active ? 1 : done ? 0.75 : 0.4,
1064
+ transition: 'opacity 120ms ease, background 120ms ease',
1065
+ } }, n));
1066
+ }) }))] })), overlayThroughlineStep?.annotation && (_jsx("div", { style: {
1067
+ maxWidth: '100%',
1068
+ padding: '6px 14px',
1069
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1070
+ border: `1px solid ${theme.colors.border}`,
1071
+ borderRadius: 6,
1072
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1073
+ textAlign: 'center',
1074
+ fontSize: theme.fontSizes[0],
1075
+ fontFamily: theme.fonts.body,
1076
+ color: theme.colors.textMuted ?? theme.colors.textSecondary,
1077
+ lineHeight: 1.35,
1078
+ }, children: overlayThroughlineStep.annotation }))] })), selected && (_jsx("div", { style: {
840
1079
  position: 'absolute',
841
1080
  top: 10,
842
1081
  right: 10,
@@ -896,7 +1135,7 @@ function RepoGroupHeader({ group, collapsed, onToggle, }) {
896
1135
  * title: closed → open + select; open and unselected → select; open and
897
1136
  * selected → close + clear focus. The right-aligned close button collapses
898
1137
  * without selecting. A step row focuses that step's edge. */
899
- function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollapsed, onFocusFlow, onClearFocus, onFocusStep, }) {
1138
+ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollapsed, onFocusFlow, onClearFocus, onFocusStep, onHoverStep, onLeaveStep, }) {
900
1139
  const { theme } = useTheme();
901
1140
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
902
1141
  const hoverBg = theme.colors.background;
@@ -904,8 +1143,52 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
904
1143
  const wholeFlowActive = active !== null && active.stepIndex === null;
905
1144
  const [headerHover, setHeaderHover] = useState(false);
906
1145
  const [closeHover, setCloseHover] = useState(false);
1146
+ const [playHover, setPlayHover] = useState(false);
907
1147
  const [hoveredStep, setHoveredStep] = useState(null);
908
1148
  const stepButtonRefs = useRef([]);
1149
+ // Autoplay: stepping through the flow's steps with a pause between each.
1150
+ const [playing, setPlaying] = useState(false);
1151
+ const playTimerRef = useRef(null);
1152
+ const stopPlaying = useCallback(() => {
1153
+ if (playTimerRef.current != null) {
1154
+ window.clearTimeout(playTimerRef.current);
1155
+ playTimerRef.current = null;
1156
+ }
1157
+ setPlaying(false);
1158
+ }, []);
1159
+ // Clear any pending timer on unmount.
1160
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1161
+ useEffect(() => () => stopPlaying(), []);
1162
+ const startPlaying = useCallback(() => {
1163
+ if (collapsed)
1164
+ onToggleCollapsed(throughline.id);
1165
+ if (active === null || active.stepIndex !== null)
1166
+ onFocusFlow(throughline);
1167
+ const stepCount = throughline.steps.length;
1168
+ if (stepCount === 0)
1169
+ return;
1170
+ setPlaying(true);
1171
+ let i = 0;
1172
+ const tick = () => {
1173
+ if (i >= stepCount) {
1174
+ playTimerRef.current = null;
1175
+ setPlaying(false);
1176
+ return;
1177
+ }
1178
+ onFocusStep(throughline, i);
1179
+ i += 1;
1180
+ playTimerRef.current = window.setTimeout(tick, THROUGHLINE_PLAY_PAUSE_MS);
1181
+ };
1182
+ tick();
1183
+ }, [collapsed, active, onToggleCollapsed, onFocusFlow, throughline, onFocusStep]);
1184
+ const togglePlay = useCallback(() => {
1185
+ if (playing) {
1186
+ stopPlaying();
1187
+ }
1188
+ else {
1189
+ startPlaying();
1190
+ }
1191
+ }, [playing, stopPlaying, startPlaying]);
909
1192
  // Keep DOM focus on the active step so the browser focus ring (and
910
1193
  // subsequent arrow keys) follow arrow navigation, not the originally
911
1194
  // clicked button.
@@ -914,11 +1197,10 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
914
1197
  return;
915
1198
  stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
916
1199
  }, [active?.stepIndex]);
917
- return (_jsxs("div", { style: { margin: '4px 0', borderRadius: 8 }, children: [_jsxs("div", { onMouseEnter: () => setHeaderHover(true), onMouseLeave: () => setHeaderHover(false), style: {
1200
+ return (_jsxs("div", { children: [_jsxs("div", { onMouseEnter: () => setHeaderHover(true), onMouseLeave: () => setHeaderHover(false), style: {
918
1201
  display: 'flex',
919
1202
  alignItems: 'center',
920
1203
  gap: 4,
921
- borderRadius: 6,
922
1204
  background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
923
1205
  transition: 'background 120ms ease',
924
1206
  }, children: [_jsx("button", { type: "button", onClick: () => {
@@ -938,8 +1220,7 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
938
1220
  display: 'flex',
939
1221
  alignItems: 'center',
940
1222
  minWidth: 0,
941
- padding: '6px 8px',
942
- borderRadius: 6,
1223
+ padding: '10px 8px',
943
1224
  border: 'none',
944
1225
  background: 'transparent',
945
1226
  textAlign: 'left',
@@ -952,39 +1233,62 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
952
1233
  fontFamily: theme.fonts.monospace,
953
1234
  fontWeight: 600,
954
1235
  color: theme.colors.text,
955
- }, children: throughline.title }) }), !collapsed && (_jsx("button", { type: "button", "aria-label": `Close ${throughline.title}`, onMouseEnter: () => setCloseHover(true), onMouseLeave: () => setCloseHover(false), onClick: (e) => {
956
- e.stopPropagation();
957
- onToggleCollapsed(throughline.id);
958
- if (active !== null)
959
- onClearFocus();
960
- }, style: {
961
- display: 'inline-flex',
962
- alignItems: 'center',
963
- justifyContent: 'center',
964
- flexShrink: 0,
965
- width: 22,
966
- height: 22,
967
- marginRight: 4,
968
- padding: 0,
969
- border: 'none',
970
- borderRadius: 4,
971
- background: closeHover ? theme.colors.border : 'transparent',
972
- color: closeHover ? theme.colors.text : muted,
973
- cursor: 'pointer',
974
- transition: 'background 120ms ease, color 120ms ease',
975
- }, children: _jsx(X, { size: 12, strokeWidth: 2 }) }))] }), !collapsed && (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 1 }, children: throughline.steps.map((step, i) => {
1236
+ }, children: throughline.title }) }), !collapsed && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": playing ? `Pause ${throughline.title} autoplay` : `Play ${throughline.title}`, title: playing ? 'Pause' : 'Play through steps', onMouseEnter: () => setPlayHover(true), onMouseLeave: () => setPlayHover(false), onClick: (e) => {
1237
+ e.stopPropagation();
1238
+ togglePlay();
1239
+ }, style: {
1240
+ display: 'inline-flex',
1241
+ alignItems: 'center',
1242
+ justifyContent: 'center',
1243
+ flexShrink: 0,
1244
+ width: 22,
1245
+ height: 22,
1246
+ padding: 0,
1247
+ border: 'none',
1248
+ borderRadius: 4,
1249
+ background: playing || playHover ? theme.colors.border : 'transparent',
1250
+ color: playing || playHover ? theme.colors.text : muted,
1251
+ cursor: 'pointer',
1252
+ transition: 'background 120ms ease, color 120ms ease',
1253
+ }, children: playing ? _jsx(Pause, { size: 12, strokeWidth: 2 }) : _jsx(Play, { size: 12, strokeWidth: 2 }) }), _jsx("button", { type: "button", "aria-label": `Close ${throughline.title}`, onMouseEnter: () => setCloseHover(true), onMouseLeave: () => setCloseHover(false), onClick: (e) => {
1254
+ e.stopPropagation();
1255
+ onToggleCollapsed(throughline.id);
1256
+ if (active !== null)
1257
+ onClearFocus();
1258
+ }, style: {
1259
+ display: 'inline-flex',
1260
+ alignItems: 'center',
1261
+ justifyContent: 'center',
1262
+ flexShrink: 0,
1263
+ width: 22,
1264
+ height: 22,
1265
+ marginRight: 4,
1266
+ padding: 0,
1267
+ border: 'none',
1268
+ borderRadius: 4,
1269
+ background: closeHover ? theme.colors.border : 'transparent',
1270
+ color: closeHover ? theme.colors.text : muted,
1271
+ cursor: 'pointer',
1272
+ transition: 'background 120ms ease, color 120ms ease',
1273
+ }, children: _jsx(X, { size: 12, strokeWidth: 2 }) })] }))] }), !collapsed && (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 1 }, children: throughline.steps.map((step, i) => {
976
1274
  const edge = edgeById.get(step.edgeId);
977
1275
  const mech = edge?.mechanism;
978
1276
  const color = mech ? MECHANISM_COLOR[mech] : muted;
979
1277
  const stepActive = active !== null && active.stepIndex === i;
980
1278
  return (_jsxs("button", { ref: (el) => {
981
1279
  stepButtonRefs.current[i] = el;
982
- }, type: "button", onMouseEnter: () => setHoveredStep(i), onMouseLeave: () => setHoveredStep(null), onClick: () => onFocusStep(throughline, i), style: {
1280
+ }, type: "button", onMouseEnter: () => {
1281
+ setHoveredStep(i);
1282
+ onHoverStep(throughline, i);
1283
+ }, onMouseLeave: () => {
1284
+ setHoveredStep(null);
1285
+ onLeaveStep();
1286
+ }, onClick: () => onFocusStep(throughline, i), style: {
983
1287
  display: 'flex',
984
1288
  alignItems: 'center',
985
1289
  gap: 8,
986
1290
  minWidth: 0,
987
- padding: '4px 8px 4px 12px',
1291
+ padding: '8px 8px 8px 12px',
988
1292
  textAlign: 'left',
989
1293
  borderRadius: 6,
990
1294
  border: 'none',
@@ -1004,9 +1308,9 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
1004
1308
  overflow: 'hidden',
1005
1309
  textOverflow: 'ellipsis',
1006
1310
  whiteSpace: 'nowrap',
1007
- fontSize: theme.fontSizes[0],
1311
+ fontSize: theme.fontSizes[1],
1008
1312
  fontFamily: theme.fonts.monospace,
1009
- color: stepActive ? theme.colors.text : muted,
1313
+ color: theme.colors.text,
1010
1314
  }, children: step.symbol })) : (_jsxs(_Fragment, { children: [_jsx("span", { style: {
1011
1315
  flexShrink: 0,
1012
1316
  width: 74,
@@ -1024,7 +1328,7 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
1024
1328
  whiteSpace: 'nowrap',
1025
1329
  fontSize: theme.fontSizes[0],
1026
1330
  fontFamily: theme.fonts.monospace,
1027
- color: stepActive ? theme.colors.text : muted,
1331
+ color: theme.colors.text,
1028
1332
  }, children: [step.file.split('/').pop(), _jsxs("span", { style: { opacity: 0.7 }, children: [":", step.line] })] })] }))] }, `${step.edgeId}-${i}`));
1029
1333
  }) }))] }));
1030
1334
  }