@principal-ai/principal-view-react 0.16.60 → 0.16.62

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 (44) hide show
  1. package/dist/index.d.ts +5 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreThroughlineCodeView.d.ts +19 -0
  6. package/dist/pierre/PierreThroughlineCodeView.d.ts.map +1 -0
  7. package/dist/pierre/PierreThroughlineCodeView.js +139 -0
  8. package/dist/pierre/PierreThroughlineCodeView.js.map +1 -0
  9. package/dist/pierre/constructColors.d.ts.map +1 -1
  10. package/dist/pierre/constructColors.js +1 -4
  11. package/dist/pierre/constructColors.js.map +1 -1
  12. package/dist/pierre/index.d.ts +2 -0
  13. package/dist/pierre/index.d.ts.map +1 -1
  14. package/dist/pierre/index.js +1 -0
  15. package/dist/pierre/index.js.map +1 -1
  16. package/dist/subsystem/FileDrawer.d.ts +8 -7
  17. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  18. package/dist/subsystem/FileDrawer.js +9 -9
  19. package/dist/subsystem/FileDrawer.js.map +1 -1
  20. package/dist/subsystem/SubsystemComponentGraph.d.ts +17 -5
  21. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  22. package/dist/subsystem/SubsystemComponentGraph.js +76 -21
  23. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  24. package/dist/subsystem/model.d.ts +64 -10
  25. package/dist/subsystem/model.d.ts.map +1 -1
  26. package/dist/subsystem/model.js +53 -8
  27. package/dist/subsystem/model.js.map +1 -1
  28. package/dist/subsystem/nodes.d.ts.map +1 -1
  29. package/dist/subsystem/nodes.js +16 -8
  30. package/dist/subsystem/nodes.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/index.ts +15 -2
  33. package/src/pierre/PierreThroughlineCodeView.tsx +210 -0
  34. package/src/pierre/constructColors.ts +1 -4
  35. package/src/pierre/index.ts +2 -0
  36. package/src/stories/Pierre/CodeView.stories.tsx +196 -0
  37. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +62 -4
  38. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +216 -0
  39. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +38 -3
  40. package/src/subsystem/FileDrawer.tsx +11 -10
  41. package/src/subsystem/SubsystemComponentGraph.tsx +121 -34
  42. package/src/subsystem/model.test.ts +44 -0
  43. package/src/subsystem/model.ts +105 -16
  44. package/src/subsystem/nodes.tsx +24 -12
@@ -59,6 +59,17 @@ const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
59
59
  const EDGE_LABEL_CHAR_PX = 6.2;
60
60
  const EDGE_LABEL_PAD_PX = 18;
61
61
 
