@principal-ai/subsystems-react 0.23.0 → 0.23.2

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 (35) hide show
  1. package/dist/index.d.ts +1 -1
  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/pierre/PierreWalkthroughCodeView.d.ts +2 -1
  6. package/dist/pierre/PierreWalkthroughCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreWalkthroughCodeView.js +4 -4
  8. package/dist/pierre/PierreWalkthroughCodeView.js.map +1 -1
  9. package/dist/pierre/constructColors.d.ts +2 -3
  10. package/dist/pierre/constructColors.d.ts.map +1 -1
  11. package/dist/pierre/constructColors.js +31 -21
  12. package/dist/pierre/constructColors.js.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts +1 -1
  14. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  15. package/dist/subsystem/SubsystemComponentGraph.js +8 -6
  16. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  17. package/dist/subsystem/model.d.ts +69 -23
  18. package/dist/subsystem/model.d.ts.map +1 -1
  19. package/dist/subsystem/model.js +184 -40
  20. package/dist/subsystem/model.js.map +1 -1
  21. package/dist/subsystem/nodes.d.ts.map +1 -1
  22. package/dist/subsystem/nodes.js +53 -25
  23. package/dist/subsystem/nodes.js.map +1 -1
  24. package/package.json +3 -3
  25. package/src/index.ts +2 -0
  26. package/src/pierre/PierreWalkthroughCodeView.tsx +12 -5
  27. package/src/pierre/constructColors.test.ts +2 -2
  28. package/src/pierre/constructColors.ts +41 -23
  29. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +2 -1
  30. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +1 -0
  31. package/src/stories/Subsystem/ComponentGraph/Packages.stories.tsx +242 -0
  32. package/src/subsystem/SubsystemComponentGraph.tsx +23 -7
  33. package/src/subsystem/model.test.ts +167 -0
  34. package/src/subsystem/model.ts +236 -46
  35. package/src/subsystem/nodes.tsx +55 -26
