@principal-ai/principal-view-react 0.16.56 → 0.16.58

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 (109) hide show
  1. package/dist/graphify/consolidated.d.ts +16 -1
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/index.d.ts +1 -1
  4. package/dist/graphify/index.d.ts.map +1 -1
  5. package/dist/graphify/index.js.map +1 -1
  6. package/dist/graphify/kind.d.ts +1 -1
  7. package/dist/graphify/kind.d.ts.map +1 -1
  8. package/dist/graphify/kind.js +7 -1
  9. package/dist/graphify/kind.js.map +1 -1
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/pierre/constructColors.d.ts +24 -0
  15. package/dist/pierre/constructColors.d.ts.map +1 -0
  16. package/dist/pierre/constructColors.js +91 -0
  17. package/dist/pierre/constructColors.js.map +1 -0
  18. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +8 -0
  19. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -0
  20. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +116 -0
  21. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -0
  22. package/dist/subsystem/ComponentDeclaration.d.ts +2 -2
  23. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  24. package/dist/subsystem/ComponentDeclaration.js +12 -11
  25. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  26. package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -1
  27. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  28. package/dist/subsystem/SubsystemComponentGraph.js +401 -40
  29. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  30. package/dist/subsystem/formatDeclaration.d.ts.map +1 -1
  31. package/dist/subsystem/formatDeclaration.js +49 -10
  32. package/dist/subsystem/formatDeclaration.js.map +1 -1
  33. package/dist/subsystem/model.d.ts +89 -12
  34. package/dist/subsystem/model.d.ts.map +1 -1
  35. package/dist/subsystem/model.js +55 -25
  36. package/dist/subsystem/model.js.map +1 -1
  37. package/dist/subsystem/nodes.d.ts +32 -1
  38. package/dist/subsystem/nodes.d.ts.map +1 -1
  39. package/dist/subsystem/nodes.js +122 -24
  40. package/dist/subsystem/nodes.js.map +1 -1
  41. package/dist/subsystem/tokenizeComponent.js +1 -1
  42. package/dist/subsystem/tokenizeComponent.js.map +1 -1
  43. package/dist/utils/elkLayout.d.ts.map +1 -1
  44. package/dist/utils/elkLayout.js +4 -3
  45. package/dist/utils/elkLayout.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/graphify/consolidated.ts +18 -1
  48. package/src/graphify/index.ts +1 -0
  49. package/src/graphify/kind.ts +7 -1
  50. package/src/index.ts +3 -2
  51. package/src/pierre/constructColors.test.ts +52 -0
  52. package/src/pierre/constructColors.ts +112 -0
  53. package/src/stories/{BoundaryNodes.stories.tsx → Audit/BoundaryNodes.stories.tsx} +1 -1
  54. package/src/stories/{CanvasEdgeTypes.stories.tsx → Audit/CanvasEdgeTypes.stories.tsx} +1 -1
  55. package/src/stories/{CanvasNodeTypes.stories.tsx → Audit/CanvasNodeTypes.stories.tsx} +1 -1
  56. package/src/stories/{ColorPriority.stories.tsx → Audit/ColorPriority.stories.tsx} +1 -1
  57. package/src/stories/{DiamondBadges.stories.tsx → Audit/DiamondBadges.stories.tsx} +1 -1
  58. package/src/stories/{NodeDimensionsTesting.stories.tsx → Audit/NodeDimensionsTesting.stories.tsx} +2 -2
  59. package/src/stories/{NodeFieldsAudit.stories.tsx → Audit/NodeFieldsAudit.stories.tsx} +1 -1
  60. package/src/stories/{NodeFontSizeTesting.stories.tsx → Audit/NodeFontSizeTesting.stories.tsx} +2 -2
  61. package/src/stories/{NodeShapes.stories.tsx → Audit/NodeShapes.stories.tsx} +1 -1
  62. package/src/stories/{WideNodesTesting.stories.tsx → Audit/WideNodesTesting.stories.tsx} +2 -2
  63. package/src/stories/{dashboard → Components}/DashboardRenderer.stories.tsx +2 -2
  64. package/src/stories/{GraphRenderer.stories.tsx → Components/GraphRenderer.stories.tsx} +2 -2
  65. package/src/stories/{MultiCanvasRenderer.stories.tsx → Components/MultiCanvasRenderer.stories.tsx} +2 -2
  66. package/src/stories/{SequenceDiagram.stories.tsx → Components/SequenceDiagram.stories.tsx} +2 -2
  67. package/src/stories/{StateView.stories.tsx → Components/StateView.stories.tsx} +2 -2
  68. package/src/stories/{TraceViewer.stories.tsx → Components/TraceViewer.stories.tsx} +3 -3
  69. package/src/stories/{WorkflowSequenceDiagram.stories.tsx → Components/WorkflowSequenceDiagram.stories.tsx} +1 -1
  70. package/src/stories/{AnimationWorkshop.stories.tsx → Features/AnimationWorkshop.stories.tsx} +5 -5
  71. package/src/stories/{ChangeTypeVisual.stories.tsx → Features/ChangeTypeVisual.stories.tsx} +2 -2
  72. package/src/stories/{EventDrivenAnimations.stories.tsx → Features/EventDrivenAnimations.stories.tsx} +2 -2
  73. package/src/stories/{MultiConfig.stories.tsx → Features/MultiConfig.stories.tsx} +2 -2
  74. package/src/stories/{ElkEdgeRouting.stories.tsx → Layout/ElkEdgeRouting.stories.tsx} +2 -2
  75. package/src/stories/{GraphOrientation.stories.tsx → Layout/GraphOrientation.stories.tsx} +3 -3
  76. package/src/stories/{MultiDirectionalConnections.stories.tsx → Layout/MultiDirectionalConnections.stories.tsx} +2 -2
  77. package/src/stories/{NodeDefinitionComparison.stories.tsx → Nodes/NodeDefinitionComparison.stories.tsx} +2 -2
  78. package/src/stories/{NodeNameFallbacks.stories.tsx → Nodes/NodeNameFallbacks.stories.tsx} +2 -2
  79. package/src/stories/{NumericColors.stories.tsx → Nodes/NumericColors.stories.tsx} +2 -2
  80. package/src/stories/{EventNameDisplay.stories.tsx → OTEL/EventNameDisplay.stories.tsx} +2 -2
  81. package/src/stories/{EventNameFallback.stories.tsx → OTEL/EventNameFallback.stories.tsx} +2 -2
  82. package/src/stories/{OtelComponents.stories.tsx → OTEL/OtelComponents.stories.tsx} +3 -3
  83. package/src/stories/{OtelNodeTypesPrototype.stories.tsx → OTEL/OtelNodeTypesPrototype.stories.tsx} +2 -2
  84. package/src/stories/{RawEventCards.stories.tsx → OTEL/RawEventCards.stories.tsx} +3 -3
  85. package/src/stories/{SpanBadges.stories.tsx → OTEL/SpanBadges.stories.tsx} +1 -1
  86. package/src/stories/{ValidationEventsCanvas.stories.tsx → OTEL/ValidationEventsCanvas.stories.tsx} +3 -3
  87. package/src/stories/{BacklogMdCanvases.stories.tsx → RealWorld/BacklogMdCanvases.stories.tsx} +2 -2
  88. package/src/stories/{FileCitySequence.stories.tsx → RealWorld/FileCitySequence.stories.tsx} +4 -4
  89. package/src/stories/{RealCanvasFiles.stories.tsx → RealWorld/RealCanvasFiles.stories.tsx} +7 -7
  90. package/src/stories/{ComponentDeclarationAudit.stories.tsx → Subsystem/ComponentDeclarationAudit.stories.tsx} +19 -20
  91. package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +365 -0
  92. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +137 -0
  93. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +238 -0
  94. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +315 -0
  95. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +181 -0
  96. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +959 -0
  97. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +646 -0
  98. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +129 -0
  99. package/src/subsystem/ComponentDeclaration.tsx +13 -13
  100. package/src/subsystem/SubsystemComponentGraph.tsx +558 -44
  101. package/src/subsystem/formatDeclaration.ts +57 -11
  102. package/src/subsystem/model.test.ts +38 -6
  103. package/src/subsystem/model.ts +145 -32
  104. package/src/subsystem/nodes.test.ts +56 -0
  105. package/src/subsystem/nodes.tsx +161 -31
  106. package/src/subsystem/tokenizeComponent.ts +1 -1
  107. package/src/utils/elkLayout.ts +4 -3
  108. package/src/stories/SubsystemComponentGraph.stories.tsx +0 -1225
  109. /package/src/stories/{dashboard/sample-dashboards → data}/activity-feed-analytics.dashboard.json +0 -0
