@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.
@@ -32,7 +32,7 @@ import {
32
32
  applyNodeChanges,
33
33
  } from '@xyflow/react';
34
34
  import { useTheme } from '@principal-ade/industry-theme';
35
- import { ChevronDown, ChevronUp, X } from 'lucide-react';
35
+ import { ChevronDown, ChevronUp, Pause, Play, X } from 'lucide-react';
36
36
  import { IndustryMarkdownSlide } from 'themed-markdown';
37
37
  import {
38
38
  buildSubsystemGraph,
@@ -58,6 +58,8 @@ const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
58
58
  /** Rough monospace width at fontSize 10 + horizontal padding/border. */
59
59
  const EDGE_LABEL_CHAR_PX = 6.2;
60
60
  const EDGE_LABEL_PAD_PX = 18;
61
+ /** Pause (ms) between steps when a throughline autoplays. */
62
+ const THROUGHLINE_PLAY_PAUSE_MS = 2500;
61
63
 
62
64
  /** Context passed to `renderThroughlineViewer` when a flow/step is focused. */
63
65
  export interface ThroughlineViewerContext {
@@ -99,12 +101,37 @@ export interface SubsystemComponentGraphProps {
99
101
  * subsystem name visible as an overlay on the canvas.
100
102
  */
101
103
  hideSidebar?: boolean;
104
+ /**
105
+ * How throughline step highlighting behaves on the canvas.
106
+ * - `focus` (default): zoom to the step, hide non-participants, open drawer
107
+ * - `dim`: keep the full graph, dim non-participants (same as hovering a step)
108
+ */
109
+ throughlineStepMode?: 'focus' | 'dim';
110
+ /**
111
+ * When true, cycles throughline steps automatically using `dim` highlighting
112
+ * (no zoom, no drawer). Loops across all throughlines that have steps.
113
+ * Useful for graph-only embeds (`hideSidebar`).
114
+ */
115
+ autoPlayThroughlines?: boolean;
116
+ /** Pause between autoplay steps in ms. @default 2500 */
117
+ throughlineAutoPlayIntervalMs?: number;
118
+ /**
119
+ * When false, focusing a throughline/step does not call `fitView`.
120
+ * @default true
121
+ */
122
+ zoomOnThroughlineFocus?: boolean;
102
123
  /**
103
124
  * Subsystem title rendered as a non-interactive overlay chip on the graph
104
125
  * canvas (top-center). Does not trigger the sidebar — for graph-only
105
126
  * embeds that still need to name what they show.
106
127
  */
107
128
  graphTitle?: string;
129
+ /**
130
+ * When true, shows the active throughline's title as a non-interactive
131
+ * overlay chip on the canvas (under `graphTitle` when both are set).
132
+ * Uses the focused or hover-highlighted throughline.
133
+ */
134
+ showThroughlineTitle?: boolean;
108
135
  /** Markdown description rendered in the sidebar. */
109
136
  description?: string;
110
137
  /** Rendered over the graph canvas only (not the title/legend sidebar). */
@@ -183,7 +210,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
183
210
  measured: { w: number; h: number } | null;
184
211
  }
185
212
 
186
- function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
213
+ 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 }: InnerProps) {
187
214
  const { theme } = useTheme();
188
215
  const { fitView } = useReactFlow();
189
216
  const viewport = useViewport();
@@ -202,6 +229,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
202
229
  const [focusedThroughlineId, setFocusedThroughlineId] = useState<string | null>(null);
203
230
  // `null` = whole flow focused; a number = that single step's edge focused.
204
231
  const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
232
+ // Hovered step in the flows panel: dims every canvas node/edge not involved
233
+ // with that step (transient — no camera move, no drawer, no focus change).
234
+ const [hoveredThroughlineStep, setHoveredThroughlineStep] = useState<{
235
+ throughlineId: string;
236
+ stepIndex: number;
237
+ } | null>(null);
205
238
  // Sidebar bottom half: which panel is shown when throughlines exist.
206
239
  const [sidebarView, setSidebarView] = useState<'files' | 'flows'>(() =>
207
240
  throughlines?.length ? 'flows' : 'files',
@@ -233,6 +266,46 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
233
266
  return throughlines.find((t) => t.id === drawerTarget.throughlineId) ?? null;
234
267
  }, [drawerTarget, throughlines]);
