@principal-ai/subsystems-react 0.18.0 → 0.19.0

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
@@ -92,6 +97,50 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
92
97
  return null;
93
98
  return throughlines.find((t) => t.id === drawerTarget.throughlineId) ?? null;
94
99
  }, [drawerTarget, throughlines]);
100
+ // Throughline shown on the canvas title chip (focus or hover/autoplay highlight).
101
+ const overlayThroughlineTitle = useMemo(() => {
102
+ if (!showThroughlineTitle || !throughlines?.length)
103
+ return null;
104
+ const id = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
105
+ if (!id)
106
+ return null;
107
+ return throughlines.find((t) => t.id === id)?.title ?? null;
108
+ }, [
109
+ showThroughlineTitle,
110
+ throughlines,
111
+ focusedThroughlineId,
112
+ hoveredThroughlineStep,
113
+ ]);
114
+ // Active step for the bottom-of-title progress + annotation chip.
115
+ const overlayThroughlineStep = useMemo(() => {
116
+ if (!showThroughlineTitle || !throughlines?.length)
117
+ return null;
118
+ const tlId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId ?? null;
119
+ let stepIndex = null;
120
+ if (focusedThroughlineId != null) {
121
+ stepIndex = focusedStepIndex;
122
+ }
123
+ else if (hoveredThroughlineStep != null) {
124
+ stepIndex = hoveredThroughlineStep.stepIndex;
125
+ }
126
+ if (tlId == null || stepIndex == null)
127
+ return null;
128
+ const tl = throughlines.find((t) => t.id === tlId);
129
+ const step = tl?.steps[stepIndex];
130
+ if (!step || !tl)
131
+ return null;
132
+ return {
133
+ index: stepIndex + 1,
134
+ total: tl.steps.length,
135
+ annotation: step.annotation,
136
+ };
137
+ }, [
138
+ showThroughlineTitle,
139
+ throughlines,
140
+ focusedThroughlineId,
141
+ focusedStepIndex,
142
+ hoveredThroughlineStep,
143
+ ]);
95
144
  const drawerTitle = useMemo(() => {
96
145
  if (!drawerTarget)
97
146
  return null;
@@ -253,12 +302,13 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
253
302
  }
254
303
  return new Set(tl.steps.map((s) => s.edgeId));
255
304
  }, [throughlines, focusedThroughlineId, focusedStepIndex]);
256
- // 1-based step numbers per edge of the selected flow (an edge can appear
257
- // in more than one step).
305
+ // 1-based step numbers per edge of the active flow (focused or
306
+ // hover/autoplay-highlighted). An edge can appear in more than one step.
258
307
  const selectedFlowStepNos = useMemo(() => {
259
- if (focusedThroughlineId == null || !throughlines)
308
+ const activeId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
309
+ if (activeId == null || !throughlines)
260
310
  return null;
261
- const tl = throughlines.find((t) => t.id === focusedThroughlineId);
311
+ const tl = throughlines.find((t) => t.id === activeId);
262
312
  if (!tl)
263
313
  return null;
264
314
  const map = new Map();
@@ -268,7 +318,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
268
318
  map.set(s.edgeId, list);
269
319
  });
270
320
  return map;
271
- }, [throughlines, focusedThroughlineId]);
321
+ }, [throughlines, focusedThroughlineId, hoveredThroughlineStep]);
272
322
  // Union of every expanded (opened) throughline's edges — the visible set.
273
323
  const openedEdgeIds = useMemo(() => {
274
324
  if (!throughlines || expandedThroughlines.size === 0)
@@ -294,6 +344,19 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
294
344
  }
295
345
  return ids.size > 0 ? ids : null;
296
346
  };
