@principal-ai/principal-view-react 0.16.59 → 0.16.61

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.
@@ -79,8 +79,22 @@ export interface SubsystemComponentGraphProps {
79
79
  maxNodeWidth?: number;
80
80
  /** Show edge labels (mechanism names) on the graph. @default true */
81
81
  showEdgeLabels?: boolean;
82
+ /** Show the mechanism-legend button overlay on the canvas. @default true */
83
+ showLegend?: boolean;
82
84
  /** Subsystem title displayed in the sidebar. */
83
85
  title?: string;
86
+ /**
87
+ * Suppresses the sidebar entirely (title, description, file tree,
88
+ * throughlines) for graph-only embeds. Pair with `graphTitle` to keep the
89
+ * subsystem name visible as an overlay on the canvas.
90
+ */
91
+ hideSidebar?: boolean;
92
+ /**
93
+ * Subsystem title rendered as a non-interactive overlay chip on the graph
94
+ * canvas (top-center). Does not trigger the sidebar — for graph-only
95
+ * embeds that still need to name what they show.
96
+ */
97
+ graphTitle?: string;
84
98
  /** Markdown description rendered in the sidebar. */
85
99
  description?: string;
86
100
  /** Rendered over the graph canvas only (not the title/legend sidebar). */
@@ -143,7 +157,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
143
157
  measured: { w: number; h: number } | null;
144
158
  }
145
159
 
