@principal-ai/subsystems-react 0.55.3 → 0.56.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.
Files changed (98) hide show
  1. package/dist/index.d.ts +6 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreFileView.js +3 -3
  6. package/dist/pierre/PierreFileView.js.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.d.ts +4 -9
  8. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  9. package/dist/pierre/PierreTrailCodeView.js +28 -25
  10. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  11. package/dist/pierre/fileAvailability.d.ts +4 -7
  12. package/dist/pierre/fileAvailability.d.ts.map +1 -1
  13. package/dist/pierre/fileAvailability.js +5 -10
  14. package/dist/pierre/fileAvailability.js.map +1 -1
  15. package/dist/pierre/trailSnippet.d.ts +10 -0
  16. package/dist/pierre/trailSnippet.d.ts.map +1 -0
  17. package/dist/pierre/trailSnippet.js +22 -0
  18. package/dist/pierre/trailSnippet.js.map +1 -0
  19. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  20. package/dist/subsystem/C4Graph.js +1 -1
  21. package/dist/subsystem/C4Graph.js.map +1 -1
  22. package/dist/subsystem/FileDrawer.d.ts +3 -1
  23. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  24. package/dist/subsystem/FileDrawer.js +2 -2
  25. package/dist/subsystem/FileDrawer.js.map +1 -1
  26. package/dist/subsystem/IssueList.d.ts.map +1 -1
  27. package/dist/subsystem/IssueList.js +26 -0
  28. package/dist/subsystem/IssueList.js.map +1 -1
  29. package/dist/subsystem/SubsystemComponentGraph.d.ts +9 -5
  30. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.js +37 -47
  32. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  33. package/dist/subsystem/TrailStackBreadcrumb.d.ts +21 -0
  34. package/dist/subsystem/TrailStackBreadcrumb.d.ts.map +1 -0
  35. package/dist/subsystem/TrailStackBreadcrumb.js +77 -0
  36. package/dist/subsystem/TrailStackBreadcrumb.js.map +1 -0
  37. package/dist/subsystem/TrailStepsNav.d.ts +4 -3
  38. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
  39. package/dist/subsystem/TrailStepsNav.js +13 -16
  40. package/dist/subsystem/TrailStepsNav.js.map +1 -1
  41. package/dist/subsystem/TrailsPanel.d.ts +9 -7
  42. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  43. package/dist/subsystem/TrailsPanel.js +126 -76
  44. package/dist/subsystem/TrailsPanel.js.map +1 -1
  45. package/dist/subsystem/model.d.ts +5 -18
  46. package/dist/subsystem/model.d.ts.map +1 -1
  47. package/dist/subsystem/model.js +3 -11
  48. package/dist/subsystem/model.js.map +1 -1
  49. package/dist/subsystem/nodes.d.ts.map +1 -1
  50. package/dist/subsystem/nodes.js +17 -24
  51. package/dist/subsystem/nodes.js.map +1 -1
  52. package/dist/subsystem/stepEmphasis.d.ts +78 -0
  53. package/dist/subsystem/stepEmphasis.d.ts.map +1 -0
  54. package/dist/subsystem/stepEmphasis.js +89 -0
  55. package/dist/subsystem/stepEmphasis.js.map +1 -0
  56. package/dist/subsystem/trailBrief.d.ts.map +1 -1
  57. package/dist/subsystem/trailBrief.js +20 -0
  58. package/dist/subsystem/trailBrief.js.map +1 -1
  59. package/dist/subsystem/trailStack.d.ts +53 -0
  60. package/dist/subsystem/trailStack.d.ts.map +1 -0
  61. package/dist/subsystem/trailStack.js +154 -0
  62. package/dist/subsystem/trailStack.js.map +1 -0
  63. package/dist/subsystem/trailStepDisplay.d.ts +38 -0
  64. package/dist/subsystem/trailStepDisplay.d.ts.map +1 -0
  65. package/dist/subsystem/trailStepDisplay.js +41 -0
  66. package/dist/subsystem/trailStepDisplay.js.map +1 -0
  67. package/package.json +3 -3
  68. package/src/index.ts +10 -1
  69. package/src/pierre/PierreFileView.tsx +3 -3
  70. package/src/pierre/PierreTrailCodeView.tsx +24 -36
  71. package/src/pierre/fileAvailability.test.ts +0 -6
  72. package/src/pierre/fileAvailability.ts +5 -13
  73. package/src/pierre/trailSnippet.test.ts +74 -0
  74. package/src/pierre/trailSnippet.ts +35 -0
  75. package/src/stories/Canvases/Subsystem/Deprecated.stories.tsx +3 -17
  76. package/src/stories/Canvases/Subsystem/NodeAnatomy.stories.tsx +3 -31
  77. package/src/stories/Canvases/Subsystem/Trails.stories.tsx +0 -187
  78. package/src/subsystem/C4Graph.tsx +0 -1
  79. package/src/subsystem/FileDrawer.tsx +6 -0
  80. package/src/subsystem/IssueList.tsx +26 -0
  81. package/src/subsystem/SubsystemComponentGraph.tsx +68 -65
  82. package/src/subsystem/TrailStackBreadcrumb.test.tsx +109 -0
  83. package/src/subsystem/TrailStackBreadcrumb.tsx +172 -0
  84. package/src/subsystem/TrailStepsNav.test.tsx +35 -0
  85. package/src/subsystem/TrailStepsNav.tsx +18 -19
  86. package/src/subsystem/TrailsPanel.test.tsx +149 -0
  87. package/src/subsystem/TrailsPanel.tsx +165 -39
  88. package/src/subsystem/model.test.ts +7 -22
  89. package/src/subsystem/model.ts +6 -20
  90. package/src/subsystem/nodes.tsx +19 -28
  91. package/src/subsystem/stepEmphasis.ts +151 -0
  92. package/src/subsystem/trailBrief.test.ts +29 -0
  93. package/src/subsystem/trailBrief.ts +20 -0
  94. package/src/subsystem/trailStack.test.ts +133 -0
  95. package/src/subsystem/trailStack.ts +229 -0
  96. package/src/subsystem/trailStepDisplay.ts +68 -0
  97. package/src/stories/Canvases/Subsystem/Proposed.stories.tsx +0 -256
  98. package/src/stories/Canvases/Subsystem/ProposedTrails.stories.tsx +0 -184
