@principal-ai/subsystems-react 0.52.0 → 0.53.1

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 (36) hide show
  1. package/dist/subsystem/C4Graph.d.ts +8 -1
  2. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  3. package/dist/subsystem/C4Graph.js +470 -155
  4. package/dist/subsystem/C4Graph.js.map +1 -1
  5. package/dist/subsystem/C4NodeCard.d.ts +42 -1
  6. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  7. package/dist/subsystem/C4NodeCard.js +75 -20
  8. package/dist/subsystem/C4NodeCard.js.map +1 -1
  9. package/dist/subsystem/IssueList.d.ts.map +1 -1
  10. package/dist/subsystem/IssueList.js +13 -4
  11. package/dist/subsystem/IssueList.js.map +1 -1
  12. package/dist/subsystem/c4.d.ts +27 -3
  13. package/dist/subsystem/c4.d.ts.map +1 -1
  14. package/dist/subsystem/c4.js +48 -0
  15. package/dist/subsystem/c4.js.map +1 -1
  16. package/dist/subsystem/model.d.ts +35 -245
  17. package/dist/subsystem/model.d.ts.map +1 -1
  18. package/dist/subsystem/model.js +11 -29
  19. package/dist/subsystem/model.js.map +1 -1
  20. package/dist/utils/elkLayout.d.ts +26 -2
  21. package/dist/utils/elkLayout.d.ts.map +1 -1
  22. package/dist/utils/elkLayout.js +40 -8
  23. package/dist/utils/elkLayout.js.map +1 -1
  24. package/package.json +4 -3
  25. package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
  26. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
  27. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
  28. package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +131 -0
  29. package/src/subsystem/C4Graph.tsx +578 -198
  30. package/src/subsystem/C4NodeCard.test.ts +6 -3
  31. package/src/subsystem/C4NodeCard.tsx +158 -49
  32. package/src/subsystem/IssueList.category.test.ts +39 -0
  33. package/src/subsystem/IssueList.tsx +14 -3
  34. package/src/subsystem/c4.ts +74 -3
  35. package/src/subsystem/model.ts +62 -305
  36. package/src/utils/elkLayout.ts +60 -10
@@ -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;
@@ -150,9 +150,16 @@ export interface ElkLayoutOptions {
150
150
  * decoration drawn around wherever the members landed.
151
151
  *
152
152
  * Only for groups whose members need no parent-relative positioning (C4's
153
- * system frame). @default false
153
+ * system frame).
154
+ *
155
+ * A set names specific groups to flatten, leaving the rest as real ELK
156
+ * parents — the drill-down needs this, because the opened container must be
157
+ * *sized by ELK to fit its nested components* while the system frame around
158
+ * it stays decorative. Descendants of a flattened group are flattened too.
159
+ *
160
+ * @default false
154
161
  */
155
- flatGroups?: boolean;
162
+ flatGroups?: boolean | ReadonlySet<string>;
156
163
 
157
164
  /**
158
165
  * ELK partition per node id. Nodes are banded by `partition` — ELK places all
@@ -520,6 +527,21 @@ export interface CompoundGroupDef {
520
527
  id: string;
521
528
  memberIds: string[];
522
529
  minWidth?: number;
530
+ /**
531
+ * ELK partition for this group. Partitioning is activated on the root and
532
+ * inherited, so a compound parent must be told its band explicitly —
533
+ * otherwise it falls to partition 0 and lays out away from the siblings it is
534
+ * supposed to sit among (which, for a flat frame around those siblings, drags
535
+ * the frame across an unrelated band).
536
+ */
537
+ partition?: number;
538
+ /**
539
+ * Space reserved above the first child, in px. Set it when the parent draws
540
+ * its own chrome at the top (C4's grown container draws a label slot and a
541
+ * technology row) so ELK still owns the fit — it pads the parent by this and
542
+ * the children land below the chrome rather than under it.
543
+ */
544
+ padTop?: number;
523
545
  }
524
546
 
525
547
  /** Which groups ELK builds, and which it drops. */
@@ -528,7 +550,7 @@ export interface CompoundGroupPlan {
528
550
  * Built groups in build order — a group's children always appear before it,
529
551
  * so callers can map ids to shells in one forward pass.
530
552
  */
531
- built: Array<{ id: string; childIds: string[]; minWidth?: number }>;
553
+ built: Array<{ id: string; childIds: string[]; minWidth?: number; padTop?: number; partition?: number }>;
532
554
  /**
533
555
  * Dropped groups. Their members are promoted into the nearest built
534
556
  * ancestor, so callers must clear those members' `parentId`.
@@ -864,11 +886,27 @@ export async function computeElkLayout(
864
886
  // and the frame is synthesized from the members' bounds after layout. This
865
887
  // keeps every edge on one spacing instead of inheriting a nested frame's.
866
888
  const flatGroupIds = new Set<string>();
867
- if (flatGroups) {
889
+ if (flatGroups === true) {
868
890
  for (const g of plan.built) flatGroupIds.add(g.id);
891
+ } else if (flatGroups) {
892
+ for (const g of plan.built) if (flatGroups.has(g.id)) flatGroupIds.add(g.id);
893
+ // A group inside a flat frame can't be an ELK parent either — its members
894
+ // lay out at the root alongside the frame's other members.
895
+ let grew = true;
896
+ while (grew) {
897
+ grew = false;
898
+ for (const g of plan.built) {
899
+ if (flatGroupIds.has(g.id)) continue;
900
+ const parentId = groupById.get(g.id)?.parentId;
901
+ if (parentId && flatGroupIds.has(parentId)) {
902
+ flatGroupIds.add(g.id);
903
+ grew = true;
904
+ }
905
+ }
906
+ }
869
907
  }
870
908
  for (const g of plan.built) {
871
- if (flatGroups) break;
909
+ if (flatGroupIds.has(g.id)) continue;
872
910
  const children: ElkNode[] = [];
873
911
  for (const cid of g.childIds) {
874
912
  const leaf = elkById.get(cid);
@@ -878,14 +916,26 @@ export async function computeElkLayout(
878
916
  if (nested) children.push(nested);
879
917
  }
880
918
  }
919
+ const sizeOptions: Record<string, string> = {};
920
+ if (g.minWidth != null) {
921
+ // MINIMUM_SIZE, not a forced size: ELK grows the parent past it when the
922
+ // children need more, but never shrinks it below it.
923
+ sizeOptions['elk.nodeSize.constraints'] = 'MINIMUM_SIZE';
924
+ sizeOptions['elk.nodeSize.minimum'] = `(${g.minWidth},0)`;
925
+ }
926
+ if (g.padTop != null) {
927
+ // Reserving the parent's own chrome. Children then start below it, and
928
+ // the parent's height still comes from ELK's fit.
929
+ sizeOptions['elk.padding'] = `[top=${g.padTop},left=12,bottom=12,right=12]`;
930
+ }
931
+ if (g.partition != null) sizeOptions['partition'] = String(g.partition);
881
932
  builtGroups.set(g.id, {
882
933
  id: g.id,
883
934
  children,
884
- layoutOptions: g.minWidth == null ? compoundLayoutOptions : {
885
- ...compoundLayoutOptions,
886
- 'elk.nodeSize.constraints': 'MINIMUM_SIZE',
887
- 'elk.nodeSize.minimum': `(${g.minWidth},0)`,
888
- },
935
+ layoutOptions:
936
+ Object.keys(sizeOptions).length > 0
937
+ ? { ...compoundLayoutOptions, ...sizeOptions }
938
+ : compoundLayoutOptions,
889
939
  });
890
940
  }
891
941