235
268
 
269
+ // Throughline shown on the canvas title chip (focus or hover/autoplay highlight).
270
+ const overlayThroughlineTitle = useMemo(() => {
271
+ if (!showThroughlineTitle || !throughlines?.length) return null;
272
+ const id = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
273
+ if (!id) return null;
274
+ return throughlines.find((t) => t.id === id)?.title ?? null;
275
+ }, [
276
+ showThroughlineTitle,
277
+ throughlines,
278
+ focusedThroughlineId,
279
+ hoveredThroughlineStep,
280
+ ]);
281
+
282
+ // Active step for the bottom-of-title progress + annotation chip.
283
+ const overlayThroughlineStep = useMemo(() => {
284
+ if (!showThroughlineTitle || !throughlines?.length) return null;
285
+ const tlId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId ?? null;
286
+ let stepIndex: number | null = null;
287
+ if (focusedThroughlineId != null) {
288
+ stepIndex = focusedStepIndex;
289
+ } else if (hoveredThroughlineStep != null) {
290
+ stepIndex = hoveredThroughlineStep.stepIndex;
291
+ }
292
+ if (tlId == null || stepIndex == null) return null;
293
+ const tl = throughlines.find((t) => t.id === tlId);
294
+ const step = tl?.steps[stepIndex];
295
+ if (!step || !tl) return null;
296
+ return {
297
+ index: stepIndex + 1,
298
+ total: tl.steps.length,
299
+ annotation: step.annotation,
300
+ };
301
+ }, [
302
+ showThroughlineTitle,
303
+ throughlines,
304
+ focusedThroughlineId,
305
+ focusedStepIndex,
306
+ hoveredThroughlineStep,
307
+ ]);
308
+
236
309
  const drawerTitle = useMemo(() => {
237
310
  if (!drawerTarget) return null;
238
311
  if (drawerTarget.kind === 'file') return drawerTarget.file;
@@ -398,11 +471,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
398
471
  return new Set(tl.steps.map((s) => s.edgeId));
399
472
  }, [throughlines, focusedThroughlineId, focusedStepIndex]);
400
473
 
401
- // 1-based step numbers per edge of the selected flow (an edge can appear
402
- // in more than one step).
474
+ // 1-based step numbers per edge of the active flow (focused or
475
+ // hover/autoplay-highlighted). An edge can appear in more than one step.
403
476
  const selectedFlowStepNos = useMemo(() => {
404
- if (focusedThroughlineId == null || !throughlines) return null;
405
- const tl = throughlines.find((t) => t.id === focusedThroughlineId);
477
+ const activeId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
478
+ if (activeId == null || !throughlines) return null;
479
+ const tl = throughlines.find((t) => t.id === activeId);
406
480
  if (!tl) return null;
407
481
  const map = new Map<string, number[]>();
408
482
  tl.steps.forEach((s, i) => {
@@ -411,7 +485,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
411
485
  map.set(s.edgeId, list);
412
486
  });
413
487
  return map;
414
- }, [throughlines, focusedThroughlineId]);
488
+ }, [throughlines, focusedThroughlineId, hoveredThroughlineStep]);
415
489
 
416
490
  // Union of every expanded (opened) throughline's edges — the visible set.
417
491
  const openedEdgeIds = useMemo(() => {
@@ -434,6 +508,19 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
434
508
  }
435
509
  return ids.size > 0 ? ids : null;
436
510
  };