@@ -20,6 +20,7 @@ import {
20
20
  import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
21
21
  import type { GraphifyComponentDetail } from '../graphify';
22
22
  import type { SubsystemDeclarationRef } from './declarationRef';
23
+ import { purlOwnerName, purlRepoKey } from './paths';
23
24
 
24
25
  /** Structured declaration shape — same union as graphify drill-down payloads. */
25
26
  export type SubsystemConstructDeclaration = GraphifyComponentDetail;
@@ -430,13 +431,15 @@ export function formatPurl(purl: string): string {
430
431
  export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
431
432
 
432
433
  /**
433
- * One boundary region — either a process (runtime deployment unit) or a
434
- * module (source file). Nodes without that field sit outside those frames.
434
+ * One boundary region — process (runtime), module (source file), or package
435
+ * (repo identity from `purl`). Nodes without that membership sit outside
436
+ * those frames. Package frames are derived from `purl` (no separate field);
437
+ * they only appear when the graph spans multiple repos (by default).
435
438
  */
436
439
  export interface SubsystemProcessRegion {
437
- /** Discriminator — which field produced this region. */
438
- kind: 'process' | 'module';
439
- /** The `process` or `module` value (e.g. `principal-studio/host`, `src/a.ts`). */
440
+ /** Discriminator — which field / identity produced this region. */
441
+ kind: 'process' | 'module' | 'package';
442
+ /** The `process` / `module` value, or purl repo key for packages. */
440
443
  key: string;
441
444
  /** Display label for the boundary frame. */
442
445
  label: string;
@@ -444,6 +447,22 @@ export interface SubsystemProcessRegion {
444
447
  memberIds: string[];
445
448
  }
446
449
 
450
+ /** Options for which boundary frames are kept. */
451
+ export interface BoundaryFrameOptions {
452
+ /**
453
+ * Keep 1-member process / module / package frames. Default false
454
+ * (singleton rule — frames need 2+ members).
455
+ */
456
+ showSingletonFrames?: boolean;
457
+ /**
458
+ * When package frames are drawn from component `purl`s.
459
+ * - `multi-repo` (default): only when 2+ distinct repo purls
460
+ * - `always`: frame every multi-member package even in a single-repo graph
461
+ * - `never`: no package frames
462
+ */
463
+ packageFrames?: 'multi-repo' | 'always' | 'never';
464
+ }
465
+
447
466
  /** React Flow id for a process boundary group node. */
448
467
  export function processGroupNodeId(processKey: string): string {
449
468
  return `process:${processKey}`;
@@ -454,11 +473,33 @@ export function moduleGroupNodeId(moduleKey: string): string {
454
473
  return `module:${moduleKey}`;
455
474
  }
456
475
 
457
- /** React Flow id for a boundary group of either kind. */
476
+ /** React Flow id for a package (repo) boundary group node. */
477
+ export function packageGroupNodeId(packageKey: string): string {
478
+ return `package:${packageKey}`;
479
+ }
480
+
481
+ /** React Flow id for a boundary group of any kind. */
458
482
  export function boundaryGroupNodeId(region: Pick<SubsystemProcessRegion, 'kind' | 'key'>): string {
459
- return region.kind === 'module'
460
- ? moduleGroupNodeId(region.key)
461
- : processGroupNodeId(region.key);
483
+ if (region.kind === 'module') return moduleGroupNodeId(region.key);
484
+ if (region.kind === 'package') return packageGroupNodeId(region.key);
485
+ return processGroupNodeId(region.key);
486
+ }
487
+
488
+ /**
489
+ * Repo/package key used for package frames — `purl` with fragment stripped.
490
+ * Externals and custom entities are not package-frame members.
491
+ */
492
+ export function componentPackageKey(
493
+ c: Pick<SubsystemComponent, 'purl' | 'construct'>,
494
+ ): string | undefined {
495
+ if (c.construct === 'external' || c.construct === 'custom_entity') return undefined;
496
+ const key = purlRepoKey(c.purl);
497
+ if (!key || key === 'external') return undefined;
498
+ return key;
499
+ }
500
+
501
+ function packageRegionLabel(packageKey: string): string {
502
+ return purlOwnerName(packageKey) ?? formatPurl(packageKey);
462
503
  }
463
504
 
464
505
  /**
@@ -508,42 +549,103 @@ export function getSubsystemModuleRegions(
508
549
  }
509
550
 
510
551
  /**
511
- * One compound frame for ELK / React Flow — module frames may nest under a
512
- * process frame via `parentId`.
552
+ * Derive package (repo) boundary regions from component `purl`s — one per
553
+ * distinct repo key. Does not apply multi-repo / singleton filters; callers
554
+ * decide via {@link buildBoundaryLayoutGroups}.
555
+ */
556
+ export function getSubsystemPackageRegions(
557
+ doc: Pick<SubsystemModelDocument, 'components'>,
558
+ ): SubsystemProcessRegion[] {
559
+ const byPackage = new Map<string, string[]>();
560
+ for (const c of doc.components) {
561
+ const key = componentPackageKey(c);
562
+ if (!key) continue;
563
+ const list = byPackage.get(key) ?? [];
564
+ list.push(c.id);
565
+ byPackage.set(key, list);
566
+ }
567
+ return [...byPackage.entries()].map(([key, memberIds]) => ({
568
+ kind: 'package' as const,
569
+ key,
570
+ label: packageRegionLabel(key),
571
+ memberIds,
572
+ }));
573
+ }
574
+
575
+ /**
576
+ * One compound frame for ELK / React Flow — modules nest under processes,
577
+ * processes under packages, when membership is shared.
513
578
  */
514
579
  export interface BoundaryLayoutGroup {
515
580
  id: string;
516
581
  memberIds: string[];
517
- /** When set, this group is a child of another boundary group (process). */
582
+ /** When set, this group is a child of another boundary group. */
518
583
  parentId?: string;
519
584
  region: SubsystemProcessRegion;
520
585
  }
521
586
 
587
+ function keepRegion(
588
+ r: SubsystemProcessRegion,
589
+ showSingletons: boolean,
590
+ ): boolean {
591
+ return showSingletons || r.memberIds.length >= 2;
592
+ }
593
+
522
594
  /**
523
- * Build the process → module → leaf group tree for layout.
524
- * Multi-member modules nest under a process when every member shares that
525
- * process; process ELK children are nested module group ids plus any
526
- * process members that are not inside a kept module frame.
595
+ * Build the package → process → module → leaf group tree for layout.
596
+ *
597
+ * Package frames come from `purl` (no separate component field). By default
598
+ * they only appear when the graph spans 2+ distinct repos. Multi-member
599
+ * modules nest under a process when every member shares that process;
600
+ * processes nest under a package the same way.
527
601
  */
528
602
  export function buildBoundaryLayoutGroups(
529
603
  doc: Pick<SubsystemModelDocument, 'components'>,
604
+ opts: BoundaryFrameOptions = {},
530
605
  ): BoundaryLayoutGroup[] {
606
+ const showSingletons = opts.showSingletonFrames === true;
607
+ const packageMode = opts.packageFrames ?? 'multi-repo';
531
608
  const byId = new Map(doc.components.map((c) => [c.id, c]));
532
- const modules = getSubsystemModuleRegions(doc).filter((r) => r.memberIds.length >= 2);
533
- const processes = getSubsystemRegions(doc).filter((r) => r.memberIds.length >= 2);
609
+
610
+ const allPackages = getSubsystemPackageRegions(doc);
611
+ const packageEligible =
612
+ packageMode === 'always'
613
+ ? true
614
+ : packageMode === 'never'
615
+ ? false
616
+ : allPackages.length >= 2;
617
+ const packages = packageEligible
618
+ ? allPackages.filter((r) => keepRegion(r, showSingletons))
619
+ : [];
620
+ const keptPackageKeys = new Set(packages.map((r) => r.key));
621
+
622
+ const modules = getSubsystemModuleRegions(doc).filter((r) =>
623
+ keepRegion(r, showSingletons),
624
+ );
625
+ const processes = getSubsystemRegions(doc).filter((r) =>
626
+ keepRegion(r, showSingletons),
627
+ );
534
628
  const keptProcessKeys = new Set(processes.map((r) => r.key));
535
629
 
536
630
  const moduleGroups: BoundaryLayoutGroup[] = modules.map((r) => {
537
631
  const processesOfMembers = new Set<string>();
632
+ const packagesOfMembers = new Set<string>();
538
633
  for (const id of r.memberIds) {
539
- const p = byId.get(id)?.process?.trim();
634
+ const c = byId.get(id);
635
+ const p = c?.process?.trim();
540
636
  if (p) processesOfMembers.add(p);
637
+ const pkg = c ? componentPackageKey(c) : undefined;
638
+ if (pkg) packagesOfMembers.add(pkg);
541
639
  }
542
640
  let parentId: string | undefined;
543
641
  if (processesOfMembers.size === 1) {
544
642
  const p = [...processesOfMembers][0]!;
545
643
  if (keptProcessKeys.has(p)) parentId = processGroupNodeId(p);
546
644
  }
645
+ if (!parentId && packagesOfMembers.size === 1) {
646
+ const pkg = [...packagesOfMembers][0]!;
647
+ if (keptPackageKeys.has(pkg)) parentId = packageGroupNodeId(pkg);
648
+ }
547
649
  return {
548
650
  id: moduleGroupNodeId(r.key),
549
651
  memberIds: [...r.memberIds],
@@ -561,14 +663,51 @@ export function buildBoundaryLayoutGroups(
561
663
  if (!mod) return true;
562
664
  return !nestedModuleKeys.has(mod);
563
665
  });
666
+
667
+ const packagesOfMembers = new Set<string>();
668
+ for (const id of r.memberIds) {
669
+ const c = byId.get(id);
670
+ const pkg = c ? componentPackageKey(c) : undefined;
671
+ if (pkg) packagesOfMembers.add(pkg);
672
+ }
673
+ let parentId: string | undefined;
674
+ if (packagesOfMembers.size === 1) {
675
+ const pkg = [...packagesOfMembers][0]!;
676
+ if (keptPackageKeys.has(pkg)) parentId = packageGroupNodeId(pkg);
677
+ }
678
+
564
679
  return {
565
680
  id: processId,
566
681
  memberIds: [...nestedModules.map((m) => m.id), ...directLeaves],
682
+ parentId,
567
683
  region: r,
568
684
  };
569
685
  });
570
686
 
571
- return [...moduleGroups, ...processGroups];
687
+ const packageGroups: BoundaryLayoutGroup[] = packages.map((r) => {
688
+ const packageId = packageGroupNodeId(r.key);
689
+ const nestedProcesses = processGroups.filter((p) => p.parentId === packageId);
690
+ const nestedModules = moduleGroups.filter((m) => m.parentId === packageId);
691
+ const claimed = new Set<string>();
692
+ for (const p of nestedProcesses) {
693
+ for (const id of p.region.memberIds) claimed.add(id);
694
+ }
695
+ for (const m of nestedModules) {
696
+ for (const id of m.region.memberIds) claimed.add(id);
697
+ }
698
+ const directLeaves = r.memberIds.filter((id) => !claimed.has(id));
699
+ return {
700
+ id: packageId,
701
+ memberIds: [
702
+ ...nestedProcesses.map((p) => p.id),
703
+ ...nestedModules.map((m) => m.id),
704
+ ...directLeaves,
705
+ ],
706
+ region: r,
707
+ };
708
+ });
709
+
710
+ return [...moduleGroups, ...processGroups, ...packageGroups];
572
711
  }
573
712
 
574
713
  export interface SubsystemGraphNodeData extends Record<string, unknown> {
@@ -689,6 +828,14 @@ export const ROLE_COLOR: Record<SubsystemComponentRole, string> = {
689
828
  service: '#0893d2', // blue — external system
690
829
  };
691
830
 
831
+ /**
832
+ * Brand color for the left construct/stereotype badge when a framework owns
833
+ * the label (e.g. `react · component`). Node border stays construct-colored.
834
+ */
835
+ export const FRAMEWORK_BADGE_COLOR: Record<string, string> = {
836
+ react: '#61dafb', // React cyan
837
+ };
838
+
692
839
  export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
693
840
  entry: 'entry',
694
841
  service: 'service',
@@ -790,14 +937,27 @@ export function constructBadgeLabel(component: {
790
937
  return constructLabel ?? '';
791
938
  }
792
939
 
940
+ /**
941
+ * Left-badge accent: framework brand when the badge shows a framework
942
+ * stereotype (e.g. React cyan for `react · component`); otherwise null so
943
+ * the caller falls back to construct color. Border stays construct-colored.
944
+ */
945
+ export function constructBadgeColor(component: {
946
+ framework?: string;
947
+ stereotype?: string;
948
+ }): string | null {
949
+ if (component.framework == null || component.stereotype == null) return null;
950
+ return FRAMEWORK_BADGE_COLOR[component.framework] ?? null;
951
+ }
952
+
793
953
  /** Default CSS floor for component nodes (padding aside). */
794
954
  export const NODE_CSS_MIN_WIDTH = 150;
795
955
  /** Inset of each top badge from the node edge (`left` / `right` style). */
796
956
  export const BADGE_EDGE_INSET = 5;
797
957
  /** Minimum gap between left construct badge and right role badge. */
798
958
  const BADGE_PAIR_GAP = 8;
799
- /** Badge box chrome: padding 5+5 + border 1+1. */
800
- const BADGE_BOX_CHROME = 12;
959
+ /** Badge box chrome: padding 8+8 + border 2+2. */
960
+ const BADGE_BOX_CHROME = 20;
801
961
  /** Approx monospace uppercase width incl. letter-spacing (~0.5px). */
802
962
  const BADGE_CHAR_WIDTH = 8;
803
963
 
@@ -840,11 +1000,11 @@ export function nodeMinWidthForBadges(component: {
840
1000
  /**
841
1001
  * Convert a subsystem graph document into React Flow nodes. Components that
842
1002
  * carry a `module` get a `parentId` pointing at their module frame
843
- * (`module:<path>`); otherwise a `process` stamps `process:<process>`. Module
844
- * frames may themselves nest under a process frame (stamped on the group node
845
- * in `buildSubsystemGraph`). Nodes without either stay top-level. The initial
846
- * grid clusters by `module ?? process ?? purl`; ELK then refines with compound
847
- * layout.
1003
+ * (`module:<path>`); otherwise a `process` stamps `process:<process>`.
1004
+ * Package (`purl`) parents are stamped later in `buildSubsystemGraph` only
1005
+ * when package frames are kept. Module frames may nest under process →
1006
+ * package. The initial grid clusters by `module ?? process ?? purl`; ELK
1007
+ * then refines with compound layout.
848
1008
  */
849
1009
  export function convertSubsystemToNodes(
850
1010
  doc: SubsystemModelDocument,
@@ -913,14 +1073,15 @@ export function convertSubsystemToNodes(
913
1073
  }
914
1074
 
915
1075
  /**
916
- * Convert boundary regions into React Flow parent (group) nodes. Module
917
- * frames that share a process nest under that process via `parentId`.
1076
+ * Convert boundary regions into React Flow parent (group) nodes. Nesting
1077
+ * (package → process → module) is stamped via `parentId` on groups.
918
1078
  * Positions/sizes are placeholders — ELK compound layout overwrites them.
919
1079
  */
920
1080
  export function convertSubsystemToGroups(
921
1081
  doc: Pick<SubsystemModelDocument, 'components'>,
1082
+ opts: BoundaryFrameOptions = {},
922
1083
  ): SubsystemGraphNode[] {
923
- return buildBoundaryLayoutGroups(doc).map((g) => ({
1084
+ return buildBoundaryLayoutGroups(doc, opts).map((g) => ({
924
1085
  id: g.id,
925
1086
  type: 'subsystem-group' as const,
926
1087
  position: { x: 0, y: 0 },
@@ -988,36 +1149,55 @@ export function subsystemGraphLayoutKey(
988
1149
  */
989
1150
  export async function buildSubsystemGraph(
990
1151
  doc: SubsystemModelDocument,
991
- opts: { maxNodeWidth?: number; showEdgeLabels?: boolean; measuredWidths?: Map<string, number>; measuredHeights?: Map<string, number> } = {},
1152
+ opts: {
1153
+ maxNodeWidth?: number;
1154
+ showEdgeLabels?: boolean;
1155
+ measuredWidths?: Map<string, number>;
1156
+ measuredHeights?: Map<string, number>;
1157
+ } & BoundaryFrameOptions = {},
992
1158
  ): Promise<{
993
1159
  nodes: SubsystemGraphNode[];
994
1160
  edges: SubsystemGraphEdge[];
995
1161
  regions: SubsystemProcessRegion[];
996
1162
  }> {
997
- const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights } = opts;
1163
+ const {
1164
+ maxNodeWidth,
1165
+ showEdgeLabels,
1166
+ measuredWidths,
1167
+ measuredHeights,
1168
+ showSingletonFrames,
1169
+ packageFrames,
1170
+ } = opts;
1171
+ const frameOpts: BoundaryFrameOptions = { showSingletonFrames, packageFrames };
998
1172
  const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
999
1173
  const edges = convertSubsystemToEdges(doc);
1000
- // Nested boundary tree: process → module → leaves (when both fields set).
1001
- const layoutGroups = buildBoundaryLayoutGroups(doc);
1174
+ // Nested boundary tree: package → process → module → leaves.
1175
+ const layoutGroups = buildBoundaryLayoutGroups(doc, frameOpts);
1002
1176
  const regions = layoutGroups.map((g) => g.region);
1003
1177
  const regionIds = new Set(layoutGroups.map((g) => g.id));
1004
1178
  const byId = new Map(doc.components.map((c) => [c.id, c]));
1005
1179
 
1006
- // Drop leaf parentIds that point at singleton / missing frames; fall back
1007
- // from a dropped module frame to a kept process frame when possible.
1180
+ // Resolve leaf parentIds against kept frames: module → process → package.
1181
+ // Stamp package parents here (not in convertSubsystemToNodes) so single-repo
1182
+ // graphs never provisionally parent under a package that will be dropped.
1008
1183
  for (const n of nodes) {
1009
1184
  if (n.type !== 'subsystem-component') continue;
1010
1185
  const parentId = (n as { parentId?: string }).parentId;
1011
- if (!parentId) continue;
1012
- if (regionIds.has(parentId)) continue;
1013
- const comp = (n.data as SubsystemGraphNodeData).component;
1014
- const processKey = comp?.process?.trim() || byId.get(n.id)?.process?.trim();
1186
+ if (parentId && regionIds.has(parentId)) continue;
1187
+ const comp = (n.data as SubsystemGraphNodeData).component ?? byId.get(n.id);
1188
+ const processKey = comp?.process?.trim();
1015
1189
  const processId = processKey ? processGroupNodeId(processKey) : undefined;
1016
1190
  if (processId && regionIds.has(processId)) {
1017
1191
  (n as { parentId?: string }).parentId = processId;
1018
- } else {
1019
- delete (n as { parentId?: string }).parentId;
1192
+ continue;
1020
1193
  }
1194
+ const packageKey = comp ? componentPackageKey(comp) : undefined;
1195
+ const packageId = packageKey ? packageGroupNodeId(packageKey) : undefined;
1196
+ if (packageId && regionIds.has(packageId)) {
1197
+ (n as { parentId?: string }).parentId = packageId;
1198
+ continue;
1199
+ }
1200
+ if (parentId) delete (n as { parentId?: string }).parentId;
1021
1201
  }
1022
1202
 
1023
1203
  // External edge targets that aren't real components → create stub nodes so
@@ -1090,7 +1270,8 @@ export async function buildSubsystemGraph(
1090
1270
  })),
1091
1271
  });
1092
1272
  const builtGroupIds = new Set(result.groupBounds.keys());
1093
- // Parents before children — process frames, then nested module frames.
1273
+ // Parents before children — package, then process, then module frames.
1274
+ const packageGroupNodes: SubsystemGraphNode[] = [];
1094
1275
  const processGroupNodes: SubsystemGraphNode[] = [];
1095
1276
  const moduleGroupNodes: SubsystemGraphNode[] = [];
1096
1277
  for (const g of layoutGroups) {
@@ -1107,7 +1288,8 @@ export async function buildSubsystemGraph(
1107
1288
  ...(parentId ? { parentId } : {}),
1108
1289
  data: { region: g.region },
1109
1290
  };
1110
- if (g.region.kind === 'process') processGroupNodes.push(group);
1291
+ if (g.region.kind === 'package') packageGroupNodes.push(group);
1292
+ else if (g.region.kind === 'process') processGroupNodes.push(group);
1111
1293
  else moduleGroupNodes.push(group);
1112
1294
  }
1113
1295
  // Clear leaf parentIds that point at groups ELK dropped.
@@ -1119,6 +1301,7 @@ export async function buildSubsystemGraph(
1119
1301
  }
1120
1302
  }
1121
1303
  placedNodes = [
1304
+ ...packageGroupNodes,
1122
1305
  ...processGroupNodes,
1123
1306
  ...moduleGroupNodes,
1124
1307
  ...(result.nodes as SubsystemGraphNode[]),
@@ -1130,6 +1313,7 @@ export async function buildSubsystemGraph(
1130
1313
  // Fall back to the (unpositioned) grid if ELK is unavailable — still
1131
1314
  // emit multi-member frames so parentId targets exist.
1132
1315
  console.warn('[subsystem-graph] ELK layout failed, using manual positions:', err);
1316
+ const packageGroupNodes: SubsystemGraphNode[] = [];
1133
1317
  const processGroupNodes: SubsystemGraphNode[] = [];
1134
1318
  const moduleGroupNodes: SubsystemGraphNode[] = [];
1135
1319
  for (const g of layoutGroups) {
@@ -1142,10 +1326,16 @@ export async function buildSubsystemGraph(
1142
1326
  ...(g.parentId ? { parentId: g.parentId } : {}),
1143
1327
  data: { region: g.region },
1144
1328
  };
1145
- if (g.region.kind === 'process') processGroupNodes.push(group);
1329
+ if (g.region.kind === 'package') packageGroupNodes.push(group);
1330
+ else if (g.region.kind === 'process') processGroupNodes.push(group);
1146
1331
  else moduleGroupNodes.push(group);
1147
1332
  }
1148
- placedNodes = [...processGroupNodes, ...moduleGroupNodes, ...nodes];
1333
+ placedNodes = [
1334
+ ...packageGroupNodes,
1335
+ ...processGroupNodes,
1336
+ ...moduleGroupNodes,
1337
+ ...nodes,
1338
+ ];
1149
1339
  }
1150
1340
  }
1151
1341
 
@@ -20,6 +20,7 @@ import {
20
20
  MECHANISM_COLOR,
21
21
  MECHANISM_STYLE,
22
22
  PROPOSED_COLOR,
23
+ constructBadgeColor,
23
24
  constructBadgeLabel,
24
25
  rightBadgeLabel,
25
26
  rightBadgeColor,
@@ -88,6 +89,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
88
89
  // shows as the hover badge, not as color — for now; a role glyph/accent may
89
90
  // come later. A component-authored `color` override wins over the construct.
90
91
  const color = componentColor(c, resolvePierreSyntaxThemeName(mode));
92
+ // Left badge: framework brand (e.g. React cyan) when the label is a
93
+ // framework stereotype; otherwise the same construct color as the border.
94
+ const badgeColor = constructBadgeColor(c) ?? color;
91
95
  const [hover, setHover] = useState(false);
92
96
  const configuredMax = SUBSYSTEM_CALLBACKS.maxNodeWidth;
93
97
  const maxWidth = configuredMax ?? 300;
@@ -114,6 +118,10 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
114
118
  // Selection is stamped into data by the graph component — React Flow's own
115
119
  // `selected` never updates because the node stops click propagation.
116
120
  const isSelected = selected || (data.isSelected as boolean | undefined) === true;
121
+ // Externals are "outside" the system — square corners on the node + badges.
122
+ const isExternal = c.construct === 'external';
123
+ const nodeRadius = isExternal ? 0 : 8;
124
+ const badgeRadius = isExternal ? 0 : 4;
117
125
 
118
126
  return (
119
127
  <div
@@ -141,7 +149,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
141
149
  minWidth: badgeMinWidth,
142
150
  maxWidth,
143
151
  padding: '6px 10px',
144
- borderRadius: 8,
152
+ borderRadius: nodeRadius,
145
153
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
146
154
  // Selected / file-matched nodes get a thicker border. Proposed nodes
147
155
  // use a dashed goldenrod border; left construct badge keeps construct color.
@@ -157,11 +165,12 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
157
165
  >
158
166
  {/* Construct / stereotype badge — prefers framework stereotype so a
159
167
  React UI unit reads as "react · component" instead of "function".
160
- Persistent; pointer-events none so clicks pass through to the node. */}
168
+ Persistent; pointer-events none so clicks pass through to the node.
169
+ Framework badges use brand color; border stays construct-colored. */}
161
170
  <div
162
171
  style={{
163
172
  position: 'absolute',
164
- top: -9,
173
+ top: -11,
165
174
  left: BADGE_EDGE_INSET,
166
175
  zIndex: 1,
167
176
  fontFamily: theme.fonts.monospace,
@@ -170,11 +179,11 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
170
179
  textTransform: 'uppercase',
171
180
  lineHeight: '17px',
172
181
  whiteSpace: 'nowrap',
173
- color,
182
+ color: badgeColor,
174
183
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
175
- border: `1px solid ${color}`,
176
- borderRadius: 4,
177
- padding: '0 5px',
184
+ border: `2px solid ${badgeColor}`,
185
+ borderRadius: badgeRadius,
186
+ padding: '2px 8px',
178
187
  }}
179
188
  >
180
189
  {constructBadgeLabel(c)}
@@ -187,7 +196,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
187
196
  <div
188
197
  style={{
189
198
  position: 'absolute',
190
- top: -9,
199
+ top: -11,
191
200
  right: BADGE_EDGE_INSET,
192
201
  zIndex: 1,
193
202
  display: 'flex',
@@ -205,9 +214,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
205
214
  lineHeight: '17px',
206
215
  color: storageColor,
207
216
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
208
- border: `1px solid ${storageColor}`,
209
- borderRadius: 4,
210
- padding: '0 5px',
217
+ border: `2px solid ${storageColor}`,
218
+ borderRadius: badgeRadius,
219
+ padding: '2px 8px',
211
220
  }}
212
221
  >
213
222
  {storageLabel}
@@ -223,9 +232,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
223
232
  lineHeight: '17px',
224
233
  color: topRightColor,
225
234
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
226
- border: `1px solid ${topRightColor}`,
227
- borderRadius: 4,
228
- padding: '0 5px',
235
+ border: `2px solid ${topRightColor}`,
236
+ borderRadius: badgeRadius,
237
+ padding: '2px 8px',
229
238
  }}
230
239
  >
231
240
  {topRightLabel}
@@ -241,7 +250,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
241
250
  <div
242
251
  style={{
243
252
  position: 'absolute',
244
- bottom: -13,
253
+ bottom: -14,
245
254
  left: '50%',
246
255
  transform: 'translateX(-50%)',
247
256
  zIndex: 1,
@@ -254,9 +263,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
254
263
  // borders and badges, not the file name
255
264
  color: theme.colors.text ?? theme.colors.textSecondary,
256
265
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
257
- border: `1px solid ${color}`,
258
- borderRadius: 4,
259
- padding: '0 5px',
266
+ border: `2px solid ${color}`,
267
+ borderRadius: badgeRadius,
268
+ padding: '2px 8px',
260
269
  cursor: 'pointer',
261
270
  }}
262
271
  onClick={(e) => {
@@ -341,8 +350,21 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
341
350
  const color = packageColor(region?.key ?? 'process');
342
351
  const dimmed = data.dimmed === true;
343
352
  const hidden = (data as { hidden?: boolean }).hidden === true;
344
- const label =
345
- region?.kind === 'module' ? `module · ${region.label}` : (region?.label ?? '');
353
+ // Label is the region identity alone (path / process key / owner/name).
354
+ // Kind is carried by frame chrome (square = process; rounded = module/package)
355
+ // — don't prefix `module ·` / `package ·` or the path reads twice.
356
+ const label = region?.label ?? '';
357
+ // Process frames match external nodes — square corners (outside the soft
358
+ // component language). Module / package frames keep the rounded look.
359
+ const isProcess = region?.kind === 'process';
360
+ const frameRadius = isProcess ? 0 : 12;
361
+ const badgeRadius = isProcess ? 0 : 4;
362
+ // Processes are deployment units — solid frame. Modules/packages stay dashed
363
+ // until selected.
364
+ const frameStyle = isProcess || selected ? 'solid' : 'dashed';
365
+ // Process keeps a tinted fill; module / package bodies stay transparent so
366
+ // nested frames don't stack washes.
367
+ const frameFill = isProcess ? `${color}14` : 'transparent';
346
368
 
347
369
  if (!region) return null;
348
370
 
@@ -353,9 +375,9 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
353
375
  width: width ?? 400,
354
376
  height: height ?? 300,
355
377
  boxSizing: 'border-box',
356
- borderRadius: 12,
357
- border: `2px ${selected ? 'solid' : 'dashed'} ${color}`,
358
- background: `${color}14`,
378
+ borderRadius: frameRadius,
379
+ border: `2px ${frameStyle} ${color}`,
380
+ background: frameFill,
359
381
  opacity: hidden ? 0 : dimmed ? 0.35 : 1,
360
382
  transition: 'opacity 150ms ease',
361
383
  pointerEvents: 'none',
@@ -364,8 +386,14 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
364
386
  <div
365
387
  style={{
366
388
  position: 'absolute',
367
- top: -19,
368
- left: 12,
389
+ // Process: badge fill starts with the process fill (inside the
390
+ // frame border); no top badge border so widths don't fight.
391
+ // Module/package sit astride the top edge like component badges.
392
+ // Always set left/transform/borderTop explicitly — React Flow reuses
393
+ // group DOM nodes and `undefined` does not clear a prior value.
394
+ top: isProcess ? 0 : -19,
395
+ left: isProcess ? '50%' : 12,
396
+ transform: isProcess ? 'translateX(-50%)' : 'none',
369
397
  fontFamily: theme.fonts.monospace,
370
398
  fontSize: theme.fontSizes[3],
371
399
  fontWeight: 700,
@@ -373,7 +401,8 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
373
401
  color,
374
402
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
375
403
  border: `1px solid ${color}`,
376
- borderRadius: 4,
404
+ borderTopWidth: isProcess ? 0 : 1,
405
+ borderRadius: badgeRadius,
377
406
  padding: '1px 7px',
378
407
  whiteSpace: 'nowrap',
379
408
  }}