@@ -7,32 +7,46 @@
7
7
  * handles all formatting.
8
8
  */
9
9
 
10
- import type { SubsystemComponent } from './model';
10
+ import type { SubsystemComponent, SubsystemComponentConstruct } from './model';
11
11
  import type { GraphifyComponentDetail } from '../graphify';
12
12
 
13
+ const TYPE_FAMILY_CONSTRUCTS: ReadonlySet<string> = new Set([
14
+ 'interface',
15
+ 'type_alias',
16
+ 'enum',
17
+ ]);
18
+
13
19
  export function generateDeclarationString(component: SubsystemComponent): string {
14
20
  const detail = component.detail;
15
- const kind = detail?.kind ?? component.kind;
21
+ // Type-family constructs own their rendering even when the detail payload
22
+ // is the shared `type` shape — the construct says which keyword is honest.
23
+ const construct = TYPE_FAMILY_CONSTRUCTS.has(component.construct)
24
+ ? component.construct
25
+ : detail?.kind ?? component.construct;
16
26
  const rawName = component.symbol || component.name || 'untitled';
17
27
  // Strip class/object prefix from dotted symbols (e.g. "SessionReader.normalize" → "normalize").
18
28
  const name = rawName.includes('.') ? rawName.split('.').pop()! : rawName;
19
29
 
20
- switch (kind) {
30
+ switch (construct) {
21
31
  case 'class':
22
32
  return generateClass(name, detail);
23
33
  case 'function':
24
34
  return generateFunction(name, detail);
25
35
  case 'method':
26
36
  return generateMethod(name, detail);
27
- case 'type':
28
- return generateType(name, detail);
37
+ case 'interface':
38
+ case 'type_alias':
39
+ case 'enum':
40
+ return generateType(name, component.construct, detail);
29
41
  case 'module':
30
42
  return generateModule(detail);
43
+ case 'store':
44
+ return generateStore(name, detail);
31
45
  case 'external':
32
46
  // Not valid TypeScript — caller should handle formatting.
33
47
  return `external '${detail?.kind === 'external' ? detail.label : name}'`;
34
48
  default:
35
- return `${kind} ${name}`;
49
+ return `${construct} ${name}`;
36
50
  }
37
51
  }
38
52
 
@@ -48,7 +62,7 @@ function formatParams(params: { name?: string; type: string }[]): string {
48
62
  }
49
63
 
50
64
  // ---------------------------------------------------------------------------
51
- // Per-kind generators
65
+ // Per-construct generators
52
66
  // ---------------------------------------------------------------------------
53
67
 
54
68
  function generateClass(name: string, detail?: GraphifyComponentDetail): string {
@@ -98,16 +112,31 @@ function generateMethod(name: string, detail?: GraphifyComponentDetail): string
98
112
  return `class ${hostClass} {\n ${name}(${params})${ret};\n}`;
99
113
  }
100
114
 
101
- function generateType(name: string, detail?: GraphifyComponentDetail): string {
115
+ function generateType(
116
+ name: string,
117
+ construct: SubsystemComponentConstruct,
118
+ detail?: GraphifyComponentDetail,
119
+ ): string {
120
+ // The type-family constructs render their declaration keyword honestly —
121
+ // the construct itself says interface / type (alias) / enum / variable.
102
122
  const tpe = detail?.kind === 'type' ? detail : undefined;
103
123
  const props = (tpe?.properties ?? [])
104
124
  .map((p) => ` ${p.name}${p.type ? `: ${p.type}` : ''};`)
105
125
  .join('\n');
106
126
 
107
- if (props) {
108
- return `interface ${name} {\n${props}\n}`;
127
+ switch (construct) {
128
+ case 'enum':
129
+ return `enum ${name} { ${(tpe?.properties ?? []).map((p) => p.name).join(', ')} }`;
130
+ case 'type_alias':
131
+ return props
132
+ ? `type ${name} = {\n${props}\n};`
133
+ : `type ${name} = unknown;`;
134
+ default:
135
+ if (props) {
136
+ return `interface ${name} {\n${props}\n}`;
137
+ }
138
+ return `interface ${name} {}`;
109
139
  }
110
- return `interface ${name} {}`;
111
140
  }
112
141
 
113
142
  function generateModule(detail?: GraphifyComponentDetail): string {
@@ -126,3 +155,20 @@ function generateModule(detail?: GraphifyComponentDetail): string {
126
155
 
127
156
  return parts.join('\n') || `module {}`;
128
157
  }
158
+
159
+ /**
160
+ * A store renders as its retained state — ambient `declare const` lines for
161
+ * the state members, never a class/method stub. The node's name labels the
162
+ * block; the access mechanism lives in separate accessor nodes.
163
+ */
164
+ function generateStore(name: string, detail?: GraphifyComponentDetail): string {
165
+ const store = detail?.kind === 'store' ? detail : undefined;
166
+ const props = (store?.properties ?? [])
167
+ .map((p) => `declare const ${p.name}${p.type ? `: ${p.type}` : ''};`)
168
+ .join('\n');
169
+
170
+ if (!props) {
171
+ return `// store: ${name} — no captured state members`;
172
+ }
173
+ return `// store: ${name}\n${props}`;
174
+ }
@@ -36,11 +36,30 @@ describe('subsystem graph model', () => {
36
36
  expect(converted[0].target).toBe('reader');
37
37
  });
38
38
 
39
+ test('buildSubsystemGraph tolerates external components with no file', async () => {
40
+ const withExternal: SubsystemComponent[] = [
41
+ ...comps,
42
+ {
43
+ id: "proposed-watcher",
44
+ name: "watchDir",
45
+ kind: "external",
46
+ // Intentionally omit file/purl — agents often leave these off for externals.
47
+ file: undefined as unknown as string,
48
+ purl: undefined as unknown as string,
49
+ },
50
+ ];
51
+ const { nodes } = await buildSubsystemGraph({
52
+ components: withExternal,
53
+ edges: [{ id: 'e3', from: 'reader', to: 'proposed-watcher', mechanism: 'feeds' }],
54
+ });
55
+ expect(nodes.find((n) => n.id === 'proposed-watcher')).toBeDefined();
56
+ });
57
+
39
58
  test('buildSubsystemGraph creates external stub nodes for non-component targets', async () => {
40
59
  const { nodes, edges: gEdges } = await buildSubsystemGraph({ components: comps, edges });
41
60
  const external = nodes.find((n) => n.id === 'external:host');
42
61
  expect(external).toBeDefined();
43
- expect(external!.data.component.kind).toBe('external');
62
+ expect(external!.data.component.construct).toBe('external');
44
63
  const crossEdge = gEdges.find((e) => e.target === 'external:host');
45
64
  expect(crossEdge).toBeDefined();
46
65
  });
@@ -62,14 +81,27 @@ describe('subsystem graph model', () => {
62
81
  });
63
82
 
64
83
  test('deriveNameFromSymbol is consistent per kind', () => {
65
- // class/type/module/function use the symbol as-is.
66
- expect(deriveNameFromSymbol('SessionReader', 'class')).toBe('SessionReader');
67
- expect(deriveNameFromSymbol('SessionRecord', 'type')).toBe('SessionRecord');
68
- // falls back to existing name when no symbol.
69
- expect(deriveNameFromSymbol(undefined, 'class', 'SessionReader')).toBe('SessionReader');
84
+ // brace-bodied constructs wear {}; module uses the symbol as-is.
85
+ expect(deriveNameFromSymbol('SessionReader', 'class')).toBe('SessionReader {}');
86
+ expect(deriveNameFromSymbol('SessionRecord', 'type_alias')).toBe('SessionRecord {}');
87
+ expect(deriveNameFromSymbol('transcript', 'module')).toBe('transcript');
88
+ // falls back to existing name when no symbol (still brace-decorated).
89
+ expect(deriveNameFromSymbol(undefined, 'class', 'SessionReader')).toBe('SessionReader {}');
70
90
  expect(deriveNameFromSymbol('', 'external', 'trail-viewer-host')).toBe('trail-viewer-host');
71
91
  });
72
92
 
93
+ test('executable constructs wear () on the node', () => {
94
+ expect(deriveNameFromSymbol('createSubsystemGraph', 'function')).toBe('createSubsystemGraph()');
95
+ // methods keep the dotted ownership symbol and wear the parens
96
+ expect(deriveNameFromSymbol('SessionCache.put', 'method')).toBe('SessionCache.put()');
97
+ // already-parenthesized labels don't double up
98
+ expect(deriveNameFromSymbol('run()', 'function')).toBe('run()');
99
+ // data-shaped constructs stay bare
100
+ expect(deriveNameFromSymbol('ROOT', 'store')).toBe('ROOT');
101
+ // brace bodies don't double up
102
+ expect(deriveNameFromSymbol('Foo {}', 'class')).toBe('Foo {}');
103
+ });
104
+
73
105
  test('deriveNameFromSymbol falls back to file basename for modules', () => {
74
106
  expect(deriveNameFromSymbol(undefined, 'module', undefined, 'transcript.ts')).toBe('transcript');
75
107
  expect(deriveNameFromSymbol(undefined, 'module', undefined, 'src/event-processors/index.ts')).toBe('index');
@@ -2,7 +2,7 @@
2
2
  * Subsystem component-graph data model + converters.
3
3
  *
4
4
  * A subsystem snapshot (see the "Subsystem artifact: facets" topic) is captured
5
- * as component nodes (kind-tagged source units), file refs, integration edges,
5
+ * as component nodes (construct-tagged source units), file refs, integration edges,
6
6
  * and entry points. This module defines the minimal document shape for the
7
7
  * *graph* facet and converts it to React Flow nodes/edges — packages render as
8
8
  * subgraphs, only cross-package edges leave the box, and shared seams
@@ -21,14 +21,30 @@ import { computeElkLayout } from '../utils/elkLayout';
21
21
  import type { GraphifyComponentDetail } from '../graphify';
22
22
  import type { SubsystemDeclarationRef } from './declarationRef';
23
23
 
24
- export type SubsystemComponentKind =
24
+ export type SubsystemComponentConstruct =
25
25
  | 'class'
26
26
  | 'function'
27
27
  | 'method'
28
- | 'type'
28
+ | 'interface'
29
+ | 'type_alias'
30
+ | 'enum'
29
31
  | 'module'
32
+ | 'store'
30
33
  | 'external';
31
34
 
35
+ /**
36
+ * Semantic role — where the node sits in the topology, orthogonal to
37
+ * `construct` (what it is). Roles have *inherited* anatomy: an entry renders
38
+ * as its real code shape (function dispatcher, type contract); a service has
39
+ * no source at all (`construct: 'external'` + purl identity). Contrast with
40
+ * `construct: 'store'`, which introduces its own anatomy (the state block) —
41
+ * that is why store is a construct and not a role. Role nodes must stay anchored to real code: an
42
+ * `entry` is a boundary element (route dispatcher, message contract) carrying
43
+ * the wire address as identity; a `service` is an external system the process
44
+ * calls out to (identity via purl, no `process` — it belongs to no region).
45
+ */
46
+ export type SubsystemComponentRole = 'entry' | 'service';
47
+
32
48
  // ---------------------------------------------------------------------------
33
49
  // Declaration tokens — structured source representation
34
50
  // ---------------------------------------------------------------------------
@@ -65,19 +81,48 @@ export type SubsystemEdgeMechanism =
65
81
  | 'contains'
66
82
  | 'feeds'
67
83
  | 'produces'
84
+ | 'writes'
85
+ | 'reads'
86
+ | 'watches'
68
87
  | 'registers-into';
69
88
 
70
- /** A component node — the named unit, kind-tagged; `file` is its location. */
89
+ /** A component node — the named unit, construct-tagged; `file` is its location. */
71
90
  export interface SubsystemComponent {
72
91
  id: string;
73
92
  name: string;
74
- kind: SubsystemComponentKind;
93
+ /**
94
+ * The node's construct — what it IS as a declaration (class, function,
95
+ * method, interface, type alias, enum, store, external), driving node
96
+ * anatomy, color, badge, and the verification strategy. Every construct
97
+ * anchors to a definition; runtime occurrences (variables, activations,
98
+ * instances) are NOT constructs — they belong to a future execution-mode
99
+ * graph whose occurrence nodes reference these definitions. Ontology:
100
+ * construct = what it is, role = where it sits, process = where it runs.
101
+ */
102
+ construct: SubsystemComponentConstruct;
75
103
  /** Source location the component lives in (repo-root-relative path). */
76
104
  file: string;
77
105
  /** PURL identifying the repo or package this component lives in (for subgraph grouping). */
78
106
  purl: string;
79
107
  /** One-line purpose shown on the node. */
80
108
  purpose?: string;
109
+ /**
110
+ * Semantic role — where the node sits in the topology (boundary element,
111
+ * external system), orthogonal to `construct` (what it is). Drives the
112
+ * glyph and the edge-pairing rules: boundary-crossing edges must terminate
113
+ * at an entry or a service. Retained state is NOT a role — it is
114
+ * `construct: 'store'` (state-block anatomy, `writes`/`reads`/`watches`
115
+ * inbound, `produces` outbound).
116
+ */
117
+ role?: SubsystemComponentRole;
118
+ /**
119
+ * Runtime process membership — which deployment unit this node is a
120
+ * member of (e.g. `trail-viewer/host`, `trail-viewer/renderer`). Nodes
121
+ * sharing a `process` are drawn inside one boundary region (grouping is
122
+ * `process ?? purl`); nodes without one sit outside every boundary
123
+ * (external actors, services, libraries).
124
+ */
125
+ process?: string;
81
126
  /** A symbol this component exposes / is (the node's identity). */
82
127
  symbol?: string;
83
128
  /**
@@ -96,7 +141,7 @@ export interface SubsystemComponent {
96
141
  capture?: 'edited' | 'analyzed' | 'referenced';
97
142
  /**
98
143
  * Graphify drill-down detail for this component, when the facet is anchored
99
- * to a graphify node. Discriminated by kind (class/function/type/module/
144
+ * to a graphify node. Discriminated by kind (class/function/type/module/store/
100
145
  * external); rendered in the detail panel on click.
101
146
  */
102
147
  detail?: GraphifyComponentDetail;
@@ -133,10 +178,42 @@ export interface SubsystemComponentEdge {
133
178
  }
134
179
 
135
180
  /**
136
- * Derive a consistent display `name` from a code `symbol` + kind.
181
+ * A single site on an existing edge — the exact `file:line` where that edge's
182
+ * seam manifests for a given flow. The edge stays the abstract contract
183
+ * (`from`, `to`, `mechanism`); a throughline step picks the concrete
184
+ * manifestation. One edge can appear in many steps.
185
+ */
186
+ export interface SubsystemThroughlineStep {
187
+ /** Id of the existing edge this hop traverses. */
188
+ edgeId: string;
189
+ /** Repo-root-relative path of the file where the edge fires. */
190
+ file: string;
191
+ /** 1-based line of the site within `file`. */
192
+ line: number;
193
+ /**
194
+ * Frame name for this hop — the function/method/symbol on the stack at
195
+ * this site. Optional so existing throughlines keep working; when set the
196
+ * flows list shows it instead of mechanism + filename.
197
+ */
198
+ symbol?: string;
199
+ }
200
+
201
+ /**
202
+ * An ordered execution story over a graph's edges — each step references an
203
+ * existing edge and the exact site where that relationship fires for a flow;
204
+ * ordering is the array. One throughline per flow (save flow, load flow, …).
205
+ */
206
+ export interface SubsystemThroughline {
207
+ id: string;
208
+ title: string;
209
+ steps: SubsystemThroughlineStep[];
210
+ }
211
+
212
+ /**
213
+ * Derive a consistent display `name` from a code `symbol` + construct.
137
214
  *
138
- * `symbol` is the source of truth (fully-qualified code identity). For most
139
- * kinds the name is the symbol itself or its last dotted segment:
215
+ * `symbol` is the source of truth (fully-qualified code identity). The name
216
+ * is the symbol itself:
140
217
  * - class/type/module/function/script/... symbol → symbol (e.g. `SessionReader`)
141
218
  * - method `Owner.method` → last segment (e.g. `SessionReader.normalize` → `normalize`)
142
219
  * - module, no symbol → basename of `file` (e.g. `transcript.ts` → `transcript`) —
@@ -145,20 +222,37 @@ export interface SubsystemComponentEdge {
145
222
  */
146
223
  export function deriveNameFromSymbol(
147
224
  symbol: string | undefined,
148
- kind: SubsystemComponentKind,
225
+ construct: SubsystemComponentConstruct,
149
226
  existingName?: string,
150
227
  file?: string,
151
228
  ): string {
229
+ let name: string | undefined;
152
230
  if (symbol && symbol.trim()) {
153
- return symbol;
154
- }
155
- // Module nodes without a symbol are whole files → use the file basename.
156
- if (kind === 'module' && file) {
231
+ name = symbol;
232
+ } else if (construct === 'module' && file) {
157
233
  const base = file.split('/').pop() ?? '';
158
234
  const clean = base.replace(/\.[^.]+$/, ''); // strip extension
159
- if (clean) return clean;
235
+ if (clean) name = clean;
236
+ }
237
+ if (!name) name = existingName ?? 'untitled';
238
+
239
+ // Decoration = what the drill-down shows. Executable constructs wear `()`
240
+ // (a signature you can call); brace-bodied constructs wear ` {}` (a member
241
+ // body — fields for types/interfaces/enums, fields+methods for classes).
242
+ // Everything else (store, variable, module, external) renders bare.
243
+ if ((construct === 'function' || construct === 'method') && !name.endsWith('()')) {
244
+ name = `${name}()`;
245
+ }
246
+ if (
247
+ (construct === 'class' ||
248
+ construct === 'interface' ||
249
+ construct === 'type_alias' ||
250
+ construct === 'enum') &&
251
+ !name.endsWith('{}')
252
+ ) {
253
+ name = `${name} {}`;
160
254
  }
161
- return existingName ?? 'untitled';
255
+ return name;
162
256
  }
163
257
 
164
258
  /**
@@ -188,6 +282,8 @@ export function formatPurl(purl: string): string {
188
282
  export interface SubsystemGraphDocument {
189
283
  components: SubsystemComponent[];
190
284
  edges: SubsystemComponentEdge[];
285
+ /** Ordered execution stories over the graph's edges (one per flow). */
286
+ throughlines?: SubsystemThroughline[];
191
287
  }
192
288
 
193
289
  // ---------------------------------------------------------------------------
@@ -202,6 +298,8 @@ export interface SubsystemGraphNodeData extends Record<string, unknown> {
202
298
  * lives in that file (spotlighted), false otherwise (dimmed). Absent when
203
299
  * no file is open — render neutrally. */
204
300
  fileMatch?: boolean;
301
+ /** True while this node is on an opened-but-unselected flow. */
302
+ dimmed?: boolean;
205
303
  }
206
304
 
207
305
  export type SubsystemGraphNode = Node<SubsystemGraphNodeData, SubsystemGraphNodeType>;
@@ -237,6 +335,9 @@ export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
237
335
  contains: '#6c5ce7', // indigo
238
336
  feeds: '#22c55e', // green — data-flow into a processor
239
337
  produces: '#e07a5f', // terracotta — emits an output type
338
+ writes: '#2f9e44', // deep green — mutates retained state
339
+ reads: '#0ea5e9', // sky — pulls from retained state
340
+ watches: '#9ca3af', // gray — observes, owns nothing
240
341
  'registers-into': '#ff6b35', // orange
241
342
  };
242
343
 
@@ -256,6 +357,9 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
256
357
  contains: 'solid',
257
358
  feeds: 'solid',
258
359
  produces: 'solid',
360
+ writes: 'solid',
361
+ reads: 'solid',
362
+ watches: 'dashed',
259
363
  'registers-into': 'dashed',
260
364
  };
261
365
 
@@ -274,14 +378,19 @@ export function packageColor(name: string): string {
274
378
  return palette[h % palette.length];
275
379
  }
276
380
 
277
- /** Color per component kind — the informative signal (rather than package). */
278
- export const KIND_COLOR: Record<SubsystemComponentKind, string> = {
279
- class: '#0893d2', // blue
280
- function: '#6c5ce7', // indigo
281
- method: '#d4a03c', // gold
282
- type: '#e07a5f', // terracotta
283
- module: '#4ec9b0', // teal
284
- external: '#b48ead', // purple
381
+ /** Color per semantic role — applied ONLY to the role badge (top-right tab on
382
+ * nodes that carry a role). The node border stays construct-colored. Note:
383
+ * `service` currently equals the old class hex; harmless while role badges
384
+ * are the only surface using it, but pick a distinct value if roles ever
385
+ * take over node borders. */
386
+ export const ROLE_COLOR: Record<SubsystemComponentRole, string> = {
387
+ entry: '#ff6b35', // orange — boundary element
388
+ service: '#0893d2', // blue — external system
389
+ };
390
+
391
+ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
392
+ entry: 'entry',
393
+ service: 'service',
285
394
  };
286
395
 
287
396
  /**
@@ -297,12 +406,16 @@ export function convertSubsystemToNodes(
297
406
  opts: { maxNodeWidth?: number } = {},
298
407
  ): SubsystemGraphNode[] {
299
408
  const { maxNodeWidth } = opts;
300
- // Group components by package.
409
+ // Group components into boundary regions by process when authored, else by
410
+ // package. Process is runtime membership (drawn as one boundary region);
411
+ // purl is code identity — nodes without a process (external actors,
412
+ // services, libraries) fall back to purl and sit outside every boundary.
301
413
  const byPkg = new Map<string, SubsystemComponent[]>();
302
414
  for (const c of doc.components) {
303
- const list = byPkg.get(c.purl) ?? [];
415
+ const regionKey = c.process ?? c.purl;
416
+ const list = byPkg.get(regionKey) ?? [];
304
417
  list.push(c);
305
- byPkg.set(c.purl, list);
418
+ byPkg.set(regionKey, list);
306
419
  }
307
420
 
308
421
  const nodes: SubsystemGraphNode[] = [];
@@ -322,7 +435,7 @@ export function convertSubsystemToNodes(
322
435
  const row = Math.floor(i / COLS);
323
436
  // Estimate rendered node width from the longest text line so ELK reserves
324
437
  // the right space (names can wrap, so we cap at the configurable max).
325
- const text = [c.symbol, c.name, c.file.split('/').pop() ?? '']
438
+ const text = [c.symbol, c.name, c.file?.split('/').pop() ?? '']
326
439
  .filter((t): t is string => !!t)
327
440
  .sort((a, b) => b.length - a.length)[0];
328
441
  const cap = maxNodeWidth ?? 300;
@@ -370,7 +483,7 @@ export function convertSubsystemToEdges(doc: SubsystemGraphDocument): SubsystemG
370
483
  target: targetId,
371
484
  data: { mechanism: e.mechanism, refs: e.refs },
372
485
  type: 'subsystem-edge',
373
- markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
486
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 32, height: 32 },
374
487
  style: { color, stroke: color, strokeDasharray: style === 'dashed' ? '6 4' : undefined },
375
488
  // `label` feeds ELK's label-space reservation only; the visible label is
376
489
  // rendered by the custom SubsystemEdge as an HTML overlay.
@@ -387,8 +500,8 @@ export function subsystemGraphLayoutKey(
387
500
  doc: Pick<SubsystemGraphDocument, 'components' | 'edges'>,
388
501
  ): string {
389
502
  const components = doc.components
390
- .map(({ id, purl, name, symbol, kind, file, purpose }) =>
391
- [id, purl, name, symbol ?? '', kind, file, purpose ?? ''].join('\0'))
503
+ .map(({ id, purl, name, symbol, construct, file, purpose, process }) =>
504
+ [id, purl, name, symbol ?? '', construct, file, purpose ?? '', process ?? ''].join('\0'))
392
505
  .sort()
393
506
  .join('\n');
394
507
  const edgeKey = doc.edges
@@ -437,7 +550,7 @@ export async function buildSubsystemGraph(
437
550
  component: {
438
551
  id: extId,
439
552
  name: label,
440
- kind: 'external',
553
+ construct: 'external',
441
554
  purl: 'external',
442
555
  file: '',
443
556
  purpose: 'cross-package integration target (not a member node)',
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility, hexWithAlpha } from './nodes';
3
+
4
+ describe('hexWithAlpha', () => {
5
+ test('appends a two-digit alpha to #rrggbb', () => {
6
+ expect(hexWithAlpha('#4ec9b0', 1)).toBe('#4ec9b0ff');
7
+ expect(hexWithAlpha('#4ec9b0', EDGE_DIM_ALPHA)).toBe('#4ec9b026');
8
+ });
9
+
10
+ test('expands #rgb', () => {
11
+ expect(hexWithAlpha('#abc', 1)).toBe('#aabbccff');
12
+ });
13
+
14
+ test('leaves non-hex values alone', () => {
15
+ expect(hexWithAlpha('teal', 0.15)).toBe('teal');
16
+ });
17
+ });
18
+
19
+ describe('fileMatchForNode', () => {
20
+ test('no open file → neutral', () => {
21
+ expect(fileMatchForNode('src/a.ts', null, false)).toBeUndefined();
22
+ });
23
+
24
+ test('open file spotlights matches and dims others', () => {
25
+ expect(fileMatchForNode('src/a.ts', 'src/a.ts', false)).toBe(true);
26
+ expect(fileMatchForNode('src/b.ts', 'src/a.ts', false)).toBe(false);
27
+ });
28
+
29
+ test('focused-edge endpoint is not dimmed when it lives in another file', () => {
30
+ expect(fileMatchForNode('src/target.ts', 'src/source.ts', true)).toBeUndefined();
31
+ expect(fileMatchForNode('src/source.ts', 'src/source.ts', true)).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe('flowElementVisibility', () => {
36
+ test('nothing open or selected → everything full', () => {
37
+ expect(flowElementVisibility({ inOpened: false, inSelected: false, anyOpened: false, anySelected: false }))
38
+ .toEqual({ hidden: false, dimmed: false });
39
+ });
40
+
41
+ test('opened, nothing selected → opened members full, rest hidden', () => {
42
+ expect(flowElementVisibility({ inOpened: true, inSelected: false, anyOpened: true, anySelected: false }))
43
+ .toEqual({ hidden: false, dimmed: false });
44
+ expect(flowElementVisibility({ inOpened: false, inSelected: false, anyOpened: true, anySelected: false }))
45
+ .toEqual({ hidden: true, dimmed: false });
46
+ });
47
+
48
+ test('selected flow or step full; other opened members dimmed; rest hidden', () => {
49
+ expect(flowElementVisibility({ inOpened: true, inSelected: true, anyOpened: true, anySelected: true }))
50
+ .toEqual({ hidden: false, dimmed: false });
51
+ expect(flowElementVisibility({ inOpened: true, inSelected: false, anyOpened: true, anySelected: true }))
52
+ .toEqual({ hidden: false, dimmed: true });
53
+ expect(flowElementVisibility({ inOpened: false, inSelected: false, anyOpened: true, anySelected: true }))
54
+ .toEqual({ hidden: true, dimmed: false });
55
+ });
56
+ });