511
+ // Edge/nodes involved in the hovered step — dim everything else.
512
+ const hoverEdgeIds = useMemo(() => {
513
+ if (!hoveredThroughlineStep || !throughlines) return null;
514
+ const tl = throughlines.find((t) => t.id === hoveredThroughlineStep.throughlineId);
515
+ if (!tl) return null;
516
+ const step = tl.steps[hoveredThroughlineStep.stepIndex];
517
+ return step ? new Set([step.edgeId]) : null;
518
+ }, [throughlines, hoveredThroughlineStep]);
519
+ const hoverNodeIds = useMemo(
520
+ () => endpointsOf(hoverEdgeIds),
521
+ // eslint-disable-next-line react-hooks/exhaustive-deps
522
+ [baseEdges, hoverEdgeIds],
523
+ );
437
524
  const openedNodeIds = useMemo(
438
525
  () => endpointsOf(openedEdgeIds),
439
526
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -467,6 +554,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
467
554
  // `isSelected` rides in data because the node's stopPropagation() keeps
468
555
  // React Flow's own selection state from updating.
469
556
  const dispNodes = useMemo(() => {
557
+ // While a step is hovered, only the hovered step's endpoints stay bright;
558
+ // everything else is dimmed (never hidden) so the step's context survives.
470
559
  return xyflowNodesBase.map((n) => {
471
560
  // Boundary frames follow their members: hidden when no member is
472
561
  // visible, dimmed when members are dimmed. Never selectable.
@@ -478,13 +567,17 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
478
567
  anyOpened: openedNodeIds != null,
479
568
  anySelected: brightNodeIds != null,
480
569
  });
570
+ const dimmed = hoverNodeIds
571
+ ? !memberIds.some((id) => hoverNodeIds.has(id))
572
+ : vis.dimmed;
573
+ const hidden = hoverNodeIds ? false : vis.hidden;
481
574
  return {
482
575
  ...n,
483
- hidden: vis.hidden,
576
+ hidden,
484
577
  selectable: false,
485
578
  data: {
486
579
  ...(n.data as object),
487
- ...(vis.dimmed && { dimmed: true }),
580
+ ...(dimmed && { dimmed: true }),
488
581
  },
489
582
  };
490
583
  }
@@ -497,22 +590,24 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
497
590
  anyOpened: openedNodeIds != null,
498
591
  anySelected: brightNodeIds != null,
499
592
  });
500
- if (fileMatch === undefined && !isSelected && !vis.dimmed) {
593
+ const dimmed = hoverNodeIds ? !hoverNodeIds.has(n.id) : vis.dimmed;
594
+ const hidden = hoverNodeIds ? false : vis.hidden;
595
+ if (fileMatch === undefined && !isSelected && !dimmed) {
501
596
  const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
502
- return { ...n, hidden: vis.hidden, data: rest };
597
+ return { ...n, hidden, data: rest };
503
598
  }
504
599
  return {
505
600
  ...n,
506
- hidden: vis.hidden,
601
+ hidden,
507
602
  data: {
508
603
  ...(n.data as object),
509
604
  ...(fileMatch !== undefined && { fileMatch }),
510
605
  ...(isSelected && { isSelected }),
511
- ...(vis.dimmed && { dimmed: true }),
606
+ ...(dimmed && { dimmed: true }),
512
607
  },
513
608
  };
514
609
  });
515
- }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
610
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, hoverNodeIds]);
516
611
 
517
612
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
518
613
  const baseEdgesKey = useMemo(
@@ -533,7 +628,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
533
628
  markerEnd: nextMarker,
534
629
  };
535
630
  };
536
- if (openedEdgeIds || focusEdgeIds) {
631
+ if (openedEdgeIds || focusEdgeIds || hoverEdgeIds) {
537
632
  return baseEdges.map((e) => {
538
633
  const vis = flowElementVisibility({
539
634
  inOpened: openedEdgeIds?.has(e.id) === true,
@@ -541,12 +636,14 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
541
636
  anyOpened: openedEdgeIds != null,
542
637
  anySelected: focusEdgeIds != null,
543
638
  });
544
- return { ...paint(e, vis.dimmed), hidden: vis.hidden };
639
+ const dimmed = hoverEdgeIds ? !hoverEdgeIds.has(e.id) : vis.dimmed;
640
+ const hidden = hoverEdgeIds ? false : vis.hidden;
641
+ return { ...paint(e, dimmed), hidden };
545
642
  });
546
643
  }
547
644
  if (!selectedEdgeId) return baseEdges;
548
645
  return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
549
- }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
646
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, hoverEdgeIds]);
550
647
 
