@principal-ai/principal-view-react 0.16.56 → 0.16.58

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.
Files changed (109) hide show
  1. package/dist/graphify/consolidated.d.ts +16 -1
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/index.d.ts +1 -1
  4. package/dist/graphify/index.d.ts.map +1 -1
  5. package/dist/graphify/index.js.map +1 -1
  6. package/dist/graphify/kind.d.ts +1 -1
  7. package/dist/graphify/kind.d.ts.map +1 -1
  8. package/dist/graphify/kind.js +7 -1
  9. package/dist/graphify/kind.js.map +1 -1
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/pierre/constructColors.d.ts +24 -0
  15. package/dist/pierre/constructColors.d.ts.map +1 -0
  16. package/dist/pierre/constructColors.js +91 -0
  17. package/dist/pierre/constructColors.js.map +1 -0
  18. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +8 -0
  19. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -0
  20. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +116 -0
  21. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -0
  22. package/dist/subsystem/ComponentDeclaration.d.ts +2 -2
  23. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  24. package/dist/subsystem/ComponentDeclaration.js +12 -11
  25. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  26. package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -1
  27. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  28. package/dist/subsystem/SubsystemComponentGraph.js +401 -40
  29. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  30. package/dist/subsystem/formatDeclaration.d.ts.map +1 -1
  31. package/dist/subsystem/formatDeclaration.js +49 -10
  32. package/dist/subsystem/formatDeclaration.js.map +1 -1
  33. package/dist/subsystem/model.d.ts +89 -12
  34. package/dist/subsystem/model.d.ts.map +1 -1
  35. package/dist/subsystem/model.js +55 -25
  36. package/dist/subsystem/model.js.map +1 -1
  37. package/dist/subsystem/nodes.d.ts +32 -1
  38. package/dist/subsystem/nodes.d.ts.map +1 -1
  39. package/dist/subsystem/nodes.js +122 -24
  40. package/dist/subsystem/nodes.js.map +1 -1
  41. package/dist/subsystem/tokenizeComponent.js +1 -1
  42. package/dist/subsystem/tokenizeComponent.js.map +1 -1
  43. package/dist/utils/elkLayout.d.ts.map +1 -1
  44. package/dist/utils/elkLayout.js +4 -3
  45. package/dist/utils/elkLayout.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/graphify/consolidated.ts +18 -1
  48. package/src/graphify/index.ts +1 -0
  49. package/src/graphify/kind.ts +7 -1
  50. package/src/index.ts +3 -2
  51. package/src/pierre/constructColors.test.ts +52 -0
  52. package/src/pierre/constructColors.ts +112 -0
  53. package/src/stories/{BoundaryNodes.stories.tsx → Audit/BoundaryNodes.stories.tsx} +1 -1
  54. package/src/stories/{CanvasEdgeTypes.stories.tsx → Audit/CanvasEdgeTypes.stories.tsx} +1 -1
  55. package/src/stories/{CanvasNodeTypes.stories.tsx → Audit/CanvasNodeTypes.stories.tsx} +1 -1
  56. package/src/stories/{ColorPriority.stories.tsx → Audit/ColorPriority.stories.tsx} +1 -1
  57. package/src/stories/{DiamondBadges.stories.tsx → Audit/DiamondBadges.stories.tsx} +1 -1
  58. package/src/stories/{NodeDimensionsTesting.stories.tsx → Audit/NodeDimensionsTesting.stories.tsx} +2 -2
  59. package/src/stories/{NodeFieldsAudit.stories.tsx → Audit/NodeFieldsAudit.stories.tsx} +1 -1
  60. package/src/stories/{NodeFontSizeTesting.stories.tsx → Audit/NodeFontSizeTesting.stories.tsx} +2 -2
  61. package/src/stories/{NodeShapes.stories.tsx → Audit/NodeShapes.stories.tsx} +1 -1
  62. package/src/stories/{WideNodesTesting.stories.tsx → Audit/WideNodesTesting.stories.tsx} +2 -2
  63. package/src/stories/{dashboard → Components}/DashboardRenderer.stories.tsx +2 -2
  64. package/src/stories/{GraphRenderer.stories.tsx → Components/GraphRenderer.stories.tsx} +2 -2
  65. package/src/stories/{MultiCanvasRenderer.stories.tsx → Components/MultiCanvasRenderer.stories.tsx} +2 -2
  66. package/src/stories/{SequenceDiagram.stories.tsx → Components/SequenceDiagram.stories.tsx} +2 -2
  67. package/src/stories/{StateView.stories.tsx → Components/StateView.stories.tsx} +2 -2
  68. package/src/stories/{TraceViewer.stories.tsx → Components/TraceViewer.stories.tsx} +3 -3
  69. package/src/stories/{WorkflowSequenceDiagram.stories.tsx → Components/WorkflowSequenceDiagram.stories.tsx} +1 -1
  70. package/src/stories/{AnimationWorkshop.stories.tsx → Features/AnimationWorkshop.stories.tsx} +5 -5
  71. package/src/stories/{ChangeTypeVisual.stories.tsx → Features/ChangeTypeVisual.stories.tsx} +2 -2
  72. package/src/stories/{EventDrivenAnimations.stories.tsx → Features/EventDrivenAnimations.stories.tsx} +2 -2
  73. package/src/stories/{MultiConfig.stories.tsx → Features/MultiConfig.stories.tsx} +2 -2
  74. package/src/stories/{ElkEdgeRouting.stories.tsx → Layout/ElkEdgeRouting.stories.tsx} +2 -2
  75. package/src/stories/{GraphOrientation.stories.tsx → Layout/GraphOrientation.stories.tsx} +3 -3
  76. package/src/stories/{MultiDirectionalConnections.stories.tsx → Layout/MultiDirectionalConnections.stories.tsx} +2 -2
  77. package/src/stories/{NodeDefinitionComparison.stories.tsx → Nodes/NodeDefinitionComparison.stories.tsx} +2 -2
  78. package/src/stories/{NodeNameFallbacks.stories.tsx → Nodes/NodeNameFallbacks.stories.tsx} +2 -2
  79. package/src/stories/{NumericColors.stories.tsx → Nodes/NumericColors.stories.tsx} +2 -2
  80. package/src/stories/{EventNameDisplay.stories.tsx → OTEL/EventNameDisplay.stories.tsx} +2 -2
  81. package/src/stories/{EventNameFallback.stories.tsx → OTEL/EventNameFallback.stories.tsx} +2 -2
  82. package/src/stories/{OtelComponents.stories.tsx → OTEL/OtelComponents.stories.tsx} +3 -3
  83. package/src/stories/{OtelNodeTypesPrototype.stories.tsx → OTEL/OtelNodeTypesPrototype.stories.tsx} +2 -2
  84. package/src/stories/{RawEventCards.stories.tsx → OTEL/RawEventCards.stories.tsx} +3 -3
  85. package/src/stories/{SpanBadges.stories.tsx → OTEL/SpanBadges.stories.tsx} +1 -1
  86. package/src/stories/{ValidationEventsCanvas.stories.tsx → OTEL/ValidationEventsCanvas.stories.tsx} +3 -3
  87. package/src/stories/{BacklogMdCanvases.stories.tsx → RealWorld/BacklogMdCanvases.stories.tsx} +2 -2
  88. package/src/stories/{FileCitySequence.stories.tsx → RealWorld/FileCitySequence.stories.tsx} +4 -4
  89. package/src/stories/{RealCanvasFiles.stories.tsx → RealWorld/RealCanvasFiles.stories.tsx} +7 -7
  90. package/src/stories/{ComponentDeclarationAudit.stories.tsx → Subsystem/ComponentDeclarationAudit.stories.tsx} +19 -20
  91. package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +365 -0
  92. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +137 -0
  93. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +238 -0
  94. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +315 -0
  95. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +181 -0
  96. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +959 -0
  97. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +646 -0
  98. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +129 -0
  99. package/src/subsystem/ComponentDeclaration.tsx +13 -13
  100. package/src/subsystem/SubsystemComponentGraph.tsx +558 -44
  101. package/src/subsystem/formatDeclaration.ts +57 -11
  102. package/src/subsystem/model.test.ts +38 -6
  103. package/src/subsystem/model.ts +145 -32
  104. package/src/subsystem/nodes.test.ts +56 -0
  105. package/src/subsystem/nodes.tsx +161 -31
  106. package/src/subsystem/tokenizeComponent.ts +1 -1
  107. package/src/utils/elkLayout.ts +4 -3
  108. package/src/stories/SubsystemComponentGraph.stories.tsx +0 -1225
  109. /package/src/stories/{dashboard/sample-dashboards → data}/activity-feed-analytics.dashboard.json +0 -0
