@principal-ai/subsystems-react 0.51.1 → 0.51.3

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 (79) hide show
  1. package/dist/index.d.ts +5 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/C4Graph.d.ts +12 -1
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +82 -14
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +29 -14
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +71 -57
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/IssueList.d.ts.map +1 -1
  14. package/dist/subsystem/IssueList.js +11 -2
  15. package/dist/subsystem/IssueList.js.map +1 -1
  16. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.js +66 -3
  18. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  19. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  20. package/dist/subsystem/TrailsPanel.js +8 -4
  21. package/dist/subsystem/TrailsPanel.js.map +1 -1
  22. package/dist/subsystem/c4.d.ts +31 -92
  23. package/dist/subsystem/c4.d.ts.map +1 -1
  24. package/dist/subsystem/c4.js +6 -285
  25. package/dist/subsystem/c4.js.map +1 -1
  26. package/dist/subsystem/c4Evidence.d.ts +1 -1
  27. package/dist/subsystem/c4Evidence.js +2 -2
  28. package/dist/subsystem/c4Evidence.js.map +1 -1
  29. package/dist/subsystem/model.d.ts +8 -0
  30. package/dist/subsystem/model.d.ts.map +1 -1
  31. package/dist/subsystem/model.js.map +1 -1
  32. package/dist/subsystem/techIcons/bun.d.ts +11 -0
  33. package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
  34. package/dist/subsystem/techIcons/bun.js +11 -0
  35. package/dist/subsystem/techIcons/bun.js.map +1 -0
  36. package/dist/subsystem/techIcons/index.d.ts +36 -0
  37. package/dist/subsystem/techIcons/index.d.ts.map +1 -0
  38. package/dist/subsystem/techIcons/index.js +55 -0
  39. package/dist/subsystem/techIcons/index.js.map +1 -0
  40. package/dist/subsystem/techIcons/node.d.ts +12 -0
  41. package/dist/subsystem/techIcons/node.d.ts.map +1 -0
  42. package/dist/subsystem/techIcons/node.js +39 -0
  43. package/dist/subsystem/techIcons/node.js.map +1 -0
  44. package/dist/subsystem/techIcons/react.d.ts +12 -0
  45. package/dist/subsystem/techIcons/react.d.ts.map +1 -0
  46. package/dist/subsystem/techIcons/react.js +19 -0
  47. package/dist/subsystem/techIcons/react.js.map +1 -0
  48. package/dist/utils/elkLayout.d.ts.map +1 -1
  49. package/dist/utils/elkLayout.js +16 -5
  50. package/dist/utils/elkLayout.js.map +1 -1
  51. package/package.json +3 -3
  52. package/src/index.ts +4 -6
  53. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
  54. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +21 -8
  55. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
  56. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +168 -2
  57. package/src/subsystem/C4Graph.tsx +127 -12
  58. package/src/subsystem/C4NodeCard.test.ts +86 -55
  59. package/src/subsystem/C4NodeCard.tsx +92 -71
  60. package/src/subsystem/IssueList.tsx +12 -1
  61. package/src/subsystem/SubsystemComponentGraph.tsx +79 -2
  62. package/src/subsystem/TrailsPanel.tsx +10 -5
  63. package/src/subsystem/c4.ts +30 -363
  64. package/src/subsystem/c4Evidence.test.ts +5 -5
  65. package/src/subsystem/c4Evidence.ts +2 -2
  66. package/src/subsystem/model.ts +8 -0
  67. package/src/subsystem/techIcons/bun.ts +10 -0
  68. package/src/subsystem/techIcons/index.tsx +73 -0
  69. package/src/subsystem/techIcons/node.ts +38 -0
  70. package/src/subsystem/techIcons/react.ts +18 -0
  71. package/src/subsystem/techIcons/techIcons.test.ts +39 -0
  72. package/src/utils/elkLayout.ts +16 -5
  73. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
  74. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
  75. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
  76. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
  77. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
  78. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
  79. package/src/subsystem/c4.test.ts +0 -573
