@principal-ai/subsystems-react 0.51.4 → 0.53.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 (69) 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 +193 -47
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +33 -27
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +89 -43
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/IssueList.d.ts +1 -1
  14. package/dist/subsystem/IssueList.d.ts.map +1 -1
  15. package/dist/subsystem/IssueList.js +20 -1
  16. package/dist/subsystem/IssueList.js.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  18. package/dist/subsystem/SubsystemComponentGraph.js +42 -19
  19. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  20. package/dist/subsystem/c4.d.ts +48 -49
  21. package/dist/subsystem/c4.d.ts.map +1 -1
  22. package/dist/subsystem/c4.js +50 -0
  23. package/dist/subsystem/c4.js.map +1 -1
  24. package/dist/subsystem/c4Evidence.d.ts +88 -84
  25. package/dist/subsystem/c4Evidence.d.ts.map +1 -1
  26. package/dist/subsystem/c4Evidence.js +113 -166
  27. package/dist/subsystem/c4Evidence.js.map +1 -1
  28. package/dist/subsystem/model.d.ts +35 -245
  29. package/dist/subsystem/model.d.ts.map +1 -1
  30. package/dist/subsystem/model.js +11 -29
  31. package/dist/subsystem/model.js.map +1 -1
  32. package/dist/subsystem/nodes.js +5 -5
  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 +15 -1
  39. package/dist/utils/edgeLabel.d.ts.map +1 -1
  40. package/dist/utils/edgeLabel.js +25 -1
  41. package/dist/utils/edgeLabel.js.map +1 -1
  42. package/dist/utils/elkLayout.d.ts +47 -0
  43. package/dist/utils/elkLayout.d.ts.map +1 -1
  44. package/dist/utils/elkLayout.js +71 -7
  45. package/dist/utils/elkLayout.js.map +1 -1
  46. package/package.json +3 -3
  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/subsystem/C4Graph.tsx +278 -82
  53. package/src/subsystem/C4NodeCard.test.ts +32 -22
  54. package/src/subsystem/C4NodeCard.tsx +134 -69
  55. package/src/subsystem/IssueList.tsx +23 -0
  56. package/src/subsystem/SubsystemComponentGraph.tsx +38 -16
  57. package/src/subsystem/c4.protocolColor.test.ts +26 -0
  58. package/src/subsystem/c4.ts +76 -52
  59. package/src/subsystem/c4Evidence.test.ts +177 -237
  60. package/src/subsystem/c4Evidence.ts +160 -235
  61. package/src/subsystem/model.ts +62 -305
  62. package/src/subsystem/nodes.group.test.tsx +15 -0
  63. package/src/subsystem/nodes.tsx +7 -7
  64. package/src/subsystem/techIcons/index.tsx +8 -0
  65. package/src/utils/edgeLabel.test.ts +21 -2
  66. package/src/utils/edgeLabel.ts +29 -1
  67. package/src/utils/elkLayout.test.ts +16 -0
  68. package/src/utils/elkLayout.ts +119 -7
  69. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
@@ -8,8 +8,9 @@
8
8
  * subgraphs, only cross-package edges leave the box, and shared seams
9
9
  * (registries/barrels/facades) are edge targets, never member nodes.
10
10
  *
