@principal-ai/subsystems-react 0.37.12 → 0.39.0

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 (74) hide show
  1. package/dist/graphify/consolidated.d.ts +8 -0
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/construct.d.ts +18 -0
  4. package/dist/graphify/construct.d.ts.map +1 -1
  5. package/dist/graphify/construct.js +23 -0
  6. package/dist/graphify/construct.js.map +1 -1
  7. package/dist/graphify/index.d.ts +2 -2
  8. package/dist/graphify/index.d.ts.map +1 -1
  9. package/dist/graphify/index.js +1 -1
  10. package/dist/graphify/index.js.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  16. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +0 -1
  17. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  18. package/dist/subsystem/IssueList.d.ts +49 -3
  19. package/dist/subsystem/IssueList.d.ts.map +1 -1
  20. package/dist/subsystem/IssueList.js +180 -79
  21. package/dist/subsystem/IssueList.js.map +1 -1
  22. package/dist/subsystem/SubsystemComponentGraph.d.ts +23 -2
  23. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  24. package/dist/subsystem/SubsystemComponentGraph.js +492 -23
  25. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  26. package/dist/subsystem/formatDeclaration.js +19 -5
  27. package/dist/subsystem/formatDeclaration.js.map +1 -1
  28. package/dist/subsystem/model.d.ts +161 -7
  29. package/dist/subsystem/model.d.ts.map +1 -1
  30. package/dist/subsystem/model.js +91 -12
  31. package/dist/subsystem/model.js.map +1 -1
  32. package/dist/subsystem/nodes.d.ts.map +1 -1
  33. package/dist/subsystem/nodes.js +133 -72
  34. package/dist/subsystem/nodes.js.map +1 -1
  35. package/dist/subsystem/symbolRefs.d.ts.map +1 -1
  36. package/dist/subsystem/symbolRefs.js +2 -0
  37. package/dist/subsystem/symbolRefs.js.map +1 -1
  38. package/dist/utils/elkLayout.d.ts +9 -0
  39. package/dist/utils/elkLayout.d.ts.map +1 -1
  40. package/dist/utils/elkLayout.js +21 -2
  41. package/dist/utils/elkLayout.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/graphify/__fixtures__/ego-samples.json +545 -0
  44. package/src/graphify/consolidated.ts +8 -0
  45. package/src/graphify/construct.test.ts +61 -1
  46. package/src/graphify/construct.ts +33 -0
  47. package/src/graphify/index.ts +6 -1
  48. package/src/index.ts +13 -1
  49. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +2 -2
  50. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +9 -9
  51. package/src/stories/Subsystem/ComponentGraph/EdgeViews.stories.tsx +4 -4
  52. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +625 -0
  53. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +7 -1
  54. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
  55. package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +5 -5
  56. package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +40 -73
  57. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +6 -6
  58. package/src/stories/Subsystem/ComponentGraph/StoreValueType.stories.tsx +261 -0
  59. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +0 -1
  60. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +354 -0
  61. package/src/stories/Subsystem/StoreValueTypeDeclaration.stories.tsx +182 -0
  62. package/src/subsystem/IssueList.focus.test.tsx +113 -0
  63. package/src/subsystem/IssueList.icon.test.tsx +95 -0
  64. package/src/subsystem/IssueList.tsx +267 -111
  65. package/src/subsystem/SubsystemComponentGraph.tsx +582 -27
  66. package/src/subsystem/formatDeclaration.test.ts +64 -0
  67. package/src/subsystem/formatDeclaration.ts +20 -5
  68. package/src/subsystem/model.test.ts +75 -2
  69. package/src/subsystem/model.ts +247 -17
  70. package/src/subsystem/nodes.group.test.tsx +52 -0
  71. package/src/subsystem/nodes.tsx +112 -8
  72. package/src/subsystem/symbolRefs.ts +2 -0
  73. package/src/subsystem/toC4.test.ts +3 -3
  74. package/src/utils/elkLayout.ts +32 -1
