@principal-ai/subsystems-react 0.50.0 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/index.d.ts +8 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +7 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts +1 -1
  6. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.js +44 -21
  8. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  9. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +493 -0
  10. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +1 -0
  11. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +329 -0
  12. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +1 -0
  13. package/dist/subsystem/C4Graph.d.ts +2 -2
  14. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  15. package/dist/subsystem/C4Graph.js +38 -73
  16. package/dist/subsystem/C4Graph.js.map +1 -1
  17. package/dist/subsystem/C4NodeCard.d.ts +119 -0
  18. package/dist/subsystem/C4NodeCard.d.ts.map +1 -0
  19. package/dist/subsystem/C4NodeCard.js +223 -0
  20. package/dist/subsystem/C4NodeCard.js.map +1 -0
  21. package/dist/subsystem/DiagnosticToggle.d.ts +8 -1
  22. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  23. package/dist/subsystem/DiagnosticToggle.js +40 -6
  24. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  25. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  26. package/dist/subsystem/FileDrawer.js +20 -21
  27. package/dist/subsystem/FileDrawer.js.map +1 -1
  28. package/dist/subsystem/IssueList.js +1 -1
  29. package/dist/subsystem/IssueList.js.map +1 -1
  30. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.js +13 -4
  32. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  33. package/dist/subsystem/c4.d.ts +272 -0
  34. package/dist/subsystem/c4.d.ts.map +1 -0
  35. package/dist/subsystem/c4.js +322 -0
  36. package/dist/subsystem/c4.js.map +1 -0
  37. package/dist/subsystem/c4Evidence.d.ts +143 -0
  38. package/dist/subsystem/c4Evidence.d.ts.map +1 -0
  39. package/dist/subsystem/c4Evidence.js +240 -0
  40. package/dist/subsystem/c4Evidence.js.map +1 -0
  41. package/dist/subsystem/model.d.ts +27 -7
  42. package/dist/subsystem/model.d.ts.map +1 -1
  43. package/dist/subsystem/model.js +15 -0
  44. package/dist/subsystem/model.js.map +1 -1
  45. package/dist/subsystem/nodes.d.ts.map +1 -1
  46. package/dist/subsystem/nodes.js +78 -7
  47. package/dist/subsystem/nodes.js.map +1 -1
  48. package/package.json +3 -2
  49. package/src/index.ts +37 -0
  50. package/src/pierre/PierreTrailCodeView.tsx +49 -21
  51. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +413 -0
  52. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
  53. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
  54. package/src/stories/Subsystem/ComponentGraph/Deprecated.stories.tsx +358 -0
  55. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +27 -2
  56. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
  57. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +1 -1
  58. package/src/stories/Subsystem/ComponentGraph/{Flows.stories.tsx → Trails.stories.tsx} +4 -4
  59. package/src/subsystem/C4Graph.tsx +81 -107
  60. package/src/subsystem/C4NodeCard.test.ts +301 -0
  61. package/src/subsystem/C4NodeCard.tsx +301 -0
  62. package/src/subsystem/DiagnosticToggle.tsx +58 -4
  63. package/src/subsystem/FileDrawer.tsx +32 -34
  64. package/src/subsystem/IssueList.tsx +1 -1
  65. package/src/subsystem/SubsystemComponentGraph.tsx +13 -5
  66. package/src/subsystem/c4.test.ts +573 -0
  67. package/src/subsystem/c4.ts +558 -0
  68. package/src/subsystem/c4Evidence.test.ts +386 -0
  69. package/src/subsystem/c4Evidence.ts +329 -0
  70. package/src/subsystem/model.ts +36 -7
  71. package/src/subsystem/nodes.tsx +124 -5
  72. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -3
  73. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
  74. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -2
  75. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
  76. package/dist/subsystem/toC4.d.ts +0 -95
  77. package/dist/subsystem/toC4.d.ts.map +0 -1
  78. package/dist/subsystem/toC4.js +0 -192
  79. package/dist/subsystem/toC4.js.map +0 -1
  80. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
  81. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -6
  82. package/src/subsystem/toC4.test.ts +0 -114
  83. package/src/subsystem/toC4.ts +0 -273
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * C4Graph — a C4 projection of a (composed) subsystem model on React Flow.
3
3
  *
