@principal-ai/subsystems-react 0.51.4 → 0.53.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.
Files changed (69) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/C4Graph.d.ts +3 -7
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +193 -47
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +33 -27
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +89 -43
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/IssueList.d.ts +1 -1
  14. package/dist/subsystem/IssueList.d.ts.map +1 -1
  15. package/dist/subsystem/IssueList.js +20 -1
  16. package/dist/subsystem/IssueList.js.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  18. package/dist/subsystem/SubsystemComponentGraph.js +42 -19
  19. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  20. package/dist/subsystem/c4.d.ts +48 -49
  21. package/dist/subsystem/c4.d.ts.map +1 -1
  22. package/dist/subsystem/c4.js +50 -0
  23. package/dist/subsystem/c4.js.map +1 -1
  24. package/dist/subsystem/c4Evidence.d.ts +88 -84
  25. package/dist/subsystem/c4Evidence.d.ts.map +1 -1
  26. package/dist/subsystem/c4Evidence.js +113 -166
  27. package/dist/subsystem/c4Evidence.js.map +1 -1
  28. package/dist/subsystem/model.d.ts +35 -245
  29. package/dist/subsystem/model.d.ts.map +1 -1
  30. package/dist/subsystem/model.js +11 -29
  31. package/dist/subsystem/model.js.map +1 -1
  32. package/dist/subsystem/nodes.js +5 -5
  33. package/dist/subsystem/nodes.js.map +1 -1
  34. package/dist/subsystem/techIcons/index.d.ts +5 -0
  35. package/dist/subsystem/techIcons/index.d.ts.map +1 -1
  36. package/dist/subsystem/techIcons/index.js +3 -0
  37. package/dist/subsystem/techIcons/index.js.map +1 -1
  38. package/dist/utils/edgeLabel.d.ts +15 -1
  39. package/dist/utils/edgeLabel.d.ts.map +1 -1
  40. package/dist/utils/edgeLabel.js +25 -1
  41. package/dist/utils/edgeLabel.js.map +1 -1
  42. package/dist/utils/elkLayout.d.ts +47 -0
  43. package/dist/utils/elkLayout.d.ts.map +1 -1
  44. package/dist/utils/elkLayout.js +71 -7
  45. package/dist/utils/elkLayout.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/index.ts +8 -6
  48. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +198 -0
  49. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +42 -6
  50. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +9 -7
  51. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +686 -2
  52. package/src/subsystem/C4Graph.tsx +278 -82
  53. package/src/subsystem/C4NodeCard.test.ts +32 -22
  54. package/src/subsystem/C4NodeCard.tsx +134 -69
  55. package/src/subsystem/IssueList.tsx +23 -0
  56. package/src/subsystem/SubsystemComponentGraph.tsx +38 -16
  57. package/src/subsystem/c4.protocolColor.test.ts +26 -0
  58. package/src/subsystem/c4.ts +76 -52
  59. package/src/subsystem/c4Evidence.test.ts +177 -237
  60. package/src/subsystem/c4Evidence.ts +160 -235
  61. package/src/subsystem/model.ts +62 -305
  62. package/src/subsystem/nodes.group.test.tsx +15 -0
  63. package/src/subsystem/nodes.tsx +7 -7
  64. package/src/subsystem/techIcons/index.tsx +8 -0
  65. package/src/utils/edgeLabel.test.ts +21 -2
  66. package/src/utils/edgeLabel.ts +29 -1
  67. package/src/utils/elkLayout.test.ts +16 -0
  68. package/src/utils/elkLayout.ts +119 -7
  69. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
@@ -26,41 +26,36 @@ import {
26
26
  useViewport,
27
27
  } from '@xyflow/react';
28
28
  import { useTheme } from '@principal-ade/industry-theme';
29
+ import { Box, LayoutGrid } from 'lucide-react';
29
30
  import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
30
- import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, EDGE_LABEL_WIDTH } from '../utils/edgeLabel';
31
- import { MECHANISM_COLOR } from './model';
31
+ import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
32
+ import { protocolColor } from './c4';
32
33
  import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
