@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.
Files changed (64) 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 +159 -45
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +26 -26
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +56 -30
  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/nodes.js +5 -5
  29. package/dist/subsystem/nodes.js.map +1 -1
  30. package/dist/subsystem/techIcons/index.d.ts +5 -0
  31. package/dist/subsystem/techIcons/index.d.ts.map +1 -1
  32. package/dist/subsystem/techIcons/index.js +3 -0
  33. package/dist/subsystem/techIcons/index.js.map +1 -1
  34. package/dist/utils/edgeLabel.d.ts +15 -1
  35. package/dist/utils/edgeLabel.d.ts.map +1 -1
  36. package/dist/utils/edgeLabel.js +25 -1
  37. package/dist/utils/edgeLabel.js.map +1 -1
  38. package/dist/utils/elkLayout.d.ts +47 -0
  39. package/dist/utils/elkLayout.d.ts.map +1 -1
  40. package/dist/utils/elkLayout.js +71 -7
  41. package/dist/utils/elkLayout.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/index.ts +8 -6
  44. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +198 -0
  45. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +42 -6
  46. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +9 -7
  47. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +686 -2
  48. package/src/subsystem/C4Graph.tsx +242 -82
  49. package/src/subsystem/C4NodeCard.test.ts +32 -22
  50. package/src/subsystem/C4NodeCard.tsx +67 -39
  51. package/src/subsystem/IssueList.tsx +23 -0
  52. package/src/subsystem/SubsystemComponentGraph.tsx +38 -16
  53. package/src/subsystem/c4.protocolColor.test.ts +26 -0
  54. package/src/subsystem/c4.ts +76 -52
  55. package/src/subsystem/c4Evidence.test.ts +177 -237
  56. package/src/subsystem/c4Evidence.ts +160 -235
  57. package/src/subsystem/nodes.group.test.tsx +15 -0
  58. package/src/subsystem/nodes.tsx +7 -7
  59. package/src/subsystem/techIcons/index.tsx +8 -0
  60. package/src/utils/edgeLabel.test.ts +21 -2
  61. package/src/utils/edgeLabel.ts +29 -1
  62. package/src/utils/elkLayout.test.ts +16 -0
  63. package/src/utils/elkLayout.ts +119 -7
  64. 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, EDGE_LABEL_WIDTH } from '../utils/edgeLabel';
31
- import { MECHANISM_COLOR } from './model';
32
- import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
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 { C4Model, C4Node } from './c4';
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
- * 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.
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 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;
49
+ function C4NodeView(props: NodeProps<Node<{ element: C4Element; selected: boolean }>>) {
50
+ const { element, selected } = props.data;
60
51
  return (
61
52
  <C4NodeCard
62
- node={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 = edgeColor(edge.mechanisms[0], muted);
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: EDGE_LABEL_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
- // 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) => {
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 + gutter,
280
- height: size.height + gutter,
273
+ width: size.width,
274
+ height: size.height,
281
275
  ...(n.parentId ? { parentId: n.parentId } : {}),
282
- data: { node: n, selected: false },
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
- // Every edge here came from a trail step, so the mechanism colours apply.
294
- const color = edgeColor(e.mechanisms[0], muted);
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
- 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;
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
- return node;
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
- const runtime = model.nodes.filter((n) => n.kind === 'container' || n.kind === 'component').length;
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} muted={muted} />
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
- <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>
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
- {/* Confirmation state, with what is still missing. */}
523
- {selected.kind === 'container' && (
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: 3,
636
+ gap: 6,
637
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
638
+ paddingTop: 8,
535
639
  }}
536
640
  >
537
- <span style={{ color: nodeStyle(selected, theme).color }}>
538
- {selected.state}
539
- {selected.containerKind ? ` · ${selected.containerKind}` : ` · ${selected.kind}`}
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
- {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
- )}
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
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
561
- {selected.members.map((alias) => (
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: theme.colors.text,
569
- overflow: 'hidden',
570
- textOverflow: 'ellipsis',
571
- whiteSpace: 'nowrap',
701
+ color: muted,
702
+ textTransform: 'uppercase',
703
+ letterSpacing: 0.5,
572
704
  }}
573
705
  >
574
- {alias}
706
+ Components
575
707
  </span>
576
- ))}
577
- </div>
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 { 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', () => {