@@ -61,8 +61,34 @@ const ISSUES: SubsystemIssue[] = [
61
61
  message: 'Step 2 references a line that moved (was :42).',
62
62
  target: {
63
63
  kind: 'step',
64
- id: 'flow-1',
65
- label: 'Checkout',
64
+ id: 'story-steps',
65
+ label: 'Story steps',
66
+ detail: 'step 2',
67
+ stepIndex: 1,
68
+ },
69
+ },
70
+ {
71
+ id: 'i20',
72
+ severity: 'info',
73
+ kind: 'step_unconfirmed',
74
+ message: 'Trail "Story steps" step 1: call site not verified (entry → store via writes)',
75
+ target: {
76
+ kind: 'step',
77
+ id: 'story-steps',
78
+ label: 'Story steps',
79
+ detail: 'step 1',
80
+ stepIndex: 0,
81
+ },
82
+ },
83
+ {
84
+ id: 'i21',
85
+ severity: 'info',
86
+ kind: 'step_stale',
87
+ message: 'Trail "Story steps" step 2: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
88
+ target: {
89
+ kind: 'step',
90
+ id: 'story-steps',
91
+ label: 'Story steps',
66
92
  detail: 'step 2',
67
93
  stepIndex: 1,
68
94
  },
@@ -181,3 +207,143 @@ export const GraphWithIssues: Story = {
181
207
  </div>
182
208
  ),
183
209
  };
210
+
211
+ /** Trail step verification findings — unconfirmed and stale call sites. */
212
+ const TRAIL_STEP_ISSUES: SubsystemIssue[] = [
213
+ {
214
+ id: 'ts1',
215
+ severity: 'info',
216
+ kind: 'step_unconfirmed',
217
+ message: 'Trail "Add to Cart" step 1: call site not verified (addToCart → cartStore via writes)',
218
+ target: {
219
+ kind: 'step',
220
+ id: 'add-to-cart',
221
+ label: 'Add to Cart',
222
+ detail: 'step 1',
223
+ stepIndex: 0,
224
+ },
225
+ },
226
+ {
227
+ id: 'ts2',
228
+ severity: 'info',
229
+ kind: 'step_unconfirmed',
230
+ message: 'Trail "Add to Cart" step 2: call site not verified (cartStore → inventoryService via calls)',
231
+ target: {
232
+ kind: 'step',
233
+ id: 'add-to-cart',
234
+ label: 'Add to Cart',
235
+ detail: 'step 2',
236
+ stepIndex: 1,
237
+ },
238
+ },
239
+ {
240
+ id: 'ts3',
241
+ severity: 'info',
242
+ kind: 'step_stale',
243
+ message: 'Trail "Checkout" step 1: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
244
+ target: {
245
+ kind: 'step',
246
+ id: 'checkout',
247
+ label: 'Checkout',
248
+ detail: 'step 1',
249
+ stepIndex: 0,
250
+ },
251
+ },
252
+ {
253
+ id: 'ts4',
254
+ severity: 'info',
255
+ kind: 'step_stale',
256
+ message: 'Trail "Checkout" step 3: call site changed since verification (lines 78-82 in src/checkout/api.ts)',
257
+ target: {
258
+ kind: 'step',
259
+ id: 'checkout',
260
+ label: 'Checkout',
261
+ detail: 'step 3',
262
+ stepIndex: 2,
263
+ },
264
+ },
265
+ ];
266
+
267
+ /** Trail step verification: shows unconfirmed and stale call site findings. */
268
+ export const TrailStepVerification: Story = {
269
+ render: () => (
270
+ <div style={{ display: 'flex', flexDirection: 'column', width: 380, height: '100vh', borderRight: '1px solid #333' }}>
271
+ <SubsystemIssueList
272
+ issues={TRAIL_STEP_ISSUES}
273
+ focusCategory="trail"
274
+ onSelectIssue={() => {}}
275
+ />
276
+ </div>
277
+ ),
278
+ };
279
+
280
+ /** Trail step verification badges on graph edges. */
281
+ const stepVerificationComponents = components([
282
+ ['api', 'checkoutApi', 'function', 'src/checkout/api.ts', 'pkg:github/you/your-app', 'Handles cart requests.', 'checkoutApi'],
283
+ ['store', 'cartStore', 'store', 'src/checkout/cartStore.ts', 'pkg:github/you/your-app', 'Retained cart state.', 'cartStore'],
284
+ ['payment', 'PaymentService', 'class', 'src/payment/service.ts', 'pkg:github/you/your-app', 'Processes payments.', 'PaymentService'],
285
+ ]);
286
+
287
+ const stepVerificationTrails = [
288
+ {
289
+ id: 'checkout-flow',
290
+ title: 'Checkout',
291
+ steps: [
292
+ { from: 'api', to: 'store', mechanism: 'writes' as const },
293
+ { from: 'store', to: 'payment', mechanism: 'calls' as const },
294
+ ],
295
+ },
296
+ ];
297
+
298
+ const stepVerificationIssues: SubsystemIssue[] = [
299
+ {
300
+ id: 'sv1',
301
+ severity: 'info',
302
+ kind: 'step_unconfirmed',
303
+ message: 'Trail "Checkout" step 1: call site not verified (checkoutApi → cartStore via writes)',
304
+ target: {
305
+ kind: 'step',
306
+ id: 'checkout-flow',
307
+ label: 'Checkout',
308
+ detail: 'step 1',
309
+ stepIndex: 0,
310
+ },
311
+ },
312
+ {
313
+ id: 'sv2',
314
+ severity: 'info',
315
+ kind: 'step_stale',
316
+ message: 'Trail "Checkout" step 2: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
317
+ target: {
318
+ kind: 'step',
319
+ id: 'checkout-flow',
320
+ label: 'Checkout',
321
+ detail: 'step 2',
322
+ stepIndex: 1,
323
+ },
324
+ },
325
+ ];
326
+
327
+ /** Graph with trail step verification badges on edges — ? for unconfirmed, clock for stale. */
328
+ export const GraphWithStepVerificationBadges: Story = {
329
+ render: () => (
330
+ <div style={{ width: '100%', height: '100vh' }}>
331
+ <SubsystemComponentGraph
332
+ components={stepVerificationComponents}
333
+ trails={stepVerificationTrails}
334
+ initialTrailId="checkout-flow"
335
+ title="Checkout with Step Verification"
336
+ description="Shows edge badges for step verification status: question mark (?) for unconfirmed call sites, clock for stale call sites."
337
+ diagnostic={{
338
+ status: 'issues',
339
+ issueCount: stepVerificationIssues.length,
340
+ onToggle: () => {},
341
+ }}
342
+ issues={stepVerificationIssues}
343
+ showIssues
344
+ onSelectIssue={() => {}}
345
+ onApplyIssueFix={() => {}}
346
+ />
347
+ </div>
348
+ ),
349
+ };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * C4Graph — a C4 projection of a (composed) subsystem model on React Flow.
3
3
  *