33
34
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
34
- import type { C4Model, C4Node } from './c4';
35
+ import type { C4Element, C4Model } from './c4';
35
36
 
36
37
  export interface C4GraphProps {
37
38
  model: C4Model;
38
39
  title?: string;
39
40
  onSelectNode?: (id: string | null) => void;
40
41
  /**
41
- * Extra width / height reserved around each card when laying out, in flow px.
42
- * The visible gap between boxes is `2 × gutter` (ELK's `nodeNode` spacing,
43
- * plus the frame padding where a frame adds one).
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.
42
+ * Breathing room around each card, in flow px, applied as ELK spacing (not by
43
+ * inflating the node box — that left the edge lines starting short of the
44
+ * card border). `nodeSpacing` is `2 × gutter`; `edgeNodeSpacing` is `gutter`.
48
45
  * @default 28
49
46
  */
50
47
  gutter?: number;
51
48
  }
52
49
 
53
- function edgeColor(mechanism: string | undefined, fallback: string): string {
54
- if (!mechanism) return fallback;
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;
50
+ function C4NodeView(
51
+ props: NodeProps<Node<{ element: C4Element; selected: boolean; componentCount?: number }>>,
52
+ ) {
53
+ const { element, selected, componentCount } = props.data;
60
54
  return (
61
55
  <C4NodeCard
62
- node={node}
56
+ node={element}
63
57
  selected={selected}
58
+ componentCount={componentCount ?? 0}
64
59
  handles={
65
60
  <>
66
61
  <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
@@ -82,11 +77,9 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
82
77
  function C4EdgeLabels({
83
78
  positions,
84
79
  edges,
85
- muted,
86
80
  }: {
87
81
  positions: Map<string, { x: number; y: number }>;
88
82
  edges: C4Model['edges'];
89
- muted: string;
90
83
  }) {
91
84
  const { theme } = useTheme();
92
85
  const viewport = useViewport();
@@ -106,7 +99,7 @@ function C4EdgeLabels({
106
99
  {[...positions].map(([id, p]) => {
107
100
  const edge = byId.get(id);
108
101
  if (!edge) return null;
109
- const color = edgeColor(edge.mechanisms[0], muted);
102
+ const color = protocolColor(edge.protocol);
110
103
  const text = edge.protocol ?? edge.label;
111
104
  return (
112
105
  <div
@@ -120,7 +113,7 @@ function C4EdgeLabels({
120
113
  display: 'flex',
121
114
  alignItems: 'center',
122
115
  justifyContent: 'center',
123
- width: EDGE_LABEL_WIDTH,
116
+ width: estimateEdgeLabelWidth(text),
124
117
  height: EDGE_LABEL_HEIGHT,
125
118
  boxSizing: 'border-box',
126
119
  padding: '7px 8px',
@@ -186,6 +179,10 @@ function C4EdgeView({ data, markerEnd }: EdgeProps<Edge<{ path?: string; color?:
186
179
  function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
187
180
  const { theme } = useTheme();
188
181
  const color = props.data.color;
182
+ const isSystem = props.data.kind === 'system';
183
+ // A system frame is the box we draw; a container frame groups components
184
+ // inside one. Boxes for each, so the badge says which level you are looking at.
185
+ const FrameIcon = isSystem ? Box : LayoutGrid;
189
186
  return (
190
187
  <div
191
188
  style={{
@@ -205,6 +202,9 @@ function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'co
205
202
  transform: 'translateY(-50%)',
206
203
  left: 12,
207
204
  zIndex: 1,
205
+ display: 'inline-flex',
206
+ alignItems: 'center',
207
+ gap: 6,
208
208
  fontFamily: theme.fonts.monospace,
209
209
  fontSize: theme.fontSizes[2],
210
210
  fontWeight: 600,
@@ -221,6 +221,7 @@ function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'co
221
221
  whiteSpace: 'nowrap',
222
222
  }}
223
223
  >
224
+ <FrameIcon size={13} strokeWidth={2.25} aria-hidden />
224
225
  {props.data.label}
225
226
  </span>
226
227
  </div>
@@ -263,12 +264,26 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
263
264
  setReady(false);
264
265
  setSelectedId(null);
265
266
 
266
- // Per-kind size, so a square component is laid out as a square. Feeding the
267
- // container dimensions for everything would make ELK reserve 250x150 of
268
- // space around a 140x140 box and space the component view twice as far
269
- // apart as it needs. `gutter` is added here, not passed as node spacing, so
270
- // a frame's padding clears the card inside it by the same margin.
271
- const rfNodes: Node[] = model.nodes.map((n, i) => {
267
+ // Draw only the elements for this view. A container diagram shows the
268
+ // runtime units (containers, externals, people); components belong to the
269
+ // component view. Components are still carried on the model (see the side
270
+ // panel), just not drawn here.
271
+ const inView = (n: C4Element): boolean =>
272
+ model.view === 'component' ? n.kind !== 'container' : n.kind !== 'component';
273
+
274
+ // How many components each container groups — computed once here because
275
+ // the card only receives its own element, not the model.
276
+ const childComponentCount = new Map<string, number>();
277
+ for (const n of model.nodes) {
278
+ if (n.kind !== 'component') continue;
279
+ const parent = n.container ?? n.parentId;
280
+ if (parent) childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
281
+ }
282
+
283
+ // Layout against the true card size, so ELK's ports sit on the card's
284
+ // border — an inflated box leaves the line starting short of the card edge.
285
+ // The `gutter` becomes ELK spacing instead (see `nodeSpacing` below).
286
+ const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
272
287
  const size = nodeSize(n);
273
288
  return {
274
289
  id: n.id,
@@ -276,10 +291,10 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
276
291
  // Fallback grid pitch uses the container box — the largest, so the
277
292
  // pre-layout positions never overlap. ELK replaces them on success.
278
293
  position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
279
- width: size.width + gutter,
280
- height: size.height + gutter,
294
+ width: size.width,
295
+ height: size.height,
281
296
  ...(n.parentId ? { parentId: n.parentId } : {}),
282
- data: { node: n, selected: false },
297
+ data: { element: n, selected: false, componentCount: childComponentCount.get(n.id) ?? 0 },
283
298
  };
284
299
  });
285
300
 
@@ -289,9 +304,32 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
289
304
  ...(g.parentId ? { parentId: g.parentId } : {}),
290
305
  }));
291
306
 
307
+ // Band nodes so a flat layout still reads as C4 and frames can't interleave.
308
+ //
309
+ // Flat mode has no ELK parents, so without bands ELK mixes members of
310
+ // different frames across the whole graph — their synthesized bounding
311
+ // boxes then overlap. A partition per frame keeps each frame's members
312
+ // contiguous; people lead, externals trail.
313
+ //
314
+ // Order (ascending = earlier in the layout direction): people, then each
315
+ // frame in model order, then external systems.
316
+ const frameIds = model.groups.map((g) => g.id);
317
+ const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
318
+ const partitionByNode = new Map<string, number>(
319
+ model.nodes.map((n) => {
320
+ if (n.kind === 'person') return [n.id, 0];
321
+ if (n.kind === 'external-system') return [n.id, frameIds.length + 1];
322
+ // A node inside a frame shares its frame's band; a frame-less node
323
+ // (a container at container view) gets its own by id order.
324
+ const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
325
+ return [n.id, 1 + band];
326
+ }),
327
+ );
328
+
292
329
  const rfEdges: Edge[] = model.edges.map((e) => {
293
- // Every edge here came from a trail step, so the mechanism colours apply.
294
- const color = edgeColor(e.mechanisms[0], muted);
330
+ // Colour keys off the protocol, not the trail verb: on a container
331
+ // diagram the transport is what a reader distinguishes at a glance.
332
+ const color = protocolColor(e.protocol);
295
333
  return {
296
334
  id: e.id,
297
335
  type: 'c4-edge',
@@ -311,7 +349,22 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
311
349
  preserveNodePositions: false,
312
350
  groups,
313
351
  keepSingletonGroups: true,
314
- edgeLabels: { enabled: true, placement: 'CENTER' },
352
+ edgeLabels: { enabled: true, placement: 'CENTER', width: C4_LABEL_WIDTH, measure: estimateEdgeLabelWidth },
353
+ // The gutter that used to inflate each node box is now real spacing, so
354
+ // the ports still sit on the card edge.
355
+ nodeSpacing: gutter * 2,
356
+ edgeNodeSpacing: gutter,
357
+ // Between-layer/between-partition gap. This is what keeps the external
358
+ // band clear of the system frame's padding — ELK gives partitions zero
359
+ // default separation. It stacks on top of ELK's reserved label layer, so
360
+ // it is kept modest.
361
+ interLayerSpacing: 40,
362
+ // Draw the system frame as a decoration around the boxes rather than an
363
+ // ELK parent. Nesting made in-frame edges inherit the frame's padding and
364
+ // spacing, so they ran longer than a root-level edge touching the same
365
+ // nodes; flattening gives every edge one spacing.
366
+ flatGroups: true,
367
+ partitionByNode,
315
368
  })
316
369
  .then((result) => {
317
370
  if (!alive) return;
@@ -341,15 +394,36 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
341
394
  return d;
342
395
  };
343
396
 
397
+ // Shell positions from flat synthesis are absolute. React Flow reads a
398
+ // child's `position` as relative to its parent, so a shell nested in
399
+ // another shell must be made relative to it.
400
+ const shellAbs = new Map(result.groupBounds);
401
+ const shellOrigin = (id: string | undefined): { x: number; y: number } => {
402
+ if (!id) return { x: 0, y: 0 };
403
+ const b = shellAbs.get(id);
404
+ return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
405
+ };
406
+
344
407
  const shells: Node[] = [...result.groupBounds.entries()]
345
408
  .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
346
409
  .map(([id, b]) => {
347
410
  const def = groupById.get(id);
348
411
  const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
412
+ const origin = shellOrigin(parentId);
413
+ // A container frame borrows its container node's border colour, so a
414
+ // frame and the box it wraps read as the same thing. Falls back to
415
+ // the accent when no element matches (e.g. a hand-authored group id).
416
+ const element = model.nodes.find((n) => n.id === id);
417
+ const frameColor =
418
+ def?.kind === 'system'
419
+ ? (theme.colors.text ?? '#888')
420
+ : element
421
+ ? nodeStyle(element, theme).color
422
+ : (theme.colors.accent ?? theme.colors.info);
349
423
  return {
350
424
  id,
351
425
  type: 'c4-group',
352
- position: { x: b.x, y: b.y },
426
+ position: { x: b.x - origin.x, y: b.y - origin.y },
353
427
  width: Math.max(200, b.width),
354
428
  height: Math.max(120, b.height),
355
429
  draggable: false,
@@ -358,20 +432,26 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
358
432
  data: {
359
433
  label: def?.label ?? id,
360
434
  kind: def?.kind ?? 'container',
361
- color: def?.kind === 'system' ? (theme.colors.text ?? '#888') : (theme.colors.accent ?? theme.colors.info),
435
+ color: frameColor,
362
436
  },
363
437
  };
364
438
  });
365
439
 
366
440
  const leaves = rfNodes.map((n) => {
367
441
  const p = positioned.get(n.id);
368
- const node = p ? { ...n, position: { x: p.position.x, y: p.position.y } } : n;
369
- const parentId = (node as { parentId?: string }).parentId;
370
- if (parentId && !builtGroups.has(parentId)) {
371
- const { parentId: _drop, ...rest } = node as { parentId?: string } & Node;
372
- return rest as Node;
442
+ // Absolute flow position from the flat layout.
443
+ const abs = p ? p.position : n.position;
444
+ // Re-parent to the shell this node sits in, if any: a component's
445
+ // container, or a container's system frame. React Flow positions a
446
+ // child relative to its parent, so subtract the shell origin.
447
+ let parentId: string | undefined;
448
+ if (n.data && typeof n.data === 'object') {
449
+ const modelNode = (n.data as { element?: C4Element }).element;
450
+ if (modelNode?.parentId && builtGroups.has(modelNode.parentId)) parentId = modelNode.parentId;
373
451
  }
374
- return node;
452
+ const origin = shellOrigin(parentId);
453
+ const node = { ...n, position: { x: abs.x - origin.x, y: abs.y - origin.y } };
454
+ return (parentId ? { ...node, parentId } : { ...node, parentId: undefined }) as Node;
375
455
  });
376
456
 
377
457
  setNodes([...shells, ...leaves]);
@@ -399,6 +479,24 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
399
479
  [model, selectedId],
400
480
  );
401
481
 
482
+ /**
483
+ * The components authored inside the selected element, if it is a container.
484
+ * These are `C4Component`s whose `container` names the selected id (or, once
485
+ * flattened, whose `parentId` does). Shown in the panel so a container can be
486
+ * inspected without zooming the diagram into its components.
487
+ */
488
+ const selectedComponents = useMemo(
489
+ () =>
490
+ selected
491
+ ? model.nodes.filter(
492
+ (n) =>
493
+ n.kind === 'component' &&
494
+ (n.container === selected.id || n.parentId === selected.id),
495
+ )
496
+ : [],
497
+ [model, selected],
498
+ );
499
+
402
500
  const displayNodes = useMemo(
403
501
  () =>
404
502
  nodes.map((n) =>
@@ -409,7 +507,11 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
409
507
  [nodes, selectedId],
410
508
  );
411
509
 
412
- const runtime = model.nodes.filter((n) => n.kind === 'container' || n.kind === 'component').length;
510
+ // What the current view draws: runtime units at container level, components
511
+ // at component level. Mirrors the `inView` filter used for the nodes.
512
+ const runtime = model.nodes.filter((n) =>
513
+ model.view === 'component' ? n.kind === 'component' : n.kind === 'container',
514
+ ).length;
413
515
  const outside = model.nodes.filter(
414
516
  (n) => n.kind === 'external-system' || n.kind === 'person',
415
517
  ).length;
@@ -443,7 +545,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
443
545
  <GraphChrome />
444
546
  </ReactFlow>
445
547
  {ready && labelPositions.size > 0 && (
446
- <C4EdgeLabels positions={labelPositions} edges={model.edges} muted={muted} />
548
+ <C4EdgeLabels positions={labelPositions} edges={model.edges} />
447
549
  )}
448
550
  {!ready && (
449
551
  <div
@@ -468,6 +570,8 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
468
570
  position: 'absolute',
469
571
  top: 12,
470
572
  left: 12,
573
+ // Above the edge-label overlay (zIndex 5).
574
+ zIndex: 10,
471
575
  fontSize: theme.fontSizes[2],
472
576
  fontWeight: 600,
473
577
  color: theme.colors.text,
@@ -489,6 +593,9 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
489
593
  position: 'absolute',
490
594
  top: 12,
491
595
  right: 12,
596
+ // Above the edge-label overlay (zIndex 5), or chips paint over the
597
+ // panel.
598
+ zIndex: 10,
492
599
  width: 280,
493
600
  maxHeight: 'calc(100% - 24px)',
494
601
  overflowY: 'auto',
@@ -499,7 +606,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
499
606
  }}
500
607
  >
501
608
  <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' }}>
609
+ <span style={{ fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
503
610
  {selected.label}
504
611
  </span>
505
612
  <button
@@ -514,13 +621,30 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
514
621
  ×
515
622
  </button>
516
623
  </div>
517
- <div style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, margin: '4px 0 8px' }}>
518
- {selected.kind} · {selected.members.length} component{selected.members.length === 1 ? '' : 's'}
519
- {selected.models && selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
520
- </div>
624
+ {/* The sub-line carries the technology — the fact worth reading at a
625
+ glance. Counts are omitted: the list below is already the count,
626
+ and kind/state are on the box itself. A person has no technology,
627
+ so its sub-line is blank. */}
628
+ {selected.kind !== 'person' &&
629
+ (() => {
630
+ const technology = (selected as { technology?: string }).technology;
631
+ return (
632
+ <div
633
+ style={{
634
+ fontFamily: theme.fonts.monospace,
635
+ fontSize: theme.fontSizes[0],
636
+ color: technology ? muted : (theme.colors.warning ?? '#e8a33a'),
637
+ margin: '4px 0 8px',
638
+ }}
639
+ >
640
+ {technology ?? 'technology: not stated'}
641
+ </div>
642
+ );
643
+ })()}
521
644
 
522
- {/* Confirmation state, with what is still missing. */}
523
- {selected.kind === 'container' && (
645
+ {/* The container's own description already shows on the card, so the
646
+ panel only adds what the card omits: the claimed process. */}
647
+ {selected.kind === 'container' && selected.process && (
524
648
  <div
525
649
  style={{
526
650
  fontFamily: theme.fonts.monospace,
@@ -529,52 +653,124 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
529
653
  borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
530
654
  paddingTop: 6,
531
655
  marginBottom: 8,
656
+ whiteSpace: 'normal',
657
+ lineHeight: 1.4,
658
+ }}
659
+ >
660
+ verifies process: {selected.process}
661
+ </div>
662
+ )}
663
+
664
+ {/* Components authored inside this container, listed so the container
665
+ can be inspected without zooming the diagram into them. Inert for
666
+ now — the drill-down hung off them comes later. */}
667
+ {selected.kind === 'container' && selectedComponents.length > 0 && (
668
+ <div
669
+ style={{
532
670
  display: 'flex',
533
671
  flexDirection: 'column',
534
- gap: 3,
672
+ gap: 6,
673
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
674
+ paddingTop: 8,
535
675
  }}
536
676
  >
537
- <span style={{ color: nodeStyle(selected, theme).color }}>
538
- {selected.state}
539
- {selected.containerKind ? ` · ${selected.containerKind}` : ` · ${selected.kind}`}
677
+ <span
678
+ style={{
679
+ fontFamily: theme.fonts.monospace,
680
+ fontSize: theme.fontSizes[0],
681
+ color: muted,
682
+ textTransform: 'uppercase',
683
+ letterSpacing: 0.5,
684
+ }}
685
+ >
686
+ Components
540
687
  </span>
541
- {selected.technology ? (
542
- <span>technology: {selected.technology}</span>
543
- ) : (
544
- <span style={{ color: theme.colors.warning ?? '#e8a33a' }}>technology: not stated</span>
545
- )}
546
- {selected.description ? (
547
- <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>{selected.description}</span>
548
- ) : (
549
- <span style={{ color: theme.colors.warning ?? '#e8a33a' }}>description: not stated</span>
550
- )}
551
- {/* A merge hides its inputs, so always show what it absorbed. */}
552
- {selected.derivedFrom && selected.derivedFrom.length > 1 && (
553
- <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
554
- consolidated from: {selected.derivedFrom.join(', ')}
555
- </span>
556
- )}
688
+ {selectedComponents.map((c) => (
689
+ <div key={c.id} style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
690
+ <span
691
+ title={c.description ?? c.label}
692
+ style={{
693
+ fontFamily: theme.fonts.body,
694
+ color: theme.colors.text,
695
+ overflow: 'hidden',
696
+ textOverflow: 'ellipsis',
697
+ whiteSpace: 'nowrap',
698
+ }}
699
+ >
700
+ {c.label}
701
+ </span>
702
+ <span
703
+ style={{
704
+ fontFamily: theme.fonts.monospace,
705
+ fontSize: theme.fontSizes[0],
706
+ color: muted,
707
+ overflow: 'hidden',
708
+ textOverflow: 'ellipsis',
709
+ whiteSpace: 'nowrap',
710
+ }}
711
+ >
712
+ {'technology' in c && c.technology ? c.technology : c.kind}
713
+ {(c.members ?? []).length > 0 ? ` · ${(c.members ?? []).length} member${(c.members ?? []).length === 1 ? '' : 's'}` : ''}
714
+ </span>
715
+ </div>
716
+ ))}
557
717
  </div>
558
718
  )}
559
719
 
560
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
561
- {selected.members.map((alias) => (
720
+ {/* No components authored: fall back to the raw member aliases, or
721
+ say so plainly when there is nothing inside at all. Same divider +
722
+ header as the authored list, so the panel sections consistently. */}
723
+ {selected.kind === 'container' && selectedComponents.length === 0 && (
724
+ <div
725
+ style={{
726
+ display: 'flex',
727
+ flexDirection: 'column',
728
+ gap: 6,
729
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
730
+ paddingTop: 8,
731
+ }}
732
+ >
562
733
  <span
563
- key={alias}
564
- title={alias}
565
734
  style={{
566
735
  fontFamily: theme.fonts.monospace,
567
736
  fontSize: theme.fontSizes[0],
568
- color: theme.colors.text,
569
- overflow: 'hidden',
570
- textOverflow: 'ellipsis',
571
- whiteSpace: 'nowrap',
737
+ color: muted,
738
+ textTransform: 'uppercase',
739
+ letterSpacing: 0.5,
572
740
  }}
573
741
  >
574
- {alias}
742
+ Components
575
743
  </span>
576
- ))}
577
- </div>
744
+ {(selected.members ?? []).length > 0 ? (
745
+ (selected.members ?? []).map((alias) => (
746
+ <span
747
+ key={alias}
748
+ title={alias}
749
+ style={{
750
+ fontFamily: theme.fonts.monospace,
751
+ fontSize: theme.fontSizes[0],
752
+ color: theme.colors.text,
753
+ overflow: 'hidden',
754
+ textOverflow: 'ellipsis',
755
+ whiteSpace: 'nowrap',
756
+ }}
757
+ >
758
+ {alias}
759
+ </span>
760
+ ))
761
+ ) : (
762
+ <span
763
+ style={{
764
+ fontFamily: theme.fonts.monospace,
765
+ fontSize: theme.fontSizes[0],
766
+ color: muted,
767
+ }}
768
+ >
769
+ No components
770
+ </span>
771
+ )}
772
+ </div>
773
+ )}
578
774
  </div>
579
775
  )}
580
776
  </div>
@@ -12,7 +12,7 @@ import {
12
12
  nodeSize,
13
13
  COMPONENT_SIZE,
14
14
  } from './C4NodeCard';
15
- import type { C4Node } from './c4';
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 drawn node, flat. `state` is required on a real `C4Node` — every authored
29
- * element carries a decision — so the card never has to cope with a box nobody
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<C4Node> = {}): C4Node {
33
- return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [], state: 'proposed', ...over };
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 and uses the primary colour', () => {
43
- const s = nodeStyle(node({ state: 'accepted' }), theme);
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 and uses the warning colour', () => {
49
- const s = nodeStyle(node({ state: 'proposed' }), theme);
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('confirmation state wins over kind for colour', () => {
71
- // Same state, four kinds: one colour. Kind is shape's job.
72
- const colors = (['container', 'component', 'external-system', 'person'] as const).map(
73
- (kind) => nodeStyle(node({ kind, state: 'accepted' }), theme).color,
74
- );
75
- expect(new Set(colors).size).toBe(1);
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 is a rounded rectangle', () => {
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).toBeGreaterThan(nodeShape(node()).radius);
181
+ expect(s.radius).toBe(0);
172
182
  });
173
183
 
174
184
  test('a person is a pill', () => {