@principal-ai/subsystems-react 0.51.4 → 0.52.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 +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +3 -7
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +159 -45
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +26 -26
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +56 -30
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +1 -1
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +20 -1
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +42 -19
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +48 -49
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +50 -0
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/c4Evidence.d.ts +88 -84
- package/dist/subsystem/c4Evidence.d.ts.map +1 -1
- package/dist/subsystem/c4Evidence.js +113 -166
- package/dist/subsystem/c4Evidence.js.map +1 -1
- package/dist/subsystem/nodes.js +5 -5
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/techIcons/index.d.ts +5 -0
- package/dist/subsystem/techIcons/index.d.ts.map +1 -1
- package/dist/subsystem/techIcons/index.js +3 -0
- package/dist/subsystem/techIcons/index.js.map +1 -1
- package/dist/utils/edgeLabel.d.ts +15 -1
- package/dist/utils/edgeLabel.d.ts.map +1 -1
- package/dist/utils/edgeLabel.js +25 -1
- package/dist/utils/edgeLabel.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +47 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +71 -7
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +8 -6
- package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +198 -0
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +42 -6
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +9 -7
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +686 -2
- package/src/subsystem/C4Graph.tsx +242 -82
- package/src/subsystem/C4NodeCard.test.ts +32 -22
- package/src/subsystem/C4NodeCard.tsx +67 -39
- package/src/subsystem/IssueList.tsx +23 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +38 -16
- package/src/subsystem/c4.protocolColor.test.ts +26 -0
- package/src/subsystem/c4.ts +76 -52
- package/src/subsystem/c4Evidence.test.ts +177 -237
- package/src/subsystem/c4Evidence.ts +160 -235
- package/src/subsystem/nodes.group.test.tsx +15 -0
- package/src/subsystem/nodes.tsx +7 -7
- package/src/subsystem/techIcons/index.tsx +8 -0
- package/src/utils/edgeLabel.test.ts +21 -2
- package/src/utils/edgeLabel.ts +29 -1
- package/src/utils/elkLayout.test.ts +16 -0
- package/src/utils/elkLayout.ts +119 -7
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
|
@@ -27,39 +27,30 @@ import {
|
|
|
27
27
|
} from '@xyflow/react';
|
|
28
28
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
29
29
|
import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
|
|
30
|
-
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT,
|
|
31
|
-
import {
|
|
32
|
-
import { C4NodeCard, NODE_H, NODE_W, nodeSize
|
|
30
|
+
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
|
|
31
|
+
import { protocolColor } from './c4';
|
|
32
|
+
import { C4NodeCard, NODE_H, NODE_W, nodeSize } from './C4NodeCard';
|
|
33
33
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
34
|
-
import type {
|
|
34
|
+
import type { C4Element, C4Model } from './c4';
|
|
35
35
|
|
|
36
36
|
export interface C4GraphProps {
|
|
37
37
|
model: C4Model;
|
|
38
38
|
title?: string;
|
|
39
39
|
onSelectNode?: (id: string | null) => void;
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* Above the default 28 the `needs …` gap row was removed, so cards are
|
|
46
|
-
* shorter and packing them tighter than the spacing they were tuned at makes
|
|
47
|
-
* them collide. Raise it for a more airy graph.
|
|
41
|
+
* Breathing room around each card, in flow px, applied as ELK spacing (not by
|
|
42
|
+
* inflating the node box — that left the edge lines starting short of the
|
|
43
|
+
* card border). `nodeSpacing` is `2 × gutter`; `edgeNodeSpacing` is `gutter`.
|
|
48
44
|
* @default 28
|
|
49
45
|
*/
|
|
50
46
|
gutter?: number;
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>) {
|
|
59
|
-
const { node, selected } = props.data;
|
|
49
|
+
function C4NodeView(props: NodeProps<Node<{ element: C4Element; selected: boolean }>>) {
|
|
50
|
+
const { element, selected } = props.data;
|
|
60
51
|
return (
|
|
61
52
|
<C4NodeCard
|
|
62
|
-
node={
|
|
53
|
+
node={element}
|
|
63
54
|
selected={selected}
|
|
64
55
|
handles={
|
|
65
56
|
<>
|
|
@@ -82,11 +73,9 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
|
|
|
82
73
|
function C4EdgeLabels({
|
|
83
74
|
positions,
|
|
84
75
|
edges,
|
|
85
|
-
muted,
|
|
86
76
|
}: {
|
|
87
77
|
positions: Map<string, { x: number; y: number }>;
|
|
88
78
|
edges: C4Model['edges'];
|
|
89
|
-
muted: string;
|
|
90
79
|
}) {
|
|
91
80
|
const { theme } = useTheme();
|
|
92
81
|
const viewport = useViewport();
|
|
@@ -106,7 +95,7 @@ function C4EdgeLabels({
|
|
|
106
95
|
{[...positions].map(([id, p]) => {
|
|
107
96
|
const edge = byId.get(id);
|
|
108
97
|
if (!edge) return null;
|
|
109
|
-
const color =
|
|
98
|
+
const color = protocolColor(edge.protocol);
|
|
110
99
|
const text = edge.protocol ?? edge.label;
|
|
111
100
|
return (
|
|
112
101
|
<div
|
|
@@ -120,7 +109,7 @@ function C4EdgeLabels({
|
|
|
120
109
|
display: 'flex',
|
|
121
110
|
alignItems: 'center',
|
|
122
111
|
justifyContent: 'center',
|
|
123
|
-
width:
|
|
112
|
+
width: estimateEdgeLabelWidth(text),
|
|
124
113
|
height: EDGE_LABEL_HEIGHT,
|
|
125
114
|
boxSizing: 'border-box',
|
|
126
115
|
padding: '7px 8px',
|
|
@@ -263,12 +252,17 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
263
252
|
setReady(false);
|
|
264
253
|
setSelectedId(null);
|
|
265
254
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
255
|
+
// Draw only the elements for this view. A container diagram shows the
|
|
256
|
+
// runtime units (containers, externals, people); components belong to the
|
|
257
|
+
// component view. Components are still carried on the model (see the side
|
|
258
|
+
// panel), just not drawn here.
|
|
259
|
+
const inView = (n: C4Element): boolean =>
|
|
260
|
+
model.view === 'component' ? n.kind !== 'container' : n.kind !== 'component';
|
|
261
|
+
|
|
262
|
+
// Layout against the true card size, so ELK's ports sit on the card's
|
|
263
|
+
// border — an inflated box leaves the line starting short of the card edge.
|
|
264
|
+
// The `gutter` becomes ELK spacing instead (see `nodeSpacing` below).
|
|
265
|
+
const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
|
|
272
266
|
const size = nodeSize(n);
|
|
273
267
|
return {
|
|
274
268
|
id: n.id,
|
|
@@ -276,10 +270,10 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
276
270
|
// Fallback grid pitch uses the container box — the largest, so the
|
|
277
271
|
// pre-layout positions never overlap. ELK replaces them on success.
|
|
278
272
|
position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
|
|
279
|
-
width: size.width
|
|
280
|
-
height: size.height
|
|
273
|
+
width: size.width,
|
|
274
|
+
height: size.height,
|
|
281
275
|
...(n.parentId ? { parentId: n.parentId } : {}),
|
|
282
|
-
data: {
|
|
276
|
+
data: { element: n, selected: false },
|
|
283
277
|
};
|
|
284
278
|
});
|
|
285
279
|
|
|
@@ -289,9 +283,32 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
289
283
|
...(g.parentId ? { parentId: g.parentId } : {}),
|
|
290
284
|
}));
|
|
291
285
|
|
|
286
|
+
// Band nodes so a flat layout still reads as C4 and frames can't interleave.
|
|
287
|
+
//
|
|
288
|
+
// Flat mode has no ELK parents, so without bands ELK mixes members of
|
|
289
|
+
// different frames across the whole graph — their synthesized bounding
|
|
290
|
+
// boxes then overlap. A partition per frame keeps each frame's members
|
|
291
|
+
// contiguous; people lead, externals trail.
|
|
292
|
+
//
|
|
293
|
+
// Order (ascending = earlier in the layout direction): people, then each
|
|
294
|
+
// frame in model order, then external systems.
|
|
295
|
+
const frameIds = model.groups.map((g) => g.id);
|
|
296
|
+
const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
|
|
297
|
+
const partitionByNode = new Map<string, number>(
|
|
298
|
+
model.nodes.map((n) => {
|
|
299
|
+
if (n.kind === 'person') return [n.id, 0];
|
|
300
|
+
if (n.kind === 'external-system') return [n.id, frameIds.length + 1];
|
|
301
|
+
// A node inside a frame shares its frame's band; a frame-less node
|
|
302
|
+
// (a container at container view) gets its own by id order.
|
|
303
|
+
const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
|
|
304
|
+
return [n.id, 1 + band];
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
|
|
292
308
|
const rfEdges: Edge[] = model.edges.map((e) => {
|
|
293
|
-
//
|
|
294
|
-
|
|
309
|
+
// Colour keys off the protocol, not the trail verb: on a container
|
|
310
|
+
// diagram the transport is what a reader distinguishes at a glance.
|
|
311
|
+
const color = protocolColor(e.protocol);
|
|
295
312
|
return {
|
|
296
313
|
id: e.id,
|
|
297
314
|
type: 'c4-edge',
|
|
@@ -311,7 +328,22 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
311
328
|
preserveNodePositions: false,
|
|
312
329
|
groups,
|
|
313
330
|
keepSingletonGroups: true,
|
|
314
|
-
edgeLabels: { enabled: true, placement: 'CENTER' },
|
|
331
|
+
edgeLabels: { enabled: true, placement: 'CENTER', width: C4_LABEL_WIDTH, measure: estimateEdgeLabelWidth },
|
|
332
|
+
// The gutter that used to inflate each node box is now real spacing, so
|
|
333
|
+
// the ports still sit on the card edge.
|
|
334
|
+
nodeSpacing: gutter * 2,
|
|
335
|
+
edgeNodeSpacing: gutter,
|
|
336
|
+
// Between-layer/between-partition gap. This is what keeps the external
|
|
337
|
+
// band clear of the system frame's padding — ELK gives partitions zero
|
|
338
|
+
// default separation. It stacks on top of ELK's reserved label layer, so
|
|
339
|
+
// it is kept modest.
|
|
340
|
+
interLayerSpacing: 40,
|
|
341
|
+
// Draw the system frame as a decoration around the boxes rather than an
|
|
342
|
+
// ELK parent. Nesting made in-frame edges inherit the frame's padding and
|
|
343
|
+
// spacing, so they ran longer than a root-level edge touching the same
|
|
344
|
+
// nodes; flattening gives every edge one spacing.
|
|
345
|
+
flatGroups: true,
|
|
346
|
+
partitionByNode,
|
|
315
347
|
})
|
|
316
348
|
.then((result) => {
|
|
317
349
|
if (!alive) return;
|
|
@@ -341,15 +373,26 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
341
373
|
return d;
|
|
342
374
|
};
|
|
343
375
|
|
|
376
|
+
// Shell positions from flat synthesis are absolute. React Flow reads a
|
|
377
|
+
// child's `position` as relative to its parent, so a shell nested in
|
|
378
|
+
// another shell must be made relative to it.
|
|
379
|
+
const shellAbs = new Map(result.groupBounds);
|
|
380
|
+
const shellOrigin = (id: string | undefined): { x: number; y: number } => {
|
|
381
|
+
if (!id) return { x: 0, y: 0 };
|
|
382
|
+
const b = shellAbs.get(id);
|
|
383
|
+
return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
|
|
384
|
+
};
|
|
385
|
+
|
|
344
386
|
const shells: Node[] = [...result.groupBounds.entries()]
|
|
345
387
|
.sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
|
|
346
388
|
.map(([id, b]) => {
|
|
347
389
|
const def = groupById.get(id);
|
|
348
390
|
const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
|
|
391
|
+
const origin = shellOrigin(parentId);
|
|
349
392
|
return {
|
|
350
393
|
id,
|
|
351
394
|
type: 'c4-group',
|
|
352
|
-
position: { x: b.x, y: b.y },
|
|
395
|
+
position: { x: b.x - origin.x, y: b.y - origin.y },
|
|
353
396
|
width: Math.max(200, b.width),
|
|
354
397
|
height: Math.max(120, b.height),
|
|
355
398
|
draggable: false,
|
|
@@ -365,13 +408,19 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
365
408
|
|
|
366
409
|
const leaves = rfNodes.map((n) => {
|
|
367
410
|
const p = positioned.get(n.id);
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
if
|
|
371
|
-
|
|
372
|
-
|
|
411
|
+
// Absolute flow position from the flat layout.
|
|
412
|
+
const abs = p ? p.position : n.position;
|
|
413
|
+
// Re-parent to the shell this node sits in, if any: a component's
|
|
414
|
+
// container, or a container's system frame. React Flow positions a
|
|
415
|
+
// child relative to its parent, so subtract the shell origin.
|
|
416
|
+
let parentId: string | undefined;
|
|
417
|
+
if (n.data && typeof n.data === 'object') {
|
|
418
|
+
const modelNode = (n.data as { element?: C4Element }).element;
|
|
419
|
+
if (modelNode?.parentId && builtGroups.has(modelNode.parentId)) parentId = modelNode.parentId;
|
|
373
420
|
}
|
|
374
|
-
|
|
421
|
+
const origin = shellOrigin(parentId);
|
|
422
|
+
const node = { ...n, position: { x: abs.x - origin.x, y: abs.y - origin.y } };
|
|
423
|
+
return (parentId ? { ...node, parentId } : { ...node, parentId: undefined }) as Node;
|
|
375
424
|
});
|
|
376
425
|
|
|
377
426
|
setNodes([...shells, ...leaves]);
|
|
@@ -399,6 +448,24 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
399
448
|
[model, selectedId],
|
|
400
449
|
);
|
|
401
450
|
|
|
451
|
+
/**
|
|
452
|
+
* The components authored inside the selected element, if it is a container.
|
|
453
|
+
* These are `C4Component`s whose `container` names the selected id (or, once
|
|
454
|
+
* flattened, whose `parentId` does). Shown in the panel so a container can be
|
|
455
|
+
* inspected without zooming the diagram into its components.
|
|
456
|
+
*/
|
|
457
|
+
const selectedComponents = useMemo(
|
|
458
|
+
() =>
|
|
459
|
+
selected
|
|
460
|
+
? model.nodes.filter(
|
|
461
|
+
(n) =>
|
|
462
|
+
n.kind === 'component' &&
|
|
463
|
+
(n.container === selected.id || n.parentId === selected.id),
|
|
464
|
+
)
|
|
465
|
+
: [],
|
|
466
|
+
[model, selected],
|
|
467
|
+
);
|
|
468
|
+
|
|
402
469
|
const displayNodes = useMemo(
|
|
403
470
|
() =>
|
|
404
471
|
nodes.map((n) =>
|
|
@@ -409,7 +476,11 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
409
476
|
[nodes, selectedId],
|
|
410
477
|
);
|
|
411
478
|
|
|
412
|
-
|
|
479
|
+
// What the current view draws: runtime units at container level, components
|
|
480
|
+
// at component level. Mirrors the `inView` filter used for the nodes.
|
|
481
|
+
const runtime = model.nodes.filter((n) =>
|
|
482
|
+
model.view === 'component' ? n.kind === 'component' : n.kind === 'container',
|
|
483
|
+
).length;
|
|
413
484
|
const outside = model.nodes.filter(
|
|
414
485
|
(n) => n.kind === 'external-system' || n.kind === 'person',
|
|
415
486
|
).length;
|
|
@@ -443,7 +514,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
443
514
|
<GraphChrome />
|
|
444
515
|
</ReactFlow>
|
|
445
516
|
{ready && labelPositions.size > 0 && (
|
|
446
|
-
<C4EdgeLabels positions={labelPositions} edges={model.edges}
|
|
517
|
+
<C4EdgeLabels positions={labelPositions} edges={model.edges} />
|
|
447
518
|
)}
|
|
448
519
|
{!ready && (
|
|
449
520
|
<div
|
|
@@ -499,7 +570,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
499
570
|
}}
|
|
500
571
|
>
|
|
501
572
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
502
|
-
<span style={{ fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
573
|
+
<span style={{ fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
503
574
|
{selected.label}
|
|
504
575
|
</span>
|
|
505
576
|
<button
|
|
@@ -514,13 +585,30 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
514
585
|
×
|
|
515
586
|
</button>
|
|
516
587
|
</div>
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
588
|
+
{/* The sub-line carries the technology — the fact worth reading at a
|
|
589
|
+
glance. Counts are omitted: the list below is already the count,
|
|
590
|
+
and kind/state are on the box itself. A person has no technology,
|
|
591
|
+
so its sub-line is blank. */}
|
|
592
|
+
{selected.kind !== 'person' &&
|
|
593
|
+
(() => {
|
|
594
|
+
const technology = (selected as { technology?: string }).technology;
|
|
595
|
+
return (
|
|
596
|
+
<div
|
|
597
|
+
style={{
|
|
598
|
+
fontFamily: theme.fonts.monospace,
|
|
599
|
+
fontSize: theme.fontSizes[0],
|
|
600
|
+
color: technology ? muted : (theme.colors.warning ?? '#e8a33a'),
|
|
601
|
+
margin: '4px 0 8px',
|
|
602
|
+
}}
|
|
603
|
+
>
|
|
604
|
+
{technology ?? 'technology: not stated'}
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
})()}
|
|
521
608
|
|
|
522
|
-
{/*
|
|
523
|
-
|
|
609
|
+
{/* The container's own description already shows on the card, so the
|
|
610
|
+
panel only adds what the card omits: the claimed process. */}
|
|
611
|
+
{selected.kind === 'container' && selected.process && (
|
|
524
612
|
<div
|
|
525
613
|
style={{
|
|
526
614
|
fontFamily: theme.fonts.monospace,
|
|
@@ -529,52 +617,124 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
|
529
617
|
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
530
618
|
paddingTop: 6,
|
|
531
619
|
marginBottom: 8,
|
|
620
|
+
whiteSpace: 'normal',
|
|
621
|
+
lineHeight: 1.4,
|
|
622
|
+
}}
|
|
623
|
+
>
|
|
624
|
+
verifies process: {selected.process}
|
|
625
|
+
</div>
|
|
626
|
+
)}
|
|
627
|
+
|
|
628
|
+
{/* Components authored inside this container, listed so the container
|
|
629
|
+
can be inspected without zooming the diagram into them. Inert for
|
|
630
|
+
now — the drill-down hung off them comes later. */}
|
|
631
|
+
{selected.kind === 'container' && selectedComponents.length > 0 && (
|
|
632
|
+
<div
|
|
633
|
+
style={{
|
|
532
634
|
display: 'flex',
|
|
533
635
|
flexDirection: 'column',
|
|
534
|
-
gap:
|
|
636
|
+
gap: 6,
|
|
637
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
638
|
+
paddingTop: 8,
|
|
535
639
|
}}
|
|
536
640
|
>
|
|
537
|
-
<span
|
|
538
|
-
{
|
|
539
|
-
|
|
641
|
+
<span
|
|
642
|
+
style={{
|
|
643
|
+
fontFamily: theme.fonts.monospace,
|
|
644
|
+
fontSize: theme.fontSizes[0],
|
|
645
|
+
color: muted,
|
|
646
|
+
textTransform: 'uppercase',
|
|
647
|
+
letterSpacing: 0.5,
|
|
648
|
+
}}
|
|
649
|
+
>
|
|
650
|
+
Components
|
|
540
651
|
</span>
|
|
541
|
-
{
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
652
|
+
{selectedComponents.map((c) => (
|
|
653
|
+
<div key={c.id} style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
654
|
+
<span
|
|
655
|
+
title={c.description ?? c.label}
|
|
656
|
+
style={{
|
|
657
|
+
fontFamily: theme.fonts.body,
|
|
658
|
+
color: theme.colors.text,
|
|
659
|
+
overflow: 'hidden',
|
|
660
|
+
textOverflow: 'ellipsis',
|
|
661
|
+
whiteSpace: 'nowrap',
|
|
662
|
+
}}
|
|
663
|
+
>
|
|
664
|
+
{c.label}
|
|
665
|
+
</span>
|
|
666
|
+
<span
|
|
667
|
+
style={{
|
|
668
|
+
fontFamily: theme.fonts.monospace,
|
|
669
|
+
fontSize: theme.fontSizes[0],
|
|
670
|
+
color: muted,
|
|
671
|
+
overflow: 'hidden',
|
|
672
|
+
textOverflow: 'ellipsis',
|
|
673
|
+
whiteSpace: 'nowrap',
|
|
674
|
+
}}
|
|
675
|
+
>
|
|
676
|
+
{'technology' in c && c.technology ? c.technology : c.kind}
|
|
677
|
+
{(c.members ?? []).length > 0 ? ` · ${(c.members ?? []).length} member${(c.members ?? []).length === 1 ? '' : 's'}` : ''}
|
|
678
|
+
</span>
|
|
679
|
+
</div>
|
|
680
|
+
))}
|
|
557
681
|
</div>
|
|
558
682
|
)}
|
|
559
683
|
|
|
560
|
-
|
|
561
|
-
|
|
684
|
+
{/* No components authored: fall back to the raw member aliases, or
|
|
685
|
+
say so plainly when there is nothing inside at all. Same divider +
|
|
686
|
+
header as the authored list, so the panel sections consistently. */}
|
|
687
|
+
{selected.kind === 'container' && selectedComponents.length === 0 && (
|
|
688
|
+
<div
|
|
689
|
+
style={{
|
|
690
|
+
display: 'flex',
|
|
691
|
+
flexDirection: 'column',
|
|
692
|
+
gap: 6,
|
|
693
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
694
|
+
paddingTop: 8,
|
|
695
|
+
}}
|
|
696
|
+
>
|
|
562
697
|
<span
|
|
563
|
-
key={alias}
|
|
564
|
-
title={alias}
|
|
565
698
|
style={{
|
|
566
699
|
fontFamily: theme.fonts.monospace,
|
|
567
700
|
fontSize: theme.fontSizes[0],
|
|
568
|
-
color:
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
whiteSpace: 'nowrap',
|
|
701
|
+
color: muted,
|
|
702
|
+
textTransform: 'uppercase',
|
|
703
|
+
letterSpacing: 0.5,
|
|
572
704
|
}}
|
|
573
705
|
>
|
|
574
|
-
|
|
706
|
+
Components
|
|
575
707
|
</span>
|
|
576
|
-
|
|
577
|
-
|
|
708
|
+
{(selected.members ?? []).length > 0 ? (
|
|
709
|
+
(selected.members ?? []).map((alias) => (
|
|
710
|
+
<span
|
|
711
|
+
key={alias}
|
|
712
|
+
title={alias}
|
|
713
|
+
style={{
|
|
714
|
+
fontFamily: theme.fonts.monospace,
|
|
715
|
+
fontSize: theme.fontSizes[0],
|
|
716
|
+
color: theme.colors.text,
|
|
717
|
+
overflow: 'hidden',
|
|
718
|
+
textOverflow: 'ellipsis',
|
|
719
|
+
whiteSpace: 'nowrap',
|
|
720
|
+
}}
|
|
721
|
+
>
|
|
722
|
+
{alias}
|
|
723
|
+
</span>
|
|
724
|
+
))
|
|
725
|
+
) : (
|
|
726
|
+
<span
|
|
727
|
+
style={{
|
|
728
|
+
fontFamily: theme.fonts.monospace,
|
|
729
|
+
fontSize: theme.fontSizes[0],
|
|
730
|
+
color: muted,
|
|
731
|
+
}}
|
|
732
|
+
>
|
|
733
|
+
No components
|
|
734
|
+
</span>
|
|
735
|
+
)}
|
|
736
|
+
</div>
|
|
737
|
+
)}
|
|
578
738
|
</div>
|
|
579
739
|
)}
|
|
580
740
|
</div>
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
nodeSize,
|
|
13
13
|
COMPONENT_SIZE,
|
|
14
14
|
} from './C4NodeCard';
|
|
15
|
-
import type {
|
|
15
|
+
import type { C4Element, C4Container } from './c4';
|
|
16
16
|
|
|
17
17
|
const theme = {
|
|
18
18
|
colors: {
|
|
@@ -25,12 +25,20 @@ const theme = {
|
|
|
25
25
|
} as Parameters<typeof nodeStyle>[1];
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* A
|
|
29
|
-
* element carries a decision — so the card never has to cope with a
|
|
30
|
-
* proposed, and no test pretends otherwise.
|
|
28
|
+
* A container element with overridable fields. `state` is required — every
|
|
29
|
+
* authored element carries a decision — so the card never has to cope with a
|
|
30
|
+
* box nobody proposed, and no test pretends otherwise.
|
|
31
31
|
*/
|
|
32
|
-
function node(over: Partial<
|
|
33
|
-
return {
|
|
32
|
+
function node(over: Partial<C4Container> = {}): C4Element {
|
|
33
|
+
return {
|
|
34
|
+
id: 'n',
|
|
35
|
+
kind: 'container',
|
|
36
|
+
label: 'n',
|
|
37
|
+
containerKind: 'application',
|
|
38
|
+
technology: '',
|
|
39
|
+
state: 'proposed',
|
|
40
|
+
...over,
|
|
41
|
+
};
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
describe('nodeStyle — one axis per channel', () => {
|
|
@@ -39,16 +47,12 @@ describe('nodeStyle — one axis per channel', () => {
|
|
|
39
47
|
expect(nodeStyle(node({ state: 'accepted' }), theme, true).dash).toBe('solid');
|
|
40
48
|
});
|
|
41
49
|
|
|
42
|
-
test('accepted is solid
|
|
43
|
-
|
|
44
|
-
expect(s.dash).toBe('solid');
|
|
45
|
-
expect(s.color).toBe('#4ec9b0');
|
|
50
|
+
test('accepted is solid', () => {
|
|
51
|
+
expect(nodeStyle(node({ state: 'accepted' }), theme).dash).toBe('solid');
|
|
46
52
|
});
|
|
47
53
|
|
|
48
|
-
test('proposed is dashed
|
|
49
|
-
|
|
50
|
-
expect(s.dash).toBe('dashed');
|
|
51
|
-
expect(s.color).toBe('#e8a33a');
|
|
54
|
+
test('proposed is dashed', () => {
|
|
55
|
+
expect(nodeStyle(node({ state: 'proposed' }), theme).dash).toBe('dashed');
|
|
52
56
|
});
|
|
53
57
|
|
|
54
58
|
test('rejected is dotted and muted', () => {
|
|
@@ -67,12 +71,18 @@ describe('nodeStyle — one axis per channel', () => {
|
|
|
67
71
|
}
|
|
68
72
|
});
|
|
69
73
|
|
|
70
|
-
test('
|
|
71
|
-
// Same state,
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
);
|
|
75
|
-
|
|
74
|
+
test('colour follows the technology, not the state', () => {
|
|
75
|
+
// Same kind and state, different tech: different hue.
|
|
76
|
+
const react = nodeStyle(node({ technology: 'React 19', state: 'accepted' }), theme).color;
|
|
77
|
+
const bun = nodeStyle(node({ technology: 'Bun', state: 'accepted' }), theme).color;
|
|
78
|
+
expect(react).not.toBe(bun);
|
|
79
|
+
// State does not move the colour.
|
|
80
|
+
expect(nodeStyle(node({ technology: 'React 19', state: 'proposed' }), theme).color).toBe(react);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an unknown technology falls back to the muted border', () => {
|
|
84
|
+
expect(nodeStyle(node({ technology: '', state: 'accepted' }), theme).color).toBe('#555');
|
|
85
|
+
expect(nodeStyle(node({ technology: 'COBOL', state: 'accepted' }), theme).color).toBe('#555');
|
|
76
86
|
});
|
|
77
87
|
|
|
78
88
|
test('border weight carries the level, not the state', () => {
|
|
@@ -165,10 +175,10 @@ describe('nodeShape — kind without relying on colour or dash', () => {
|
|
|
165
175
|
expect(nodeShape(node({ kind: 'component' })).kind).toBe('square');
|
|
166
176
|
});
|
|
167
177
|
|
|
168
|
-
test('an external system
|
|
178
|
+
test('an external system has no corner radius', () => {
|
|
169
179
|
const s = nodeShape(node({ kind: 'external-system' }));
|
|
170
180
|
expect(s.kind).toBe('rect');
|
|
171
|
-
expect(s.radius).
|
|
181
|
+
expect(s.radius).toBe(0);
|
|
172
182
|
});
|
|
173
183
|
|
|
174
184
|
test('a person is a pill', () => {
|