@principal-ai/subsystems-react 0.51.2 → 0.51.4

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 (85) hide show
  1. package/dist/index.d.ts +5 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/C4Graph.d.ts +12 -1
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +115 -22
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +22 -22
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +56 -77
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  14. package/dist/subsystem/SubsystemComponentGraph.js +32 -13
  15. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  16. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  17. package/dist/subsystem/TrailsPanel.js +8 -4
  18. package/dist/subsystem/TrailsPanel.js.map +1 -1
  19. package/dist/subsystem/c4.d.ts +24 -86
  20. package/dist/subsystem/c4.d.ts.map +1 -1
  21. package/dist/subsystem/c4.js +6 -285
  22. package/dist/subsystem/c4.js.map +1 -1
  23. package/dist/subsystem/c4Evidence.js +1 -1
  24. package/dist/subsystem/c4Evidence.js.map +1 -1
  25. package/dist/subsystem/model.d.ts +14 -0
  26. package/dist/subsystem/model.d.ts.map +1 -1
  27. package/dist/subsystem/model.js.map +1 -1
  28. package/dist/subsystem/nodes.d.ts.map +1 -1
  29. package/dist/subsystem/nodes.js +4 -2
  30. package/dist/subsystem/nodes.js.map +1 -1
  31. package/dist/subsystem/techIcons/bun.d.ts +11 -0
  32. package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
  33. package/dist/subsystem/techIcons/bun.js +11 -0
  34. package/dist/subsystem/techIcons/bun.js.map +1 -0
  35. package/dist/subsystem/techIcons/index.d.ts +36 -0
  36. package/dist/subsystem/techIcons/index.d.ts.map +1 -0
  37. package/dist/subsystem/techIcons/index.js +55 -0
  38. package/dist/subsystem/techIcons/index.js.map +1 -0
  39. package/dist/subsystem/techIcons/node.d.ts +12 -0
  40. package/dist/subsystem/techIcons/node.d.ts.map +1 -0
  41. package/dist/subsystem/techIcons/node.js +39 -0
  42. package/dist/subsystem/techIcons/node.js.map +1 -0
  43. package/dist/subsystem/techIcons/react.d.ts +12 -0
  44. package/dist/subsystem/techIcons/react.d.ts.map +1 -0
  45. package/dist/subsystem/techIcons/react.js +19 -0
  46. package/dist/subsystem/techIcons/react.js.map +1 -0
  47. package/dist/utils/edgeLabel.d.ts +1 -1
  48. package/dist/utils/edgeLabel.d.ts.map +1 -1
  49. package/dist/utils/edgeLabel.js +1 -1
  50. package/dist/utils/edgeLabel.js.map +1 -1
  51. package/dist/utils/elkLayout.d.ts +18 -0
  52. package/dist/utils/elkLayout.d.ts.map +1 -1
  53. package/dist/utils/elkLayout.js +90 -9
  54. package/dist/utils/elkLayout.js.map +1 -1
  55. package/package.json +3 -3
  56. package/src/index.ts +4 -6
  57. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
  58. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +20 -10
  59. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
  60. package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
  61. package/src/subsystem/C4Graph.tsx +171 -19
  62. package/src/subsystem/C4NodeCard.test.ts +71 -77
  63. package/src/subsystem/C4NodeCard.tsx +65 -95
  64. package/src/subsystem/SubsystemComponentGraph.tsx +41 -13
  65. package/src/subsystem/TrailsPanel.tsx +10 -5
  66. package/src/subsystem/c4.ts +23 -357
  67. package/src/subsystem/c4Evidence.test.ts +1 -1
  68. package/src/subsystem/c4Evidence.ts +1 -1
  69. package/src/subsystem/model.ts +14 -0
  70. package/src/subsystem/nodes.tsx +5 -2
  71. package/src/subsystem/techIcons/bun.ts +10 -0
  72. package/src/subsystem/techIcons/index.tsx +73 -0
  73. package/src/subsystem/techIcons/node.ts +38 -0
  74. package/src/subsystem/techIcons/react.ts +18 -0
  75. package/src/subsystem/techIcons/techIcons.test.ts +39 -0
  76. package/src/utils/edgeLabel.ts +1 -1
  77. package/src/utils/elkLayout.test.ts +34 -0
  78. package/src/utils/elkLayout.ts +88 -9
  79. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
  80. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
  81. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
  82. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
  83. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
  84. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
  85. package/src/subsystem/c4.test.ts +0 -573
