@principal-ai/subsystems-react 0.51.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 (87) hide show
  1. package/dist/index.d.ts +6 -6
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -4
  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 +25 -16
  16. package/dist/subsystem/C4Graph.js.map +1 -1
  17. package/dist/subsystem/C4NodeCard.d.ts +71 -36
  18. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  19. package/dist/subsystem/C4NodeCard.js +88 -94
  20. package/dist/subsystem/C4NodeCard.js.map +1 -1
  21. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  22. package/dist/subsystem/FileDrawer.js +20 -21
  23. package/dist/subsystem/FileDrawer.js.map +1 -1
  24. package/dist/subsystem/IssueList.js +1 -1
  25. package/dist/subsystem/IssueList.js.map +1 -1
  26. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  27. package/dist/subsystem/SubsystemComponentGraph.js +13 -4
  28. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  29. package/dist/subsystem/c4.d.ts +272 -0
  30. package/dist/subsystem/c4.d.ts.map +1 -0
  31. package/dist/subsystem/c4.js +322 -0
  32. package/dist/subsystem/c4.js.map +1 -0
  33. package/dist/subsystem/c4Evidence.d.ts +143 -0
  34. package/dist/subsystem/c4Evidence.d.ts.map +1 -0
  35. package/dist/subsystem/c4Evidence.js +240 -0
  36. package/dist/subsystem/c4Evidence.js.map +1 -0
  37. package/dist/subsystem/model.d.ts +2 -6
  38. package/dist/subsystem/model.d.ts.map +1 -1
  39. package/dist/subsystem/model.js.map +1 -1
  40. package/package.json +4 -3
  41. package/src/index.ts +26 -28
  42. package/src/pierre/PierreTrailCodeView.tsx +49 -21
  43. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +167 -79
  44. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
  45. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
  46. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
  47. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +1 -1
  48. package/src/subsystem/C4Graph.tsx +39 -24
  49. package/src/subsystem/C4NodeCard.test.ts +169 -64
  50. package/src/subsystem/C4NodeCard.tsx +102 -108
  51. package/src/subsystem/FileDrawer.tsx +32 -34
  52. package/src/subsystem/IssueList.tsx +1 -1
  53. package/src/subsystem/SubsystemComponentGraph.tsx +13 -5
  54. package/src/subsystem/c4.test.ts +573 -0
  55. package/src/subsystem/c4.ts +558 -0
  56. package/src/subsystem/c4Evidence.test.ts +386 -0
  57. package/src/subsystem/c4Evidence.ts +329 -0
  58. package/src/subsystem/model.ts +2 -6
  59. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +0 -95
  60. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +0 -1
  61. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +0 -340
  62. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +0 -1
  63. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -7
  64. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
  65. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -7335
  66. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
  67. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +0 -2
  68. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +0 -1
  69. package/dist/stories/Subsystem/C4Graph/generateFixture.js +0 -145
  70. package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +0 -1
  71. package/dist/subsystem/c4Associations.d.ts +0 -186
  72. package/dist/subsystem/c4Associations.d.ts.map +0 -1
  73. package/dist/subsystem/c4Associations.js +0 -284
  74. package/dist/subsystem/c4Associations.js.map +0 -1
  75. package/dist/subsystem/toC4.d.ts +0 -214
  76. package/dist/subsystem/toC4.d.ts.map +0 -1
  77. package/dist/subsystem/toC4.js +0 -282
  78. package/dist/subsystem/toC4.js.map +0 -1
  79. package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +0 -273
  80. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
  81. package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +0 -344
  82. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -7339
  83. package/src/stories/Subsystem/C4Graph/generateFixture.ts +0 -161
  84. package/src/subsystem/c4Associations.test.ts +0 -484
  85. package/src/subsystem/c4Associations.ts +0 -398
  86. package/src/subsystem/toC4.test.ts +0 -114
  87. package/src/subsystem/toC4.ts +0 -478
@@ -1,6 +1,16 @@
1
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';
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';
4
14
 
5
15
  const theme = {
6
16
  colors: {
@@ -12,86 +22,137 @@ const theme = {
12
22
  },
13
23
  } as Parameters<typeof nodeStyle>[1];
14
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
+ */
15
30
  function node(over: Partial<C4Node> = {}): C4Node {
16
- return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [], isStore: false, ...over };
31
+ return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [], state: 'proposed', ...over };
17
32
  }
