@principal-ai/subsystems-react 0.53.6 → 0.54.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 (104) hide show
  1. package/dist/index.d.ts +8 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  6. package/dist/pierre/PierreTrailCodeView.js +7 -5
  7. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  8. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  9. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +1 -2
  10. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  11. package/dist/subsystem/C4Graph.d.ts +144 -9
  12. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  13. package/dist/subsystem/C4Graph.js +924 -148
  14. package/dist/subsystem/C4Graph.js.map +1 -1
  15. package/dist/subsystem/C4NodeCard.d.ts +22 -1
  16. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  17. package/dist/subsystem/C4NodeCard.js +10 -2
  18. package/dist/subsystem/C4NodeCard.js.map +1 -1
  19. package/dist/subsystem/ComponentDeclaration.d.ts +5 -4
  20. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  21. package/dist/subsystem/ComponentDeclaration.js +12 -4
  22. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  23. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  24. package/dist/subsystem/ConstructsCatalog.js +10 -7
  25. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  26. package/dist/subsystem/GraphSidebar.d.ts +71 -0
  27. package/dist/subsystem/GraphSidebar.d.ts.map +1 -0
  28. package/dist/subsystem/GraphSidebar.js +145 -0
  29. package/dist/subsystem/GraphSidebar.js.map +1 -0
  30. package/dist/subsystem/GraphWithSidebar.d.ts +24 -0
  31. package/dist/subsystem/GraphWithSidebar.d.ts.map +1 -0
  32. package/dist/subsystem/GraphWithSidebar.js +25 -0
  33. package/dist/subsystem/GraphWithSidebar.js.map +1 -0
  34. package/dist/subsystem/SubsystemComponentGraph.d.ts +33 -0
  35. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  36. package/dist/subsystem/SubsystemComponentGraph.js +364 -411
  37. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  38. package/dist/subsystem/TrailStepsNav.d.ts +48 -0
  39. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -0
  40. package/dist/subsystem/TrailStepsNav.js +189 -0
  41. package/dist/subsystem/TrailStepsNav.js.map +1 -0
  42. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  43. package/dist/subsystem/TrailsPanel.js +3 -4
  44. package/dist/subsystem/TrailsPanel.js.map +1 -1
  45. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts +151 -0
  46. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts.map +1 -0
  47. package/dist/subsystem/__fixtures__/c4Drilldown.js +783 -0
  48. package/dist/subsystem/__fixtures__/c4Drilldown.js.map +1 -0
  49. package/dist/subsystem/c4.d.ts +39 -8
  50. package/dist/subsystem/c4.d.ts.map +1 -1
  51. package/dist/subsystem/c4.js.map +1 -1
  52. package/dist/subsystem/declarationRef.d.ts +26 -18
  53. package/dist/subsystem/declarationRef.d.ts.map +1 -1
  54. package/dist/subsystem/declarationRef.js +9 -4
  55. package/dist/subsystem/declarationRef.js.map +1 -1
  56. package/dist/subsystem/model.d.ts +19 -1
  57. package/dist/subsystem/model.d.ts.map +1 -1
  58. package/dist/subsystem/model.js +12 -2
  59. package/dist/subsystem/model.js.map +1 -1
  60. package/dist/subsystem/nodes.d.ts.map +1 -1
  61. package/dist/subsystem/nodes.js +24 -4
  62. package/dist/subsystem/nodes.js.map +1 -1
  63. package/dist/subsystem/trailBrief.d.ts.map +1 -1
  64. package/dist/subsystem/trailBrief.js +6 -1
  65. package/dist/subsystem/trailBrief.js.map +1 -1
  66. package/package.json +3 -3
  67. package/src/index.ts +9 -2
  68. package/src/pierre/PierreTrailCodeView.tsx +9 -13
  69. package/src/stories/Subsystem/C4Graph/C4ToComponentDrill.stories.tsx +496 -0
  70. package/src/stories/Subsystem/ComponentGraph/AnimatedProgression.stories.tsx +7 -7
  71. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +19 -25
  72. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +8 -10
  73. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +19 -19
  74. package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +6 -6
  75. package/src/stories/Subsystem/ComponentGraph/TrailAutoplay.stories.tsx +7 -7
  76. package/src/stories/Subsystem/ComponentGraph/Trails.stories.tsx +27 -30
  77. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +1 -2
  78. package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +10 -13
  79. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +3 -4
  80. package/src/stories/Subsystem/Sidebar/GraphSidebar.stories.tsx +206 -0
  81. package/src/subsystem/C4Graph.groups.test.ts +96 -2
  82. package/src/subsystem/C4Graph.tsx +1377 -165
  83. package/src/subsystem/C4NodeCard.test.ts +1 -2
  84. package/src/subsystem/C4NodeCard.tsx +24 -2
  85. package/src/subsystem/ComponentDeclaration.tsx +18 -8
  86. package/src/subsystem/ConstructsCatalog.tsx +23 -8
  87. package/src/subsystem/GraphSidebar.test.tsx +152 -0
  88. package/src/subsystem/GraphSidebar.tsx +300 -0
  89. package/src/subsystem/GraphWithSidebar.test.tsx +78 -0
  90. package/src/subsystem/GraphWithSidebar.tsx +60 -0
  91. package/src/subsystem/SubsystemComponentGraph.tsx +284 -370
  92. package/src/subsystem/TrailStepsNav.test.tsx +179 -0
  93. package/src/subsystem/TrailStepsNav.tsx +372 -0
  94. package/src/subsystem/TrailsPanel.tsx +3 -4
  95. package/src/subsystem/__fixtures__/c4Drilldown.test.ts +338 -0
  96. package/src/subsystem/__fixtures__/c4Drilldown.ts +847 -0
  97. package/src/subsystem/c4.ts +41 -8
  98. package/src/subsystem/declarationRef.ts +28 -19
  99. package/src/subsystem/model.test.ts +32 -12
  100. package/src/subsystem/model.ts +30 -1
  101. package/src/subsystem/nodes.group.test.tsx +82 -0
  102. package/src/subsystem/nodes.tsx +30 -2
  103. package/src/subsystem/trailBrief.test.ts +2 -2
  104. package/src/subsystem/trailBrief.ts +6 -1