@@ -32,7 +32,7 @@ import {
32
32
  applyNodeChanges,
33
33
  } from '@xyflow/react';
34
34
  import { useTheme } from '@principal-ade/industry-theme';
35
- import { Map as MapIcon } from 'lucide-react';
35
+ import { Map as MapIcon, X } from 'lucide-react';
36
36
  import { IndustryMarkdownSlide } from 'themed-markdown';
37
37
  import {
38
38
  buildSubsystemGraph,
@@ -41,9 +41,10 @@ import {
41
41
  type SubsystemComponentEdge,
42
42
  type SubsystemComponent,
43
43
  type SubsystemEdgeMechanism,
44
+ type SubsystemThroughline,
44
45
  } from './model';
45
46
  import type { SubsystemOpenFileOptions } from './declarationRef';
46
- import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
47
+ import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
47
48
  import { SubsystemFileTree } from './SubsystemFileTree';
48
49
  import { GraphLayoutCover } from './GraphLayoutCover';
49
50
  import { ComponentDeclaration } from './ComponentDeclaration';
@@ -55,6 +56,15 @@ import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
55
56
  export interface SubsystemComponentGraphProps {
56
57
  components: SubsystemComponent[];
57
58
  edges: SubsystemComponentEdge[];
59
+ /**
60
+ * Ordered execution stories over the graph's edges — one throughline per
61
+ * flow. When present the sidebar's bottom half offers a Files/Flows toggle:
62
+ * the flows panel lists each throughline's steps (`symbol` or `file:line`);
63
+ * clicking a flow row toggles its steps; clicking a step focuses that
64
+ * step's edge. Opened flows stay on the canvas (unselected ones dimmed);
65
+ * everything else is hidden.
66
+ */
67
+ throughlines?: SubsystemThroughline[];
58
68
  onSelect?: (componentId: string) => void;
59
69
  /** Called when an edge is clicked (relationship / mechanism + refs seam). */
60
70
  onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
@@ -126,7 +136,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
126
136
  measured: { w: number; h: number } | null;
127
137
  }
128
138
 
129
- function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
139
+ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
130
140
  const { theme } = useTheme();
131
141
  const { fitView } = useReactFlow();
132
142
  const viewport = useViewport();
@@ -147,6 +157,18 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
147
157
  const [legendOpen, setLegendOpen] = useState(false);
148
158
  // Component the pointer is over (null on leave) → transient tree highlight.
149
159
  const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
160
+ // Throughline focus — selected flow (or step) is full strength; other
161
+ // opened-flow members stay visible but dimmed; everything else is hidden.
162
+ const [focusedThroughlineId, setFocusedThroughlineId] = useState<string | null>(null);
163
+ // `null` = whole flow focused; a number = that single step's edge focused.
164
+ const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
165
+ // Sidebar bottom half: which panel is shown when throughlines exist.
166
+ const [sidebarView, setSidebarView] = useState<'files' | 'flows'>(() =>
167
+ throughlines?.length ? 'flows' : 'files',
168
+ );
169
+ // Throughline flows the user has expanded (via the title row). Closed by
170
+ // default so a graph with several flows doesn't dump every step list at once.
171
+ const [expandedThroughlines, setExpandedThroughlines] = useState<Set<string>>(new Set());
150
172
  // Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
151
173
  // closure over the effect deps) can toggle without a stale value.
152
174
  const selectedRef = useRef<SubsystemComponent | null>(null);
@@ -181,11 +203,19 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
181
203
  measuredDimsRef.current = new Map();
182
204
  prevMeasuredSigRef.current = '';
183
205
  const doc = { components: componentsRef.current, edges: edgesRef.current };
184
- void buildSubsystemGraph(doc, { maxNodeWidth, showEdgeLabels }).then(({ nodes, edges: e }) => {
185
- if (!alive) return;
186
- setBuilt({ nodes, edges: e as Edge[] });
187
- setLayoutReady(false);
188
- });
206
+ void buildSubsystemGraph(doc, { maxNodeWidth, showEdgeLabels })
207
+ .then(({ nodes, edges: e }) => {
208
+ if (!alive) return;
209
+ setBuilt({ nodes, edges: e as Edge[] });
210
+ setLayoutReady(false);
211
+ })
212
+ .catch((err) => {
213
+ console.warn('[subsystem-graph] initial layout failed:', err);
214
+ if (!alive) return;
215
+ // Reveal the cover even on failure so the UI is not stuck forever.
216
+ setBuilt({ nodes: [], edges: [] });
217
+ setLayoutReady(true);
218
+ });
189
219
  return () => { alive = false; };
190
220
  }, [layoutKey, maxNodeWidth, showEdgeLabels]);
