@principal-ai/subsystems-react 0.51.3 → 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 (70) 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 +202 -63
  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 +74 -26
  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 +6 -0
  29. package/dist/subsystem/model.d.ts.map +1 -1
  30. package/dist/subsystem/model.js.map +1 -1
  31. package/dist/subsystem/nodes.d.ts.map +1 -1
  32. package/dist/subsystem/nodes.js +9 -7
  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 +14 -0
  39. package/dist/utils/edgeLabel.d.ts.map +1 -1
  40. package/dist/utils/edgeLabel.js +24 -0
  41. package/dist/utils/edgeLabel.js.map +1 -1
  42. package/dist/utils/elkLayout.d.ts +65 -0
  43. package/dist/utils/elkLayout.d.ts.map +1 -1
  44. package/dist/utils/elkLayout.js +145 -11
  45. package/dist/utils/elkLayout.js.map +1 -1
  46. package/package.json +1 -1
  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/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
  53. package/src/subsystem/C4Graph.tsx +296 -99
  54. package/src/subsystem/C4NodeCard.test.ts +32 -22
  55. package/src/subsystem/C4NodeCard.tsx +67 -39
  56. package/src/subsystem/IssueList.tsx +23 -0
  57. package/src/subsystem/SubsystemComponentGraph.tsx +79 -23
  58. package/src/subsystem/c4.protocolColor.test.ts +26 -0
  59. package/src/subsystem/c4.ts +76 -52
  60. package/src/subsystem/c4Evidence.test.ts +177 -237
  61. package/src/subsystem/c4Evidence.ts +160 -235
  62. package/src/subsystem/model.ts +6 -0
  63. package/src/subsystem/nodes.group.test.tsx +15 -0
  64. package/src/subsystem/nodes.tsx +12 -9
  65. package/src/subsystem/techIcons/index.tsx +8 -0
  66. package/src/utils/edgeLabel.test.ts +21 -2
  67. package/src/utils/edgeLabel.ts +28 -0
  68. package/src/utils/elkLayout.test.ts +50 -0
  69. package/src/utils/elkLayout.ts +191 -11
  70. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
@@ -144,6 +144,26 @@ const trails: SubsystemTrail[] = [
144
144
  },
145
145
  ],
146
146
  },
147
+ {
148
+ id: 'wt-planned-seam',
149
+ title: 'Planned seam (step-proposed)',
150
+ steps: [
151
+ {
152
+ from: 'audit',
153
+ to: 'cli',
154
+ mechanism: 'uses',
155
+ file: 'src/bun/verify-subsystem-component.ts',
156
+ line: 1,
157
+ symbol: 'probeCliVersion',
158
+ // Step-level proposed: BOTH endpoints are real components — the
159
+ // seam itself is what is planned. The edge label draws dashed on
160
+ // the canvas; distinct from a proposed endpoint, which dashes the
161
+ // node instead. Verification skips the step until promoted.
162
+ proposed: true,
163
+ annotation: 'The step itself is proposed — edge label is dashed.',
164
+ },
165
+ ],
166
+ },
147
167
  ];
148
168
 
