@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.
@@ -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',
@@ -215,6 +248,50 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
215
248
  const [descToggleHover, setDescToggleHover] = useState(false);
216
249
  // `true` only when a description exists AND the user opened it.
217
250
  const showDesc = !!description && descriptionVisible;
251
+ // Sidebar width (px). Draggable via the resize handle between the sidebar
252
+ // and the graph canvas; clamps to sensible bounds while dragging.
253
+ const [sidebarWidth, setSidebarWidth] = useState(340);
254
+ const [sidebarDrag, setSidebarDrag] = useState(false);
255
+ const sidebarDragStartX = useRef(0);
256
+ const sidebarDragStartWidth = useRef(340);
257
+ const sidebarMinWidth = 240;
258
+ const sidebarMaxWidth = useMemo(
259
+ () => Math.max(Math.min((_measured?.w ?? 680) * 0.5, 600), sidebarMinWidth),
260
+ [_measured?.w],
261
+ );
262
+ // Resize drag: capture the drag state so document-level move/up listeners
263
+ // stay attached for the duration of the gesture, then release on mouseup.
264
+ const onSidebarResizeStart = useCallback(
265
+ (e: ReactMouseEvent) => {
266
+ e.preventDefault();
267
+ sidebarDragStartX.current = e.clientX;
268
+ sidebarDragStartWidth.current = sidebarWidth;
269
+ setSidebarDrag(true);
270
+ },
271
+ [sidebarWidth],
272
+ );
273
+ useEffect(() => {
274
+ if (!sidebarDrag) return;
275
+ const onMove = (e: globalThis.MouseEvent) => {
276
+ const delta = e.clientX - sidebarDragStartX.current;
277
+ const next = Math.min(
278
+ Math.max(sidebarDragStartWidth.current + delta, sidebarMinWidth),
279
+ sidebarMaxWidth,
280
+ );
281
+ setSidebarWidth(next);
282
+ };
283
+ const onUp = () => {
284
+ setSidebarDrag(false);
285
+ // Re-fit the canvas so the graph re-centers in the new available space.
286
+ requestAnimationFrame(() => fitView());
287
+ };
288
+ document.addEventListener('mousemove', onMove);
289
+ document.addEventListener('mouseup', onUp);
290
+ return () => {
291
+ document.removeEventListener('mousemove', onMove);
292
+ document.removeEventListener('mouseup', onUp);
293
+ };
294
+ }, [sidebarDrag, sidebarMaxWidth, fitView]);
218
295
  // Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
219
296
  // closure over the effect deps) can toggle without a stale value.
220
297
  const selectedRef = useRef<SubsystemComponent | null>(null);
@@ -233,6 +310,46 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
233
310
  return throughlines.find((t) => t.id === drawerTarget.throughlineId) ?? null;
234
311
  }, [drawerTarget, throughlines]);
235
312
 
313
+ // Throughline shown on the canvas title chip (focus or hover/autoplay highlight).
314
+ const overlayThroughlineTitle = useMemo(() => {
315
+ if (!showThroughlineTitle || !throughlines?.length) return null;
316
+ const id = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
317
+ if (!id) return null;
318
+ return throughlines.find((t) => t.id === id)?.title ?? null;
319
+ }, [
320
+ showThroughlineTitle,
321
+ throughlines,
322
+ focusedThroughlineId,
323
+ hoveredThroughlineStep,
324
+ ]);
325
+
326
+ // Active step for the bottom-of-title progress + annotation chip.
327
+ const overlayThroughlineStep = useMemo(() => {
328
+ if (!showThroughlineTitle || !throughlines?.length) return null;
329
+ const tlId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId ?? null;
330
+ let stepIndex: number | null = null;
331
+ if (focusedThroughlineId != null) {
332
+ stepIndex = focusedStepIndex;
333
+ } else if (hoveredThroughlineStep != null) {
334
+ stepIndex = hoveredThroughlineStep.stepIndex;
335
+ }
336
+ if (tlId == null || stepIndex == null) return null;
337
+ const tl = throughlines.find((t) => t.id === tlId);
338
+ const step = tl?.steps[stepIndex];
339
+ if (!step || !tl) return null;
340
+ return {
341
+ index: stepIndex + 1,
342
+ total: tl.steps.length,
343
+ annotation: step.annotation,
344
+ };
345
+ }, [
346
+ showThroughlineTitle,
347
+ throughlines,
348
+ focusedThroughlineId,
349
+ focusedStepIndex,
350
+ hoveredThroughlineStep,
351
+ ]);
352
+
236
353
  const drawerTitle = useMemo(() => {
237
354
  if (!drawerTarget) return null;
238
355
  if (drawerTarget.kind === 'file') return drawerTarget.file;
@@ -398,11 +515,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
398
515
  return new Set(tl.steps.map((s) => s.edgeId));
399
516
  }, [throughlines, focusedThroughlineId, focusedStepIndex]);