551
648
  const onNodesChange = useCallback(
552
649
  (changes: NodeChange[]) => {
@@ -672,6 +769,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
672
769
  // components the edge attaches to (and therefore the edge line between them).
673
770
  const fitFocusBounds = useCallback(
674
771
  (ids: ReadonlySet<string>) => {
772
+ if (!zoomOnThroughlineFocus) return;
675
773
  const nodeIds = new Set<string>();
676
774
  for (const e of baseEdges) {
677
775
  if (!ids.has(e.id)) continue;
@@ -685,7 +783,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
685
783
  duration: 300,
686
784
  });
687
785
  },
688
- [baseEdges, fitView],
786
+ [baseEdges, fitView, zoomOnThroughlineFocus],
689
787
  );
690
788
 
691
789
  // Focus an entire flow: hide everything but the flow's nodes and edges, and
@@ -693,34 +791,54 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
693
791
  // reads as the narrative. No drawer: the code view only opens on a step
694
792
  // click. A stale throughline drawer (from a previously focused flow's step)
695
793
  // closes; an explicitly opened file drawer stays.
794
+ // In `dim` mode: clear step highlight and leave the full graph visible
795
+ // (no zoom / hide) — matching "step away from a hovered step".
696
796
  const focusThroughlineEdges = useCallback(
697
797
  (tl: SubsystemThroughline) => {
698
798
  setSelected(null);
699
799
  setSelectedEdgeId(null);
800
+ setHoveredThroughlineStep(null);
801
+ if (throughlineStepMode === 'dim') {
802
+ setFocusedStepIndex(null);
803
+ setFocusedThroughlineId(null);
804
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
805
+ return;
806
+ }
700
807
  setFocusedStepIndex(null);
701
808
  setFocusedThroughlineId(tl.id);
702
809
  fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
703
810
  setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
704
811
  },
705
- [fitFocusBounds],
812
+ [fitFocusBounds, throughlineStepMode],
706
813
  );
707
814
 
708
815
  const clearThroughlineFocus = useCallback(() => {
709
816
  setFocusedThroughlineId(null);
710
817
  setFocusedStepIndex(null);
818
+ setHoveredThroughlineStep(null);
711
819
  setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
712
820
  }, []);
713
821
 
714
822
  // Focus a single step's edge on the canvas and open/scroll the throughline
715
823
  // drawer to that step's snippet.
824
+ // In `dim` mode: only dim non-participants (same as hovering a step) —
825
+ // no camera move, no drawer, no hide.
716
826
  const focusThroughlineStep = useCallback(
717
827
  (tl: SubsystemThroughline, stepIndex: number) => {
718
828
  const step = tl.steps[stepIndex];
719
829
  if (!step) return;
720
830
  setSelected(null);
721
831
  setSelectedEdgeId(null);
832
+ if (throughlineStepMode === 'dim') {
833
+ setFocusedStepIndex(null);
834
+ setFocusedThroughlineId(null);
835
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex });
836
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
837
+ return;
838
+ }
722
839
  setFocusedStepIndex(stepIndex);
723
840
  setFocusedThroughlineId(tl.id);
841
+ setHoveredThroughlineStep(null);
724
842
  fitFocusBounds(new Set([step.edgeId]));
725
843
  if (renderThroughlineViewer) {
726
844
  setDrawerTarget({
@@ -736,9 +854,45 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
736
854
  });
737
855
  }
738
856
  },
739
- [fitFocusBounds, renderThroughlineViewer],
857
+ [fitFocusBounds, renderThroughlineViewer, throughlineStepMode],
740
858
  );
741
859
 
860
+ // Graph-only embeds: cycle throughline steps with hover-style dimming.
861
+ useEffect(() => {
862
+ if (!autoPlayThroughlines || !throughlines?.length || !layoutReady) return;
863
+ const playable = throughlines.filter((tl) => tl.steps.length > 0);
864
+ if (playable.length === 0) return;
865
+
866
+ let cancelled = false;
867
+ let tlIdx = 0;
868
+ let stepIdx = 0;
869
+ let timer: number | null = null;
870
+ const interval = Math.max(400, throughlineAutoPlayIntervalMs);
871
+
872
+ const tick = () => {
873
+ if (cancelled) return;
874
+ const tl = playable[tlIdx]!;
875
+ setSelected(null);
876
+ setSelectedEdgeId(null);
877
+ setFocusedThroughlineId(null);
878
+ setFocusedStepIndex(null);
879
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: stepIdx });
880
+ stepIdx += 1;
881
+ if (stepIdx >= tl.steps.length) {
882
+ stepIdx = 0;
883
+ tlIdx = (tlIdx + 1) % playable.length;
884
+ }
885
+ timer = window.setTimeout(tick, interval);
886
+ };
887
+
888
+ tick();
889
+ return () => {
890
+ cancelled = true;
891
+ if (timer != null) window.clearTimeout(timer);
892
+ setHoveredThroughlineStep(null);
893
+ };
894
+ }, [autoPlayThroughlines, throughlines, throughlineAutoPlayIntervalMs, layoutReady]);
895
+
742
896
  // Arrow keys step through the focused throughline once a step is active
