@principal-ai/subsystems-react 0.42.0 → 0.43.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 (81) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +1 -1
  6. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -1
  7. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +6 -9
  8. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  9. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +15 -50
  10. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  11. package/dist/subsystem/C4Graph.d.ts +2 -3
  12. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  13. package/dist/subsystem/C4Graph.js +2 -3
  14. package/dist/subsystem/C4Graph.js.map +1 -1
  15. package/dist/subsystem/IssueList.d.ts +5 -5
  16. package/dist/subsystem/IssueList.d.ts.map +1 -1
  17. package/dist/subsystem/IssueList.js +9 -18
  18. package/dist/subsystem/IssueList.js.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.d.ts +16 -12
  20. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  21. package/dist/subsystem/SubsystemComponentGraph.js +99 -113
  22. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  23. package/dist/subsystem/SubsystemFileTree.d.ts +5 -5
  24. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
  25. package/dist/subsystem/SubsystemFileTree.js +13 -49
  26. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  27. package/dist/subsystem/model.d.ts +67 -53
  28. package/dist/subsystem/model.d.ts.map +1 -1
  29. package/dist/subsystem/model.js +305 -55
  30. package/dist/subsystem/model.js.map +1 -1
  31. package/dist/subsystem/nodes.d.ts.map +1 -1
  32. package/dist/subsystem/nodes.js +11 -4
  33. package/dist/subsystem/nodes.js.map +1 -1
  34. package/dist/subsystem/toC4.d.ts +4 -4
  35. package/dist/subsystem/toC4.d.ts.map +1 -1
  36. package/dist/subsystem/toC4.js +3 -7
  37. package/dist/subsystem/toC4.js.map +1 -1
  38. package/dist/utils/elkLayout.d.ts.map +1 -1
  39. package/dist/utils/elkLayout.js +7 -0
  40. package/dist/utils/elkLayout.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/index.ts +0 -4
  43. package/src/stories/Subsystem/AgentsPanel.stories.tsx +3 -5
  44. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -4
  45. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +1 -1
  46. package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +2 -4
  47. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +8 -8
  48. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +19 -19
  49. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +0 -1
  50. package/src/stories/Subsystem/ComponentGraph/CustomEntities.stories.tsx +3 -3
  51. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +0 -2
  52. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +2 -6
  53. package/src/stories/Subsystem/ComponentGraph/EdgeViews.stories.tsx +21 -21
  54. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +0 -3
  55. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +30 -32
  56. package/src/stories/Subsystem/ComponentGraph/GraphTitle.stories.tsx +4 -4
  57. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -26
  58. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +3 -25
  59. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +7 -8
  60. package/src/stories/Subsystem/ComponentGraph/ModulePathNesting.stories.tsx +116 -0
  61. package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +9 -11
  62. package/src/stories/Subsystem/ComponentGraph/Packages.stories.tsx +3 -6
  63. package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +3 -3
  64. package/src/stories/Subsystem/ComponentGraph/Proposed.stories.tsx +8 -6
  65. package/src/stories/Subsystem/ComponentGraph/ProposedWalkthroughs.stories.tsx +0 -1
  66. package/src/stories/Subsystem/ComponentGraph/Reorder.stories.tsx +0 -1
  67. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +26 -28
  68. package/src/stories/Subsystem/ComponentGraph/WalkthroughAutoplay.stories.tsx +0 -1
  69. package/src/stories/Subsystem/ComponentGraph/WorkspacePackageFrames.stories.tsx +2 -3
  70. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +18 -58
  71. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +0 -2
  72. package/src/subsystem/C4Graph.tsx +2 -3
  73. package/src/subsystem/IssueList.tsx +12 -22
  74. package/src/subsystem/SubsystemComponentGraph.tsx +117 -135
  75. package/src/subsystem/SubsystemFileTree.tsx +17 -50
  76. package/src/subsystem/model.test.ts +155 -24
  77. package/src/subsystem/model.ts +352 -108
  78. package/src/subsystem/nodes.tsx +13 -3
  79. package/src/subsystem/toC4.test.ts +5 -12
  80. package/src/subsystem/toC4.ts +5 -9
  81. package/src/utils/elkLayout.ts +10 -0