146
- function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
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) {
147
161
  const { theme } = useTheme();
148
162
  const { fitView } = useReactFlow();
149
163
  const viewport = useViewport();
@@ -770,7 +784,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
770
784
  return (
771
785
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
772
786
  {/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
773
- {(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
787
+ {!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
774
788
  <div
775
789
  style={{
776
790
  width: 340,
@@ -1052,7 +1066,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1052
1066
  <Controls showZoom showFitView showInteractive />
1053
1067
  </ReactFlow>
1054
1068
  {/* Legend button — top-left overlay on the canvas; opens the modal. */}
1055
- {usedMechanisms.size > 0 && (
1069
+ {showLegend !== false && usedMechanisms.size > 0 && (
1056
1070
  <button
1057
1071
  type="button"
1058
1072
  onClick={() => setLegendOpen(true)}
@@ -1078,6 +1092,36 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1078
1092
  <MapIcon size={13} />
1079
1093
  Legend
1080
1094
  </button>
1095
+ )}
1096
+ {/* Graph title — non-interactive chip centered at the top of the canvas
1097
+ (clear of the top-left legend button and top-right declaration
1098
+ card). Lets graph-only embeds name the subsystem they show. */}
1099
+ {graphTitle && (
1100
+ <div
1101
+ style={{
1102
+ position: 'absolute',
1103
+ top: 10,
1104
+ left: '50%',
1105
+ transform: 'translateX(-50%)',
1106
+ zIndex: 6,
1107
+ maxWidth: '60%',
1108
+ overflow: 'hidden',
1109
+ textOverflow: 'ellipsis',
1110
+ whiteSpace: 'nowrap',
1111
+ padding: '6px 18px',
1112
+ fontSize: theme.fontSizes[3],
1113
+ fontWeight: 600,
1114
+ fontFamily: theme.fonts.heading,
1115
+ color: theme.colors.text,
1116
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1117
+ border: `1px solid ${theme.colors.border}`,
1118
+ borderRadius: 6,
1119
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1120
+ pointerEvents: 'none',
1121
+ }}
1122
+ >
1123
+ {graphTitle}
1124
+ </div>
1081
1125
  )}
1082
1126
  {/* Selected-component declaration — floating card over the canvas
1083
1127
  (top-right, clear of the top-left legend button). The graph never
@@ -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');
@@ -45,6 +45,22 @@ export type SubsystemComponentConstruct =
45
45
  */
46
46
  export type SubsystemComponentRole = 'entry' | 'service';
47
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
+
48
64
  // ---------------------------------------------------------------------------
49
65
  // Declaration tokens — structured source representation
50
66
  // ---------------------------------------------------------------------------
@@ -97,7 +113,10 @@ export interface SubsystemComponent {
97
113
  * anchors to a definition; runtime occurrences (variables, activations,
98
114
  * instances) are NOT constructs — they belong to a future execution-mode
99
115
  * graph whose occurrence nodes reference these definitions. Ontology:
100
- * construct = what it is, role = where it sits, process = where it runs.
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'`).
101
120
  */
102
121
  construct: SubsystemComponentConstruct;
103
122
  /** Source location the component lives in (repo-root-relative path). */
@@ -115,6 +134,17 @@ export interface SubsystemComponent {
115
134
  * inbound, `produces` outbound).
116
135
  */
117
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;
118
148
  /**
119
149
  * Runtime process membership — which deployment unit this node is a
120
150
  * member of (e.g. `trail-viewer/host`, `trail-viewer/renderer`). Nodes
@@ -225,6 +255,7 @@ export function deriveNameFromSymbol(
225
255
  construct: SubsystemComponentConstruct,
226
256
  existingName?: string,
227
257
  file?: string,
258
+ stereotype?: string,
228
259
  ): string {
229
260
  let name: string | undefined;
230
261
  if (symbol && symbol.trim()) {
@@ -236,10 +267,13 @@ export function deriveNameFromSymbol(
236
267
  }
237
268
  if (!name) name = existingName ?? 'untitled';
238
269
 
239
- // Decoration = what the drill-down shows. Executable constructs wear `()`
240
- // (a signature you can call); brace-bodied constructs wear ` {}` (a member
241
- // body fields for types/interfaces/enums, fields+methods for classes).
242
- // 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
+ }
243
277
  if ((construct === 'function' || construct === 'method') && !name.endsWith('()')) {
244
278
  name = `${name}()`;
245
279
  }
@@ -443,6 +477,64 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
443
477
  service: 'service',
444
478
  };
445
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
+
446
538
  /**
447
539
  * Convert a subsystem graph document into React Flow nodes. Components that
448
540
  * carry a `process` get a `parentId` pointing at their boundary group node
@@ -489,9 +581,9 @@ export function convertSubsystemToNodes(
489
581
  .sort((a, b) => b.length - a.length)[0];
490
582
  const cap = maxNodeWidth ?? 300;
491
583
  const textWidth = Math.min(cap, Math.max(60, (text?.length ?? 10) * 8));
492
- // Account for CSS minWidth and padding/border so ELK's port positions match
493
- // the actual rendered node boundaries.
494
- 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);
495
587
  const cssPadding = 20; // horizontal padding (left + right)
496
588
  const cssBorder = 4; // 2px border each side
497
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,
@@ -86,7 +89,10 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
86
89
  const configuredMax = SUBSYSTEM_CALLBACKS.maxNodeWidth;
87
90
  const maxWidth = configuredMax ?? 300;
88
91
  // `symbol` is the source of truth; `name` is derived from it consistently.
89
- 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);
90
96
  // Set while a file is open in the drawer: true → spotlight, false → dim,
91
97
  // absent (no file open) → neutral.
92
98
  const fileMatch = data.fileMatch as boolean | undefined;
@@ -117,7 +123,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
117
123
  boxSizing: 'border-box',
118
124
  width: nodeWidth,
119
125
  height: nodeHeight,
120
- minWidth: 150,
126
+ minWidth: badgeMinWidth,
121
127
  maxWidth,
122
128
  padding: '6px 10px',
123
129
  borderRadius: 8,
@@ -135,20 +141,21 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
135
141
  fontFamily: theme.fonts.body,
136
142
  }}
137
143
  >
138
- {/* Construct badge — a small tab riding the top-right border, in the
139
- construct color. Persistent (no hover needed); pointer-events none so
140
- 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. */}
141
147
  <div
142
148
  style={{
143
149
  position: 'absolute',
144
150
  top: -9,
145
- left: 10,
151
+ left: BADGE_EDGE_INSET,
146
152
  zIndex: 1,
147
153
  fontFamily: theme.fonts.monospace,
148
154
  fontSize: theme.fontSizes[0] * 1.1,
149
155
  letterSpacing: 0.5,
150
156
  textTransform: 'uppercase',
151
157
  lineHeight: '17px',
158
+ whiteSpace: 'nowrap',
152
159
  color,
153
160
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
154
161
  border: `1px solid ${color}`,
@@ -156,7 +163,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
156
163
  padding: '0 5px',
157
164
  }}
158
165
  >
159
- {CONSTRUCT_LABEL[c.construct] ?? c.construct}
166
+ {constructBadgeLabel(c)}
160
167
  </div>
161
168
 
162
169
  {/* Role badge — top-right, only when the node carries a topology role.
@@ -167,13 +174,14 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
167
174
  style={{
168
175
  position: 'absolute',
169
176
  top: -9,
170
- right: 10,
177
+ right: BADGE_EDGE_INSET,
171
178
  zIndex: 1,
172
179
  fontFamily: theme.fonts.monospace,
173
180
  fontSize: theme.fontSizes[0] * 1.1,
174
181
  letterSpacing: 0.5,
175
182
  textTransform: 'uppercase',
176
183
  lineHeight: '17px',
184
+ whiteSpace: 'nowrap',
177
185
  color: ROLE_COLOR[c.role],
178
186
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
179
187
  border: `1px solid ${ROLE_COLOR[c.role]}`,
@@ -242,10 +250,15 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
242
250
  </div>
243
251
 
244
252
  {/* Hide the identity line when the symbol is just the title without its
245
- decoration (`()` or ` {}`) — only show it when it adds information
246
- (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). */}
247
256
  {c.symbol &&
248
- c.symbol !== displayName.replace(/ ?\{\}$/, '').replace(/\(\)$/, '') && (
257
+ c.symbol !==
258
+ displayName
259
+ .replace(/^<(.+)>$/, '$1')
260
+ .replace(/ ?\{\}$/, '')
261
+ .replace(/\(\)$/, '') && (
249
262
  <div
250
263
  style={{
251
264
  fontSize: theme.fontSizes[0] * 0.82,