@@ -67,16 +67,22 @@ interface C4ElementBase {
67
67
  state: C4ElementState;
68
68
  /** Why this element is proposed as it is — shown in the confirm UI. */
69
69
  rationale?: string;
70
- /** The component aliases this element groups. */
71
- members?: string[];
72
- /** Distinct constructs among the members, when resolved from a document. */
73
- constructs?: string[];
74
- /** Source model ids that contributed members, when attribution is supplied. */
75
- models?: string[];
76
70
  /** When the state last changed (ISO). */
77
71
  decidedAt?: string;
78
72
  }
79
73
 
74
+ /**
75
+ * The component aliases an element groups — the explicit member list.
76
+ *
77
+ * Only the kinds that have no `process` claim to resolve members from carry
78
+ * one: an external system (its few aliases) and an authored C4 component (the
79
+ * aliases it groups). A container's members are *derived* from its `process`
80
+ * claim against the model document, never stored here.
81
+ */
82
+ interface C4MemberList {
83
+ members?: string[];
84
+ }
85
+
80
86
  /**
81
87
  * What kind of thing a container is.
82
88
  *
@@ -112,7 +118,7 @@ export interface C4Container extends C4ElementBase {
112
118
  * split across containers (an RPC bridge with a renderer half and a host half),
113
119
  * which is precisely the case worth being able to state.
114
120
  */