347
+ // Edge/nodes involved in the hovered step — dim everything else.
348
+ const hoverEdgeIds = useMemo(() => {
349
+ if (!hoveredThroughlineStep || !throughlines)
350
+ return null;
351
+ const tl = throughlines.find((t) => t.id === hoveredThroughlineStep.throughlineId);
352
+ if (!tl)
353
+ return null;
354
+ const step = tl.steps[hoveredThroughlineStep.stepIndex];
355
+ return step ? new Set([step.edgeId]) : null;
356
+ }, [throughlines, hoveredThroughlineStep]);
357
+ const hoverNodeIds = useMemo(() => endpointsOf(hoverEdgeIds),
358
+ // eslint-disable-next-line react-hooks/exhaustive-deps
359
+ [baseEdges, hoverEdgeIds]);
297
360
  const openedNodeIds = useMemo(() => endpointsOf(openedEdgeIds),
298
361
  // eslint-disable-next-line react-hooks/exhaustive-deps
299
362
  [baseEdges, openedEdgeIds]);
@@ -323,6 +386,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
323
386
  // `isSelected` rides in data because the node's stopPropagation() keeps
324
387
  // React Flow's own selection state from updating.
325
388
  const dispNodes = useMemo(() => {
389
+ // While a step is hovered, only the hovered step's endpoints stay bright;
390
+ // everything else is dimmed (never hidden) so the step's context survives.
326
391
  return xyflowNodesBase.map((n) => {
327
392
  // Boundary frames follow their members: hidden when no member is
328
393
  // visible, dimmed when members are dimmed. Never selectable.
@@ -334,13 +399,17 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
334
399
  anyOpened: openedNodeIds != null,
335
400
  anySelected: brightNodeIds != null,
336
401
  });
402
+ const dimmed = hoverNodeIds
403
+ ? !memberIds.some((id) => hoverNodeIds.has(id))
404
+ : vis.dimmed;
405
+ const hidden = hoverNodeIds ? false : vis.hidden;
337
406
  return {
338
407
  ...n,
339
- hidden: vis.hidden,
408
+ hidden,
340
409
  selectable: false,
341
410
  data: {
342
411
  ...n.data,
343
- ...(vis.dimmed && { dimmed: true }),
412
+ ...(dimmed && { dimmed: true }),
344
413
  },
345
414
  };
346
415
  }
@@ -353,22 +422,24 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
353
422
  anyOpened: openedNodeIds != null,
354
423
  anySelected: brightNodeIds != null,
355
424
  });
356
- if (fileMatch === undefined && !isSelected && !vis.dimmed) {
425
+ const dimmed = hoverNodeIds ? !hoverNodeIds.has(n.id) : vis.dimmed;
426
+ const hidden = hoverNodeIds ? false : vis.hidden;
427
+ if (fileMatch === undefined && !isSelected && !dimmed) {
357
428
  const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data;
358
- return { ...n, hidden: vis.hidden, data: rest };
429
+ return { ...n, hidden, data: rest };
359
430
  }
360
431
  return {
361
432
  ...n,
362
- hidden: vis.hidden,
433
+ hidden,
363
434
  data: {
364
435
  ...n.data,
365
436
  ...(fileMatch !== undefined && { fileMatch }),
366
437
  ...(isSelected && { isSelected }),
367
- ...(vis.dimmed && { dimmed: true }),
438
+ ...(dimmed && { dimmed: true }),
368
439
  },
369
440
  };
370
441
  });
371
- }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
442
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, hoverNodeIds]);
372
443
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
373
444
  const baseEdgesKey = useMemo(() => convertedEdges.map((e) => e.id).sort().join(','), [convertedEdges]);
374
445
  const dispEdges = useMemo(() => {
@@ -383,7 +454,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
383
454
  markerEnd: nextMarker,
384
455
  };
385
456
  };