@@ -62,3 +62,55 @@ describe('SubsystemGroupNode frame color', () => {
62
62
  expect(frame.style.border).toContain('#ff6b35');
63
63
  });
64
64
  });
65
+
66
+ describe('SubsystemGroupNode boundary badge', () => {
67
+ // A boundary finding is a property of the region's shape, so it badges the
68
+ // frame with its own icon rather than borrowing the verification-rung chips
69
+ // that leaf constructs wear.
70
+ const moduleRegion = {
71
+ kind: 'module' as const,
72
+ key: 'src/bun/index.ts',
73
+ label: 'src/bun/index.ts',
74
+ memberAliases: ['a', 'b', 'c'],
75
+ };
76
+
77
+ test('a boundary finding badges the frame with its own icon', () => {
78
+ const { container } = renderGroup({
79
+ region: moduleRegion,
80
+ issue: {
81
+ severity: 'info',
82
+ kind: 'boundary_process_nest_disagree',
83
+ count: 1,
84
+ },
85
+ });
86
+ // The chip is aria-hidden and inert; its lucide icon carries the class.
87
+ expect(container.querySelector('.lucide-split')).not.toBeNull();
88
+ });
89
+
90
+ test('a finding with no dedicated frame icon is not badged', () => {
91
+ // `construct_unconfirmed` belongs on a leaf node, not the frame — badging
92
+ // the region with it would imply the region is at fault.
93
+ const { container } = renderGroup({
94
+ region: moduleRegion,
95
+ issue: { severity: 'error', kind: 'construct_unconfirmed', count: 1 },
96
+ });
97
+ expect(container.querySelector('svg')).toBeNull();
98
+ });
99
+
100
+ test('a region with no findings renders no chip', () => {
101
+ const { container } = renderGroup({ region: moduleRegion });
102
+ expect(container.querySelector('svg')).toBeNull();
103
+ });
104
+
105
+ test('the chip shows a count when the region has more than one finding', () => {
106
+ const { container } = renderGroup({
107
+ region: moduleRegion,
108
+ issue: {
109
+ severity: 'error',
110
+ kind: 'boundary_process_nest_disagree',
111
+ count: 3,
112
+ },
113
+ });
114
+ expect(container.textContent).toContain('3');
115
+ });
116
+ });
@@ -17,8 +17,8 @@ import {
17
17
  } from '@xyflow/react';
18
18
  import { useTheme } from '@principal-ade/industry-theme';