400
517
 
401
- // 1-based step numbers per edge of the selected flow (an edge can appear
402
- // in more than one step).
518
+ // 1-based step numbers per edge of the active flow (focused or
519
+ // hover/autoplay-highlighted). An edge can appear in more than one step.
403
520
  const selectedFlowStepNos = useMemo(() => {
404
- if (focusedThroughlineId == null || !throughlines) return null;
405
- const tl = throughlines.find((t) => t.id === focusedThroughlineId);
521
+ const activeId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
522
+ if (activeId == null || !throughlines) return null;
523
+ const tl = throughlines.find((t) => t.id === activeId);
406
524
  if (!tl) return null;
407
525
  const map = new Map<string, number[]>();
408
526
  tl.steps.forEach((s, i) => {
@@ -411,7 +529,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
411
529
  map.set(s.edgeId, list);
412
530
  });
413
531
  return map;
414
- }, [throughlines, focusedThroughlineId]);
532
+ }, [throughlines, focusedThroughlineId, hoveredThroughlineStep]);
415
533
 
416
534
  // Union of every expanded (opened) throughline's edges — the visible set.
417
535
  const openedEdgeIds = useMemo(() => {
@@ -434,6 +552,19 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
434
552
  }
435
553
  return ids.size > 0 ? ids : null;
436
554
  };
555
+ // Edge/nodes involved in the hovered step — dim everything else.
556
+ const hoverEdgeIds = useMemo(() => {
557
+ if (!hoveredThroughlineStep || !throughlines) return null;
558
+ const tl = throughlines.find((t) => t.id === hoveredThroughlineStep.throughlineId);
559
+ if (!tl) return null;
560
+ const step = tl.steps[hoveredThroughlineStep.stepIndex];
561
+ return step ? new Set([step.edgeId]) : null;
562
+ }, [throughlines, hoveredThroughlineStep]);
563
+ const hoverNodeIds = useMemo(
564
+ () => endpointsOf(hoverEdgeIds),
565
+ // eslint-disable-next-line react-hooks/exhaustive-deps
566
+ [baseEdges, hoverEdgeIds],
567
+ );
437
568
  const openedNodeIds = useMemo(
438
569
  () => endpointsOf(openedEdgeIds),
439
570
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -467,6 +598,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
467
598
  // `isSelected` rides in data because the node's stopPropagation() keeps
468
599
  // React Flow's own selection state from updating.
469
600
  const dispNodes = useMemo(() => {
601
+ // While a step is hovered, only the hovered step's endpoints stay bright;
602
+ // everything else is dimmed (never hidden) so the step's context survives.
470
603
  return xyflowNodesBase.map((n) => {
471
604
  // Boundary frames follow their members: hidden when no member is
472
605
  // visible, dimmed when members are dimmed. Never selectable.
@@ -478,13 +611,17 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
478
611
  anyOpened: openedNodeIds != null,
479
612
  anySelected: brightNodeIds != null,
480
613
  });
614
+ const dimmed = hoverNodeIds
615
+ ? !memberIds.some((id) => hoverNodeIds.has(id))
616
+ : vis.dimmed;
617
+ const hidden = hoverNodeIds ? false : vis.hidden;
481
618
  return {
482
619
  ...n,
483
- hidden: vis.hidden,
620
+ hidden,
484
621
  selectable: false,
485
622
  data: {
486
623
  ...(n.data as object),
487
- ...(vis.dimmed && { dimmed: true }),
624
+ ...(dimmed && { dimmed: true }),
488
625
  },
489
626
  };
490
627
  }
@@ -497,22 +634,24 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
497
634
  anyOpened: openedNodeIds != null,
498
635
  anySelected: brightNodeIds != null,
499
636
  });