@@ -0,0 +1,212 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import '@xyflow/react/dist/style.css';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import { C4Graph } from '../../../subsystem/C4Graph';
6
+ import type { C4Edge, C4Model, C4Node } from '../../../subsystem/c4';
7
+
8
+ const meta = {
9
+ title: 'Subsystem/C4Graph/C4Container',
10
+ component: C4Graph,
11
+ parameters: { layout: 'fullscreen' },
12
+ tags: ['autodocs'],
13
+ decorators: [
14
+ (Story) => (
15
+ <ThemeProvider theme={defaultEditorTheme}>
16
+ <Story />
17
+ </ThemeProvider>
18
+ ),
19
+ ],
20
+ } satisfies Meta<typeof C4Graph>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // A C4 container diagram, hand-built.
27
+ //
28
+ // Nothing here is derived: the boxes are `C4Node`s an author wrote down, and
29
+ // the lines are explicit `C4Edge`s — no trails, no rollup. This is the shape a
30
+ // caller hands to `C4Graph` once a model has been authored.
31
+ //
32
+ // The system frame holds the containers and the external systems (C4 draws a
33
+ // dependency we do not own inside the boundary); the person sits outside it.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const SYSTEM = { id: 'system:acme', label: 'Subsystem Modeling', repoKey: 'pkg:github/acme/subsystem-modeling' };
37
+
38
+ function container(over: Partial<C4Node> & Pick<C4Node, 'id' | 'label'>): C4Node {
39
+ return {
40
+ kind: 'container',
41
+ members: [],
42
+ constructs: [],
43
+ state: 'accepted',
44
+ containerKind: 'application',
45
+ ...over,
46
+ };
47
+ }
48
+
49
+ const NODES: C4Node[] = [
50
+ container({
51
+ id: 'container:studio-host',
52
+ label: 'Subsystem Studio — host',
53
+ containerKind: 'application',
54
+ technology: 'Bun + Electrobun',
55
+ description: 'Owns the model store on disk and serves the renderer over RPC.',
56
+ parentId: SYSTEM.id,
57
+ }),
58
+ container({
59
+ id: 'container:studio-renderer',
60
+ label: 'Subsystem Studio — renderer',
61
+ containerKind: 'application',
62
+ technology: 'React 19',
63
+ description: 'Renders subsystem models and talks to the host over RPC.',
64
+ parentId: SYSTEM.id,
65
+ }),
66
+ container({
67
+ id: 'container:cli',
68
+ label: 'Subsystem Modeling CLI',
69
+ containerKind: 'application',
70
+ technology: 'Node',
71
+ description: 'Creates and updates models from the terminal.',
72
+ parentId: SYSTEM.id,
73
+ }),
74
+ container({
75
+ id: 'container:model-store',
76
+ label: 'Subsystem model store',
77
+ containerKind: 'data-store',
78
+ technology: 'JSON on disk',
79
+ description: 'Every stored model and the correction queue.',
80
+ parentId: SYSTEM.id,
81
+ }),
82
+ // An external system drawn inside the boundary — something we depend on.
83
+ {
84
+ kind: 'external-system',
85
+ id: 'external:opencode',
86
+ label: 'OpenCode v2 service',
87
+ technology: 'HTTP',
88
+ description: 'Runs the maintain agent sessions this model is edited by.',
89
+ state: 'accepted',
90
+ members: [],
91
+ constructs: [],
92
+ parentId: SYSTEM.id,
93
+ },
94
+ // A person sits outside every system boundary.
95
+ {
96
+ kind: 'person',
97
+ id: 'person:maintainer',
98
+ label: 'Maintain operator',
99
+ description: 'Reviews and accepts the correction proposals.',
100
+ state: 'accepted',
101
+ members: [],
102
+ constructs: [],
103
+ },
104
+ ];
105
+
106
+ const EDGES: C4Edge[] = [
107
+ {
108
+ id: 'flow:renderer-host',
109
+ source: 'container:studio-renderer',
110
+ target: 'container:studio-host',
111
+ label: 'RPC',
112
+ protocol: 'Electrobun RPC',
113
+ mechanisms: ['calls'],
114
+ count: 1,
115
+ },
116
+ {
117
+ id: 'flow:host-store',
118
+ source: 'container:studio-host',
119
+ target: 'container:model-store',
120
+ label: 'fs · JSON',
121
+ protocol: 'file I/O',
122
+ mechanisms: ['reads', 'writes'],
123
+ count: 2,
124
+ },
125
+ {
126
+ id: 'flow:host-opencode',
127
+ source: 'container:studio-host',
128
+ target: 'external:opencode',
129
+ label: 'HTTPS',
130
+ protocol: 'HTTP',
131
+ mechanisms: ['calls'],
132
+ count: 1,
133
+ },
134
+ {
135
+ id: 'flow:cli-store',
136
+ source: 'container:cli',
137
+ target: 'container:model-store',
138
+ label: 'fs · JSON',
139
+ protocol: 'file I/O',
140
+ mechanisms: ['writes'],
141
+ count: 1,
142
+ },
143
+ {
144
+ id: 'flow:maintainer-renderer',
145
+ source: 'person:maintainer',
146
+ target: 'container:studio-renderer',
147
+ label: 'UI',
148
+ mechanisms: ['uses'],
149
+ count: 1,
150
+ },
151
+ ];
152
+
153
+ /**
154
+ * The container-view projection of the authored elements. From here on it is a
155
+ * plain render input: `C4Graph` sizes and lays these out, it does not build any
156
+ * of them.
157
+ */
158
+ const containerModel: C4Model = {
159
+ view: 'container',
160
+ system: SYSTEM,
161
+ nodes: NODES,
162
+ groups: [
163
+ {
164
+ id: SYSTEM.id,
165
+ kind: 'system',
166
+ label: SYSTEM.label,
167
+ memberIds: NODES.filter((n) => n.parentId === SYSTEM.id).map((n) => n.id),
168
+ },
169
+ ],
170
+ edges: EDGES,
171
+ };
172
+
173
+ function Demo() {
174
+ const [selected, setSelected] = useState<string | null>(null);
175
+ const model = useMemo(() => containerModel, []);
176
+
177
+ const containers = model.nodes.filter((n) => n.kind === 'container').length;
178
+ const outside = model.nodes.filter(
179
+ (n) => n.kind === 'external-system' || n.kind === 'person',
180
+ ).length;
181
+
182
+ return (
183
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
184
+ <div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
185
+ <C4Graph model={model} title="Subsystem Modeling" onSelectNode={setSelected} />
186
+ </div>
187
+ <div
188
+ style={{
189
+ padding: '6px 12px',
190
+ display: 'flex',
191
+ alignItems: 'center',
192
+ gap: 18,
193
+ fontFamily: 'monospace',
194
+ fontSize: 12,
195
+ color: '#aaa',
196
+ borderTop: '1px solid #333',
197
+ }}
198
+ >
199
+ <span>
200
+ {containers} containers · {outside} outside
201
+ </span>
202
+ <span>{model.edges.length} lines</span>
203
+ <span>selected: {selected ?? '(none)'}</span>
204
+ <span style={{ color: '#777' }}>boxes and lines are authored, not derived</span>
205
+ </div>
206
+ </div>
207
+ );
208
+ }
209
+
210
+ export const Containers: Story = {
211
+ render: () => <Demo />,
212
+ };
@@ -6,7 +6,7 @@ import {
6
6
  nodeStyle,
7
7
  nodeSubtitle,
8
8
  nodeTag,
9
- nodeTypeLabel,
9
+ nodeTopLabel,
10
10
  nodeShape,
11
11
  nodeSize,
12
12
  nodeMissing,
@@ -25,7 +25,7 @@ import type { C4Node } from '../../../subsystem/c4';
25
25
  // is a plain presentational component, so it can be reviewed and diffed
26
26
  // without a layout engine in the way.
27
27
  //
28
- // Every card here is a flattened `C4Node` — exactly what `buildC4Model`
28
+ // Every card here is a flattened `C4Node` — the one shape a C4 box draws with,
29
29
  // produces from an authored element, with the per-kind fields already resolved
30
30
  // to the one shape the card draws with.
31
31
  // ---------------------------------------------------------------------------
@@ -334,7 +334,12 @@ function Gallery() {
334
334
 
335
335
  <div style={{ fontFamily: 'monospace', fontSize: 11, marginBottom: 26, lineHeight: 1.9 }}>
336
336
  <div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
337
- <span style={{ color: '#e8e8ea' }}>KIND — shape, icon, and border weight</span>
337
+ <span style={{ color: '#e8e8ea' }}>KIND — shape and border weight</span>
338
+ </div>
339
+ <div style={{ color: muted, marginTop: 2 }}>
340
+ Top-left is the technology’s brand mark — shown only when we hold the official SVG — and its
341
+ name. Top-right is a container’s C4 sort, application vs data-store. The line under the label
342
+ is the description.
338
343
  </div>
339
344
  <div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginTop: 4 }}>