743
897
  // (sidebar click or drawer open). Ignores typing targets and chords.
744
898
  useEffect(() => {
@@ -1045,7 +1199,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1045
1199
  flex: 1,
1046
1200
  minHeight: 0,
1047
1201
  overflowY: 'auto',
1048
- padding: '0 4px 12px',
1202
+ padding: '0 0 12px',
1049
1203
  }}
1050
1204
  >
1051
1205
  {throughlines.map((tl) => (
@@ -1054,11 +1208,21 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1054
1208
  throughline={tl}
1055
1209
  edges={edges}
1056
1210
  collapsed={!expandedThroughlines.has(tl.id)}
1057
- active={focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null}
1211
+ active={
1212
+ focusedThroughlineId === tl.id
1213
+ ? { stepIndex: focusedStepIndex }
1214
+ : hoveredThroughlineStep?.throughlineId === tl.id
1215
+ ? { stepIndex: hoveredThroughlineStep.stepIndex }
1216
+ : null
1217
+ }
1058
1218
  onToggleCollapsed={toggleThroughlineCollapsed}
1059
1219
  onFocusFlow={focusThroughlineEdges}
1060
1220
  onClearFocus={clearThroughlineFocus}
1061
1221
  onFocusStep={focusThroughlineStep}
1222
+ onHoverStep={(tl, i) =>
1223
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: i })
1224
+ }
1225
+ onLeaveStep={() => setHoveredThroughlineStep(null)}
1062
1226
  />
1063
1227
  ))}
1064
1228
  </div>
@@ -1213,10 +1377,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1213
1377
  <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
1214
1378
  <Controls showZoom showFitView showInteractive />
1215
1379
  </ReactFlow>