4
- * Prototype test case: feed it a `C4Model` from `toC4()` and it draws the
4
+ * Feed it a `C4Model` from `buildC4Model()` 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.
@@ -25,9 +25,10 @@ import {
25
25
  } from '@xyflow/react';
26
26
  import { useTheme } from '@principal-ade/industry-theme';
27
27
  import { computeElkLayout } from '../utils/elkLayout';
28
- import { describeConstructBreakdown, MECHANISM_COLOR } from './model';
28
+ import { MECHANISM_COLOR } from './model';
29
+ import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
29
30
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
30
- import type { C4Model, C4Node } from './toC4';
31
+ import type { C4Model, C4Node } from './c4';
31
32
 
32
33
  export interface C4GraphProps {
33
34
  model: C4Model;
@@ -35,104 +36,24 @@ export interface C4GraphProps {
35
36
  onSelectNode?: (id: string | null) => void;
36
37
  }
37
38
 
38
- const NODE_W = 230;
39
- const NODE_H = 80;
40
-
41
- function baseName(path: string): string {
42
- const parts = path.split('/').filter(Boolean);
43
- return parts[parts.length - 1] ?? path;
44
- }
45
-
46
39
  function edgeColor(mechanism: string | undefined, fallback: string): string {
47
40
  if (!mechanism) return fallback;
48
41
  return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
49
42
  }
50
43
 
51
44
  function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>) {
52
- const { theme } = useTheme();
53
45
  const { node, selected } = props.data;
54
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
55
-
56
- const kindColor =
57
- node.kind === 'external'
58
- ? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
59
- : node.kind === 'actor'
60
- ? (theme.colors.accent ?? theme.colors.info)
61
- : (theme.colors.border ?? '#333');
62
-
63
- const tag = node.kind === 'container' ? 'container' : node.kind === 'external' ? 'external system' : node.kind === 'actor' ? 'actor' : 'component';
64
-
65
- const subtitle =
66
- node.kind === 'component'
67
- ? `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`
68
- : `${describeConstructBreakdown(node.constructs)}${
69
- node.members.length > 0 ? ` · ${node.members.length} component${node.members.length === 1 ? '' : 's'}` : ''
70
- }`;
71
-
72
46
  return (
73
- <div
74
- style={{
75
- width: NODE_W,
76
- height: NODE_H,
77
- boxSizing: 'border-box',
78
- display: 'flex',
79
- flexDirection: 'column',
80
- justifyContent: 'center',
81
- gap: 2,
82
- padding: '8px 12px',
83
- borderRadius: node.kind === 'actor' ? 20 : 8,
84
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
85
- border: `2px ${node.kind === 'component' ? 'solid' : node.kind === 'container' ? 'solid' : 'dashed'} ${
86
- selected ? theme.colors.primary : kindColor
87
- }`,
88
- boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
89
- cursor: 'pointer',
90
- fontFamily: theme.fonts.body,
91
- }}
92
- >
93
- <span
94
- style={{
95
- fontFamily: theme.fonts.monospace,
96
- fontSize: theme.fontSizes[0],
97
- letterSpacing: 0.5,
98
- textTransform: 'uppercase',
99
- color: kindColor,
100
- whiteSpace: 'nowrap',
101
- overflow: 'hidden',
102
- textOverflow: 'ellipsis',
103
- }}
104
- >
105
- {tag}
106
- </span>
107
- <span
108
- style={{
109
- fontWeight: 600,
110
- fontSize: theme.fontSizes[2],
111
- color: theme.colors.text,
112
- whiteSpace: 'nowrap',
113
- overflow: 'hidden',
114
- textOverflow: 'ellipsis',
115
- }}
116
- title={node.label}
117
- >
118
- {node.label}
119
- </span>
120
- <span
121
- style={{
122
- fontFamily: theme.fonts.monospace,
123
- fontSize: theme.fontSizes[0],
124
- color: muted,
125
- whiteSpace: 'nowrap',
126
- overflow: 'hidden',
127
- textOverflow: 'ellipsis',
128
- }}
129
- title={node.members.join(', ')}
130
- >
131
- {subtitle}
132
- </span>
133
- <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
134
- <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
135
- </div>
47
+ <C4NodeCard
48
+ node={node}
49
+ selected={selected}
50
+ handles={
51
+ <>
52
+ <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
53
+ <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
54
+ </>
55
+ }
56
+ />
136
57
  );
137
58
  }
