@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
@@ -1,57 +1,29 @@
1
1
  /**
2
- * C4 evidence — what an agent needs in order to propose elements.
2
+ * C4 evidence — the pure read-side of proposing and verifying C4 containers.
3
3
  *
4
- * This module does not decide anything. It answers "what is worth asking
5
- * about?" so an agent can go and read the source, then propose a
6
- * `C4Element` with a rationale. The questions come from the C4 notation
7
- * rules the document cannot satisfy on its own:
4
+ * This module does not decide anything. The proposing agent reads the
5
+ * subsystem diagrams, queries the existing containers, reasons, and writes
6
+ * its own rationale; what lives here is only the mechanical part of that
7
+ * loop:
8
8
  *
9
- * - every container needs a technology
10
- * - every element needs a description
11
- * - a container is a runtime boundary, so a library is not one
12
- * - a container cannot straddle two software systems
9
+ * - `proposeElements` — the proposal scaffold: skip keys containers
10
+ * already claim, skip boundaries no container can be discerned from
11
+ * (no runtime code behind them)
12
+ * - `verifyProcessBoundaries` — a boundary's verification status, read
13
+ * off the claiming container
14
+ * - `verificationIssues` — those statuses as dynamic-topology findings,
15
+ * so verification surfaces through the standard diagnostics pipeline
16
+ * - `findUnassigned` — runtime components no `process` names at all
13
17
  *
14
- * The output is a set of concerns. Resolving one is the agent's job, and the
15
- * answer lands in an element's `rationale`.
18
+ * Judgements — is this the same unit under a different name? is one function
19
+ * a deployable unit? — belong to the agent and the human, never to a
20
+ * matcher.
16
21
  */
17
22
 
18
23
  import type { C4Container, C4ContainerKind, C4Element, C4ElementSet } from './c4';
24
+ import type { SubsystemIssue } from './IssueList';
19
25
 
