@principal-ai/subsystems-react 0.24.26 → 0.25.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 (36) hide show
  1. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +1 -1
  2. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  3. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +7 -7
  4. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  5. package/dist/subsystem/ComponentDeclaration.d.ts +1 -1
  6. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  7. package/dist/subsystem/ComponentDeclaration.js +1 -1
  8. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  9. package/dist/subsystem/ConstructsCatalog.d.ts +2 -2
  10. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  11. package/dist/subsystem/ConstructsCatalog.js +62 -62
  12. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts +2 -2
  14. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  15. package/dist/subsystem/SubsystemComponentGraph.js +27 -27
  16. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  17. package/dist/subsystem/model.d.ts +21 -5
  18. package/dist/subsystem/model.d.ts.map +1 -1
  19. package/dist/subsystem/model.js +72 -38
  20. package/dist/subsystem/model.js.map +1 -1
  21. package/dist/subsystem/nodes.d.ts +3 -3
  22. package/dist/subsystem/nodes.d.ts.map +1 -1
  23. package/dist/subsystem/nodes.js +39 -11
  24. package/dist/subsystem/nodes.js.map +1 -1
  25. package/dist/subsystem/paths.d.ts +1 -1
  26. package/dist/subsystem/paths.js +1 -1
  27. package/package.json +1 -1
  28. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +183 -0
  29. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +8 -8
  30. package/src/subsystem/ComponentDeclaration.tsx +2 -2
  31. package/src/subsystem/ConstructsCatalog.tsx +71 -75
  32. package/src/subsystem/SubsystemComponentGraph.tsx +28 -28
  33. package/src/subsystem/model.test.ts +103 -51
  34. package/src/subsystem/model.ts +83 -43
  35. package/src/subsystem/nodes.tsx +57 -18
  36. package/src/subsystem/paths.ts +1 -1
@@ -127,7 +127,13 @@ export type SubsystemEdgeView = 'relations' | 'walkthroughs';
127
127
 
128
128
  /** A component node — the named unit, construct-tagged; `file` is its location. */