@@ -123,6 +123,12 @@ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
123
123
  trail: 'trail',
124
124
  step_span_unconfirmed: 'trail',
125
125
  step_span_stale: 'trail',
126
+ step_site_outside_from: 'trail',
127
+ step_site_unconfirmed: 'trail',
128
+ step_site_mismatch: 'trail',
129
+ step_site_stale: 'trail',
130
+ step_target_mismatch: 'trail',
131
+ step_target_stale: 'trail',
126
132
  };
127
133
 
128
134
  /**
@@ -198,6 +204,14 @@ export const ISSUE_KIND_ICON: Record<string, LucideIcon> = {
198
204
  construct_span_stale: FileCodeCorner,
199
205
  step_span_unconfirmed: FileCodeCorner,
200
206
  step_span_stale: FileCodeCorner,
207
+ // A `calls` edge whose site does not live in the source component — the
208
+ // mechanism and the code are disagreeing, so a broken-link glyph.
209
+ step_site_outside_from: Link2Off,
210
+ step_site_unconfirmed: FileCodeCorner,
211
+ step_site_mismatch: Link2Off,
212
+ step_site_stale: FileCodeCorner,
213
+ step_target_mismatch: Link2Off,
214
+ step_target_stale: FileCodeCorner,
201
215
  };
202
216
 
203
217
  /**
@@ -288,6 +302,12 @@ const KIND_LABEL: Record<string, string> = {
288
302
  trail: 'Step issue',
289
303
  step_span_unconfirmed: 'Step span not captured',
290
304
  step_span_stale: 'Step span changed',
305
+ step_site_outside_from: 'Calls site outside source',
306
+ step_site_unconfirmed: 'Site construct not captured',
307
+ step_site_mismatch: 'Site construct mismatch',
308
+ step_site_stale: 'Site construct changed',
309
+ step_target_mismatch: 'Target construct mismatch',
310
+ step_target_stale: 'Target construct changed',
291
311
  };
292
312
 
293
313
  /** Label for an issue's kind. Explicit `kindLabel` wins; unknown kinds humanize. */
@@ -329,6 +349,12 @@ const KIND_ORDER: Record<string, number> = {
329
349
  trail: 40,
330
350
  step_span_unconfirmed: 41,
331
351
  step_span_stale: 42,
352
+ step_site_outside_from: 43,
353
+ step_site_unconfirmed: 44,
354
+ step_site_mismatch: 45,
355
+ step_site_stale: 46,
356
+ step_target_mismatch: 47,
357
+ step_target_stale: 48,
332
358
  };
333
359
 
334
360
  const KIND_ORDER_FALLBACK = 100;
@@ -91,6 +91,8 @@ import { GraphWithSidebar } from './GraphWithSidebar';
91
91
  import type { GraphSidebarTab } from './GraphSidebar';
92
92
  import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
93
93
  import { TrailsPanel, TRAIL_PLAY_PAUSE_MS } from './TrailsPanel';