340
345
  {KINDS.map((kind) => {
@@ -385,13 +390,18 @@ function Gallery() {
385
390
  </div>
386
391
  )}
387
392
  <Case
388
- caption={`${v.node.id} · "${nodeTag(v.node)}"${
389
- nodeTypeLabel(v.node) ? ` / ${nodeTypeLabel(v.node)}` : ''
390
- } · ${v.node.state} · ${nodeShape(v.node).kind} ${
391
- nodeSize(v.node).width
392
- }×${nodeSize(v.node).height} · ${nodeSubtitle(v.node)}${
393
- nodeMissing(v.node).length ? ` · missing ${nodeMissing(v.node).join('+')}` : ''
394
- }`}
393
+ caption={[
394
+ v.node.id,
395
+ `"${nodeTag(v.node)}"`,
396
+ v.node.kind === 'container' ? v.node.containerKind : null,
397
+ v.node.state,
398
+ `${nodeShape(v.node).kind} ${nodeSize(v.node).width}×${nodeSize(v.node).height}`,
399
+ nodeTopLabel(v.node),
400
+ nodeSubtitle(v.node),
401
+ nodeMissing(v.node).length ? `missing ${nodeMissing(v.node).join('+')}` : null,
402
+ ]
403
+ .filter(Boolean)
404
+ .join(' · ')}
395
405
  >
396
406
  <C4NodeCard node={v.node} />
397
407
  </Case>
@@ -503,8 +503,10 @@ const MAINTAIN_ISSUES: SubsystemIssue[] = [
503
503
  target: {
504
504
  kind: 'step',
505
505
  id: 'wt-wait-finish-event',
506
- label: 'Wait for the finish event',
507
- detail: 'step 7',
506
+ // The host labels a step by its call-site symbol — the same row text
507
+ // the trail UI shows — with the trail title as the detail line.
508
+ label: 'matchFinishEvent',
509
+ detail: 'Wait for the finish event',
508
510
  stepIndex: 6,
509
511
  },
510
512
  },
@@ -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
  );
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * C4Graph — a C4 projection of a (composed) subsystem model on React Flow.
3
3
  *
4
- * Feed it a `C4Model` from `buildC4Model()` and it draws the
4
+ * Feed it an authored `C4Model` and it draws the
5
5
  * system as a compound frame, its containers (or components) as boxes, and
6
6
  * externals/actors outside the system. `trail` steps render as flow
7
7
  * edges. Click a box to list the source components it rolled up.
@@ -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,
@@ -22,9 +23,11 @@ import {
22
23
  ReactFlow,
23
24
  ReactFlowProvider,
24
25
  useReactFlow,
26
+ useViewport,
25
27
  } from '@xyflow/react';
26
28
  import { useTheme } from '@principal-ade/industry-theme';
27
- import { computeElkLayout } from '../utils/elkLayout';
29
+ import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
30
+ import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, EDGE_LABEL_WIDTH } from '../utils/edgeLabel';
28
31
  import { MECHANISM_COLOR } from './model';