500
- if (fileMatch === undefined && !isSelected && !vis.dimmed) {
637
+ const dimmed = hoverNodeIds ? !hoverNodeIds.has(n.id) : vis.dimmed;
638
+ const hidden = hoverNodeIds ? false : vis.hidden;
639
+ if (fileMatch === undefined && !isSelected && !dimmed) {
501
640
  const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
502
- return { ...n, hidden: vis.hidden, data: rest };
641
+ return { ...n, hidden, data: rest };
503
642
  }
504
643
  return {
505
644
  ...n,
506
- hidden: vis.hidden,
645
+ hidden,
507
646
  data: {
508
647
  ...(n.data as object),
509
648
  ...(fileMatch !== undefined && { fileMatch }),
510
649
  ...(isSelected && { isSelected }),
511
- ...(vis.dimmed && { dimmed: true }),
650
+ ...(dimmed && { dimmed: true }),
512
651
  },
513
652
  };
514
653
  });
515
- }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
654
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, hoverNodeIds]);
516
655
 
517
656
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
518
657
  const baseEdgesKey = useMemo(
@@ -533,7 +672,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
533
672
  markerEnd: nextMarker,
534
673
  };
535
674
  };
536
- if (openedEdgeIds || focusEdgeIds) {
675
+ if (openedEdgeIds || focusEdgeIds || hoverEdgeIds) {
537
676
  return baseEdges.map((e) => {
538
677
  const vis = flowElementVisibility({
539
678
  inOpened: openedEdgeIds?.has(e.id) === true,
@@ -541,12 +680,14 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
541
680
  anyOpened: openedEdgeIds != null,
542
681
  anySelected: focusEdgeIds != null,
543
682
  });
544
- return { ...paint(e, vis.dimmed), hidden: vis.hidden };
683
+ const dimmed = hoverEdgeIds ? !hoverEdgeIds.has(e.id) : vis.dimmed;
684
+ const hidden = hoverEdgeIds ? false : vis.hidden;
685
+ return { ...paint(e, dimmed), hidden };
545
686
  });
546
687
  }
547
688
  if (!selectedEdgeId) return baseEdges;
548
689
  return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
549
- }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
690
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, hoverEdgeIds]);
550
691
 