19
19
  import {
20
- MECHANISM_COLOR,
21
- MECHANISM_STYLE,
20
+ edgeColor,
21
+ edgeStrokeStyle,
22
22
  PROPOSED_COLOR,
23
23
  constructBadgeColor,
24
24
  constructBadgeLabel,
@@ -36,9 +36,12 @@ import {
36
36
  type SubsystemGraphNodeData,
37
37
  type SubsystemGroupNodeData,
38
38
  type SubsystemGraphEdge,
39
+ type SubsystemNodeIssue,
39
40
  } from './model';
40
41
  import { componentColor } from '../pierre/constructColors';
41
42
  import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
43
+ import { ISSUE_KIND_ICON, ISSUE_RUNG_ICON } from './IssueList';
44
+ import type { LucideIcon } from 'lucide-react';
42
45
 
43
46
  export const CONSTRUCT_LABEL: Record<string, string> = {
44
47
  class: 'class',
@@ -112,6 +115,80 @@ function useSubsystemCallbacks(): SubsystemGraphCallbacks {
112
115
  return useContext(SubsystemCallbacksContext) ?? SUBSYSTEM_CALLBACKS;
113
116
  }
114
117
 
118
+ /**
119
+ * The diagnostics overlay for a component node: the node's own border turns
120
+ * dotted (see the node's `borderStyle`) and an earliest-failing-rung corner
121
+ * chip (file → symbol → declaration → type → signature) carries the severity
122
+ * color, with a count when the node has more than one finding. The chip is
123
+ * purely additive and non-interactive; the border stays construct-colored, so
124
+ * severity never has to compete with the construct palette.
125
+ */
126
+ function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
127
+ const { theme } = useTheme();
128
+ const isError = issue.severity === 'error';
129
+ const color = isError
130
+ ? (theme.colors.error ?? '#e5534b')
131
+ : (theme.colors.warning ?? '#d4a017');
132
+ return (
133
+ <IssueChip
134
+ color={color}
135
+ Icon={ISSUE_RUNG_ICON[issue.rung]}
136
+ count={issue.count}
137
+ anchor={{ right: -9, bottom: -8 }}
138
+ />
139
+ );
140
+ }
141
+
142
+ /**
143
+ * The shared severity chip: a severity-colored ring around the finding's icon,
144
+ * with a count when there is more than one. Component nodes anchor it to the
145
+ * bottom-right corner; a region frame anchors it to the top-right so it never
146
+ * collides with the frame's own name badge. `aria-hidden` and inert — the
147
+ * canvas badge never competes with the sidebar card for the interaction.
148
+ */
149
+ function IssueChip({
150
+ color,
151
+ Icon,
152
+ count,
153
+ anchor,
154
+ }: {
155
+ color: string;
156
+ Icon: LucideIcon;
157
+ count: number;
158
+ anchor: React.CSSProperties;
159
+ }) {
160
+ const { theme } = useTheme();
161
+ const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
162
+ return (
163
+ <div
164
+ aria-hidden
165
+ style={{
166
+ position: 'absolute',
167
+ zIndex: 2,
168
+ display: 'inline-flex',
169
+ alignItems: 'center',
170
+ justifyContent: 'center',
171
+ gap: 4,
172
+ minWidth: 22,
173
+ padding: '3px 5px',
174
+ borderRadius: 5,
175
+ border: `2px solid ${color}`,
176
+ background: badgeBg,
177
+ color,
178
+ fontFamily: theme.fonts.monospace,
179
+ fontSize: theme.fontSizes[1],
180
+ fontWeight: 700,
181
+ lineHeight: 1.1,
182
+ pointerEvents: 'none',
183
+ ...anchor,
184
+ }}
185
+ >
186
+ <Icon size={14} color={color} />
187
+ {count > 1 ? <span>{count}</span> : null}
188
+ </div>
189
+ );
190
+ }
191
+
115
192
  export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeData, 'subsystem-component'>>) {
116
193
  const { theme, mode } = useTheme();
117
194
  const callbacks = useSubsystemCallbacks();
@@ -168,6 +245,12 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
168
245
  const borderW = isSelected || fileMatch ? 4 : 2;
169
246
  const badgeTop = -9 - borderW;
170
247
  const badgeEdge = -borderW;
248
+ // A node with diagnostics turns its border dotted — a "something's off"
249
+ // sibling of `proposed`'s dashed, but distinct so the two don't read alike.
250
+ // The border keeps its construct color; the severity rides on the chip.
251
+ // Verification skips `proposed` nodes, so the two rarely stack — issue wins.
252
+ const hasIssue = data.issue != null;
253
+ const borderStyle = hasIssue ? 'dotted' : c.proposed ? 'dashed' : 'solid';
171
254
 
172
255
  return (
173
256
  <div
@@ -198,8 +281,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
198
281
  borderRadius: nodeRadius,
199
282
  background: hover ? hoverBg : nodeBg,
200
283
  // Selected / file-matched nodes get a thicker border. Proposed nodes
201
- // use a dashed goldenrod border; left construct badge keeps construct color.
202
- border: `${borderW}px ${c.proposed ? 'dashed' : 'solid'} ${borderColor}`,
284
+ // use a dashed goldenrod border, issue nodes a dotted construct-colored
285
+ // one; the left construct badge keeps construct color either way.
286
+ border: `${borderW}px ${borderStyle} ${borderColor}`,
203
287
  boxShadow: fileMatch
204
288
  ? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
205
289
  : '0 1px 4px rgba(0,0,0,0.25)',
@@ -339,6 +423,8 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
339
423
  </div>
340
424
  )}