115
- export interface C4Component extends C4ElementBase {
121
+ export interface C4Component extends C4ElementBase, C4MemberList {
116
122
  kind: 'component';
117
123
  /** The id of the container this component sits inside. */
118
124
  container: string;
@@ -120,7 +126,7 @@ export interface C4Component extends C4ElementBase {
120
126
  technology: string;
121
127
  }
122
128
  /** A software system outside the boundary we are drawing. */
123
- export interface C4ExternalSystem extends C4ElementBase {
129
+ export interface C4ExternalSystem extends C4ElementBase, C4MemberList {
124
130
  kind: 'external-system';
125
131
  /** Often unknown for a third-party system, so optional here. */
126
132
  technology?: string;
@@ -223,6 +229,21 @@ export function deriveC4Groups(
223
229
  return groups;
224
230
  }
225
231
 
232
+ /**
233
+ * One trail contributing steps to a rolled-up C4 edge — the drill-down
234
+ * provenance that lets a line on the C4 diagram answer "which trail steps
235
+ * implement this?".
236
+ *
237
+ * Deliberately ids only: the host owns the trail document and can resolve
238
+ * titles, so this stays a pointer instead of duplicating content.
239
+ */
240
+ export interface C4TrailRef {
241
+ /** Trail id in the subsystem model document, e.g. `tl-audit`. */
242
+ trailId: string;
243
+ /** Indices into `trail.steps` of the steps that cross this edge's endpoints. */
244
+ stepIndices: number[];
245
+ }
246
+
226
247
  /**
227
248
  * A line between two elements. Authored, like the boxes.
228
249
  *
@@ -230,6 +251,12 @@ export function deriveC4Groups(
230
251
  * interaction (`protocol`), and the label is that plus the trail verbs where
231
252
  * known. `label` is what the chip shows; `protocol` and `mechanisms` are kept
232
253
  * separate so a caller can render either.
254
+ *
255
+ * `count` / `mechanisms` / `trailRefs` are the rolled-up view of the same line:
256
+ * how many component-level steps produced it (union of the referenced steps),
257
+ * which verbs they used, and — when a builder supplied provenance — exactly
258
+ * which trails/steps. All three are derived data riding an authored line, so
259
+ * they are optional: an authored edge may carry none until a rollup has run.
233
260
  */
234
261
  export interface C4Edge {
235
262
  id: string;
@@ -243,6 +270,12 @@ export interface C4Edge {
243
270
  mechanisms: string[];
244
271
  /** How many underlying steps this line rolled up. */
245
272
  count: number;
273
+ /**
274
+ * The trails whose steps cross this edge's endpoints. The drill-down bridge:
275
+ * a reader clicks the line, gets these refs, and jumps into the component
276
+ * graph's trail focus. Absent on an authored line no rollup has run over.
277
+ */
278
+ trailRefs?: C4TrailRef[];
246
279
  }
247
280
 
248
281
  /**
@@ -1,27 +1,31 @@
1
1
  /**
2
- * Declaration anchoring — start-line + single-line content hash.
2
+ * Source-span anchoring — a content-addressed range of source.
3
3
  *
4
- * Graphify emits `source_location: "L42"` (declaration start line). We capture
5
- * that line's normalized text as a fingerprint to detect drift without end-line
6
- * spans or graphify changes.
4
+ * A span is `startLine` + `endLine` + a hash of the normalized content across
5
+ * that range. The type is owned by `@principal-ai/subsystems-core` (the
6
+ * portable standard); this module re-exports it and provides the line helpers
7
+ * shared by the renderers and host-side hashing.
8
+ *
9
+ * Graphify emits `source_location: "L42"` (a start line only); the end line is
10
+ * derived by verification, and `hashContentRange` (host-side) produces the
11
+ * `contentHash`.
7
12
  */
8
13
 
9
- /** Persisted anchor for a subsystem component's source declaration. */
10
- export interface SubsystemDeclarationRef {
11
- /** Repo-root-relative path (matches `component.file`). */
12
- file: string;
13
- /** 1-based declaration start line (from graphify `source_location`). */
14
- startLine: number;
15
- /** SHA-256 of {@link normalizeDeclarationLine} output, truncated to 32 hex chars. */
16
- lineHash: string;
17
- /** Graphify node id when captured from an exact anchor. */
18
- graphifyNodeId?: string;
19
- /** ISO timestamp when this ref was recorded. */
20
- capturedAt: string;
21
- }
14
+ import type { SubsystemSourceSpan } from '@principal-ai/subsystems-core';
22
15
 
23
- export type DeclarationFreshness =
24
- | 'fresh'
16
+ export type { SubsystemSourceSpan };
17
+
18
+ /**
19
+ * How fresh an anchored span is, as computed by verification.
20
+ *
21
+ * - `full` — the span (start + end + hash) is present and live content matches.
22
+ * - `stale` — present, but the content or position drifted.
23
+ * - `missing` — present, but the range no longer resolves.
24
+ * - `unanchored` — no span captured yet.
25
+ * - `unchecked` — source unreadable / repo unresolved.
26
+ */
27
+ export type SpanFreshness =
28
+ | 'full'
25
29
  | 'stale'
26
30
  | 'missing'
27
31
  | 'unanchored'
@@ -55,6 +59,11 @@ export function extractDeclarationLine(content: string, lineNumber: number): str
55
59
  export interface SubsystemOpenFileOptions {
56
60
  /** Scroll/highlight this 1-based line (declaration start or trail site). */
57
61
  startLine?: number;
62
+ /**
63
+ * End of the anchored span, when the owner carries one. Lets the snippet
64
+ * window show the construct's full extent instead of a single line.
65
+ */
66
+ endLine?: number;
58
67
  /**
59
68
  * When true with `startLine`, show the whole file scrolled to that line
60
69
  * instead of a focused snippet window.
@@ -61,7 +61,6 @@ const trails: SubsystemTrail[] = [
61
61
  to: 'reader',
62
62
  mechanism: 'calls',
63
63
  file: 'transcript.ts',
64
- line: 1,
65
64
  purl: 'pkg:github/principal-ai/agent-monitoring#transcript.ts',
66
65
  symbol: 'transcript',
67
66
  },
@@ -70,7 +69,6 @@ const trails: SubsystemTrail[] = [
70
69
  to: 'host',
71
70
  mechanism: 'calls',
72
71
  file: 'SessionReader.ts',
73
- line: 1,
74
72
  purl: 'pkg:github/principal-ai/agent-monitoring#SessionReader.ts',
75
73
  symbol: 'SessionReader',
76
74
  },
@@ -112,7 +110,7 @@ describe('subsystem graph model', () => {
112
110
  trails: [{
113
111
  id: 'w1',
114
112
  title: 'feed',
115
- steps: [{ from: 'reader', to: 'proposed-watcher', mechanism: 'feeds', file: 'x.ts', line: 1 }],
113
+ steps: [{ from: 'reader', to: 'proposed-watcher', mechanism: 'feeds', file: 'x.ts' }],
116
114
  }],
117
115
  });
118
116
  expect(nodes.find((n) => n.id === 'proposed-watcher')).toBeDefined();
@@ -455,7 +453,7 @@ describe('subsystem graph model', () => {
455
453
  trails: [{
456
454
  id: 'w1',
457
455
  title: 'call',
458
- steps: [{ from: 'a', to: 'b', mechanism: 'calls', file: 'a.ts', line: 1 }],
456
+ steps: [{ from: 'a', to: 'b', mechanism: 'calls', file: 'a.ts' }],
459
457
  }],
460
458
  });
461
459
  expect(regions.map((r) => r.key)).toEqual(['app/host']);
@@ -463,6 +461,30 @@ describe('subsystem graph model', () => {
463
461
  expect(nodes.find((n) => n.id === processGroupNodeId('app/lonely'))).toBeUndefined();
464
462
  });
465
463
 
464
+ test('codeProcessKeys marks a process region for package chrome without changing its identity', async () => {
465
+ const { regions, nodes } = await buildSubsystemGraph(
466
+ {
467
+ components: [
468
+ { alias: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
469
+ { alias: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
470
+ // Singleton, kept — the component graph passes showSingletonFrames
471
+ // true, and the fixture's real singleton (`subsystems-react/ui`)
472
+ // is exactly the demotion case.
473
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'lib/ui' },
474
+ ],
475
+ trails: [],
476
+ },
477
+ { showSingletonFrames: true, codeProcessKeys: new Set(['lib/ui']) },
478
+ );
479
+ const lib = regions.find((r) => r.key === 'lib/ui');
480
+ // Presentation only: kind stays 'process', so the group id and issue
481
+ // targeting still read the claim.
482
+ expect(lib?.kind).toBe('process');
483
+ expect(lib?.chrome).toBe('package');
484
+ expect(regions.find((r) => r.key === 'app/host')?.chrome).toBeUndefined();
485
+ expect(nodes.some((n) => n.id === processGroupNodeId('lib/ui'))).toBe(true);
486
+ });
487
+
466
488
  // NOTE: buildSubsystemGraph cannot assert ELK-built frames here — elkjs
467
489
  // constructs a Worker, which bun's test env lacks, so every call falls back
468
490
  // to manual positions. Frame existence is covered by planCompoundGroups
@@ -792,25 +814,24 @@ describe('subsystem graph model', () => {
792
814
  }
793
815
  });
794
816
 
795
- test('subsystemGraphLayoutKey ignores declarationRef-only changes', () => {
817
+ test('subsystemGraphLayoutKey ignores declarationSpan-only changes', () => {
796
818
  const base = doc;
797
- const withRef = {
819
+ const withSpan = {
798
820
  components: comps.map((c, i) =>
799
821
  i === 0
800
822
  ? {
801
823
  ...c,
802
- declarationRef: {
803
- file: c.file,
824
+ declarationSpan: {
804
825
  startLine: 42,
805
- lineHash: 'abc',
806
- capturedAt: new Date(0).toISOString(),
826
+ endLine: 48,
827
+ contentHash: 'abc',
807
828
  },
808
829
  }
809
830
  : c,
810
831
  ),
811
832
  trails,
812
833
  };
813
- expect(subsystemGraphLayoutKey(base)).toBe(subsystemGraphLayoutKey(withRef));
834
+ expect(subsystemGraphLayoutKey(base)).toBe(subsystemGraphLayoutKey(withSpan));
814
835
  });
815
836
  });
816
837
 
@@ -845,7 +866,6 @@ describe('isConstructsOnlyModel', () => {
845
866
  to: 'reader',
846
867
  mechanism: 'calls',
847
868
  file: 'transcript.ts',
848
- line: 1,
849
869
  },
850
870
  ],
851
871
  },
@@ -441,6 +441,15 @@ export interface SubsystemProcessRegion {
441
441
  label: string;
442
442
  /** Component ids that are members of this region. */
443
443
  memberAliases: string[];
444
+ /**
445
+ * Presentation override: `'package'` draws the frame with package chrome —
446
+ * rounded, dashed, transparent — instead of the solid runtime chrome
447
+ * process frames carry. For a process region the host judged code
448
+ * organisation (a library, an area, a proposal) rather than an accepted
449
+ * deployment unit. Presentation only: `kind`, node id, issue targeting and
450
+ * membership still read the claim as a process region.
451
+ */
452
+ chrome?: 'package';
444
453
  }
445
454
 
446
455
  /** Options for which boundary frames are kept. */
@@ -466,6 +475,15 @@ export interface BoundaryFrameOptions {
466
475
  * and module frames.
467
476
  */
468
477
  moduleNesting?: 'exact' | 'path';
478
+ /**
479
+ * Process keys whose frames draw package chrome (rounded, dashed) instead
480
+ * of runtime chrome (square, solid) — the host's judgment that these claims
481
+ * group code organisation (a library, an area, a proposal), not an accepted
482
+ * deployment unit. Typically derived from the accepted C4 element set: a
483
+ * process no container or external backs is code, not a box. Presentation
484
+ * only — region identity and issue targeting are unchanged.
485
+ */
486
+ codeProcessKeys?: ReadonlySet<string>;
469
487
  }
470
488
 
471
489
  /** React Flow id for a process boundary group node. */
@@ -1830,7 +1848,7 @@ export function convertSubsystemToEdges(
1830
1848
  return edges;
1831
1849
  }
1832
1850
 
1833
- /** Stable key for layout-affecting graph fields (ignores declarationRef, etc.). */
1851
+ /** Stable key for layout-affecting graph fields (ignores declarationSpan, etc.). */
1834
1852
  export function subsystemGraphLayoutKey(
1835
1853
  doc: Pick<SubsystemModelDocument, 'components' | 'trails'> & {
1836
1854
  graphifyRelations?: readonly SubsystemGraphifyRelation[];
@@ -1894,6 +1912,7 @@ export async function buildSubsystemGraph(
1894
1912
  moduleNesting,
1895
1913
  graphifyRelations,
1896
1914
  orderByLine = false,
1915
+ codeProcessKeys,
1897
1916
  } = opts;
1898
1917
  const frameOpts: BoundaryFrameOptions = {
1899
1918
  showSingletonFrames,
@@ -1904,6 +1923,16 @@ export async function buildSubsystemGraph(
1904
1923
  const edges = convertSubsystemToEdges(doc, graphifyRelations);
1905
1924
  // Nested boundary tree: package → process → module → leaves.
1906
1925
  const layoutGroups = buildBoundaryLayoutGroups(doc, frameOpts);
1926
+ // Demote host-judged code regions after grouping (both nesting variants
1927
+ // return through this call), keeping `kind` intact so ids and issue
1928
+ // targeting still read the process claim.
1929
+ if (codeProcessKeys?.size) {
1930
+ for (const g of layoutGroups) {
1931
+ if (g.region.kind === 'process' && codeProcessKeys.has(g.region.key)) {
1932
+ g.region = { ...g.region, chrome: 'package' };
1933
+ }
1934
+ }
1935
+ }
1907
1936
  const regions = layoutGroups.map((g) => g.region);
1908
1937
  const regionIds = new Set(layoutGroups.map((g) => g.id));
1909
1938
  const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
@@ -63,6 +63,88 @@ describe('SubsystemGroupNode frame color', () => {
63
63
  });
64
64
  });
65
65
 
66
+ describe('SubsystemGroupNode chrome', () => {
67
+ // Runtime chrome asserts "deployment unit": square, solid, tinted. A
68
+ // process region the host marked `chrome: 'package'` groups code
69
+ // organisation instead — a library, an area, a proposal — and draws as a
70
+ // package boundary: rounded, dashed, transparent.
71
+ test('a plain process region wears runtime chrome', () => {
72
+ const { container } = renderGroup({ region });
73
+ const frame = container.querySelector('div') as HTMLElement;
74
+ expect(frame.style.border).toContain('solid');
75
+ expect(frame.style.borderRadius).toBe('0px');
76
+ expect(frame.style.background).not.toBe('transparent');
77
+ });
78
+
79
+ test("chrome: 'package' draws the frame as a code boundary", () => {
80
+ const { container } = renderGroup({
81
+ region: { ...region, chrome: 'package' },
82
+ });
83
+ const frame = container.querySelector('div') as HTMLElement;
84
+ expect(frame.style.border).toContain('dashed');
85
+ expect(frame.style.borderRadius).toBe('12px');
86
+ expect(frame.style.background).toBe('transparent');
87
+ });
88
+
89
+ test('chrome does not change the region identity the frame renders', () => {
90
+ const { container } = renderGroup({
91
+ region: { ...region, chrome: 'package' },
92
+ });
93
+ // The badge still reads the label — kind is chrome's business alone.
94
+ expect(container.textContent).toContain('booking-web/server');
95
+ });
96
+ });
97
+
98
+ describe('SubsystemGroupNode package glyph', () => {
99
+ // A frame that renders as a package boundary wears the Package glyph: a
100
+ // real package (purl repo identity) or a process claim the host demoted to
101
+ // package chrome. Module/directory/plain-process frames stay text-only —
102
+ // the glyph is what tells the shared dashed chrome "code organisation, not
103
+ // a deployment unit".
104
+ test('a package-kind region wears the glyph', () => {
105
+ const { container } = renderGroup({
106
+ region: {
107
+ kind: 'package',
108
+ key: 'pkg:github/acme/app',
109
+ label: 'acme/app',
110
+ memberAliases: ['a', 'b'],
111
+ },
112
+ });
113
+ expect(container.querySelector('.lucide-package')).not.toBeNull();
114
+ });
115
+
116
+ test("a process region demoted to package chrome wears the glyph", () => {
117
+ const { container } = renderGroup({
118
+ region: { ...region, chrome: 'package' },
119
+ });
120
+ expect(container.querySelector('.lucide-package')).not.toBeNull();
121
+ });
122
+
123
+ test('plain process, module, and directory regions carry no glyph', () => {
124
+ expect(renderGroup({ region }).container.querySelector('.lucide-package')).toBeNull();
125
+ expect(
126
+ renderGroup({
127
+ region: {
128
+ kind: 'module',
129
+ key: 'src/bun/index.ts',
130
+ label: 'src/bun/index.ts',
131
+ memberAliases: ['a', 'b'],
132
+ },
133
+ }).container.querySelector('.lucide-package'),
134
+ ).toBeNull();
135
+ expect(
136
+ renderGroup({
137
+ region: {
138
+ kind: 'directory',
139
+ key: 'src/bun',
140
+ label: 'src/bun',
141
+ memberAliases: ['a', 'b'],
142
+ },
143
+ }).container.querySelector('.lucide-package'),
144
+ ).toBeNull();
145
+ });
146
+ });
147
+
66
148
  describe('SubsystemGroupNode boundary badge', () => {
67
149
  // A boundary finding is a property of the region's shape, so it badges the
68
150
  // frame with its own icon rather than borrowing the verification-rung chips
@@ -46,7 +46,7 @@ import {
46
46
  import { componentColor } from '../pierre/constructColors';
47
47
  import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
48
48
  import { ISSUE_KIND_ICON, ISSUE_RUNG_ICON } from './IssueList';
49
- import { Search } from 'lucide-react';
49
+ import { Package, Search } from 'lucide-react';
50
50
  import type { LucideIcon } from 'lucide-react';
51
51
 
52
52
  export const CONSTRUCT_LABEL: Record<string, string> = {
@@ -592,7 +592,18 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
592
592
  // Directory frames (path-derived nesting) render like modules — dashed,
593
593
  // collapsible path badge.
594
594
  const isModule = region?.kind === 'module' || region?.kind === 'directory';
595
- const isProcess = region?.kind === 'process';
595
+ // Runtime chrome asserts "deployment unit" — a process region the host
596
+ // marked `chrome: 'package'` groups code organisation instead (a library,
597
+ // an area, a proposal) and forgoes it, drawing like a package frame.
598
+ // Presentation only: `kind` still says process, so ids and issue targets
599
+ // key off the claim unchanged.
600
+ const isProcess = region?.kind === 'process' && region?.chrome !== 'package';
601
+ // A frame renders as a package boundary two ways: it IS a package (repo
602
+ // identity from `purl`) or the host demoted a process claim to package
603
+ // chrome (a library, an area, a proposal). Both wear the Package glyph, so
604
+ // "code organisation, not a deployment unit" reads off the canvas instead
605
+ // of hiding in the shared rounded/dashed chrome.
606
+ const isPackageFrame = region?.kind === 'package' || region?.chrome === 'package';
596
607
  const expanded = isModule && expandedLabel === label;
597
608
  const availableBadgeWidth = Math.max(0, (width ?? 400) - MODULE_BADGE_INSET * 2 - 4);
598
609
  const collapsedLabel = isModule ? moduleBadgeLabel(label, availableBadgeWidth) : label;
@@ -707,8 +718,25 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
707
718
  maxWidth: expanded ? undefined : availableBadgeWidth,
708
719
  }
709
720
  : undefined),
721
+ ...(isPackageFrame
722
+ ? {
723
+ // Icon + key on one row: the Package glyph leads the label
724
+ // so package chrome reads as code organisation at a glance.
725
+ display: 'inline-flex',
726
+ alignItems: 'center',
727
+ gap: 5,
728
+ }
729
+ : undefined),
710
730
  }}
711
731
  >
732
+ {isPackageFrame && (
733
+ <Package
734
+ size={13}
735
+ strokeWidth={2}
736
+ aria-hidden="true"
737
+ style={{ flexShrink: 0 }}
738
+ />
739
+ )}
712
740
  {canToggle ? (
713
741
  <span style={{ whiteSpace: 'nowrap' }}>
714
742
  {expanded ? label : hover ? hoveredLabel : collapsedLabel}
@@ -11,7 +11,7 @@ const trail: SubsystemTrail = {
11
11
  to: 'api',
12
12
  mechanism: 'calls',
13
13
  file: 'src/ui/login.tsx',
14
- line: 42,
14
+ span: { startLine: 42, endLine: 42, contentHash: 'h' },
15
15
  purl: 'pkg:github/acme/app',
16
16
  symbol: 'Login.submit',
17
17
  },
@@ -20,7 +20,7 @@ const trail: SubsystemTrail = {
20
20
  to: 'store',
21
21
  mechanism: 'writes',
22
22
  file: 'src/api/session.ts',
23
- line: 7,
23
+ span: { startLine: 7, endLine: 7, contentHash: 'h' },
24
24
  purl: 'pkg:github/acme/app',
25
25
  symbol: 'Session.create',
26
26
  annotation: 'session row is written here',
@@ -31,7 +31,12 @@ export function buildStepBrief(
31
31
  lines.push(`- to: \`${step.to}\``);
32
32
  lines.push(`- mechanism: \`${step.mechanism}\``);
33
33
  lines.push(`- symbol: \`${step.symbol}\``);
34
- lines.push(`- site: \`${step.file}:${step.line}\``);
34
+ const site = step.span
35
+ ? step.span.startLine === step.span.endLine
36
+ ? `${step.file}:${step.span.startLine}`
37
+ : `${step.file}:${step.span.startLine}-${step.span.endLine}`
38
+ : step.file;
39
+ lines.push(`- site: \`${site}\``);
35
40
  lines.push(`- purl: \`${step.purl}\``);
36
41
  if (step.annotation != null && step.annotation.length > 0) {
37
42
  lines.push(`- note: ${step.annotation}`);