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