341
425
 
426
+ {data.issue && <NodeIssueOverlay issue={data.issue} />}
427
+
342
428
  <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
343
429
  <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
344
430
  </div>
@@ -494,6 +580,20 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
494
580
  label
495
581
  )}
496
582
  </div>
583
+ {/* Boundary diagnostics badge — top-right, opposite the name badge, so the
584
+ two never collide however long the module path grows. */}
585
+ {data.issue && ISSUE_KIND_ICON[data.issue.kind] && (
586
+ <IssueChip
587
+ color={
588
+ data.issue.severity === 'error'
589
+ ? (theme.colors.error ?? '#e5534b')
590
+ : (theme.colors.warning ?? '#d4a017')
591
+ }
592
+ Icon={ISSUE_KIND_ICON[data.issue.kind]!}
593
+ count={data.issue.count}
594
+ anchor={{ right: 12, top: 0, transform: 'translateY(-50%)' }}
595
+ />
596
+ )}
497
597
  </div>
498
598
  );
499
599
  }
@@ -560,10 +660,14 @@ export function SubsystemEdge({
560
660
  }: EdgeProps<SubsystemGraphEdge>) {
561
661
  const path = data?.elkPath ?? '';
562
662
  const mechanism = data?.mechanism ?? 'uses';
563
- const color = MECHANISM_COLOR[mechanism] ?? '#888';
564
- // Dash style comes from the mechanism table (dashed = inverted-control or
565
- // observational relationships: hierarchy, registration, watches).
566
- const isDashed = MECHANISM_STYLE[mechanism] === 'dashed';
663
+ // Color + dash resolve from provenance: subsystem mechanisms use the
664
+ // MECHANISM_* tables, graphify-native relations use the separate
665
+ // GRAPHIFY_RELATION_* palette (see edgeColor / edgeStrokeStyle).
666
+ const edgeRef = { mechanism, provenance: data?.provenance };
667
+ const color = edgeColor(edgeRef);
668
+ // Dash style encodes provenance (graphify) and relationship kind (dashed =
669
+ // inverted-control or observational: hierarchy, registration, watches).
670
+ const isDashed = edgeStrokeStyle(edgeRef) === 'dashed';
567
671
  const dimmed = data?.dimmed === true;
568
672
  // Dim the stroke by color, never via path `opacity`. SVG markers are shared
569
673
  // by id; opacity on the referencing path paints every arrowhead that uses
@@ -335,6 +335,8 @@ export function extractDeclarationSymbolRefs(
335
335
  for (const alt of declaration.unionOf ?? []) addType(alt, undefined, 'union');
336
336
  break;
337
337
  case 'store':
338
+ // The store's own value type (`valueTypeRef`), then its members.
339
+ addType(declaration.valueType, declaration.valueTypeRef, 'field');
338
340
  for (const p of declaration.properties ?? []) {
339
341
  addType(p.type, p.typeRef, 'field');
340
342
  }
@@ -20,9 +20,9 @@ const doc: SubsystemModelDocument = {
20
20
  ],
21
21
  relations: [
22
22
  { id: 'r1', from: 'a', to: 'b', relationType: 'method' }, // intra p1
23
- { id: 'r2', from: 'a', to: 'c', relationType: 'references' },
24
- { id: 'r3', from: 'b', to: 'c', relationType: 'references' },
25
- { id: 'r4', from: 'a', to: 'x', relationType: 'references' },
23
+ { id: 'r2', from: 'a', to: 'c', relationType: 'method' },
24
+ { id: 'r3', from: 'b', to: 'c', relationType: 'method' },
25
+ { id: 'r4', from: 'a', to: 'x', relationType: 'method' },
26
26
  ],
27
27
  walkthroughs: [
28
28
  {
@@ -84,6 +84,16 @@ export interface ElkLayoutOptions {
84
84
  */
85
85
  direction?: 'RIGHT' | 'LEFT' | 'DOWN' | 'UP';
86
86
 
87
+ /**
88
+ * Order same-layer nodes by their source line (ascending) instead of leaving
89
+ * it to the crossing-minimizer. Nodes must carry a `data.component.line`
90
+ * (used when set; nodes without one keep ELK's neutral ordering). Off by
91
+ * default — the generic layered layout is tuned for crossings, and line order
92
+ * only makes sense for graphs that model a source region top-to-bottom.
93
+ * @default false
94
+ */
95
+ orderByLine?: boolean;
96
+
87
97
  /**
88
98
  * Compound groups — each becomes an ELK parent whose `memberIds` are laid
89
99
  * out inside it. `memberIds` may be leaf node ids or other group ids
@@ -323,11 +333,15 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
323
333
  interLayerSpacing = 0,
324
334
  edgeLabels,
325
335
  direction = 'RIGHT',
336
+ orderByLine = false,
326
337
  } = options;
327
338
 
328
339
  const baseOptions: LayoutOptions = {
329
340
  'elk.algorithm': 'layered',
330
341
  'elk.direction': direction,
342
+ // Keep same-layer ordering stable (not reversed / randomized) so a
343
+ // line-ordered rewrite or ELK's own ordering is predictable.
344
+ 'elk.layered.crossingMinimization.semiInteractive': 'true',
331
345
  // Spacing
332
346
  'elk.spacing.nodeNode': String(nodeSpacing),
333
347
  'elk.spacing.edgeEdge': String(edgeSpacing),
@@ -346,6 +360,14 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
346
360
  'elk.layered.thoroughness': '50',
347
361
  };
348
362
 
363
+ // Model order (position hints) so a per-node `data.line` can drive same-layer
364
+ // ordering. ELK honours the `y` of each node as a position hint against the
365
+ // model order.
366
+ if (orderByLine) {
367
+ baseOptions['elk.layered.fixedAlignment'] = 'NONE';
368
+ baseOptions['elk.layered.considerModelOrder.strategy'] = 'NODES_AND_EDGES';
369
+ }
370
+
349
371
  // Reserve space for inline edge labels so they don't overlap nodes/edges.
350
372
  if (edgeLabels?.enabled !== false) {
351
373
  const placement = edgeLabels?.placement ?? 'CENTER';
@@ -507,6 +529,7 @@ export async function computeElkLayout(
507
529
  const edgeLabels = options.edgeLabels;
508
530
  const endpointInset = options.endpointInset ?? 0;
509
531
  const direction = options.direction ?? 'RIGHT';
532
+ const orderByLine = options.orderByLine ?? false;
510
533
 
511
534
  // Build a map of original node positions BEFORE passing to ELK
512
535
  // (ELK mutates the input nodes in place, so we must save positions first)
@@ -527,12 +550,20 @@ export async function computeElkLayout(
527
550
  const nd = node.data as { component?: { layer?: number }; layer?: number } | undefined;
528
551
  layer = nd?.layer ?? nd?.component?.layer;
529
552
 
553
+ // Optional source-line ordering: seed each node's `y` hint from its line so
554
+ // ELK's model-order placement puts lower lines further down within a layer.
555
+ let y = node.position.y;
556
+ if (orderByLine) {
557
+ const line = (node.data as { component?: { line?: number } } | undefined)?.component?.line;
558
+ if (typeof line === 'number') y = line;
559
+ }
560
+
530
561
  return {
531
562
  id: node.id,
532
563
  width,
533
564
  height,
534
565
  x: node.position.x,
535
- y: node.position.y,
566
+ y,
536
567
  // Add ports on each side for edge connections
537
568
  ports: [
538
569
  { id: `${node.id}_top`, properties: { 'port.side': 'NORTH' } },