138
59
 
@@ -209,15 +130,24 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
209
130
  setReady(false);
210
131
  setSelectedId(null);
211
132
 
212
- const rfNodes: Node[] = model.nodes.map((n, i) => ({
213
- id: n.id,
214
- type: 'c4-node',
215
- position: { x: (i % 4) * (NODE_W + 60), y: Math.floor(i / 4) * (NODE_H + 40) },
216
- width: NODE_W,
217
- height: NODE_H,
218
- ...(n.parentId ? { parentId: n.parentId } : {}),
219
- data: { node: n, selected: false },
220
- }));
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.
137
+ const rfNodes: Node[] = model.nodes.map((n, i) => {
138
+ const size = nodeSize(n);
139
+ return {
140
+ id: n.id,
141
+ type: 'c4-node',
142
+ // Fallback grid pitch uses the container box — the largest, so the
143
+ // pre-layout positions never overlap. ELK replaces them on success.
144
+ position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
145
+ width: size.width,
146
+ height: size.height,
147
+ ...(n.parentId ? { parentId: n.parentId } : {}),
148
+ data: { node: n, selected: false },
149
+ };
150
+ });
221
151
 
222
152
  const groups = model.groups.map((g) => ({
223
153
  id: g.id,
@@ -226,8 +156,8 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
226
156
  }));
227
157
 