18
33
 
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', () => {
34
+ describe('nodeStyle — one axis per channel', () => {
24
35
  test('selected overrides everything', () => {
25
- expect(nodeStyle(decorated('accepted'), theme, true).width).toBe(3);
26
- expect(nodeStyle(node(), theme, true).color).toBe('#4ec9b0');
36
+ expect(nodeStyle(node({ state: 'accepted' }), theme, true).width).toBe(3);
37
+ expect(nodeStyle(node({ state: 'accepted' }), theme, true).dash).toBe('solid');
27
38
  });
28
39
 
29
40
  test('accepted is solid and uses the primary colour', () => {
30
- const s = nodeStyle(decorated('accepted'), theme);
41
+ const s = nodeStyle(node({ state: 'accepted' }), theme);
31
42
  expect(s.dash).toBe('solid');
32
43
  expect(s.color).toBe('#4ec9b0');
33
44
  });
34
45
 
35
46
  test('proposed is dashed and uses the warning colour', () => {
36
- const s = nodeStyle(decorated('proposed'), theme);
47
+ const s = nodeStyle(node({ state: 'proposed' }), theme);
37
48
  expect(s.dash).toBe('dashed');
38
49
  expect(s.color).toBe('#e8a33a');
39
50
  });
40
51
 
41
52
  test('rejected is dotted and muted', () => {
42
- const s = nodeStyle(decorated('rejected'), theme);
53
+ const s = nodeStyle(node({ state: 'rejected' }), theme);
43
54
  expect(s.dash).toBe('dotted');
44
55
  expect(s.color).toBe('#555');
45
56
  });
46
57
 
47
- test('no association is muted, so confirmed boxes read stronger', () => {
48
- expect(nodeStyle(node(), theme).color).toBe('#555');
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
+ }
49
66
  });
50
67
 
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');
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);
54
74
  });
55
75
 
56
- test('confirmation state wins over kind', () => {
57
- const s = nodeStyle(decorated('accepted'), theme);
58
- expect(s.color).toBe('#4ec9b0');
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);
59
85
  });
60
86
 
61
87
  test('falls back safely when the theme has no colour', () => {
62
88
  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');
89
+ expect(() => nodeStyle(node({ state: 'accepted' }), bare)).not.toThrow();
90
+ expect(nodeStyle(node({ state: 'accepted' }), bare).dash).toBe('solid');
65
91
  });
66
92
  });
67
93
 
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');
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');
73
105
  });
74
106
 
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');
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');
79
111
  });
80
112
  });
81
113
 