20
- /** What a concern is about — a derived key, or an existing element id. */
21
- export interface C4Concern {
22
- /** The process key, module path, or element id this concerns. */
23
- target: string;
24
- kind: C4ConcernKind;
25
- message: string;
26
- /** Aliases that triggered it. */
27
- members?: string[];
28
- }
29
-
30
- /**
31
- * The questions worth a human's attention.
32
- *
33
- * Each maps to something an agent cannot settle by reading a string — it has
34
- * to open the source, or ask the user.
35
- */
36
- export type C4ConcernKind =
37
- /** two or more process keys may be the same deployable unit */
38
- | 'merge_candidate'
39
- /** exactly one member — a lone function is not a deployable unit */
40
- | 'singleton'
41
- /** no member executes at runtime — a library is not a container */
42
- | 'library_shaped'
43
- /** a store whose storage kind was never decided */
44
- | 'store_unclassified'
45
- /** members claim more than one repo — a container cannot straddle systems */
46
- | 'spans_repos'
47
- /** an external with no resolvable identity */
48
- | 'external_unidentified'
49
- /** a runtime component with no process at all */
50
- | 'unassigned'
51
- /** C4 requires technology on every container */
52
- | 'missing_technology';
53
-
54
- /** The fields a concern check reads off a component. */
26
+ /** The fields the propose/verify gates read off a component. */
55
27
  export interface C4MemberView {
56
28
  alias: string;
57
29
  construct: string;
@@ -76,122 +48,6 @@ export function isRuntimeConstruct(construct: string): boolean {
76
48
  return RUNTIME_CONSTRUCTS.has(construct);
77
49
  }
78
50
 
79
- /**
80
- * Derive the questions worth asking about one candidate element.
81
- *
82
- * Pure over the rollup, so it runs identically against a single model, the 40
83
- * composed models, or a fixture. Pure *questions* — it never proposes an
84
- * answer, because the answers are architectural.
85
- */
86
- export function deriveConcerns(
87
- key: string,
88
- members: readonly C4MemberView[],
89
- tech: { frameworks: readonly string[] } = { frameworks: [] },
90
- ): C4Concern[] {
91
- const out: C4Concern[] = [];
92
- const aliases = members.map((m) => m.alias);
93
- const runtime = members.filter((m) => isRuntimeConstruct(m.construct));
94
-
95
- if (members.length === 1) {
96
- out.push({
97
- target: key,
98
- kind: 'singleton',
99
- message: `process "${key}" has exactly one member (${aliases[0]}) — a lone function is not a deployable unit`,
100
- members: aliases,
101
- });
102
- }
103
-
104
- // No member executes at runtime ⇒ this cannot be a deployable unit,
105
- // regardless of how many members it has. A lone interface and a package of
106
- // interfaces fail the same way.
107
- if (members.length > 0 && runtime.length === 0) {
108
- out.push({
109
- target: key,
110
- kind: 'library_shaped',
111
- message: `process "${key}" has ${members.length} member(s) but none execute at runtime (${[...new Set(members.map((m) => m.construct))].join(', ')}) — this reads as a library, not a container`,
112
- members: aliases,
113
- });
114
- }
115
-
116
- if (runtime.length > 0 && tech.frameworks.length === 0) {
117
- out.push({
118
- target: key,
119
- kind: 'missing_technology',
120
- message: `container "${key}" has no framework signal — C4 requires a technology on every container`,
121
- members: aliases,
122
- });
123
- }
124
-
125
- const repos = new Set(
126
- members
127
- .map((m) => (m.purl ?? '').split('#')[0])
128
- .filter((r) => r && !r.startsWith('external:')),
129
- );
130
- if (repos.size > 1) {
131
- out.push({
132
- target: key,
133
- kind: 'spans_repos',
134
- message: `process "${key}" spans ${repos.size} repo keys (${[...repos].join(', ')}) — a C4 container cannot straddle a system boundary`,
135
- members: aliases,
136
- });
137
- }
138
-
139
- const unclassified = members.filter((m) => m.construct === 'store' && !storageOf(m));
140
- if (unclassified.length > 0) {
141
- out.push({
142
- target: key,
143
- kind: 'store_unclassified',
144
- message: `${unclassified.length} store(s) in "${key}" declare no storage kind (${unclassified.map((m) => m.alias).join(', ')}) — decide whether each is a C4 data store or an internal detail`,
145
- members: unclassified.map((m) => m.alias),
146
- });
147
- }
148
-
149
- const unidentified = members.filter(
150
- (m) => m.construct === 'external' && (!m.purl || m.purl === 'external' || m.purl.startsWith('external:proposed')),
151
- );
152
- if (unidentified.length > 0) {
153
- out.push({
154
- target: key,
155
- kind: 'external_unidentified',
156
- message: `${unidentified.length} external(s) have no resolvable identity (${unidentified.map((m) => m.alias).join(', ')}) — a C4 box named "external" is not a diagram`,
157
- members: unidentified.map((m) => m.alias),
158
- });
159
- }
160
-
161
- return out;
162
- }
163
-
164
- /**
165
- * Suggest merges between keys that share a trailing role.
166
- *
167
- * `subsystems-studio/host` and `principal-studio/host` are the same role under
168
- * two app names. This is a *question*, never a merge — only the user knows
169
- * whether they are one process or two.
170
- */
171
- export function suggestMerges(
172
- rollup: ReadonlyArray<{ key: string; memberCount: number }>,
173
- ): Array<{ role: string; keys: string[]; reason: string }> {
174
- const byRole = new Map<string, string[]>();
175
- for (const { key } of rollup) {
176
- const slash = key.lastIndexOf('/');
177
- if (slash <= 0) continue; // a bare key has no app/role shape
178
- const role = key.slice(slash + 1);
179
- const list = byRole.get(role) ?? [];
180
- list.push(key);
181
- byRole.set(role, list);
182
- }
183
- const out: Array<{ role: string; keys: string[]; reason: string }> = [];
184
- for (const [role, keys] of [...byRole].sort((a, b) => a[0].localeCompare(b[0]))) {
185
- if (keys.length < 2) continue;
186
- out.push({
187
- role,
188
- keys: [...keys].sort(),
189
- reason: `${keys.length} process keys end in "/${role}" (${[...keys].sort().join(', ')}) — one deployable unit under two names, or two units?`,
190
- });
191
- }
192
- return out;
193
- }
194
-
195
51
  /**
196
52
  * Runtime components that state no `process` at all.
197
53
  *
@@ -204,87 +60,158 @@ export function findUnassigned(members: readonly C4MemberView[]): C4MemberView[]
204
60
  }
205
61
 
206
62
  /**
207
- * Fold a decided merge into an element set.
63
+ * How far a process boundary has travelled toward being a container.
208
64
  *
209
- * Two process keys that are really one container become ONE element holding
210
- * both sets of members. The survivor keeps the id, label, and confirmed
211
- * attributes; the other disappears and its members move across.
65
+ * A boundary starts `unassigned` — no container claims it. Proposing an
66
+ * element for it (see `proposeElements`) assigns the boundary to a container,
67
+ * which reads as `proposed` until a human decides. `accepted` verifies the
68
+ * boundary: someone approved this run of code as one deployable unit.
69
+ * `rejected` means the proposal was wrong and the boundary should not be
70
+ * drawn as a box.
71
+ */
72
+ export type C4ProcessVerification =
73
+ | 'unassigned'
74
+ | 'proposed'
75
+ | 'rejected'
76
+ | 'verified';
77
+
78
+ /** One process boundary's assignment and its review state. */
79
+ export interface C4ProcessBoundaryStatus {
80
+ /** The `process` key, e.g. `principal-studio/host`. */
81
+ key: string;
82
+ /** Aliases carrying this key in the model, when the caller supplied them. */
83
+ memberAliases: string[];
84
+ status: C4ProcessVerification;
85
+ /** The container whose state decided the status. Absent when `unassigned`. */
86
+ element?: C4Element;
87
+ }
88
+
89
+ /**
90
+ * Read a process boundary's verification off its container assignment.
212
91
  *
213
- * Which side survives is the **evidence-heavy** one — the element holding more
214
- * members — because folding the 113-member host into the 1-member one would
215
- * leave the box labelled after an almost-empty process and lose its confirmed
216
- * technology. Pass `preferId` to override.
92
+ * The claimant is the container whose `process` equals the boundary's key —
93
+ * a plain string match, no indirection. Two keys that are one deployable unit
94
+ * under two names are fixed in the model document (a consolidation proposal
95
+ * rewrites the components' `process` fields), so the reader never sees a
96
+ * discrepancy to resolve. When several containers claim one key (a
97
+ * hand-edited set can do this), the decided one wins: accepted over rejected
98
+ * over proposed, so one reviewer's yes outranks another's no.
217
99
  *
218
- * Returns a new set; the input is untouched. Idempotent.
100
+ * Pure: the same rollup and elements always read the same. `rollup` accepts
101
+ * `SubsystemProcessRegion`s directly — only `key` and `memberAliases` are read.
219
102
  */
220
- export function mergeElements(
221
- elements: readonly C4Element[],
222
- derivedFromKeys: readonly string[],
223
- preferId?: string,
224
- memberCountByKey?: ReadonlyMap<string, number>,
225
- ): C4Element[] {
226
- const wanted = new Set(derivedFromKeys.map((k) => k.trim()).filter(Boolean));
227
- if (wanted.size < 2) return [...elements];
228
-
229
- const involved = elements.filter((e) => e.derivedFrom?.some((k) => wanted.has(k)));
230
- if (involved.length < 2) return [...elements];
231
- // Only containers merge. A component has a required `container` field, and
232
- // folding one into another would silently repoint it at a new parent.
233
- if (involved.some((e) => e.kind !== 'container')) return [...elements];
103
+ export function verifyProcessBoundaries(options: {
104
+ rollup: ReadonlyArray<{ key: string; memberAliases?: readonly string[] }>;
105
+ elements: readonly C4Element[];
106
+ }): C4ProcessBoundaryStatus[] {
107
+ const byKey = new Map<string, C4Element[]>();
108
+ for (const e of options.elements) {
109
+ if (e.kind !== 'container' || !e.process) continue;
110
+ const list = byKey.get(e.process) ?? [];
111
+ list.push(e);
112
+ byKey.set(e.process, list);
113
+ }
234
114
 
235
- const weight = (e: C4Element): number => {
236
- if (memberCountByKey) {
237
- let n = 0;
238
- for (const k of e.derivedFrom ?? []) n += memberCountByKey.get(k) ?? 0;
239
- return n;
115
+ const precedence = { accepted: 0, rejected: 1, proposed: 2 } as const;
116
+ return options.rollup.map(({ key, memberAliases }) => {
117
+ const aliases = [...(memberAliases ?? [])];
118
+ const claimants = byKey.get(key) ?? [];
119
+ if (claimants.length === 0) {
120
+ return { key, memberAliases: aliases, status: 'unassigned' as const };
240
121
  }
241
- return e.members.length;
242
- };
243
-
244
- const survivor =
245
- (preferId ? involved.find((e) => e.id === preferId) : undefined) ??
246
- [...involved].sort((a, b) => weight(b) - weight(a) || a.id.localeCompare(b.id))[0]!;
247
-
248
- const absorbed = involved.filter((e) => e.id !== survivor.id);
249
- const absorbedIds = new Set(absorbed.map((e) => e.id));
250
-
251
- const merged: C4Container = {
252
- ...(survivor as C4Container),
253
- members: [...new Set([...survivor.members, ...absorbed.flatMap((e) => e.members)])],
254
- derivedFrom: [
255
- ...new Set([...(survivor.derivedFrom ?? []), ...absorbed.flatMap((e) => e.derivedFrom ?? [])]),
256
- ],
257
- rationale:
258
- [survivor.rationale, ...absorbed.map((e) => e.rationale)].filter(Boolean).join(' | ') ||
259
- undefined,
260
- };
122
+ const decided = [...claimants].sort(
123
+ (a, b) => precedence[a.state] - precedence[b.state] || a.id.localeCompare(b.id),
124
+ )[0]!;
125
+ const status = decided.state === 'accepted' ? ('verified' as const) : decided.state;
126
+ return { key, memberAliases: aliases, status, element: decided };
127
+ });
128
+ }
261
129
 
262
- return [...elements.filter((e) => e.id !== survivor.id && !absorbedIds.has(e.id)), merged].sort(
263
- (a, b) => a.id.localeCompare(b.id),
264
- );
130
+ /**
131
+ * Verification statuses as diagnostics findings — the dynamic-topology layer
132
+ * of the issues list. One finding per boundary that is NOT verified: an
133
+ * unclaimed boundary is the absence a proposing agent run resolves, a
134
+ * `proposed` one awaits the human's decision, a `rejected` one records that
135
+ * the decision was no. `verified` reports nothing — the audit reports
136
+ * absence only, and silence is the good state.
137
+ *
138
+ * The findings ride the existing pipeline untouched: they badge the process
139
+ * frame (target kind `process`), list under Dynamic topology, focus their
140
+ * members on click, and carry the fix the decision needs. Pure over its
141
+ * input, like the rest of this module.
142
+ */
143
+ export function verificationIssues(
144
+ statuses: readonly C4ProcessBoundaryStatus[],
145
+ ): SubsystemIssue[] {
146
+ const out: SubsystemIssue[] = [];
147
+ for (const { key, status, element } of statuses) {
148
+ if (status === 'verified') continue;
149
+ const label = element?.label ?? key;
150
+ out.push({
151
+ id: `process-verification:${key}`,
152
+ severity: 'info',
153
+ kind: `boundary_process_${status}`,
154
+ message:
155
+ status === 'unassigned'
156
+ ? `No C4 container claims this process boundary yet — a proposing agent run would scaffold one.`
157
+ : status === 'proposed'
158
+ ? `Container "${label}" claims this boundary and awaits a decision.`
159
+ : `Container "${label}" was rejected for this boundary — it stays unclaimed until a container is accepted.`,
160
+ target: { kind: 'process', id: key, label: key },
161
+ fix:
162
+ status === 'unassigned'
163
+ ? { label: 'Propose container' }
164
+ : status === 'proposed'
165
+ ? { label: 'Accept container' }
166
+ : undefined,
167
+ });
168
+ }
169
+ return out;
265
170
  }
266
171
 
267
172
  /**
268
- * A starting point for an agent: one proposed element per derived key, shaped
269
- * by the mechanical evidence, with the open questions in the rationale.
173
+ * A starting point for an agent, shaped by the two questions that matter when
174
+ * assessing a process boundary:
270
175
  *
271
- * Every element comes back `proposed`. This is a scaffold to edit and argue
272
- * with — not an answer, and never `accepted`.
176
+ * 1. **Does a container already exist for it?** (`elements`) — a key one
177
+ * already claims is never re-proposed; proposing another would be
178
+ * exactly the duplicate this module exists to prevent. The exact claim
179
+ * is a fact and skipped here; whether a *similarly named* container is
180
+ * the same unit is a judgement, so it belongs to the agent querying the
181
+ * container list — the fix there is a consolidation proposal, not a
182
+ * second box.
183
+ * 2. **Can the boundary be discerned into a container?** — code that never
184
+ * runs cannot be, so a library-shaped group gets no proposal; that
185
+ * fact is the agent's to report, not the scaffold's.
186
+ *
187
+ * For boundaries passing both, one `proposed` container per key. The
188
+ * rationale is the proposing agent's own reasoning, written on the proposal
189
+ * it POSTs — this scaffold does not fabricate one. A scaffold to edit and
190
+ * argue with — not an answer, and never `accepted`.
273
191
  */
274
- export function proposeElements(
275
- options: {
276
- repoKey: string;
277
- rollup: ReadonlyArray<{ key: string; members: readonly C4MemberView[]; frameworks: readonly string[] }>;
278
- author?: string;
279
- },
280
- ): C4ElementSet {
281
- const elements = options.rollup.map(({ key, members, frameworks }) => {
282
- const concerns = deriveConcerns(key, members, { frameworks });
283
- return {
192
+ export function proposeElements(options: {
193
+ repoKey: string;
194
+ rollup: ReadonlyArray<{
195
+ key: string;
196
+ members: readonly C4MemberView[];
197
+ frameworks: readonly string[];
198
+ }>;
199
+ /** Containers that already exist (from the element store), by `process` claim. */
200
+ elements?: readonly C4Element[];
201
+ }): C4ElementSet {
202
+ const existing = options.elements ?? [];
203
+ const claimed = new Set(existing.flatMap((e) => (e.kind === 'container' && e.process ? [e.process] : [])));
204
+
205
+ const elements: C4Container[] = [];
206
+ for (const { key, members, frameworks } of options.rollup) {
207
+ if (claimed.has(key)) continue; // already covered — exact claim, a fact
208
+ const runtime = members.filter((m) => isRuntimeConstruct(m.construct));
209
+ if (members.length > 0 && runtime.length === 0) continue; // not discernible: code that never runs
210
+
211
+ elements.push({
284
212
  id: `container:${key}`,
285
- kind: 'container' as const,
286
- members: members.map((m) => m.alias),
287
- derivedFrom: [key],
213
+ kind: 'container',
214
+ process: key,
288
215
  label: key,
289
216
  containerKind: suggestContainerKind(members),
290
217
  // C4 requires a technology on every container. With no framework signal
@@ -292,11 +219,9 @@ export function proposeElements(
292
219
  // inventing "TypeScript" here would be exactly the derivation this
293
220
  // module stopped doing.
294
221
  technology: frameworks.length > 0 ? frameworks.join(' + ') : '',
295
- state: 'proposed' as const,
296
- rationale: concerns.map((c) => c.message).join(' ') || undefined,
297
- author: options.author,
298
- };
299
- });
222
+ state: 'proposed',
223
+ });
224
+ }
300
225
 
301
226
  return {
302
227
  repoKey: options.repoKey,
@@ -1429,6 +1429,12 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
1429
1429
  /** True while another edge is selected — render this edge (and its label)
1430
1430
  * dimmed to focus the selected relationship. */
1431
1431
  dimmed?: boolean;
1432
+ /**
1433
+ * A backing trail step declares itself `proposed` — a planned seam between
1434
+ * otherwise-real components. The SVG edge and its label draw dashed;
1435
+ * distinct from a proposed ENDPOINT, which dashes the node instead.
1436
+ */
1437
+ proposedStep?: boolean;
1432
1438
  /** ELK-computed label midpoint (from the actual edge path, not node centers). */
1433
1439
  labelX?: number;
1434
1440
  labelY?: number;
@@ -113,4 +113,19 @@ describe('SubsystemGroupNode boundary badge', () => {
113
113
  });
114
114
  expect(container.textContent).toContain('3');
115
115
  });
116
+
117
+ test('a process verification finding badges the process frame too', () => {
118
+ // Dynamic-topology findings target kind `process`; the frame badge is the
119
+ // same mechanism as a module finding — the boundary's state is the fault.
120
+ const { container } = renderGroup({
121
+ region,
122
+ issue: {
123
+ severity: 'info',
124
+ kind: 'boundary_process_unassigned',
125
+ count: 1,
126
+ },
127
+ });
128
+ // The kind has a dedicated frame icon, so the lucide icon carries the class.
129
+ expect(container.querySelector('.lucide-circle-ellipsis')).not.toBeNull();
130
+ });
116
131
  });
@@ -146,10 +146,10 @@ function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
146
146
 
147
147
  /**
148
148
  * The shared severity chip: a severity-colored ring around the finding's icon,
149
- * with a count when there is more than one. Component nodes anchor it to the
150
- * bottom-right corner; a region frame anchors it to the top-right so it never
151
- * collides with the frame's own name badge. `aria-hidden` and inert — the
152
- * canvas badge never competes with the sidebar card for the interaction.
149
+ * with a count when there is more than one. Component nodes and region frames
150
+ * both anchor it overhanging the bottom-right corner — the issues convention.
151
+ * `aria-hidden` and inert — the canvas badge never competes with the sidebar
152
+ * card for the interaction.
153
153
  */
154
154
  function IssueChip({
155
155
  color,
@@ -709,8 +709,8 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
709
709
  label
710
710
  )}
711
711
  </div>
712
- {/* Boundary diagnostics badge — top-right, opposite the name badge, so the
713
- two never collide however long the module path grows. */}
712
+ {/* Boundary diagnostics badge — overhanging the bottom-right corner, the
713
+ same anchor component chips wear. */}
714
714
  {data.issue && ISSUE_KIND_ICON[data.issue.kind] && (
715
715
  <IssueChip
716
716
  color={
@@ -720,7 +720,7 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
720
720
  }
721
721
  Icon={ISSUE_KIND_ICON[data.issue.kind]!}
722
722
  count={data.issue.count}
723
- anchor={{ right: 12, top: 0, transform: 'translateY(-50%)' }}
723
+ anchor={{ right: -9, bottom: -8 }}
724
724
  />
725
725
  )}
726
726
  </div>
@@ -833,8 +833,11 @@ export function SubsystemEdge({
833
833
  const edgeRef = { mechanism, provenance: data?.provenance };
834
834
  const color = edgeColor(edgeRef);
835
835
  // Dash style encodes provenance (graphify) and relationship kind (dashed =
836
- // inverted-control or observational: hierarchy, registration, watches).
837
- const isDashed = edgeStrokeStyle(edgeRef) === 'dashed';
836
+ // inverted-control or observational: hierarchy, registration, watches). A
837
+ // step-proposed edge also dashes — the seam itself is planned — matching
838
+ // the dashed edge label and a proposed node's dashed border.
839
+ const isDashed =
840
+ edgeStrokeStyle(edgeRef) === 'dashed' || data?.proposedStep === true;
838
841
  const dimmed = data?.dimmed === true;
839
842
  // Dim the stroke by color, never via path `opacity`. SVG markers are shared
840
843
  // by id; opacity on the referencing path paints every arrowhead that uses
@@ -24,6 +24,11 @@ export interface TechBrand {
24
24
  svg: string;
25
25
  /** Matched against the technology string. First match in `TECH_BRANDS` wins. */
26
26
  match: RegExp;
27
+ /**
28
+ * Representative brand hue, for the card border. The dominant colour of the
29
+ * mark, chosen to stay legible as a thin line.
30
+ */
31
+ color: string;
27
32
  }
28
33
 
29
34
  /**
@@ -36,12 +41,14 @@ export const TECH_BRANDS: readonly TechBrand[] = [
36
41
  source: 'https://react.dev',
37
42
  svg: reactSvg,
38
43
  match: /\breact\b/i,
44
+ color: '#61dafb',
39
45
  },
40
46
  {
41
47
  name: 'Bun',
42
48
  source: 'https://bun.sh/logo.svg',
43
49
  svg: bunSvg,
44
50
  match: /\bbun\b/i,
51
+ color: '#fbf0df',
45
52
  },
46
53
  {
47
54
  name: 'Node.js',
@@ -50,6 +57,7 @@ export const TECH_BRANDS: readonly TechBrand[] = [
50
57
  // Matches "Node" and "Node.js"; "Electrobun" has no word boundary before
51
58
  // "bun" and never reaches here.
52
59
  match: /\bnode(\.js)?\b/i,
60
+ color: '#5fa04e',
53
61
  },
54
62
  ];
55
63
 
@@ -11,13 +11,32 @@ import {
11
11
  EDGE_LABEL_WIDTH,
12
12
  EDGE_LABEL_SIDE_PADDING,
13
13
  EDGE_LABEL_EDGE_GAP,
14
+ estimateEdgeLabelWidth,
14
15
  } from './edgeLabel';
15
16
 
17
+ describe('estimateEdgeLabelWidth', () => {
18
+ test('grows with the text', () => {
19
+ expect(estimateEdgeLabelWidth('file I/O')).toBeGreaterThan(estimateEdgeLabelWidth('RPC'));
20
+ });
21
+
22
+ test('clamps a very short label to a readable minimum', () => {
23
+ expect(estimateEdgeLabelWidth('')).toBe(48);
24
+ expect(estimateEdgeLabelWidth('a')).toBe(48);
25
+ });
26
+
27
+ test('fits the longest C4 protocol we render', () => {
28
+ // A rough sanity ceiling: 'file I/O' plus chip padding must sit under the
29
+ // shared fixed width, or C4 would be wider than the box it replaced.
30
+ expect(estimateEdgeLabelWidth('file I/O')).toBeLessThan(EDGE_LABEL_WIDTH);
31
+ });
32
+ });
33
+
16
34
  describe('edge-label geometry', () => {
17
- test('the labelled-edge gap is the box plus clearance on both sides', () => {
35
+ test('the reserved run is the chip plus clearance on both sides', () => {
36
+ // The reserved label box carries the chip width plus the side padding,
37
+ // because ELK's CENTER_LAYER adds no clearance of its own.
18
38
  expect(EDGE_LABEL_EDGE_GAP).toBe(
19
39
  EDGE_LABEL_WIDTH + EDGE_LABEL_SIDE_PADDING * 2,
20
40
  );
21
- expect(EDGE_LABEL_EDGE_GAP).toBeGreaterThan(EDGE_LABEL_WIDTH);
22
41
  });
23
42
  });
@@ -19,6 +19,34 @@ export const EDGE_LABEL_HEIGHT = 40;
19
19
  /** Label font size. Fixed so every label renders at the same text size. */
20
20
  export const EDGE_LABEL_FONT_SIZE = 14;
21
21
 
22
+ /**
23
+ * Approx monospace advance at `EDGE_LABEL_FONT_SIZE` (14px), incl. the tiny
24
+ * letter-spacing. Fira Code / SF Mono / Courier sit at ~0.6em (8.4px); rounded
25
+ * up so a width estimate never undershoots and clips the label.
26
+ */
27
+ const LABEL_CHAR_WIDTH = 8.5;
28
+
29
+ /** Chip chrome: horizontal padding is 8+8 in the overlay (see C4EdgeLabels). */
30
+ const LABEL_CHIP_CHROME = 16;
31
+
32
+ /**
33
+ * Estimated rendered width of an edge label's chip, from its text. Both the
34
+ * overlay and the ELK reservation must use this same number, or the reserved
35
+ * run drifts from what renders. Clamped to a minimum so a one-word protocol
36
+ * still reads as a chip.
37
+ */
38
+ export function estimateEdgeLabelWidth(text: string): number {
39
+ return Math.max(48, (text?.length ?? 0) * LABEL_CHAR_WIDTH + LABEL_CHIP_CHROME);
40
+ }
41
+
42
+ /**
43
+ * Reserved label width for C4's own edges, used as the fallback/max when a
44
+ * caller doesn't size per-label. C4's lines carry short protocols (`RPC`,
45
+ * `HTTP`, `file I/O`), so the shared 140 (sized for the component graph's
46
+ * `registers-into`) is wider than needed and inflates the run between boxes.
47
+ */
48
+ export const C4_LABEL_WIDTH = 90;
49
+
22
50
  /**
23
51
  * Clear space we want on each side of a label box.
24
52
  *