@principal-ai/subsystems-react 0.35.7 → 0.36.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 (47) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts.map +1 -1
  6. package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js.map +1 -1
  7. package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -1
  8. package/dist/subsystem/SubsystemAggregateGraph.js +152 -21
  9. package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -1
  10. package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
  11. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  12. package/dist/subsystem/SubsystemComponentGraph.js +9 -5
  13. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  14. package/dist/subsystem/graphChrome.d.ts +15 -0
  15. package/dist/subsystem/graphChrome.d.ts.map +1 -1
  16. package/dist/subsystem/graphChrome.js +17 -0
  17. package/dist/subsystem/graphChrome.js.map +1 -1
  18. package/dist/subsystem/model.d.ts +26 -0
  19. package/dist/subsystem/model.d.ts.map +1 -1
  20. package/dist/subsystem/model.js +90 -2
  21. package/dist/subsystem/model.js.map +1 -1
  22. package/dist/subsystem/nodes.d.ts.map +1 -1
  23. package/dist/subsystem/nodes.js +12 -9
  24. package/dist/subsystem/nodes.js.map +1 -1
  25. package/dist/utils/elkLayout.d.ts +45 -0
  26. package/dist/utils/elkLayout.d.ts.map +1 -1
  27. package/dist/utils/elkLayout.js +114 -84
  28. package/dist/utils/elkLayout.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/graphify/signature.test.ts +1 -1
  31. package/src/index.ts +1 -0
  32. package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
  33. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
  34. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +8 -8
  35. package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +19 -11
  36. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
  37. package/src/subsystem/SubsystemAggregateGraph.tsx +229 -21
  38. package/src/subsystem/SubsystemComponentGraph.tsx +34 -16
  39. package/src/subsystem/graphChrome.tsx +20 -0
  40. package/src/subsystem/model.test.ts +105 -0
  41. package/src/subsystem/model.ts +119 -4
  42. package/src/subsystem/nodes.tsx +49 -64
  43. package/src/utils/elkLayout.test.ts +117 -3
  44. package/src/utils/elkLayout.ts +154 -82
  45. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
  46. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
  47. /package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.ts +0 -0
@@ -663,6 +663,18 @@ export function moduleMinWidthForBadge(path: string): number {
663
663
  );
664
664
  }
665
665
 
666
+ /**
667
+ * Min frame width so a **non-collapsing** boundary badge (process / package)
668
+ * fits inside with padding at both ends. Process badges are centred on the top
669
+ * edge and never collapse, so the full label must clear `MODULE_BADGE_INSET` on
670
+ * each side; ELK receives this as the frame's `minWidth`.
671
+ */
672
+ export function boundaryMinWidthForBadge(label: string): number {
673
+ return Math.ceil(
674
+ moduleBadgeWidth(label) + MODULE_BADGE_INSET * 2 + MODULE_FRAME_BORDER,
675
+ );
676
+ }
677
+
666
678
  /** React Flow id for a package (repo) boundary group node. */
