@principal-ai/subsystems-react 0.37.12 → 0.39.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 (74) hide show
  1. package/dist/graphify/consolidated.d.ts +8 -0
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/construct.d.ts +18 -0
  4. package/dist/graphify/construct.d.ts.map +1 -1
  5. package/dist/graphify/construct.js +23 -0
  6. package/dist/graphify/construct.js.map +1 -1
  7. package/dist/graphify/index.d.ts +2 -2
  8. package/dist/graphify/index.d.ts.map +1 -1
  9. package/dist/graphify/index.js +1 -1
  10. package/dist/graphify/index.js.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  16. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +0 -1
  17. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  18. package/dist/subsystem/IssueList.d.ts +49 -3
  19. package/dist/subsystem/IssueList.d.ts.map +1 -1
  20. package/dist/subsystem/IssueList.js +180 -79
  21. package/dist/subsystem/IssueList.js.map +1 -1
  22. package/dist/subsystem/SubsystemComponentGraph.d.ts +23 -2
  23. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  24. package/dist/subsystem/SubsystemComponentGraph.js +492 -23
  25. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  26. package/dist/subsystem/formatDeclaration.js +19 -5
  27. package/dist/subsystem/formatDeclaration.js.map +1 -1
  28. package/dist/subsystem/model.d.ts +161 -7
  29. package/dist/subsystem/model.d.ts.map +1 -1
  30. package/dist/subsystem/model.js +91 -12
  31. package/dist/subsystem/model.js.map +1 -1
  32. package/dist/subsystem/nodes.d.ts.map +1 -1
  33. package/dist/subsystem/nodes.js +133 -72
  34. package/dist/subsystem/nodes.js.map +1 -1
  35. package/dist/subsystem/symbolRefs.d.ts.map +1 -1
  36. package/dist/subsystem/symbolRefs.js +2 -0
  37. package/dist/subsystem/symbolRefs.js.map +1 -1
  38. package/dist/utils/elkLayout.d.ts +9 -0
  39. package/dist/utils/elkLayout.d.ts.map +1 -1
  40. package/dist/utils/elkLayout.js +21 -2
  41. package/dist/utils/elkLayout.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/graphify/__fixtures__/ego-samples.json +545 -0
  44. package/src/graphify/consolidated.ts +8 -0
  45. package/src/graphify/construct.test.ts +61 -1
  46. package/src/graphify/construct.ts +33 -0
  47. package/src/graphify/index.ts +6 -1
  48. package/src/index.ts +13 -1
  49. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +2 -2
  50. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +9 -9
  51. package/src/stories/Subsystem/ComponentGraph/EdgeViews.stories.tsx +4 -4
  52. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +625 -0
  53. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +7 -1
  54. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
  55. package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +5 -5
  56. package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +40 -73
  57. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +6 -6
  58. package/src/stories/Subsystem/ComponentGraph/StoreValueType.stories.tsx +261 -0
  59. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +0 -1
  60. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +354 -0
  61. package/src/stories/Subsystem/StoreValueTypeDeclaration.stories.tsx +182 -0
  62. package/src/subsystem/IssueList.focus.test.tsx +113 -0
  63. package/src/subsystem/IssueList.icon.test.tsx +95 -0
  64. package/src/subsystem/IssueList.tsx +267 -111
  65. package/src/subsystem/SubsystemComponentGraph.tsx +582 -27
  66. package/src/subsystem/formatDeclaration.test.ts +64 -0
  67. package/src/subsystem/formatDeclaration.ts +20 -5
  68. package/src/subsystem/model.test.ts +75 -2
  69. package/src/subsystem/model.ts +247 -17
  70. package/src/subsystem/nodes.group.test.tsx +52 -0
  71. package/src/subsystem/nodes.tsx +112 -8
  72. package/src/subsystem/symbolRefs.ts +2 -0
  73. package/src/subsystem/toC4.test.ts +3 -3
  74. package/src/utils/elkLayout.ts +32 -1
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Ego-graph prototype: reuse the subsystem component nodes to render one
3
+ * graphify symbol at the center plus its real connections.
4
+ *
5
+ * The center + neighbors come from `graphify/__fixtures__/ego-samples.json`
6
+ * (extracted from this repo's cached graph). Each graphify node is mapped onto
7
+ * a `SubsystemComponent`; the graphify edges are passed through the dedicated
8
+ * `graphifyRelations` input, so they render with the separate
9
+ * `GRAPHIFY_RELATION_COLOR` palette — visually distinct from authored subsystem
10
+ * mechanisms — and keep their raw verb as the label (no folding to
11
+ * `references`).
12
+ */
13
+ import React from 'react';
14
+ import '@xyflow/react/dist/style.css';
15
+ import type { Meta, StoryObj } from '@storybook/react';
16
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
17
+ import { SubsystemComponentGraph } from '../../../subsystem/SubsystemComponentGraph';
18
+ import type {
19
+ SubsystemComponent,
20
+ SubsystemComponentConstruct,
21
+ SubsystemGraphifyRelation,
22
+ } from '../../../subsystem/model';
23
+ import { inferConstructFromGraphify } from '../../../graphify';
24
+ import type { GraphifyEdge, GraphifyNode } from '../../../graphify';
25
+ import egoSamples from '../../../graphify/__fixtures__/ego-samples.json';
26
+
27
+ const meta = {
28
+ title: 'Subsystem/EgoGraph',
29
+ component: SubsystemComponentGraph,
30
+ parameters: { layout: 'fullscreen' },
31
+ tags: ['autodocs'],
32
+ decorators: [
33
+ (Story) => (
34
+ <ThemeProvider theme={defaultEditorTheme}>
35
+ <Story />
36
+ </ThemeProvider>
37
+ ),
38
+ ],
39
+ } satisfies Meta<typeof SubsystemComponentGraph>;
40
+
41
+ export default meta;
42
+ type Story = StoryObj<typeof meta>;
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Fixture shape
46
+ // ---------------------------------------------------------------------------
47
+
48
+ type FixtureNode = {
49
+ id: string;
50
+ label: string;
51
+ file_type?: string;
52
+ source_file?: string;
53
+ source_location?: string;
54
+ type?: string;
55
+ };
56
+
57
+ type FixtureEdge = {
58
+ source: string;
59
+ target: string;
60
+ relation: string;
61
+ confidence?: string;
62
+ context?: string;
63
+ source_file?: string;
64
+ source_location?: string;
65
+ };
66
+
67
+ type FixtureCategory = {
68
+ center?: FixtureNode;
69
+ degree?: number;
70
+ neighborCount?: number;
71
+ neighbors?: FixtureNode[];
72
+ edges?: FixtureEdge[];
73
+ };
74
+
75
+ type Category = 'class' | 'function' | 'method' | 'type' | 'module';
76
+
77
+ const categories = egoSamples.categories as unknown as Record<Category, FixtureCategory>;
78
+ const authoredCategories = egoSamples.authoredCategories as unknown as Record<
79
+ string,
80
+ { component: SubsystemComponent }
81
+ >;
82
+
83
+ const REPO_PURL = 'pkg:github/principal-ai/subsystem-modeling';
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // graphify -> subsystem mapping
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /** The fixture center's category, expressed as a subsystem construct. */
90
+ const CENTER_CONSTRUCT: Record<Category, SubsystemComponentConstruct> = {
91
+ class: 'class',
92
+ function: 'function',
93
+ method: 'method',
94
+ type: 'interface',
95
+ // Subsystem models draw files as module *frames*, not nodes — there is no
96
+ // `module` construct. Stand in with `external` so the file node still draws.
97
+ module: 'external',
98
+ };
99
+
100
+ /** Coarse graphify-inferred construct -> subsystem construct. */
101
+ const INFERRED_TO_CONSTRUCT: Record<string, SubsystemComponentConstruct> = {
102
+ class: 'class',
103
+ function: 'function',
104
+ method: 'method',
105
+ type: 'interface',
106
+ module: 'external',
107
+ unknown: 'external',
108
+ };
109
+
110
+ function parseLine(location?: string): number | undefined {
111
+ const m = /^L(\d+)$/.exec(location ?? '');
112
+ return m ? Number(m[1]) : undefined;
113
+ }
114
+
115
+ function purlFor(sourceFile?: string): string {
116
+ return sourceFile ? `${REPO_PURL}#${sourceFile}` : REPO_PURL;
117
+ }
118
+
119
+ /**
120
+ * Graphify labels carry their own decoration: functions end `name()`, methods
121
+ * start `.name()`. A subsystem `symbol` is the bare code identity and the node
122
+ * renderer re-derives the `()` / `.` decoration itself, so passing a decorated
123
+ * label through would double it (`readFoo()()` → the name printed twice).
124
+ * Strip the graphify decoration and hand back a clean symbol.
125
+ */
126
+ function stripGraphifyDecoration(label: string): string {
127
+ let s = label.trim();
128
+ if (s.endsWith('()')) s = s.slice(0, -2);
129
+ if (s.startsWith('.')) s = s.slice(1);
130
+ return s;
131
+ }
132
+
133
+ function toComponent(
134
+ alias: string,
135
+ node: FixtureNode,
136
+ construct: SubsystemComponentConstruct,
137
+ purpose: string,
138
+ lineOverride?: number,
139
+ ): SubsystemComponent {
140
+ const line = lineOverride ?? parseLine(node.source_location);
141
+ const symbol = stripGraphifyDecoration(node.label);
142
+ return {
143
+ alias,
144
+ name: symbol,
145
+ construct,
146
+ symbol,
147
+ file: node.source_file ?? '',
148
+ purl: purlFor(node.source_file),
149
+ purpose,
150
+ // `line` also seeds ELK's same-layer ordering (orderByLine).
151
+ ...(line != null ? { line } : {}),
152
+ ...(line != null
153
+ ? {
154
+ declarationRef: {
155
+ file: node.source_file ?? '',
156
+ startLine: line,
157
+ lineHash: 'ego-fixture',
158
+ capturedAt: new Date(0).toISOString(),
159
+ },
160
+ }
161
+ : {}),
162
+ };
163
+ }
164
+
165
+ interface EgoGraph {
166
+ components: SubsystemComponent[];
167
+ graphifyRelations: SubsystemGraphifyRelation[];
168
+ }
169
+
170
+ /** Turn one fixture category into a subsystem graph centered on its symbol. */
171
+ function toEgoGraph(cat: FixtureCategory, category: Category): EgoGraph {
172
+ const center = cat.center;
173
+ if (!center) return { components: [], graphifyRelations: [] };
174
+
175
+ const edges = cat.edges ?? [];
176
+ const neighbors = cat.neighbors ?? [];
177
+
178
+ // graphify file nodes (`inferConstructFromGraphify` → 'module') are not
179
+ // symbols — they are the CONTAINER of symbols. The center's own file becomes
180
+ // a `module` boundary (a frame around its members) and its `contains` edges
181
+ // are dropped: membership is expressed by the frame, exactly as an authored
182
+ // model does it. Files reached as neighbors stay as nodes (they are external
183
+ // to the center's file), so only the center's file is framed.
184
+ const isFileNode = (n: FixtureNode): boolean =>
185
+ inferConstructFromGraphify(
186
+ n as unknown as GraphifyNode,
187
+ edges as unknown as GraphifyEdge[],
188
+ ).construct === 'module';
189
+
190
+ const centerFile = center.source_file;
191
+ const fileNodeIds = new Set(
192
+ neighbors.filter((n) => isFileNode(n)).map((n) => n.id),
193
+ );
194
+ // Neighbors that live in the center's file stay; the file node for that file
195
+ // is dropped (its containment is now the frame).
196
+ const knotless = neighbors.filter(
197
+ (n) => !(fileNodeIds.has(n.id) && n.source_file === centerFile),
198
+ );
199
+
200
+ const aliasById = new Map<string, string>([[center.id, 'center']]);
201
+ knotless.forEach((n, i) => aliasById.set(n.id, `n${i}`));
202
+ const aliasFor = (id: string): string | undefined => aliasById.get(id);
203
+
204
+ const centerConstruct = CENTER_CONSTRUCT[category];
205
+ const framedCenter: SubsystemComponent = {
206
+ ...toComponent(
207
+ 'center',
208
+ center,
209
+ centerConstruct,
210
+ `graphify center — ${center.label}`,
211
+ ),
212
+ ...(centerConstruct !== 'external' && centerFile ? { module: centerFile } : {}),
213
+ };
214
+ const components: SubsystemComponent[] = [framedCenter];
215
+
216
+ // `component.line` is the CALL-SITE line, not the declaration line: the
217
+ // edge's `source_location` on an edge FROM the center (L115, L119, …), which
218
+ // `orderByLine` uses to stack callees in the order the center calls them.
219
+ // (The neighbor's own node `source_location` — where it is declared — is
220
+ // deliberately ignored here; declaration order would read arbitrarily.)
221
+ const callLineByAlias = new Map<string, number>();
222
+ for (const e of edges) {
223
+ const from = aliasFor(e.source);
224
+ const to = aliasFor(e.target);
225
+ const line = parseLine(e.source_location);
226
+ if (from === 'center' && to && line != null) callLineByAlias.set(to, line);
227
+ }
228
+
229
+ knotless.forEach((n, i) => {
230
+ const inferred = inferConstructFromGraphify(
231
+ n as unknown as GraphifyNode,
232
+ edges as unknown as GraphifyEdge[],
233
+ ).construct;
234
+ const construct = INFERRED_TO_CONSTRUCT[inferred] ?? 'external';
235
+ const purpose =
236
+ inferred === 'module'
237
+ ? 'graphify file node in ANOTHER file — kept as a node (outside the center frame)'
238
+ : `graphify ${inferred} neighbor (${n.file_type ?? 'code'})`;
239
+ // Members of the center's file join the frame; others stand alone.
240
+ const module = n.source_file === centerFile ? centerFile : undefined;
241
+ const c = toComponent(`n${i}`, n, construct, purpose, callLineByAlias.get(`n${i}`));
242
+ components.push(module ? { ...c, module } : c);
243
+ });
244
+
245
+ // Raw graphify edges — passed straight through (no verb folding). The
246
+ // canvas colors them from GRAPHIFY_RELATION_COLOR and draws them dashed.
247
+ // `contains` is dropped: the frame carries containment, not an edge.
248
+ const graphifyRelations: SubsystemGraphifyRelation[] = edges
249
+ .map((e, i): SubsystemGraphifyRelation | null => {
250
+ if (e.relation === 'contains') return null;
251
+ const from = aliasFor(e.source);
252
+ const to = aliasFor(e.target);
253
+ if (!from || !to) return null;
254
+ return {
255
+ id: `g${i}`,
256
+ from,
257
+ to,
258
+ relation: e.relation,
259
+ refs: e.source_file ? [e.source_file] : undefined,
260
+ line: parseLine(e.source_location),
261
+ confidence: e.confidence,
262
+ context: e.context,
263
+ };
264
+ })
265
+ .filter((x): x is SubsystemGraphifyRelation => x != null);
266
+
267
+ return { components, graphifyRelations };
268
+ }
269
+
270
+ function EgoDemo({ category, orderByLine }: { category: Category; orderByLine: boolean }) {
271
+ const graph = React.useMemo(() => toEgoGraph(categories[category], category), [category]);
272
+ const cat = categories[category];
273
+ const verbs = [...new Set(graph.graphifyRelations.map((r) => r.relation))];
274
+ return (
275
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
276
+ <SubsystemComponentGraph
277
+ components={graph.components}
278
+ relations={[]}
279
+ graphifyRelations={graph.graphifyRelations}
280
+ orderByLine={orderByLine}
281
+ showSingletonFrames
282
+ title={`Ego graph — ${category}`}
283
+ description={`graphify center ${cat.center?.label ?? '?'} · ${cat.degree ?? 0} edges · verbs: ${verbs.join(', ')}${orderByLine ? ' · ordered by call-site line' : ''}`}
284
+ showEdgeLabels
285
+ hideSidebar={false}
286
+ />
287
+ </div>
288
+ );
289
+ }
290
+
291
+ /** Everything on disk for the center: node + neighbor nodes + raw edges. */
292
+ function RawDump({ category }: { category: Category }) {
293
+ const cat = categories[category];
294
+ return (
295
+ <pre
296
+ style={{
297
+ margin: 0,
298
+ padding: 12,
299
+ fontSize: 12,
300
+ lineHeight: 1.5,
301
+ fontFamily: 'monospace',
302
+ color: '#ddd',
303
+ background: '#111',
304
+ height: '100vh',
305
+ overflow: 'auto',
306
+ boxSizing: 'border-box',
307
+ }}
308
+ >
309
+ {JSON.stringify({ category, ...cat }, null, 2)}
310
+ </pre>
311
+ );
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Stories — one per graphify node category
316
+ // ---------------------------------------------------------------------------
317
+
318
+ export const Class: Story = { render: () => <EgoDemo category="class" orderByLine /> };
319
+ export const Function: Story = { render: () => <EgoDemo category="function" orderByLine /> };
320
+ export const Method: Story = { render: () => <EgoDemo category="method" orderByLine /> };
321
+ export const Type: Story = { render: () => <EgoDemo category="type" orderByLine /> };
322
+ export const Module: Story = { render: () => <EgoDemo category="module" orderByLine /> };
323
+
324
+ /** Same centers, ELK's default ordering — compare against the line-ordered set. */
325
+ export const MethodReorderOff: Story = {
326
+ render: () => <EgoDemo category="method" orderByLine={false} />,
327
+ };
328
+
329
+ /** The raw extracted data behind each center — no rendering involved. */
330
+ export const Raw: Story = { render: () => <RawDump category="class" /> };
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Authored-only constructs (no graphify node) pulled from the model store
334
+ // ---------------------------------------------------------------------------
335
+
336
+ const authoredComponents: SubsystemComponent[] = [
337
+ authoredCategories.store?.component,
338
+ authoredCategories.external?.component,
339
+ authoredCategories.custom_entity?.component,
340
+ ].filter(Boolean) as SubsystemComponent[];
341
+
342
+ /** store / external / custom_entity — authored nodes, no graphify anchor. */
343
+ export const AuthoredConstructs: Story = {
344
+ render: () => (
345
+ <div style={{ width: '100%', height: '100vh' }}>
346
+ <SubsystemComponentGraph
347
+ components={authoredComponents}
348
+ relations={[]}
349
+ title="Authored-only constructs"
350
+ description="store / external / custom_entity — reused cast from stored subsystem models (no graphify node, no edges)."
351
+ />
352
+ </div>
353
+ ),
354
+ };
@@ -0,0 +1,182 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
3
+ import { generateDeclarationString } from '../../subsystem/formatDeclaration';
4
+ import type { SubsystemComponent } from '../../subsystem/model';
5
+
6
+ /**
7
+ * StoreValueType — how a store's declared value type renders in the
8
+ * **declaration panel** (the click panel), not on the node.
9
+ *
10
+ * A store is a state declaration, so it declares its type the way every other
11
+ * declaration does: `declaration.valueType` (`Map<string, FeedState>` for a state
12
+ * block, a row type for a table), orthogonal to `declaration.storage`. The
13
+ * rendered string comes from the real `generateStore` in
14
+ * `formatDeclaration.ts` — not a sketch.
15
+ *
16
+ * The node itself is unchanged: title = the state declaration's name, badge =
17
+ * storage backing. The value type is panel-only.
18
+ */
19
+ const meta = {
20
+ title: 'Subsystem/StoreValueTypeDeclaration',
21
+ parameters: { layout: 'padded' },
22
+ tags: ['autodocs'],
23
+ decorators: [
24
+ (Story) => (
25
+ <ThemeProvider theme={defaultEditorTheme}>
26
+ <div style={{ maxWidth: '80ch', margin: '0 auto' }}>
27
+ <Story />
28
+ </div>
29
+ </ThemeProvider>
30
+ ),
31
+ ],
32
+ } satisfies Meta;
33
+ export default meta;
34
+
35
+ type Case = {
36
+ label: string;
37
+ note: string;
38
+ component: SubsystemComponent;
39
+ };
40
+
41
+ const CASES: Case[] = [
42
+ {
43
+ label: 'memory · value type, no named members',
44
+ note: 'The store declares what it holds; nothing else to render.',
45
+ component: {
46
+ id: 'feeds',
47
+ name: 'feeds',
48
+ construct: 'store',
49
+ symbol: 'feeds',
50
+ file: 'packages/subsystems-studio/src/bun/opencode-v2-live.ts',
51
+ purl: 'pkg:github/principal-ai/subsystem-modeling',
52
+ declaration: {
53
+ kind: 'store',
54
+ storage: 'memory',
55
+ properties: [],
56
+ valueType: 'Map<string, OpencodeLiveFeedState>',
57
+ },
58
+ },
59
+ },
60
+ {
61
+ label: 'memory · closure-local state',
62
+ note: 'State created inside a factory, reached through an accessor. Still a declaration.',
63
+ component: {
64
+ id: 'landed',
65
+ name: 'landed',
66
+ construct: 'store',
67
+ symbol: 'landed',
68
+ file: 'packages/subsystems-studio/src/bun/maintainer-probe.ts',
69
+ purl: 'pkg:github/principal-ai/subsystem-modeling',
70
+ declaration: {
71
+ kind: 'store',
72
+ storage: 'memory',
73
+ properties: [],
74
+ valueType: 'Map<string, number>',
75
+ },
76
+ },
77
+ },
78
+ {
79
+ label: 'disk · value type + named members',
80
+ note: 'Members render as a typed body under the value type.',
81
+ component: {
82
+ id: 'runs',
83
+ name: 'runs',
84
+ construct: 'store',
85
+ symbol: 'runs',
86
+ file: 'packages/subsystems-studio/src/bun/subsystem-model-runs.ts',
87
+ purl: 'pkg:github/principal-ai/subsystem-modeling',
88
+ declaration: {
89
+ kind: 'store',
90
+ storage: 'disk',
91
+ valueType: 'SubsystemModelRun[]',
92
+ properties: [{ name: 'runs', type: 'SubsystemModelRun[]' }],
93
+ },
94
+ },
95
+ },
96
+ {
97
+ label: 'memory · no value type → the gap',
98
+ note: 'Says so explicitly — the completeness finding reads this back.',
99
+ component: {
100
+ id: 'tabs',
101
+ name: 'tabs',
102
+ construct: 'store',
103
+ symbol: 'tabs',
104
+ file: 'packages/subsystems-studio/src/bun/index.ts',
105
+ purl: 'pkg:github/principal-ai/subsystem-modeling',
106
+ declaration: { kind: 'store', storage: 'memory', properties: [] },
107
+ },
108
+ },
109
+ {
110
+ label: 'db · row shape, no scalar value type',
111
+ note: 'A table names its row/record type; columns stay as members.',
112
+ component: {
113
+ id: 'session_v2',
114
+ name: 'session_v2',
115
+ construct: 'store',
116
+ symbol: 'session_v2',
117
+ file: 'packages/subsystems-studio/src/bun/maintain-sessions.ts',
118
+ purl: 'pkg:github/principal-ai/subsystem-modeling',
119
+ declaration: {
120
+ kind: 'store',
121
+ storage: 'external',
122
+ properties: [
123
+ { name: 'id', type: 'text' },
124
+ { name: 'title', type: 'text' },
125
+ { name: 'agent', type: 'text' },
126
+ { name: 'time_created', type: 'integer' },
127
+ ],
128
+ },
129
+ },
130
+ },
131
+ ];
132
+
133
+ function Panel({ component }: { component: SubsystemComponent }) {
134
+ return (
135
+ <pre
136
+ style={{
137
+ margin: 0,
138
+ padding: '10px 12px',
139
+ borderRadius: 6,
140
+ background: 'rgba(0,0,0,0.35)',
141
+ border: '1px solid rgba(255,255,255,0.08)',
142
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
143
+ fontSize: 12,
144
+ lineHeight: 1.5,
145
+ color: '#e5e7eb',
146
+ whiteSpace: 'pre-wrap',
147
+ overflowX: 'auto',
148
+ }}
149
+ >
150
+ {generateDeclarationString(component)}
151
+ </pre>
152
+ );
153
+ }
154
+
155
+ export const StoreDeclaration: StoryObj = {
156
+ render: () => (
157
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
158
+ <div style={{ fontSize: 12, color: '#b0b0b0', lineHeight: 1.7 }}>
159
+ <strong>Rendered by the real <code>generateStore</code>.</strong> A store declares
160
+ its type the way every other declaration does — <code>declaration.valueType</code> —
161
+ and the value type is panel-only, not a node channel. The node stays{' '}
162
+ <code>name → title</code>, <code>storage → badge</code>.
163
+ </div>
164
+ {CASES.map((c) => (
165
+ <div key={c.component.id}>
166
+ <div
167
+ style={{
168
+ fontFamily: 'ui-monospace, monospace',
169
+ fontSize: 12,
170
+ color: '#f3f4f6',
171
+ marginBottom: 6,
172
+ }}
173
+ >
174
+ {c.label}
175
+ </div>
176
+ <div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 8 }}>{c.note}</div>
177
+ <Panel component={c.component} />
178
+ </div>
179
+ ))}
180
+ </div>
181
+ ),
182
+ };
@@ -0,0 +1,113 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { createElement } from 'react';
3
+ import { Window } from 'happy-dom';
4
+ import { cleanup, fireEvent, render } from '@testing-library/react/pure';
5
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
6
+ import { SubsystemIssueList, type SubsystemIssue } from './IssueList';
7
+
8
+ // Bun's happy-dom test environment isn't registered (no @happy-dom/global-
9
+ // registrator), so wire the DOM primitives React DOM needs before rendering.
10
+ const dom = new Window();
11
+ const globals = globalThis as unknown as Record<string, unknown>;
12
+ globals.window = dom;
13
+ globals.document = dom.document;
14
+ globals.navigator = dom.navigator;
15
+ globals.HTMLElement = dom.HTMLElement;
16
+ globals.Node = dom.Node;
17
+ globals.Element = dom.Element;
18
+ globals.Event = dom.Event;
19
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
20
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
21
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
22
+ globals.localStorage = dom.localStorage;
23
+ globals.matchMedia = dom.matchMedia.bind(dom);
24
+
25
+ afterEach(cleanup);
26
+
27
+ const issues: SubsystemIssue[] = [
28
+ {
29
+ id: 'ct',
30
+ severity: 'error',
31
+ kind: 'missing_file',
32
+ message: 'construct finding',
33
+ target: { kind: 'component', id: 'alpha', label: 'Alpha' },
34
+ },
35
+ {
36
+ id: 'wl',
37
+ severity: 'info',
38
+ kind: 'walkthrough',
39
+ message: 'walkthrough finding',
40
+ target: { kind: 'walkthrough', id: 'w', label: 'Walkthrough' },
41
+ },
42
+ ];
43
+
44
+ function setup() {
45
+ const events: string[] = [];
46
+ const view = render(
47
+ createElement(
48
+ ThemeProvider,
49
+ { theme: defaultEditorTheme },
50
+ createElement(SubsystemIssueList, {
51
+ issues,
52
+ onSelectIssue: (i) => events.push(`select:${i.id}`),
53
+ onDeselectIssue: (i) => events.push(`deselect:${i.id}`),
54
+ }),
55
+ ),
56
+ );
57
+ const category = (name: string) =>
58
+ [...view.container.querySelectorAll('button')].find((b) =>
59
+ b.textContent?.includes(name),
60
+ ) as HTMLElement;
61
+ const card = (label: string) =>
62
+ [...view.container.querySelectorAll('[role="button"]')].find((b) =>
63
+ b.textContent?.includes(label),
64
+ ) as HTMLElement;
65
+ return { ...view, events, category, card };
66
+ }
67
+
68
+ describe('collapsing a category retracts the focus it established', () => {
69
+ // Closing a CATEGORY unmounts its cards, so a card stops being expanded
70
+ // without ever toggling itself. A host that framed a target on expand has to
71
+ // hear about it, or the canvas stays parked on a target the list no longer
72
+ // shows as open.
73
+ test('clicking the open category emits a deselect for its expanded card', () => {
74
+ const { events, category, card } = setup();
75
+ fireEvent.click(category('Constructs'));
76
+ fireEvent.click(card('Alpha'));
77
+ expect(events).toEqual(['select:ct']);
78
+
79
+ fireEvent.click(category('Constructs'));
80
+ expect(events).toEqual(['select:ct', 'deselect:ct']);
81
+ });
82
+
83
+ test('opening a sibling layer retracts the other layer’s card', () => {
84
+ // The accordion closes every other layer, so that layer's cards unmount
85
+ // on this click — same obligation as collapsing the layer directly.
86
+ const { events, category, card } = setup();
87
+ fireEvent.click(category('Constructs'));
88
+ fireEvent.click(card('Alpha'));
89
+
90
+ fireEvent.click(category('Walkthrough'));
91
+ expect(events).toEqual(['select:ct', 'deselect:ct']);
92
+ });
93
+
94
+ test('collapsing a layer with nothing expanded emits no deselect', () => {
95
+ const { events, category } = setup();
96
+ fireEvent.click(category('Constructs'));
97
+ fireEvent.click(category('Constructs'));
98
+ expect(events).toEqual([]);
99
+ });
100
+
101
+ test('collapsing the card itself still emits exactly one deselect', () => {
102
+ // The ref is cleared by the card's own collapse, so closing the layer
103
+ // afterwards must not re-report it.
104
+ const { events, category, card } = setup();
105
+ fireEvent.click(category('Constructs'));
106
+ fireEvent.click(card('Alpha'));
107
+ fireEvent.click(card('Alpha'));
108
+ expect(events).toEqual(['select:ct', 'deselect:ct']);
109
+
110
+ fireEvent.click(category('Constructs'));
111
+ expect(events).toEqual(['select:ct', 'deselect:ct']);
112
+ });
113
+ });