191
221
 
@@ -237,6 +267,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
237
267
  }
238
268
  const src = edgeById.get(edgeId);
239
269
  setSelectedEdgeId(edgeId);
270
+ // A direct edge selection on the canvas supersedes any throughline focus.
271
+ setFocusedThroughlineId(null);
272
+ setFocusedStepIndex(null);
240
273
  if (src) onEdgeSelect?.(src);
241
274
  },
242
275
  [edgeById, onEdgeSelect],
@@ -255,6 +288,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
255
288
  }
256
289
  setSelected(comp);
257
290
  setSelectedEdgeId(null);
291
+ setFocusedThroughlineId(null);
292
+ setFocusedStepIndex(null);
258
293
  onSelect?.(id);
259
294
  }
260
295
  };
@@ -273,26 +308,120 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
273
308
  const xyflowNodesBase = nodes as Node[];
274
309
  const baseEdges = convertedEdges as Edge[];
275
310
 
311
+ // When an edge is selected, dim every other edge + its label to focus it.
312
+ const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
313
+ // Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
314
+ // without a stale closure value.
315
+ const selectedEdgeIdRef = useRef<string | null>(null);
316
+ selectedEdgeIdRef.current = selectedEdgeId;
317
+ // Edge ids in throughline focus (an active flow's edge set, or a single
318
+ // step's edge). Used to frame the camera. `null` = no throughline focus.
319
+ const focusEdgeIds = useMemo(() => {
320
+ if (focusedThroughlineId == null || !throughlines) return null;
321
+ const tl = throughlines.find((t) => t.id === focusedThroughlineId);
322
+ if (!tl) return null;
323
+ if (focusedStepIndex != null) {
324
+ const step = tl.steps[focusedStepIndex];
325
+ return step ? new Set([step.edgeId]) : null;
326
+ }
327
+ return new Set(tl.steps.map((s) => s.edgeId));
328
+ }, [throughlines, focusedThroughlineId, focusedStepIndex]);
329
+
330
+ // 1-based step numbers per edge of the selected flow (an edge can appear
331
+ // in more than one step).
332
+ const selectedFlowStepNos = useMemo(() => {
333
+ if (focusedThroughlineId == null || !throughlines) return null;
334
+ const tl = throughlines.find((t) => t.id === focusedThroughlineId);
335
+ if (!tl) return null;
336
+ const map = new Map<string, number[]>();
337
+ tl.steps.forEach((s, i) => {
338
+ const list = map.get(s.edgeId) ?? [];
339
+ list.push(i + 1);
340
+ map.set(s.edgeId, list);
341
+ });
342
+ return map;
343
+ }, [throughlines, focusedThroughlineId]);
344
+
345
+ // Union of every expanded (opened) throughline's edges — the visible set.
346
+ const openedEdgeIds = useMemo(() => {
347
+ if (!throughlines || expandedThroughlines.size === 0) return null;
348
+ const ids = new Set<string>();
349
+ for (const tl of throughlines) {
350
+ if (!expandedThroughlines.has(tl.id)) continue;
351
+ for (const s of tl.steps) ids.add(s.edgeId);
352
+ }
353
+ return ids.size > 0 ? ids : null;
354
+ }, [throughlines, expandedThroughlines]);
355
+
356
+ const endpointsOf = (edgeIds: ReadonlySet<string> | null): Set<string> | null => {
357
+ if (!edgeIds) return null;
358
+ const ids = new Set<string>();
359
+ for (const e of baseEdges) {
360
+ if (!edgeIds.has(e.id)) continue;
361
+ ids.add(e.source);
362
+ ids.add(e.target);
363
+ }
364
+ return ids.size > 0 ? ids : null;
365
+ };
366
+ const openedNodeIds = useMemo(
367
+ () => endpointsOf(openedEdgeIds),
368
+ // eslint-disable-next-line react-hooks/exhaustive-deps
369
+ [baseEdges, openedEdgeIds],
370
+ );
371
+ // Endpoints of the bright set: the selected step's edge, or the whole flow
372
+ // when no step is focused.
373
+ const brightNodeIds = useMemo(
374
+ () => endpointsOf(focusEdgeIds),
375
+ // eslint-disable-next-line react-hooks/exhaustive-deps
376
+ [baseEdges, focusEdgeIds],
377
+ );
378
+
379
+ // Source + target of the focused step/flow (or a canvas-selected edge). If a
380
+ // file is open, those endpoints stay undimmed even when they don't live in
381
+ // that file.
382
+ const focusNodeIds = useMemo(() => {
383
+ if (brightNodeIds) return brightNodeIds;
384
+ if (selectedEdgeId) {
385
+ const e = baseEdges.find((x) => x.id === selectedEdgeId);
386
+ if (e) return new Set([e.source, e.target]);
387
+ }
388
+ return null;
389
+ }, [baseEdges, brightNodeIds, selectedEdgeId]);
390
+
276
391
  // While a file is open in the drawer, tag each node with whether its
