@principal-ai/subsystems-react 0.51.2 → 0.51.4
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 +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +12 -1
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +115 -22
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +22 -22
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +56 -77
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +32 -13
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
- package/dist/subsystem/TrailsPanel.js +8 -4
- package/dist/subsystem/TrailsPanel.js.map +1 -1
- package/dist/subsystem/c4.d.ts +24 -86
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +6 -285
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/c4Evidence.js +1 -1
- package/dist/subsystem/c4Evidence.js.map +1 -1
- package/dist/subsystem/model.d.ts +14 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +4 -2
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/techIcons/bun.d.ts +11 -0
- package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
- package/dist/subsystem/techIcons/bun.js +11 -0
- package/dist/subsystem/techIcons/bun.js.map +1 -0
- package/dist/subsystem/techIcons/index.d.ts +36 -0
- package/dist/subsystem/techIcons/index.d.ts.map +1 -0
- package/dist/subsystem/techIcons/index.js +55 -0
- package/dist/subsystem/techIcons/index.js.map +1 -0
- package/dist/subsystem/techIcons/node.d.ts +12 -0
- package/dist/subsystem/techIcons/node.d.ts.map +1 -0
- package/dist/subsystem/techIcons/node.js +39 -0
- package/dist/subsystem/techIcons/node.js.map +1 -0
- package/dist/subsystem/techIcons/react.d.ts +12 -0
- package/dist/subsystem/techIcons/react.d.ts.map +1 -0
- package/dist/subsystem/techIcons/react.js +19 -0
- package/dist/subsystem/techIcons/react.js.map +1 -0
- package/dist/utils/edgeLabel.d.ts +1 -1
- package/dist/utils/edgeLabel.d.ts.map +1 -1
- package/dist/utils/edgeLabel.js +1 -1
- package/dist/utils/edgeLabel.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +18 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +90 -9
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +4 -6
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +20 -10
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
- package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
- package/src/subsystem/C4Graph.tsx +171 -19
- package/src/subsystem/C4NodeCard.test.ts +71 -77
- package/src/subsystem/C4NodeCard.tsx +65 -95
- package/src/subsystem/SubsystemComponentGraph.tsx +41 -13
- package/src/subsystem/TrailsPanel.tsx +10 -5
- package/src/subsystem/c4.ts +23 -357
- package/src/subsystem/c4Evidence.test.ts +1 -1
- package/src/subsystem/c4Evidence.ts +1 -1
- package/src/subsystem/model.ts +14 -0
- package/src/subsystem/nodes.tsx +5 -2
- package/src/subsystem/techIcons/bun.ts +10 -0
- package/src/subsystem/techIcons/index.tsx +73 -0
- package/src/subsystem/techIcons/node.ts +38 -0
- package/src/subsystem/techIcons/react.ts +18 -0
- package/src/subsystem/techIcons/techIcons.test.ts +39 -0
- package/src/utils/edgeLabel.ts +1 -1
- package/src/utils/elkLayout.test.ts +34 -0
- package/src/utils/elkLayout.ts +88 -9
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
- package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
- package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
- package/src/subsystem/c4.test.ts +0 -573
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
nodeSubtitle,
|
|
5
5
|
nodeTag,
|
|
6
6
|
nodeKindIcon,
|
|
7
|
-
|
|
7
|
+
nodeTopLabel,
|
|
8
8
|
nodeShape,
|
|
9
9
|
nodeMissing,
|
|
10
10
|
NODE_W,
|
|
@@ -113,14 +113,19 @@ describe('nodeTag — what the box IS', () => {
|
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
describe('nodeKindIcon —
|
|
117
|
-
test('
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
describe('nodeKindIcon — a container’s sort, not its C4 kind', () => {
|
|
117
|
+
test('application and data-store get different icons', () => {
|
|
118
|
+
const app = nodeKindIcon(node({ containerKind: 'application' }));
|
|
119
|
+
const store = nodeKindIcon(node({ containerKind: 'data-store' }));
|
|
120
|
+
expect(app).toBeDefined();
|
|
121
|
+
expect(store).toBeDefined();
|
|
122
|
+
expect(app).not.toBe(store);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('only containers get an icon — the shape carries the rest', () => {
|
|
126
|
+
for (const kind of ['component', 'external-system', 'person'] as const) {
|
|
127
|
+
expect(nodeKindIcon(node({ kind }))).toBeUndefined();
|
|
128
|
+
}
|
|
124
129
|
});
|
|
125
130
|
|
|
126
131
|
test('the icon does not depend on confirmation state', () => {
|
|
@@ -130,16 +135,22 @@ describe('nodeKindIcon — the kind as a glyph, not a word', () => {
|
|
|
130
135
|
});
|
|
131
136
|
});
|
|
132
137
|
|
|
133
|
-
describe('
|
|
134
|
-
test('a
|
|
135
|
-
expect(
|
|
136
|
-
|
|
138
|
+
describe('nodeTopLabel — the technology, or the kind when none is stated', () => {
|
|
139
|
+
test('a stated technology is the label', () => {
|
|
140
|
+
expect(
|
|
141
|
+
nodeTopLabel(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
|
|
142
|
+
).toBe('Bun + Electrobun');
|
|
143
|
+
expect(nodeTopLabel(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
|
|
137
144
|
});
|
|
138
145
|
|
|
139
|
-
test('
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
test('a container with no technology falls back to its kind', () => {
|
|
147
|
+
expect(nodeTopLabel(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the other kinds fall back to their C4 kind', () => {
|
|
151
|
+
expect(nodeTopLabel(node({ kind: 'component' }))).toBe('component');
|
|
152
|
+
expect(nodeTopLabel(node({ kind: 'external-system' }))).toBe('external-system');
|
|
153
|
+
expect(nodeTopLabel(node({ kind: 'person' }))).toBe('person');
|
|
143
154
|
});
|
|
144
155
|
});
|
|
145
156
|
|
|
@@ -174,52 +185,28 @@ describe('nodeShape — kind without relying on colour or dash', () => {
|
|
|
174
185
|
});
|
|
175
186
|
|
|
176
187
|
describe('nodeSubtitle', () => {
|
|
177
|
-
test('
|
|
178
|
-
|
|
179
|
-
node({
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
test('singularises a single member', () => {
|
|
200
|
-
const s = nodeSubtitle(node({ constructs: ['function'], members: ['a'] }));
|
|
201
|
-
expect(s).toContain('1 component');
|
|
202
|
-
expect(s).not.toContain('1 components');
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test('a component shows its construct and file basename', () => {
|
|
206
|
-
const s = nodeSubtitle(
|
|
207
|
-
node({
|
|
208
|
-
kind: 'component',
|
|
209
|
-
component: {
|
|
210
|
-
alias: 'a',
|
|
211
|
-
name: 'buildGroups',
|
|
212
|
-
construct: 'function',
|
|
213
|
-
file: 'src/subsystem/model.ts',
|
|
214
|
-
purl: 'pkg:x#src/subsystem/model.ts',
|
|
215
|
-
},
|
|
216
|
-
}),
|
|
217
|
-
);
|
|
218
|
-
expect(s).toBe('function · model.ts');
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test('a component shows its technology when it has one', () => {
|
|
222
|
-
expect(nodeSubtitle(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
|
|
188
|
+
test('is the element description when it has one', () => {
|
|
189
|
+
expect(
|
|
190
|
+
nodeSubtitle(node({ technology: 'Bun', description: 'Runs the audit pipeline.' })),
|
|
191
|
+
).toBe('Runs the audit pipeline.');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('is empty when nothing is stated — there is no fallback', () => {
|
|
195
|
+
const withTech = node({ technology: 'Bun + Electrobun' });
|
|
196
|
+
const withMembers = node({ constructs: ['function', 'store'], members: ['a', 'b'] });
|
|
197
|
+
const component = node({
|
|
198
|
+
kind: 'component',
|
|
199
|
+
component: {
|
|
200
|
+
alias: 'a',
|
|
201
|
+
name: 'buildGroups',
|
|
202
|
+
construct: 'function',
|
|
203
|
+
file: 'src/subsystem/model.ts',
|
|
204
|
+
purl: 'pkg:x#src/subsystem/model.ts',
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
for (const n of [withTech, withMembers, component]) {
|
|
208
|
+
expect(nodeSubtitle(n)).toBe('');
|
|
209
|
+
}
|
|
223
210
|
});
|
|
224
211
|
});
|
|
225
212
|
|
|
@@ -285,8 +272,8 @@ describe('nodeSize — a component is a proper square', () => {
|
|
|
285
272
|
|
|
286
273
|
describe('card metrics', () => {
|
|
287
274
|
test('exposes the size the graph lays out against', () => {
|
|
288
|
-
expect(NODE_W).toBe(
|
|
289
|
-
expect(NODE_H).toBe(
|
|
275
|
+
expect(NODE_W).toBe(250);
|
|
276
|
+
expect(NODE_H).toBe(150);
|
|
290
277
|
});
|
|
291
278
|
|
|
292
279
|
test('COMPONENT_SIZE is a square, not just a smaller rectangle', () => {
|
|
@@ -295,23 +282,23 @@ describe('card metrics', () => {
|
|
|
295
282
|
});
|
|
296
283
|
|
|
297
284
|
test('is near-square, not a wide strip', () => {
|
|
298
|
-
// The card carries
|
|
299
|
-
//
|
|
300
|
-
//
|
|
285
|
+
// The card carries three fields (technology row, label, description). A
|
|
286
|
+
// 3:1 strip wraps the last two hard; this is the aspect ratio that lets
|
|
287
|
+
// each sit on its own line.
|
|
301
288
|
const ratio = NODE_W / NODE_H;
|
|
302
289
|
expect(ratio).toBeGreaterThan(1);
|
|
303
290
|
expect(ratio).toBeLessThan(1.8);
|
|
304
291
|
});
|
|
305
292
|
|
|
306
|
-
test('is tall enough for
|
|
307
|
-
//
|
|
293
|
+
test('is tall enough for three lines of content', () => {
|
|
294
|
+
// technology row + 2-line label + description, plus padding.
|
|
308
295
|
expect(NODE_H).toBeGreaterThanOrEqual(130);
|
|
309
296
|
});
|
|
310
297
|
|
|
311
|
-
test('leaves breathing room around
|
|
298
|
+
test('leaves breathing room around three lines of text', () => {
|
|
312
299
|
// Top-aligned now, not centred, so this is about slack rather than
|
|
313
|
-
// balance:
|
|
314
|
-
const contentBudget = 16 + 40 + 16 +
|
|
300
|
+
// balance: technology row + 2-line label slot + description + padding.
|
|
301
|
+
const contentBudget = 16 + 40 + 16 + 20; // lines + 10px padding ×2
|
|
315
302
|
expect(NODE_H - contentBudget).toBeGreaterThanOrEqual(12);
|
|
316
303
|
});
|
|
317
304
|
|
|
@@ -323,13 +310,20 @@ describe('card metrics', () => {
|
|
|
323
310
|
});
|
|
324
311
|
|
|
325
312
|
describe('container kinds', () => {
|
|
326
|
-
test('
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
313
|
+
test('each container kind has an icon and a fallback label', () => {
|
|
314
|
+
for (const containerKind of ['application', 'data-store'] as const) {
|
|
315
|
+
expect(nodeKindIcon(node({ containerKind }))).toBeDefined();
|
|
316
|
+
// No technology stated, so the label falls back to the kind.
|
|
317
|
+
expect(nodeTopLabel(node({ containerKind, technology: '' }))).toBe(containerKind);
|
|
330
318
|
}
|
|
331
319
|
});
|
|
332
320
|
|
|
321
|
+
test('application and data-store do not share an icon', () => {
|
|
322
|
+
expect(nodeKindIcon(node({ containerKind: 'application' }))).not.toBe(
|
|
323
|
+
nodeKindIcon(node({ containerKind: 'data-store' })),
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
333
327
|
test('a library is not one of them', () => {
|
|
334
328
|
// C4 says a module "typically" is not an element at all, so there is no
|
|
335
329
|
// kind to render. A library-shaped group is a review comment.
|
|
@@ -7,24 +7,24 @@
|
|
|
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
|
-
* Kind
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Kind is carried by shape and size — plus an icon for a container's sort,
|
|
11
|
+
* application vs data-store — and C4 notation is surfaced in the order a reader
|
|
12
|
+
* wants it: technology on the top row, description under the label.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
16
|
-
import {
|
|
16
|
+
import { AppWindow, Database } from 'lucide-react';
|
|
17
17
|
import type { LucideIcon } from 'lucide-react';
|
|
18
|
-
import {
|
|
18
|
+
import { TechMark, technologyBrand } from './techIcons';
|
|
19
19
|
import type { C4Node } from './c4';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Card box for a container: a wide rectangle. A container carries four things
|
|
23
|
-
* at once (
|
|
24
|
-
* and a strip would force the last two to
|
|
23
|
+
* at once (technology row, label, description, and the notation gap it still
|
|
24
|
+
* has), and a strip would force the last two to wrap hard.
|
|
25
25
|
*/
|
|
26
|
-
export const NODE_W =
|
|
27
|
-
export const NODE_H =
|
|
26
|
+
export const NODE_W = 250;
|
|
27
|
+
export const NODE_H = 150;
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* A component is a proper square — equal sides, and visibly smaller than the
|
|
@@ -32,7 +32,7 @@ export const NODE_H = 138;
|
|
|
32
32
|
* these are different C4 levels, and it survives grayscale and colour-blind
|
|
33
33
|
* reading where a border-weight difference alone does not.
|
|
34
34
|
*/
|
|
35
|
-
export const COMPONENT_SIZE =
|
|
35
|
+
export const COMPONENT_SIZE = 140;
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Per-level box size. The graph lays out against these, so a square component
|
|
@@ -45,12 +45,10 @@ export function nodeSize(node: C4Node): { width: number; height: number } {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Fixed slot
|
|
49
|
-
*
|
|
50
|
-
* marker is two lines shorter and a row of cards reads as ragged.
|
|
48
|
+
* Fixed label slot, so the description below sits at the same y on every card
|
|
49
|
+
* even when a label is one line rather than two.
|
|
51
50
|
*/
|
|
52
51
|
const LABEL_SLOT_H = 40;
|
|
53
|
-
const GAP_SLOT_H = 18;
|
|
54
52
|
|
|
55
53
|
type Theme = ReturnType<typeof useTheme>['theme'];
|
|
56
54
|
|
|
@@ -91,32 +89,18 @@ export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeSty
|
|
|
91
89
|
// Dashed — an agent asked; a human has not answered.
|
|
92
90
|
return { color: theme.colors.warning ?? '#e8a33a', dash: 'dashed', width: weight };
|
|
93
91
|
case 'rejected':
|
|
94
|
-
// Should never be drawn (
|
|
92
|
+
// Should never be drawn (a rejected element is not projected); belt-and-braces.
|
|
95
93
|
return { color: muted, dash: 'dotted', width: 1 };
|
|
96
94
|
}
|
|
97
95
|
}
|
|
98
96
|
|
|
99
|
-
function baseName(path: string): string {
|
|
100
|
-
const parts = path.split('/').filter(Boolean);
|
|
101
|
-
return parts[parts.length - 1] ?? path;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
97
|
/**
|
|
105
|
-
* The
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* not repeated here. Technology is the remaining confirmed attribute; without
|
|
109
|
-
* one we fall back to what the document told us.
|
|
98
|
+
* The line under the label: the element's one-line responsibility, which C4
|
|
99
|
+
* asks every element to carry. No fallback — when nothing is stated, the
|
|
100
|
+
* notation-gap row says so rather than the card inventing a summary.
|
|
110
101
|
*/
|
|
111
102
|
export function nodeSubtitle(node: C4Node): string {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// No stated technology: fall back to what the document told us.
|
|
115
|
-
if (node.kind === 'component') {
|
|
116
|
-
return `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`;
|
|
117
|
-
}
|
|
118
|
-
const count = node.members.length;
|
|
119
|
-
return `${describeConstructBreakdown(node.constructs)}${count > 0 ? ` · ${count} component${count === 1 ? '' : 's'}` : ''}`;
|
|
103
|
+
return node.description ?? '';
|
|
120
104
|
}
|
|
121
105
|
|
|
122
106
|
/**
|
|
@@ -130,31 +114,26 @@ export function nodeTag(node: C4Node): string {
|
|
|
130
114
|
}
|
|
131
115
|
|
|
132
116
|
/**
|
|
133
|
-
* The
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
117
|
+
* The icon on the top row: a container's sort, **application vs data-store**.
|
|
118
|
+
*
|
|
119
|
+
* The C4 kind itself does not need a glyph — the box shape already carries it
|
|
120
|
+
* (square vs rectangle vs pill), and the row's one text slot is worth more
|
|
121
|
+
* spent on the technology. Only containers have a C4-recognised split, so the
|
|
122
|
+
* other three kinds return nothing.
|
|
137
123
|
*/
|
|
138
|
-
export function nodeKindIcon(node: C4Node): LucideIcon {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return Container;
|
|
142
|
-
case 'component':
|
|
143
|
-
return Component;
|
|
144
|
-
case 'external-system':
|
|
145
|
-
return Cloud;
|
|
146
|
-
case 'person':
|
|
147
|
-
return UserRound;
|
|
148
|
-
}
|
|
124
|
+
export function nodeKindIcon(node: C4Node): LucideIcon | undefined {
|
|
125
|
+
if (node.kind !== 'container') return undefined;
|
|
126
|
+
return node.containerKind === 'data-store' ? Database : AppWindow;
|
|
149
127
|
}
|
|
150
128
|
|
|
151
129
|
/**
|
|
152
|
-
* The
|
|
153
|
-
*
|
|
154
|
-
* so
|
|
130
|
+
* The text on the top row: the technology the box is built with (Bun, React,
|
|
131
|
+
* Postgres…). Falls back to the container kind / C4 kind when no technology is
|
|
132
|
+
* stated, so the row is never blank.
|
|
155
133
|
*/
|
|
156
|
-
export function
|
|
157
|
-
|
|
134
|
+
export function nodeTopLabel(node: C4Node): string {
|
|
135
|
+
if (node.technology) return node.technology;
|
|
136
|
+
return node.kind === 'container' ? (node.containerKind ?? 'container') : node.kind;
|
|
158
137
|
}
|
|
159
138
|
|
|
160
139
|
/** How a box is drawn. Shape carries the C4 kind, independent of colour. */
|
|
@@ -226,11 +205,15 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
226
205
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
227
206
|
const style = nodeStyle(node, theme, selected);
|
|
228
207
|
const shape = nodeShape(node);
|
|
229
|
-
const missing = nodeMissing(node);
|
|
230
208
|
const Icon = nodeKindIcon(node);
|
|
231
|
-
const
|
|
209
|
+
const brand = technologyBrand(node.technology);
|
|
210
|
+
const topLabel = nodeTopLabel(node);
|
|
211
|
+
const subtitle = nodeSubtitle(node);
|
|
232
212
|
|
|
233
213
|
const size = nodeSize(node);
|
|
214
|
+
// A pill (a person) has no straight left edge, so left-aligned text reads as
|
|
215
|
+
// spilling out of the oval. Centre the text and inset it from the curve.
|
|
216
|
+
const centered = shape.kind === 'pill';
|
|
234
217
|
|
|
235
218
|
return (
|
|
236
219
|
<div
|
|
@@ -246,7 +229,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
246
229
|
// height on every card. In a graph grid that makes the row unscannable.
|
|
247
230
|
justifyContent: 'flex-start',
|
|
248
231
|
gap: 3,
|
|
249
|
-
padding: '10px 12px',
|
|
232
|
+
padding: centered ? '10px 30px' : '10px 12px',
|
|
250
233
|
borderRadius: shape.radius,
|
|
251
234
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
252
235
|
border: `${style.width} ${style.dash} ${style.color}`,
|
|
@@ -255,30 +238,26 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
255
238
|
fontFamily: theme.fonts.body,
|
|
256
239
|
}}
|
|
257
240
|
>
|
|
258
|
-
{/*
|
|
259
|
-
|
|
260
|
-
|
|
241
|
+
{/* Left: the brand mark when we hold the official one, then the
|
|
242
|
+
technology. Right: a container's C4 sort, application vs data-store.
|
|
243
|
+
The kind itself stays on the shape, confirmation on the border (see
|
|
244
|
+
`nodeShape`, `nodeStyle`). */}
|
|
261
245
|
<span
|
|
262
246
|
style={{
|
|
263
247
|
display: 'flex',
|
|
264
248
|
alignItems: 'center',
|
|
249
|
+
justifyContent: centered ? 'center' : 'space-between',
|
|
265
250
|
gap: 6,
|
|
266
251
|
fontFamily: theme.fonts.monospace,
|
|
267
252
|
fontSize: theme.fontSizes[0],
|
|
268
|
-
color:
|
|
253
|
+
color: muted,
|
|
269
254
|
letterSpacing: 0.5,
|
|
270
255
|
textTransform: 'uppercase',
|
|
271
256
|
minWidth: 0,
|
|
272
257
|
}}
|
|
273
258
|
>
|
|
274
|
-
<span
|
|
275
|
-
|
|
276
|
-
aria-label={nodeTag(node)}
|
|
277
|
-
style={{ display: 'inline-flex', flexShrink: 0 }}
|
|
278
|
-
>
|
|
279
|
-
<Icon size={13} strokeWidth={2.25} />
|
|
280
|
-
</span>
|
|
281
|
-
{typeLabel && (
|
|
259
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
|
260
|
+
{brand && <TechMark brand={brand} />}
|
|
282
261
|
<span
|
|
283
262
|
style={{
|
|
284
263
|
fontWeight: 600,
|
|
@@ -288,7 +267,16 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
288
267
|
minWidth: 0,
|
|
289
268
|
}}
|
|
290
269
|
>
|
|
291
|
-
{
|
|
270
|
+
{topLabel}
|
|
271
|
+
</span>
|
|
272
|
+
</span>
|
|
273
|
+
{Icon && (
|
|
274
|
+
<span
|
|
275
|
+
title={node.containerKind ?? node.kind}
|
|
276
|
+
aria-label={node.containerKind ?? node.kind}
|
|
277
|
+
style={{ display: 'inline-flex', flexShrink: 0, color: style.color }}
|
|
278
|
+
>
|
|
279
|
+
<Icon size={13} strokeWidth={2.25} />
|
|
292
280
|
</span>
|
|
293
281
|
)}
|
|
294
282
|
</span>
|
|
@@ -307,43 +295,25 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
|
|
|
307
295
|
WebkitLineClamp: 2,
|
|
308
296
|
WebkitBoxOrient: 'vertical',
|
|
309
297
|
overflow: 'hidden',
|
|
298
|
+
textAlign: centered ? 'center' : 'left',
|
|
310
299
|
}}
|
|
311
300
|
title={node.label}
|
|
312
301
|
>
|
|
313
302
|
{node.label}
|
|
314
303
|
</span>
|
|
315
|
-
|
|
316
|
-
style={{
|
|
317
|
-
fontFamily: theme.fonts.monospace,
|
|
318
|
-
fontSize: theme.fontSizes[0],
|
|
319
|
-
color: muted,
|
|
320
|
-
whiteSpace: 'nowrap',
|
|
321
|
-
overflow: 'hidden',
|
|
322
|
-
textOverflow: 'ellipsis',
|
|
323
|
-
}}
|
|
324
|
-
title={node.description ?? node.members.join(', ')}
|
|
325
|
-
>
|
|
326
|
-
{nodeSubtitle(node)}
|
|
327
|
-
</span>
|
|
328
|
-
{/* Reserved row: without it, a card with no notation gap is two lines
|
|
329
|
-
shorter and the row of cards reads as ragged. */}
|
|
330
|
-
<span style={{ height: GAP_SLOT_H }} aria-hidden="true" />
|
|
331
|
-
{missing.length > 0 && (
|
|
304
|
+
{subtitle && (
|
|
332
305
|
<span
|
|
333
|
-
title={`C4 requires a ${missing.join(' and a ')} on every container`}
|
|
334
306
|
style={{
|
|
335
307
|
fontFamily: theme.fonts.monospace,
|
|
336
308
|
fontSize: theme.fontSizes[0],
|
|
337
|
-
color:
|
|
338
|
-
lineHeight: 1.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
WebkitBoxOrient: 'vertical',
|
|
343
|
-
overflow: 'hidden',
|
|
309
|
+
color: muted,
|
|
310
|
+
lineHeight: 1.35,
|
|
311
|
+
whiteSpace: 'normal',
|
|
312
|
+
overflowWrap: 'anywhere',
|
|
313
|
+
textAlign: centered ? 'center' : 'left',
|
|
344
314
|
}}
|
|
345
315
|
>
|
|
346
|
-
|
|
316
|
+
{subtitle}
|
|
347
317
|
</span>
|
|
348
318
|
)}
|
|
349
319
|
{handles}
|
|
@@ -1190,18 +1190,15 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1190
1190
|
const stepIssueBadgeByEdgeId = useMemo(() => {
|
|
1191
1191
|
const map = new Map<string, { kind: 'step_unconfirmed' | 'step_stale'; severity: 'info' | 'error'; count: number }>();
|
|
1192
1192
|
if (!issues?.length || !trails?.length) {
|
|
1193
|
-
console.log('[stepIssueBadge] early return: issues=', issues?.length, 'trails=', trails?.length);
|
|
1194
1193
|
return map;
|
|
1195
1194
|
}
|
|
1196
1195
|
for (const issue of issues) {
|
|
1197
1196
|
if (issue.kind !== 'step_unconfirmed' && issue.kind !== 'step_stale') continue;
|
|
1198
1197
|
const stepInfo = issueStep(issue);
|
|
1199
|
-
console.log('[stepIssueBadge] issue', issue.id, 'kind=', issue.kind, 'stepInfo=', stepInfo);
|
|
1200
1198
|
if (!stepInfo) continue;
|
|
1201
1199
|
const step = stepInfo.trail.steps[stepInfo.stepIndex];
|
|
1202
1200
|
if (!step) continue;
|
|
1203
1201
|
const edgeId = trailStepGraphEdgeId(step);
|
|
1204
|
-
console.log('[stepIssueBadge] -> edgeId=', edgeId);
|
|
1205
1202
|
const prev = map.get(edgeId);
|
|
1206
1203
|
// step_stale is worse than step_unconfirmed
|
|
1207
1204
|
const isWorse = issue.kind === 'step_stale' && prev?.kind !== 'step_stale';
|
|
@@ -1211,10 +1208,25 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1211
1208
|
count: (prev?.count ?? 0) + 1,
|
|
1212
1209
|
});
|
|
1213
1210
|
}
|
|
1214
|
-
console.log('[stepIssueBadge] final map size=', map.size);
|
|
1215
1211
|
return map;
|
|
1216
1212
|
}, [issues, trails, issueStep]);
|
|
1217
1213
|
|
|
1214
|
+
// Edges backed by a step that ITSELF declares `proposed` — a planned seam
|
|
1215
|
+
// between otherwise-real components. Distinct from a proposed ENDPOINT
|
|
1216
|
+
// (the node is the placeholder there, and draws its own dashed border):
|
|
1217
|
+
// here the seam is the claim that is not real yet, so the edge label draws
|
|
1218
|
+
// dashed. Proposed steps are never verified, so this never competes with
|
|
1219
|
+
// the step-issue badge.
|
|
1220
|
+
const proposedEdgeIds = useMemo(() => {
|
|
1221
|
+
const set = new Set<string>();
|
|
1222
|
+
for (const trail of trails ?? []) {
|
|
1223
|
+
for (const step of trail.steps) {
|
|
1224
|
+
if (step.proposed) set.add(trailStepGraphEdgeId(step));
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return set;
|
|
1228
|
+
}, [trails]);
|
|
1229
|
+
|
|
1218
1230
|
// Expanded verification layers in the diagnostics list. When any layer with
|
|
1219
1231
|
// findings is expanded, the canvas dims everything that layer does not
|
|
1220
1232
|
// implicate (see `issueFocus`). The list publishes this through
|
|
@@ -1534,6 +1546,17 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1534
1546
|
() => convertedEdges.map((e) => e.id).sort().join(','),
|
|
1535
1547
|
[convertedEdges],
|
|
1536
1548
|
);
|
|
1549
|
+
// Stamp `proposedStep` onto edge data so the SVG edge itself (not just its
|
|
1550
|
+
// label) draws dashed when a backing trail step declares itself proposed.
|
|
1551
|
+
const proposedStampedEdges = useMemo(
|
|
1552
|
+
() =>
|
|
1553
|
+
baseEdges.map((e) =>
|
|
1554
|
+
proposedEdgeIds.has(e.id)
|
|
1555
|
+
? { ...e, data: { ...(e.data as object), proposedStep: true } }
|
|
1556
|
+
: e,
|
|
1557
|
+
),
|
|
1558
|
+
[baseEdges, proposedEdgeIds],
|
|
1559
|
+
);
|
|
1537
1560
|
|
|
1538
1561
|
const dispEdges = useMemo(() => {
|
|
1539
1562
|
const paint = (e: Edge, dimmed: boolean): Edge => {
|
|
@@ -1562,7 +1585,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1562
1585
|
return isTrailMechanism(mechanism);
|
|
1563
1586
|
};
|
|
1564
1587
|
if (openedEdgeIds || focusEdgeIds || previewEdgeIds || issueFocusEdgeIds) {
|
|
1565
|
-
return
|
|
1588
|
+
return proposedStampedEdges.map((e) => {
|
|
1566
1589
|
const vis = flowElementVisibility({
|
|
1567
1590
|
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
1568
1591
|
inSelected: focusEdgeIds?.has(e.id) === true,
|
|
@@ -1576,13 +1599,13 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
1576
1599
|
});
|
|
1577
1600
|
}
|
|
1578
1601
|
if (selectedEdgeId) {
|
|
1579
|
-
return
|
|
1602
|
+
return proposedStampedEdges.map((e) => ({
|
|
1580
1603
|
...paint(e, e.id !== selectedEdgeId),
|
|
1581
1604
|
hidden: !edgeInView(e),
|
|
1582
1605
|
}));
|
|
1583
1606
|
}
|
|
1584
|
-
return
|
|
1585
|
-
}, [
|
|
1607
|
+
return proposedStampedEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
|
|
1608
|
+
}, [proposedStampedEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
|
|
1586
1609
|
|
|
1587
1610
|
const onNodesChange = useCallback(
|
|
1588
1611
|
(changes: NodeChange[]) => {
|
|
@@ -2499,7 +2522,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2499
2522
|
pathLength?: number;
|
|
2500
2523
|
} | undefined;
|
|
2501
2524
|
const stepIssue = stepIssueBadgeByEdgeId.get(e.id);
|
|
2502
|
-
|
|
2525
|
+
const proposed = proposedEdgeIds.has(e.id);
|
|
2503
2526
|
return {
|
|
2504
2527
|
id: e.id,
|
|
2505
2528
|
mechanism: d?.mechanism ?? 'uses',
|
|
@@ -2510,11 +2533,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2510
2533
|
pathLength: d?.pathLength ?? 0,
|
|
2511
2534
|
stepNos: selectedFlowStepNos?.get(e.id),
|
|
2512
2535
|
stepIssue,
|
|
2536
|
+
proposed,
|
|
2513
2537
|
};
|
|
2514
2538
|
});
|
|
2515
|
-
console.log('[edgeLabels] total labels=', labels.length, 'with stepIssue=', labels.filter(l => l.stepIssue).length);
|
|
2516
2539
|
return labels;
|
|
2517
|
-
}, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId]);
|
|
2540
|
+
}, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId, proposedEdgeIds]);
|
|
2518
2541
|
|
|
2519
2542
|
// Unique source files across components → sidebar file trees.
|
|
2520
2543
|
const treeFilePaths = treeFiles;
|
|
@@ -2941,7 +2964,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2941
2964
|
crisp rounded box; soft / ambiguous ones (uses, feeds,
|
|
2942
2965
|
watches …) get a real lobed cloud silhouette — ambiguous, not
|
|
2943
2966
|
a dashed proposal (or a pill, which border-radius can only
|
|
2944
|
-
ever make).
|
|
2967
|
+
ever make). A step that ITSELF declares `proposed` dashes the
|
|
2968
|
+
silhouette — the seam is planned, distinct from a proposed
|
|
2969
|
+
ENDPOINT, which dashes the node instead. */}
|
|
2945
2970
|
{verifiable ? (
|
|
2946
2971
|
<span
|
|
2947
2972
|
aria-hidden
|
|
@@ -2949,7 +2974,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2949
2974
|
position: 'absolute',
|
|
2950
2975
|
inset: 0,
|
|
2951
2976
|
borderRadius: 4,
|
|
2952
|
-
border:
|
|
2977
|
+
border: lbl.proposed
|
|
2978
|
+
? `1.5px dashed ${color}`
|
|
2979
|
+
: `0.5px solid ${color}`,
|
|
2953
2980
|
background:
|
|
2954
2981
|
theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
2955
2982
|
pointerEvents: 'none',
|
|
@@ -2974,6 +3001,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
|
|
|
2974
3001
|
stroke={hexWithAlpha(color, 0.75)}
|
|
2975
3002
|
strokeWidth={1.2}
|
|
2976
3003
|
strokeLinejoin="round"
|
|
3004
|
+
strokeDasharray={lbl.proposed ? '4 3' : undefined}
|
|
2977
3005
|
/>
|
|
2978
3006
|
</svg>
|
|
2979
3007
|
)}
|