386
- if (openedEdgeIds || focusEdgeIds) {
457
+ if (openedEdgeIds || focusEdgeIds || hoverEdgeIds) {
387
458
  return baseEdges.map((e) => {
388
459
  const vis = flowElementVisibility({
389
460
  inOpened: openedEdgeIds?.has(e.id) === true,
@@ -391,13 +462,15 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
391
462
  anyOpened: openedEdgeIds != null,
392
463
  anySelected: focusEdgeIds != null,
393
464
  });
394
- return { ...paint(e, vis.dimmed), hidden: vis.hidden };
465
+ const dimmed = hoverEdgeIds ? !hoverEdgeIds.has(e.id) : vis.dimmed;
466
+ const hidden = hoverEdgeIds ? false : vis.hidden;
467
+ return { ...paint(e, dimmed), hidden };
395
468
  });
396
469
  }
397
470
  if (!selectedEdgeId)
398
471
  return baseEdges;
399
472
  return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
400
- }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
473
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, hoverEdgeIds]);
401
474
  const onNodesChange = useCallback((changes) => {
402
475
  // Capture dimension changes (React Flow's measurement callback).
403
476
  // Group parents are ELK-sized — ignore their measurements.
@@ -492,6 +565,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
492
565
  // live positions + measured dims — so the frame always includes BOTH
493
566
  // components the edge attaches to (and therefore the edge line between them).
494
567
  const fitFocusBounds = useCallback((ids) => {
568
+ if (!zoomOnThroughlineFocus)
569
+ return;
495
570
  const nodeIds = new Set();
496
571
  for (const e of baseEdges) {
497
572
  if (!ids.has(e.id))
@@ -506,35 +581,55 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
506
581
  padding: 0.25,
507
582
  duration: 300,
508
583
  });
509
- }, [baseEdges, fitView]);
584
+ }, [baseEdges, fitView, zoomOnThroughlineFocus]);
510
585
  // Focus an entire flow: hide everything but the flow's nodes and edges, and
511
586
  // frame the flow on the canvas. Selection state is cleared — the graph now
512
587
  // reads as the narrative. No drawer: the code view only opens on a step
513
588
  // click. A stale throughline drawer (from a previously focused flow's step)
514
589
  // closes; an explicitly opened file drawer stays.
590
+ // In `dim` mode: clear step highlight and leave the full graph visible
591
+ // (no zoom / hide) — matching "step away from a hovered step".
515
592
  const focusThroughlineEdges = useCallback((tl) => {
516
593
  setSelected(null);
517
594
  setSelectedEdgeId(null);
595
+ setHoveredThroughlineStep(null);
596
+ if (throughlineStepMode === 'dim') {
597
+ setFocusedStepIndex(null);
598
+ setFocusedThroughlineId(null);
599
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
600
+ return;
601
+ }
518
602
  setFocusedStepIndex(null);
519
603
  setFocusedThroughlineId(tl.id);
520
604
  fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
521
605
  setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
522
- }, [fitFocusBounds]);
606
+ }, [fitFocusBounds, throughlineStepMode]);
523
607
  const clearThroughlineFocus = useCallback(() => {
524
608
  setFocusedThroughlineId(null);
525
609
  setFocusedStepIndex(null);
610
+ setHoveredThroughlineStep(null);
526
611
  setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
527
612
  }, []);
528
613
  // Focus a single step's edge on the canvas and open/scroll the throughline
529
614
  // drawer to that step's snippet.
615
+ // In `dim` mode: only dim non-participants (same as hovering a step) —
616
+ // no camera move, no drawer, no hide.
530
617
  const focusThroughlineStep = useCallback((tl, stepIndex) => {
531
618
  const step = tl.steps[stepIndex];
532
619
  if (!step)
533
620
  return;
534
621
  setSelected(null);
535
622
  setSelectedEdgeId(null);
623
+ if (throughlineStepMode === 'dim') {
624
+ setFocusedStepIndex(null);
625
+ setFocusedThroughlineId(null);
626
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex });
627
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
628
+ return;
629
+ }
536
630
  setFocusedStepIndex(stepIndex);
537
631
  setFocusedThroughlineId(tl.id);
632
+ setHoveredThroughlineStep(null);
538
633
  fitFocusBounds(new Set([step.edgeId]));