667
679
  export function packageGroupNodeId(packageKey: string): string {
668
680
  return `package:${packageKey}`;
@@ -824,6 +836,13 @@ export function buildBoundaryLayoutGroups(
824
836
  // ("value already present"). Mixed-process modules (common in composed
825
837
  // graphs, where models frame one file under different processes) never
826
838
  // nest — without this claim they land in both frames.
839
+ //
840
+ // Singletons deliberately do NOT claim, `showSingletonFrames` or not: a
841
+ // one-member process spans exactly one package and a one-member module
842
+ // sits in exactly one process, so each always nests, and the enclosing
843
+ // frame's own `claimed`/`nestedModuleKeys` filter already excludes the
844
+ // leaf. Claiming as well would only strip those leaves from a package's
845
+ // direct leaves for no gain.
827
846
  const claimedByModule = new Set<string>();
828
847
  for (const r of modules) {
829
848
  if (r.memberAliases.length >= 2) {
@@ -832,8 +851,7 @@ export function buildBoundaryLayoutGroups(
832
851
  }
833
852
  // Same rule one level up: a leaf owned by a multi-member process frame
834
853
  // must not also sit directly in a package frame. (Singleton processes
835
- // never claim — ELK skips them and promotes the member upward, so the
836
- // member has to stay reachable through its package or ungrouped.)
854
+ // never claim — see above.)
837
855
  const claimedByProcess = new Set<string>();
838
856
  for (const r of processes) {
839
857
  if (r.memberAliases.length >= 2) {
@@ -1046,6 +1064,87 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
1046
1064
  'registers-into': 'dashed',
1047
1065
  };
1048
1066
 
1067
+ /**
1068
+ * Region colors for process boundaries in the aggregate graph. Kept separate
1069
+ * from MECHANISM_COLOR on purpose: that palette encodes edge semantics on thin
1070
+ * arrowed strokes, this one encodes containment on wide filled regions, and a
1071
+ * shared hue would make the two readings ambiguous.
1072
+ */
1073
+ export const BOUNDARY_COLOR: readonly string[] = [
1074
+ '#6c9eff', // blue
1075
+ '#3fb8a0', // teal
1076
+ '#8fbf5f', // green
1077
+ '#d9a441', // amber
1078
+ '#d67ab1', // magenta
1079
+ '#9d7fe0', // violet
1080
+ '#e8705f', // coral
1081
+ '#4fb0d4', // sky
1082
+ '#c2a83c', // olive
1083
+ '#a876c8', // orchid
1084
+ '#d1487f', // crimson
1085
+ '#a3c94f', // lime
1086
+ ];
1087
+
1088
+ /** Stable 32-bit hash (FNV-1a plus a final mix) so low bits still spread. */
1089
+ function hashBoundaryKey(key: string): number {
1090
+ let h = 0x811c9dc5;
1091
+ for (let i = 0; i < key.length; i++) {
1092
+ h ^= key.charCodeAt(i);
1093
+ h = Math.imul(h, 0x01000193);
1094
+ }
1095
+ h ^= h >>> 15;
1096
+ h = Math.imul(h, 0x2545f491);
1097
+ h ^= h >>> 13;
1098
+ return h >>> 0;
1099
+ }
1100
+
1101
+ /**
1102
+ * Assign each process boundary key a color from BOUNDARY_COLOR.
1103
+ *
1104
+ * A bare `hash % palette.length` collides constantly once a repo has more than
1105
+ * a handful of processes — unrelated boundaries end up the same hue, which
1106
+ * defeats the point of coding them at all. So the hash only picks the
1107
+ * *preferred* slot and collisions probe forward to the next free one, with
1108
+ * keys visited in sorted order to keep the result deterministic. Distinct
1109
+ * boundaries therefore never share a color unless the key count exceeds the
1110
+ * palette, and past that point the least-used hue wins rather than an
1111
+ * arbitrary one, so the busiest boundaries keep unique colors longest.
1112
+ *
1113
+ * Assignment depends on the key set, so filtering the graph can reshuffle
1114
+ * hues. That is the deliberate trade: the legend is always on screen, whereas
1115
+ * a stable-but-colliding mapping is unreadable.
1116
+ */
1117
+ export function assignBoundaryColors(keys: readonly string[]): Map<string, string> {
1118
+ const out = new Map<string, string>();
1119
+ const owners = new Map<string, number>();
1120
+ for (const key of [...keys].sort()) {
1121
+ if (out.has(key)) continue;
1122
+ const start = hashBoundaryKey(key) % BOUNDARY_COLOR.length;
1123
+ let picked: string | undefined;
1124
+ for (let i = 0; i < BOUNDARY_COLOR.length; i++) {
1125
+ const candidate = BOUNDARY_COLOR[(start + i) % BOUNDARY_COLOR.length]!;
1126
+ if (owners.get(candidate)) continue;
1127
+ picked = candidate;
1128
+ break;
1129
+ }
1130
+ // Palette exhausted: share the least-used hue, ties going to the earlier
1131
+ // slot so the result stays deterministic.
1132
+ if (!picked) {
1133
+ picked = BOUNDARY_COLOR.reduce((a, b) =>
1134
+ (owners.get(a) ?? 0) <= (owners.get(b) ?? 0) ? a : b,
1135
+ )!;
1136
+ }
1137
+ owners.set(picked, (owners.get(picked) ?? 0) + 1);
1138
+ out.set(key, picked);
1139
+ }
1140
+ return out;
1141
+ }
1142
+
1143
+ /** Low-alpha fill for a boundary region, from its assigned color. */
1144
+ export function boundaryFill(color: string, alpha = '1f'): string {
1145
+ return `${color}${alpha}`;
1146
+ }
1147
+
1049
1148
  /** Mechanism → [description, verifiable-with-graphify]. Drives the "not
1050
1149
  * directly verifiable" styling of edge labels. */
1051
1150
  export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
@@ -1550,8 +1649,19 @@ export async function buildSubsystemGraph(
1550
1649
  id: g.id,
1551
1650
  memberIds: g.memberAliases,
1552
1651
  parentId: g.parentId,
1553
- minWidth: g.region.kind === 'module' ? moduleMinWidthForBadge(g.region.label) : undefined,
1652
+ // Modules collapse a long path to `first/…/last`, so they only need
1653
+ // room for the collapsed badge. Processes/packages never collapse:
1654
+ // size the frame to hold the full centered label plus padding.
1655
+ minWidth:
1656
+ g.region.kind === 'module'
1657
+ ? moduleMinWidthForBadge(g.region.label)
1658
+ : boundaryMinWidthForBadge(g.region.label),
1554
1659
  })),
1660
+ // Honour `showSingletonFrames`: a one-member process or module is
1661
+ // still a real boundary. `buildBoundaryLayoutGroups` already made
1662
+ // those regions claim their members, so keeping them here cannot
1663
+ // double-parent a leaf.
1664
+ keepSingletonGroups: showSingletonFrames === true,
1555
1665
  });
1556
1666
  const builtGroupIds = new Set(result.groupBounds.keys());
1557
1667
  // Parents before children — package, then process, then module frames.
@@ -1605,7 +1715,12 @@ export async function buildSubsystemGraph(
1605
1715
  id: g.id,
1606
1716
  type: 'subsystem-group',
1607
1717
  position: { x: 0, y: 0 },
1608
- width: 400,
1718
+ width: Math.max(
1719
+ 400,
1720
+ g.region.kind === 'module'
1721
+ ? moduleMinWidthForBadge(g.region.label)
1722
+ : boundaryMinWidthForBadge(g.region.label),
1723
+ ),
1609
1724
  height: 300,
1610
1725
  ...(g.parentId ? { parentId: g.parentId } : {}),
1611
1726
  data: { region: g.region },
@@ -27,7 +27,6 @@ import {
27
27
  storageBadgeLabel,
28
28
  storageBadgeColor,
29
29
  deriveNameFromSymbol,
30
- BADGE_EDGE_INSET,
31
30
  nodeMinWidthForBadges,
32
31
  MODULE_BADGE_INSET,
33
32
  moduleBadgeLabel,
@@ -70,9 +69,6 @@ function breakWords(s: string): string {
70
69
  export interface SubsystemGraphCallbacks {
71
70
  /** Click a component — open its file/entry point. */
72
71
  onSelect?: (componentAlias: string) => void;
73
- /** Click the filename badge — open that component's file in the drawer
74
- * (same path as clicking the file link in the declaration panel). */
75
- onOpenFile?: (componentAlias: string) => void;
76
72
  /** Click an edge (or its label) — select the relationship. */
77
73
  onEdgeSelect?: (edgeId: string) => void;
78
74
  /** Hover a component (null on leave) — associates it with the file tree. */
@@ -121,6 +117,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
121
117
  const callbacks = useSubsystemCallbacks();
122
118
  const { data, selected, width: nodeWidth, height: nodeHeight } = props;
123
119
  const c = data.component;
120
+ const [hover, setHover] = useState(false);
124
121
  // Construct owns node color, derived from the active Pierre syntax theme —
125
122
  // the same palette the declaration panel and file drawer render with. Role
126
123
  // shows as the hover badge, not as color — for now; a role glyph/accent may
@@ -129,7 +126,6 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
129
126
  // Left badge: framework brand (e.g. React cyan) when the label is a
130
127
  // framework stereotype; otherwise the same construct color as the border.
131
128
  const badgeColor = constructBadgeColor(c) ?? color;
132
- const [hover, setHover] = useState(false);
133
129
  const configuredMax = callbacks.maxNodeWidth;
134
130
  const maxWidth = configuredMax ?? 300;
135
131
  // `symbol` is the source of truth; `name` is derived from it consistently.
@@ -145,6 +141,11 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
145
141
  // Set while a file is open in the drawer: true → spotlight, false → dim,
146
142
  // absent (no file open) → neutral.
147
143
  const fileMatch = data.fileMatch as boolean | undefined;
144
+ // Node fill lifts on hover as the clickability affordance; badges share it
145
+ // so they read as tabs on the same surface rather than floating patches.
146
+ const nodeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
147
+ const hoverBg =
148
+ theme.colors.backgroundHover ?? theme.colors.backgroundTertiary ?? nodeBg;
148
149
  // Border: proposed uses the goldenrod accent (dashed); construct color stays
149
150
  // on the left badge. File-open spotlight still wins with primary.
150
151
  const borderColor = fileMatch
@@ -159,6 +160,14 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
159
160
  const isExternal = c.construct === 'external';
160
161
  const nodeRadius = isExternal ? 0 : 8;
161
162
  const badgeRadius = isExternal ? 0 : 4;
163
+ // The border thickens on selection / file-match. Absolutely-positioned
164
+ // badges are laid out from the padding box (inside the border), so a raw
165
+ // `top` / `left` would slide with the border. Anchor them to the border box
166
+ // instead, offsetting by the border width. Side badges sit flush with the
167
+ // node's left / right edge (`-borderW`); the top reference stays at -9.
168
+ const borderW = isSelected || fileMatch ? 4 : 2;
169
+ const badgeTop = -9 - borderW;
170
+ const badgeEdge = -borderW;
162
171
 
163
172
  return (
164
173
  <div
@@ -187,15 +196,15 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
187
196
  maxWidth,
188
197
  padding: '6px 10px',
189
198
  borderRadius: nodeRadius,
190
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
199
+ background: hover ? hoverBg : nodeBg,
191
200
  // Selected / file-matched nodes get a thicker border. Proposed nodes
192
201
  // use a dashed goldenrod border; left construct badge keeps construct color.
193
- border: `${isSelected || fileMatch ? 4 : 2}px ${c.proposed ? 'dashed' : 'solid'} ${borderColor}`,
202
+ border: `${borderW}px ${c.proposed ? 'dashed' : 'solid'} ${borderColor}`,
194
203
  boxShadow: fileMatch
195
204
  ? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
196
205
  : '0 1px 4px rgba(0,0,0,0.25)',
197
206
  opacity: fileMatch === false || data.dimmed === true ? 0.18 : 1,
198
- transition: 'opacity 150ms ease',
207
+ transition: 'opacity 150ms ease, background-color 120ms ease',
199
208
  cursor: 'pointer',
200
209
  fontFamily: theme.fonts.body,
201
210
  }}
@@ -207,8 +216,8 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
207
216
  <div
208
217
  style={{
209
218
  position: 'absolute',
210
- top: -11,
211
- left: BADGE_EDGE_INSET,
219
+ top: badgeTop,
220
+ left: badgeEdge,
212
221
  zIndex: 1,
213
222
  fontFamily: theme.fonts.monospace,
214
223
  fontSize: theme.fontSizes[1],
@@ -217,7 +226,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
217
226
  lineHeight: '17px',
218
227
  whiteSpace: 'nowrap',
219
228
  color: badgeColor,
220
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
229
+ background: nodeBg,
221
230
  border: `2px solid ${badgeColor}`,
222
231
  borderRadius: badgeRadius,
223
232
  padding: '2px 8px',
@@ -226,15 +235,18 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
226
235
  {constructBadgeLabel(c)}
227
236
  </div>
228
237
 
229
- {/* Right badge cluster — proposed wins over role when both are set;
238
+ {/* Secondary badge cluster — proposed wins over role when both are set;
230
239
  store storage (retention backing) sits beside, since it is
231
- orthogonal to topology. Persistent; pointer-events pass through. */}
240
+ orthogonal to topology. Persistent; pointer-events pass through.
241
+ Sits astride the bottom edge, centered (mirroring the top construct
242
+ badge's straddle), so the top edge stays free for the construct tag. */}
232
243
  {(topRightLabel != null || storageLabel != null) && (
233
244
  <div
234
245
  style={{
235
246
  position: 'absolute',
236
- top: -11,
237
- right: BADGE_EDGE_INSET,
247
+ bottom: badgeTop,
248
+ left: '50%',
249
+ transform: 'translateX(-50%)',
238
250
  zIndex: 1,
239
251
  display: 'flex',
240
252
  gap: 6,
@@ -250,7 +262,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
250
262
  textTransform: 'uppercase',
251
263
  lineHeight: '17px',
252
264
  color: storageColor,
253
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
265
+ background: nodeBg,
254
266
  border: `2px solid ${storageColor}`,
255
267
  borderRadius: badgeRadius,
256
268
  padding: '2px 8px',
@@ -268,7 +280,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
268
280
  textTransform: 'uppercase',
269
281
  lineHeight: '17px',
270
282
  color: topRightColor,
271
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
283
+ background: nodeBg,
272
284
  border: `2px solid ${topRightColor}`,
273
285
  borderRadius: badgeRadius,
274
286
  padding: '2px 8px',
@@ -280,41 +292,6 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
280
292
  </div>
281
293
  )}
282
294
 
283
- {/* Filename badge — a strip across the bottom border, shown on hover
284
- and pinned while this node's file is open in the drawer (fileMatch).
285
- Mirrors the construct/role tab badges; hidden for fileless nodes. */}
286
- {(hover || fileMatch === true) && c.file ? (
287
- <div
288
- style={{
289
- position: 'absolute',
290
- bottom: -14,
291
- left: '50%',
292
- transform: 'translateX(-50%)',
293
- zIndex: 1,
294
- fontFamily: theme.fonts.monospace,
295
- fontSize: theme.fontSizes[1],
296
- letterSpacing: 0.5,
297
- lineHeight: '17px',
298
- whiteSpace: 'nowrap',
299
- // filename stays neutral — the construct color belongs to the
300
- // borders and badges, not the file name
301
- color: theme.colors.text ?? theme.colors.textSecondary,
302
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
303
- border: `2px solid ${color}`,
304
- borderRadius: badgeRadius,
305
- padding: '2px 8px',
306
- cursor: 'pointer',
307
- }}
308
- onClick={(e) => {
309
- // Open the file directly; don't also toggle node selection.
310
- e.stopPropagation();
311
- callbacks.onOpenFile?.(c.alias);
312
- }}
313
- >
314
- {c.file.split('/').pop()}
315
- </div>
316
- ) : null}
317
-
318
295
  {/* Purpose is no longer shown as a hover tooltip below the node — it
319
296
  lives in the declaration panel and the graphify detail payload. */}
320
297
 
@@ -405,6 +382,7 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
405
382
  isModule && canToggle ? moduleBadgeHoverLabel(label, availableBadgeWidth) : label;
406
383
  const frameRadius = isProcess ? 0 : 12;
407
384
  const badgeRadius = isProcess ? 0 : 4;
385
+ const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
408
386
  // Processes are deployment units — solid frame. Modules/packages stay dashed
409
387
  // until selected.
410
388
  const frameStyle = isProcess || selected ? 'solid' : 'dashed';
@@ -453,14 +431,15 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
453
431
  } : undefined}
454
432
  style={{
455
433
  position: 'absolute',
456
- // Process: badge fill starts with the process fill (inside the
457
- // frame border); no top badge border so widths don't fight.
458
- // Module/package sit astride the top edge like component badges.
459
- // Always set left/transform/borderTop explicitly — React Flow reuses
460
- // group DOM nodes and `undefined` does not clear a prior value.
461
- top: isProcess ? 0 : -19,
434
+ // Process: the tab sits flush over the frame's top border (`top:
435
+ // -2` — the frame border is 2px) and carries its own full border, so
436
+ // the two read as one piece. Module/package are centred astride the
437
+ // top edge like component badges. Always set left/transform
438
+ // explicitly — React Flow reuses group DOM nodes and `undefined`
439
+ // does not clear a prior value.
440
+ top: isProcess ? -2 : 0,
462
441
  left: isProcess ? '50%' : 12,
463
- transform: isProcess ? 'translateX(-50%)' : 'none',
442
+ transform: isProcess ? 'translateX(-50%)' : 'translateY(-50%)',
464
443
  zIndex: canToggle ? 10 : undefined,
465
444
  // Width is explicit while collapsible so hover-reveal of more path
466
445
  // text animates; the cap keeps both states inside the frame.
@@ -472,17 +451,23 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
472
451
  : undefined,
473
452
  transition: 'width 140ms ease, box-shadow 120ms ease',
474
453
  cursor: canToggle ? 'pointer' : undefined,
475
- boxShadow: canToggle && hover ? `0 2px 10px ${color}55` : undefined,
454
+ // The trailing halo squares the rounded corners back off in the fill
455
+ // colour, so an edge crossing a corner can't show through the notch.
456
+ // Square (process) badges have no notch, and the halo would erase the
457
+ // frame border they now sit flush on, so they skip it.
458
+ boxShadow: isProcess
459
+ ? undefined
460
+ : `${canToggle && hover ? `0 2px 10px ${color}55, ` : ''}0 0 0 1.5px ${badgeBg}`,
476
461
  fontFamily: theme.fonts.monospace,
477
462
  fontSize: theme.fontSizes[3],
478
463
  fontWeight: 700,
479
464
  letterSpacing: 0.6,
480
465
  color,
481
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
482
- border: `1px solid ${color}`,
483
- borderTopWidth: isProcess ? 0 : 1,
466
+ background: badgeBg,
467
+ border: `2px solid ${color}`,
468
+ borderTopWidth: 2,
484
469
  borderRadius: badgeRadius,
485
- padding: '1px 7px',
470
+ padding: '3px 9px',
486
471
  whiteSpace: 'nowrap',
487
472
  overflow: 'hidden',
488
473
  textOverflow: 'ellipsis',
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Tests for ELK Layout Utility
3
3
  *
4
- * Tests the pure helper functions that don't require the ELK runtime.
5
- * The computeElkLayout function is tested via Storybook visual tests
6
- * since ELK requires a web worker environment.
4
+ * Tests the pure helper functions that don't require the ELK runtime, plus
5
+ * the compound-group planner. computeElkLayout itself needs a web worker
6
+ * (elkjs constructs one), so it is covered by Storybook visual tests and by
7
+ * testing planCompoundGroups — the part that decides which frames exist.
7
8
  */
8
9
 
9
10
  import { describe, test, expect } from 'bun:test';
@@ -11,9 +12,122 @@ import {
11
12
  pointsToPath,
12
13
  pointsToSmoothPath,
13
14
  calculatePathMidpoint,
15
+ planCompoundGroups,
14
16
  type Point,
15
17
  } from './elkLayout';
16
18
 
19
+ describe('planCompoundGroups', () => {
20
+ test('drops a single-leaf group by default and promotes its member', () => {
21
+ const plan = planCompoundGroups(
22
+ [{ id: 'proc', memberIds: ['solo'] }],
23
+ ['solo'],
24
+ );
25
+ expect(plan.built.map((g) => g.id)).toEqual([]);
26
+ expect(plan.skipped).toEqual(['proc']);
27
+ });
28
+
29
+ test('keeps a single-leaf group when the caller opts in', () => {
30
+ const plan = planCompoundGroups(
31
+ [{ id: 'proc', memberIds: ['solo'] }],
32
+ ['solo'],
33
+ true,
34
+ );
35
+ expect(plan.skipped).toEqual([]);
36
+ expect(plan.built).toEqual([{ id: 'proc', childIds: ['solo'], minWidth: undefined }]);
37
+ });
38
+
39
+ test('keeps a multi-leaf group in both modes', () => {
40
+ const defs = [{ id: 'proc', memberIds: ['a', 'b'] }];
41
+ for (const keep of [false, true]) {
42
+ const plan = planCompoundGroups(defs, ['a', 'b'], keep);
43
+ expect(plan.built.map((g) => g.id)).toEqual(['proc']);
44
+ expect(plan.skipped).toEqual([]);
45
+ }
46
+ });
47
+
48
+ test('always drops an empty group even when singletons are kept', () => {
49
+ const plan = planCompoundGroups([{ id: 'proc', memberIds: [] }], ['a'], true);
50
+ expect(plan.built).toEqual([]);
51
+ expect(plan.skipped).toEqual(['proc']);
52
+ });
53
+
54
+ test('counts leaves through nested groups', () => {
55
+ // process → module(one leaf) is still a single leaf, so both drop by
56
+ // default even though the process lists one member id.
57
+ const defs = [
58
+ { id: 'mod', memberIds: ['a'] },
59
+ { id: 'proc', memberIds: ['mod'] },
60
+ ];
61
+ expect(planCompoundGroups(defs, ['a']).skipped).toEqual(['mod', 'proc']);
62
+ expect(planCompoundGroups(defs, ['a'], true).built.map((g) => g.id)).toEqual([
63
+ 'mod',
64
+ 'proc',
65
+ ]);
66
+ });
67
+
68
+ test('a two-leaf module keeps its process alive under the default', () => {
69
+ const defs = [
70
+ { id: 'mod', memberIds: ['a', 'b'] },
71
+ { id: 'proc', memberIds: ['mod'] },
72
+ ];
73
+ const plan = planCompoundGroups(defs, ['a', 'b']);
74
+ expect(plan.built.map((g) => g.id)).toEqual(['mod', 'proc']);
75
+ expect(plan.built[1]!.childIds).toEqual(['mod']);
76
+ });
77
+
78
+ test('promotes a dropped group member into its built parent', () => {
79
+ // proc contains a singleton module (dropped) plus a bare leaf, so proc
80
+ // still has two leaves and survives; the module's leaf is promoted in.
81
+ const defs = [
82
+ { id: 'mod', memberIds: ['a'] },
83
+ { id: 'proc', memberIds: ['mod', 'b'] },
84
+ ];
85
+ const plan = planCompoundGroups(defs, ['a', 'b']);
86
+ expect(plan.skipped).toEqual(['mod']);
87
+ expect(plan.built.map((g) => g.id)).toEqual(['proc']);
88
+ expect(plan.built[0]!.childIds.sort()).toEqual(['a', 'b']);
89
+ });
90
+
91
+ test('builds children before parents', () => {
92
+ const defs = [
93
+ { id: 'pkg', memberIds: ['procA', 'procB'] },
94
+ { id: 'procA', memberIds: ['a1', 'a2'] },
95
+ { id: 'procB', memberIds: ['b1', 'b2'] },
96
+ ];
97
+ const plan = planCompoundGroups(defs, ['a1', 'a2', 'b1', 'b2']);
98
+ const order = plan.built.map((g) => g.id);
99
+ expect(order.indexOf('procA')).toBeLessThan(order.indexOf('pkg'));
100
+ expect(order.indexOf('procB')).toBeLessThan(order.indexOf('pkg'));
101
+ });
102
+
103
+ test('drops groups caught in a cycle instead of looping forever', () => {
104
+ const defs = [
105
+ { id: 'x', memberIds: ['y'] },
106
+ { id: 'y', memberIds: ['x'] },
107
+ ];
108
+ const plan = planCompoundGroups(defs, ['a']);
109
+ expect(plan.built).toEqual([]);
110
+ expect(plan.skipped.sort()).toEqual(['x', 'y']);
111
+ });
112
+
113
+ test('ignores member ids that match no leaf or group', () => {
114
+ const plan = planCompoundGroups(
115
+ [{ id: 'proc', memberIds: ['a', 'ghost'] }],
116
+ ['a'],
117
+ true,
118
+ );
119
+ expect(plan.built[0]!.childIds).toEqual(['a']);
120
+ });
121
+
122
+ test('carries minWidth through to the plan', () => {
123
+ const plan = planCompoundGroups(
124
+ [{ id: 'mod', memberIds: ['a', 'b'], minWidth: 220 }],
125
+ ['a', 'b'],
126
+ );
127
+ expect(plan.built[0]!.minWidth).toBe(220);
128
+ });
129
+ });
130
+
17
131
  describe('elkLayout helper functions', () => {
18
132
  describe('pointsToPath', () => {
19
133
  test('should return empty string for empty array', () => {