@@ -15,14 +15,10 @@ import { MarkerType, } from '@xyflow/react';
15
15
  import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
16
16
  import { EDGE_LABEL_SIDE_PADDING, EDGE_ARROW_INSET } from '../utils/edgeLabel';
17
17
  import { purlOwnerName, purlRepoKey } from './paths';
18
- /** Stable id for a derived graph edge from a relation or walkthrough hop. */
18
+ /** Stable id for a derived graph edge from a walkthrough hop. */
19
19
  export function derivedGraphEdgeId(from, to, mechanism) {
20
20
  return `${from}--${mechanism}-->${to}`;
21
21
  }
22
- /**
23
- * Build display edges for the graph canvas from topology relations and
24
- * walkthrough hops (deduped by from/to/mechanism).
25
- */
26
22
  /** React Flow / canvas edge id for a walkthrough hop. */
27
23
  export function walkthroughStepGraphEdgeId(step) {
28
24
  return derivedGraphEdgeId(step.from, step.to, step.mechanism);
@@ -57,18 +53,6 @@ export function reorderTargetIndex(boundary, from) {
57
53
  }
58
54
  export function deriveGraphEdges(doc) {
59
55
  const byId = new Map();
60
- for (const r of doc.relations ?? []) {
61
- const id = r.id || derivedGraphEdgeId(r.from, r.to, r.relationType);
62
- if (!byId.has(id)) {
63
- byId.set(id, {
64
- id,
65
- from: r.from,
66
- to: r.to,
67
- mechanism: r.relationType,
68
- refs: r.refs,
69
- });
70
- }
71
- }
72
56
  for (const w of doc.walkthroughs ?? []) {
73
57
  for (const step of w.steps) {
74
58
  const id = derivedGraphEdgeId(step.from, step.to, step.mechanism);
@@ -101,14 +85,20 @@ export function deriveGraphEdges(doc) {
101
85
  return [...byId.values()];
102
86
  }
103
87
  /**
104
- * True when the model has components but no topology or walkthrough edges.
105
- * Those snapshots are a catalog of declarations, not a graph.
88
+ * True when the model has components but no graph structure at all: no
89
+ * walkthrough/graphify edges AND no boundary containment (`module` / `process`)
90
+ * to frame. Those snapshots are a catalog of declarations, not a graph.
91
+ *
92
+ * A model that carries `module` / `process` membership still draws frames even
93
+ * with zero edges — that is static topology, so it is not constructs-only.
106
94
  */
107
95
  export function isConstructsOnlyModel(doc) {
108
96
  if (doc.components.length === 0)
109
97
  return false;
98
+ const hasBoundary = doc.components.some((c) => (c.module?.trim() ?? '') !== '' || (c.process?.trim() ?? '') !== '');
99
+ if (hasBoundary)
100
+ return false;
110
101
  return deriveGraphEdges({
111
- relations: doc.relations,
112
102
  walkthroughs: doc.walkthroughs,
113
103
  graphifyRelations: doc.graphifyRelations,
114
104
  }).length === 0;
@@ -216,6 +206,20 @@ export function processGroupNodeId(processKey) {
216
206
  export function moduleGroupNodeId(moduleKey) {
217
207
  return `module:${moduleKey}`;
218
208
  }
209
+ /** React Flow id for a directory boundary group node (path-derived nesting). */
210
+ export function directoryGroupNodeId(dirKey) {
211
+ return `directory:${dirKey}`;
212
+ }
213
+ /** Normalize a module path to forward slashes, no trailing slash or `./`. */
214
+ function normalizeModulePath(p) {
215
+ return p.trim().replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\.\//, '');
216
+ }
217
+ /** Parent directory of a path-like key, or `''` when it has no slash. */
218
+ function parentDirectory(path) {
219
+ const norm = normalizeModulePath(path);
220
+ const idx = norm.lastIndexOf('/');
221
+ return idx <= 0 ? '' : norm.slice(0, idx);
222
+ }
219
223
  export const MODULE_BADGE_INSET = 12;
220
224
  const MODULE_BADGE_CHAR_WIDTH = 12.5;
221
225
  const MODULE_BADGE_CHROME = 16;
@@ -284,6 +288,8 @@ export function packageGroupNodeId(packageKey) {
284
288
  export function boundaryGroupNodeId(region) {
285
289
  if (region.kind === 'module')
286
290
  return moduleGroupNodeId(region.key);
291
+ if (region.kind === 'directory')
292
+ return directoryGroupNodeId(region.key);
287
293
  if (region.kind === 'package')
288
294
  return packageGroupNodeId(region.key);
289
295
  return processGroupNodeId(region.key);
@@ -379,6 +385,9 @@ function keepRegion(r, showSingletons) {
379
385
  * processes nest under a package the same way.
380
386
  */
381
387
  export function buildBoundaryLayoutGroups(doc, opts = {}) {
388
+ if (opts.moduleNesting === 'path') {
389
+ return buildBoundaryLayoutGroupsByPath(doc, opts);
390
+ }
382
391
  const showSingletons = opts.showSingletonFrames === true;
383
392
  const packageMode = opts.packageFrames ?? 'multi-repo';
384
393
  const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
@@ -517,17 +526,246 @@ export function buildBoundaryLayoutGroups(doc, opts = {}) {
517
526
  return [...moduleGroups, ...processGroups, ...packageGroups];
518
527
  }
519
528
  /**
520
- * Runtime vocabulary of relation types — mirrors `SubsystemRelationType`.
521
- * Used to split derived display edges into their relation vs walkthrough
522
- * source (the two unions are disjoint).
529
+ * Path-nesting variant of {@link buildBoundaryLayoutGroups}.
530
+ *
531
+ * Adds directory frames derived from each component's `module` path segments
532
+ * (`src/session/transcript.ts` → `src` → `src/session` → module frame) and
533
+ * nests module frames under the deepest directory that contains them. Directory
534
+ * frames are kept when they hold 2+ leaves (or `showSingletonFrames`). Process
535
+ * and package frames nest above directories exactly as before.
536
+ *
537
+ * Module frames are unchanged (one per exact `module` string); leaves still
538
+ * parent to their module frame in `convertSubsystemToNodes`.
523
539
  */
524
- export const SUBSYSTEM_RELATION_TYPES = [
525
- 'extends',
526
- 'inherits',
527
- 'implements',
528
- 'mixes_in',
529
- 'method',
530
- ];
540
+ export function buildBoundaryLayoutGroupsByPath(doc, opts = {}) {
541
+ const showSingletons = opts.showSingletonFrames === true;
542
+ const packageMode = opts.packageFrames ?? 'multi-repo';
543
+ const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
544
+ const allPackages = getSubsystemPackageRegions(doc);
545
+ const packageEligible = packageMode === 'always'
546
+ ? true
547
+ : packageMode === 'never'
548
+ ? false
549
+ : allPackages.length >= 2;
550
+ const packages = packageEligible
551
+ ? allPackages.filter((r) => keepRegion(r, showSingletons))
552
+ : [];
553
+ const keptPackageKeys = new Set(packages.map((r) => r.key));
554
+ const processes = getSubsystemRegions(doc).filter((r) => keepRegion(r, showSingletons));
555
+ const keptProcessKeys = new Set(processes.map((r) => r.key));
556
+ const modules = getSubsystemModuleRegions(doc).filter((r) => keepRegion(r, showSingletons));
557
+ // Directory regions: every ancestor directory of a component's module path,
558
+ // with the union of all aliases beneath it.
559
+ const dirMembers = new Map();
560
+ for (const c of doc.components) {
561
+ const m = c.module?.trim();
562
+ if (!m)
563
+ continue;
564
+ let d = parentDirectory(m);
565
+ while (d) {
566
+ const set = dirMembers.get(d) ?? new Set();
567
+ set.add(c.alias);
568
+ dirMembers.set(d, set);
569
+ d = parentDirectory(d);
570
+ }
571
+ }
572
+ const keptDirKeys = new Set();
573
+ for (const [d, members] of dirMembers) {
574
+ if (members.size >= 2 || showSingletons)
575
+ keptDirKeys.add(d);
576
+ }
577
+ // Files sitting directly in each directory (drives chain compaction below).
578
+ const directFileCount = new Map();
579
+ for (const c of doc.components) {
580
+ const m = c.module?.trim();
581
+ if (!m)
582
+ continue;
583
+ const d = parentDirectory(m);
584
+ if (d)
585
+ directFileCount.set(d, (directFileCount.get(d) ?? 0) + 1);
586
+ }
587
+ // Compact single-child directory chains, mirroring the file tree: a directory
588
+ // whose only child is a single subdirectory (and which holds no files
589
+ // directly) is merged into it. `app` → `app/book` renders one `app/book`
590
+ // frame, not `app` wrapping `book`.
591
+ let compacting = true;
592
+ while (compacting) {
593
+ compacting = false;
594
+ const childDirsOf = new Map();
595
+ for (const d of keptDirKeys) {
596
+ let a = parentDirectory(d);
597
+ while (a && !keptDirKeys.has(a))
598
+ a = parentDirectory(a);
599
+ if (!a)
600
+ continue;
601
+ const list = childDirsOf.get(a) ?? [];
602
+ list.push(d);
603
+ childDirsOf.set(a, list);
604
+ }
605
+ for (const d of [...keptDirKeys]) {
606
+ const childDirs = childDirsOf.get(d) ?? [];
607
+ if (childDirs.length === 1 && (directFileCount.get(d) ?? 0) === 0) {
608
+ keptDirKeys.delete(d);
609
+ compacting = true;
610
+ }
611
+ }
612
+ }
613
+ const nearestKeptDirAncestor = (dirKey) => {
614
+ let p = parentDirectory(dirKey);
615
+ while (p) {
616
+ if (keptDirKeys.has(p))
617
+ return p;
618
+ p = parentDirectory(p);
619
+ }
620
+ return undefined;
621
+ };
622
+ const dirRegions = [...keptDirKeys]
623
+ .sort()
624
+ .map((key) => {
625
+ // Label is the path relative to the enclosing kept directory (which
626
+ // already carries the prefix): `book` under `app`, or the full collapsed
627
+ // path `app/book` when the chain was compacted.
628
+ const ancestor = nearestKeptDirAncestor(key);
629
+ return {
630
+ kind: 'directory',
631
+ key,
632
+ label: ancestor ? key.slice(ancestor.length + 1) : key,
633
+ memberAliases: [...(dirMembers.get(key) ?? [])],
634
+ };
635
+ });
636
+ const moduleDirParent = (moduleKey) => {
637
+ let d = parentDirectory(moduleKey);
638
+ while (d) {
639
+ if (keptDirKeys.has(d))
640
+ return d;
641
+ d = parentDirectory(d);
642
+ }
643
+ return undefined;
644
+ };
645
+ const sharedProcessParent = (memberAliases) => {
646
+ const seen = new Set();
647
+ for (const alias of memberAliases) {
648
+ const p = byAlias.get(alias)?.process?.trim();
649
+ if (p)
650
+ seen.add(p);
651
+ }
652
+ if (seen.size !== 1)
653
+ return undefined;
654
+ const p = [...seen][0];
655
+ return keptProcessKeys.has(p) ? processGroupNodeId(p) : undefined;
656
+ };
657
+ const sharedPackageParent = (memberAliases) => {
658
+ const seen = new Set();
659
+ for (const alias of memberAliases) {
660
+ const c = byAlias.get(alias);
661
+ const pkg = c ? componentPackageKey(c) : undefined;
662
+ if (pkg)
663
+ seen.add(pkg);
664
+ }
665
+ if (seen.size !== 1)
666
+ return undefined;
667
+ const pkg = [...seen][0];
668
+ return keptPackageKeys.has(pkg) ? packageGroupNodeId(pkg) : undefined;
669
+ };
670
+ const frameParent = (memberAliases) => sharedProcessParent(memberAliases) ?? sharedPackageParent(memberAliases);
671
+ const moduleGroups = modules.map((r) => {
672
+ const dirParent = moduleDirParent(r.key);
673
+ const parentId = dirParent
674
+ ? directoryGroupNodeId(dirParent)
675
+ : frameParent(r.memberAliases);
676
+ // Relative label when nested: `app/book/actions.ts` under `app/book`
677
+ // reads as `actions.ts`. At the root the full module path stays.
678
+ const label = dirParent ? r.key.slice(dirParent.length + 1) : r.label;
679
+ return {
680
+ id: moduleGroupNodeId(r.key),
681
+ memberAliases: [...r.memberAliases],
682
+ parentId,
683
+ region: { ...r, label },
684
+ };
685
+ });
686
+ const dirGroupByKey = new Map(dirRegions.map((r) => [r.key, r]));
687
+ const moduleRegionByKey = new Map(modules.map((r) => [r.key, r]));
688
+ const dirGroups = dirRegions.map((r) => {
689
+ const id = directoryGroupNodeId(r.key);
690
+ const ancestor = nearestKeptDirAncestor(r.key);
691
+ const parentId = ancestor
692
+ ? directoryGroupNodeId(ancestor)
693
+ : frameParent(r.memberAliases);
694
+ // Immediate children: subdirectories and module frames parented here.
695
+ // Only child *group ids* go in the layout member list — a group's
696
+ // descendant leaves belong to its child groups alone, or ELK sees the
697
+ // same leaf in two compound parents and rejects the layout.
698
+ const childDirKeys = dirRegions
699
+ .filter((d) => nearestKeptDirAncestor(d.key) === r.key)
700
+ .map((d) => d.key);
701
+ const childModuleKeys = modules
702
+ .filter((m) => moduleDirParent(m.key) === r.key)
703
+ .map((m) => m.key);
704
+ const claimed = new Set();
705
+ for (const dk of childDirKeys) {
706
+ for (const a of dirGroupByKey.get(dk)?.memberAliases ?? [])
707
+ claimed.add(a);
708
+ }
709
+ for (const mk of childModuleKeys) {
710
+ for (const a of moduleRegionByKey.get(mk)?.memberAliases ?? [])
711
+ claimed.add(a);
712
+ }
713
+ const directLeaves = r.memberAliases.filter((a) => !claimed.has(a));
714
+ return {
715
+ id,
716
+ memberAliases: [
717
+ ...childDirKeys.map(directoryGroupNodeId),
718
+ ...childModuleKeys.map(moduleGroupNodeId),
719
+ ...directLeaves,
720
+ ],
721
+ parentId,
722
+ region: r,
723
+ };
724
+ });
725
+ const frameGroups = [...dirGroups, ...moduleGroups];
726
+ // Aliases owned by a module frame (singleton or not) must not become direct
727
+ // leaves of an enclosing process/package frame.
728
+ const moduleMemberAliases = new Set();
729
+ for (const r of modules)
730
+ for (const a of r.memberAliases)
731
+ moduleMemberAliases.add(a);
732
+ const processGroups = processes.map((r) => {
733
+ const processId = processGroupNodeId(r.key);
734
+ const nested = frameGroups.filter((g) => g.parentId === processId);
735
+ const claimed = new Set();
736
+ for (const g of nested)
737
+ for (const a of g.region.memberAliases)
738
+ claimed.add(a);
739
+ const directLeaves = r.memberAliases.filter((alias) => !claimed.has(alias) && !moduleMemberAliases.has(alias));
740
+ return {
741
+ id: processId,
742
+ memberAliases: [...nested.map((g) => g.id), ...directLeaves],
743
+ parentId: sharedPackageParent(r.memberAliases),
744
+ region: r,
745
+ };
746
+ });
747
+ const processMemberAliases = new Set();
748
+ for (const r of processes)
749
+ for (const a of r.memberAliases)
750
+ processMemberAliases.add(a);
751
+ const packageGroups = packages.map((r) => {
752
+ const packageId = packageGroupNodeId(r.key);
753
+ const nested = [...processGroups, ...frameGroups].filter((g) => g.parentId === packageId);
754
+ const claimed = new Set();
755
+ for (const g of nested)
756
+ for (const a of g.region.memberAliases)
757
+ claimed.add(a);
758
+ const directLeaves = r.memberAliases.filter((alias) => !claimed.has(alias) &&
759
+ !moduleMemberAliases.has(alias) &&
760
+ !processMemberAliases.has(alias));
761
+ return {
762
+ id: packageId,
763
+ memberAliases: [...nested.map((g) => g.id), ...directLeaves],
764
+ region: r,
765
+ };
766
+ });
767
+ return [...frameGroups, ...processGroups, ...packageGroups];
768
+ }
531
769
  /** Runtime vocabulary of walkthrough hop mechanisms — mirrors `SubsystemWalkthroughMechanism`. */
532
770
  export const SUBSYSTEM_WALKTHROUGH_MECHANISMS = [
533
771
  'calls',
@@ -539,24 +777,14 @@ export const SUBSYSTEM_WALKTHROUGH_MECHANISMS = [
539
777
  'watches',
540
778
  'registers-into',
541
779
  ];
542
- const RELATION_TYPE_SET = new Set(SUBSYSTEM_RELATION_TYPES);
543
780
  const WALKTHROUGH_MECHANISM_SET = new Set(SUBSYSTEM_WALKTHROUGH_MECHANISMS);
544
- /** True when a mechanism belongs to the topology relation vocabulary. */
545
- export function isRelationMechanism(mechanism) {
546
- return RELATION_TYPE_SET.has(mechanism);
547
- }
548
781
  /** True when a mechanism belongs to the walkthrough hop vocabulary. */
549
782
  export function isWalkthroughMechanism(mechanism) {
550
783
  return WALKTHROUGH_MECHANISM_SET.has(mechanism);
551
784
  }
552
785
  export const MECHANISM_COLOR = {
553
786
  calls: '#22c55e',
554
- extends: '#b48ead',
555
- inherits: '#9b6fd0',
556
- implements: '#c586c0',
557
- mixes_in: '#d474a8',
558
787
  uses: '#e3b341',
559
- method: '#c586c0',
560
788
  feeds: '#4ec9b0',
561
789
  produces: '#a78bfa',
562
790
  writes: '#e8853a',
@@ -566,12 +794,7 @@ export const MECHANISM_COLOR = {
566
794
  };
567
795
  export const MECHANISM_STYLE = {
568
796
  calls: 'solid',
569
- extends: 'dashed',
570
- inherits: 'dashed',
571
- implements: 'dashed',
572
- mixes_in: 'dashed',
573
797
  uses: 'solid',
574
- method: 'solid',
575
798
  feeds: 'solid',
576
799
  produces: 'solid',
577
800
  writes: 'solid',
@@ -718,12 +941,7 @@ export function boundaryFill(color, alpha = '1f') {
718
941
  * directly verifiable" styling of edge labels. */
719
942
  export const MECHANISM_DESCRIPTIONS = [
720
943
  ['calls', 'function/method call (call graph edge)', true],
721
- ['extends', 'class inheritance', true],
722
- ['inherits', 'class inheritance', true],
723
- ['implements', 'implements interface / protocol', true],
724
- ['mixes_in', 'applies mixin', true],
725
944
  ['uses', 'general dependency (import, call, or reference)', false],
726
- ['method', 'structural: has method / member', true],
727
945
  ['feeds', 'data flow: output feeds into input', false],
728
946
  ['produces', 'data flow: produces / outputs', false],
729
947
  ['writes', 'state access: mutates retained state', true],
@@ -731,6 +949,29 @@ export const MECHANISM_DESCRIPTIONS = [
731
949
  ['watches', 'observes retained state without owning it', false],
732
950
  ['registers-into', 'registration pattern', false],
733
951
  ];
952
+ /**
953
+ * Single border/badge color for directory (folder) frames. Folders read as one
954
+ * kind of container, so they share a neutral hue rather than hashing per-path
955
+ * like modules / processes / packages. Host `boundaryColors` still overrides.
956
+ */
957
+ export const FOLDER_FRAME_COLOR = '#7aa2d4';
958
+ /**
959
+ * True when a boundary frame names a folder. `directory` frames always are.
960
+ * A `module` frame is a folder when its key looks like a directory path — the
961
+ * last segment has no file extension (the model has no explicit file/folder
962
+ * flag, so this is a heuristic; `Dockerfile`-style extensionless files read as
963
+ * folders).
964
+ */
965
+ export function isFolderFrame(region) {
966
+ if (!region)
967
+ return false;
968
+ if (region.kind === 'directory')
969
+ return true;
970
+ if (region.kind !== 'module')
971
+ return false;
972
+ const last = region.key.split('/').pop() ?? '';
973
+ return last !== '' && !last.includes('.');
974
+ }
734
975
  /** Package color palette (derived deterministically from the package name). */
735
976
  export function packageColor(name) {
736
977
  const palette = [
@@ -1041,8 +1282,12 @@ export function subsystemGraphLayoutKey(doc) {
1041
1282
  * layered with minimized crossings.
1042
1283
  */
1043
1284
  export async function buildSubsystemGraph(doc, opts = {}) {
1044
- const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames, packageFrames, graphifyRelations, orderByLine = false, } = opts;
1045
- const frameOpts = { showSingletonFrames, packageFrames };
1285
+ const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames, packageFrames, moduleNesting, graphifyRelations, orderByLine = false, } = opts;
1286
+ const frameOpts = {
1287
+ showSingletonFrames,
1288
+ packageFrames,
1289
+ moduleNesting,
1290
+ };
1046
1291
  const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
1047
1292
  const edges = convertSubsystemToEdges(doc, graphifyRelations);
1048
1293
  // Nested boundary tree: package → process → module → leaves.
@@ -1133,7 +1378,12 @@ export async function buildSubsystemGraph(doc, opts = {}) {
1133
1378
  try {
1134
1379
  const result = await computeElkLayout(nodes, edges, {
1135
1380
  routingStyle: 'orthogonal',
1136
- direction: 'RIGHT',
1381
+ // Edge-driven graphs flow left-to-right. With no edges (pure
1382
+ // containment, e.g. the static-topology layer) a RIGHT layout stacks
1383
+ // disconnected top-level frames in one vertical column; DOWN makes the
1384
+ // layered pass place those sibling frames along the horizontal axis
1385
+ // while each frame's members stack vertically.
1386
+ direction: edges.length > 0 ? 'RIGHT' : 'DOWN',
1137
1387
  nodeSpacing: 60,
1138
1388
  edgeSpacing: 30,
1139
1389
  edgeNodeSpacing: 60,
@@ -1153,7 +1403,7 @@ export async function buildSubsystemGraph(doc, opts = {}) {
1153
1403
  // Modules collapse a long path to `first/…/last`, so they only need
1154
1404
  // room for the collapsed badge. Processes/packages never collapse:
1155
1405
  // size the frame to hold the full centered label plus padding.
1156
- minWidth: g.region.kind === 'module'
1406
+ minWidth: g.region.kind === 'module' || g.region.kind === 'directory'
1157
1407
  ? moduleMinWidthForBadge(g.region.label)
1158
1408
  : boundaryMinWidthForBadge(g.region.label),
1159
1409
  })),
@@ -1221,7 +1471,7 @@ export async function buildSubsystemGraph(doc, opts = {}) {
1221
1471
  id: g.id,
1222
1472
  type: 'subsystem-group',
1223
1473
  position: { x: 0, y: 0 },
1224
- width: Math.max(400, g.region.kind === 'module'
1474
+ width: Math.max(400, g.region.kind === 'module' || g.region.kind === 'directory'
1225
1475
  ? moduleMinWidthForBadge(g.region.label)
1226
1476
  : boundaryMinWidthForBadge(g.region.label)),
1227
1477
  height: 300,