82
- describe('nodeSubtitle', () => {
83
- test('shows type and technology once confirmed', () => {
84
- const s = nodeSubtitle(
85
- decorated('accepted', { type: 'application', technology: 'Bun + Electrobun' }),
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,
86
140
  );
87
- expect(s).toBe('application · Bun + Electrobun');
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');
88
149
  });
89
150
 
90
- test('omits technology when it was never stated', () => {
91
- expect(nodeSubtitle(decorated('accepted', { type: 'data-store' }))).toBe('data-store');
151
+ test('shows a data store with no technology as just the kind', () => {
152
+ expect(nodeSubtitle(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
92
153
  });
93
154
 
94
- test('falls back to the construct breakdown for a raw container', () => {
155
+ test('falls back to the construct breakdown when neither is stated', () => {
95
156
  const s = nodeSubtitle(node({ constructs: ['function', 'store'], members: ['a', 'b'] }));
96
157
  expect(s).toContain('function');
97
158
  expect(s).toContain('store');
@@ -119,38 +180,85 @@ describe('nodeSubtitle', () => {
119
180
  );
120
181
  expect(s).toBe('function · model.ts');
121
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
+ });
122
187
  });
123
188
 
124
189
  describe('nodeMissing', () => {
125
- test('a bare container reports both notation gaps', () => {
126
- expect(nodeMissing(node())).toEqual(['technology', 'description']);
190
+ test('a container with neither reports both notation gaps', () => {
191
+ expect(nodeMissing(node({ technology: '', description: undefined }))).toEqual([
192
+ 'technology',
193
+ 'description',
194
+ ]);
127
195
  });
128
196
 
129
197
  test('one supplied gap drops only that one', () => {
130
- expect(nodeMissing(decorated('accepted', { technology: 'React' }))).toEqual(['description']);
198
+ expect(nodeMissing(node({ technology: 'React', description: undefined }))).toEqual([
199
+ 'description',
200
+ ]);
131
201
  });
132
202
 
133
203
  test('a fully stated container reports nothing', () => {
134
204
  expect(
135
- nodeMissing(decorated('accepted', { technology: 'React', description: 'Renders graphs.' })),
205
+ nodeMissing(node({ technology: 'React', description: 'Renders graphs.', state: 'accepted' })),
136
206
  ).toEqual([]);
137
207
  });
138
208
 
139
209
  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([]);
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);
143
246
  });
144
247
  });
145
248
 
146
249
  describe('card metrics', () => {
147
250
  test('exposes the size the graph lays out against', () => {
148
- expect(NODE_W).toBeGreaterThan(0);
149
- expect(NODE_H).toBeGreaterThan(0);
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);
150
258
  });
151
259
 
152
260
  test('is near-square, not a wide strip', () => {
153
- // The card carries four fields (tag, label, type+technology, notation
261
+ // The card carries four fields (tag, label, kind+technology, notation
154
262
  // gap). A 3:1 strip truncates the last two; this is the aspect ratio that
155
263
  // lets each sit on its own line.
156
264
  const ratio = NODE_W / NODE_H;
@@ -177,20 +285,17 @@ describe('card metrics', () => {
177
285
  });
178
286
  });
179
287
 
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);
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);
194
293
  }
195
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
+ });
196
301
  });
@@ -7,23 +7,41 @@
7
7
  *
8
8
  * The encoding has one job: make confirmation state legible at a glance. A box
9
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.
10
+ * Kind says what the box is — shape, size, and a written tag — and everything
11
+ * else in C4 notation (technology, description) is surfaced in the order a
12
+ * reader wants it.
12
13
  */
13
14
 
14
15
  import { useTheme } from '@principal-ade/industry-theme';
15
16
  import { describeConstructBreakdown } from './model';
16
- import type { C4Node } from './toC4';
17
+ import type { C4Node } from './c4';
17
18
 
18
19
  /**
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.
20
+ * Card box for a container: a wide rectangle. A container carries four things
21
+ * at once (tag, label, type + technology, and the notation gap it still has),
22
+ * and a strip would force the last two to truncate.
23
23
  */
24
24
  export const NODE_W = 210;
25
25
  export const NODE_H = 138;
26
26
 
27
+ /**
28
+ * A component is a proper square — equal sides, and visibly smaller than the
29
+ * container it sits inside. Square-vs-rectangle is the clearest signal that
30
+ * these are different C4 levels, and it survives grayscale and colour-blind
31
+ * reading where a border-weight difference alone does not.
32
+ */
33
+ export const COMPONENT_SIZE = 132;
34
+
35
+ /**
36
+ * Per-level box size. The graph lays out against these, so a square component
37
+ * and a rectangular container do not collide at a shared nominal width.
38
+ */
39
+ export function nodeSize(node: C4Node): { width: number; height: number } {
40
+ return node.kind === 'component'
41
+ ? { width: COMPONENT_SIZE, height: COMPONENT_SIZE }
42
+ : { width: NODE_W, height: NODE_H };
43
+ }
44
+
27
45
  /**
28
46
  * Fixed slot heights, so rows line up across every card regardless of which
29
47
  * optional fields are present. Without these a card missing its notation-gap
@@ -36,26 +54,34 @@ type Theme = ReturnType<typeof useTheme>['theme'];
36
54
 
37
55
  export interface NodeStyle {
38
56
  color: string;
39
- dash: string;
57
+ dash: 'solid' | 'dashed' | 'dotted';
40
58
  width: number;
41
59
  }
42
60
 
43
61
  /**
44
- * Border style and colour for one node, by what we know about it.
62
+ * Border style for one node: colour, dash and weight, from confirmation state
63
+ * and level.
64
+ *
65
+ * One axis per channel, so nothing is said twice and nothing is contradicted:
66
+ *
67
+ * colour + dash → confirmation state (the primary axis)
68
+ * weight → C4 level, as a grayscale redundancy for the square
69
+ * shape + size → C4 kind (`nodeShape`, `nodeSize`)
45
70
  *
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.
71
+ * State owning the dash is deliberate. An earlier build gave the dash to kind
72
+ * and drew proposed boxes solid, quietly contradicting the one thing the
73
+ * confirm UI needs a reader to see. Kind now says itself through shape, and
74
+ * the border says who vouched for it.
49
75
  */