4
- * Feed it a `C4Model` from `buildC4Model()` and it draws the
4
+ * Feed it an authored `C4Model` and it draws the
5
5
  * system as a compound frame, its containers (or components) as boxes, and
6
6
  * externals/actors outside the system. `trail` steps render as flow
7
7
  * edges. Click a box to list the source components it rolled up.
@@ -22,9 +22,11 @@ import {
22
22
  ReactFlow,
23
23
  ReactFlowProvider,
24
24
  useReactFlow,
25
+ useViewport,
25
26
  } from '@xyflow/react';
26
27
  import { useTheme } from '@principal-ade/industry-theme';
27
28
  import { computeElkLayout } from '../utils/elkLayout';
29
+ import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, EDGE_LABEL_WIDTH } from '../utils/edgeLabel';
28
30
  import { MECHANISM_COLOR } from './model';
29
31
  import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
30
32
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
@@ -34,6 +36,17 @@ export interface C4GraphProps {
34
36
  model: C4Model;
35
37
  title?: string;
36
38
  onSelectNode?: (id: string | null) => void;
39
+ /**
40
+ * Extra width / height reserved around each card when laying out, in flow px.
41
+ * The visible gap between boxes is `2 × gutter` (ELK's `nodeNode` spacing,
42
+ * plus the frame padding where a frame adds one).
43
+ *
44
+ * Above the default 28 the `needs …` gap row was removed, so cards are
45
+ * shorter and packing them tighter than the spacing they were tuned at makes
46
+ * them collide. Raise it for a more airy graph.
47
+ * @default 28
48
+ */
49
+ gutter?: number;
37
50
  }