1216
- {/* Graph title — non-interactive chip centered at the top of the canvas
1217
- (clear of the top-right declaration card). Lets graph-only embeds
1218
- name the subsystem they show. */}
1219
- {graphTitle && (
1380
+ {/* Graph / throughline / step titles — non-interactive chips at the top
1381
+ of the canvas. Graph-only embeds use these without opening the sidebar. */}
1382
+ {(graphTitle || overlayThroughlineTitle || overlayThroughlineStep) && (
1220
1383
  <div
1221
1384
  style={{
1222
1385
  position: 'absolute',
@@ -1224,23 +1387,124 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1224
1387
  left: '50%',
1225
1388
  transform: 'translateX(-50%)',
1226
1389
  zIndex: 6,
1227
- maxWidth: '60%',
1228
- overflow: 'hidden',
1229
- textOverflow: 'ellipsis',
1230
- whiteSpace: 'nowrap',
1231
- padding: '6px 18px',
1232
- fontSize: theme.fontSizes[3],
1233
- fontWeight: 600,
1234
- fontFamily: theme.fonts.monospace,
1235
- color: theme.colors.text,
1236
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
1237
- border: `1px solid ${theme.colors.border}`,
1238
- borderRadius: 6,
1239
- boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1390
+ display: 'flex',
1391
+ flexDirection: 'column',
1392
+ alignItems: 'center',
1393
+ gap: 6,
1394
+ maxWidth: '70%',
1240
1395
  pointerEvents: 'none',
1241
1396
  }}
1242
1397
  >
1243
- {graphTitle}
1398
+ {graphTitle && (
1399
+ <div
1400
+ style={{
1401
+ maxWidth: '100%',
1402
+ overflow: 'hidden',
1403
+ textOverflow: 'ellipsis',
1404
+ whiteSpace: 'nowrap',
1405
+ padding: '6px 18px',
1406
+ fontSize: theme.fontSizes[3],
1407
+ fontWeight: 600,
1408
+ fontFamily: theme.fonts.monospace,
1409
+ color: theme.colors.text,
1410
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1411
+ border: `1px solid ${theme.colors.border}`,
1412
+ borderRadius: 6,
1413
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1414
+ }}
1415
+ >
1416
+ {graphTitle}
1417
+ </div>
1418
+ )}
1419
+ {overlayThroughlineTitle && (
1420
+ <div
1421
+ style={{
1422
+ maxWidth: '100%',
1423
+ minWidth: overlayThroughlineStep ? 160 : undefined,
1424
+ display: 'flex',
1425
+ flexDirection: 'column',
1426
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1427
+ border: `1px solid ${theme.colors.border}`,
1428
+ borderRadius: 6,
1429
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1430
+ overflow: 'hidden',
1431
+ opacity: 0.95,
1432
+ }}
1433
+ aria-label={
1434
+ overlayThroughlineStep
1435
+ ? `${overlayThroughlineTitle}, step ${overlayThroughlineStep.index} of ${overlayThroughlineStep.total}`
1436
+ : overlayThroughlineTitle
1437
+ }
1438
+ >
1439
+ <div
1440
+ style={{
1441
+ padding: '5px 14px',
1442
+ fontSize: theme.fontSizes[2] ?? theme.fontSizes[1],
1443
+ fontWeight: 600,
1444
+ fontFamily: theme.fonts.monospace,
1445
+ color: theme.colors.text,
1446
+ overflow: 'hidden',
1447
+ textOverflow: 'ellipsis',
1448
+ whiteSpace: 'nowrap',
1449
+ textAlign: 'center',
1450
+ }}
1451
+ >
1452
+ {overlayThroughlineTitle}
1453
+ </div>
1454
+ {overlayThroughlineStep && overlayThroughlineStep.total > 0 && (
1455
+ <div
1456
+ style={{
1457
+ display: 'flex',
1458
+ gap: 3,
1459
+ padding: '0 6px 5px',
1460
+ }}
1461
+ aria-hidden="true"
1462
+ >
1463
+ {Array.from({ length: overlayThroughlineStep.total }, (_, i) => {
1464
+ const n = i + 1;
1465
+ const active = n === overlayThroughlineStep.index;
1466
+ const done = n < overlayThroughlineStep.index;
1467
+ return (
1468
+ <span
1469
+ key={n}
1470
+ style={{
1471
+ flex: 1,
1472
+ height: 2,
1473
+ borderRadius: 1,
1474
+ background: active
1475
+ ? (theme.colors.accent ?? theme.colors.primary ?? theme.colors.text)
1476
+ : done
1477
+ ? (theme.colors.textSecondary ?? theme.colors.text)
1478
+ : (theme.colors.border ?? 'rgba(127,127,127,0.45)'),
1479
+ opacity: active ? 1 : done ? 0.75 : 0.4,
1480
+ transition: 'opacity 120ms ease, background 120ms ease',
1481
+ }}
1482
+ />
1483
+ );
1484
+ })}
1485
+ </div>
1486
+ )}
1487
+ </div>
1488
+ )}
1489
+ {overlayThroughlineStep?.annotation && (
1490
+ <div
1491
+ style={{
1492
+ maxWidth: '100%',
1493
+ padding: '6px 14px',
1494
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1495
+ border: `1px solid ${theme.colors.border}`,
1496
+ borderRadius: 6,
1497
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1498
+ textAlign: 'center',
1499
+ fontSize: theme.fontSizes[0],
1500
+ fontFamily: theme.fonts.body,
1501
+ color: theme.colors.textMuted ?? theme.colors.textSecondary,
1502
+ lineHeight: 1.35,
1503
+ }}
1504
+ >
1505
+ {overlayThroughlineStep.annotation}
1506
+ </div>
1507
+ )}
1244
1508
  </div>
1245
1509
  )}
1246
1510
  {/* Selected-component declaration — floating card over the canvas
@@ -1388,6 +1652,8 @@ function ThroughlineFlow({
1388
1652
  onFocusFlow,
1389
1653
  onClearFocus,
1390
1654
  onFocusStep,
1655
+ onHoverStep,
1656
+ onLeaveStep,
1391
1657
  }: {
1392
1658
  throughline: SubsystemThroughline;
1393
1659
  edges: SubsystemComponentEdge[];
@@ -1398,6 +1664,8 @@ function ThroughlineFlow({
1398
1664
  onFocusFlow: (tl: SubsystemThroughline) => void;
1399
1665
  onClearFocus: () => void;
1400
1666
  onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
1667
+ onHoverStep: (tl: SubsystemThroughline, stepIndex: number) => void;
1668
+ onLeaveStep: () => void;
1401
1669
  }) {
1402
1670
  const { theme } = useTheme();
1403
1671
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -1406,8 +1674,52 @@ function ThroughlineFlow({
1406
1674
  const wholeFlowActive = active !== null && active.stepIndex === null;
1407
1675
  const [headerHover, setHeaderHover] = useState(false);
1408
1676
  const [closeHover, setCloseHover] = useState(false);
1677
+ const [playHover, setPlayHover] = useState(false);
1409
1678
  const [hoveredStep, setHoveredStep] = useState<number | null>(null);
1410
1679
  const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
1680
+ // Autoplay: stepping through the flow's steps with a pause between each.
1681
+ const [playing, setPlaying] = useState(false);
1682
+ const playTimerRef = useRef<number | null>(null);
1683
+
1684
+ const stopPlaying = useCallback(() => {
1685
+ if (playTimerRef.current != null) {
1686
+ window.clearTimeout(playTimerRef.current);
1687
+ playTimerRef.current = null;
1688
+ }
1689
+ setPlaying(false);
1690
+ }, []);
1691
+
1692
+ // Clear any pending timer on unmount.
1693
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1694
+ useEffect(() => () => stopPlaying(), []);
1695
+
1696
+ const startPlaying = useCallback(() => {
1697
+ if (collapsed) onToggleCollapsed(throughline.id);
1698
+ if (active === null || active.stepIndex !== null) onFocusFlow(throughline);
1699
+ const stepCount = throughline.steps.length;
1700
+ if (stepCount === 0) return;
1701
+ setPlaying(true);
1702
+ let i = 0;
1703
+ const tick = () => {
1704
+ if (i >= stepCount) {
1705
+ playTimerRef.current = null;
1706
+ setPlaying(false);
1707
+ return;
1708
+ }
1709
+ onFocusStep(throughline, i);
1710
+ i += 1;
1711
+ playTimerRef.current = window.setTimeout(tick, THROUGHLINE_PLAY_PAUSE_MS);
1712
+ };
1713
+ tick();
1714
+ }, [collapsed, active, onToggleCollapsed, onFocusFlow, throughline, onFocusStep]);
1715
+
1716
+ const togglePlay = useCallback(() => {
1717
+ if (playing) {
1718
+ stopPlaying();
1719
+ } else {
1720
+ startPlaying();
1721
+ }
1722
+ }, [playing, stopPlaying, startPlaying]);
1411
1723
 
1412
1724
  // Keep DOM focus on the active step so the browser focus ring (and
1413
1725
  // subsequent arrow keys) follow arrow navigation, not the originally
@@ -1418,7 +1730,7 @@ function ThroughlineFlow({
1418
1730
  }, [active?.stepIndex]);
1419
1731
 
1420
1732
  return (
1421
- <div style={{ margin: '4px 0', borderRadius: 8 }}>
1733
+ <div>
1422
1734
  <div
1423
1735
  onMouseEnter={() => setHeaderHover(true)}
1424
1736
  onMouseLeave={() => setHeaderHover(false)}
@@ -1426,7 +1738,6 @@ function ThroughlineFlow({
1426
1738
  display: 'flex',
1427
1739
  alignItems: 'center',
1428
1740
  gap: 4,
1429
- borderRadius: 6,
1430
1741
  background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
1431
1742
  transition: 'background 120ms ease',
1432
1743
  }}
@@ -1449,8 +1760,7 @@ function ThroughlineFlow({
1449
1760
  display: 'flex',
1450
1761
  alignItems: 'center',
1451
1762
  minWidth: 0,
1452
- padding: '6px 8px',
1453
- borderRadius: 6,
1763
+ padding: '10px 8px',
1454
1764
  border: 'none',
1455
1765
  background: 'transparent',
1456
1766
  textAlign: 'left',
@@ -1472,6 +1782,35 @@ function ThroughlineFlow({
1472
1782
  </span>
1473
1783
  </button>
1474
1784
  {!collapsed && (
1785
+ <>
1786
+ <button
1787
+ type="button"
1788
+ aria-label={playing ? `Pause ${throughline.title} autoplay` : `Play ${throughline.title}`}
1789
+ title={playing ? 'Pause' : 'Play through steps'}
1790
+ onMouseEnter={() => setPlayHover(true)}
1791
+ onMouseLeave={() => setPlayHover(false)}
1792
+ onClick={(e) => {
1793
+ e.stopPropagation();
1794
+ togglePlay();
1795
+ }}
1796
+ style={{
1797
+ display: 'inline-flex',
1798
+ alignItems: 'center',
1799
+ justifyContent: 'center',
1800
+ flexShrink: 0,
1801
+ width: 22,
1802
+ height: 22,
1803
+ padding: 0,
1804
+ border: 'none',
1805
+ borderRadius: 4,
1806
+ background: playing || playHover ? theme.colors.border : 'transparent',
1807
+ color: playing || playHover ? theme.colors.text : muted,
1808
+ cursor: 'pointer',
1809
+ transition: 'background 120ms ease, color 120ms ease',
1810
+ }}
1811
+ >
1812
+ {playing ? <Pause size={12} strokeWidth={2} /> : <Play size={12} strokeWidth={2} />}
1813
+ </button>
1475
1814
  <button
1476
1815
  type="button"
1477
1816
  aria-label={`Close ${throughline.title}`}
@@ -1501,6 +1840,7 @@ function ThroughlineFlow({
1501
1840
  >
1502
1841
  <X size={12} strokeWidth={2} />
1503
1842
  </button>
1843
+ </>
1504
1844
  )}
1505
1845
  </div>
1506
1846
  {!collapsed && (
@@ -1517,15 +1857,21 @@ function ThroughlineFlow({
1517
1857
  stepButtonRefs.current[i] = el;
1518
1858
  }}
1519
1859
  type="button"
1520
- onMouseEnter={() => setHoveredStep(i)}
1521
- onMouseLeave={() => setHoveredStep(null)}
1860
+ onMouseEnter={() => {
1861
+ setHoveredStep(i);
1862
+ onHoverStep(throughline, i);
1863
+ }}
1864
+ onMouseLeave={() => {
1865
+ setHoveredStep(null);
1866
+ onLeaveStep();
1867
+ }}
1522
1868
  onClick={() => onFocusStep(throughline, i)}
1523
1869
  style={{
1524
1870
  display: 'flex',
1525
1871
  alignItems: 'center',
1526
1872
  gap: 8,
1527
1873
  minWidth: 0,
1528
- padding: '4px 8px 4px 12px',
1874
+ padding: '8px 8px 8px 12px',
1529
1875
  textAlign: 'left',
1530
1876
  borderRadius: 6,
1531
1877
  border: 'none',
@@ -1554,9 +1900,9 @@ function ThroughlineFlow({
1554
1900
  overflow: 'hidden',
1555
1901
  textOverflow: 'ellipsis',
1556
1902
  whiteSpace: 'nowrap',
1557
- fontSize: theme.fontSizes[0],
1903
+ fontSize: theme.fontSizes[1],
1558
1904
  fontFamily: theme.fonts.monospace,
1559
- color: stepActive ? theme.colors.text : muted,
1905
+ color: theme.colors.text,
1560
1906
  }}
1561
1907
  >
1562
1908
  {step.symbol}
@@ -1586,7 +1932,7 @@ function ThroughlineFlow({
1586
1932
  whiteSpace: 'nowrap',
1587
1933
  fontSize: theme.fontSizes[0],
1588
1934
  fontFamily: theme.fonts.monospace,
1589
- color: stepActive ? theme.colors.text : muted,
1935
+ color: theme.colors.text,
1590
1936
  }}
1591
1937
  >
1592
1938
  {step.file.split('/').pop()}