539
634
  if (renderThroughlineViewer) {
540
635
  setDrawerTarget({
@@ -550,7 +645,43 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
550
645
  startLine: step.line,
551
646
  });
552
647
  }
553
- }, [fitFocusBounds, renderThroughlineViewer]);
648
+ }, [fitFocusBounds, renderThroughlineViewer, throughlineStepMode]);
649
+ // Graph-only embeds: cycle throughline steps with hover-style dimming.
650
+ useEffect(() => {
651
+ if (!autoPlayThroughlines || !throughlines?.length || !layoutReady)
652
+ return;
653
+ const playable = throughlines.filter((tl) => tl.steps.length > 0);
654
+ if (playable.length === 0)
655
+ return;
656
+ let cancelled = false;
657
+ let tlIdx = 0;
658
+ let stepIdx = 0;
659
+ let timer = null;
660
+ const interval = Math.max(400, throughlineAutoPlayIntervalMs);
661
+ const tick = () => {
662
+ if (cancelled)
663
+ return;
664
+ const tl = playable[tlIdx];
665
+ setSelected(null);
666
+ setSelectedEdgeId(null);
667
+ setFocusedThroughlineId(null);
668
+ setFocusedStepIndex(null);
669
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: stepIdx });
670
+ stepIdx += 1;
671
+ if (stepIdx >= tl.steps.length) {
672
+ stepIdx = 0;
673
+ tlIdx = (tlIdx + 1) % playable.length;
674
+ }
675
+ timer = window.setTimeout(tick, interval);
676
+ };
677
+ tick();
678
+ return () => {
679
+ cancelled = true;
680
+ if (timer != null)
681
+ window.clearTimeout(timer);
682
+ setHoveredThroughlineStep(null);
683
+ };
684
+ }, [autoPlayThroughlines, throughlines, throughlineAutoPlayIntervalMs, layoutReady]);
554
685
  // Arrow keys step through the focused throughline once a step is active
555
686
  // (sidebar click or drawer open). Ignores typing targets and chords.
