@principal-ai/subsystems-react 0.50.0 → 0.51.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 (61) hide show
  1. package/dist/index.d.ts +8 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +95 -0
  6. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +1 -0
  7. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +340 -0
  8. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +1 -0
  9. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +4 -0
  10. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +1 -1
  11. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +7334 -1
  12. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -1
  13. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +2 -0
  14. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +1 -0
  15. package/dist/stories/Subsystem/C4Graph/generateFixture.js +145 -0
  16. package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +1 -0
  17. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  18. package/dist/subsystem/C4Graph.js +16 -60
  19. package/dist/subsystem/C4Graph.js.map +1 -1
  20. package/dist/subsystem/C4NodeCard.d.ts +84 -0
  21. package/dist/subsystem/C4NodeCard.d.ts.map +1 -0
  22. package/dist/subsystem/C4NodeCard.js +229 -0
  23. package/dist/subsystem/C4NodeCard.js.map +1 -0
  24. package/dist/subsystem/DiagnosticToggle.d.ts +8 -1
  25. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  26. package/dist/subsystem/DiagnosticToggle.js +40 -6
  27. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  28. package/dist/subsystem/c4Associations.d.ts +186 -0
  29. package/dist/subsystem/c4Associations.d.ts.map +1 -0
  30. package/dist/subsystem/c4Associations.js +284 -0
  31. package/dist/subsystem/c4Associations.js.map +1 -0
  32. package/dist/subsystem/model.d.ts +25 -1
  33. package/dist/subsystem/model.d.ts.map +1 -1
  34. package/dist/subsystem/model.js +15 -0
  35. package/dist/subsystem/model.js.map +1 -1
  36. package/dist/subsystem/nodes.d.ts.map +1 -1
  37. package/dist/subsystem/nodes.js +78 -7
  38. package/dist/subsystem/nodes.js.map +1 -1
  39. package/dist/subsystem/toC4.d.ts +119 -0
  40. package/dist/subsystem/toC4.d.ts.map +1 -1
  41. package/dist/subsystem/toC4.js +95 -5
  42. package/dist/subsystem/toC4.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/index.ts +39 -0
  45. package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +273 -0
  46. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +325 -0
  47. package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +344 -0
  48. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +7337 -4
  49. package/src/stories/Subsystem/C4Graph/generateFixture.ts +161 -0
  50. package/src/stories/Subsystem/ComponentGraph/Deprecated.stories.tsx +358 -0
  51. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +27 -2
  52. package/src/stories/Subsystem/ComponentGraph/{Flows.stories.tsx → Trails.stories.tsx} +4 -4
  53. package/src/subsystem/C4Graph.tsx +51 -92
  54. package/src/subsystem/C4NodeCard.test.ts +196 -0
  55. package/src/subsystem/C4NodeCard.tsx +307 -0
  56. package/src/subsystem/DiagnosticToggle.tsx +58 -4
  57. package/src/subsystem/c4Associations.test.ts +484 -0
  58. package/src/subsystem/c4Associations.ts +398 -0
  59. package/src/subsystem/model.ts +34 -1
  60. package/src/subsystem/nodes.tsx +124 -5
  61. package/src/subsystem/toC4.ts +210 -5
@@ -25,7 +25,8 @@ 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, nodeStyle } from './C4NodeCard';
29
30
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
30
31
  import type { C4Model, C4Node } from './toC4';
31
32
 
@@ -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
 