50
76
  export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeStyle {
51
77
  const muted = theme.colors.border ?? '#555';
52
78
  if (selected) return { color: theme.colors.primary ?? '#5aa', dash: 'solid', width: 3 };
53
79
 
54
- // Border width carries the C4 level, so a component stays a component even
80
+ // Border weight carries the level, so a component stays a component even
55
81
  // in grayscale or for a reader who cannot separate the hues.
56
82
  const weight = node.kind === 'component' ? 1 : 2;
57
83
 
58
- switch (node.decoration?.state) {
84
+ switch (node.state) {
59
85
  case 'accepted':
60
86
  // Solid and saturated — part of the architecture now.
61
87
  return { color: theme.colors.primary ?? '#4ec9b0', dash: 'solid', width: weight };
@@ -63,21 +89,8 @@ export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeSty
63
89
  // Dashed — an agent asked; a human has not answered.
64
90
  return { color: theme.colors.warning ?? '#e8a33a', dash: 'dashed', width: weight };
65
91
  case 'rejected':
66
- // Should never be drawn (toC4 drops it); belt-and-braces.
92
+ // Should never be drawn (buildC4Model drops it); belt-and-braces.
67
93
  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
94
  }
82
95
  }
83
96
 
@@ -86,10 +99,15 @@ function baseName(path: string): string {
86
99
  return parts[parts.length - 1] ?? path;
87
100
  }
88
101
 
89
- /** The type + technology line under the label. */
102
+ /**
103
+ * The kind + technology line under the label.
104
+ *
105
+ * A container shows its `containerKind` (application / data-store / queue);
106
+ * C4 has no kind for the other three, so they show technology or nothing.
107
+ */
90
108
  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(' · ');
109
+ const parts = [node.containerKind, node.technology].filter(Boolean);
110
+ if (parts.length > 0) return parts.join(' · ');
93
111
 
94
112
  // No confirmed attributes: fall back to what the document told us.
95
113
  if (node.kind === 'component') {
@@ -100,68 +118,68 @@ export function nodeSubtitle(node: C4Node): string {
100
118
  }
101
119
 
102
120
  /**
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`).
121
+ * What the box IS — its C4 kind. An accepted container is still a container;
122
+ * collapsing the two is what once made containers and components unreadable.
123
+ * Confirmation state is a separate axis carried by the border (see
124
+ * `nodeStyle`), not by a word on the card.
109
125
  */
110
126
  export function nodeTag(node: C4Node): string {
111
127
  return node.kind;
112
128
  }
113
129
 
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
- }
130
+ /** How a box is drawn. Shape carries the C4 kind, independent of colour. */
131
+ export type C4ShapeKind = 'rect' | 'square' | 'pill';
132
+
133
+ export interface NodeShape {
134
+ kind: C4ShapeKind;
135
+ /** Corner radius, for the shapes that use one. */
136
+ radius: number;
129
137
  }
130
138
 
131
139
  /**
132
- * Shape cue for the C4 level, independent of colour and dash pattern.
140
+ * Shape cue for the C4 kind, independent of colour and dash — both of which
141
+ * belong to confirmation state.
133
142
  *
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
143
  * The convention:
137
144
  *
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
145
+ * container wide rectangle, sharp corners — a runtime boundary
146
+ * component square — a part inside one (see `nodeSize`)
147
+ * external system wide rectangle, rounded — someone else's system
148
+ * person pill — not a box at all
149
+ *
150
+ * An external system is separated from a container by radius alone, which is
151
+ * a weak cue. That is deliberate rather than an oversight: the border dash is
152
+ * the only channel that could say "outside our control" loudly, and it is
153
+ * already spent on proposed-vs-accepted. The kind tag says it in words, and
154
+ * the frame position says it structurally.
142
155
  */
143
- export function nodeShape(node: C4Node): { radius: number; dash: 'solid' | 'dashed' } {
156
+ export function nodeShape(node: C4Node): NodeShape {
144
157
  switch (node.kind) {
145
- case 'actor':
146
- return { radius: NODE_H / 2, dash: 'solid' };
147
- case 'external':
148
- return { radius: 14, dash: 'dashed' };
158
+ case 'person':
159
+ return { kind: 'pill', radius: NODE_H / 2 };
160
+ case 'external-system':
161
+ return { kind: 'rect', radius: 14 };
149
162
  case 'component':
150
- return { radius: 14, dash: 'solid' };
163
+ return { kind: 'square', radius: 6 };
151
164
  default:
152
- return { radius: 4, dash: 'solid' };
165
+ return { kind: 'rect', radius: 4 };
153
166
  }
154
167
  }
155
168
 
156
169
  /**
157
- * C4 notation requires a technology and a description on every container.
158
- * Name the gaps rather than leaving them to be noticed.
170
+ * The gaps the C4 notation still wants on this element, named rather than left
171
+ * to be noticed.
172
+ *
173
+ * Containers are where the notation is strictest: it requires a technology and
174
+ * a description on every one. A component needs a technology too, but a
175
+ * component drawn without a parent already reports `unknown_container`, and
176
+ * that is the question worth asking first.
159
177
  */
160
178
  export function nodeMissing(node: C4Node): string[] {
161
179
  if (node.kind !== 'container') return [];
162
180
  const missing: string[] = [];
163
- if (!node.decoration?.technology) missing.push('technology');
164
- if (!node.decoration?.description) missing.push('description');
181
+ if (!node.technology) missing.push('technology');
182
+ if (!node.description) missing.push('description');
165
183
  return missing;
166
184
  }
167
185
 
@@ -179,14 +197,15 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
179
197
  const style = nodeStyle(node, theme, selected);
180
198
  const shape = nodeShape(node);
181
199
  const missing = nodeMissing(node);
182
- const unconfirmed = !node.decoration?.state;
200
+
201
+ const size = nodeSize(node);
183
202
 
184
203
  return (
185
204
  <div
186
205
  onClick={onClick}
187
206
  style={{
188
- width: NODE_W,
189
- minHeight: NODE_H,
207
+ width: size.width,
208
+ minHeight: size.height,
190
209
  boxSizing: 'border-box',
191
210
  display: 'flex',
192
211
  flexDirection: 'column',
@@ -204,48 +223,23 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
204
223
  fontFamily: theme.fonts.body,
205
224
  }}
206
225
  >
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. */}
226
+ {/* The kind is what the box IS. Confirmation state is carried by the
227
+ border (see `nodeStyle`), not by a word: the card says what it is,
228
+ and the styling says who vouched for it. */}
210
229
  <span
211
230
  style={{
212
- display: 'flex',
213
- alignItems: 'baseline',
214
- gap: 6,
215
231
  fontFamily: theme.fonts.monospace,
216
232
  fontSize: theme.fontSizes[0],
233
+ color: style.color,
234
+ fontWeight: 600,
217
235
  letterSpacing: 0.5,
218
236
  textTransform: 'uppercase',
237
+ whiteSpace: 'nowrap',
238
+ overflow: 'hidden',
239
+ textOverflow: 'ellipsis',
219
240
  }}
220
241
  >
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>
242
+ {nodeTag(node)}
249
243
  </span>
250
244
  {/* Two lines rather than an ellipsis: a truncated label hides the very
251
245
  thing that distinguishes two containers from each other. The fixed
@@ -276,7 +270,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
276
270
  overflow: 'hidden',
277
271
  textOverflow: 'ellipsis',
278
272
  }}
279
- title={node.decoration?.description ?? node.members.join(', ')}
273
+ title={node.description ?? node.members.join(', ')}
280
274
  >
281
275
  {nodeSubtitle(node)}
282
276
  </span>