29
32
  import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
30
33
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
@@ -34,6 +37,17 @@ export interface C4GraphProps {
34
37
  model: C4Model;
35
38
  title?: string;
36
39
  onSelectNode?: (id: string | null) => void;
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.
48
+ * @default 28
49
+ */
50
+ gutter?: number;
37
51
  }
38
52
 
39
53
  function edgeColor(mechanism: string | undefined, fallback: string): string {
@@ -57,6 +71,118 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
57
71
  );
58
72
  }
59
73
 
74
+ /**
75
+ * Edge-label overlay.
76
+ *
77
+ * Drawn as chips in flow space via `<ViewportPortal>` — React Flow applies the
78
+ * viewport transform, so the label chip needs no manual zoom/pan math and can
79
+ * never drift from the edges it sits on. Positions are ELK's computed label
80
+ * anchors (`edgeLabelPositions`), in flow coordinates.
81
+ */
82
+ function C4EdgeLabels({
83
+ positions,
84
+ edges,
85
+ muted,
86
+ }: {
87
+ positions: Map<string, { x: number; y: number }>;
88
+ edges: C4Model['edges'];
89
+ muted: string;
90
+ }) {
91
+ const { theme } = useTheme();
92
+ const viewport = useViewport();
93
+ const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
94
+ const byId = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
95
+
96
+ return (
97
+ <div
98
+ style={{
99
+ position: 'absolute',
100
+ inset: 0,
101
+ overflow: 'hidden',
102
+ pointerEvents: 'none',
103
+ zIndex: 5,
104
+ }}
105
+ >
106
+ {[...positions].map(([id, p]) => {
107
+ const edge = byId.get(id);
108
+ if (!edge) return null;
109
+ const color = edgeColor(edge.mechanisms[0], muted);
110
+ const text = edge.protocol ?? edge.label;
111
+ return (
112
+ <div
113
+ key={id}
114
+ style={{
115
+ position: 'absolute',
116
+ left: p.x * viewport.zoom + viewport.x,
117
+ top: p.y * viewport.zoom + viewport.y,
118
+ transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
119
+ transformOrigin: 'center center',
120
+ display: 'flex',
121
+ alignItems: 'center',
122
+ justifyContent: 'center',
123
+ width: EDGE_LABEL_WIDTH,
124
+ height: EDGE_LABEL_HEIGHT,
125
+ boxSizing: 'border-box',
126
+ padding: '7px 8px',
127
+ }}
128
+ >
129
+ <span
130
+ aria-hidden
131
+ style={{
132
+ position: 'absolute',
133
+ inset: 0,
134
+ borderRadius: 4,
135
+ border: `0.5px solid ${color}`,
136
+ background: surface,
137
+ }}
138
+ />
139
+ <span
140
+ style={{
141
+ position: 'relative',
142
+ maxWidth: '100%',
143
+ overflow: 'hidden',
144
+ textOverflow: 'ellipsis',
145
+ whiteSpace: 'nowrap',
146
+ fontSize: EDGE_LABEL_FONT_SIZE,
147
+ lineHeight: 1,
148
+ fontFamily: theme.fonts.monospace,
149
+ fontWeight: 500,
150
+ color,
151
+ }}
152
+ >
153
+ {text}
154
+ </span>
155
+ </div>
156
+ );
157
+ })}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ /**
163
+ * C4 edge — draws ELK's routed polyline, not React Flow's default bezier.
164
+ *
165
+ * The line and the label chip must share one geometry: the chip is placed from
166
+ * the same `elkPathPoints` this renders. React Flow's built-in edge would curve
167
+ * between handles while the chip sat on ELK's orthogonal route, which is why
168
+ * labels read as "off the line".
169
+ */
170
+ function C4EdgeView({ data, markerEnd }: EdgeProps<Edge<{ path?: string; color?: string }>>) {
171
+ const path = data?.path ?? '';
172
+ if (!path) return null;
173
+ return (
174
+ <path
175
+ d={path}
176
+ fill="none"
177
+ stroke={data?.color}
178
+ strokeWidth={1}
179
+ strokeDasharray="6 4"
180
+ markerEnd={markerEnd}
181
+ style={{ pointerEvents: 'none' }}
182
+ />
183
+ );
184
+ }
185
+
60
186
  function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
61
187
  const { theme } = useTheme();
62
188
  const color = props.data.color;
@@ -106,7 +232,11 @@ const nodeTypes = {
106
232
  'c4-group': C4GroupView,
107
233
  };
108
234
 
109
- function Inner({ model, title, onSelectNode }: C4GraphProps) {
235
+ const edgeTypes = {
236
+ 'c4-edge': C4EdgeView,
237
+ };
238
+
239
+ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
110
240
  const { theme } = useTheme();
111
241
  const { fitView } = useReactFlow();
112
242
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -114,6 +244,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
114
244
  const [rfEdges, setRfEdges] = useState<Edge[]>([]);
115
245
  const [ready, setReady] = useState(false);
116
246
  const [selectedId, setSelectedId] = useState<string | null>(null);
247
+ const [labelPositions, setLabelPositions] = useState<Map<string, { x: number; y: number }>>(
248
+ () => new Map(),
249
+ );
117
250
 
118
251
  const layoutKey = useMemo(
119
252
  () =>
@@ -130,10 +263,11 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
130
263
  setReady(false);
131
264
  setSelectedId(null);
132
265
 
133
- // Per-node size, so a square component is laid out as a square. Feeding the
134
- // container dimensions for everything would make ELK reserve 210x138 of
135
- // space around a 132x132 box and space the component view twice as far
136
- // apart as it needs.
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.
137
271
  const rfNodes: Node[] = model.nodes.map((n, i) => {
138
272
  const size = nodeSize(n);
139
273
  return {
@@ -142,8 +276,8 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
142
276
  // Fallback grid pitch uses the container box — the largest, so the
143
277
  // pre-layout positions never overlap. ELK replaces them on success.
144
278
  position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
145
- width: size.width,
146
- height: size.height,
279
+ width: size.width + gutter,
280
+ height: size.height + gutter,
147
281
  ...(n.parentId ? { parentId: n.parentId } : {}),
148
282
  data: { node: n, selected: false },
149
283
  };
@@ -160,18 +294,15 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
160
294
  const color = edgeColor(e.mechanisms[0], muted);
161
295
  return {
162
296
  id: e.id,
297
+ type: 'c4-edge',
163
298
  source: e.source,
164
299
  target: e.target,
165
- label: e.label,
166
- labelShowBg: false,
167
- labelStyle: { fill: muted, fontSize: theme.fontSizes[0], fontFamily: theme.fonts.monospace },
168
- style: {
169
- stroke: color,
170
- strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
171
- strokeDasharray: '6 4',
172
- },
300
+ // The reserved label box and the chip must agree on size, so ELK is
301
+ // given the same text the chip draws (protocol first, then label). The
302
+ // chip itself is the C4EdgeLabels overlay, not this field.
303
+ label: e.protocol ?? e.label,
173
304
  markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
174
- data: { edge: e },
305
+ data: { edge: e, color, path: '' },
175
306
  };
176
307
  });
177
308
 
@@ -180,10 +311,23 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
180
311
  preserveNodePositions: false,
181
312
  groups,
182
313
  keepSingletonGroups: true,
314
+ edgeLabels: { enabled: true, placement: 'CENTER' },
183
315
  })
184
316
  .then((result) => {
185
317
  if (!alive) return;
186
318
  const positioned = new Map(result.nodes.map((n) => [n.id, n]));
319
+ // Draw each edge along ELK's routed polyline and place its label from
320
+ // the same points — one geometry for the line and the chip.
321
+ const labelPositions = new Map<string, { x: number; y: number }>();
322
+ const withPaths = rfEdges.map((e) => {
323
+ const pts = result.edgePathPoints?.get(e.id);
324
+ if (!pts || pts.length === 0) return e;
325
+ labelPositions.set(e.id, pointAlongPath(pts, 0.5));
326
+ // Rounded corners on ELK's orthogonal route — gentler than raw right
327
+ // angles, and still the exact polyline the label is placed on.
328
+ return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
329
+ });
330
+ setLabelPositions(labelPositions);
187
331
  const builtGroups = new Set(result.groupBounds.keys());
188
332
  const groupById = new Map(model.groups.map((g) => [g.id, g]));
189
333
 
@@ -231,7 +375,7 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
231
375
  });
232
376
 
233
377
  setNodes([...shells, ...leaves]);
234
- setRfEdges(rfEdges);
378
+ setRfEdges(withPaths);
235
379
  setReady(true);
236
380
  requestAnimationFrame(() => fitView({ padding: 0.15 }));
237
381
  })
@@ -277,6 +421,7 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
277
421
  nodes={displayNodes}
278
422
  edges={rfEdges}
279
423
  nodeTypes={nodeTypes}
424
+ edgeTypes={edgeTypes}
280
425
  className={GRAPH_CANVAS_CLASS}
281
426
  {...GRAPH_NAV_PROPS}
282
427
  onNodeClick={(_e, node) => {
@@ -290,9 +435,16 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
290
435
  onSelectNode?.(null);
291
436
  }}
292
437
  proOptions={{ hideAttribution: true }}
438
+ // Pin the flow to its container. The label overlay is a sibling sized
439
+ // `inset: 0`, so the two must share an origin or `viewport.y` (measured
440
+ // in the flow's own box) lands the chips off vertically.
441
+ style={{ width: '100%', height: '100%' }}
293
442
  >
294
443
  <GraphChrome />
295
444
  </ReactFlow>
445
+ {ready && labelPositions.size > 0 && (
446
+ <C4EdgeLabels positions={labelPositions} edges={model.edges} muted={muted} />
447
+ )}
296
448
  {!ready && (
297
449
  <div
298
450
  style={{