62
+ /** Context passed to `renderThroughlineViewer` when a flow/step is focused. */
63
+ export interface ThroughlineViewerContext {
64
+ throughline: SubsystemThroughline;
65
+ /** Focused step index; `null` means the whole flow (no specific step). */
66
+ stepIndex: number | null;
67
+ }
68
+
69
+ type DrawerTarget =
70
+ | { kind: 'file'; file: string; startLine?: number }
71
+ | { kind: 'throughline'; throughlineId: string; stepIndex: number | null };
72
+
62
73
  export interface SubsystemComponentGraphProps {
63
74
  components: SubsystemComponent[];
64
75
  edges: SubsystemComponentEdge[];
@@ -67,8 +78,9 @@ export interface SubsystemComponentGraphProps {
67
78
  * flow. When present the sidebar's bottom half offers a Files/Flows toggle:
68
79
  * the flows panel lists each throughline's steps (`symbol` or `file:line`);
69
80
  * clicking a flow row toggles its steps; clicking a step focuses that
70
- * step's edge. Opened flows stay on the canvas (unselected ones dimmed);
71
- * everything else is hidden.
81
+ * step's edge and (when `renderThroughlineViewer` is set) opens the bottom
82
+ * drawer on that flow's snippets. Opened flows stay on the canvas
83
+ * (unselected ones dimmed); everything else is hidden.
72
84
  */
73
85
  throughlines?: SubsystemThroughline[];
74
86
  onSelect?: (componentId: string) => void;
@@ -105,11 +117,16 @@ export interface SubsystemComponentGraphProps {
105
117
  sidebarAfterDescription?: ReactNode;
106
118
  /**
107
119
  * Host-injected reader/renderer for the bottom file drawer, keyed by
108
- * repo-root-relative path. Opening happens on node click (component with a
109
- * `file`) and sidebar file-tree click. Keeps this package free of fs and
110
- * code-view dependencies.
120
+ * repo-root-relative path. Opening happens on declaration/file-tree clicks.
121
+ * Keeps this package free of fs and code-view dependencies.
111
122
  */
112
123
  renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
124
+ /**
125
+ * Host-injected multi-snippet viewer for a focused throughline. When set,
126
+ * clicking a flow step (or the whole flow) opens the bottom drawer with
127
+ * this content and updates it as the focused step changes.
128
+ */
129
+ renderThroughlineViewer?: (ctx: ThroughlineViewerContext) => ReactNode;
113
130
  /**
114
131
  * Legacy component-keyed variant, kept for backward compatibility. When
115
132
  * `renderFileViewer` is absent, drawer content resolves via the first
@@ -136,28 +153,39 @@ const edgeTypes: EdgeTypes = {
136
153
  'subsystem-edge': SubsystemEdge,
137
154
  };
138
155
 
139
- // Memoized drawer body: only rebuilds children when the open file changes.
156
+ // Memoized drawer body: only rebuilds children when the open target changes.
140
157
  // Inner re-renders on every viewport pan/zoom and hover; recreating host
141
158
  // elements then would churn their readFile closures and flash the file
142
159
  // viewer's loading state on each render.
143
- const DrawerContent = memo(function DrawerContent({
160
+ const FileDrawerContent = memo(function FileDrawerContent({
144
161
  render,
145
162
  file,
146
163
  startLine,
147
164
  }: {
148
165
  render: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
149
- file: string | null;
166
+ file: string;
150
167
  startLine?: number;
151
168
  }) {
152
- if (!file) return null;
153
169
  return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
154
170
  });
155
171
 
172
+ const ThroughlineDrawerContent = memo(function ThroughlineDrawerContent({
173
+ render,
174
+ throughline,
175
+ stepIndex,
176
+ }: {
177
+ render: (ctx: ThroughlineViewerContext) => ReactNode;
178
+ throughline: SubsystemThroughline;
179
+ stepIndex: number | null;
180
+ }) {
181
+ return <>{render({ throughline, stepIndex })}</>;
182
+ });
183
+
156
184
  interface InnerProps extends SubsystemComponentGraphProps {
157
185
  measured: { w: number; h: number } | null;
158
186
  }
159
187
 
160
- function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showLegend, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
188
+ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showLegend, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
161
189
  const { theme } = useTheme();
162
190
  const { fitView } = useReactFlow();
163
191
  const viewport = useViewport();
@@ -167,13 +195,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
167
195
  });
168
196
  const [layoutReady, setLayoutReady] = useState(false);
169
197
  const [selected, setSelected] = useState<SubsystemComponent | null>(null);
170
- /** File shown in the bottom drawer + optional declaration scroll target. */
171
- const [openFileTarget, setOpenFileTarget] = useState<{
172
- file: string;
173
- startLine?: number;
174
- } | null>(null);
175
- const openFile = openFileTarget?.file ?? null;
176
- const openFileStartLine = openFileTarget?.startLine;
198
+ /** Bottom drawer: single file or throughline multi-snippet mode. */
199
+ const [drawerTarget, setDrawerTarget] = useState<DrawerTarget | null>(null);
177
200
  // Edge-legend modal visibility (opened from the canvas's top-left button).
178
201
  const [legendOpen, setLegendOpen] = useState(false);
179
202
  // Component the pointer is over (null on leave) → transient tree highlight.
@@ -194,9 +217,31 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
194
217
  // closure over the effect deps) can toggle without a stale value.
195
218
  const selectedRef = useRef<SubsystemComponent | null>(null);
196
219
  selectedRef.current = selected;
197
- // Ref mirror of `openFile` for the tree-click toggle.
220
+ // Ref mirror of the open file drawer target for tree-click toggle.
198
221
  const openFileRef = useRef<{ file: string; startLine?: number } | null>(null);
199
- openFileRef.current = openFileTarget;
222
+ const openFile =
223
+ drawerTarget?.kind === 'file' ? drawerTarget.file : null;
224
+ openFileRef.current =
225
+ drawerTarget?.kind === 'file'
226
+ ? { file: drawerTarget.file, startLine: drawerTarget.startLine }
227
+ : null;
228
+
229
+ const focusedThroughline = useMemo(() => {
230
+ if (drawerTarget?.kind !== 'throughline' || !throughlines) return null;
231
+ return throughlines.find((t) => t.id === drawerTarget.throughlineId) ?? null;
232
+ }, [drawerTarget, throughlines]);
233
+
234
+ const drawerTitle = useMemo(() => {
235
+ if (!drawerTarget) return null;
236
+ if (drawerTarget.kind === 'file') return drawerTarget.file;
237
+ const tl = focusedThroughline;
238
+ if (!tl) return null;
239
+ if (drawerTarget.stepIndex == null) return tl.title;
240
+ const step = tl.steps[drawerTarget.stepIndex];
241
+ if (!step) return tl.title;
242
+ const site = `${step.file.split('/').pop() ?? step.file}:${step.line}`;
243
+ return `${tl.title} · ${site}`;
244
+ }, [drawerTarget, focusedThroughline]);
200
245
 
201
246
  // Refresh selected component when the components list updates (e.g. verify
202
247
  // writes back declarationRef).
@@ -594,10 +639,10 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
594
639
  const onTreeSelectFile = useCallback(
595
640
  (file: string) => {
596
641
  if (openFileRef.current?.file === file && openFileRef.current.startLine == null) {
597
- setOpenFileTarget(null);
642
+ setDrawerTarget(null);
598
643
  return;
599
644
  }
600
- setOpenFileTarget({ file });
645
+ setDrawerTarget({ kind: 'file', file });
601
646
  onFileSelect?.(file);
602
647
  },
603
648
  [onFileSelect],
@@ -610,10 +655,10 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
610
655
  openFileRef.current?.file === file &&
611
656
  openFileRef.current.startLine === startLine
612
657
  ) {
613
- setOpenFileTarget(null);
658
+ setDrawerTarget(null);
614
659
  return;
615
660
  }
616
- setOpenFileTarget({ file, startLine });
661
+ setDrawerTarget({ kind: 'file', file, startLine });
617
662
  onFileSelect?.(file);
618
663
  },
619
664
  [onFileSelect],
@@ -643,7 +688,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
643
688
 
644
689
  // Focus an entire flow: hide everything but the flow's nodes and edges, and
645
690
  // frame the flow on the canvas. Selection state is cleared — the graph now
646
- // reads as the narrative.
691
+ // reads as the narrative. Opens the throughline drawer when a viewer is set.
647
692
  const focusThroughlineEdges = useCallback(
648
693
  (tl: SubsystemThroughline) => {
649
694
  setSelected(null);
@@ -651,17 +696,28 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
651
696
  setFocusedStepIndex(null);
652
697
  setFocusedThroughlineId(tl.id);
653
698
  fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
699
+ if (renderThroughlineViewer) {
700
+ setDrawerTarget({ kind: 'throughline', throughlineId: tl.id, stepIndex: null });
701
+ } else if (tl.steps[0]) {
702
+ // Fallback: single-file snippet of the first step.
703
+ setDrawerTarget({
704
+ kind: 'file',
705
+ file: tl.steps[0].file,
706
+ startLine: tl.steps[0].line,
707
+ });
708
+ }
654
709
  },
655
- [fitFocusBounds],
710
+ [fitFocusBounds, renderThroughlineViewer],
656
711
  );
657
712
 
658
713
  const clearThroughlineFocus = useCallback(() => {
659
714
  setFocusedThroughlineId(null);
660
715
  setFocusedStepIndex(null);
716
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
661
717
  }, []);
662
718
 
663
- // Focus a single step's edge on the canvas. The step's file:line is listed
664
- // in the row; we don't open the drawer from here.
719
+ // Focus a single step's edge on the canvas and open/scroll the throughline
720
+ // drawer to that step's snippet.
665
721
  const focusThroughlineStep = useCallback(
666
722
  (tl: SubsystemThroughline, stepIndex: number) => {
667
723
  const step = tl.steps[stepIndex];
@@ -671,8 +727,21 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
671
727
  setFocusedStepIndex(stepIndex);
672
728
  setFocusedThroughlineId(tl.id);
673
729
  fitFocusBounds(new Set([step.edgeId]));
730
+ if (renderThroughlineViewer) {
731
+ setDrawerTarget({
732
+ kind: 'throughline',
733
+ throughlineId: tl.id,
734
+ stepIndex,
735
+ });
736
+ } else {
737
+ setDrawerTarget({
738
+ kind: 'file',
739
+ file: step.file,
740
+ startLine: step.line,
741
+ });
742
+ }
674
743
  },
675
- [fitFocusBounds],
744
+ [fitFocusBounds, renderThroughlineViewer],
676
745
  );
677
746
 
678
747
  const toggleThroughlineCollapsed = useCallback((tlId: string) => {
@@ -781,6 +850,14 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
781
850
  [],
782
851
  );
783
852
 
853
+ const throughlineViewerRef = useRef(renderThroughlineViewer);
854
+ throughlineViewerRef.current = renderThroughlineViewer;
855
+ const renderThroughlineDrawerContent = useCallback(
856
+ (ctx: ThroughlineViewerContext) =>
857
+ throughlineViewerRef.current?.(ctx) ?? null,
858
+ [],
859
+ );
860
+
784
861
  return (
785
862
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
786
863
  {/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
@@ -1157,12 +1234,22 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1157
1234
  mechanisms={usedMechanisms}
1158
1235
  onClose={() => setLegendOpen(false)}
1159
1236
  />
1160
- <FileDrawer file={openFile} onClose={() => setOpenFileTarget(null)}>
1161
- <DrawerContent
1162
- render={renderDrawerContent}
1163
- file={openFile}
1164
- startLine={openFileStartLine}
1165
- />
1237
+ <FileDrawer title={drawerTitle} onClose={() => setDrawerTarget(null)}>
1238
+ {drawerTarget?.kind === 'throughline' &&
1239
+ focusedThroughline &&
1240
+ renderThroughlineViewer ? (
1241
+ <ThroughlineDrawerContent
1242
+ render={renderThroughlineDrawerContent}
1243
+ throughline={focusedThroughline}
1244
+ stepIndex={drawerTarget.stepIndex}
1245
+ />
1246
+ ) : drawerTarget?.kind === 'file' ? (
1247
+ <FileDrawerContent
1248
+ render={renderDrawerContent}
1249
+ file={drawerTarget.file}
1250
+ startLine={drawerTarget.startLine}
1251
+ />
1252
+ ) : null}
1166
1253
  </FileDrawer>
1167
1254
  {/* Startup cover — hides measurement, layout swap, and camera settle. */}
1168
1255
  <GraphLayoutCover revealed={layoutReady} />
@@ -7,6 +7,8 @@ import {
7
7
  processGroupNodeId,
8
8
  buildSubsystemGraph,
9
9
  deriveNameFromSymbol,
10
+ constructBadgeLabel,
11
+ nodeMinWidthForBadges,
10
12
  formatPurl,
11
13
  packageColor,
12
14
  subsystemGraphLayoutKey,
@@ -105,6 +107,48 @@ describe('subsystem graph model', () => {
105
107
  expect(deriveNameFromSymbol('Foo {}', 'class')).toBe('Foo {}');
106
108
  });
107
109
 
110
+ test('deriveNameFromSymbol uses JSX decoration for component stereotype', () => {
111
+ expect(deriveNameFromSymbol('AnalysisView', 'function', undefined, undefined, 'component')).toBe(
112
+ '<AnalysisView>',
113
+ );
114
+ expect(deriveNameFromSymbol('useDrawingsHost', 'function', undefined, undefined, 'hook')).toBe(
115
+ 'useDrawingsHost()',
116
+ );
117
+ });
118
+
119
+ test('constructBadgeLabel prefers framework · stereotype over construct', () => {
120
+ expect(
121
+ constructBadgeLabel({
122
+ construct: 'function',
123
+ framework: 'react',
124
+ stereotype: 'component',
125
+ }),
126
+ ).toBe('react · component');
127
+ expect(constructBadgeLabel({ construct: 'function', stereotype: 'hook' })).toBe('hook');
128
+ expect(constructBadgeLabel({ construct: 'function' })).toBe('function');
129
+ expect(constructBadgeLabel({ construct: 'type_alias' })).toBe('type alias');
130
+ });
131
+
132
+ test('nodeMinWidthForBadges widens for long construct badges and role pairs', () => {
133
+ const plain = nodeMinWidthForBadges({ construct: 'function' });
134
+ expect(plain).toBe(150);
135
+
136
+ const stereotype = nodeMinWidthForBadges({
137
+ construct: 'function',
138
+ framework: 'react',
139
+ stereotype: 'component',
140
+ });
141
+ expect(stereotype).toBeGreaterThan(150);
142
+
143
+ const withRole = nodeMinWidthForBadges({
144
+ construct: 'function',
145
+ framework: 'react',
146
+ stereotype: 'component',
147
+ role: 'entry',
148
+ });
149
+ expect(withRole).toBeGreaterThan(stereotype);
150
+ });
151
+
108
152
  test('deriveNameFromSymbol falls back to file basename for modules', () => {
109
153
  expect(deriveNameFromSymbol(undefined, 'module', undefined, 'transcript.ts')).toBe('transcript');
110
154
  expect(deriveNameFromSymbol(undefined, 'module', undefined, 'src/event-processors/index.ts')).toBe('index');
@@ -28,7 +28,6 @@ export type SubsystemComponentConstruct =
28
28
  | 'interface'
29
29
  | 'type_alias'
30
30
  | 'enum'
31
- | 'react_component'
32
31
  | 'module'
33
32
  | 'store'
34
33
  | 'external';
@@ -46,6 +45,22 @@ export type SubsystemComponentConstruct =
46
45
  */
47
46
  export type SubsystemComponentRole = 'entry' | 'service';
48
47
 
48
+ /**
49
+ * Framework that owns a stereotype vocabulary (open string).
50
+ * Examples: `react`, `vue`, `nestjs`, `django`, `spring`.
51
+ * Empty when the node is language-only / framework-agnostic.
52
+ */
53
+ export type SubsystemFramework = string;
54
+
55
+ /**
56
+ * Framework-level pattern stamped on a language construct (open string).
57
+ * Examples: `component`, `hook`, `middleware`, `controller`, `guard`.
58
+ * Empty when no framework pattern applies. Pair with `framework` when set —
59
+ * a React component stays `construct: 'function'` with
60
+ * `framework: 'react'` + `stereotype: 'component'`.
61
+ */
62
+ export type SubsystemStereotype = string;
63
+
49
64
  // ---------------------------------------------------------------------------
50
65
  // Declaration tokens — structured source representation
51
66
  // ---------------------------------------------------------------------------
@@ -93,14 +108,15 @@ export interface SubsystemComponent {
93
108
  name: string;
94
109
  /**
95
110
  * The node's construct — what it IS as a declaration (class, function,
96
- * method, interface, type alias, enum, react_component, store, external),
97
- * driving node anatomy, color, badge, and the verification strategy. Every
98
- * construct anchors to a definition; runtime occurrences (variables,
99
- * activations, instances) are NOT constructs — they belong to a future
100
- * execution-mode graph whose occurrence nodes reference these definitions.
101
- * Ontology: construct = what it is, role = where it sits, process = where
102
- * it runs. Use `react_component` (not `function`) for JSX/TSX UI units so
103
- * the badge reads "component" instead of "function".
111
+ * method, interface, type alias, enum, store, external), driving node
112
+ * anatomy, color, badge, and the verification strategy. Every construct
113
+ * anchors to a definition; runtime occurrences (variables, activations,
114
+ * instances) are NOT constructs — they belong to a future execution-mode
115
+ * graph whose occurrence nodes reference these definitions. Ontology:
116
+ * construct = what it is, framework + stereotype = which framework pattern
117
+ * it plays, role = where it sits, process = where it runs. Prefer
118
+ * `framework` + `stereotype` over inventing framework-specific constructs
119
+ * (a React component is still `construct: 'function'`).
104
120
  */
105
121
  construct: SubsystemComponentConstruct;
106
122
  /** Source location the component lives in (repo-root-relative path). */
@@ -118,6 +134,17 @@ export interface SubsystemComponent {
118
134
  * inbound, `produces` outbound).
119
135
  */
120
136
  role?: SubsystemComponentRole;
137
+ /**
138
+ * Framework that owns the stereotype vocabulary (e.g. `react`, `nestjs`).
139
+ * Orthogonal to `construct` — leave empty for language-only units.
140
+ */
141
+ framework?: SubsystemFramework;
142
+ /**
143
+ * Framework pattern this declaration plays (e.g. `component`, `hook`).
144
+ * When set, the node badge prefers this label over the construct name so
145
+ * a React UI unit reads as "component" rather than "function".
146
+ */
147
+ stereotype?: SubsystemStereotype;
121
148
  /**
122
149
  * Runtime process membership — which deployment unit this node is a
123
150
  * member of (e.g. `trail-viewer/host`, `trail-viewer/renderer`). Nodes
@@ -228,6 +255,7 @@ export function deriveNameFromSymbol(
228
255
  construct: SubsystemComponentConstruct,
229
256
  existingName?: string,
230
257
  file?: string,
258
+ stereotype?: string,
231
259
  ): string {
232
260
  let name: string | undefined;
233
261
  if (symbol && symbol.trim()) {
@@ -239,10 +267,13 @@ export function deriveNameFromSymbol(
239
267
  }
240
268
  if (!name) name = existingName ?? 'untitled';
241
269
 
242
- // Decoration = what the drill-down shows. Executable constructs wear `()`
243
- // (a signature you can call); brace-bodied constructs wear ` {}` (a member
244
- // body fields for types/interfaces/enums, fields+methods for classes).
245
- // Everything else (store, variable, module, external) renders bare.
270
+ // Decorations = what the drill-down shows. Framework stereotypes can override
271
+ // the language decoration (a React component wears `<>` instead of `()`).
272
+ // Executable constructs wear `()`; brace-bodied constructs wear ` {}`.
273
+ // Everything else (store, module, external) renders bare.
274
+ if (stereotype === 'component' && !name.startsWith('<')) {
275
+ return `<${name}>`;
276
+ }
246
277
  if ((construct === 'function' || construct === 'method') && !name.endsWith('()')) {
247
278
  name = `${name}()`;
248
279
  }
@@ -446,6 +477,64 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
446
477
  service: 'service',
447
478
  };
448
479
 
480
+ /**
481
+ * Primary badge text for a node: prefer framework stereotype over the
482
+ * language construct so a React UI unit reads as "component" / "hook"
483
+ * rather than "function". When both framework and stereotype are set,
484
+ * show `framework · stereotype` (e.g. `react · component`).
485
+ */
486
+ export function constructBadgeLabel(component: {
487
+ construct: SubsystemComponentConstruct;
488
+ framework?: string;
489
+ stereotype?: string;
490
+ }): string {
491
+ const constructLabel =
492
+ component.construct === 'type_alias' ? 'type alias' : component.construct;
493
+ if (component.stereotype && component.framework) {
494
+ return `${component.framework} · ${component.stereotype}`;
495
+ }
496
+ if (component.stereotype) return component.stereotype;
497
+ return constructLabel ?? '';
498
+ }
499
+
500
+ /** Default CSS floor for component nodes (padding aside). */
501
+ export const NODE_CSS_MIN_WIDTH = 150;
502
+ /** Inset of each top badge from the node edge (`left` / `right` style). */
503
+ export const BADGE_EDGE_INSET = 5;
504
+ /** Minimum gap between left construct badge and right role badge. */
505
+ const BADGE_PAIR_GAP = 8;
506
+ /** Badge box chrome: padding 5+5 + border 1+1. */
507
+ const BADGE_BOX_CHROME = 12;
508
+ /** Approx monospace uppercase width incl. letter-spacing (~0.5px). */
509
+ const BADGE_CHAR_WIDTH = 8;
510
+
511
+ /** Estimated rendered width of a top tab badge label. */
512
+ export function estimateBadgeLabelWidth(label: string): number {
513
+ return (label?.length ?? 0) * BADGE_CHAR_WIDTH + BADGE_BOX_CHROME;
514
+ }
515
+
516
+ /**
517
+ * Minimum node width so top badges stay on one line and (when both are
518
+ * present) don't overlap — badges are absolutely positioned, so they don't
519
+ * contribute to layout unless we widen the node explicitly.
520
+ */
521
+ export function nodeMinWidthForBadges(component: {
522
+ construct: SubsystemComponentConstruct;
523
+ framework?: string;
524
+ stereotype?: string;
525
+ role?: SubsystemComponentRole;
526
+ }): number {
527
+ const left = estimateBadgeLabelWidth(constructBadgeLabel(component));
528
+ if (component.role == null) {
529
+ return Math.max(NODE_CSS_MIN_WIDTH, BADGE_EDGE_INSET + left + BADGE_EDGE_INSET);
530
+ }
531
+ const right = estimateBadgeLabelWidth(ROLE_LABEL[component.role]);
532
+ return Math.max(
533
+ NODE_CSS_MIN_WIDTH,
534
+ BADGE_EDGE_INSET + left + BADGE_PAIR_GAP + right + BADGE_EDGE_INSET,
535
+ );
536
+ }
537
+
449
538
  /**
450
539
  * Convert a subsystem graph document into React Flow nodes. Components that
451
540
  * carry a `process` get a `parentId` pointing at their boundary group node
@@ -492,9 +581,9 @@ export function convertSubsystemToNodes(
492
581
  .sort((a, b) => b.length - a.length)[0];
493
582
  const cap = maxNodeWidth ?? 300;
494
583
  const textWidth = Math.min(cap, Math.max(60, (text?.length ?? 10) * 8));
495
- // Account for CSS minWidth and padding/border so ELK's port positions match
496
- // the actual rendered node boundaries.
497
- const cssMinWidth = 150;
584
+ // Account for CSS minWidth (incl. top badges) and padding/border so ELK's
585
+ // port positions match the actual rendered node boundaries.
586
+ const cssMinWidth = nodeMinWidthForBadges(c);
498
587
  const cssPadding = 20; // horizontal padding (left + right)
499
588
  const cssBorder = 4; // 2px border each side
500
589
  const rawWidth = Math.max(cssMinWidth, textWidth + cssPadding + cssBorder);
@@ -21,7 +21,10 @@ import {
21
21
  MECHANISM_STYLE,
22
22
  ROLE_COLOR,
23
23
  ROLE_LABEL,
24
+ constructBadgeLabel,
24
25
  deriveNameFromSymbol,
26
+ BADGE_EDGE_INSET,
27
+ nodeMinWidthForBadges,
25
28
  packageColor,
26
29
  type SubsystemGraphNodeData,
27
30
  type SubsystemGroupNodeData,
@@ -37,7 +40,6 @@ export const CONSTRUCT_LABEL: Record<string, string> = {
37
40
  interface: 'interface',
38
41
  type_alias: 'type alias',
39
42
  enum: 'enum',
40
- react_component: 'component',
41
43
  module: 'module',
42
44
  store: 'store',
43
45
  external: 'external',
@@ -87,7 +89,10 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
87
89
  const configuredMax = SUBSYSTEM_CALLBACKS.maxNodeWidth;
88
90
  const maxWidth = configuredMax ?? 300;
89
91
  // `symbol` is the source of truth; `name` is derived from it consistently.
90
- const displayName = deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file);
92
+ const displayName = deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file, c.stereotype);
93
+ // Top badges are absolutely positioned — widen the node so they nowrap
94
+ // instead of wrapping, including when construct + role badges share the top.
95
+ const badgeMinWidth = nodeMinWidthForBadges(c);
91
96
  // Set while a file is open in the drawer: true → spotlight, false → dim,
92
97
  // absent (no file open) → neutral.
93
98
  const fileMatch = data.fileMatch as boolean | undefined;
@@ -118,7 +123,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
118
123
  boxSizing: 'border-box',
119
124
  width: nodeWidth,
120
125
  height: nodeHeight,
121
- minWidth: 150,
126
+ minWidth: badgeMinWidth,
122
127
  maxWidth,
123
128
  padding: '6px 10px',
124
129
  borderRadius: 8,
@@ -136,20 +141,21 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
136
141
  fontFamily: theme.fonts.body,
137
142
  }}
138
143
  >
139
- {/* Construct badge — a small tab riding the top-right border, in the
140
- construct color. Persistent (no hover needed); pointer-events none so
141
- clicks pass through to the node. */}
144
+ {/* Construct / stereotype badge — prefers framework stereotype so a
145
+ React UI unit reads as "react · component" instead of "function".
146
+ Persistent; pointer-events none so clicks pass through to the node. */}
142
147
  <div
143
148
  style={{
144
149
  position: 'absolute',
145
150
  top: -9,
146
- left: 10,
151
+ left: BADGE_EDGE_INSET,
147
152
  zIndex: 1,
148
153
  fontFamily: theme.fonts.monospace,
149
154
  fontSize: theme.fontSizes[0] * 1.1,
150
155
  letterSpacing: 0.5,
151
156
  textTransform: 'uppercase',
152
157
  lineHeight: '17px',
158
+ whiteSpace: 'nowrap',
153
159
  color,
154
160
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
155
161
  border: `1px solid ${color}`,
@@ -157,7 +163,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
157
163
  padding: '0 5px',
158
164
  }}
159
165
  >
160
- {CONSTRUCT_LABEL[c.construct] ?? c.construct}
166
+ {constructBadgeLabel(c)}
161
167
  </div>
162
168
 
163
169
  {/* Role badge — top-right, only when the node carries a topology role.
@@ -168,13 +174,14 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
168
174
  style={{
169
175
  position: 'absolute',
170
176
  top: -9,
171
- right: 10,
177
+ right: BADGE_EDGE_INSET,
172
178
  zIndex: 1,
173
179
  fontFamily: theme.fonts.monospace,
174
180
  fontSize: theme.fontSizes[0] * 1.1,
175
181
  letterSpacing: 0.5,
176
182
  textTransform: 'uppercase',
177
183
  lineHeight: '17px',
184
+ whiteSpace: 'nowrap',
178
185
  color: ROLE_COLOR[c.role],
179
186
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
180
187
  border: `1px solid ${ROLE_COLOR[c.role]}`,
@@ -243,10 +250,15 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
243
250
  </div>
244
251
 
245
252
  {/* Hide the identity line when the symbol is just the title without its
246
- decoration (`()` or ` {}`) — only show it when it adds information
247
- (e.g. the dotted host on methods, or a different code identity). */}
253
+ decoration (`()`, ` {}`, or `<>`) — only show it when it adds
254
+ information (e.g. the dotted host on methods, or a different code
255
+ identity). */}
248
256
  {c.symbol &&
249
- c.symbol !== displayName.replace(/ ?\{\}$/, '').replace(/\(\)$/, '') && (
257
+ c.symbol !==
258
+ displayName
259
+ .replace(/^<(.+)>$/, '$1')
260
+ .replace(/ ?\{\}$/, '')
261
+ .replace(/\(\)$/, '') && (
250
262
  <div
251
263
  style={{
252
264
  fontSize: theme.fontSizes[0] * 0.82,