@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.
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreTrailCodeView.js +44 -21
- package/dist/pierre/PierreTrailCodeView.js.map +1 -1
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +493 -0
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +329 -0
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +1 -0
- package/dist/subsystem/C4Graph.d.ts +2 -2
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +25 -16
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +71 -36
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +88 -94
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +20 -21
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/IssueList.js +1 -1
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +13 -4
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +272 -0
- package/dist/subsystem/c4.d.ts.map +1 -0
- package/dist/subsystem/c4.js +322 -0
- package/dist/subsystem/c4.js.map +1 -0
- package/dist/subsystem/c4Evidence.d.ts +143 -0
- package/dist/subsystem/c4Evidence.d.ts.map +1 -0
- package/dist/subsystem/c4Evidence.js +240 -0
- package/dist/subsystem/c4Evidence.js.map +1 -0
- package/dist/subsystem/model.d.ts +2 -6
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/package.json +4 -3
- package/src/index.ts +26 -28
- package/src/pierre/PierreTrailCodeView.tsx +49 -21
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +167 -79
- package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
- package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +1 -1
- package/src/subsystem/C4Graph.tsx +39 -24
- package/src/subsystem/C4NodeCard.test.ts +169 -64
- package/src/subsystem/C4NodeCard.tsx +102 -108
- package/src/subsystem/FileDrawer.tsx +32 -34
- package/src/subsystem/IssueList.tsx +1 -1
- package/src/subsystem/SubsystemComponentGraph.tsx +13 -5
- package/src/subsystem/c4.test.ts +573 -0
- package/src/subsystem/c4.ts +558 -0
- package/src/subsystem/c4Evidence.test.ts +386 -0
- package/src/subsystem/c4Evidence.ts +329 -0
- package/src/subsystem/model.ts +2 -6
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +0 -95
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +0 -340
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -7
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -7335
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
- package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +0 -2
- package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/generateFixture.js +0 -145
- package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +0 -1
- package/dist/subsystem/c4Associations.d.ts +0 -186
- package/dist/subsystem/c4Associations.d.ts.map +0 -1
- package/dist/subsystem/c4Associations.js +0 -284
- package/dist/subsystem/c4Associations.js.map +0 -1
- package/dist/subsystem/toC4.d.ts +0 -214
- package/dist/subsystem/toC4.d.ts.map +0 -1
- package/dist/subsystem/toC4.js +0 -282
- package/dist/subsystem/toC4.js.map +0 -1
- package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +0 -273
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
- package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +0 -344
- package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -7339
- package/src/stories/Subsystem/C4Graph/generateFixture.ts +0 -161
- package/src/subsystem/c4Associations.test.ts +0 -484
- package/src/subsystem/c4Associations.ts +0 -398
- package/src/subsystem/toC4.test.ts +0 -114
- package/src/subsystem/toC4.ts +0 -478
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
|
|
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: [],
|
|
31
|
+
return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [], state: 'proposed', ...over };
|
|
17
32
|
}
|
|
18
33
|
|
|
19
|
-
|
|
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(
|
|
26
|
-
expect(nodeStyle(node(), theme, true).
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
48
|
-
|
|
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('
|
|
52
|
-
|
|
53
|
-
|
|
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('
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
64
|
-
expect(nodeStyle(
|
|
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
|
|
70
|
-
expect(nodeTag(
|
|
71
|
-
expect(nodeTag(
|
|
72
|
-
expect(nodeTag(
|
|
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('
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
expect(nodeTag(node({
|
|
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('
|
|
83
|
-
test('
|
|
84
|
-
const s =
|
|
85
|
-
|
|
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
|
-
|
|
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('
|
|
91
|
-
expect(nodeSubtitle(
|
|
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
|
|
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
|
|
126
|
-
expect(nodeMissing(node())).toEqual([
|
|
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(
|
|
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(
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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).
|
|
149
|
-
expect(NODE_H).
|
|
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,
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
*
|
|
11
|
-
* surfaced in the order a
|
|
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 './
|
|
17
|
+
import type { C4Node } from './c4';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* Card box
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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:
|
|
57
|
+
dash: 'solid' | 'dashed' | 'dotted';
|
|
40
58
|
width: number;
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
/**
|
|
44
|
-
* Border style
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
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.
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
|
92
|
-
if (
|
|
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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
139
|
-
* component
|
|
140
|
-
* external
|
|
141
|
-
*
|
|
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):
|
|
156
|
+
export function nodeShape(node: C4Node): NodeShape {
|
|
144
157
|
switch (node.kind) {
|
|
145
|
-
case '
|
|
146
|
-
return { radius: NODE_H / 2
|
|
147
|
-
case 'external':
|
|
148
|
-
return {
|
|
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 {
|
|
163
|
+
return { kind: 'square', radius: 6 };
|
|
151
164
|
default:
|
|
152
|
-
return {
|
|
165
|
+
return { kind: 'rect', radius: 4 };
|
|
153
166
|
}
|
|
154
167
|
}
|
|
155
168
|
|
|
156
169
|
/**
|
|
157
|
-
* C4 notation
|
|
158
|
-
*
|
|
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.
|
|
164
|
-
if (!node.
|
|
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
|
-
|
|
200
|
+
|
|
201
|
+
const size = nodeSize(node);
|
|
183
202
|
|
|
184
203
|
return (
|
|
185
204
|
<div
|
|
186
205
|
onClick={onClick}
|
|
187
206
|
style={{
|
|
188
|
-
width:
|
|
189
|
-
minHeight:
|
|
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
|
-
{/*
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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.
|
|
273
|
+
title={node.description ?? node.members.join(', ')}
|
|
280
274
|
>
|
|
281
275
|
{nodeSubtitle(node)}
|
|
282
276
|
</span>
|