38
51
 
39
52
  function edgeColor(mechanism: string | undefined, fallback: string): string {
@@ -57,6 +70,98 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
57
70
  );
58
71
  }
59
72
 
73
+ /**
74
+ * Edge-label overlay.
75
+ *
76
+ * Drawn as an HTML layer above the canvas rather than React Flow's built-in
77
+ * edge label, so a C4 line's label reads like the subsystem graph's: an opaque
78
+ * rounded chip in the mechanism colour, monospace text, scaling with zoom.
79
+ *
80
+ * Positions come from ELK's computed label anchors (`edgeLabelPositions`, flow
81
+ * coords); the current viewport converts them to screen space.
82
+ */
83
+ function C4EdgeLabels({
84
+ positions,
85
+ edges,
86
+ muted,
87
+ }: {
88
+ positions: Map<string, { x: number; y: number }>;
89
+ edges: C4Model['edges'];
90
+ muted: string;
91
+ }) {
92
+ const { theme } = useTheme();
93
+ const viewport = useViewport();
94
+ const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
95
+ const byId = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
96
+
97
+ return (
98
+ <div
99
+ style={{
100
+ position: 'absolute',
101
+ inset: 0,
102
+ overflow: 'hidden',
103
+ pointerEvents: 'none',
104
+ zIndex: 5,
105
+ }}
106
+ >
107
+ {[...positions].map(([id, p]) => {
108
+ const edge = byId.get(id);
109
+ if (!edge) return null;
110
+ const color = edgeColor(edge.mechanisms[0], muted);
111
+ // C4 asks the line to name the interaction's protocol; fall back to the
112
+ // label when a caller supplied none.
113
+ const text = edge.protocol ?? edge.label;
114
+ return (
115
+ <div
116
+ key={id}
117
+ style={{
118
+ position: 'absolute',
119
+ left: p.x * viewport.zoom + viewport.x,
120
+ top: p.y * viewport.zoom + viewport.y,
121
+ transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
122
+ transformOrigin: 'center center',
123
+ display: 'flex',
124
+ alignItems: 'center',
125
+ justifyContent: 'center',
126
+ width: EDGE_LABEL_WIDTH,
127
+ height: EDGE_LABEL_HEIGHT,
128
+ boxSizing: 'border-box',
129
+ padding: '7px 8px',
130
+ }}
131
+ >
132
+ <span
133
+ aria-hidden
134
+ style={{
135
+ position: 'absolute',
136
+ inset: 0,
137
+ borderRadius: 4,
138
+ border: `0.5px solid ${color}`,
139
+ background: surface,
140
+ }}
141
+ />
142
+ <span
143
+ style={{
144
+ position: 'relative',
145
+ maxWidth: '100%',
146
+ overflow: 'hidden',
147
+ textOverflow: 'ellipsis',
148
+ whiteSpace: 'nowrap',
149
+ fontSize: EDGE_LABEL_FONT_SIZE,
150
+ lineHeight: 1,
151
+ fontFamily: theme.fonts.monospace,
152
+ fontWeight: 500,
153
+ color,
154
+ }}
155
+ >
156
+ {text}
157
+ </span>
158
+ </div>
159
+ );
160
+ })}
161
+ </div>
162
+ );
163
+ }
164
+
60
165
  function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
61
166
  const { theme } = useTheme();
62
167
  const color = props.data.color;
@@ -106,7 +211,7 @@ const nodeTypes = {
106
211
  'c4-group': C4GroupView,
107
212
  };
108
213
 