11
- * Self-contained in this package (not yet promoted to `@principal-ai/core`), so
12
- * we can iterate on the UI + story without publishing a dependency.
11
+ * Document types come from `@principal-ai/subsystems-core` (the portable
12
+ * standard's home); react layers display concerns (edges, regions, layout)
13
+ * on top.
13
14
  */
14
15
 
15
16
  import {
@@ -22,9 +23,7 @@ import { EDGE_LABEL_SIDE_PADDING, EDGE_ARROW_INSET } from '../utils/edgeLabel';
22
23
  import type { GraphifyComponentDetail } from '../graphify';
23
24
  import type {
24
25
  SubsystemConstructDeclaration,
25
- SubsystemComponentRemoval,
26
26
  } from '@principal-ai/subsystems-core';
27
- import type { SubsystemDeclarationRef } from './declarationRef';
28
27
  import { purlOwnerName, purlRepoKey } from './paths';
29
28
 
30
29
  /**
@@ -55,7 +54,6 @@ export type { SubsystemConstructDeclaration } from '@principal-ai/subsystems-cor
55
54
  export type AnyComponentDetail =
56
55
  | SubsystemConstructDeclaration
57
56
  | GraphifyComponentDetail;
58
- export type SubsystemDeclarationProvenance = 'verified' | 'authored';
59
57
 
60
58
  /** One declared parameter in an agent-extracted (augmented) signature. */
61
59
  export interface SubsystemSignatureParameter {
@@ -78,84 +76,60 @@ export interface SubsystemSignatureClaim {
78
76
  returnType?: string;
79
77
  }
80
78
 
81
- export type SubsystemComponentConstruct =
82
- | 'class'
83
- | 'function'
84
- | 'method'
85
- | 'interface'
86
- | 'type_alias'
87
- | 'enum'
88
- | 'store'
89
- | 'external'
90
- | 'custom_entity';
91
-
92
- /**
93
- * Semantic role — where the node sits in the topology, orthogonal to
94
- * `construct` (what it is). Roles have *inherited* anatomy: an entry renders
95
- * as its real code shape (function dispatcher, type contract); a service has
96
- * no source at all (`construct: 'external'` + purl identity). Contrast with
97
- * `construct: 'store'`, which introduces its own anatomy (the state block) —
98
- * that is why store is a construct and not a role. Role nodes must stay anchored to real code: an
99
- * `entry` is a boundary element (route dispatcher, message contract) carrying
100
- * the wire address as identity; a `service` is an external system the process
101
- * calls out to (identity via purl, no `process` — it belongs to no region).
102
- */
103
- export type SubsystemComponentRole = 'entry' | 'service';
104
-
105
79
  /**
106
- * Framework that owns a stereotype vocabulary (open string).
107
- * Examples: `react`, `vue`, `nestjs`, `django`, `spring`.
108
- * Empty when the node is language-only / framework-agnostic.
80
+ * Document vocabulary — imported from `@principal-ai/subsystems-core`, the
81
+ * portable standard's home. This package used to define a parallel copy
82
+ * ("self-contained until promoted"); core owns the types now, so react
83
+ * re-exports them by construction instead of hand-mirroring.
109
84
  */
110
- export type SubsystemFramework = string;
85
+ import type {
86
+ SubsystemModelDocument,
87
+ SubsystemTrail,
88
+ SubsystemTrailStep,
89
+ SubsystemComponentRole,
90
+ SubsystemTrailMechanism,
91
+ SubsystemEdgeMechanism,
92
+ SubsystemConstruct as SubsystemComponentConstruct,
93
+ SubsystemComponent as CoreSubsystemComponent,
94
+ } from '@principal-ai/subsystems-core';
95
+ import {
96
+ getSubsystemProcessRegions as coreGetSubsystemProcessRegions,
97
+ getSubsystemModuleRegions as coreGetSubsystemModuleRegions,
98
+ } from '@principal-ai/subsystems-core';
111
99
 
100
+ export type {
101
+ SubsystemModelDocument,
102
+ SubsystemTrail,
103
+ SubsystemTrailStep,
104
+ SubsystemComponentRole,
105
+ SubsystemFramework,
106
+ SubsystemStereotype,
107
+ SubsystemEntityKind,
108
+ SubsystemTrailMechanism,
109
+ SubsystemEdgeMechanism,
110
+ SubsystemDeclToken,
111
+ SubsystemDeclTokenKind,
112
+ SubsystemDeclarationProvenance,
113
+ } from '@principal-ai/subsystems-core';
114
+ export type { SubsystemComponentConstruct };
112
115
  /**
113
- * Framework-level pattern stamped on a language construct (open string).
114
- * Examples: `component`, `hook`, `middleware`, `controller`, `guard`.
115
- * Empty when no framework pattern applies. Pair with `framework` when set —
116
- * a React component stays `construct: 'function'` with
117
- * `framework: 'react'` + `stereotype: 'component'`.
116
+ * A component node — the named unit, construct-tagged; `file` is its location.
117
+ * Core's portable shape plus the one field a model document never carries:
118
+ * `signatureAugmentation` is host-enrichment (populated when serving an
119
+ * enriched graph), never persisted.
118
120
  */
119
- export type SubsystemStereotype = string;
120
-
121
- // ---------------------------------------------------------------------------
122
- // Declaration tokens — structured source representation
123
- // ---------------------------------------------------------------------------
124
-
125
- export type SubsystemDeclTokenKind =
126
- | 'keyword'
127
- | 'name'
128
- | 'member'
129
- | 'type'
130
- | 'punctuation'
131
- | 'string'
132
- | 'newline';
133
-
134
- export interface SubsystemDeclToken {
135
- text: string;
136
- kind: SubsystemDeclTokenKind;
137
- /** Shiki/Pierre foreground when tokenized client-side; omitted on legacy wire tokens. */
138
- color?: string;
121
+ export interface SubsystemComponent extends CoreSubsystemComponent {
122
+ /**
123
+ * Accepted agent-extracted signature (from the graphify augmentation store)
124
+ * confirming this function/method when Graphify has no usable signature
125
+ * edges. Populated by the host when serving an enriched graph — never
126
+ * persisted in the model document. Rendered as the declaration when the
127
+ * component has no own `declaration`; no marker distinguishes the source.
128
+ */
129
+ signatureAugmentation?: SubsystemSignatureClaim;
139
130
  }
140
131
 
141
- /**
142
- * Trail step mechanism — runtime seams with a `file:line` site.
143
- */
144
- export type SubsystemTrailMechanism =
145
- | 'calls'
146
- | 'uses'
147
- | 'feeds'
148
- | 'produces'
149
- | 'writes'
150
- | 'reads'
151
- | 'watches'
152
- | 'registers-into';
153
-
154
- /** Union for derived graph-edge styling — the trail step mechanism. */
155
- export type SubsystemEdgeMechanism = SubsystemTrailMechanism;
156
-
157
- /**
158
- * Which edge source the canvas draws. The two sources are disjoint; a view
132
+ /** Which edge source the canvas draws. The two sources are disjoint; a view
159
133
  * shows only edges (and their labels) from the selected source.
160
134
  * - `graphify`: only graphify-native static edges (`imports`, `contains`, …)
161
135
  * - `trails`: only trail step edges (`calls`, `feeds`, …)
@@ -203,155 +177,10 @@ export interface SubsystemGraphifyRelation {
203
177
  context?: string;
204
178
  }
205
179
 
206
- /** A component node — the named unit, construct-tagged; `file` is its location. */
207
- export interface SubsystemComponent {
208
- /**
209
- * Model-local stable alias, unique per model. Referenced by trail
210
- * `from` / `to`; edges point at the alias, not the location.
211
- * Code identity (for composed multi-model views) lives on
212
- * `purl` + `file` + `symbol`, not here.
213
- */
214
- alias: string;
215
- name: string;
216
- /**
217
- * The node's construct — what it IS as a declaration (class, function,
218
- * method, interface, type alias, enum, store, external), driving node
219
- * anatomy, color, badge, and the verification strategy. Every construct
220
- * anchors to a definition; runtime occurrences (variables, activations,
221
- * instances) are NOT constructs — they belong to a future execution-mode
222
- * graph whose occurrence nodes reference these definitions. Ontology:
223
- * construct = what it is, framework + stereotype = which framework pattern
224
- * it plays, role = where it sits, process = where it runs. Prefer
225
- * `framework` + `stereotype` over inventing framework-specific constructs
226
- * (a React component is still `construct: 'function'`).
227
- */
228
- construct: SubsystemComponentConstruct;
229
- /** Source location the component lives in (repo-root-relative path). */
230
- file: string;
231
- /** PURL identifying the repo or package this component lives in (for subgraph grouping). */
232
- purl: string;
233
- /**
234
- * App/repo logo shown on the declaration card in place of the GitHub owner
235
- * avatar (any image URL or data URI). Optional; typically one image stamped
236
- * across the components of a repo.
237
- */
238
- logo?: string;
239
- /** One-line purpose shown on the node. */
240
- purpose?: string;
241
- /**
242
- * Design / migration placeholder — participates in edges and flows but is
243
- * not a live source declaration yet. Verification skips source checks until
244
- * promoted (`proposed` cleared, `file` + `symbol` filled). Orthogonal to
245
- * `construct` (intended shape) and `role` (topology).
246
- */
247
- proposed?: boolean;
248
- /**
249
- * The claim was real but its source is gone upstream — deleted or renamed.
250
- * The inverse of `proposed`; verification skips source checks the same way.
251
- * Renders with a slate border and a `deprecated` badge that opens the removal
252
- * provenance. Transitional — a cleanup pass removes the node.
253
- */
254
- deprecated?: boolean;
255
- /** Which commit removed this claim's source, and why. Present when deprecated. */
256
- removedIn?: SubsystemComponentRemoval;
257
- /**
258
- * Semantic role — where the node sits in the topology (boundary element,
259
- * external system), orthogonal to `construct` (what it is). Drives the
260
- * glyph and the edge-pairing rules: boundary-crossing edges must terminate
261
- * at an entry or a service. Retained state is NOT a role — it is
262
- * `construct: 'store'` (state-block anatomy, `writes`/`reads`/`watches`
263
- * inbound, `produces` outbound).
264
- */
265
- role?: SubsystemComponentRole;
266
- /**
267
- * Framework that owns the stereotype vocabulary (e.g. `react`, `nestjs`).
268
- * Orthogonal to `construct` — leave empty for language-only units.
269
- */
270
- framework?: SubsystemFramework;
271
- /**
272
- * Framework pattern this declaration plays (e.g. `component`, `hook`).
273
- * When set, the node badge prefers this label over the construct name so
274
- * a React UI unit reads as "component" rather than "function".
275
- */
276
- stereotype?: SubsystemStereotype;
277
- /**
278
- * Open-string entity kind for `construct: 'custom_entity'` nodes — what the
279
- * actor/entity is (e.g. `Person`, `agent`, `queue`). Not code; there is no
280
- * graphify anchor. Contrast with `construct` (what the declaration is) and
281
- * `role` (where it sits topologically). Empty/unset for code constructs.
282
- */
283
- entityKind?: string;
284
- /**
285
- * Authored color override for the node border + badges, when the themed
286
- * construct color isn't wanted. Primarily for `construct: 'custom_entity'`
287
- * (an author picks a fitting hue for a Person/agent/queue) — but usable on
288
- * any construct. Any `#rrggbb` string; when set it wins over the
289
- * construct-derived color, mirrors in the declaration panel. Leave unset to
290
- * inherit the Pierre construct palette.
291
- */
292
- color?: string;
293
- /**
294
- * Runtime process membership — which deployment unit this node is a
295
- * member of (e.g. `principal-studio/host`, `principal-studio/renderer`). Nodes
296
- * sharing a `process` are drawn inside one boundary region; nodes without
297
- * one sit outside every process boundary (external actors, services,
298
- * libraries). Orthogonal to `module` (source-file frame).
299
- */
300
- process?: string;
301
- /**
302
- * Source-module membership — which file/module this export belongs to
303
- * (e.g. `src/session/transcript.ts`). Nodes sharing a `module` are drawn
304
- * inside one boundary frame. Prefer this over inventing a module construct:
305
- * each export keeps its real construct (`function` / `class` / …) and the
306
- * file reads as a frame. Orthogonal to `process` (runtime deployment).
307
- * When both are set, the module frame is the component's parent (finer
308
- * grain); process framing still groups siblings that share a process.
309
- */
310
- module?: string;
311
- /** A symbol this component exposes / is (the node's identity). */
312
- symbol?: string;
313
- /**
314
- * Semantic layer/phase for the layout (e.g. `1` = input, `2` = processing,
315
- * `3` = output). When set, ELK places the component in this layer so the
316
- * graph reads as a left-to-right pipeline and *conveys the idea* rather than
317
- * letting ELK guess the order.
318
- */
319
- layer?: number;
320
- /**
321
- * Structured declaration shape of the construct (params, members, type
322
- * buckets, …). Single source of truth for the click panel — authored by
323
- * agents/CLI or filled by verified graphify capture. Discriminated by kind.
324
- */
325
- declaration?: SubsystemConstructDeclaration;
326
- /**
327
- * @deprecated No longer used — verification now tracks at the model level
328
- * via `verifiedAtCommits`. Keeping the field temporarily for migration.
329
- */
330
- declarationProvenance?: SubsystemDeclarationProvenance;
331
- /**
332
- * Pre-tokenized declaration for the detail panel. When present, the
333
- * renderer skips client-side tokenization. Tokens are language-agnostic;
334
- * a different language just needs a different tokenizer and text joiner.
335
- */
336
- tokens?: SubsystemDeclToken[];
337
- /**
338
- * Accepted agent-extracted signature (from the graphify augmentation store)
339
- * confirming this function/method when Graphify has no usable signature
340
- * edges. Populated by the host when serving an enriched graph — never
341
- * persisted in the model document. Rendered as the declaration when the
342
- * component has no own `declaration`; no marker distinguishes the source.
343
- */
344
- signatureAugmentation?: SubsystemSignatureClaim;
345
- /**
346
- * Anchored declaration location: graphify start line + hash of that line's
347
- * content at capture time. Populated by verify when an exact anchor resolves.
348
- */
349
- declarationRef?: SubsystemDeclarationRef;
350
- }
351
-
352
180
  /**
353
181
  * Derived / display graph edge used by renderers. Built from trail steps
354
182
  * (and graphify-native relations) — not authored as its own document field.
183
+ * Richer than core's minimal edge: this is the canvas's drawing shape.
355
184
  */
356
185
  export interface SubsystemComponentEdge {
357
186
  id: string;
@@ -359,7 +188,7 @@ export interface SubsystemComponentEdge {
359
188
  to: string; // component alias or external target label
360
189
  /**
361
190
  * The edge verb. For `provenance: 'subsystem'` (the default) this is a
362
- * `SubsystemEdgeMechanism`; for `provenance: 'graphify'` it is the raw
191
+ * `SubsystemTrailMechanism`; for `provenance: 'graphify'` it is the raw
363
192
  * graphify relation. Typed as `string` because the display edge is a derived
364
193
  * structure and graphify's verb set is open — the trail vocabulary
365
194
  * (`SubsystemTrailMechanism`) stays closed.
@@ -381,60 +210,6 @@ export interface SubsystemComponentEdge {
381
210
  context?: string;
382
211
  }
383
212
 
384
- /**
385
- * A single runtime step — `from`/`to`/`mechanism` plus the exact `file:line`
386
- * where that seam fires for a trail.
387
- */
388
- export interface SubsystemTrailStep {
389
- from: string;
390
- to: string;
391
- mechanism: SubsystemTrailMechanism;
392
- /** Repo-root-relative path of the file where the seam fires. */
393
- file: string;
394
- /** 1-based line of the site within `file`. */
395
- line: number;
396
- /**
397
- * File-anchored purl of the seam site (e.g.
398
- * `pkg:github/owner/name#path/to/file.ts`), mirroring component `purl`.
399
- * Required: readers resolve the checkout from this instead of guessing
400
- * the repo from the step's endpoint components.
401
- */
402
- purl: string;
403
- /**
404
- * Frame name for this step — the function/method on the stack at the site.
405
- * Required; the Trails list shows this instead of a bare
406
- * mechanism + filename fallback.
407
- */
408
- symbol: string;
409
- /**
410
- * Planned seam — the step describes intended behavior through code that is
411
- * not written yet. Orthogonal to (and redundant with) the endpoints' own
412
- * `proposed` flags: a step is proposed when it says so OR either endpoint
413
- * is proposed. Verification skips source checks either way, and viewers
414
- * tint it exactly like a proposed node.
415
- */
416
- proposed?: boolean;
417
- /**
418
- * Free-text note anchored to this step's site line. Optional — informative
419
- * only, never verified against source; the Pierre trail code view
420
- * surfaces it in the annotation column next to the highlighted line.
421
- */
422
- annotation?: string;
423
- }
424
-
425
- /** An ordered runtime trail — one named behavior story. */
426
- export interface SubsystemTrail {
427
- id: string;
428
- title: string;
429
- steps: SubsystemTrailStep[];
430
- }
431
-
432
- export interface SubsystemModelDocument {
433
- components: SubsystemComponent[];
434
- /** Ordered runtime trails (one per named behavior). */
435
- trails?: SubsystemTrail[];
436
- }
437
-
438
213
  /** Stable id for a derived graph edge from a trail step. */
439
214
  export function derivedGraphEdgeId(
440
215
  from: string,
@@ -819,47 +594,29 @@ function packageRegionLabel(packageKey: string): string {
819
594
 
820
595
  /**
821
596
  * Derive process boundary regions — one per distinct non-empty `process`
822
- * value, in first-appearance order.
597
+ * value, in first-appearance order. Wraps core's rollup, adding the frame
598
+ * `kind` the canvas reads.
823
599
  */
824
600
  export function getSubsystemRegions(
825
601
  doc: Pick<SubsystemModelDocument, 'components'>,
826
602
  ): SubsystemProcessRegion[] {
827
- const byProcess = new Map<string, string[]>();
828
- for (const c of doc.components) {
829
- const p = c.process?.trim();
830
- if (!p) continue;
831
- const list = byProcess.get(p) ?? [];
832
- list.push(c.alias);
833
- byProcess.set(p, list);
834
- }
835
- return [...byProcess.entries()].map(([key, memberAliases]) => ({
603
+ return coreGetSubsystemProcessRegions(doc).map((r) => ({
604
+ ...r,
836
605
  kind: 'process' as const,
837
- key,
838
- label: key,
839
- memberAliases,
840
606
  }));
841
607
  }
842
608
 
843
609
  /**
844
610
  * Derive module boundary regions — one per distinct non-empty `module`
845
611
  * value, in first-appearance order. Label is the module path (file).
612
+ * Wraps core's rollup, adding the frame `kind` the canvas reads.
846
613
  */
847
614
  export function getSubsystemModuleRegions(
848
615
  doc: Pick<SubsystemModelDocument, 'components'>,
849
616
  ): SubsystemProcessRegion[] {
850
- const byModule = new Map<string, string[]>();
851
- for (const c of doc.components) {
852
- const m = c.module?.trim();
853
- if (!m) continue;
854
- const list = byModule.get(m) ?? [];
855
- list.push(c.alias);
856
- byModule.set(m, list);
857
- }
858
- return [...byModule.entries()].map(([key, memberAliases]) => ({
617
+ return coreGetSubsystemModuleRegions(doc).map((r) => ({
618
+ ...r,
859
619
  kind: 'module' as const,
860
- key,
861
- label: key,
862
- memberAliases,
863
620
  }));
864
621
  }
865
622
 
@@ -1933,7 +1690,7 @@ export function nodeMinWidthForBadges(component: {
1933
1690
  * then refines with compound layout.
1934
1691
  */
1935
1692
  export function convertSubsystemToNodes(
1936
- doc: SubsystemModelDocument,
1693
+ doc: Pick<SubsystemModelDocument, 'components' | 'trails'>,
1937
1694
  opts: { maxNodeWidth?: number } = {},
1938
1695
  ): SubsystemGraphNode[] {
1939
1696
  const { maxNodeWidth } = opts;
@@ -2024,7 +1781,7 @@ export function convertSubsystemToGroups(
2024
1781
  * relationship is visible without a member node.
2025
1782
  */
2026
1783
  export function convertSubsystemToEdges(
2027
- doc: SubsystemModelDocument,
1784
+ doc: Pick<SubsystemModelDocument, 'components' | 'trails'>,
2028
1785
  graphifyRelations: readonly SubsystemGraphifyRelation[] = [],
2029
1786
  ): SubsystemGraphEdge[] {
2030
1787
  const compAliases = new Set(doc.components.map((c) => c.alias));
@@ -2086,7 +1843,7 @@ export function subsystemGraphLayoutKey(
2086
1843
  * layered with minimized crossings.
2087
1844
  */
2088
1845
  export async function buildSubsystemGraph(
2089
- doc: SubsystemModelDocument,
1846
+ doc: Pick<SubsystemModelDocument, 'components' | 'trails'>,
2090
1847
  opts: {
2091
1848
  maxNodeWidth?: number;
2092
1849
  showEdgeLabels?: boolean;
@@ -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>
@@ -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
  *
@@ -33,7 +61,7 @@ export const EDGE_LABEL_FONT_SIZE = 14;
33
61
  * "min edge length" here double-counts (it is applied twice), which is how the
34
62
  * gap silently ballooned before.
35
63
  */
36
- export const EDGE_LABEL_SIDE_PADDING = 8;
64
+ export const EDGE_LABEL_SIDE_PADDING = 32;
37
65
 
38
66
  /** Actual end-to-end gap ELK leaves for a labelled edge (box + both clearances). */
39
67
  export const EDGE_LABEL_EDGE_GAP =
@@ -15,8 +15,10 @@ import {
15
15
  pointAlongPath,
16
16
  fractionAlongPath,
17
17
  planCompoundGroups,
18
+ getElkOptions,
18
19
  type Point,
19
20
  } from './elkLayout';
21
+ import { EDGE_LABEL_WIDTH, EDGE_LABEL_SIDE_PADDING } from './edgeLabel';
20
22
 
21
23
  describe('planCompoundGroups', () => {
22
24
  test('drops a single-leaf group by default and promotes its member', () => {
@@ -131,6 +133,20 @@ describe('planCompoundGroups', () => {
131
133
  });
132
134
 
133
135
  describe('elkLayout helper functions', () => {
136
+ describe('getElkOptions — label-aware between-layer spacing', () => {
137
+ test('does not stack spacing on top of ELK’s reserved label layer', () => {
138
+ // CENTER_LAYER already reserves the label; adding the box width here
139
+ // double-counts and balloons the run (measured 548px for 140 + 204).
140
+ const opts = getElkOptions({ edgeLabels: { enabled: true, placement: 'CENTER' } });
141
+ expect(opts['elk.layered.spacing.nodeNodeBetweenLayers']).toBe('0');
142
+ });
143
+
144
+ test('an explicit interLayerSpacing still wins', () => {
145
+ const opts = getElkOptions({ interLayerSpacing: 120, edgeLabels: { enabled: true } });
146
+ expect(opts['elk.layered.spacing.nodeNodeBetweenLayers']).toBe('120');
147
+ });
148
+ });
149
+
134
150
  describe('pointAlongPath / fractionAlongPath', () => {
135
151
  const bent: Point[] = [
136
152
  { x: 0, y: 0 },