@@ -212,7 +133,7 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
212
133
  const rfNodes: Node[] = model.nodes.map((n, i) => ({
213
134
  id: n.id,
214
135
  type: 'c4-node',
215
- position: { x: (i % 4) * (NODE_W + 60), y: Math.floor(i / 4) * (NODE_H + 40) },
136
+ position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
216
137
  width: NODE_W,
217
138
  height: NODE_H,
218
139
  ...(n.parentId ? { parentId: n.parentId } : {}),
@@ -431,6 +352,44 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
431
352
  {selected.kind} · {selected.members.length} component{selected.members.length === 1 ? '' : 's'}
432
353
  {selected.models && selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
433
354
  </div>
355
+
356
+ {/* Confirmation state, with what is still missing. */}
357
+ {selected.kind === 'container' && (
358
+ <div
359
+ style={{
360
+ fontFamily: theme.fonts.monospace,
361
+ fontSize: theme.fontSizes[0],
362
+ color: muted,
363
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
364
+ paddingTop: 6,
365
+ marginBottom: 8,
366
+ display: 'flex',
367
+ flexDirection: 'column',
368
+ gap: 3,
369
+ }}
370
+ >
371
+ <span style={{ color: nodeStyle(selected, theme).color }}>
372
+ {selected.decoration?.state ?? 'unconfirmed'} · {selected.decoration?.type ?? 'no type'}
373
+ </span>
374
+ {selected.decoration?.technology ? (
375
+ <span>technology: {selected.decoration.technology}</span>
376
+ ) : (
377
+ <span style={{ color: theme.colors.warning ?? '#e8a33a' }}>technology: not stated</span>
378
+ )}
379
+ {selected.decoration?.description ? (
380
+ <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>{selected.decoration.description}</span>
381
+ ) : (
382
+ <span style={{ color: theme.colors.warning ?? '#e8a33a' }}>description: not stated</span>
383
+ )}
384
+ {/* A merge hides its inputs, so always show what it absorbed. */}
385
+ {selected.sourceKeys && selected.sourceKeys.length > 1 && (
386
+ <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
387
+ merged from: {selected.sourceKeys.join(', ')}
388
+ </span>
389
+ )}
390
+ </div>
391
+ )}
392
+
434
393
  <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
435
394
  {selected.members.map((alias) => (
436
395
  <span
@@ -0,0 +1,196 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { nodeStyle, nodeSubtitle, nodeTag, nodeMissing, NODE_W, NODE_H } from './C4NodeCard';
3
+ import type { C4Node, C4ElementType, C4AssociationState } from './toC4';
4
+
5
+ const theme = {
6
+ colors: {
7
+ border: '#555',
8
+ primary: '#4ec9b0',
9
+ warning: '#e8a33a',
10
+ accent: '#e3b341',
11
+ info: '#0ea5e9',
12
+ },
13
+ } as Parameters<typeof nodeStyle>[1];
14
+
15
+ function node(over: Partial<C4Node> = {}): C4Node {
16
+ return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [], isStore: false, ...over };
17
+ }
18
+
19
+ function decorated(state: C4AssociationState, extra: Record<string, unknown> = {}): C4Node {
20
+ return node({ decoration: { id: 'container:x', state, ...extra } as C4Node['decoration'] });
21
+ }
22
+
23
+ describe('nodeStyle', () => {
24
+ test('selected overrides everything', () => {
25
+ expect(nodeStyle(decorated('accepted'), theme, true).width).toBe(3);
26
+ expect(nodeStyle(node(), theme, true).color).toBe('#4ec9b0');
27
+ });
28
+
29
+ test('accepted is solid and uses the primary colour', () => {
30
+ const s = nodeStyle(decorated('accepted'), theme);
31
+ expect(s.dash).toBe('solid');
32
+ expect(s.color).toBe('#4ec9b0');
33
+ });
34
+
35
+ test('proposed is dashed and uses the warning colour', () => {
36
+ const s = nodeStyle(decorated('proposed'), theme);
37
+ expect(s.dash).toBe('dashed');
38
+ expect(s.color).toBe('#e8a33a');
39
+ });
40
+
41
+ test('rejected is dotted and muted', () => {
42
+ const s = nodeStyle(decorated('rejected'), theme);
43
+ expect(s.dash).toBe('dotted');
44
+ expect(s.color).toBe('#555');
45
+ });
46
+
47
+ test('no association is muted, so confirmed boxes read stronger', () => {
48
+ expect(nodeStyle(node(), theme).color).toBe('#555');
49
+ });
50
+
51
+ test('an external and an actor are distinguished without an association', () => {
52
+ expect(nodeStyle(node({ kind: 'external' }), theme).color).toBe('#e8a33a');
53
+ expect(nodeStyle(node({ kind: 'actor' }), theme).color).toBe('#e3b341');
54
+ });
55
+
56
+ test('confirmation state wins over kind', () => {
57
+ const s = nodeStyle(decorated('accepted'), theme);
58
+ expect(s.color).toBe('#4ec9b0');
59
+ });
60
+
61
+ test('falls back safely when the theme has no colour', () => {
62
+ const bare = { colors: {} } as Parameters<typeof nodeStyle>[1];
63
+ expect(() => nodeStyle(decorated('accepted'), bare)).not.toThrow();
64
+ expect(nodeStyle(decorated('accepted'), bare).dash).toBe('solid');
65
+ });
66
+ });
67
+
68
+ describe('nodeTag', () => {
69
+ test('names the confirmation state ahead of the C4 kind', () => {
70
+ expect(nodeTag(decorated('accepted'))).toBe('confirmed');
71
+ expect(nodeTag(decorated('proposed'))).toBe('proposed');
72
+ expect(nodeTag(decorated('rejected'))).toBe('rejected');
73
+ });
74
+
75
+ test('falls back to the kind when there is no association', () => {
76
+ expect(nodeTag(node())).toBe('container');
77
+ expect(nodeTag(node({ kind: 'external' }))).toBe('external');
78
+ expect(nodeTag(node({ kind: 'actor' }))).toBe('actor');
79
+ });
80
+ });
81
+
82
+ describe('nodeSubtitle', () => {
83
+ test('shows type and technology once confirmed', () => {
84
+ const s = nodeSubtitle(
85
+ decorated('accepted', { type: 'application', technology: 'Bun + Electrobun' }),
86
+ );
87
+ expect(s).toBe('application · Bun + Electrobun');
88
+ });
89
+
90
+ test('omits technology when it was never stated', () => {
91
+ expect(nodeSubtitle(decorated('accepted', { type: 'data-store' }))).toBe('data-store');
92
+ });
93
+
94
+ test('falls back to the construct breakdown for a raw container', () => {
95
+ const s = nodeSubtitle(node({ constructs: ['function', 'store'], members: ['a', 'b'] }));
96
+ expect(s).toContain('function');
97
+ expect(s).toContain('store');
98
+ expect(s).toContain('2 components');
99
+ });
100
+
101
+ test('singularises a single member', () => {
102
+ const s = nodeSubtitle(node({ constructs: ['function'], members: ['a'] }));
103
+ expect(s).toContain('1 component');
104
+ expect(s).not.toContain('1 components');
105
+ });
106
+
107
+ test('a component shows its construct and file basename', () => {
108
+ const s = nodeSubtitle(
109
+ node({
110
+ kind: 'component',
111
+ component: {
112
+ alias: 'a',
113
+ name: 'buildGroups',
114
+ construct: 'function',
115
+ file: 'src/subsystem/model.ts',
116
+ purl: 'pkg:x#src/subsystem/model.ts',
117
+ },
118
+ }),
119
+ );
120
+ expect(s).toBe('function · model.ts');
121
+ });
122
+ });
123
+
124
+ describe('nodeMissing', () => {
125
+ test('a bare container reports both notation gaps', () => {
126
+ expect(nodeMissing(node())).toEqual(['technology', 'description']);
127
+ });
128
+
129
+ test('one supplied gap drops only that one', () => {
130
+ expect(nodeMissing(decorated('accepted', { technology: 'React' }))).toEqual(['description']);
131
+ });
132
+
133
+ test('a fully stated container reports nothing', () => {
134
+ expect(
135
+ nodeMissing(decorated('accepted', { technology: 'React', description: 'Renders graphs.' })),
136
+ ).toEqual([]);
137
+ });
138
+
139
+ test('the requirement is on containers only', () => {
140
+ expect(nodeMissing(node({ kind: 'external' }))).toEqual([]);
141
+ expect(nodeMissing(node({ kind: 'actor' }))).toEqual([]);
142
+ expect(nodeMissing(node({ kind: 'component' }))).toEqual([]);
143
+ });
144
+ });
145
+
146
+ describe('card metrics', () => {
147
+ test('exposes the size the graph lays out against', () => {
148
+ expect(NODE_W).toBeGreaterThan(0);
149
+ expect(NODE_H).toBeGreaterThan(0);
150
+ });
151
+
152
+ test('is near-square, not a wide strip', () => {
153
+ // The card carries four fields (tag, label, type+technology, notation
154
+ // gap). A 3:1 strip truncates the last two; this is the aspect ratio that
155
+ // lets each sit on its own line.
156
+ const ratio = NODE_W / NODE_H;
157
+ expect(ratio).toBeGreaterThan(1);
158
+ expect(ratio).toBeLessThan(1.8);
159
+ });
160
+
161
+ test('is tall enough for four lines of content', () => {
162
+ // tag + 2-line label + subtitle + gap marker, plus 8px padding top/bottom.
163
+ expect(NODE_H).toBeGreaterThanOrEqual(130);
164
+ });
165
+
166
+ test('leaves breathing room around four lines of text', () => {
167
+ // Top-aligned now, not centred, so this is about slack rather than
168
+ // balance: tag + 2-line label slot + subtitle + gap slot + padding.
169
+ const contentBudget = 16 + 40 + 16 + 18 + 20; // lines + 10px padding ×2
170
+ expect(NODE_H - contentBudget).toBeGreaterThanOrEqual(12);
171
+ });
172
+
173
+ test('fits the longest real container label on two lines', () => {
174
+ // "subsystems-studio/renderer" is the longest label the 40 stored models
175
+ // produce. It must be allowed to wrap rather than ellipsize.
176
+ expect(NODE_W).toBeGreaterThanOrEqual(200);
177
+ });
178
+ });
179
+
180
+ /** Compile-time guard: every declared element type is reachable from a card. */
181
+ const ALL_TYPES: C4ElementType[] = [
182
+ 'application',
183
+ 'data-store',
184
+ 'queue',
185
+ 'library',
186
+ 'software-system',
187
+ 'person',
188
+ ];
189
+
190
+ describe('element types', () => {
191
+ test('each one renders as a subtitle without special-casing', () => {
192
+ for (const type of ALL_TYPES) {
193
+ expect(nodeSubtitle(decorated('accepted', { type }))).toBe(type);
194
+ }
195
+ });
196
+ });
@@ -0,0 +1,307 @@
1
+ /**
2
+ * C4NodeCard — the box an element draws as, with nothing else.
3
+ *
4
+ * Extracted from `C4Graph.tsx` so the visual encoding can be inspected,
5
+ * reviewed, and unit-tested without standing up a graph. React Flow supplies
6
+ * edges and hit-testing; this supplies the reading surface.
7
+ *
8
+ * The encoding has one job: make confirmation state legible at a glance. A box
9
+ * nobody has looked at must not read the same as a box a person signed off.
10
+ * Everything else here is C4 notation — a type, a technology, a description —
11
+ * surfaced in the order a reader wants it.
12
+ */
13
+
14
+ import { useTheme } from '@principal-ade/industry-theme';
15
+ import { describeConstructBreakdown } from './model';
16
+ import type { C4Node } from './toC4';
17
+
18
+ /**
19
+ * Card box. Near-square rather than a wide strip: a C4 element carries four
20
+ * things at once (tag, label, type + technology, and the notation gap it still
21
+ * has), and a wide strip forces the last two to truncate. The extra height is
22
+ * what lets the `needs …` marker sit on its own line instead of clipping.
23
+ */
24
+ export const NODE_W = 210;
25
+ export const NODE_H = 138;
26
+
27
+ /**
28
+ * Fixed slot heights, so rows line up across every card regardless of which
29
+ * optional fields are present. Without these a card missing its notation-gap
30
+ * marker is two lines shorter and a row of cards reads as ragged.
31
+ */
32
+ const LABEL_SLOT_H = 40;
33
+ const GAP_SLOT_H = 18;
34
+
35
+ type Theme = ReturnType<typeof useTheme>['theme'];
36
+
37
+ export interface NodeStyle {
38
+ color: string;
39
+ dash: string;
40
+ width: number;
41
+ }
42
+
43
+ /**
44
+ * Border style and colour for one node, by what we know about it.
45
+ *
46
+ * Confirmation state wins over C4 kind: an accepted container and a raw
47
+ * derived container are both "containers", and the reader's first question
48
+ * about either is whether anyone vouched for it.
49
+ */
50
+ export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeStyle {
51
+ const muted = theme.colors.border ?? '#555';
52
+ if (selected) return { color: theme.colors.primary ?? '#5aa', dash: 'solid', width: 3 };
53
+
54
+ // Border width carries the C4 level, so a component stays a component even
55
+ // in grayscale or for a reader who cannot separate the hues.
56
+ const weight = node.kind === 'component' ? 1 : 2;
57
+
58
+ switch (node.decoration?.state) {
59
+ case 'accepted':
60
+ // Solid and saturated — part of the architecture now.
61
+ return { color: theme.colors.primary ?? '#4ec9b0', dash: 'solid', width: weight };
62
+ case 'proposed':
63
+ // Dashed — an agent asked; a human has not answered.
64
+ return { color: theme.colors.warning ?? '#e8a33a', dash: 'dashed', width: weight };
65
+ case 'rejected':
66
+ // Should never be drawn (toC4 drops it); belt-and-braces.
67
+ return { color: muted, dash: 'dotted', width: 1 };
68
+ default:
69
+ break;
70
+ }
71
+
72
+ // No association at all — the raw derivation. Muted, so confirmed boxes
73
+ // read as the stronger signal.
74
+ switch (node.kind) {
75
+ case 'external':
76
+ return { color: theme.colors.warning ?? '#a78bfa', dash: 'solid', width: weight };
77
+ case 'actor':
78
+ return { color: theme.colors.accent ?? theme.colors.info ?? '#e3b341', dash: 'solid', width: weight };
79
+ default:
80
+ return { color: muted, dash: 'solid', width: weight };
81
+ }
82
+ }
83
+
84
+ function baseName(path: string): string {
85
+ const parts = path.split('/').filter(Boolean);
86
+ return parts[parts.length - 1] ?? path;
87
+ }
88
+
89
+ /** The type + technology line under the label. */
90
+ export function nodeSubtitle(node: C4Node): string {
91
+ const d = node.decoration;
92
+ if (d?.type || d?.technology) return [d.type, d.technology].filter(Boolean).join(' · ');
93
+
94
+ // No confirmed attributes: fall back to what the document told us.
95
+ if (node.kind === 'component') {
96
+ return `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`;
97
+ }
98
+ const count = node.members.length;
99
+ return `${describeConstructBreakdown(node.constructs)}${count > 0 ? ` · ${count} component${count === 1 ? '' : 's'}` : ''}`;
100
+ }
101
+
102
+ /**
103
+ * The C4 level — what the box IS.
104
+ *
105
+ * Deliberately independent of confirmation state. An accepted container is
106
+ * still a container, and a component is a component whether or not anyone has
107
+ * reviewed it; collapsing the two into one label is what made the two
108
+ * indistinguishable. Confirmation is a separate axis (`nodeStateTag`).
109
+ */
110
+ export function nodeTag(node: C4Node): string {
111
+ return node.kind;
112
+ }
113
+
114
+ /**
115
+ * Confirmation state, as its own short tag. Empty when nothing was proposed —
116
+ * which is itself the distinction from a reviewed element.
117
+ */
118
+ export function nodeStateTag(node: C4Node): string {
119
+ switch (node.decoration?.state) {
120
+ case 'accepted':
121
+ return 'confirmed';
122
+ case 'proposed':
123
+ return 'proposed';
124
+ case 'rejected':
125
+ return 'rejected';
126
+ default:
127
+ return 'unconfirmed';
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Shape cue for the C4 level, independent of colour and dash pattern.
133
+ *
134
+ * Colour alone is not enough: a container and a component with no association
135
+ * both fall back to the muted border, so they were identical in grayscale.
136
+ * The convention:
137
+ *
138
+ * container solid rectangle — a runtime boundary, drawn to scale
139
+ * component rounded rectangle — a part inside one, drawn smaller
140
+ * external rounded rectangle + dashed (nothing to confirm inside)
141
+ * actor fully rounded — a person, not a box
142
+ */
143
+ export function nodeShape(node: C4Node): { radius: number; dash: 'solid' | 'dashed' } {
144
+ switch (node.kind) {
145
+ case 'actor':
146
+ return { radius: NODE_H / 2, dash: 'solid' };
147
+ case 'external':
148
+ return { radius: 14, dash: 'dashed' };
149
+ case 'component':
150
+ return { radius: 14, dash: 'solid' };
151
+ default:
152
+ return { radius: 4, dash: 'solid' };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * C4 notation requires a technology and a description on every container.
158
+ * Name the gaps rather than leaving them to be noticed.
159
+ */
160
+ export function nodeMissing(node: C4Node): string[] {
161
+ if (node.kind !== 'container') return [];
162
+ const missing: string[] = [];
163
+ if (!node.decoration?.technology) missing.push('technology');
164
+ if (!node.decoration?.description) missing.push('description');
165
+ return missing;
166
+ }
167
+
168
+ export interface C4NodeCardProps {
169
+ node: C4Node;
170
+ selected?: boolean;
171
+ /** Draw React Flow's edge handles. Off when rendering a bare gallery. */
172
+ handles?: React.ReactNode;
173
+ onClick?: () => void;
174
+ }
175
+
176
+ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeCardProps) {
177
+ const { theme } = useTheme();
178
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
179
+ const style = nodeStyle(node, theme, selected);
180
+ const shape = nodeShape(node);
181
+ const missing = nodeMissing(node);
182
+ const unconfirmed = !node.decoration?.state;
183
+
184
+ return (
185
+ <div
186
+ onClick={onClick}
187
+ style={{
188
+ width: NODE_W,
189
+ minHeight: NODE_H,
190
+ boxSizing: 'border-box',
191
+ display: 'flex',
192
+ flexDirection: 'column',
193
+ // Top-aligned, not centred: the number of lines varies (the notation-gap
194
+ // marker is conditional), and centring pushes the tag to a different
195
+ // height on every card. In a graph grid that makes the row unscannable.
196
+ justifyContent: 'flex-start',
197
+ gap: 3,
198
+ padding: '10px 12px',
199
+ borderRadius: shape.radius,
200
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
201
+ border: `${style.width} ${style.dash} ${style.color}`,
202
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
203
+ cursor: onClick ? 'pointer' : 'default',
204
+ fontFamily: theme.fonts.body,
205
+ }}
206
+ >
207
+ {/* Level and state on one row, separately styled. The level is what the
208
+ box IS; the state is whether anyone has vouched for it. Merging them
209
+ into one label is what made containers and components unreadable. */}
210
+ <span
211
+ style={{
212
+ display: 'flex',
213
+ alignItems: 'baseline',
214
+ gap: 6,
215
+ fontFamily: theme.fonts.monospace,
216
+ fontSize: theme.fontSizes[0],
217
+ letterSpacing: 0.5,
218
+ textTransform: 'uppercase',
219
+ }}
220
+ >
221
+ <span
222
+ style={{
223
+ color: style.color,
224
+ fontWeight: 600,
225
+ whiteSpace: 'nowrap',
226
+ overflow: 'hidden',
227
+ textOverflow: 'ellipsis',
228
+ minWidth: 0,
229
+ }}
230
+ >
231
+ {nodeTag(node)}
232
+ </span>
233
+ <span
234
+ title={unconfirmed ? 'Nobody has reviewed this element yet' : undefined}
235
+ style={{
236
+ color: unconfirmed ? muted : style.color,
237
+ // Unconfirmed is a quiet absence, not a loud claim.
238
+ opacity: unconfirmed ? 0.75 : 1,
239
+ letterSpacing: 0,
240
+ textTransform: 'none',
241
+ whiteSpace: 'nowrap',
242
+ overflow: 'hidden',
243
+ textOverflow: 'ellipsis',
244
+ minWidth: 0,
245
+ }}
246
+ >
247
+ {nodeStateTag(node)}
248
+ </span>
249
+ </span>
250
+ {/* Two lines rather than an ellipsis: a truncated label hides the very
251
+ thing that distinguishes two containers from each other. The fixed
252
+ height reserves both lines even for a one-line label, so the subtitle
253
+ below sits at the same y on every card. */}
254
+ <span
255
+ style={{
256
+ fontWeight: 600,
257
+ fontSize: theme.fontSizes[2],
258
+ color: theme.colors.text,
259
+ lineHeight: 1.25,
260
+ height: LABEL_SLOT_H,
261
+ display: '-webkit-box',
262
+ WebkitLineClamp: 2,
263
+ WebkitBoxOrient: 'vertical',
264
+ overflow: 'hidden',
265
+ }}
266
+ title={node.label}
267
+ >
268
+ {node.label}
269
+ </span>
270
+ <span
271
+ style={{
272
+ fontFamily: theme.fonts.monospace,
273
+ fontSize: theme.fontSizes[0],
274
+ color: muted,
275
+ whiteSpace: 'nowrap',
276
+ overflow: 'hidden',
277
+ textOverflow: 'ellipsis',
278
+ }}
279
+ title={node.decoration?.description ?? node.members.join(', ')}
280
+ >
281
+ {nodeSubtitle(node)}
282
+ </span>
283
+ {/* Reserved row: without it, a card with no notation gap is two lines
284
+ shorter and the row of cards reads as ragged. */}
285
+ <span style={{ height: GAP_SLOT_H }} aria-hidden="true" />
286
+ {missing.length > 0 && (
287
+ <span
288
+ title={`C4 requires a ${missing.join(' and a ')} on every container`}
289
+ style={{
290
+ fontFamily: theme.fonts.monospace,
291
+ fontSize: theme.fontSizes[0],
292
+ color: theme.colors.warning ?? '#e8a33a',
293
+ lineHeight: 1.3,
294
+ height: GAP_SLOT_H,
295
+ display: '-webkit-box',
296
+ WebkitLineClamp: 2,
297
+ WebkitBoxOrient: 'vertical',
298
+ overflow: 'hidden',
299
+ }}
300
+ >
301
+ needs {missing.join(' + ')}
302
+ </span>
303
+ )}
304
+ {handles}
305
+ </div>
306
+ );
307
+ }