109
- function Inner({ model, title, onSelectNode }: C4GraphProps) {
214
+ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
110
215
  const { theme } = useTheme();
111
216
  const { fitView } = useReactFlow();
112
217
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -114,6 +219,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
114
219
  const [rfEdges, setRfEdges] = useState<Edge[]>([]);
115
220
  const [ready, setReady] = useState(false);
116
221
  const [selectedId, setSelectedId] = useState<string | null>(null);
222
+ const [labelPositions, setLabelPositions] = useState<Map<string, { x: number; y: number }>>(
223
+ () => new Map(),
224
+ );
117
225
 
118
226
  const layoutKey = useMemo(
119
227
  () =>
@@ -130,10 +238,11 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
130
238
  setReady(false);
131
239
  setSelectedId(null);
132
240
 
133
- // Per-node size, so a square component is laid out as a square. Feeding the
134
- // container dimensions for everything would make ELK reserve 210x138 of
135
- // space around a 132x132 box and space the component view twice as far
136
- // apart as it needs.
241
+ // Per-kind size, so a square component is laid out as a square. Feeding the
242
+ // container dimensions for everything would make ELK reserve 250x150 of
243
+ // space around a 140x140 box and space the component view twice as far
244
+ // apart as it needs. `gutter` is added here, not passed as node spacing, so
245
+ // a frame's padding clears the card inside it by the same margin.
137
246
  const rfNodes: Node[] = model.nodes.map((n, i) => {
138
247
  const size = nodeSize(n);
139
248
  return {
@@ -142,8 +251,8 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
142
251
  // Fallback grid pitch uses the container box — the largest, so the
143
252
  // pre-layout positions never overlap. ELK replaces them on success.
144
253
  position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
145
- width: size.width,
146
- height: size.height,
254
+ width: size.width + gutter,
255
+ height: size.height + gutter,
147
256
  ...(n.parentId ? { parentId: n.parentId } : {}),
148
257
  data: { node: n, selected: false },
149
258
  };
@@ -162,16 +271,17 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
162
271
  id: e.id,
163
272
  source: e.source,
164
273
  target: e.target,
165
- label: e.label,
166
- labelShowBg: false,
167
- labelStyle: { fill: muted, fontSize: theme.fontSizes[0], fontFamily: theme.fonts.monospace },
274
+ // The reserved label box and the chip must agree on size, so ELK is
275
+ // given the same text the chip draws (protocol first, then label). The
276
+ // chip itself is the C4EdgeLabels overlay, not this field.
277
+ label: e.protocol ?? e.label,
168
278
  style: {
169
279
  stroke: color,
170
280
  strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
171
281
  strokeDasharray: '6 4',
172
282
  },
173
283
  markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
174
- data: { edge: e },
284
+ data: { edge: e, color },
175
285
  };
176
286
  });
177
287
 
@@ -180,10 +290,12 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
180
290
  preserveNodePositions: false,
181
291
  groups,
182
292
  keepSingletonGroups: true,
293
+ edgeLabels: { enabled: true, placement: 'CENTER' },
183
294
  })