551
692
  const onNodesChange = useCallback(
552
693
  (changes: NodeChange[]) => {
@@ -672,6 +813,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
672
813
  // components the edge attaches to (and therefore the edge line between them).
673
814
  const fitFocusBounds = useCallback(
674
815
  (ids: ReadonlySet<string>) => {
816
+ if (!zoomOnThroughlineFocus) return;
675
817
  const nodeIds = new Set<string>();
676
818
  for (const e of baseEdges) {
677
819
  if (!ids.has(e.id)) continue;
@@ -685,7 +827,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
685
827
  duration: 300,
686
828
  });
687
829
  },
688
- [baseEdges, fitView],
830
+ [baseEdges, fitView, zoomOnThroughlineFocus],
689
831
  );
690
832
 
691
833
  // Focus an entire flow: hide everything but the flow's nodes and edges, and
@@ -693,34 +835,54 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
693
835
  // reads as the narrative. No drawer: the code view only opens on a step
694
836
  // click. A stale throughline drawer (from a previously focused flow's step)
695
837
  // closes; an explicitly opened file drawer stays.
838
+ // In `dim` mode: clear step highlight and leave the full graph visible
839
+ // (no zoom / hide) — matching "step away from a hovered step".
696
840
  const focusThroughlineEdges = useCallback(
697
841
  (tl: SubsystemThroughline) => {
698
842
  setSelected(null);
699
843
  setSelectedEdgeId(null);
844
+ setHoveredThroughlineStep(null);
845
+ if (throughlineStepMode === 'dim') {
846
+ setFocusedStepIndex(null);
847
+ setFocusedThroughlineId(null);
848
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
849
+ return;
850
+ }
700
851
  setFocusedStepIndex(null);
701
852
  setFocusedThroughlineId(tl.id);
702
853
  fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
703
854
  setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
704
855
  },
705
- [fitFocusBounds],
856
+ [fitFocusBounds, throughlineStepMode],
706
857
  );
707
858
 
708
859
  const clearThroughlineFocus = useCallback(() => {
709
860
  setFocusedThroughlineId(null);
710
861
  setFocusedStepIndex(null);
862
+ setHoveredThroughlineStep(null);
711
863
  setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
712
864
  }, []);
713
865
 
714
866
  // Focus a single step's edge on the canvas and open/scroll the throughline
715
867
  // drawer to that step's snippet.
868
+ // In `dim` mode: only dim non-participants (same as hovering a step) —
869
+ // no camera move, no drawer, no hide.
716
870
  const focusThroughlineStep = useCallback(
717
871
  (tl: SubsystemThroughline, stepIndex: number) => {
718
872
  const step = tl.steps[stepIndex];
719
873
  if (!step) return;
720
874
  setSelected(null);
721
875
  setSelectedEdgeId(null);
876
+ if (throughlineStepMode === 'dim') {
877
+ setFocusedStepIndex(null);
878
+ setFocusedThroughlineId(null);
879
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex });
880
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
881
+ return;
882
+ }
722
883
  setFocusedStepIndex(stepIndex);
723
884
  setFocusedThroughlineId(tl.id);
885
+ setHoveredThroughlineStep(null);
724
886
  fitFocusBounds(new Set([step.edgeId]));
725
887
  if (renderThroughlineViewer) {
726
888
  setDrawerTarget({
@@ -736,9 +898,45 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
736
898
  });
737
899
  }
738
900
  },
739
- [fitFocusBounds, renderThroughlineViewer],
901
+ [fitFocusBounds, renderThroughlineViewer, throughlineStepMode],
740
902
  );
741
903
 
904
+ // Graph-only embeds: cycle throughline steps with hover-style dimming.
905
+ useEffect(() => {
906
+ if (!autoPlayThroughlines || !throughlines?.length || !layoutReady) return;
907
+ const playable = throughlines.filter((tl) => tl.steps.length > 0);
908
+ if (playable.length === 0) return;
909
+
910
+ let cancelled = false;
911
+ let tlIdx = 0;
912
+ let stepIdx = 0;
913
+ let timer: number | null = null;
914
+ const interval = Math.max(400, throughlineAutoPlayIntervalMs);
915
+
916
+ const tick = () => {
917
+ if (cancelled) return;
918
+ const tl = playable[tlIdx]!;
919
+ setSelected(null);
920
+ setSelectedEdgeId(null);
921
+ setFocusedThroughlineId(null);
922
+ setFocusedStepIndex(null);
923
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: stepIdx });
924
+ stepIdx += 1;
925
+ if (stepIdx >= tl.steps.length) {
926
+ stepIdx = 0;
927
+ tlIdx = (tlIdx + 1) % playable.length;
928
+ }
929
+ timer = window.setTimeout(tick, interval);
930
+ };
931
+
932
+ tick();
933
+ return () => {
934
+ cancelled = true;
935
+ if (timer != null) window.clearTimeout(timer);
936
+ setHoveredThroughlineStep(null);
937
+ };
938
+ }, [autoPlayThroughlines, throughlines, throughlineAutoPlayIntervalMs, layoutReady]);
939
+
742
940
  // Arrow keys step through the focused throughline once a step is active
743
941
  // (sidebar click or drawer open). Ignores typing targets and chords.