129
129
  export interface SubsystemComponent {
130
- id: string;
130
+ /**
131
+ * Model-local stable alias, unique per model. Referenced by relation /
132
+ * walkthrough `from` / `to`; edges point at the alias, not the location.
133
+ * Code identity (for composed multi-model views) lives on
134
+ * `purl` + `file` + `symbol`, not here.
135
+ */
136
+ alias: string;
131
137
  name: string;
132
138
  /**
133
139
  * The node's construct — what it IS as a declaration (class, function,
@@ -268,8 +274,8 @@ export interface SubsystemRelation {
268
274
  */
269
275
  export interface SubsystemComponentEdge {
270
276
  id: string;
271
- from: string; // component id
272
- to: string; // component id or external target label
277
+ from: string; // component alias
278
+ to: string; // component alias or external target label
273
279
  mechanism: SubsystemEdgeMechanism;
274
280
  /** Concrete file/symbol refs backing the edge (the seam). */
275
281
  refs?: string[];
@@ -374,7 +380,7 @@ export function deriveGraphEdges(doc: {
374
380
  * Those snapshots are a catalog of declarations, not a graph.
375
381
  */
376
382
  export function isConstructsOnlyModel(doc: {
377
- components: readonly { id: string }[];
383
+ components: readonly { alias: string }[];
378
384
  relations?: readonly SubsystemRelation[];
379
385
  walkthroughs?: readonly SubsystemWalkthrough[];
380
386
  }): boolean {
@@ -473,7 +479,7 @@ export interface SubsystemProcessRegion {
473
479
  /** Display label for the boundary frame. */
474
480
  label: string;
475
481
  /** Component ids that are members of this region. */
476
- memberIds: string[];
482
+ memberAliases: string[];
477
483
  }
478
484
 
479
485
  /** Options for which boundary frames are kept. */
@@ -516,10 +522,44 @@ export function moduleBadgeLabel(path: string, availableWidth = 0): string {
516
522
  return `${parts[0]}/…/${parts[parts.length - 1]}`;
517
523
  }
518
524
 
525
+ /**
526
+ * Hover form of the badge: the collapsed `first/…/last` plus as many trailing
527
+ * path segments as fit in `availableWidth`. Greedily pulls segments off the
528
+ * tail so hovering reveals the path without ever distorting the glyphs or
529
+ * spilling past the frame's inner width. Falls back to the full path (no
530
+ * collapse) if everything fits.
531
+ */
532
+ export function moduleBadgeHoverLabel(path: string, availableWidth = 0): string {
533
+ if (path.length * MODULE_BADGE_CHAR_WIDTH + MODULE_BADGE_CHROME <= availableWidth) {
534
+ return path;
535
+ }
536
+ const parts = path.split(/[\\/]/).filter((part) => part !== '' && part !== '.');
537
+ if (parts.length <= 2) return path;
538
+ let tailStart = parts.length - 1;
539
+ while (tailStart > 1) {
540
+ const candidate = `${parts[0]}/…/${parts.slice(tailStart - 1).join('/')}`;
541
+ if (candidate.length * MODULE_BADGE_CHAR_WIDTH + MODULE_BADGE_CHROME <= availableWidth) {
542
+ tailStart -= 1;
543
+ } else {
544
+ break;
545
+ }
546
+ }
547
+ return `${parts[0]}/…/${parts.slice(tailStart).join('/')}`;
548
+ }
549
+
550
+ /** Estimated render width (px) of the badge for `path` in the badge font. */
551
+ export function moduleBadgeWidth(path: string): number {
552
+ return Math.ceil(path.length * MODULE_BADGE_CHAR_WIDTH + MODULE_BADGE_CHROME);
553
+ }
554
+
519
555
  export function moduleMinWidthForBadge(path: string): number {
556
+ const collapsed = moduleBadgeLabel(path);
557
+ // `…` renders ~1.4× a regular char in the bold badge font — account for it
558
+ // so a path that just crosses the fit threshold doesn't collapse in vain.
559
+ const expanded = path[0] === '…' ? 0 : 1;
560
+ const glyphWidth = collapsed.length * MODULE_BADGE_CHAR_WIDTH + 4 * expanded;
520
561
  return Math.ceil(
521
- moduleBadgeLabel(path).length * MODULE_BADGE_CHAR_WIDTH +
522
- MODULE_BADGE_CHROME + MODULE_BADGE_INSET * 2 + MODULE_FRAME_BORDER,
562
+ glyphWidth + MODULE_BADGE_CHROME + MODULE_BADGE_INSET * 2 + MODULE_FRAME_BORDER,
523
563
  );
524
564
  }
525
565
 
@@ -564,14 +604,14 @@ export function getSubsystemRegions(
564
604
  const p = c.process?.trim();
565
605
  if (!p) continue;
566
606
  const list = byProcess.get(p) ?? [];
567
- list.push(c.id);
607
+ list.push(c.alias);
568
608
  byProcess.set(p, list);
569
609
  }
570
- return [...byProcess.entries()].map(([key, memberIds]) => ({
610
+ return [...byProcess.entries()].map(([key, memberAliases]) => ({
571
611
  kind: 'process' as const,
572
612
  key,
573
613
  label: key,
574
- memberIds,
614
+ memberAliases,
575
615
  }));
576
616
  }
577
617
 
@@ -587,14 +627,14 @@ export function getSubsystemModuleRegions(
587
627
  const m = c.module?.trim();
588
628
  if (!m) continue;
589
629
  const list = byModule.get(m) ?? [];
590
- list.push(c.id);
630
+ list.push(c.alias);
591
631
  byModule.set(m, list);
592
632
  }
593
- return [...byModule.entries()].map(([key, memberIds]) => ({
633
+ return [...byModule.entries()].map(([key, memberAliases]) => ({
594
634
  kind: 'module' as const,
595
635
  key,
596
636
  label: key,
597
- memberIds,
637
+ memberAliases,
598
638
  }));
599
639
  }
600
640
 
@@ -611,14 +651,14 @@ export function getSubsystemPackageRegions(
611
651
  const key = componentPackageKey(c);
612
652
  if (!key) continue;
613
653
  const list = byPackage.get(key) ?? [];
614
- list.push(c.id);
654
+ list.push(c.alias);
615
655
  byPackage.set(key, list);
616
656
  }
617
- return [...byPackage.entries()].map(([key, memberIds]) => ({
657
+ return [...byPackage.entries()].map(([key, memberAliases]) => ({
618
658
  kind: 'package' as const,
619
659
  key,
620
660
  label: packageRegionLabel(key),
621
- memberIds,
661
+ memberAliases,
622
662
  }));
623
663
  }
624
664
 
@@ -628,7 +668,7 @@ export function getSubsystemPackageRegions(
628
668
  */
629
669
  export interface BoundaryLayoutGroup {
630
670
  id: string;
631
- memberIds: string[];
671
+ memberAliases: string[];
632
672
  /** When set, this group is a child of another boundary group. */
633
673
  parentId?: string;
634
674
  region: SubsystemProcessRegion;
@@ -638,7 +678,7 @@ function keepRegion(
638
678
  r: SubsystemProcessRegion,
639
679
  showSingletons: boolean,
640
680
  ): boolean {
641
- return showSingletons || r.memberIds.length >= 2;
681
+ return showSingletons || r.memberAliases.length >= 2;
642
682
  }
643
683
 
644
684
  /**
@@ -655,7 +695,7 @@ export function buildBoundaryLayoutGroups(
655
695
  ): BoundaryLayoutGroup[] {
656
696
  const showSingletons = opts.showSingletonFrames === true;
657
697
  const packageMode = opts.packageFrames ?? 'multi-repo';
658
- const byId = new Map(doc.components.map((c) => [c.id, c]));
698
+ const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
659
699
 
660
700
  const allPackages = getSubsystemPackageRegions(doc);
661
701
  const packageEligible =
@@ -680,8 +720,8 @@ export function buildBoundaryLayoutGroups(
680
720
  const moduleGroups: BoundaryLayoutGroup[] = modules.map((r) => {
681
721
  const processesOfMembers = new Set<string>();
682
722
  const packagesOfMembers = new Set<string>();
683
- for (const id of r.memberIds) {
684
- const c = byId.get(id);
723
+ for (const alias of r.memberAliases) {
724
+ const c = byAlias.get(alias);
685
725
  const p = c?.process?.trim();
686
726
  if (p) processesOfMembers.add(p);
687
727
  const pkg = c ? componentPackageKey(c) : undefined;
@@ -698,7 +738,7 @@ export function buildBoundaryLayoutGroups(
698
738
  }
699
739
  return {
700
740
  id: moduleGroupNodeId(r.key),
701
- memberIds: [...r.memberIds],
741
+ memberAliases: [...r.memberAliases],
702
742
  parentId,
703
743
  region: r,
704
744
  };
@@ -708,15 +748,15 @@ export function buildBoundaryLayoutGroups(
708
748
  const processId = processGroupNodeId(r.key);
709
749
  const nestedModules = moduleGroups.filter((m) => m.parentId === processId);
710
750
  const nestedModuleKeys = new Set(nestedModules.map((m) => m.region.key));
711
- const directLeaves = r.memberIds.filter((id) => {
712
- const mod = byId.get(id)?.module?.trim();
751
+ const directLeaves = r.memberAliases.filter((alias) => {
752
+ const mod = byAlias.get(alias)?.module?.trim();
713
753
  if (!mod) return true;
714
754
  return !nestedModuleKeys.has(mod);
715
755
  });
716
756
 
717
757
  const packagesOfMembers = new Set<string>();
718
- for (const id of r.memberIds) {
719
- const c = byId.get(id);
758
+ for (const alias of r.memberAliases) {
759
+ const c = byAlias.get(alias);
720
760
  const pkg = c ? componentPackageKey(c) : undefined;
721
761
  if (pkg) packagesOfMembers.add(pkg);
722
762
  }
@@ -728,7 +768,7 @@ export function buildBoundaryLayoutGroups(
728
768
 
729
769
  return {
730
770
  id: processId,
731
- memberIds: [...nestedModules.map((m) => m.id), ...directLeaves],
771
+ memberAliases: [...nestedModules.map((m) => m.id), ...directLeaves],
732
772
  parentId,
733
773
  region: r,
734
774
  };
@@ -740,15 +780,15 @@ export function buildBoundaryLayoutGroups(
740
780
  const nestedModules = moduleGroups.filter((m) => m.parentId === packageId);
741
781
  const claimed = new Set<string>();
742
782
  for (const p of nestedProcesses) {
743
- for (const id of p.region.memberIds) claimed.add(id);
783
+ for (const alias of p.region.memberAliases) claimed.add(alias);
744
784
  }
745
785
  for (const m of nestedModules) {
746
- for (const id of m.region.memberIds) claimed.add(id);
786
+ for (const alias of m.region.memberAliases) claimed.add(alias);
747
787
  }
748
- const directLeaves = r.memberIds.filter((id) => !claimed.has(id));
788
+ const directLeaves = r.memberAliases.filter((alias) => !claimed.has(alias));
749
789
  return {
750
790
  id: packageId,
751
- memberIds: [
791
+ memberAliases: [
752
792
  ...nestedProcesses.map((p) => p.id),
753
793
  ...nestedModules.map((m) => m.id),
754
794
  ...directLeaves,
@@ -1175,7 +1215,7 @@ export function convertSubsystemToNodes(
1175
1215
  ? processGroupNodeId(processKey)
1176
1216
  : undefined;
1177
1217
  nodes.push({
1178
- id: c.id,
1218
+ id: c.alias,
1179
1219
  type: 'subsystem-component',
1180
1220
  ...(parentId ? { parentId } : {}),
1181
1221
  position: { x: PAD + col * COL_W, y: cursorY + row * ROW_H },
@@ -1211,18 +1251,18 @@ export function convertSubsystemToGroups(
1211
1251
 
1212
1252
  /**
1213
1253
  * Convert a subsystem graph document into React Flow edges. Edges whose target
1214
- * is an external label (not a component id) point at a synthetic stub so the
1254
+ * is an external label (not a component alias) point at a synthetic stub so the
1215
1255
  * relationship is visible without a member node.
1216
1256
  */
1217
1257
  export function convertSubsystemToEdges(doc: SubsystemModelDocument): SubsystemGraphEdge[] {
1218
- const compIds = new Set(doc.components.map((c) => c.id));
1258
+ const compAliases = new Set(doc.components.map((c) => c.alias));
1219
1259
  const edges: SubsystemGraphEdge[] = [];
1220
1260
 
1221
1261
  for (const e of deriveGraphEdges(doc)) {
1222
1262
  const color = MECHANISM_COLOR[e.mechanism];
1223
1263
  const style = MECHANISM_STYLE[e.mechanism];
1224
1264
  // If `to` is a real component, connect directly; otherwise point at a stub node.
1225
- const isExternal = !compIds.has(e.to);
1265
+ const isExternal = !compAliases.has(e.to);
1226
1266
  const targetId = isExternal ? `external:${e.to}` : e.to;
1227
1267
 
1228
1268
  edges.push({
@@ -1248,8 +1288,8 @@ export function subsystemGraphLayoutKey(
1248
1288
  doc: Pick<SubsystemModelDocument, 'components' | 'relations' | 'walkthroughs'>,
1249
1289
  ): string {
1250
1290
  const components = doc.components
1251
- .map(({ id, purl, name, symbol, construct, file, purpose, process, module }) =>
1252
- [id, purl, name, symbol ?? '', construct, file, purpose ?? '', process ?? '', module ?? ''].join('\0'))
1291
+ .map(({ alias, purl, name, symbol, construct, file, purpose, process, module }) =>
1292
+ [alias, purl, name, symbol ?? '', construct, file, purpose ?? '', process ?? '', module ?? ''].join('\0'))
1253
1293
  .sort()
1254
1294
  .join('\n');
1255
1295
  const edgeKey = deriveGraphEdges(doc)
@@ -1292,7 +1332,7 @@ export async function buildSubsystemGraph(
1292
1332
  const layoutGroups = buildBoundaryLayoutGroups(doc, frameOpts);
1293
1333
  const regions = layoutGroups.map((g) => g.region);
1294
1334
  const regionIds = new Set(layoutGroups.map((g) => g.id));
1295
- const byId = new Map(doc.components.map((c) => [c.id, c]));
1335
+ const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
1296
1336
 
1297
1337
  // Resolve leaf parentIds against kept frames: module → process → package.
1298
1338
  // Stamp package parents here (not in convertSubsystemToNodes) so single-repo
@@ -1301,7 +1341,7 @@ export async function buildSubsystemGraph(
1301
1341
  if (n.type !== 'subsystem-component') continue;
1302
1342
  const parentId = (n as { parentId?: string }).parentId;
1303
1343
  if (parentId && regionIds.has(parentId)) continue;
1304
- const comp = (n.data as SubsystemGraphNodeData).component ?? byId.get(n.id);
1344
+ const comp = (n.data as SubsystemGraphNodeData).component ?? byAlias.get(n.id);
1305
1345
  const processKey = comp?.process?.trim();
1306
1346
  const processId = processKey ? processGroupNodeId(processKey) : undefined;
1307
1347
  if (processId && regionIds.has(processId)) {
@@ -1319,10 +1359,10 @@ export async function buildSubsystemGraph(
1319
1359
 
1320
1360
  // External edge targets that aren't real components → create stub nodes so
1321
1361
  // cross-package edges have something to land on.
1322
- const realIds = new Set(doc.components.map((c) => c.id));
1362
+ const realAliases = new Set(doc.components.map((c) => c.alias));
1323
1363
  const externalIds: string[] = [];
1324
1364
  for (const e of deriveGraphEdges(doc)) {
1325
- if (!realIds.has(e.to)) {
1365
+ if (!realAliases.has(e.to)) {
1326
1366
  const extId = `external:${e.to}`;
1327
1367
  if (!externalIds.includes(extId)) externalIds.push(extId);
1328
1368
  }
@@ -1338,7 +1378,7 @@ export async function buildSubsystemGraph(
1338
1378
  height: 60,
1339
1379
  data: {
1340
1380
  component: {
1341
- id: extId,
1381
+ alias: extId,
1342
1382
  name: label,
1343
1383
  construct: 'external',
1344
1384
  purl: 'external',
@@ -1382,7 +1422,7 @@ export async function buildSubsystemGraph(
1382
1422
  edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
1383
1423
  groups: layoutGroups.map((g) => ({
1384
1424
  id: g.id,
1385
- memberIds: g.memberIds,
1425
+ memberIds: g.memberAliases,
1386
1426
  parentId: g.parentId,
1387
1427
  minWidth: g.region.kind === 'module' ? moduleMinWidthForBadge(g.region.label) : undefined,
1388
1428
  })),
@@ -31,6 +31,8 @@ import {
31
31
  nodeMinWidthForBadges,
32
32
  MODULE_BADGE_INSET,
33
33
  moduleBadgeLabel,
34
+ moduleBadgeHoverLabel,
35
+ moduleBadgeWidth,
34
36
  packageColor,
35
37
  type SubsystemGraphNodeData,
36
38
  type SubsystemGroupNodeData,
@@ -67,14 +69,14 @@ function breakWords(s: string): string {
67
69
 
68
70
  export interface SubsystemGraphCallbacks {
69
71
  /** Click a component — open its file/entry point. */
70
- onSelect?: (componentId: string) => void;
72
+ onSelect?: (componentAlias: string) => void;
71
73
  /** Click the filename badge — open that component's file in the drawer
72
74
  * (same path as clicking the file link in the declaration panel). */
73
- onOpenFile?: (componentId: string) => void;
75
+ onOpenFile?: (componentAlias: string) => void;
74
76
  /** Click an edge (or its label) — select the relationship. */
75
77
  onEdgeSelect?: (edgeId: string) => void;
76
78
  /** Hover a component (null on leave) — associates it with the file tree. */
77
- onHover?: (componentId: string | null) => void;
79
+ onHover?: (componentAlias: string | null) => void;
78
80
  /** Upper bound for node width; nodes grow with content up to this, then wrap. */
79
81
  maxNodeWidth?: number;
80
82
  }
@@ -129,7 +131,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
129
131
  <div
130
132
  onMouseEnter={() => {
131
133
  setHover(true);
132
- SUBSYSTEM_CALLBACKS.onHover?.(c.id);
134
+ SUBSYSTEM_CALLBACKS.onHover?.(c.alias);
133
135
  }}
134
136
  onMouseLeave={() => {
135
137
  setHover(false);
@@ -137,7 +139,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
137
139
  }}
138
140
  onClick={(e) => {
139
141
  e.stopPropagation();
140
- SUBSYSTEM_CALLBACKS.onSelect?.(c.id);
142
+ SUBSYSTEM_CALLBACKS.onSelect?.(c.alias);
141
143
  }}
142
144
  style={{
143
145
  position: 'relative',
@@ -273,7 +275,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
273
275
  onClick={(e) => {
274
276
  // Open the file directly; don't also toggle node selection.
275
277
  e.stopPropagation();
276
- SUBSYSTEM_CALLBACKS.onOpenFile?.(c.id);
278
+ SUBSYSTEM_CALLBACKS.onOpenFile?.(c.alias);
277
279
  }}
278
280
  >
279
281
  {c.file.split('/').pop()}
@@ -356,13 +358,18 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
356
358
  const label = region?.label ?? '';
357
359
  const [expandedLabel, setExpandedLabel] = useState<string | null>(null);
358
360
  const isModule = region?.kind === 'module';
361
+ const isProcess = region?.kind === 'process';
359
362
  const expanded = isModule && expandedLabel === label;
360
363
  const availableBadgeWidth = Math.max(0, (width ?? 400) - MODULE_BADGE_INSET * 2 - 4);
361
364
  const collapsedLabel = isModule ? moduleBadgeLabel(label, availableBadgeWidth) : label;
362
- const canExpand = isModule && collapsedLabel !== label;
363
- // Process frames match external nodes — square corners (outside the soft
364
- // component language). Module / package frames keep the rounded look.
365
- const isProcess = region?.kind === 'process';
365
+ // A module badge is collapsible while something collapsed, and stays
366
+ // togglable while expanded (so the full path can collapse back).
367
+ const canToggle = isModule && (expanded || collapsedLabel !== label);
368
+ // Hover affords clickability by revealing more of the path — the badge
369
+ // widens to fit real text (never stretched), hinting before the click.
370
+ const [hover, setHover] = useState(false);
371
+ const hoveredLabel =
372
+ isModule && canToggle ? moduleBadgeHoverLabel(label, availableBadgeWidth) : label;
366
373
  const frameRadius = isProcess ? 0 : 12;
367
374
  const badgeRadius = isProcess ? 0 : 4;
368
375
  // Processes are deployment units — solid frame. Modules/packages stay dashed
@@ -391,16 +398,17 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
391
398
  >
392
399
  <div
393
400
  className={isModule ? 'nodrag nopan' : undefined}
394
- role={canExpand ? 'button' : undefined}
395
- tabIndex={canExpand && !hidden ? 0 : undefined}
396
- aria-expanded={canExpand ? expanded : undefined}
397
- aria-label={canExpand ? `${expanded ? 'Collapse' : 'Expand'} module path: ${label}` : undefined}
398
- title={isModule ? label : undefined}
399
- onClick={canExpand ? (event) => {
401
+ role={canToggle ? 'button' : undefined}
402
+ tabIndex={canToggle && !hidden ? 0 : undefined}
403
+ aria-expanded={canToggle ? expanded : undefined}
404
+ aria-label={canToggle ? `${expanded ? 'Collapse' : 'Expand'} module path: ${label}` : undefined}
405
+ onMouseEnter={canToggle ? () => setHover(true) : undefined}
406
+ onMouseLeave={canToggle ? () => setHover(false) : undefined}
407
+ onClick={canToggle ? (event) => {
400
408
  event.stopPropagation();
401
409
  setExpandedLabel(expanded ? null : label);
402
410
  } : undefined}
403
- onKeyDown={canExpand ? (event) => {
411
+ onKeyDown={canToggle ? (event) => {
404
412
  if (event.key === 'Enter' || event.key === ' ') {
405
413
  event.preventDefault();
406
414
  event.stopPropagation();
@@ -420,6 +428,18 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
420
428
  top: isProcess ? 0 : -19,
421
429
  left: isProcess ? '50%' : 12,
422
430
  transform: isProcess ? 'translateX(-50%)' : 'none',
431
+ zIndex: canToggle ? 10 : undefined,
432
+ // Width is explicit while collapsible so hover-reveal of more path
433
+ // text animates; the cap keeps both states inside the frame.
434
+ width:
435
+ isModule && canToggle && !expanded
436
+ ? hover
437
+ ? Math.min(availableBadgeWidth, moduleBadgeWidth(hoveredLabel))
438
+ : Math.min(availableBadgeWidth, moduleBadgeWidth(collapsedLabel))
439
+ : undefined,
440
+ transition: 'width 140ms ease, box-shadow 120ms ease',
441
+ cursor: canToggle ? 'pointer' : undefined,
442
+ boxShadow: canToggle && hover ? `0 2px 10px ${color}55` : undefined,
423
443
  fontFamily: theme.fonts.monospace,
424
444
  fontSize: theme.fontSizes[3],
425
445
  fontWeight: 700,
@@ -431,9 +451,28 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
431
451
  borderRadius: badgeRadius,
432
452
  padding: '1px 7px',
433
453
  whiteSpace: 'nowrap',
454
+ overflow: 'hidden',
455
+ textOverflow: 'ellipsis',
456
+ boxSizing: 'border-box',
457
+ pointerEvents: hidden ? 'none' : 'auto',
458
+ ...(isModule
459
+ ? {
460
+ // Cap the collapsed badge at the frame's inner width so it
461
+ // never spills past the module — it gets the `…`/click-to-
462
+ // expand treatment instead. Expanding lifts the cap so the
463
+ // full path grows the badge past the frame's edge.
464
+ maxWidth: expanded ? undefined : availableBadgeWidth,
465
+ }
466
+ : undefined),
434
467
  }}
435
468
  >
436
- {label}
469
+ {canToggle ? (
470
+ <span style={{ whiteSpace: 'nowrap' }}>
471
+ {expanded ? label : hover ? hoveredLabel : collapsedLabel}
472
+ </span>
473
+ ) : (
474
+ label
475
+ )}
437
476
  </div>
438
477
  </div>
439
478
  );
@@ -5,7 +5,7 @@
5
5
  * by its `purl` (`pkg:github/<owner>/<name>[#<path>]`) — never to the machine.
6
6
  * When a graph spans several repos the sidebar tree needs synthetic
7
7
  * `<owner>/<name>/` prefixes to keep same-named paths apart; hosts resolve
8
- * each file against `repoRoots[purlRepo] ?? repoRoot`.
8
+ * each file against its own purl's checkout (via the Alexandria registry).
9
9
  */
10
10
 
11
11
  /** Normalize a purl to its repo key: fragment and surrounding whitespace stripped. */