94
+ import { TrailStackBreadcrumb } from './TrailStackBreadcrumb';
95
+ import type { StepEmphasisMap } from './stepEmphasis';
94
96
  import {
95
97
  EDGE_LABEL_WIDTH,
96
98
  EDGE_LABEL_HEIGHT,
@@ -106,11 +108,6 @@ export interface TrailViewerContext {
106
108
  stepIndex: number | null;
107
109
  /** Open a step's full source file over the trail drawer (keeps snippets mounted). */
108
110
  onOpenFile: (path: string, opts?: SubsystemOpenFileOptions) => void;
109
- /**
110
- * Aliases of components marked `proposed`. A step whose file can't be read
111
- * but whose endpoint is proposed can be labelled as planned, not missing.
112
- */
113
- proposedAliases: ReadonlySet<string>;
114
111
  /**
115
112
  * Resolve a token in a step's snippet to a construct the step touches (its
116
113
  * `from`/`to` component), returning that component's alias. `null` when the
@@ -287,6 +284,14 @@ export interface SubsystemComponentGraphProps {
287
284
  * and never reorders its own prop).
288
285
  */
289
286
  onReorderTrails?: (next: SubsystemTrail[]) => void;
287
+ /**
288
+ * PROTOTYPE: host-owned step emphasis augmentations, keyed by trail + claim
289
+ * fingerprint. Kept out of the model document; the host reads a separate
290
+ * augmentation store. A mark is a star plus optional markdown guidance.
291
+ */
292
+ stepEmphasis?: StepEmphasisMap;
293
+ /** Toggle a step's guidance off. Omit to hide the remove action. */
294
+ onRemoveStepEmphasis?: (trail: SubsystemTrail, stepIndex: number) => void;
290
295
  onSelect?: (componentAlias: string) => void;
291
296
  /** Called when an edge is clicked (relationship / mechanism + refs seam). */
292
297
  onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
@@ -579,7 +584,6 @@ const TrailDrawerContent = memo(function TrailDrawerContent({
579
584
  trail,
580
585
  stepIndex,
581
586
  onOpenFile,
582
- proposedAliases,
583
587
  resolveSymbol,
584
588
  onSymbolClick,
585
589
  onVisibleStepChange,
@@ -588,7 +592,6 @@ const TrailDrawerContent = memo(function TrailDrawerContent({
588
592
  trail: SubsystemTrail;
589
593
  stepIndex: number | null;
590
594
  onOpenFile: (path: string, opts?: SubsystemOpenFileOptions) => void;
591
- proposedAliases: ReadonlySet<string>;
592
595
  resolveSymbol?: (query: TrailSymbolQuery) => string | null;
593
596
  onSymbolClick?: (symbol: string, query: TrailSymbolQuery) => void;
594
597
  onVisibleStepChange?: (index: number) => void;
@@ -599,7 +602,6 @@ const TrailDrawerContent = memo(function TrailDrawerContent({
599
602
  trail,
600
603
  stepIndex,
601
604
  onOpenFile,
602
- proposedAliases,
603
605
  resolveSymbol,
604
606
  onSymbolClick,
605
607
  onVisibleStepChange,
@@ -612,7 +614,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
612
614
  measured: { w: number; h: number } | null;
613
615
  }
614
616
 
615
- function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirection: layoutDirectionProp, defaultLayoutDirection = 'horizontal', onLayoutDirectionChange, hideLayoutToggle = false, refocusOnLayoutFlip = true, initialTrailId, onTrailSelect, onTrailStepHover, onReorderTrails, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, title, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, codeProcessKeys, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
617
+ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirection: layoutDirectionProp, defaultLayoutDirection = 'horizontal', onLayoutDirectionChange, hideLayoutToggle = false, refocusOnLayoutFlip = true, initialTrailId, onTrailSelect, onTrailStepHover, onReorderTrails, stepEmphasis, onRemoveStepEmphasis, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, title, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, codeProcessKeys, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
616
618
  const { theme } = useTheme();
617
619
  const { fitView, fitBounds, screenToFlowPosition } = useReactFlow();
618
620
  const viewport = useViewport();
@@ -1431,24 +1433,42 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1431
1433
 
1432
1434
  // Per-edge step verification badge: maps edge IDs to step verification status.
1433
1435
  // Keyed by the React Flow edge id (from → mechanism → to). An edge can have
1434
- // multiple steps with issues; we show the worst status (stale > unconfirmed).
1436
+ // multiple steps with issues; we show the worst status.
1435
1437
  const stepIssueBadgeByEdgeId = useMemo(() => {
1436
- const map = new Map<string, { kind: 'step_span_unconfirmed' | 'step_span_stale'; severity: 'info' | 'error'; count: number }>();
1438
+ type StepIssueKind =
1439
+ | 'step_span_unconfirmed'
1440
+ | 'step_span_stale'
1441
+ | 'step_site_outside_from';
1442
+ // Unconfirmed is the hard failure; a miscategorized `calls` edge is the
1443
+ // next most actionable (it changes what the edge means); drift is last.
1444
+ const rank: Record<StepIssueKind, number> = {
1445
+ step_span_unconfirmed: 3,
1446
+ step_site_outside_from: 2,
1447
+ step_span_stale: 1,
1448
+ };
1449
+ const map = new Map<string, { kind: StepIssueKind; severity: 'info' | 'error'; count: number }>();
1437
1450
  if (!issues?.length || !trails?.length) {
1438
1451
  return map;
1439
1452
  }
1440
1453
  for (const issue of issues) {
1441
- if (issue.kind !== 'step_span_unconfirmed' && issue.kind !== 'step_span_stale') continue;
1454
+ if (
1455
+ issue.kind !== 'step_span_unconfirmed' &&
1456
+ issue.kind !== 'step_span_stale' &&
1457
+ issue.kind !== 'step_site_outside_from'
1458
+ ) {
1459
+ continue;
1460
+ }
1442
1461
  const stepInfo = issueStep(issue);
1443
1462
  if (!stepInfo) continue;
1444
1463
  const step = stepInfo.trail.steps[stepInfo.stepIndex];
1445
1464
  if (!step) continue;
1446
1465
  const edgeId = trailStepGraphEdgeId(step);
1466
+ const kind = issue.kind as StepIssueKind;
1447
1467
  const prev = map.get(edgeId);
1448
- // step_span_stale is worse than step_span_unconfirmed
1449
- const isWorse = issue.kind === 'step_span_stale' && prev?.kind !== 'step_span_stale';
1468
+ const isWorse = !prev || rank[kind] > rank[prev.kind];
1469
+ const nextKind = isWorse ? kind : prev!.kind;
1450
1470
  map.set(edgeId, {
1451
- kind: isWorse || !prev ? issue.kind as 'step_span_unconfirmed' | 'step_span_stale' : prev.kind,
1471
+ kind: nextKind,
1452
1472
  severity: prev?.severity === 'error' || issue.severity === 'error' ? 'error' : 'info',
1453
1473
  count: (prev?.count ?? 0) + 1,
1454
1474
  });
@@ -1456,22 +1476,6 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1456
1476
  return map;
1457
1477
  }, [issues, trails, issueStep]);
1458
1478
 
1459
- // Edges backed by a step that ITSELF declares `proposed` — a planned seam
1460
- // between otherwise-real components. Distinct from a proposed ENDPOINT
1461
- // (the node is the placeholder there, and draws its own dashed border):
1462
- // here the seam is the claim that is not real yet, so the edge label draws
1463
- // dashed. Proposed steps are never verified, so this never competes with
1464
- // the step-issue badge.
1465
- const proposedEdgeIds = useMemo(() => {
1466
- const set = new Set<string>();
1467
- for (const trail of trails ?? []) {
1468
- for (const step of trail.steps) {
1469
- if (step.proposed) set.add(trailStepGraphEdgeId(step));
1470
- }
1471
- }
1472
- return set;
1473
- }, [trails]);
1474
-
1475
1479
  // Expanded verification layers in the diagnostics list. When any layer with
1476
1480
  // findings is expanded, the canvas dims everything that layer does not
1477
1481
  // implicate (see `issueFocus`). The list publishes this through
@@ -1798,17 +1802,6 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1798
1802
  () => convertedEdges.map((e) => e.id).sort().join(','),
1799
1803
  [convertedEdges],
1800
1804
  );
1801
- // Stamp `proposedStep` onto edge data so the SVG edge itself (not just its
1802
- // label) draws dashed when a backing trail step declares itself proposed.
1803
- const proposedStampedEdges = useMemo(
1804
- () =>
1805
- baseEdges.map((e) =>
1806
- proposedEdgeIds.has(e.id)
1807
- ? { ...e, data: { ...(e.data as object), proposedStep: true } }
1808
- : e,
1809
- ),
1810
- [baseEdges, proposedEdgeIds],
1811
- );
1812
1805
 
1813
1806
  const dispEdges = useMemo(() => {
1814
1807
  const paint = (e: Edge, dimmed: boolean): Edge => {
@@ -1837,7 +1830,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1837
1830
  return isTrailMechanism(mechanism);
1838
1831
  };
1839
1832
  if (openedEdgeIds || focusEdgeIds || previewEdgeIds || issueFocusEdgeIds) {
1840
- return proposedStampedEdges.map((e) => {
1833
+ return baseEdges.map((e) => {
1841
1834
  const vis = flowElementVisibility({
1842
1835
  inOpened: openedEdgeIds?.has(e.id) === true,
1843
1836
  inSelected: focusEdgeIds?.has(e.id) === true,
@@ -1851,13 +1844,13 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1851
1844
  });
1852
1845
  }
1853
1846
  if (selectedEdgeId) {
1854
- return proposedStampedEdges.map((e) => ({
1847
+ return baseEdges.map((e) => ({
1855
1848
  ...paint(e, e.id !== selectedEdgeId),
1856
1849
  hidden: !edgeInView(e),
1857
1850
  }));
1858
1851
  }
1859
- return proposedStampedEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
1860
- }, [proposedStampedEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
1852
+ return baseEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
1853
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
1861
1854
 
1862
1855
  // Gated on layoutReady so the pre-reveal Pass 1 swap never flashes a count.
1863
1856
  const unroutedIds = useMemo(
@@ -1947,10 +1940,10 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1947
1940
  // own owner-avatar header. Clicking a header collapses that repo's tree.
1948
1941
  const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
1949
1942
  const hasTrails = useMemo(() => (trails?.length ?? 0) > 0, [trails]);
1950
- // Aliases of proposed components — the flows panel tints trail titles
1951
- // (and steps) that touch one.
1952
- const proposedAliases = useMemo(
1953
- () => new Set(components.filter((c) => c.proposed).map((c) => c.alias)),
1943
+ // Alias → component, so the Trails panel can show human hop names instead
1944
+ // of model-local aliases (and the site/`via` distinction can read).
1945
+ const componentByAlias = useMemo(
1946
+ () => new Map(components.map((c) => [c.alias, c])),
1954
1947
  [components],
1955
1948
  );
1956
1949
  // Diagnostics view in the sidebar. Uncontrolled unless the host pins
@@ -2800,7 +2793,6 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2800
2793
  pathLength?: number;
2801
2794
  } | undefined;
2802
2795
  const stepIssue = stepIssueBadgeByEdgeId.get(e.id);
2803
- const proposed = proposedEdgeIds.has(e.id);
2804
2796
  return {
2805
2797
  id: e.id,
2806
2798
  mechanism: d?.mechanism ?? 'uses',
@@ -2811,11 +2803,10 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2811
2803
  pathLength: d?.pathLength ?? 0,
2812
2804
  stepNos: selectedFlowStepNos?.get(e.id),
2813
2805
  stepIssue,
2814
- proposed,
2815
2806
  };
2816
2807
  });
2817
2808
  return labels;
2818
- }, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId, proposedEdgeIds]);
2809
+ }, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId]);
2819
2810
 
2820
2811
  // Unique source files across components → sidebar file trees.
2821
2812
  const treeFilePaths = treeFiles;
@@ -3006,7 +2997,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3006
2997
  onTrailStepHover?.(null);
3007
2998
  }}
3008
2999
  onReorder={onReorderTrails}
3009
- proposedAliases={proposedAliases}
3000
+ componentByAlias={componentByAlias}
3001
+ stepEmphasis={stepEmphasis}
3002
+ onRemoveStepEmphasis={onRemoveStepEmphasis}
3010
3003
  layoutFocusedTrailId={layoutTrailId}
3011
3004
  onFocusLayout={toggleLayoutFocus}
3012
3005
  />
@@ -3153,9 +3146,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3153
3146
  crisp rounded box; soft / ambiguous ones (uses, feeds,
3154
3147
  watches …) get a real lobed cloud silhouette — ambiguous, not
3155
3148
  a dashed proposal (or a pill, which border-radius can only
3156
- ever make). A step that ITSELF declares `proposed` dashes the
3157
- silhouette — the seam is planned, distinct from a proposed
3158
- ENDPOINT, which dashes the node instead. */}
3149
+ ever make). */}
3159
3150
  {verifiable ? (
3160
3151
  <span
3161
3152
  aria-hidden
@@ -3163,9 +3154,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3163
3154
  position: 'absolute',
3164
3155
  inset: 0,
3165
3156
  borderRadius: 4,
3166
- border: lbl.proposed
3167
- ? `1.5px dashed ${color}`
3168
- : `0.5px solid ${color}`,
3157
+ border: `0.5px solid ${color}`,
3169
3158
  background:
3170
3159
  theme.colors.backgroundSecondary ?? theme.colors.background,
3171
3160
  pointerEvents: 'none',
@@ -3190,7 +3179,6 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3190
3179
  stroke={hexWithAlpha(color, 0.75)}
3191
3180
  strokeWidth={1.2}
3192
3181
  strokeLinejoin="round"
3193
- strokeDasharray={lbl.proposed ? '4 3' : undefined}
3194
3182
  />
3195
3183
  </svg>
3196
3184
  )}
@@ -3221,9 +3209,13 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3221
3209
  return (
3222
3210
  <span
3223
3211
  aria-hidden
3224
- title={lbl.stepIssue.kind === 'step_span_stale'
3225
- ? 'Call site changed since verification'
3226
- : 'Call site not yet verified'}
3212
+ title={
3213
+ lbl.stepIssue.kind === 'step_span_stale'
3214
+ ? 'Call site changed since verification'
3215
+ : lbl.stepIssue.kind === 'step_site_outside_from'
3216
+ ? 'Calls site is outside its source component'
3217
+ : 'Call site not yet verified'
3218
+ }
3227
3219
  style={{
3228
3220
  position: 'absolute',
3229
3221
  right: -7,
@@ -3553,6 +3545,18 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3553
3545
  suppressEscape={fileOverlayOpen}
3554
3546
  hidden={hideDrawer}
3555
3547
  progress={drawerProgress}
3548
+ subheader={
3549
+ drawerTarget?.kind === 'trail' &&
3550
+ focusedTrail &&
3551
+ drawerTarget.stepIndex != null ? (
3552
+ <TrailStackBreadcrumb
3553
+ trail={focusedTrail}
3554
+ stepIndex={drawerTarget.stepIndex}
3555
+ componentByAlias={componentByAlias}
3556
+ onFocusStep={(index) => focusTrailStep(focusedTrail, index)}
3557
+ />
3558
+ ) : null
3559
+ }
3556
3560
  >
3557
3561
  {drawerTarget?.kind === 'trail' &&
3558
3562
  focusedTrail &&
@@ -3562,7 +3566,6 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
3562
3566
  trail={focusedTrail}
3563
3567
  stepIndex={drawerTarget.stepIndex}
3564
3568
  onOpenFile={onOpenFileFromTrail}
3565
- proposedAliases={proposedAliases}
3566
3569
  resolveSymbol={resolveTrailSymbol}
3567
3570
  onSymbolClick={openConstructDeclaration}
3568
3571
  onVisibleStepChange={setVisibleStepIndex}
@@ -0,0 +1,109 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { Window } from 'happy-dom';
3
+ import { cleanup, fireEvent, render } from '@testing-library/react/pure';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import type { SubsystemTrail } from './model';
6
+ import { TrailStackBreadcrumb } from './TrailStackBreadcrumb';
7
+
8
+ const dom = new Window();
9
+ const globals = globalThis as unknown as Record<string, unknown>;
10
+ globals.window = dom;
11
+ globals.document = dom.document;
12
+ globals.navigator = dom.navigator;
13
+ globals.HTMLElement = dom.HTMLElement;
14
+ globals.Node = dom.Node;
15
+ globals.Element = dom.Element;
16
+ globals.Event = dom.Event;
17
+ globals.MouseEvent = dom.MouseEvent;
18
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
19
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
20
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
21
+ globals.localStorage = dom.localStorage;
22
+ globals.sessionStorage = dom.sessionStorage;
23
+ globals.matchMedia = dom.matchMedia.bind(dom);
24
+ globals.ResizeObserver = class {
25
+ observe() {}
26
+ unobserve() {}
27
+ disconnect() {}
28
+ };
29
+
30
+ afterEach(cleanup);
31
+
32
+ const trail: SubsystemTrail = {
33
+ id: 't',
34
+ title: 'T',
35
+ steps: [
36
+ {
37
+ from: 'panel',
38
+ to: 'rpc',
39
+ mechanism: 'calls',
40
+ file: 'src/panel.ts',
41
+ purl: 'pkg:github/acme/app#src/panel.ts',
42
+ symbol: 'MaintenancePanel',
43
+ target: {
44
+ purl: 'pkg:github/acme/app#src/rpc.ts',
45
+ file: 'src/rpc.ts',
46
+ symbol: 'getRpc',
47
+ },
48
+ },
49
+ {
50
+ from: 'rpc',
51
+ to: 'detail',
52
+ mechanism: 'calls',
53
+ file: 'src/rpc.ts',
54
+ purl: 'pkg:github/acme/app#src/rpc.ts',
55
+ symbol: 'getRpc',
56
+ target: {
57
+ purl: 'pkg:github/acme/app#src/detail.ts',
58
+ file: 'src/detail.ts',
59
+ symbol: 'modelDetail',
60
+ },
61
+ },
62
+ {
63
+ from: 'panel',
64
+ to: 'merge',
65
+ mechanism: 'uses',
66
+ file: 'src/list.tsx',
67
+ purl: 'pkg:github/acme/app#src/list.tsx',
68
+ symbol: 'ModelList',
69
+ },
70
+ ],
71
+ };
72
+
73
+ function renderBreadcrumb(stepIndex: number, onFocusStep?: (i: number) => void) {
74
+ return render(
75
+ <ThemeProvider theme={defaultEditorTheme}>
76
+ <TrailStackBreadcrumb
77
+ trail={trail}
78
+ stepIndex={stepIndex}
79
+ onFocusStep={onFocusStep}
80
+ />
81
+ </ThemeProvider>,
82
+ );
83
+ }
84
+
85
+ describe('TrailStackBreadcrumb', () => {
86
+ test('renders the call chain and the callee', () => {
87
+ const { getByText } = renderBreadcrumb(1);
88
+ expect(getByText('MaintenancePanel')).toBeTruthy();
89
+ expect(getByText('getRpc')).toBeTruthy();
90
+ expect(getByText('modelDetail')).toBeTruthy();
91
+ });
92
+
93
+ test('clicking a frame focuses that step', () => {
94
+ let focused: number | null = null;
95
+ const { getByText } = renderBreadcrumb(1, (i) => {
96
+ focused = i;
97
+ });
98
+ fireEvent.click(getByText('MaintenancePanel'));
99
+ expect(focused).toBe(0);
100
+ });
101
+
102
+ test('a uses step is an annotation, not a fourth frame', () => {
103
+ const { getByText, queryByText } = renderBreadcrumb(2);
104
+ expect(getByText('Context')).toBeTruthy();
105
+ expect(getByText('uses merge')).toBeTruthy();
106
+ // The uses step's site symbol is not rendered as a frame.
107
+ expect(queryByText('ModelList')).toBeNull();
108
+ });
109
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * TrailStackBreadcrumb — the call stack at one trail step.
3
+ *
4
+ * Rendered above the trail code view. `calls` steps form the chain; `uses` and
5
+ * data/event seams are annotations on the nearest frame, never frames of their
6
+ * own. Clicking a frame focuses the step that created it.
7
+ */
8
+
9
+ import type { CSSProperties } from 'react';
10
+ import { ArrowRight, CornerDownRight } from 'lucide-react';
11
+ import { useTheme } from '@principal-ade/industry-theme';
12
+ import type { SubsystemTrail } from './model';
13
+ import { buildTrailStack } from './trailStack';
14
+ import type { TrailComponentLabels } from './trailStepDisplay';
15
+
16
+ export interface TrailStackBreadcrumbProps {
17
+ trail: SubsystemTrail;
18
+ /** Selected step; the stack is the chain into its caller frame. */
19
+ stepIndex: number;
20
+ /** Alias → component, for human frame/callee labels when no symbol resolves. */
21
+ componentByAlias?: TrailComponentLabels;
22
+ /** Focus a frame's step (usually the trail panel's onFocusStep). */
23
+ onFocusStep?: (stepIndex: number) => void;
24
+ }
25
+
26
+ const chipBase: CSSProperties = {
27
+ display: 'inline-flex',
28
+ alignItems: 'center',
29
+ minWidth: 0,
30
+ maxWidth: 260,
31
+ padding: '2px 7px',
32
+ borderRadius: 4,
33
+ lineHeight: 1.4,
34
+ whiteSpace: 'nowrap',
35
+ overflow: 'hidden',
36
+ textOverflow: 'ellipsis',
37
+ };
38
+
39
+ export function TrailStackBreadcrumb({
40
+ trail,
41
+ stepIndex,
42
+ componentByAlias,
43
+ onFocusStep,
44
+ }: TrailStackBreadcrumbProps) {
45
+ const { theme } = useTheme();
46
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
47
+ const stack = buildTrailStack(trail, stepIndex, componentByAlias);
48
+ if (stack.frames.length === 0) return null;
49
+
50
+ return (
51
+ <div
52
+ aria-label={`${
53
+ stack.isCall ? 'Call stack' : 'Trail context'
54
+ } at step ${stepIndex + 1}`}
55
+ style={{
56
+ display: 'flex',
57
+ alignItems: 'center',
58
+ gap: 6,
59
+ flexShrink: 0,
60
+ minHeight: 28,
61
+ padding: '4px 10px',
62
+ overflowX: 'auto',
63
+ borderBottom: `1px solid ${theme.colors.border}`,
64
+ background:
65
+ theme.colors.backgroundSecondary ?? theme.colors.background,
66
+ }}
67
+ >
68
+ <span
69
+ style={{
70
+ flexShrink: 0,
71
+ paddingRight: 2,
72
+ fontSize: 9,
73
+ fontFamily: theme.fonts.body,
74
+ fontWeight: 600,
75
+ letterSpacing: 0.5,
76
+ textTransform: 'uppercase',
77
+ color: muted,
78
+ }}
79
+ >
80
+ {stack.isCall ? 'Stack' : 'Context'}
81
+ </span>
82
+
83
+ {stack.frames.map((frame, index) => {
84
+ const chipStyle: CSSProperties = {
85
+ ...chipBase,
86
+ fontFamily: theme.fonts.monospace,
87
+ fontSize: theme.fontSizes[0],
88
+ border: `1px solid ${
89
+ frame.current ? theme.colors.primary : theme.colors.border
90
+ }`,
91
+ background: frame.current ? theme.colors.background : 'transparent',
92
+ color: frame.current ? theme.colors.text : theme.colors.textSecondary,
93
+ fontWeight: frame.current ? 600 : 400,
94
+ cursor: onFocusStep ? 'pointer' : 'default',
95
+ flexShrink: 0,
96
+ };
97
+ const label = (
98
+ <span
99
+ style={{
100
+ overflow: 'hidden',
101
+ textOverflow: 'ellipsis',
102
+ }}
103
+ >
104
+ {frame.label}
105
+ </span>
106
+ );
107
+ return (
108
+ <span
109
+ key={`${frame.stepIndex}:${index}`}
110
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
111
+ >
112
+ {index > 0 && (
113
+ <span aria-hidden style={{ color: muted, fontSize: 11 }}>
114
+ ›
115
+ </span>
116
+ )}
117
+ {onFocusStep ? (
118
+ <button
119
+ type="button"
120
+ onClick={() => onFocusStep(frame.stepIndex)}
121
+ style={chipStyle}
122
+ >
123
+ {label}
124
+ </button>
125
+ ) : (
126
+ <span style={chipStyle}>{label}</span>
127
+ )}
128
+ </span>
129
+ );
130
+ })}
131
+
132
+ {stack.callee && (
133
+ <span
134
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
135
+ >
136
+ <ArrowRight size={11} style={{ color: muted, flexShrink: 0 }} />
137
+ <span
138
+ style={{
139
+ ...chipBase,
140
+ fontFamily: theme.fonts.monospace,
141
+ fontSize: theme.fontSizes[0],
142
+ border: `1px dashed ${theme.colors.border}`,
143
+ color: muted,
144
+ flexShrink: 0,
145
+ }}
146
+ >
147
+ {stack.callee}
148
+ </span>
149
+ </span>
150
+ )}
151
+
152
+ {stack.annotations.map((annotation) => (
153
+ <span
154
+ key={`annotation:${annotation.stepIndex}`}
155
+ style={{
156
+ display: 'inline-flex',
157
+ alignItems: 'center',
158
+ gap: 3,
159
+ flexShrink: 0,
160
+ fontSize: 10,
161
+ fontFamily: theme.fonts.monospace,
162
+ color: muted,
163
+ whiteSpace: 'nowrap',
164
+ }}
165
+ >
166
+ <CornerDownRight size={10} style={{ flexShrink: 0 }} />
167
+ {annotation.label}
168
+ </span>
169
+ ))}
170
+ </div>
171
+ );
172
+ }
@@ -155,6 +155,41 @@ describe('TrailStepsNav', () => {
155
155
  expect(cleared).toBe(true);
156
156
  });
157
157
 
158
+ test('uses rows show the logical hop and the via site', () => {
159
+ const usesTrail: SubsystemTrail = {
160
+ id: 'uses',
161
+ title: 'Uses flow',
162
+ steps: [
163
+ {
164
+ from: 'panel',
165
+ to: 'merge',
166
+ mechanism: 'uses',
167
+ file: 'src/list.tsx',
168
+ span: { startLine: 10, endLine: 12, contentHash: 'h' },
169
+ purl: 'pkg:github/x#src/list.tsx',
170
+ symbol: 'ModelList',
171
+ },
172
+ ],
173
+ };
174
+ const { getByText } = renderNav({
175
+ trails: [usesTrail],
176
+ focusedTrailId: 'uses',
177
+ focusedStepIndex: null,
178
+ hoveredTrailStep: null,
179
+ componentByAlias: new Map([
180
+ ['panel', { alias: 'panel', name: 'MaintenancePanel' }],
181
+ ['merge', { alias: 'merge', name: 'mergeProvenance' }],
182
+ ]),
183
+ onFocusFlow: () => {},
184
+ onClearFocus: () => {},
185
+ onFocusStep: () => {},
186
+ onHoverStep: () => {},
187
+ onHoverFlow: () => {},
188
+ onLeaveStep: () => {},
189
+ });
190
+ expect(getByText('MaintenancePanel uses mergeProvenance')).toBeTruthy();
191
+ });
192
+
158
193
  test('clicking the step-pane header title also goes back', () => {
159
194
  let cleared = false;
160
195
  const { getByRole } = renderNav({