744
942
  useEffect(() => {
@@ -894,15 +1092,15 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
894
1092
  );
895
1093
 
896
1094
  return (
897
- <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
1095
+ <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row', userSelect: sidebarDrag ? 'none' : undefined }}>
898
1096
  {/* Sidebar: scrollable title/description on top, files or flows pinned below.
899
1097
  The description hides by default so files/flows get the room; the
900
1098
  title-row toggle reveals it, and the lower panel yields back to 50%. */}
901
1099
  {!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
902
1100
  <div
903
1101
  style={{
904
- width: 340,
905
- minWidth: 340,
1102
+ width: sidebarWidth,
1103
+ minWidth: sidebarWidth,
906
1104
  borderRight: `1px solid ${theme.colors.border}`,
907
1105
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
908
1106
  display: 'flex',
@@ -1045,7 +1243,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1045
1243
  flex: 1,
1046
1244
  minHeight: 0,
1047
1245
  overflowY: 'auto',
1048
- padding: '0 4px 12px',
1246
+ padding: '0 0 12px',
1049
1247
  }}
1050
1248
  >
1051
1249
  {throughlines.map((tl) => (
@@ -1054,11 +1252,21 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1054
1252
  throughline={tl}
1055
1253
  edges={edges}
1056
1254
  collapsed={!expandedThroughlines.has(tl.id)}
1057
- active={focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null}
1255
+ active={
1256
+ focusedThroughlineId === tl.id
1257
+ ? { stepIndex: focusedStepIndex }
1258
+ : hoveredThroughlineStep?.throughlineId === tl.id
1259
+ ? { stepIndex: hoveredThroughlineStep.stepIndex }
1260
+ : null
1261
+ }
1058
1262
  onToggleCollapsed={toggleThroughlineCollapsed}
1059
1263
  onFocusFlow={focusThroughlineEdges}
1060
1264
  onClearFocus={clearThroughlineFocus}
1061
1265
  onFocusStep={focusThroughlineStep}
1266
+ onHoverStep={(tl, i) =>
1267
+ setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: i })
1268
+ }
1269
+ onLeaveStep={() => setHoveredThroughlineStep(null)}
1062
1270
  />
1063
1271
  ))}
1064
1272
  </div>
@@ -1104,6 +1312,23 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1104
1312
  </div>
1105
1313
  )}
1106
1314
 
1315
+ {/* Drag handle between sidebar and canvas — resize the left panel. */}
1316
+ {!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
1317
+ <div
1318
+ onMouseDown={onSidebarResizeStart}
1319
+ aria-label="Resize sidebar"
1320
+ title="Drag to resize"
1321
+ style={{
1322
+ width: 3,
1323
+ flexShrink: 0,
1324
+ cursor: 'col-resize',
1325
+ background: theme.colors.border,
1326
+ transition: 'background 120ms ease',
1327
+ zIndex: 1,
1328
+ }}
1329
+ />
1330
+ )}
1331
+
1107
1332
  {/* Graph area */}
1108
1333
  <div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