184
295
  .then((result) => {
185
296
  if (!alive) return;
186
297
  const positioned = new Map(result.nodes.map((n) => [n.id, n]));
298
+ setLabelPositions(result.edgeLabelPositions ?? new Map());
187
299
  const builtGroups = new Set(result.groupBounds.keys());
188
300
  const groupById = new Map(model.groups.map((g) => [g.id, g]));
189
301
 
@@ -293,6 +405,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
293
405
  >
294
406
  <GraphChrome />
295
407
  </ReactFlow>
408
+ {ready && labelPositions.size > 0 && (
409
+ <C4EdgeLabels positions={labelPositions} edges={model.edges} muted={muted} />
410
+ )}
296
411
  {!ready && (
297
412
  <div
298
413
  style={{
@@ -3,6 +3,8 @@ import {
3
3
  nodeStyle,
4
4
  nodeSubtitle,
5
5
  nodeTag,
6
+ nodeKindIcon,
7
+ nodeTopLabel,
6
8
  nodeShape,
7
9
  nodeMissing,
8
10
  NODE_W,
@@ -111,6 +113,47 @@ describe('nodeTag — what the box IS', () => {
111
113
  });
112
114
  });
113
115
 
116
+ describe('nodeKindIcon — a container’s sort, not its C4 kind', () => {
117
+ test('application and data-store get different icons', () => {
118
+ const app = nodeKindIcon(node({ containerKind: 'application' }));
119
+ const store = nodeKindIcon(node({ containerKind: 'data-store' }));
120
+ expect(app).toBeDefined();
121
+ expect(store).toBeDefined();
122
+ expect(app).not.toBe(store);
123
+ });
124
+
125
+ test('only containers get an icon — the shape carries the rest', () => {
126
+ for (const kind of ['component', 'external-system', 'person'] as const) {
127
+ expect(nodeKindIcon(node({ kind }))).toBeUndefined();
128
+ }
129
+ });
130
+
131
+ test('the icon does not depend on confirmation state', () => {
132
+ expect(nodeKindIcon(node({ state: 'proposed' }))).toBe(
133
+ nodeKindIcon(node({ state: 'accepted' })),
134
+ );
135
+ });
136
+ });
137
+
138
+ describe('nodeTopLabel — the technology, or the kind when none is stated', () => {
139
+ test('a stated technology is the label', () => {
140
+ expect(
141
+ nodeTopLabel(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
142
+ ).toBe('Bun + Electrobun');
143
+ expect(nodeTopLabel(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
144
+ });
145
+
146
+ test('a container with no technology falls back to its kind', () => {
147
+ expect(nodeTopLabel(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
148
+ });
149
+
150
+ test('the other kinds fall back to their C4 kind', () => {
151
+ expect(nodeTopLabel(node({ kind: 'component' }))).toBe('component');
152
+ expect(nodeTopLabel(node({ kind: 'external-system' }))).toBe('external-system');
153
+ expect(nodeTopLabel(node({ kind: 'person' }))).toBe('person');
154
+ });
155
+ });
156
+
114
157
  describe('nodeShape — kind without relying on colour or dash', () => {
115
158
  test('a container is a sharp rectangle', () => {
116
159
  const s = nodeShape(node());
@@ -142,47 +185,28 @@ describe('nodeShape — kind without relying on colour or dash', () => {
142
185
  });
143
186
 
144
187
  describe('nodeSubtitle', () => {
145
- test('shows the container kind and technology', () => {
188
+ test('is the element description when it has one', () => {
146
189
  expect(
147
- nodeSubtitle(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
148
- ).toBe('application · Bun + Electrobun');
149
- });
150
-
151
- test('shows a data store with no technology as just the kind', () => {
152
- expect(nodeSubtitle(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
153
- });
154
-
155
- test('falls back to the construct breakdown when neither is stated', () => {
156
- const s = nodeSubtitle(node({ constructs: ['function', 'store'], members: ['a', 'b'] }));
157
- expect(s).toContain('function');
158
- expect(s).toContain('store');
159
- expect(s).toContain('2 components');
160
- });
161
-
162
- test('singularises a single member', () => {
163
- const s = nodeSubtitle(node({ constructs: ['function'], members: ['a'] }));
164
- expect(s).toContain('1 component');
165
- expect(s).not.toContain('1 components');
166
- });
167
-
168
- test('a component shows its construct and file basename', () => {
169
- const s = nodeSubtitle(
170
- node({
171
- kind: 'component',
172
- component: {
173
- alias: 'a',
174
- name: 'buildGroups',
175
- construct: 'function',
176
- file: 'src/subsystem/model.ts',
177
- purl: 'pkg:x#src/subsystem/model.ts',
178
- },
179
- }),
180
- );
181
- expect(s).toBe('function · model.ts');
182
- });
183
-
184
- test('a component shows its technology when it has one', () => {
185
- expect(nodeSubtitle(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
190
+ nodeSubtitle(node({ technology: 'Bun', description: 'Runs the audit pipeline.' })),
191
+ ).toBe('Runs the audit pipeline.');
192
+ });
193
+
194
+ test('is empty when nothing is stated — there is no fallback', () => {
195
+ const withTech = node({ technology: 'Bun + Electrobun' });
196
+ const withMembers = node({ constructs: ['function', 'store'], members: ['a', 'b'] });
197
+ const component = node({
198
+ kind: 'component',
199
+ component: {
200
+ alias: 'a',
201
+ name: 'buildGroups',
202
+ construct: 'function',
203
+ file: 'src/subsystem/model.ts',
204
+ purl: 'pkg:x#src/subsystem/model.ts',
205
+ },
206
+ });
207
+ for (const n of [withTech, withMembers, component]) {
208
+ expect(nodeSubtitle(n)).toBe('');
209
+ }
186
210
  });
187
211
  });
188
212
 
@@ -248,8 +272,8 @@ describe('nodeSize — a component is a proper square', () => {
248
272
 
249
273
  describe('card metrics', () => {
250
274
  test('exposes the size the graph lays out against', () => {
251
- expect(NODE_W).toBe(210);
252
- expect(NODE_H).toBe(138);
275
+ expect(NODE_W).toBe(250);
276
+ expect(NODE_H).toBe(150);
253
277
  });
254
278
 
255
279
  test('COMPONENT_SIZE is a square, not just a smaller rectangle', () => {
@@ -258,23 +282,23 @@ describe('card metrics', () => {
258
282
  });
259
283
 
260
284
  test('is near-square, not a wide strip', () => {
261
- // The card carries four fields (tag, label, kind+technology, notation
262
- // gap). A 3:1 strip truncates the last two; this is the aspect ratio that
263
- // lets each sit on its own line.
285
+ // The card carries three fields (technology row, label, description). A
286
+ // 3:1 strip wraps the last two hard; this is the aspect ratio that lets
287
+ // each sit on its own line.
264
288
  const ratio = NODE_W / NODE_H;
265
289
  expect(ratio).toBeGreaterThan(1);
266
290
  expect(ratio).toBeLessThan(1.8);
267
291
  });
268
292
 
269
- test('is tall enough for four lines of content', () => {
270
- // tag + 2-line label + subtitle + gap marker, plus 8px padding top/bottom.
293
+ test('is tall enough for three lines of content', () => {
294
+ // technology row + 2-line label + description, plus padding.
271
295
  expect(NODE_H).toBeGreaterThanOrEqual(130);
272
296
  });
273
297
 
274
- test('leaves breathing room around four lines of text', () => {
298
+ test('leaves breathing room around three lines of text', () => {
275
299
  // Top-aligned now, not centred, so this is about slack rather than
276
- // balance: tag + 2-line label slot + subtitle + gap slot + padding.
277
- const contentBudget = 16 + 40 + 16 + 18 + 20; // lines + 10px padding ×2
300
+ // balance: technology row + 2-line label slot + description + padding.
301
+ const contentBudget = 16 + 40 + 16 + 20; // lines + 10px padding ×2
278
302
  expect(NODE_H - contentBudget).toBeGreaterThanOrEqual(12);
279
303
  });
280
304
 
@@ -286,16 +310,23 @@ describe('card metrics', () => {
286
310
  });
287
311
 
288
312
  describe('container kinds', () => {
289
- test('every container kind renders as a subtitle without special-casing', () => {
290
- const kinds = ['application', 'data-store', 'queue'] as const;
291
- for (const containerKind of kinds) {
292
- expect(nodeSubtitle(node({ containerKind, technology: '' }))).toBe(containerKind);
313
+ test('each container kind has an icon and a fallback label', () => {
314
+ for (const containerKind of ['application', 'data-store'] as const) {
315
+ expect(nodeKindIcon(node({ containerKind }))).toBeDefined();
316
+ // No technology stated, so the label falls back to the kind.
317
+ expect(nodeTopLabel(node({ containerKind, technology: '' }))).toBe(containerKind);
293
318
  }
294
319
  });
295
320
 
321
+ test('application and data-store do not share an icon', () => {
322
+ expect(nodeKindIcon(node({ containerKind: 'application' }))).not.toBe(
323
+ nodeKindIcon(node({ containerKind: 'data-store' })),
324
+ );
325
+ });
326
+
296
327
  test('a library is not one of them', () => {
297
328
  // C4 says a module "typically" is not an element at all, so there is no
298
329
  // kind to render. A library-shaped group is a review comment.
299
- expect(['application', 'data-store', 'queue']).not.toContain('library');
330
+ expect(['application', 'data-store']).not.toContain('library');
300
331
  });
301
332
  });