556
687
  useEffect(() => {
@@ -749,8 +880,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
749
880
  flex: 1,
750
881
  minHeight: 0,
751
882
  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) => {
883
+ padding: '0 0 12px',
884
+ }, children: throughlines.map((tl) => (_jsx(ThroughlineFlow, { throughline: tl, edges: edges, collapsed: !expandedThroughlines.has(tl.id), active: focusedThroughlineId === tl.id
885
+ ? { stepIndex: focusedStepIndex }
886
+ : hoveredThroughlineStep?.throughlineId === tl.id
887
+ ? { stepIndex: hoveredThroughlineStep.stepIndex }
888
+ : 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
889
  const groupKey = group.repoKey ?? '__no-repo__';
755
890
  const collapsed = collapsedRepos.has(groupKey);
756
891
  return (_jsxs("div", { style: {
@@ -816,27 +951,88 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
816
951
  flex: 1,
817
952
  minHeight: 0,
818
953
  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: {
954
+ }, 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
955
  position: 'absolute',
821
956
  top: 10,
822
957
  left: '50%',
823
958
  transform: 'translateX(-50%)',
824
959
  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)',
960
+ display: 'flex',
961
+ flexDirection: 'column',
962
+ alignItems: 'center',
963
+ gap: 6,
964
+ maxWidth: '70%',
838
965
  pointerEvents: 'none',
839
- }, children: graphTitle })), selected && (_jsx("div", { style: {
966
+ }, children: [graphTitle && (_jsx("div", { style: {
967
+ maxWidth: '100%',
968
+ overflow: 'hidden',
969
+ textOverflow: 'ellipsis',
970
+ whiteSpace: 'nowrap',
971
+ padding: '6px 18px',
972
+ fontSize: theme.fontSizes[3],
973
+ fontWeight: 600,
974
+ fontFamily: theme.fonts.monospace,
975
+ color: theme.colors.text,
976
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
977
+ border: `1px solid ${theme.colors.border}`,
978
+ borderRadius: 6,
979
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
980
+ }, children: graphTitle })), overlayThroughlineTitle && (_jsxs("div", { style: {
981
+ maxWidth: '100%',
982
+ minWidth: overlayThroughlineStep ? 160 : undefined,
983
+ display: 'flex',
984
+ flexDirection: 'column',
985
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
986
+ border: `1px solid ${theme.colors.border}`,
987
+ borderRadius: 6,
988
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
989
+ overflow: 'hidden',
990
+ opacity: 0.95,
991
+ }, "aria-label": overlayThroughlineStep
992
+ ? `${overlayThroughlineTitle}, step ${overlayThroughlineStep.index} of ${overlayThroughlineStep.total}`
993
+ : overlayThroughlineTitle, children: [_jsx("div", { style: {
994
+ padding: '5px 14px',
995
+ fontSize: theme.fontSizes[2] ?? theme.fontSizes[1],
996
+ fontWeight: 600,
997
+ fontFamily: theme.fonts.monospace,
998
+ color: theme.colors.text,
999
+ overflow: 'hidden',
1000
+ textOverflow: 'ellipsis',
1001
+ whiteSpace: 'nowrap',
1002
+ textAlign: 'center',
1003
+ }, children: overlayThroughlineTitle }), overlayThroughlineStep && overlayThroughlineStep.total > 0 && (_jsx("div", { style: {
1004
+ display: 'flex',
1005
+ gap: 3,
1006
+ padding: '0 6px 5px',
1007
+ }, "aria-hidden": "true", children: Array.from({ length: overlayThroughlineStep.total }, (_, i) => {
1008
+ const n = i + 1;
1009
+ const active = n === overlayThroughlineStep.index;
1010
+ const done = n < overlayThroughlineStep.index;
1011
+ return (_jsx("span", { style: {
1012
+ flex: 1,
1013
+ height: 2,
1014
+ borderRadius: 1,
1015
+ background: active
1016
+ ? (theme.colors.accent ?? theme.colors.primary ?? theme.colors.text)
1017
+ : done
1018
+ ? (theme.colors.textSecondary ?? theme.colors.text)
1019
+ : (theme.colors.border ?? 'rgba(127,127,127,0.45)'),
1020
+ opacity: active ? 1 : done ? 0.75 : 0.4,
1021
+ transition: 'opacity 120ms ease, background 120ms ease',
1022
+ } }, n));
1023
+ }) }))] })), overlayThroughlineStep?.annotation && (_jsx("div", { style: {
1024
+ maxWidth: '100%',
1025
+ padding: '6px 14px',
1026
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1027
+ border: `1px solid ${theme.colors.border}`,
1028
+ borderRadius: 6,
1029
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1030
+ textAlign: 'center',
1031
+ fontSize: theme.fontSizes[0],
1032
+ fontFamily: theme.fonts.body,
1033
+ color: theme.colors.textMuted ?? theme.colors.textSecondary,
1034
+ lineHeight: 1.35,
1035
+ }, children: overlayThroughlineStep.annotation }))] })), selected && (_jsx("div", { style: {
840
1036
  position: 'absolute',
841
1037
  top: 10,
842
1038
  right: 10,
@@ -896,7 +1092,7 @@ function RepoGroupHeader({ group, collapsed, onToggle, }) {
896
1092
  * title: closed → open + select; open and unselected → select; open and
897
1093
  * selected → close + clear focus. The right-aligned close button collapses
898
1094
  * without selecting. A step row focuses that step's edge. */
899
- function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollapsed, onFocusFlow, onClearFocus, onFocusStep, }) {
1095
+ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollapsed, onFocusFlow, onClearFocus, onFocusStep, onHoverStep, onLeaveStep, }) {
900
1096
  const { theme } = useTheme();
901
1097
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
902
1098
  const hoverBg = theme.colors.background;
@@ -904,8 +1100,52 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
904
1100
  const wholeFlowActive = active !== null && active.stepIndex === null;
905
1101
  const [headerHover, setHeaderHover] = useState(false);
906
1102
  const [closeHover, setCloseHover] = useState(false);
1103
+ const [playHover, setPlayHover] = useState(false);
907
1104
  const [hoveredStep, setHoveredStep] = useState(null);
908
1105
  const stepButtonRefs = useRef([]);
1106
+ // Autoplay: stepping through the flow's steps with a pause between each.
1107
+ const [playing, setPlaying] = useState(false);
1108
+ const playTimerRef = useRef(null);
1109
+ const stopPlaying = useCallback(() => {
1110
+ if (playTimerRef.current != null) {
1111
+ window.clearTimeout(playTimerRef.current);
1112
+ playTimerRef.current = null;
1113
+ }
1114
+ setPlaying(false);
1115
+ }, []);
1116
+ // Clear any pending timer on unmount.
1117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1118
+ useEffect(() => () => stopPlaying(), []);
1119
+ const startPlaying = useCallback(() => {
1120
+ if (collapsed)
1121
+ onToggleCollapsed(throughline.id);
1122
+ if (active === null || active.stepIndex !== null)
1123
+ onFocusFlow(throughline);
1124
+ const stepCount = throughline.steps.length;
1125
+ if (stepCount === 0)
1126
+ return;
1127
+ setPlaying(true);
1128
+ let i = 0;
1129
+ const tick = () => {
1130
+ if (i >= stepCount) {
1131
+ playTimerRef.current = null;
1132
+ setPlaying(false);
1133
+ return;
1134
+ }
1135
+ onFocusStep(throughline, i);
1136
+ i += 1;
1137
+ playTimerRef.current = window.setTimeout(tick, THROUGHLINE_PLAY_PAUSE_MS);
1138
+ };
1139
+ tick();
1140
+ }, [collapsed, active, onToggleCollapsed, onFocusFlow, throughline, onFocusStep]);
1141
+ const togglePlay = useCallback(() => {
1142
+ if (playing) {
1143
+ stopPlaying();
1144
+ }
1145
+ else {
1146
+ startPlaying();
1147
+ }
1148
+ }, [playing, stopPlaying, startPlaying]);
909
1149
  // Keep DOM focus on the active step so the browser focus ring (and
910
1150
  // subsequent arrow keys) follow arrow navigation, not the originally
911
1151
  // clicked button.
@@ -914,11 +1154,10 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
914
1154
  return;
915
1155
  stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
916
1156
  }, [active?.stepIndex]);
917
- return (_jsxs("div", { style: { margin: '4px 0', borderRadius: 8 }, children: [_jsxs("div", { onMouseEnter: () => setHeaderHover(true), onMouseLeave: () => setHeaderHover(false), style: {
1157
+ return (_jsxs("div", { children: [_jsxs("div", { onMouseEnter: () => setHeaderHover(true), onMouseLeave: () => setHeaderHover(false), style: {
918
1158
  display: 'flex',
919
1159
  alignItems: 'center',
920
1160
  gap: 4,
921
- borderRadius: 6,
922
1161
  background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
923
1162
  transition: 'background 120ms ease',
924
1163
  }, children: [_jsx("button", { type: "button", onClick: () => {
@@ -938,8 +1177,7 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
938
1177
  display: 'flex',
939
1178
  alignItems: 'center',
940
1179
  minWidth: 0,
941
- padding: '6px 8px',
942
- borderRadius: 6,
1180
+ padding: '10px 8px',
943
1181
  border: 'none',
944
1182
  background: 'transparent',
945
1183
  textAlign: 'left',
@@ -952,39 +1190,62 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
952
1190
  fontFamily: theme.fonts.monospace,
953
1191
  fontWeight: 600,
954
1192
  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) => {
1193
+ }, 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) => {
1194
+ e.stopPropagation();
1195
+ togglePlay();
1196
+ }, style: {
1197
+ display: 'inline-flex',
1198
+ alignItems: 'center',
1199
+ justifyContent: 'center',
1200
+ flexShrink: 0,
1201
+ width: 22,
1202
+ height: 22,
1203
+ padding: 0,
1204
+ border: 'none',
1205
+ borderRadius: 4,
1206
+ background: playing || playHover ? theme.colors.border : 'transparent',
1207
+ color: playing || playHover ? theme.colors.text : muted,
1208
+ cursor: 'pointer',
1209
+ transition: 'background 120ms ease, color 120ms ease',
1210
+ }, 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) => {
1211
+ e.stopPropagation();
1212
+ onToggleCollapsed(throughline.id);
1213
+ if (active !== null)
1214
+ onClearFocus();
1215
+ }, style: {
1216
+ display: 'inline-flex',
1217
+ alignItems: 'center',
1218
+ justifyContent: 'center',
1219
+ flexShrink: 0,
1220
+ width: 22,
1221
+ height: 22,
1222
+ marginRight: 4,
1223
+ padding: 0,
1224
+ border: 'none',
1225
+ borderRadius: 4,
1226
+ background: closeHover ? theme.colors.border : 'transparent',
1227
+ color: closeHover ? theme.colors.text : muted,
1228
+ cursor: 'pointer',
1229
+ transition: 'background 120ms ease, color 120ms ease',
1230
+ }, children: _jsx(X, { size: 12, strokeWidth: 2 }) })] }))] }), !collapsed && (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 1 }, children: throughline.steps.map((step, i) => {
976
1231
  const edge = edgeById.get(step.edgeId);
977
1232
  const mech = edge?.mechanism;
978
1233
  const color = mech ? MECHANISM_COLOR[mech] : muted;
979
1234
  const stepActive = active !== null && active.stepIndex === i;
980
1235
  return (_jsxs("button", { ref: (el) => {
981
1236
  stepButtonRefs.current[i] = el;
982
- }, type: "button", onMouseEnter: () => setHoveredStep(i), onMouseLeave: () => setHoveredStep(null), onClick: () => onFocusStep(throughline, i), style: {
1237
+ }, type: "button", onMouseEnter: () => {
1238
+ setHoveredStep(i);
1239
+ onHoverStep(throughline, i);
1240
+ }, onMouseLeave: () => {
1241
+ setHoveredStep(null);
1242
+ onLeaveStep();
1243
+ }, onClick: () => onFocusStep(throughline, i), style: {
983
1244
  display: 'flex',
984
1245
  alignItems: 'center',
985
1246
  gap: 8,
986
1247
  minWidth: 0,
987
- padding: '4px 8px 4px 12px',
1248
+ padding: '8px 8px 8px 12px',
988
1249
  textAlign: 'left',
989
1250
  borderRadius: 6,
990
1251
  border: 'none',
@@ -1004,9 +1265,9 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
1004
1265
  overflow: 'hidden',
1005
1266
  textOverflow: 'ellipsis',
1006
1267
  whiteSpace: 'nowrap',
1007
- fontSize: theme.fontSizes[0],
1268
+ fontSize: theme.fontSizes[1],
1008
1269
  fontFamily: theme.fonts.monospace,
1009
- color: stepActive ? theme.colors.text : muted,
1270
+ color: theme.colors.text,
1010
1271
  }, children: step.symbol })) : (_jsxs(_Fragment, { children: [_jsx("span", { style: {
1011
1272
  flexShrink: 0,
1012
1273
  width: 74,
@@ -1024,7 +1285,7 @@ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollap
1024
1285
  whiteSpace: 'nowrap',
1025
1286
  fontSize: theme.fontSizes[0],
1026
1287
  fontFamily: theme.fonts.monospace,
1027
- color: stepActive ? theme.colors.text : muted,
1288
+ color: theme.colors.text,
1028
1289
  }, children: [step.file.split('/').pop(), _jsxs("span", { style: { opacity: 0.7 }, children: [":", step.line] })] })] }))] }, `${step.edgeId}-${i}`));
1029
1290
  }) }))] }));
1030
1291
  }