1109
1334
  {/* Canvas box — the edge-label overlay and legend anchor here, so
@@ -1213,10 +1438,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1213
1438
  <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
1214
1439
  <Controls showZoom showFitView showInteractive />
1215
1440
  </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 && (
1441
+ {/* Graph / throughline / step titles — non-interactive chips at the top
1442
+ of the canvas. Graph-only embeds use these without opening the sidebar. */}
1443
+ {(graphTitle || overlayThroughlineTitle || overlayThroughlineStep) && (
1220
1444
  <div
1221
1445
  style={{
1222
1446
  position: 'absolute',
@@ -1224,23 +1448,124 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1224
1448
  left: '50%',
1225
1449
  transform: 'translateX(-50%)',
1226
1450
  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)',
1451
+ display: 'flex',
1452
+ flexDirection: 'column',
1453
+ alignItems: 'center',
1454
+ gap: 6,
1455
+ maxWidth: '70%',
1240
1456
  pointerEvents: 'none',
1241
1457
  }}
1242
1458
  >
1243
- {graphTitle}
1459
+ {graphTitle && (
1460
+ <div
1461
+ style={{
1462
+ maxWidth: '100%',
1463
+ overflow: 'hidden',
1464
+ textOverflow: 'ellipsis',
1465
+ whiteSpace: 'nowrap',
1466
+ padding: '6px 18px',
1467
+ fontSize: theme.fontSizes[3],
1468
+ fontWeight: 600,
1469
+ fontFamily: theme.fonts.monospace,
1470
+ color: theme.colors.text,
1471
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1472
+ border: `1px solid ${theme.colors.border}`,
1473
+ borderRadius: 6,
1474
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1475
+ }}
1476
+ >
1477
+ {graphTitle}
1478
+ </div>
1479
+ )}
1480
+ {overlayThroughlineTitle && (
1481
+ <div
1482
+ style={{
1483
+ maxWidth: '100%',
1484
+ minWidth: overlayThroughlineStep ? 160 : undefined,
1485
+ display: 'flex',
1486
+ flexDirection: 'column',
1487
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1488
+ border: `1px solid ${theme.colors.border}`,
1489
+ borderRadius: 6,
1490
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1491
+ overflow: 'hidden',
1492
+ opacity: 0.95,
1493
+ }}
1494
+ aria-label={
1495
+ overlayThroughlineStep
1496
+ ? `${overlayThroughlineTitle}, step ${overlayThroughlineStep.index} of ${overlayThroughlineStep.total}`
1497
+ : overlayThroughlineTitle
1498
+ }
1499
+ >
1500
+ <div
1501
+ style={{
1502
+ padding: '5px 14px',
1503
+ fontSize: theme.fontSizes[2] ?? theme.fontSizes[1],
1504
+ fontWeight: 600,
1505
+ fontFamily: theme.fonts.monospace,
1506
+ color: theme.colors.text,
1507
+ overflow: 'hidden',
1508
+ textOverflow: 'ellipsis',
1509
+ whiteSpace: 'nowrap',
1510
+ textAlign: 'center',
1511
+ }}
1512
+ >
1513
+ {overlayThroughlineTitle}
1514
+ </div>
1515
+ {overlayThroughlineStep && overlayThroughlineStep.total > 0 && (
1516
+ <div
1517
+ style={{
1518
+ display: 'flex',
1519
+ gap: 3,
1520
+ padding: '0 6px 5px',
1521
+ }}
1522
+ aria-hidden="true"
1523
+ >
1524
+ {Array.from({ length: overlayThroughlineStep.total }, (_, i) => {
1525
+ const n = i + 1;
1526
+ const active = n === overlayThroughlineStep.index;
1527
+ const done = n < overlayThroughlineStep.index;
1528
+ return (
1529
+ <span
1530
+ key={n}
1531
+ style={{
1532
+ flex: 1,
1533
+ height: 2,
1534
+ borderRadius: 1,
1535
+ background: active
1536
+ ? (theme.colors.accent ?? theme.colors.primary ?? theme.colors.text)
1537
+ : done
1538
+ ? (theme.colors.textSecondary ?? theme.colors.text)
1539
+ : (theme.colors.border ?? 'rgba(127,127,127,0.45)'),
1540
+ opacity: active ? 1 : done ? 0.75 : 0.4,
1541
+ transition: 'opacity 120ms ease, background 120ms ease',
1542
+ }}
1543
+ />
1544
+ );
1545
+ })}
1546
+ </div>
1547
+ )}
1548
+ </div>
1549
+ )}
1550
+ {overlayThroughlineStep?.annotation && (
1551
+ <div
1552
+ style={{
1553
+ maxWidth: '100%',
1554
+ padding: '6px 14px',
1555
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1556
+ border: `1px solid ${theme.colors.border}`,
1557
+ borderRadius: 6,
1558
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1559
+ textAlign: 'center',
1560
+ fontSize: theme.fontSizes[0],
1561
+ fontFamily: theme.fonts.body,
1562
+ color: theme.colors.textMuted ?? theme.colors.textSecondary,
1563
+ lineHeight: 1.35,
1564
+ }}
1565
+ >
1566
+ {overlayThroughlineStep.annotation}
1567
+ </div>
1568
+ )}
1244
1569
  </div>
1245
1570
  )}