228
158
  const rfEdges: Edge[] = model.edges.map((e) => {
229
- const flow = e.kind === 'flow';
230
- const color = flow ? edgeColor(e.mechanisms[0], muted) : (theme.colors.border ?? '#666');
159
+ // Every edge here came from a trail step, so the mechanism colours apply.
160
+ const color = edgeColor(e.mechanisms[0], muted);
231
161
  return {
232
162
  id: e.id,
233
163
  source: e.source,
@@ -238,7 +168,7 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
238
168
  style: {
239
169
  stroke: color,
240
170
  strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
241
- ...(flow ? { strokeDasharray: '6 4' } : {}),
171
+ strokeDasharray: '6 4',
242
172
  },
243
173
  markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
244
174
  data: { edge: e },
@@ -320,7 +250,10 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
320
250
  // eslint-disable-next-line react-hooks/exhaustive-deps
321
251
  }, [layoutKey]);
322
252
 
323
- const selected = useMemo(() => model.nodes.find((n) => n.id === selectedId) ?? null, [model, selectedId]);
253
+ const selected = useMemo(
254
+ () => model.nodes.find((n) => n.id === selectedId) ?? null,
255
+ [model, selectedId],
256
+ );
324
257
 
325
258
  const displayNodes = useMemo(
326
259
  () =>
@@ -333,7 +266,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
333
266
  );
334
267
 
335
268
  const runtime = model.nodes.filter((n) => n.kind === 'container' || n.kind === 'component').length;
336
- const outside = model.nodes.filter((n) => n.kind === 'external' || n.kind === 'actor').length;
269
+ const outside = model.nodes.filter(
270
+ (n) => n.kind === 'external-system' || n.kind === 'person',
271
+ ).length;
337
272
 
338
273
  return (
339
274
  <div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
@@ -431,6 +366,45 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
431
366
  {selected.kind} · {selected.members.length} component{selected.members.length === 1 ? '' : 's'}
432
367
  {selected.models && selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
433
368
  </div>
369
+
370
+ {/* Confirmation state, with what is still missing. */}
371
+ {selected.kind === 'container' && (
372
+ <div
373
+ style={{
374
+ fontFamily: theme.fonts.monospace,
375
+ fontSize: theme.fontSizes[0],
376
+ color: muted,
377
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
378
+ paddingTop: 6,
379
+ marginBottom: 8,
380
+ display: 'flex',
381
+ flexDirection: 'column',
382
+ gap: 3,
383
+ }}
384
+ >
385
+ <span style={{ color: nodeStyle(selected, theme).color }}>
386
+ {selected.state}
387
+ {selected.containerKind ? ` · ${selected.containerKind}` : ` · ${selected.kind}`}
388
+ </span>
389
+ {selected.technology ? (
390
+ <span>technology: {selected.technology}</span>
391
+ ) : (
392
+ <span style={{ color: theme.colors.warning ?? '#e8a33a' }}>technology: not stated</span>
393
+ )}
394
+ {selected.description ? (
395
+ <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>{selected.description}</span>
396
+ ) : (
397
+ <span style={{ color: theme.colors.warning ?? '#e8a33a' }}>description: not stated</span>
398
+ )}
399
+ {/* A merge hides its inputs, so always show what it absorbed. */}
400
+ {selected.derivedFrom && selected.derivedFrom.length > 1 && (
401
+ <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
402
+ consolidated from: {selected.derivedFrom.join(', ')}
403
+ </span>
404
+ )}
405
+ </div>
406
+ )}
407
+
434
408
  <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
435
409
  {selected.members.map((alias) => (
436
410
  <span
@@ -0,0 +1,301 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ nodeStyle,
4
+ nodeSubtitle,
5
+ nodeTag,
6
+ nodeShape,
7
+ nodeMissing,
8
+ NODE_W,
9
+ NODE_H,
10
+ nodeSize,
11
+ COMPONENT_SIZE,
12
+ } from './C4NodeCard';
13
+ import type { C4Node } from './c4';
14
+
15
+ const theme = {
16
+ colors: {
17
+ border: '#555',
18
+ primary: '#4ec9b0',
19
+ warning: '#e8a33a',
20
+ accent: '#e3b341',
21
+ info: '#0ea5e9',
22
+ },
23
+ } as Parameters<typeof nodeStyle>[1];
24
+
25
+ /**
26
+ * A drawn node, flat. `state` is required on a real `C4Node` — every authored
27
+ * element carries a decision — so the card never has to cope with a box nobody
28
+ * proposed, and no test pretends otherwise.
29
+ */
30
+ function node(over: Partial<C4Node> = {}): C4Node {
31
+ return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [], state: 'proposed', ...over };
32
+ }
33
+
34
+ describe('nodeStyle — one axis per channel', () => {
35
+ test('selected overrides everything', () => {
36
+ expect(nodeStyle(node({ state: 'accepted' }), theme, true).width).toBe(3);
37
+ expect(nodeStyle(node({ state: 'accepted' }), theme, true).dash).toBe('solid');
38
+ });
39
+
40
+ test('accepted is solid and uses the primary colour', () => {
41
+ const s = nodeStyle(node({ state: 'accepted' }), theme);
42
+ expect(s.dash).toBe('solid');
43
+ expect(s.color).toBe('#4ec9b0');
44
+ });
45
+
46
+ test('proposed is dashed and uses the warning colour', () => {
47
+ const s = nodeStyle(node({ state: 'proposed' }), theme);
48
+ expect(s.dash).toBe('dashed');
49
+ expect(s.color).toBe('#e8a33a');
50
+ });
51
+
52
+ test('rejected is dotted and muted', () => {
53
+ const s = nodeStyle(node({ state: 'rejected' }), theme);
54
+ expect(s.dash).toBe('dotted');
55
+ expect(s.color).toBe('#555');
56
+ });
57
+
58
+ test('the dash belongs to state, never to kind', () => {
59
+ // This is the bug that made proposed boxes draw solid: the card rendered
60
+ // `shape.dash`, and shape answered to kind. Every kind at the same state
61
+ // must now draw the same dash.
62
+ for (const kind of ['container', 'component', 'external-system', 'person'] as const) {
63
+ expect(nodeStyle(node({ kind, state: 'proposed' }), theme).dash).toBe('dashed');
64
+ expect(nodeStyle(node({ kind, state: 'accepted' }), theme).dash).toBe('solid');
65
+ }
66
+ });
67
+
68
+ test('confirmation state wins over kind for colour', () => {
69
+ // Same state, four kinds: one colour. Kind is shape's job.
70
+ const colors = (['container', 'component', 'external-system', 'person'] as const).map(
71
+ (kind) => nodeStyle(node({ kind, state: 'accepted' }), theme).color,
72
+ );
73
+ expect(new Set(colors).size).toBe(1);
74
+ });
75
+
76
+ test('border weight carries the level, not the state', () => {
77
+ // Otherwise a component and a container differ only by hue, which is
78
+ // invisible in grayscale and to a colourblind reader.
79
+ expect(nodeStyle(node({ kind: 'component' }), theme).width).toBe(1);
80
+ expect(nodeStyle(node(), theme).width).toBe(2);
81
+ });
82
+
83
+ test('a component stays thinner once confirmed', () => {
84
+ expect(nodeStyle(node({ kind: 'component', state: 'accepted' }), theme).width).toBe(1);
85
+ });
86
+
87
+ test('falls back safely when the theme has no colour', () => {
88
+ const bare = { colors: {} } as Parameters<typeof nodeStyle>[1];
89
+ expect(() => nodeStyle(node({ state: 'accepted' }), bare)).not.toThrow();
90
+ expect(nodeStyle(node({ state: 'accepted' }), bare).dash).toBe('solid');
91
+ });
92
+ });
93
+
94
+ describe('nodeTag — what the box IS', () => {
95
+ test('names the C4 kind, never the confirmation state', () => {
96
+ expect(nodeTag(node({ state: 'accepted' }))).toBe('container');
97
+ expect(nodeTag(node({ state: 'proposed' }))).toBe('container');
98
+ expect(nodeTag(node({ state: 'rejected' }))).toBe('container');
99
+ });
100
+
101
+ test('keeps all four kinds distinct', () => {
102
+ expect(nodeTag(node({ kind: 'component' }))).toBe('component');
103
+ expect(nodeTag(node({ kind: 'external-system' }))).toBe('external-system');
104
+ expect(nodeTag(node({ kind: 'person' }))).toBe('person');
105
+ });
106
+
107
+ test('a confirmed container is still a container', () => {
108
+ // This is the regression that made the two levels unreadable: state used
109
+ // to overwrite the kind, so an accepted container stopped being one.
110
+ expect(nodeTag(node({ state: 'accepted' }))).not.toBe('confirmed');
111
+ });
112
+ });
113
+
114
+ describe('nodeShape — kind without relying on colour or dash', () => {
115
+ test('a container is a sharp rectangle', () => {
116
+ const s = nodeShape(node());
117
+ expect(s.kind).toBe('rect');
118
+ expect(s.radius).toBeLessThanOrEqual(4);
119
+ });
120
+
121
+ test('a component is a square', () => {
122
+ expect(nodeShape(node({ kind: 'component' })).kind).toBe('square');
123
+ });
124
+
125
+ test('an external system is a rounded rectangle', () => {
126
+ const s = nodeShape(node({ kind: 'external-system' }));
127
+ expect(s.kind).toBe('rect');
128
+ expect(s.radius).toBeGreaterThan(nodeShape(node()).radius);
129
+ });
130
+
131
+ test('a person is a pill', () => {
132
+ const s = nodeShape(node({ kind: 'person' }));
133
+ expect(s.kind).toBe('pill');
134
+ expect(s.radius).toBeGreaterThanOrEqual(NODE_H / 2);
135
+ });
136
+
137
+ test('shape does not depend on confirmation state', () => {
138
+ expect(nodeShape(node({ state: 'proposed' })).kind).toBe(
139
+ nodeShape(node({ state: 'accepted' })).kind,
140
+ );
141
+ });
142
+ });
143
+
144
+ describe('nodeSubtitle', () => {
145
+ test('shows the container kind and technology', () => {
146
+ 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');
186
+ });
187
+ });
188
+
189
+ describe('nodeMissing', () => {
190
+ test('a container with neither reports both notation gaps', () => {
191
+ expect(nodeMissing(node({ technology: '', description: undefined }))).toEqual([
192
+ 'technology',
193
+ 'description',
194
+ ]);
195
+ });
196
+
197
+ test('one supplied gap drops only that one', () => {
198
+ expect(nodeMissing(node({ technology: 'React', description: undefined }))).toEqual([
199
+ 'description',
200
+ ]);
201
+ });
202
+
203
+ test('a fully stated container reports nothing', () => {
204
+ expect(
205
+ nodeMissing(node({ technology: 'React', description: 'Renders graphs.', state: 'accepted' })),
206
+ ).toEqual([]);
207
+ });
208
+
209
+ test('the requirement is on containers only', () => {
210
+ for (const kind of ['external-system', 'person', 'component'] as const) {
211
+ expect(nodeMissing(node({ kind, technology: '', description: undefined }))).toEqual([]);
212
+ }
213
+ });
214
+ });
215
+
216
+ describe('nodeSize — a component is a proper square', () => {
217
+ test('a component has equal width and height', () => {
218
+ const s = nodeSize(node({ kind: 'component' }));
219
+ expect(s.width).toBe(s.height);
220
+ });
221
+
222
+ test('a container is wider than it is tall', () => {
223
+ const s = nodeSize(node());
224
+ expect(s.width).toBeGreaterThan(s.height);
225
+ });
226
+
227
+ test('a component is smaller than its container', () => {
228
+ const c = nodeSize(node({ kind: 'component' }));
229
+ const k = nodeSize(node());
230
+ expect(c.width).toBeLessThan(k.width);
231
+ expect(c.height).toBeLessThan(k.height);
232
+ });
233
+
234
+ test('the square still fits its three lines of text', () => {
235
+ // tag + 2-line label slot + subtitle + 10px padding x2. Without this the
236
+ // square clips its own subtitle.
237
+ const s = nodeSize(node({ kind: 'component' }));
238
+ const content = 16 + 40 + 16 + 20;
239
+ expect(s.height).toBeGreaterThanOrEqual(content);
240
+ });
241
+
242
+ test('every kind but a component shares the container box', () => {
243
+ const c = nodeSize(node());
244
+ expect(nodeSize(node({ kind: 'external-system' }))).toEqual(c);
245
+ expect(nodeSize(node({ kind: 'person' }))).toEqual(c);
246
+ });
247
+ });
248
+
249
+ describe('card metrics', () => {
250
+ test('exposes the size the graph lays out against', () => {
251
+ expect(NODE_W).toBe(210);
252
+ expect(NODE_H).toBe(138);
253
+ });
254
+
255
+ test('COMPONENT_SIZE is a square, not just a smaller rectangle', () => {
256
+ expect(COMPONENT_SIZE).toBe(nodeSize(node({ kind: 'component' })).width);
257
+ expect(COMPONENT_SIZE).toBe(nodeSize(node({ kind: 'component' })).height);
258
+ });
259
+
260
+ 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.
264
+ const ratio = NODE_W / NODE_H;
265
+ expect(ratio).toBeGreaterThan(1);
266
+ expect(ratio).toBeLessThan(1.8);
267
+ });
268
+
269
+ test('is tall enough for four lines of content', () => {
270
+ // tag + 2-line label + subtitle + gap marker, plus 8px padding top/bottom.
271
+ expect(NODE_H).toBeGreaterThanOrEqual(130);
272
+ });
273
+
274
+ test('leaves breathing room around four lines of text', () => {
275
+ // 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
278
+ expect(NODE_H - contentBudget).toBeGreaterThanOrEqual(12);
279
+ });
280
+
281
+ test('fits the longest real container label on two lines', () => {
282
+ // "subsystems-studio/renderer" is the longest label the 40 stored models
283
+ // produce. It must be allowed to wrap rather than ellipsize.
284
+ expect(NODE_W).toBeGreaterThanOrEqual(200);
285
+ });
286
+ });
287
+
288
+ 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);
293
+ }
294
+ });
295
+
296
+ test('a library is not one of them', () => {
297
+ // C4 says a module "typically" is not an element at all, so there is no
298
+ // kind to render. A library-shaped group is a review comment.
299
+ expect(['application', 'data-store', 'queue']).not.toContain('library');
300
+ });
301
+ });