149
169
  function ProposedTrailsDemo() {
@@ -153,7 +173,7 @@ function ProposedTrailsDemo() {
153
173
  components={components}
154
174
  trails={trails}
155
175
  title="proposed-work marker"
156
- description="Trails that touch a **proposed** component get their title tinted darkgoldenrod in the flows panel; expand a row and each step onto a proposed component is tinted too (index and title). Nodes for proposed components already draw dashed darkgoldenrod on the canvas."
176
+ description="Trails that touch a **proposed** component get their title tinted darkgoldenrod in the flows panel; expand a row and each step onto a proposed component is tinted too (index and title). Nodes for proposed components draw dashed darkgoldenrod on the canvas. A step that is itself `proposed` — a planned seam between real components — dashes its **edge label** instead."
157
177
  />
158
178
  </div>
159
179
  );
@@ -14,6 +14,7 @@
14
14
  import { useEffect, useMemo, useState } from 'react';
15
15
  import {
16
16
  Edge,
17
+ EdgeProps,
17
18
  Handle,
18
19
  MarkerType,
19
20
  Node,
@@ -25,40 +26,31 @@ import {
25
26
  useViewport,
26
27
  } from '@xyflow/react';
27
28
  import { useTheme } from '@principal-ade/industry-theme';
28
- import { computeElkLayout } from '../utils/elkLayout';
29
- import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, EDGE_LABEL_WIDTH } from '../utils/edgeLabel';
30
- import { MECHANISM_COLOR } from './model';
31
- import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
29
+ import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
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';
32
33
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
33
- import type { C4Model, C4Node } from './c4';
34
+ import type { C4Element, C4Model } from './c4';
34
35
 
35
36
  export interface C4GraphProps {
36
37
  model: C4Model;
37
38
  title?: string;
38
39
  onSelectNode?: (id: string | null) => void;
39
40
  /**
40
- * Extra width / height reserved around each card when laying out, in flow px.
41
- * The visible gap between boxes is `2 × gutter` (ELK's `nodeNode` spacing,
42
- * plus the frame padding where a frame adds one).
43
- *
44
- * Above the default 28 the `needs …` gap row was removed, so cards are
45
- * shorter and packing them tighter than the spacing they were tuned at makes
46
- * 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`.
47
44
  * @default 28
48
45
  */
49
46
  gutter?: number;
50
47
  }
51
48
 
52
- function edgeColor(mechanism: string | undefined, fallback: string): string {
53
- if (!mechanism) return fallback;
54
- return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
55
- }
56
-
57
- function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>) {
58
- const { node, selected } = props.data;
49
+ function C4NodeView(props: NodeProps<Node<{ element: C4Element; selected: boolean }>>) {
50
+ const { element, selected } = props.data;
59
51
  return (
60
52
  <C4NodeCard
61
- node={node}
53
+ node={element}
62
54
  selected={selected}
63
55
  handles={
64
56
  <>
@@ -73,21 +65,17 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
73
65
  /**
74
66
  * Edge-label overlay.
75
67
  *
76
- * Drawn as an HTML layer above the canvas rather than React Flow's built-in
77
- * edge label, so a C4 line's label reads like the subsystem graph's: an opaque
78
- * rounded chip in the mechanism colour, monospace text, scaling with zoom.
79
- *
80
- * Positions come from ELK's computed label anchors (`edgeLabelPositions`, flow
81
- * coords); the current viewport converts them to screen space.
68
+ * Drawn as chips in flow space via `<ViewportPortal>` — React Flow applies the
69
+ * viewport transform, so the label chip needs no manual zoom/pan math and can
70
+ * never drift from the edges it sits on. Positions are ELK's computed label
71
+ * anchors (`edgeLabelPositions`), in flow coordinates.
82
72
  */
83
73
  function C4EdgeLabels({
84
74
  positions,
85
75
  edges,
86
- muted,
87
76
  }: {
88
77
  positions: Map<string, { x: number; y: number }>;
89
78
  edges: C4Model['edges'];
90
- muted: string;
91
79
  }) {
92
80
  const { theme } = useTheme();
93
81
  const viewport = useViewport();
@@ -107,9 +95,7 @@ function C4EdgeLabels({
107
95
  {[...positions].map(([id, p]) => {
108
96
  const edge = byId.get(id);
109
97
  if (!edge) return null;
110
- const color = edgeColor(edge.mechanisms[0], muted);
111
- // C4 asks the line to name the interaction's protocol; fall back to the
112
- // label when a caller supplied none.
98
+ const color = protocolColor(edge.protocol);
113
99
  const text = edge.protocol ?? edge.label;
114
100
  return (
115
101
  <div
@@ -123,7 +109,7 @@ function C4EdgeLabels({
123
109
  display: 'flex',
124
110
  alignItems: 'center',
125
111
  justifyContent: 'center',
126
- width: EDGE_LABEL_WIDTH,
112
+ width: estimateEdgeLabelWidth(text),
127
113
  height: EDGE_LABEL_HEIGHT,
128
114
  boxSizing: 'border-box',
129
115
  padding: '7px 8px',
@@ -162,6 +148,30 @@ function C4EdgeLabels({
162
148
  );
163
149
  }
164
150
 
151
+ /**
152
+ * C4 edge — draws ELK's routed polyline, not React Flow's default bezier.
153
+ *
154
+ * The line and the label chip must share one geometry: the chip is placed from
155
+ * the same `elkPathPoints` this renders. React Flow's built-in edge would curve
156
+ * between handles while the chip sat on ELK's orthogonal route, which is why
157
+ * labels read as "off the line".
158
+ */
159
+ function C4EdgeView({ data, markerEnd }: EdgeProps<Edge<{ path?: string; color?: string }>>) {
160
+ const path = data?.path ?? '';
161
+ if (!path) return null;
162
+ return (
163
+ <path
164
+ d={path}
165
+ fill="none"
166
+ stroke={data?.color}
167
+ strokeWidth={1}
168
+ strokeDasharray="6 4"
169
+ markerEnd={markerEnd}
170
+ style={{ pointerEvents: 'none' }}
171
+ />
172
+ );
173
+ }
174
+
165
175
  function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
166
176
  const { theme } = useTheme();
167
177
  const color = props.data.color;
@@ -211,6 +221,10 @@ const nodeTypes = {
211
221
  'c4-group': C4GroupView,
212
222
  };
213
223
 
224
+ const edgeTypes = {
225
+ 'c4-edge': C4EdgeView,
226
+ };
227
+
214
228
  function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
215
229
  const { theme } = useTheme();
216
230
  const { fitView } = useReactFlow();
@@ -238,12 +252,17 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
238
252
  setReady(false);
239
253
  setSelectedId(null);
240
254
 
241
- // Per-kind size, so a square component is laid out as a square. Feeding the
242
- // container dimensions for everything would make ELK reserve 250x150 of
243
- // space around a 140x140 box and space the component view twice as far
244
- // apart as it needs. `gutter` is added here, not passed as node spacing, so
245
- // a frame's padding clears the card inside it by the same margin.
246
- 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) => {
247
266
  const size = nodeSize(n);
248
267
  return {
249
268
  id: n.id,
@@ -251,10 +270,10 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
251
270
  // Fallback grid pitch uses the container box — the largest, so the
252
271
  // pre-layout positions never overlap. ELK replaces them on success.
253
272
  position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
254
- width: size.width + gutter,
255
- height: size.height + gutter,
273
+ width: size.width,
274
+ height: size.height,
256
275
  ...(n.parentId ? { parentId: n.parentId } : {}),
257
- data: { node: n, selected: false },
276
+ data: { element: n, selected: false },
258
277
  };
259
278
  });
260
279
 
@@ -264,24 +283,43 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
264
283
  ...(g.parentId ? { parentId: g.parentId } : {}),
265
284
  }));
266
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
+
267
308
  const rfEdges: Edge[] = model.edges.map((e) => {
268
- // Every edge here came from a trail step, so the mechanism colours apply.
269
- 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);
270
312
  return {
271
313
  id: e.id,
314
+ type: 'c4-edge',
272
315
  source: e.source,
273
316
  target: e.target,
274
317
  // The reserved label box and the chip must agree on size, so ELK is
275
318
  // given the same text the chip draws (protocol first, then label). The
276
319
  // chip itself is the C4EdgeLabels overlay, not this field.
277
320
  label: e.protocol ?? e.label,
278
- style: {
279
- stroke: color,
280
- strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
281
- strokeDasharray: '6 4',
282
- },
283
321
  markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
284
- data: { edge: e, color },
322
+ data: { edge: e, color, path: '' },
285
323
  };
286
324
  });
287
325
 
@@ -290,12 +328,38 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
290
328
  preserveNodePositions: false,
291
329
  groups,
292
330
  keepSingletonGroups: true,
293
- 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,
294
347
  })
295
348
  .then((result) => {
296
349
  if (!alive) return;
297
350
  const positioned = new Map(result.nodes.map((n) => [n.id, n]));
298
- setLabelPositions(result.edgeLabelPositions ?? new Map());
351
+ // Draw each edge along ELK's routed polyline and place its label from
352
+ // the same points — one geometry for the line and the chip.
353
+ const labelPositions = new Map<string, { x: number; y: number }>();
354
+ const withPaths = rfEdges.map((e) => {
355
+ const pts = result.edgePathPoints?.get(e.id);
356
+ if (!pts || pts.length === 0) return e;
357
+ labelPositions.set(e.id, pointAlongPath(pts, 0.5));
358
+ // Rounded corners on ELK's orthogonal route — gentler than raw right
359
+ // angles, and still the exact polyline the label is placed on.
360
+ return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
361
+ });
362
+ setLabelPositions(labelPositions);
299
363
  const builtGroups = new Set(result.groupBounds.keys());
300
364
  const groupById = new Map(model.groups.map((g) => [g.id, g]));
301
365
 
@@ -309,15 +373,26 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
309
373
  return d;
310
374
  };
311
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
+
312
386
  const shells: Node[] = [...result.groupBounds.entries()]
313
387
  .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
314
388
  .map(([id, b]) => {
315
389
  const def = groupById.get(id);
316
390
  const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
391
+ const origin = shellOrigin(parentId);
317
392
  return {
318
393
  id,
319
394
  type: 'c4-group',
320
- position: { x: b.x, y: b.y },
395
+ position: { x: b.x - origin.x, y: b.y - origin.y },
321
396
  width: Math.max(200, b.width),
322
397
  height: Math.max(120, b.height),
323
398
  draggable: false,
@@ -333,17 +408,23 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
333
408
 
334
409
  const leaves = rfNodes.map((n) => {
335
410
  const p = positioned.get(n.id);
336
- const node = p ? { ...n, position: { x: p.position.x, y: p.position.y } } : n;
337
- const parentId = (node as { parentId?: string }).parentId;
338
- if (parentId && !builtGroups.has(parentId)) {
339
- const { parentId: _drop, ...rest } = node as { parentId?: string } & Node;
340
- 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;
341
420
  }
342
- 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;
343
424
  });
344
425
 
345
426
  setNodes([...shells, ...leaves]);
346
- setRfEdges(rfEdges);
427
+ setRfEdges(withPaths);
347
428
  setReady(true);
348
429
  requestAnimationFrame(() => fitView({ padding: 0.15 }));
349
430
  })
@@ -367,6 +448,24 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
367
448
  [model, selectedId],
368
449
  );
369
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
+
370
469
  const displayNodes = useMemo(
371
470
  () =>
372
471
  nodes.map((n) =>
@@ -377,7 +476,11 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
377
476
  [nodes, selectedId],
378
477
  );
379
478
 
380
- 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;
381
484
  const outside = model.nodes.filter(
382
485
  (n) => n.kind === 'external-system' || n.kind === 'person',
383
486
  ).length;
@@ -389,6 +492,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
389
492
  nodes={displayNodes}
390
493
  edges={rfEdges}
391
494
  nodeTypes={nodeTypes}
495
+ edgeTypes={edgeTypes}
392
496
  className={GRAPH_CANVAS_CLASS}
393
497
  {...GRAPH_NAV_PROPS}
394
498
  onNodeClick={(_e, node) => {
@@ -402,11 +506,15 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
402
506
  onSelectNode?.(null);
403
507
  }}
404
508
  proOptions={{ hideAttribution: true }}
509
+ // Pin the flow to its container. The label overlay is a sibling sized
510
+ // `inset: 0`, so the two must share an origin or `viewport.y` (measured
511
+ // in the flow's own box) lands the chips off vertically.
512
+ style={{ width: '100%', height: '100%' }}
405
513
  >
406
514
  <GraphChrome />
407
515
  </ReactFlow>
408
516
  {ready && labelPositions.size > 0 && (
409
- <C4EdgeLabels positions={labelPositions} edges={model.edges} muted={muted} />
517
+ <C4EdgeLabels positions={labelPositions} edges={model.edges} />
410
518
  )}
411
519
  {!ready && (
412
520
  <div
@@ -462,7 +570,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
462
570
  }}
463
571
  >
464
572
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
465
- <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' }}>
466
574
  {selected.label}
467
575
  </span>
468
576
  <button
@@ -477,13 +585,30 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
477
585
  ×
478
586
  </button>
479
587
  </div>
480
- <div style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, margin: '4px 0 8px' }}>
481
- {selected.kind} · {selected.members.length} component{selected.members.length === 1 ? '' : 's'}
482
- {selected.models && selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
483
- </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
+ })()}
484
608
 
485
- {/* Confirmation state, with what is still missing. */}
486
- {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 && (
487
612
  <div
488
613
  style={{
489
614
  fontFamily: theme.fonts.monospace,
@@ -492,52 +617,124 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
492
617
  borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
493
618
  paddingTop: 6,
494
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={{
495
634
  display: 'flex',
496
635
  flexDirection: 'column',
497
- gap: 3,
636
+ gap: 6,
637
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
638
+ paddingTop: 8,
498
639
  }}
499
640
  >
500
- <span style={{ color: nodeStyle(selected, theme).color }}>
501
- {selected.state}
502
- {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
503
651
  </span>
504
- {selected.technology ? (
505
- <span>technology: {selected.technology}</span>
506
- ) : (
507
- <span style={{ color: theme.colors.warning ?? '#e8a33a' }}>technology: not stated</span>
508
- )}
509
- {selected.description ? (
510
- <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>{selected.description}</span>
511
- ) : (
512
- <span style={{ color: theme.colors.warning ?? '#e8a33a' }}>description: not stated</span>
513
- )}
514
- {/* A merge hides its inputs, so always show what it absorbed. */}
515
- {selected.derivedFrom && selected.derivedFrom.length > 1 && (
516
- <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
517
- consolidated from: {selected.derivedFrom.join(', ')}
518
- </span>
519
- )}
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
+ ))}
520
681
  </div>
521
682
  )}
522
683
 
523
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
524
- {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
+ >
525
697
  <span
526
- key={alias}
527
- title={alias}
528
698
  style={{
529
699
  fontFamily: theme.fonts.monospace,
530
700
  fontSize: theme.fontSizes[0],
531
- color: theme.colors.text,
532
- overflow: 'hidden',
533
- textOverflow: 'ellipsis',
534
- whiteSpace: 'nowrap',
701
+ color: muted,
702
+ textTransform: 'uppercase',
703
+ letterSpacing: 0.5,
535
704
  }}
536
705
  >
537
- {alias}
706
+ Components
538
707
  </span>
539
- ))}
540
- </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
+ )}
541
738
  </div>
542
739
  )}
543
740
  </div>