1246
1571
  {/* Selected-component declaration — floating card over the canvas
@@ -1388,6 +1713,8 @@ function ThroughlineFlow({
1388
1713
  onFocusFlow,
1389
1714
  onClearFocus,
1390
1715
  onFocusStep,
1716
+ onHoverStep,
1717
+ onLeaveStep,
1391
1718
  }: {
1392
1719
  throughline: SubsystemThroughline;
1393
1720
  edges: SubsystemComponentEdge[];
@@ -1398,6 +1725,8 @@ function ThroughlineFlow({
1398
1725
  onFocusFlow: (tl: SubsystemThroughline) => void;
1399
1726
  onClearFocus: () => void;
1400
1727
  onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
1728
+ onHoverStep: (tl: SubsystemThroughline, stepIndex: number) => void;
1729
+ onLeaveStep: () => void;
1401
1730
  }) {
1402
1731
  const { theme } = useTheme();
1403
1732
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -1406,8 +1735,52 @@ function ThroughlineFlow({
1406
1735
  const wholeFlowActive = active !== null && active.stepIndex === null;
1407
1736
  const [headerHover, setHeaderHover] = useState(false);
1408
1737
  const [closeHover, setCloseHover] = useState(false);
1738
+ const [playHover, setPlayHover] = useState(false);
1409
1739
  const [hoveredStep, setHoveredStep] = useState<number | null>(null);
1410
1740
  const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
1741
+ // Autoplay: stepping through the flow's steps with a pause between each.
1742
+ const [playing, setPlaying] = useState(false);
1743
+ const playTimerRef = useRef<number | null>(null);
1744
+
1745
+ const stopPlaying = useCallback(() => {
1746
+ if (playTimerRef.current != null) {
1747
+ window.clearTimeout(playTimerRef.current);
1748
+ playTimerRef.current = null;
1749
+ }
1750
+ setPlaying(false);
1751
+ }, []);
1752
+
1753
+ // Clear any pending timer on unmount.
1754
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1755
+ useEffect(() => () => stopPlaying(), []);
1756
+
1757
+ const startPlaying = useCallback(() => {
1758
+ if (collapsed) onToggleCollapsed(throughline.id);
1759
+ if (active === null || active.stepIndex !== null) onFocusFlow(throughline);
1760
+ const stepCount = throughline.steps.length;
1761
+ if (stepCount === 0) return;
1762
+ setPlaying(true);
1763
+ let i = 0;
1764
+ const tick = () => {
1765
+ if (i >= stepCount) {
1766
+ playTimerRef.current = null;
1767
+ setPlaying(false);
1768
+ return;
1769
+ }
1770
+ onFocusStep(throughline, i);
1771
+ i += 1;
1772
+ playTimerRef.current = window.setTimeout(tick, THROUGHLINE_PLAY_PAUSE_MS);
1773
+ };
1774
+ tick();
1775
+ }, [collapsed, active, onToggleCollapsed, onFocusFlow, throughline, onFocusStep]);
1776
+
1777
+ const togglePlay = useCallback(() => {
1778
+ if (playing) {
1779
+ stopPlaying();
1780
+ } else {
1781
+ startPlaying();
1782
+ }
1783
+ }, [playing, stopPlaying, startPlaying]);
1411
1784
 
1412
1785
  // Keep DOM focus on the active step so the browser focus ring (and
1413
1786
  // subsequent arrow keys) follow arrow navigation, not the originally
@@ -1418,7 +1791,7 @@ function ThroughlineFlow({
1418
1791
  }, [active?.stepIndex]);
1419
1792
 
1420
1793
  return (
1421
- <div style={{ margin: '4px 0', borderRadius: 8 }}>
1794
+ <div>
1422
1795
  <div
1423
1796
  onMouseEnter={() => setHeaderHover(true)}
1424
1797
  onMouseLeave={() => setHeaderHover(false)}
@@ -1426,7 +1799,6 @@ function ThroughlineFlow({
1426
1799
  display: 'flex',
1427
1800
  alignItems: 'center',
1428
1801
  gap: 4,
1429
- borderRadius: 6,
1430
1802
  background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
1431
1803
  transition: 'background 120ms ease',
1432
1804
  }}
@@ -1449,8 +1821,7 @@ function ThroughlineFlow({
1449
1821
  display: 'flex',
1450
1822
  alignItems: 'center',
1451
1823
  minWidth: 0,
1452
- padding: '6px 8px',
1453
- borderRadius: 6,
1824
+ padding: '10px 8px',
1454
1825
  border: 'none',
1455
1826
  background: 'transparent',
1456
1827
  textAlign: 'left',
@@ -1472,6 +1843,35 @@ function ThroughlineFlow({
1472
1843
  </span>
1473
1844
  </button>
1474
1845
  {!collapsed && (
1846
+ <>
1847
+ <button
1848
+ type="button"
1849
+ aria-label={playing ? `Pause ${throughline.title} autoplay` : `Play ${throughline.title}`}
1850
+ title={playing ? 'Pause' : 'Play through steps'}
1851
+ onMouseEnter={() => setPlayHover(true)}
1852
+ onMouseLeave={() => setPlayHover(false)}
1853
+ onClick={(e) => {
1854
+ e.stopPropagation();
1855
+ togglePlay();
1856
+ }}
1857
+ style={{
1858
+ display: 'inline-flex',
1859
+ alignItems: 'center',
1860
+ justifyContent: 'center',
1861
+ flexShrink: 0,
1862
+ width: 22,
1863
+ height: 22,
1864
+ padding: 0,
1865
+ border: 'none',
1866
+ borderRadius: 4,
1867
+ background: playing || playHover ? theme.colors.border : 'transparent',
1868
+ color: playing || playHover ? theme.colors.text : muted,
1869
+ cursor: 'pointer',
1870
+ transition: 'background 120ms ease, color 120ms ease',
1871
+ }}
1872
+ >
1873
+ {playing ? <Pause size={12} strokeWidth={2} /> : <Play size={12} strokeWidth={2} />}
1874
+ </button>
1475
1875
  <button
1476
1876
  type="button"
1477
1877
  aria-label={`Close ${throughline.title}`}
@@ -1501,6 +1901,7 @@ function ThroughlineFlow({
1501
1901
  >
1502
1902
  <X size={12} strokeWidth={2} />
1503
1903
  </button>
1904
+ </>
1504
1905
  )}
1505
1906
  </div>
1506
1907
  {!collapsed && (
@@ -1517,15 +1918,21 @@ function ThroughlineFlow({
1517
1918
  stepButtonRefs.current[i] = el;
1518
1919
  }}
1519
1920
  type="button"
1520
- onMouseEnter={() => setHoveredStep(i)}
1521
- onMouseLeave={() => setHoveredStep(null)}
1921
+ onMouseEnter={() => {
1922
+ setHoveredStep(i);
1923
+ onHoverStep(throughline, i);
1924
+ }}
1925
+ onMouseLeave={() => {
1926
+ setHoveredStep(null);
1927
+ onLeaveStep();
1928
+ }}
1522
1929
  onClick={() => onFocusStep(throughline, i)}
1523
1930
  style={{
1524
1931
  display: 'flex',
1525
1932
  alignItems: 'center',
1526
1933
  gap: 8,
1527
1934
  minWidth: 0,
1528
- padding: '4px 8px 4px 12px',
1935
+ padding: '8px 8px 8px 12px',
1529
1936
  textAlign: 'left',
1530
1937
  borderRadius: 6,
1531
1938
  border: 'none',
@@ -1554,9 +1961,9 @@ function ThroughlineFlow({
1554
1961
  overflow: 'hidden',
1555
1962
  textOverflow: 'ellipsis',
1556
1963
  whiteSpace: 'nowrap',
1557
- fontSize: theme.fontSizes[0],
1964
+ fontSize: theme.fontSizes[1],
1558
1965
  fontFamily: theme.fonts.monospace,
1559
- color: stepActive ? theme.colors.text : muted,
1966
+ color: theme.colors.text,
1560
1967
  }}
1561
1968
  >
1562
1969
  {step.symbol}
@@ -1586,7 +1993,7 @@ function ThroughlineFlow({
1586
1993
  whiteSpace: 'nowrap',
1587
1994
  fontSize: theme.fontSizes[0],
1588
1995
  fontFamily: theme.fonts.monospace,
1589
- color: stepActive ? theme.colors.text : muted,
1996
+ color: theme.colors.text,
1590
1997
  }}
1591
1998
  >
1592
1999
  {step.file.split('/').pop()}