277
392
  // component lives in that file — the node renderer spotlights matches and
278
393
  // dims non-matches (mirrors the edge-dimming behavior on selection).
394
+ // Opened-but-unselected members are dimmed; a selected step further dims
395
+ // the rest of its own flow. Nodes not on any opened flow are hidden.
279
396
  // `isSelected` rides in data because the node's stopPropagation() keeps
280
- // React Flow's own selection state from ever updating.
397
+ // React Flow's own selection state from updating.
281
398
  const dispNodes = useMemo(() => {
282
399
  return xyflowNodesBase.map((n) => {
283
400
  const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
284
- const fileMatch = openFile ? comp?.file === openFile : undefined;
401
+ const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
285
402
  const isSelected = selected?.id !== undefined && comp?.id === selected.id;
286
- if (fileMatch === undefined && !isSelected) {
287
- const { fileMatch: _f, isSelected: _s, ...rest } = n.data as Record<string, unknown>;
288
- return { ...n, data: rest };
403
+ const vis = flowElementVisibility({
404
+ inOpened: openedNodeIds?.has(n.id) === true,
405
+ inSelected: brightNodeIds?.has(n.id) === true,
406
+ anyOpened: openedNodeIds != null,
407
+ anySelected: brightNodeIds != null,
408
+ });
409
+ if (fileMatch === undefined && !isSelected && !vis.dimmed) {
410
+ const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
411
+ return { ...n, hidden: vis.hidden, data: rest };
289
412
  }
290
413
  return {
291
414
  ...n,
292
- data: { ...(n.data as object), ...(fileMatch !== undefined && { fileMatch }), ...(isSelected && { isSelected }) },
415
+ hidden: vis.hidden,
416
+ data: {
417
+ ...(n.data as object),
418
+ ...(fileMatch !== undefined && { fileMatch }),
419
+ ...(isSelected && { isSelected }),
420
+ ...(vis.dimmed && { dimmed: true }),
421
+ },
293
422
  };
294
423
  });
295
- }, [xyflowNodesBase, openFile, selected]);
424
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
296
425
 
297
426
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
298
427
  const baseEdgesKey = useMemo(
@@ -300,22 +429,33 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
300
429
  [convertedEdges],
301
430
  );
302
431
 
303
- // When an edge is selected, dim every other edge + its label to focus it.
304
- const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
305
- // Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
306
- // without a stale closure value.
307
- const selectedEdgeIdRef = useRef<string | null>(null);
308
- selectedEdgeIdRef.current = selectedEdgeId;
309
432
  const dispEdges = useMemo(() => {
433
+ const paint = (e: Edge, dimmed: boolean): Edge => {
434
+ const markerEnd = e.markerEnd;
435
+ const nextMarker =
436
+ dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
437
+ ? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
438
+ : markerEnd;
439
+ return {
440
+ ...e,
441
+ data: { ...(e.data as object), dimmed },
442
+ markerEnd: nextMarker,
443
+ };
444
+ };
445
+ if (openedEdgeIds || focusEdgeIds) {
446
+ return baseEdges.map((e) => {
447
+ const vis = flowElementVisibility({
448
+ inOpened: openedEdgeIds?.has(e.id) === true,
449
+ inSelected: focusEdgeIds?.has(e.id) === true,
450
+ anyOpened: openedEdgeIds != null,
451
+ anySelected: focusEdgeIds != null,
452
+ });
453
+ return { ...paint(e, vis.dimmed), hidden: vis.hidden };
454
+ });
455
+ }
310
456
  if (!selectedEdgeId) return baseEdges;
311
- return baseEdges.map((e) => ({
312
- ...e,
313
- data: {
314
- ...(e.data as object),
315
- dimmed: e.id !== selectedEdgeId,
316
- },
317
- }));
318
- }, [baseEdges, selectedEdgeId]);
457
+ return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
458
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
319
459
 
320
460
  const onNodesChange = useCallback(
321
461
  (changes: NodeChange[]) => {
@@ -355,6 +495,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
355
495
  (_e, node: Node) => {
356
496
  const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
357
497
  setSelectedEdgeId(null);
498
+ setFocusedThroughlineId(null);
499
+ setFocusedStepIndex(null);
358
500
  if (node.type === 'subsystem-component' && comp) {
359
501
  // Clicking the already-selected node unselects it (toggle off).
360
502
  // Selection is independent of the file drawer — nodes never open it.
@@ -380,11 +522,14 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
380
522
  const onPaneClick = useCallback(() => {
381
523
  setSelected(null);
382
524
  setSelectedEdgeId(null);
525
+ setFocusedThroughlineId(null);
526
+ setFocusedStepIndex(null);
383
527
  }, []);
384
528
 
385
529
  // Sidebar file trees — one per repo on multi-repo graphs, each under its
386
530
  // own owner-avatar header. Clicking a header collapses that repo's tree.
387
531
  const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
532
+ const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
388
533
  const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
389
534
  const toggleRepoCollapsed = useCallback((key: string) => {
390
535
  setCollapsedRepos((prev) => {
@@ -426,6 +571,83 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
426
571
  [onFileSelect],
427
572
  );
428
573
 
574
+ // Camera helper shared by the throughline interactions: frames the focused
575
+ // edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
576
+ // live positions + measured dims — so the frame always includes BOTH
577
+ // components the edge attaches to (and therefore the edge line between them).
578
+ const fitFocusBounds = useCallback(
579
+ (ids: ReadonlySet<string>) => {
580
+ const nodeIds = new Set<string>();
581
+ for (const e of baseEdges) {
582
+ if (!ids.has(e.id)) continue;
583
+ nodeIds.add(e.source);
584
+ nodeIds.add(e.target);
585
+ }
586
+ if (nodeIds.size === 0) return;
587
+ fitView({
588
+ nodes: [...nodeIds].map((id) => ({ id })),
589
+ padding: 0.25,
590
+ duration: 300,
591
+ });
592
+ },
593
+ [baseEdges, fitView],
594
+ );
595
+
596
+ // Focus an entire flow: hide everything but the flow's nodes and edges, and
597
+ // frame the flow on the canvas. Selection state is cleared — the graph now
598
+ // reads as the narrative.
599
+ const focusThroughlineEdges = useCallback(
600
+ (tl: SubsystemThroughline) => {
601
+ setSelected(null);
602
+ setSelectedEdgeId(null);
603
+ setFocusedStepIndex(null);
604
+ setFocusedThroughlineId(tl.id);
605
+ fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
606
+ },
607
+ [fitFocusBounds],
608
+ );
609
+
610
+ const clearThroughlineFocus = useCallback(() => {
611
+ setFocusedThroughlineId(null);
612
+ setFocusedStepIndex(null);
613
+ }, []);
614
+
615
+ // Focus a single step's edge on the canvas. The step's file:line is listed
616
+ // in the row; we don't open the drawer from here.
617
+ const focusThroughlineStep = useCallback(
618
+ (tl: SubsystemThroughline, stepIndex: number) => {
619
+ const step = tl.steps[stepIndex];
620
+ if (!step) return;
621
+ setSelected(null);
622
+ setSelectedEdgeId(null);
623
+ setFocusedStepIndex(stepIndex);
624
+ setFocusedThroughlineId(tl.id);
625
+ fitFocusBounds(new Set([step.edgeId]));
626
+ },
627
+ [fitFocusBounds],
628
+ );
629
+
630
+ const toggleThroughlineCollapsed = useCallback((tlId: string) => {
631
+ setExpandedThroughlines((prev) => {
632
+ const next = new Set(prev);
633
+ if (next.has(tlId)) next.delete(tlId);
634
+ else next.add(tlId);
635
+ return next;
636
+ });
637
+ }, []);
638
+
639
+ // Filename-badge clicks on nodes open the drawer through the same path as
640
+ // the declaration panel's file link (toggle + tree sync, no start line).
641
+ useEffect(() => {
642
+ SUBSYSTEM_CALLBACKS.onOpenFile = (componentId: string) => {
643
+ const comp = components.find((c) => c.id === componentId);
644
+ if (comp?.file) onOpenDeclarationFile(comp.file);
645
+ };
646
+ return () => {
647
+ SUBSYSTEM_CALLBACKS.onOpenFile = undefined;
648
+ };
649
+ }, [components, onOpenDeclarationFile]);
650
+
429
651
  // Detail-panel links: related-name clicks select the matching component —
430
652
  // resolved by id, name, or symbol (call labels may carry a trailing `()`).
431
653
  const resolveRelatedComponent = useCallback(
@@ -441,6 +663,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
441
663
  if (!comp || comp.id === selectedRef.current?.id) return;
442
664
  setSelected(comp);
443
665
  setSelectedEdgeId(null);
666
+ setFocusedThroughlineId(null);
667
+ setFocusedStepIndex(null);
444
668
  onSelect?.(comp.id);
445
669
  },
446
670
  [components, onSelect],
@@ -450,17 +674,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
450
674
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
451
675
  // the actual edge path (not node-center approximations).
452
676
  const edgeLabels = useMemo(() => {
453
- return dispEdges.map((e) => {
454
- const d = e.data as { mechanism?: string; dimmed?: boolean; labelX?: number; labelY?: number } | undefined;
455
- return {
456
- id: e.id,
457
- mechanism: d?.mechanism ?? 'imports',
458
- dimmed: d?.dimmed === true,
459
- midX: d?.labelX ?? 0,
460
- midY: d?.labelY ?? 0,
461
- };
462
- });
463
- }, [dispEdges]);
677
+ return dispEdges
678
+ .filter((e) => !e.hidden)
679
+ .map((e) => {
680
+ const d = e.data as { mechanism?: string; dimmed?: boolean; labelX?: number; labelY?: number } | undefined;
681
+ return {
682
+ id: e.id,
683
+ mechanism: d?.mechanism ?? 'imports',
684
+ dimmed: d?.dimmed === true,
685
+ midX: d?.labelX ?? 0,
686
+ midY: d?.labelY ?? 0,
687
+ stepNos: selectedFlowStepNos?.get(e.id),
688
+ };
689
+ });
690
+ }, [dispEdges, selectedFlowStepNos]);
464
691
 
465
692
  const usedMechanisms = useMemo(() => {
466
693
  return new Set(edgeLabels.map((l) => l.mechanism));
@@ -501,8 +728,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
501
728
 
502
729
  return (
503
730
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
504
- {/* Sidebar: scrollable title/description on top, file tree pinned to the bottom half */}
505
- {(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0) && (
731
+ {/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
732
+ {(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
506
733
  <div
507
734
  style={{
508
735
  width: 340,
@@ -556,7 +783,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
556
783
  )}
557
784
  {sidebarAfterDescription}
558
785
  </div>
559
- {treeFilePaths.length > 0 && (
786
+ {(treeFilePaths.length > 0 || hasThroughlines) && (
560
787
  <div
561
788
  style={{
562
789
  height: '50%',
@@ -568,6 +795,72 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
568
795
  overflow: 'hidden',
569
796
  }}
570
797
  >
798
+ {hasThroughlines && (
799
+ <div
800
+ role="tablist"
801
+ aria-label="Sidebar view"
802
+ style={{
803
+ display: 'flex',
804
+ width: '100%',
805
+ flexShrink: 0,
806
+ borderBottom: `1px solid ${theme.colors.border}`,
807
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
808
+ }}
809
+ >
810
+ {(['files', 'flows'] as const).map((view) => (
811
+ <button
812
+ key={view}
813
+ type="button"
814
+ role="tab"
815
+ aria-selected={sidebarView === view}
816
+ onClick={() => setSidebarView(view)}
817
+ style={{
818
+ flex: 1,
819
+ minWidth: 0,
820
+ padding: '8px 8px',
821
+ border: 'none',
822
+ borderRadius: 0,
823
+ background: sidebarView === view ? theme.colors.background : 'transparent',
824
+ color:
825
+ sidebarView === view
826
+ ? theme.colors.text
827
+ : theme.colors.textSecondary,
828
+ fontSize: theme.fontSizes[1],
829
+ fontFamily: theme.fonts.monospace,
830
+ textTransform: 'capitalize',
831
+ cursor: 'pointer',
832
+ }}
833
+ >
834
+ {view}
835
+ </button>
836
+ ))}
837
+ </div>
838
+ )}
839
+ {sidebarView === 'flows' && throughlines && throughlines.length > 0 ? (
840
+ <div
841
+ style={{
842
+ flex: 1,
843
+ minHeight: 0,
844
+ overflowY: 'auto',
845
+ padding: '0 4px 12px',
846
+ }}
847
+ >
848
+ {throughlines.map((tl) => (
849
+ <ThroughlineFlow
850
+ key={tl.id}
851
+ throughline={tl}
852
+ edges={edges}
853
+ collapsed={!expandedThroughlines.has(tl.id)}
854
+ active={focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null}
855
+ onToggleCollapsed={toggleThroughlineCollapsed}
856
+ onFocusFlow={focusThroughlineEdges}
857
+ onClearFocus={clearThroughlineFocus}
858
+ onFocusStep={focusThroughlineStep}
859
+ />
860
+ ))}
861
+ </div>
862
+ ) : treeFilePaths.length > 0 ? (
863
+ <>
571
864
  {repoGroups.groups.map((group, i) => {
572
865
  const groupKey = group.repoKey ?? '__no-repo__';
573
866
  const collapsed = collapsedRepos.has(groupKey);
@@ -601,6 +894,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
601
894
  </div>
602
895
  );
603
896
  })}
897
+ </>
898
+ ) : null}
604
899
  </div>
605
900
  )}
606
901
  </div>
@@ -641,6 +936,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
641
936
  position: 'absolute',
642
937
  left: screenX,
643
938
  top: screenY,
939
+ // Center on the flow-space midpoint. Labels live in screen
940
+ // space (fixed size when zoomed out), so top-left anchoring
941
+ // would drift them right/down of the edge as zoom drops.
942
+ transform: 'translate(-50%, -50%)',
644
943
  fontSize: 10,
645
944
  fontFamily: theme.fonts.monospace,
646
945
  fontWeight: 500,
@@ -655,7 +954,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
655
954
  whiteSpace: 'nowrap',
656
955
  }}
657
956
  >
658
- {lbl.mechanism}
957
+ {lbl.stepNos?.length ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}` : lbl.mechanism}
659
958
  </div>
660
959
  );
661
960
  })}
@@ -852,6 +1151,221 @@ function RepoGroupHeader({
852
1151
  );
853
1152
  }
854
1153
 
1154
+ /** One collapsible throughline in the sidebar's flows panel. Clicking the
1155
+ * title: closed → open + select; open and unselected → select; open and
1156
+ * selected → close + clear focus. The right-aligned close button collapses
1157
+ * without selecting. A step row focuses that step's edge. */
1158
+ function ThroughlineFlow({
1159
+ throughline,
1160
+ edges,
1161
+ collapsed,
1162
+ active,
1163
+ onToggleCollapsed,
1164
+ onFocusFlow,
1165
+ onClearFocus,
1166
+ onFocusStep,
1167
+ }: {
1168
+ throughline: SubsystemThroughline;
1169
+ edges: SubsystemComponentEdge[];
1170
+ collapsed: boolean;
1171
+ /** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
1172
+ active: { stepIndex: number | null } | null;
1173
+ onToggleCollapsed: (tlId: string) => void;
1174
+ onFocusFlow: (tl: SubsystemThroughline) => void;
1175
+ onClearFocus: () => void;
1176
+ onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
1177
+ }) {
1178
+ const { theme } = useTheme();
1179
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
1180
+ const hoverBg = theme.colors.background;
1181
+ const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
1182
+ const wholeFlowActive = active !== null && active.stepIndex === null;
1183
+ const [headerHover, setHeaderHover] = useState(false);
1184
+ const [closeHover, setCloseHover] = useState(false);
1185
+ const [hoveredStep, setHoveredStep] = useState<number | null>(null);
1186
+
1187
+ return (
1188
+ <div style={{ margin: '4px 0', borderRadius: 8 }}>
1189
+ <div
1190
+ onMouseEnter={() => setHeaderHover(true)}
1191
+ onMouseLeave={() => setHeaderHover(false)}
1192
+ style={{
1193
+ display: 'flex',
1194
+ alignItems: 'center',
1195
+ gap: 4,
1196
+ borderRadius: 6,
1197
+ background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
1198
+ transition: 'background 120ms ease',
1199
+ }}
1200
+ >
1201
+ <button
1202
+ type="button"
1203
+ onClick={() => {
1204
+ if (collapsed) {
1205
+ onToggleCollapsed(throughline.id);
1206
+ onFocusFlow(throughline);
1207
+ } else if (active === null) {
1208
+ onFocusFlow(throughline);
1209
+ } else {
1210
+ onToggleCollapsed(throughline.id);
1211
+ onClearFocus();
1212
+ }
1213
+ }}
1214
+ style={{
1215
+ flex: 1,
1216
+ display: 'flex',
1217
+ alignItems: 'center',
1218
+ minWidth: 0,
1219
+ padding: '6px 8px',
1220
+ borderRadius: 6,
1221
+ border: 'none',
1222
+ background: 'transparent',
1223
+ textAlign: 'left',
1224
+ cursor: 'pointer',
1225
+ }}
1226
+ >
1227
+ <span
1228
+ style={{
1229
+ overflow: 'hidden',
1230
+ textOverflow: 'ellipsis',
1231
+ whiteSpace: 'nowrap',
1232
+ fontSize: theme.fontSizes[1],
1233
+ fontFamily: theme.fonts.monospace,
1234
+ fontWeight: 600,
1235
+ color: theme.colors.text,
1236
+ }}
1237
+ >
1238
+ {throughline.title}
1239
+ </span>
1240
+ </button>
1241
+ {!collapsed && (
1242
+ <button
1243
+ type="button"
1244
+ aria-label={`Close ${throughline.title}`}
1245
+ onMouseEnter={() => setCloseHover(true)}
1246
+ onMouseLeave={() => setCloseHover(false)}
1247
+ onClick={(e) => {
1248
+ e.stopPropagation();
1249
+ onToggleCollapsed(throughline.id);
1250
+ if (active !== null) onClearFocus();
1251
+ }}
1252
+ style={{
1253
+ display: 'inline-flex',
1254
+ alignItems: 'center',
1255
+ justifyContent: 'center',
1256
+ flexShrink: 0,
1257
+ width: 22,
1258
+ height: 22,
1259
+ marginRight: 4,
1260
+ padding: 0,
1261
+ border: 'none',
1262
+ borderRadius: 4,
1263
+ background: closeHover ? theme.colors.border : 'transparent',
1264
+ color: closeHover ? theme.colors.text : muted,
1265
+ cursor: 'pointer',
1266
+ transition: 'background 120ms ease, color 120ms ease',
1267
+ }}
1268
+ >
1269
+ <X size={12} strokeWidth={2} />
1270
+ </button>
1271
+ )}
1272
+ </div>
1273
+ {!collapsed && (
1274
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
1275
+ {throughline.steps.map((step, i) => {
1276
+ const edge = edgeById.get(step.edgeId);
1277
+ const mech = edge?.mechanism;
1278
+ const color = mech ? MECHANISM_COLOR[mech] : muted;
1279
+ const stepActive = active !== null && active.stepIndex === i;
1280
+ return (
1281
+ <button
1282
+ key={`${step.edgeId}-${i}`}
1283
+ type="button"
1284
+ onMouseEnter={() => setHoveredStep(i)}
1285
+ onMouseLeave={() => setHoveredStep(null)}
1286
+ onClick={() => onFocusStep(throughline, i)}
1287
+ style={{
1288
+ display: 'flex',
1289
+ alignItems: 'center',
1290
+ gap: 8,
1291
+ minWidth: 0,
1292
+ padding: '4px 8px 4px 12px',
1293
+ textAlign: 'left',
1294
+ borderRadius: 6,
1295
+ border: 'none',
1296
+ background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
1297
+ cursor: 'pointer',
1298
+ transition: 'background 120ms ease',
1299
+ }}
1300
+ >
1301
+ <span
1302
+ style={{
1303
+ flexShrink: 0,
1304
+ width: 14,
1305
+ fontSize: theme.fontSizes[0] * 0.8,
1306
+ fontFamily: theme.fonts.monospace,
1307
+ color: stepActive ? theme.colors.text : muted,
1308
+ }}
1309
+ >
1310
+ {i + 1}
1311
+ </span>
1312
+ {step.symbol ? (
1313
+ <span
1314
+ style={{
1315
+ flex: 1,
1316
+ minWidth: 0,
1317
+ overflow: 'hidden',
1318
+ textOverflow: 'ellipsis',
1319
+ whiteSpace: 'nowrap',
1320
+ fontSize: theme.fontSizes[0],
1321
+ fontFamily: theme.fonts.monospace,
1322
+ color: stepActive ? theme.colors.text : muted,
1323
+ }}
1324
+ >
1325
+ {step.symbol}
1326
+ </span>
1327
+ ) : (
1328
+ <>
1329
+ <span
1330
+ style={{
1331
+ flexShrink: 0,
1332
+ width: 74,
1333
+ overflow: 'hidden',
1334
+ textOverflow: 'ellipsis',
1335
+ whiteSpace: 'nowrap',
1336
+ fontSize: theme.fontSizes[0],
1337
+ fontFamily: theme.fonts.monospace,
1338
+ color,
1339
+ }}
1340
+ >
1341
+ {mech ?? step.edgeId}
1342
+ </span>
1343
+ <span
1344
+ style={{
1345
+ flex: 1,
1346
+ minWidth: 0,
1347
+ overflow: 'hidden',
1348
+ textOverflow: 'ellipsis',
1349
+ whiteSpace: 'nowrap',
1350
+ fontSize: theme.fontSizes[0],
1351
+ fontFamily: theme.fonts.monospace,
1352
+ color: stepActive ? theme.colors.text : muted,
1353
+ }}
1354
+ >
1355
+ {step.file.split('/').pop()}
1356
+ <span style={{ opacity: 0.7 }}>:{step.line}</span>
1357
+ </span>
1358
+ </>
1359
+ )}
1360
+ </button>
1361
+ );
1362
+ })}
1363
+ </div>
1364
+ )}
1365
+ </div>
1366
+ );
1367
+ }
1368
+
855
1369
  export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
856
1370
  const wrapRef = useRef<HTMLDivElement>(null);
857
1371
  const [size, setSize] = useState<{ w: number; h: number } | null>(null);