@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
@@ -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', () => {
@@ -16,7 +16,7 @@ import { useTheme } from '@principal-ade/industry-theme';
16
16
  import { AppWindow, Database } from 'lucide-react';
17
17
  import type { LucideIcon } from 'lucide-react';
18
18
  import { TechMark, technologyBrand } from './techIcons';
19
- import type { C4Node } from './c4';
19
+ import type { C4Element } from './c4';
20
20
 
21
21
  /**
22
22
  * Card box for a container: a wide rectangle. A container carries four things
@@ -38,7 +38,7 @@ export const COMPONENT_SIZE = 140;
38
38
  * Per-level box size. The graph lays out against these, so a square component
39
39
  * and a rectangular container do not collide at a shared nominal width.
40
40
  */
41
- export function nodeSize(node: C4Node): { width: number; height: number } {
41
+ export function nodeSize(node: C4Element): { width: number; height: number } {
42
42
  return node.kind === 'component'
43
43
  ? { width: COMPONENT_SIZE, height: COMPONENT_SIZE }
44
44
  : { width: NODE_W, height: NODE_H };
@@ -59,35 +59,50 @@ export interface NodeStyle {
59
59
  }
60
60
 
61
61
  /**
62
- * Border style for one node: colour, dash and weight, from confirmation state
63
- * and level.
62
+ * Border style for one node: colour from **technology**, dash and weight from
63
+ * **confirmation state** and level.
64
64
  *
65
- * One axis per channel, so nothing is said twice and nothing is contradicted:
65
+ * One axis per channel, so nothing is said twice:
66
66
  *
67
- * colour + dash → confirmation state (the primary axis)
67
+ * colour → technology (the brand hue; see `technologyBrand`)
68
+ * dash → confirmation state (solid accepted, dashed proposed)
68
69
  * weight → C4 level, as a grayscale redundancy for the square
69
70
  * shape + size → C4 kind (`nodeShape`, `nodeSize`)
70
71
  *
71
- * State owning the dash is deliberate. An earlier build gave the dash to kind
72
- * and drew proposed boxes solid, quietly contradicting the one thing the
73
- * confirm UI needs a reader to see. Kind now says itself through shape, and
74
- * the border says who vouched for it.
72
+ * Colour moved from state to technology: on a container diagram the reader
73
+ * wants "is this Bun or React" at a glance, and the state already reads off the
74
+ * dash. A selected node overrides colour so a click target stands out.
75
75
  */
76
- export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeStyle {
76
+ /** The technology string for an element, if its kind has one. */
77
+ export function nodeTechnology(node: C4Element): string | undefined {
78
+ switch (node.kind) {
79
+ case 'container':
80
+ case 'component':
81
+ return node.technology;
82
+ case 'external-system':
83
+ return node.technology;
84
+ case 'person':
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ export function nodeStyle(node: C4Element, theme: Theme, selected = false): NodeStyle {
77
90
  const muted = theme.colors.border ?? '#555';
91
+ const techColor = technologyBrand(nodeTechnology(node))?.color;
78
92
  if (selected) return { color: theme.colors.primary ?? '#5aa', dash: 'solid', width: 3 };
79
93
 
80
94
  // Border weight carries the level, so a component stays a component even
81
95
  // in grayscale or for a reader who cannot separate the hues.
82
96
  const weight = node.kind === 'component' ? 1 : 2;
97
+ const color = techColor ?? muted;
83
98
 
84
99
  switch (node.state) {
85
100
  case 'accepted':
86
- // Solid and saturated — part of the architecture now.
87
- return { color: theme.colors.primary ?? '#4ec9b0', dash: 'solid', width: weight };
101
+ // Solid — part of the architecture now.
102
+ return { color, dash: 'solid', width: weight };
88
103
  case 'proposed':
89
104
  // Dashed — an agent asked; a human has not answered.
90
- return { color: theme.colors.warning ?? '#e8a33a', dash: 'dashed', width: weight };
105
+ return { color, dash: 'dashed', width: weight };
91
106
  case 'rejected':
92
107
  // Should never be drawn (a rejected element is not projected); belt-and-braces.
93
108
  return { color: muted, dash: 'dotted', width: 1 };
@@ -99,7 +114,7 @@ export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeSty
99
114
  * asks every element to carry. No fallback — when nothing is stated, the
100
115
  * notation-gap row says so rather than the card inventing a summary.
101
116
  */
102
- export function nodeSubtitle(node: C4Node): string {
117
+ export function nodeSubtitle(node: C4Element): string {
103
118
  return node.description ?? '';
104
119
  }
105
120
 
@@ -109,7 +124,7 @@ export function nodeSubtitle(node: C4Node): string {
109
124
  * Confirmation state is a separate axis carried by the border (see
110
125
  * `nodeStyle`), not by a word on the card.
111
126
  */
112
- export function nodeTag(node: C4Node): string {
127
+ export function nodeTag(node: C4Element): string {
113
128
  return node.kind;
114
129
  }
115
130
 
@@ -119,11 +134,17 @@ export function nodeTag(node: C4Node): string {
119
134
  * The C4 kind itself does not need a glyph — the box shape already carries it
120
135
  * (square vs rectangle vs pill), and the row's one text slot is worth more
121
136
  * spent on the technology. Only containers have a C4-recognised split, so the
122
- * other three kinds return nothing.
137
+ * other kinds return nothing.
123
138
  */
124
- export function nodeKindIcon(node: C4Node): LucideIcon | undefined {
125
- if (node.kind !== 'container') return undefined;
126
- return node.containerKind === 'data-store' ? Database : AppWindow;
139
+ export function nodeKindIcon(node: C4Element): LucideIcon | undefined {
140
+ switch (node.kind) {
141
+ case 'container':
142
+ return node.containerKind === 'data-store' ? Database : AppWindow;
143
+ case 'component':
144
+ case 'external-system':
145
+ case 'person':
146
+ return undefined;
147
+ }
127
148
  }
128
149
 
129
150
  /**
@@ -131,9 +152,17 @@ export function nodeKindIcon(node: C4Node): LucideIcon | undefined {
131
152
  * Postgres…). Falls back to the container kind / C4 kind when no technology is
132
153
  * stated, so the row is never blank.
133
154
  */
134
- export function nodeTopLabel(node: C4Node): string {
135
- if (node.technology) return node.technology;
136
- return node.kind === 'container' ? (node.containerKind ?? 'container') : node.kind;
155
+ export function nodeTopLabel(node: C4Element): string {
156
+ const technology = nodeTechnology(node);
157
+ if (technology) return technology;
158
+ switch (node.kind) {
159
+ case 'container':
160
+ return node.containerKind;
161
+ case 'component':
162
+ case 'external-system':
163
+ case 'person':
164
+ return node.kind;
165
+ }
137
166
  }
138
167
 
139
168
  /** How a box is drawn. Shape carries the C4 kind, independent of colour. */
@@ -151,26 +180,24 @@ export interface NodeShape {
151
180
  *
152
181
  * The convention:
153
182
  *
154
- * container wide rectangle, sharp corners — a runtime boundary
183
+ * container wide rectangle, slight radius — a runtime boundary
155
184
  * component square — a part inside one (see `nodeSize`)
156
- * external system wide rectangle, rounded — someone else's system
185
+ * external system wide rectangle, no radius — someone else's system
157
186
  * person pill — not a box at all
158
187
  *
159
- * An external system is separated from a container by radius alone, which is
160
- * a weak cue. That is deliberate rather than an oversight: the border dash is
161
- * the only channel that could say "outside our control" loudly, and it is
162
- * already spent on proposed-vs-accepted. The kind icon says it, and the frame
163
- * position says it structurally.
188
+ * A container keeps a slight radius; an external system is drawn with none, so
189
+ * the two are distinguishable by corner treatment as well as by the kind icon
190
+ * and frame position. The border dash stays spent on proposed-vs-accepted.
164
191
  */
165
- export function nodeShape(node: C4Node): NodeShape {
192
+ export function nodeShape(node: C4Element): NodeShape {
166
193
  switch (node.kind) {
167
194
  case 'person':
168
195
  return { kind: 'pill', radius: NODE_H / 2 };
169
196
  case 'external-system':
170
- return { kind: 'rect', radius: 14 };
197
+ return { kind: 'rect', radius: 0 };
171
198
  case 'component':
172
199
  return { kind: 'square', radius: 6 };
173
- default:
200
+ case 'container':
174
201
  return { kind: 'rect', radius: 4 };
175
202
  }
176
203
  }
@@ -184,7 +211,7 @@ export function nodeShape(node: C4Node): NodeShape {
184
211
  * component drawn without a parent already reports `unknown_container`, and
185
212
  * that is the question worth asking first.
186
213
  */
187
- export function nodeMissing(node: C4Node): string[] {
214
+ export function nodeMissing(node: C4Element): string[] {
188
215
  if (node.kind !== 'container') return [];
189
216
  const missing: string[] = [];
190
217
  if (!node.technology) missing.push('technology');
@@ -193,7 +220,7 @@ export function nodeMissing(node: C4Node): string[] {
193
220
  }
194
221
 
195
222
  export interface C4NodeCardProps {
196
- node: C4Node;
223
+ node: C4Element;
197
224
  selected?: boolean;
198
225
  /** Draw React Flow's edge handles. Off when rendering a bare gallery. */
199
226
  handles?: React.ReactNode;
@@ -206,7 +233,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
206
233
  const style = nodeStyle(node, theme, selected);
207
234
  const shape = nodeShape(node);
208
235
  const Icon = nodeKindIcon(node);
209
- const brand = technologyBrand(node.technology);
236
+ const brand = technologyBrand(nodeTechnology(node));
210
237
  const topLabel = nodeTopLabel(node);
211
238
  const subtitle = nodeSubtitle(node);
212
239
 
@@ -232,7 +259,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
232
259
  padding: centered ? '10px 30px' : '10px 12px',
233
260
  borderRadius: shape.radius,
234
261
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
235
- border: `${style.width} ${style.dash} ${style.color}`,
262
+ border: `${style.width}px ${style.dash} ${style.color}`,
236
263
  boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
237
264
  cursor: onClick ? 'pointer' : 'default',
238
265
  fontFamily: theme.fonts.body,
@@ -272,8 +299,8 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
272
299
  </span>
273
300
  {Icon && (
274
301
  <span
275
- title={node.containerKind ?? node.kind}
276
- aria-label={node.containerKind ?? node.kind}
302
+ title={node.kind === 'container' ? node.containerKind : node.kind}
303
+ aria-label={node.kind === 'container' ? node.containerKind : node.kind}
277
304
  style={{ display: 'inline-flex', flexShrink: 0, color: style.color }}
278
305
  >
279
306
  <Icon size={13} strokeWidth={2.25} />
@@ -286,6 +313,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
286
313
  below sits at the same y on every card. */}
287
314
  <span
288
315
  style={{
316
+ fontFamily: theme.fonts.body,
289
317
  fontWeight: 600,
290
318
  fontSize: theme.fontSizes[2],
291
319
  color: theme.colors.text,
@@ -17,7 +17,10 @@ import {
17
17
  ChevronDown,
18
18
  ChevronRight,
19
19
  CircleCheck,
20
+ CircleDashed,
21
+ CircleEllipsis,
20
22
  CircleHelp,
23
+ CircleX,
21
24
  Component,
22
25
  FileX,
23
26
  FolderGit2,
@@ -41,6 +44,7 @@ export type SubsystemIssueSeverity = 'error' | 'info';
41
44
  export type SubsystemIssueTargetKind =
42
45
  | 'component'
43
46
  | 'module'
47
+ | 'process'
44
48
  | 'trail'
45
49
  | 'step'
46
50
  | 'repo'
@@ -105,6 +109,12 @@ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
105
109
  graphify_unavailable: 'repo',
106
110
  boundary_module_file_mismatch: 'static-topology',
107
111
  boundary_process_nest_disagree: 'dynamic-topology',
112
+ // C4 process verification (verifyProcessBoundaries): a boundary whose
113
+ // container claim is not an accepted one. `verified` reports nothing —
114
+ // the audit reports absence only.
115
+ boundary_process_unassigned: 'dynamic-topology',
116
+ boundary_process_proposed: 'dynamic-topology',
117
+ boundary_process_rejected: 'dynamic-topology',
108
118
  trail: 'trail',
109
119
  step_unconfirmed: 'trail',
110
120
  step_stale: 'trail',
@@ -168,6 +178,11 @@ export const ISSUE_KIND_ICON: Record<string, LucideIcon> = {
168
178
  // One module, more than one claimed process parent — a containment that can't
169
179
  // be drawn, which is why the region sits at the root instead of nested.
170
180
  boundary_process_nest_disagree: Split,
181
+ // C4 process verification: unclaimed = no container at all, proposed = an
182
+ // agent scaffold awaits a decision, rejected = a container was declined.
183
+ boundary_process_unassigned: CircleEllipsis,
184
+ boundary_process_proposed: CircleDashed,
185
+ boundary_process_rejected: CircleX,
171
186
  // Trail step verification: unconfirmed = no augmentation yet, stale = code changed
172
187
  step_unconfirmed: CircleHelp,
173
188
  step_stale: History,
@@ -250,6 +265,11 @@ const KIND_LABEL: Record<string, string> = {
250
265
  graphify_unavailable: 'Graphify cache unavailable',
251
266
  boundary_module_file_mismatch: 'Declaration file outside module',
252
267
  boundary_process_nest_disagree: 'Module spans multiple process contexts',
268
+ // C4 process verification (labels read as boundary states, since the layer
269
+ // scopes them to process frames).
270
+ boundary_process_unassigned: 'Process boundary unclaimed',
271
+ boundary_process_proposed: 'Container awaiting decision',
272
+ boundary_process_rejected: 'Container rejected',
253
273
  trail: 'Step issue',
254
274
  step_unconfirmed: 'Call site not verified',
255
275
  step_stale: 'Call site changed',
@@ -284,6 +304,9 @@ const KIND_ORDER: Record<string, number> = {
284
304
  boundary_module_file_mismatch: 23,
285
305
  // Runtime topology — process containment
286
306
  boundary_process_nest_disagree: 30,
307
+ boundary_process_unassigned: 31,
308
+ boundary_process_proposed: 32,
309
+ boundary_process_rejected: 33,
287
310
  // Trail — mechanical issues first, then step verification
288
311
  trail: 40,
289
312
  step_unconfirmed: 41,
@@ -39,6 +39,7 @@ import {
39
39
  deriveGraphEdges,
40
40
  isConstructsOnlyModel,
41
41
  moduleGroupNodeId,
42
+ processGroupNodeId,
42
43
  isTrailMechanism,
43
44
  edgeColor,
44
45
  MECHANISM_DESCRIPTIONS,
@@ -1099,16 +1100,24 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1099
1100
  [components],
1100
1101
  );
1101
1102
 
1102
- // A `module` issue target names a boundary frame, so focusing the card frames
1103
- // that region on the canvas. Null when the target names no frame (a singleton
1104
- // dropped from the layout, or a region key nothing declares).
1105
- const issueModuleNodeId = useCallback(
1103
+ // A `module` / `process` issue target names a boundary frame, so focusing
1104
+ // the card frames that region on the canvas. Null when the target names no
1105
+ // frame (a singleton dropped from the layout, or a region key nothing
1106
+ // declares).
1107
+ const issueFrameNodeId = useCallback(
1106
1108
  (issue: SubsystemIssue): string | null => {
1107
1109
  const target = issue.target;
1108
- if (target?.kind !== 'module') return null;
1109
- const key = target.id ?? target.label;
1110
- if (!components.some((c) => c.module?.trim() === key)) return null;
1111
- return moduleGroupNodeId(key);
1110
+ if (target?.kind === 'module') {
1111
+ const key = target.id ?? target.label;
1112
+ if (!components.some((c) => c.module?.trim() === key)) return null;
1113
+ return moduleGroupNodeId(key);
1114
+ }
1115
+ if (target?.kind === 'process') {
1116
+ const key = target.id ?? target.label;
1117
+ if (!components.some((c) => c.process === key)) return null;
1118
+ return processGroupNodeId(key);
1119
+ }
1120
+ return null;
1112
1121
  },
1113
1122
  [components],
1114
1123
  );
@@ -1159,7 +1168,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1159
1168
  }, [issues, issueComponent]);
1160
1169
 
1161
1170
  // Per-frame diagnostics badge: the same fold, but for findings about a
1162
- // BOUNDARY rather than a construct. Keyed by the region's React Flow node id.
1171
+ // BOUNDARY rather than a construct. Keyed by the region's React Flow node
1172
+ // id — a `module` target names a module frame, a `process` target a process
1173
+ // frame (dynamic-topology findings, e.g. C4 process verification).
1163
1174
  // Only kinds with a dedicated frame icon qualify (`ISSUE_KIND_ICON`) — a
1164
1175
  // finding with no icon of its own has nothing to badge the frame with, and
1165
1176
  // borrowing its layer's icon would imply the frame is at fault for it.
@@ -1168,8 +1179,12 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1168
1179
  if (!issues?.length) return map;
1169
1180
  for (const issue of issues) {
1170
1181
  const target = issue.target;
1171
- if (target?.kind !== 'module' || !ISSUE_KIND_ICON[issue.kind]) continue;
1172
- const id = moduleGroupNodeId(target.id ?? target.label);
1182
+ if (!target || !ISSUE_KIND_ICON[issue.kind]) continue;
1183
+ if (target.kind !== 'module' && target.kind !== 'process') continue;
1184
+ const id =
1185
+ target.kind === 'module'
1186
+ ? moduleGroupNodeId(target.id ?? target.label)
1187
+ : processGroupNodeId(target.id ?? target.label);
1173
1188
  const prev = map.get(id);
1174
1189
  map.set(id, {
1175
1190
  severity:
@@ -1211,6 +1226,22 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1211
1226
  return map;
1212
1227
  }, [issues, trails, issueStep]);
1213
1228
 
1229
+ // Edges backed by a step that ITSELF declares `proposed` — a planned seam
1230
+ // between otherwise-real components. Distinct from a proposed ENDPOINT
1231
+ // (the node is the placeholder there, and draws its own dashed border):
1232
+ // here the seam is the claim that is not real yet, so the edge label draws
1233
+ // dashed. Proposed steps are never verified, so this never competes with
1234
+ // the step-issue badge.
1235
+ const proposedEdgeIds = useMemo(() => {
1236
+ const set = new Set<string>();
1237
+ for (const trail of trails ?? []) {
1238
+ for (const step of trail.steps) {
1239
+ if (step.proposed) set.add(trailStepGraphEdgeId(step));
1240
+ }
1241
+ }
1242
+ return set;
1243
+ }, [trails]);
1244
+
1214
1245
  // Expanded verification layers in the diagnostics list. When any layer with
1215
1246
  // findings is expanded, the canvas dims everything that layer does not
1216
1247
  // implicate (see `issueFocus`). The list publishes this through
@@ -1251,6 +1282,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1251
1282
  // target:
1252
1283
  // component → that node
1253
1284
  // module → every component in that module
1285
+ // process → every component with that `process` key
1254
1286
  // trail → the flow's nodes + its step edges
1255
1287
  // repo → every component of that repo
1256
1288
  // graph → whole-graph finding; implicates nothing specific
@@ -1282,6 +1314,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1282
1314
  nodeIds.add(c.alias);
1283
1315
  }
1284
1316
  }
1317
+ } else if (t.kind === 'process') {
1318
+ const key = t.id ?? t.label;
1319
+ for (const c of components) {
1320
+ if (c.process === key) nodeIds.add(c.alias);
1321
+ }
1285
1322
  } else if (t.kind === 'trail') {
1286
1323
  const wt = (trails ?? []).find(
1287
1324
  (w) => w.id === t.id || w.title === t.label,
@@ -1472,7 +1509,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1472
1509
  // Host override wins over the library's derived frame color.
1473
1510
  const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
1474
1511
  // Boundary findings badge the FRAME, not a member — a region's shape is
1475
- // the fault, so no leaf construct carries it.
1512
+ // the fault, so no leaf construct carries it. Process verification
1513
+ // findings ride the same path: their target kind is `process`.
1476
1514
  const regionIssue = regionIssueByNodeId.get(n.id);
1477
1515
  return {
1478
1516
  ...n,
@@ -1530,6 +1568,17 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1530
1568
  () => convertedEdges.map((e) => e.id).sort().join(','),
1531
1569
  [convertedEdges],
1532
1570
  );
1571
+ // Stamp `proposedStep` onto edge data so the SVG edge itself (not just its
1572
+ // label) draws dashed when a backing trail step declares itself proposed.
1573
+ const proposedStampedEdges = useMemo(
1574
+ () =>
1575
+ baseEdges.map((e) =>
1576
+ proposedEdgeIds.has(e.id)
1577
+ ? { ...e, data: { ...(e.data as object), proposedStep: true } }
1578
+ : e,
1579
+ ),
1580
+ [baseEdges, proposedEdgeIds],
1581
+ );
1533
1582
 
1534
1583
  const dispEdges = useMemo(() => {
1535
1584
  const paint = (e: Edge, dimmed: boolean): Edge => {
@@ -1558,7 +1607,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1558
1607
  return isTrailMechanism(mechanism);
1559
1608
  };
1560
1609
  if (openedEdgeIds || focusEdgeIds || previewEdgeIds || issueFocusEdgeIds) {
1561
- return baseEdges.map((e) => {
1610
+ return proposedStampedEdges.map((e) => {
1562
1611
  const vis = flowElementVisibility({
1563
1612
  inOpened: openedEdgeIds?.has(e.id) === true,
1564
1613
  inSelected: focusEdgeIds?.has(e.id) === true,
@@ -1572,13 +1621,13 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1572
1621
  });
1573
1622
  }
1574
1623
  if (selectedEdgeId) {
1575
- return baseEdges.map((e) => ({
1624
+ return proposedStampedEdges.map((e) => ({
1576
1625
  ...paint(e, e.id !== selectedEdgeId),
1577
1626
  hidden: !edgeInView(e),
1578
1627
  }));
1579
1628
  }
1580
- return baseEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
1581
- }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
1629
+ return proposedStampedEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
1630
+ }, [proposedStampedEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
1582
1631
 
1583
1632
  const onNodesChange = useCallback(
1584
1633
  (changes: NodeChange[]) => {
@@ -2349,7 +2398,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2349
2398
  } else {
2350
2399
  // Nothing selectable — but a module target names a boundary frame, so
2351
2400
  // frame that region.
2352
- const moduleNodeId = issueModuleNodeId(issue);
2401
+ const moduleNodeId = issueFrameNodeId(issue);
2353
2402
  if (moduleNodeId) {
2354
2403
  setSelected(null);
2355
2404
  setSelectedEdgeId(null);
@@ -2367,7 +2416,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2367
2416
  [
2368
2417
  issueComponent,
2369
2418
  issueStep,
2370
- issueModuleNodeId,
2419
+ issueFrameNodeId,
2371
2420
  focusTrailStep,
2372
2421
  onSelect,
2373
2422
  onSelectIssue,
@@ -2391,7 +2440,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2391
2440
  setSelectedEdgeId(null);
2392
2441
  } else {
2393
2442
  const step = issueStep(issue);
2394
- const moduleNodeId = step ? null : issueModuleNodeId(issue);
2443
+ const moduleNodeId = step ? null : issueFrameNodeId(issue);
2395
2444
  if (step) {
2396
2445
  // Only unwind a step focus this card still owns. A dim-mode graph
2397
2446
  // keeps its step focus in hover state instead (owned by the pointer,
@@ -2418,7 +2467,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2418
2467
  fitView({ padding: 0.1, duration: 300, minZoom: 0.05, maxZoom: 2 });
2419
2468
  });
2420
2469
  },
2421
- [issueComponent, issueStep, issueModuleNodeId, fitView],
2470
+ [issueComponent, issueStep, issueFrameNodeId, fitView],
2422
2471
  );
2423
2472
 
2424
2473
  // Construct tokens inside a trail snippet. A step's line is an edge
@@ -2495,6 +2544,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2495
2544
  pathLength?: number;
2496
2545
  } | undefined;
2497
2546
  const stepIssue = stepIssueBadgeByEdgeId.get(e.id);
2547
+ const proposed = proposedEdgeIds.has(e.id);
2498
2548
  return {
2499
2549
  id: e.id,
2500
2550
  mechanism: d?.mechanism ?? 'uses',
@@ -2505,10 +2555,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2505
2555
  pathLength: d?.pathLength ?? 0,
2506
2556
  stepNos: selectedFlowStepNos?.get(e.id),
2507
2557
  stepIssue,
2558
+ proposed,
2508
2559
  };
2509
2560
  });
2510
2561
  return labels;
2511
- }, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId]);
2562
+ }, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId, proposedEdgeIds]);
2512
2563
 
2513
2564
  // Unique source files across components → sidebar file trees.
2514
2565
  const treeFilePaths = treeFiles;
@@ -2935,7 +2986,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2935
2986
  crisp rounded box; soft / ambiguous ones (uses, feeds,
2936
2987
  watches …) get a real lobed cloud silhouette — ambiguous, not
2937
2988
  a dashed proposal (or a pill, which border-radius can only
2938
- ever make). */}
2989
+ ever make). A step that ITSELF declares `proposed` dashes the
2990
+ silhouette — the seam is planned, distinct from a proposed
2991
+ ENDPOINT, which dashes the node instead. */}
2939
2992
  {verifiable ? (
2940
2993
  <span
2941
2994
  aria-hidden
@@ -2943,7 +2996,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2943
2996
  position: 'absolute',
2944
2997
  inset: 0,
2945
2998
  borderRadius: 4,
2946
- border: `0.5px solid ${color}`,
2999
+ border: lbl.proposed
3000
+ ? `1.5px dashed ${color}`
3001
+ : `0.5px solid ${color}`,
2947
3002
  background:
2948
3003
  theme.colors.backgroundSecondary ?? theme.colors.background,
2949
3004
  pointerEvents: 'none',
@@ -2968,6 +3023,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2968
3023
  stroke={hexWithAlpha(color, 0.75)}
2969
3024
  strokeWidth={1.2}
2970
3025
  strokeLinejoin="round"
3026
+ strokeDasharray={lbl.proposed ? '4 3' : undefined}
2971
3027
  />
2972
3028
  </svg>
2973
3029
  )}
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { PROTOCOL_COLOR, PROTOCOL_COLOR_FALLBACK, protocolColor } from './c4';
3
+
4
+ describe('protocolColor', () => {
5
+ test('maps a known protocol to its hue', () => {
6
+ expect(protocolColor('HTTP')).toBe(PROTOCOL_COLOR.http);
7
+ expect(protocolColor('RPC')).toBe(PROTOCOL_COLOR.rpc);
8
+ expect(protocolColor('file I/O')).toBe(PROTOCOL_COLOR['file i/o']);
9
+ });
10
+
11
+ test('matches a token inside a longer protocol string', () => {
12
+ expect(protocolColor('Electrobun RPC')).toBe(PROTOCOL_COLOR.rpc);
13
+ expect(protocolColor('HTTPS')).toBe(PROTOCOL_COLOR.https);
14
+ });
15
+
16
+ test('prefers the longest matching token', () => {
17
+ // `JSON-RPC` must find `json-rpc`, not the shorter `rpc`.
18
+ expect(protocolColor('JSON-RPC')).toBe(PROTOCOL_COLOR['json-rpc']);
19
+ });
20
+
21
+ test('falls back for an unknown or missing protocol', () => {
22
+ expect(protocolColor('carrier pigeon')).toBe(PROTOCOL_COLOR_FALLBACK);
23
+ expect(protocolColor(undefined)).toBe(PROTOCOL_COLOR_FALLBACK);
24
+ expect(protocolColor('')).toBe(PROTOCOL_COLOR_FALLBACK);
25
+ });
26
+ });