@principal-ai/subsystems-react 0.55.3 → 0.56.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 (107) hide show
  1. package/dist/index.d.ts +12 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreFileView.js +3 -3
  6. package/dist/pierre/PierreFileView.js.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.d.ts +1 -6
  8. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  9. package/dist/pierre/PierreTrailCodeView.js +21 -18
  10. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  11. package/dist/pierre/fileAvailability.d.ts +4 -7
  12. package/dist/pierre/fileAvailability.d.ts.map +1 -1
  13. package/dist/pierre/fileAvailability.js +5 -10
  14. package/dist/pierre/fileAvailability.js.map +1 -1
  15. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  16. package/dist/subsystem/C4Graph.js +1 -1
  17. package/dist/subsystem/C4Graph.js.map +1 -1
  18. package/dist/subsystem/DecorationPanel.d.ts +37 -0
  19. package/dist/subsystem/DecorationPanel.d.ts.map +1 -0
  20. package/dist/subsystem/DecorationPanel.js +147 -0
  21. package/dist/subsystem/DecorationPanel.js.map +1 -0
  22. package/dist/subsystem/FileDrawer.d.ts +3 -1
  23. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  24. package/dist/subsystem/FileDrawer.js +2 -2
  25. package/dist/subsystem/FileDrawer.js.map +1 -1
  26. package/dist/subsystem/IssueList.d.ts.map +1 -1
  27. package/dist/subsystem/IssueList.js +26 -0
  28. package/dist/subsystem/IssueList.js.map +1 -1
  29. package/dist/subsystem/SubsystemComponentGraph.d.ts +9 -5
  30. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.js +37 -47
  32. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  33. package/dist/subsystem/TrailStackBreadcrumb.d.ts +21 -0
  34. package/dist/subsystem/TrailStackBreadcrumb.d.ts.map +1 -0
  35. package/dist/subsystem/TrailStackBreadcrumb.js +77 -0
  36. package/dist/subsystem/TrailStackBreadcrumb.js.map +1 -0
  37. package/dist/subsystem/TrailStepsNav.d.ts +4 -3
  38. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
  39. package/dist/subsystem/TrailStepsNav.js +13 -16
  40. package/dist/subsystem/TrailStepsNav.js.map +1 -1
  41. package/dist/subsystem/TrailsPanel.d.ts +9 -7
  42. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  43. package/dist/subsystem/TrailsPanel.js +126 -76
  44. package/dist/subsystem/TrailsPanel.js.map +1 -1
  45. package/dist/subsystem/decoration.d.ts +112 -0
  46. package/dist/subsystem/decoration.d.ts.map +1 -0
  47. package/dist/subsystem/decoration.js +54 -0
  48. package/dist/subsystem/decoration.js.map +1 -0
  49. package/dist/subsystem/model.d.ts +5 -18
  50. package/dist/subsystem/model.d.ts.map +1 -1
  51. package/dist/subsystem/model.js +3 -11
  52. package/dist/subsystem/model.js.map +1 -1
  53. package/dist/subsystem/nodes.d.ts.map +1 -1
  54. package/dist/subsystem/nodes.js +17 -24
  55. package/dist/subsystem/nodes.js.map +1 -1
  56. package/dist/subsystem/stepEmphasis.d.ts +78 -0
  57. package/dist/subsystem/stepEmphasis.d.ts.map +1 -0
  58. package/dist/subsystem/stepEmphasis.js +89 -0
  59. package/dist/subsystem/stepEmphasis.js.map +1 -0
  60. package/dist/subsystem/trailBrief.d.ts.map +1 -1
  61. package/dist/subsystem/trailBrief.js +20 -0
  62. package/dist/subsystem/trailBrief.js.map +1 -1
  63. package/dist/subsystem/trailStack.d.ts +53 -0
  64. package/dist/subsystem/trailStack.d.ts.map +1 -0
  65. package/dist/subsystem/trailStack.js +154 -0
  66. package/dist/subsystem/trailStack.js.map +1 -0
  67. package/dist/subsystem/trailStepDisplay.d.ts +38 -0
  68. package/dist/subsystem/trailStepDisplay.d.ts.map +1 -0
  69. package/dist/subsystem/trailStepDisplay.js +41 -0
  70. package/dist/subsystem/trailStepDisplay.js.map +1 -0
  71. package/package.json +3 -3
  72. package/src/index.ts +47 -1
  73. package/src/pierre/PierreFileView.tsx +3 -3
  74. package/src/pierre/PierreTrailCodeView.tsx +25 -28
  75. package/src/pierre/fileAvailability.test.ts +0 -6
  76. package/src/pierre/fileAvailability.ts +5 -13
  77. package/src/stories/Canvases/Subsystem/Deprecated.stories.tsx +3 -17
  78. package/src/stories/Canvases/Subsystem/NodeAnatomy.stories.tsx +3 -31
  79. package/src/stories/Canvases/Subsystem/StepEmphasis.stories.tsx +528 -0
  80. package/src/stories/Canvases/Subsystem/Trails.stories.tsx +0 -187
  81. package/src/stories/Components/DecorationPanel.stories.tsx +160 -0
  82. package/src/subsystem/C4Graph.tsx +0 -1
  83. package/src/subsystem/DecorationPanel.test.tsx +128 -0
  84. package/src/subsystem/DecorationPanel.tsx +334 -0
  85. package/src/subsystem/FileDrawer.tsx +6 -0
  86. package/src/subsystem/IssueList.tsx +26 -0
  87. package/src/subsystem/SubsystemComponentGraph.tsx +68 -65
  88. package/src/subsystem/TrailStackBreadcrumb.test.tsx +109 -0
  89. package/src/subsystem/TrailStackBreadcrumb.tsx +172 -0
  90. package/src/subsystem/TrailStepsNav.test.tsx +35 -0
  91. package/src/subsystem/TrailStepsNav.tsx +18 -19
  92. package/src/subsystem/TrailsPanel.test.tsx +149 -0
  93. package/src/subsystem/TrailsPanel.tsx +165 -39
  94. package/src/subsystem/decoration.test.ts +72 -0
  95. package/src/subsystem/decoration.ts +151 -0
  96. package/src/subsystem/model.test.ts +7 -22
  97. package/src/subsystem/model.ts +6 -20
  98. package/src/subsystem/nodes.tsx +19 -28
  99. package/src/subsystem/stepEmphasis.test.ts +110 -0
  100. package/src/subsystem/stepEmphasis.ts +151 -0
  101. package/src/subsystem/trailBrief.test.ts +29 -0
  102. package/src/subsystem/trailBrief.ts +20 -0
  103. package/src/subsystem/trailStack.test.ts +133 -0
  104. package/src/subsystem/trailStack.ts +229 -0
  105. package/src/subsystem/trailStepDisplay.ts +68 -0
  106. package/src/stories/Canvases/Subsystem/Proposed.stories.tsx +0 -256
  107. package/src/stories/Canvases/Subsystem/ProposedTrails.stories.tsx +0 -184
@@ -170,7 +170,6 @@ function TrailsDemo() {
170
170
  trail,
171
171
  stepIndex,
172
172
  onOpenFile,
173
- proposedAliases,
174
173
  resolveSymbol,
175
174
  onSymbolClick,
176
175
  onVisibleStepChange,
@@ -181,7 +180,6 @@ function TrailsDemo() {
181
180
  readFile={readStoryFile}
182
181
  contextLines={4}
183
182
  onOpenFile={onOpenFile}
184
- proposedAliases={proposedAliases}
185
183
  resolveSymbol={resolveSymbol}
186
184
  onSymbolClick={onSymbolClick}
187
185
  onVisibleStepChange={onVisibleStepChange}
@@ -223,189 +221,6 @@ export const ThreeTrails: Story = {
223
221
  render: () => <TrailsDemo />,
224
222
  };
225
223
 
226
- /**
227
- * The proposed-seam failure mode: a trail step whose component is
228
- * `proposed` points at a file that isn't in the checkout yet (no story
229
- * fixture). The step must render an inline "Proposed — … isn't in the local
230
- * checkout yet." placeholder with no Open-file affordance, while the live steps
231
- * around it still render their snippets — one bad step must not blank the flow.
232
- */
233
- const proposedComponents: SubsystemComponent[] = [
234
- ...drawingComponents,
235
- {
236
- alias: 'runs',
237
- name: 'SubsystemModelRunStore',
238
- construct: 'store',
239
- file: 'src/runs/subsystemModelRuns.ts',
240
- purl: 'pkg:github/principal-ai/desktop-app',
241
- symbol: 'SubsystemModelRunStore',
242
- purpose: 'proposed store associating maintain runs with a model',
243
- proposed: true,
244
- process: 'draw-host',
245
- },
246
- ];
247
-
248
- const proposedTrails: SubsystemTrail[] = [
249
- {
250
- id: 'tl-associate-run',
251
- title: 'Associate a finished run with its model (proposed)',
252
- steps: [
253
- {
254
- from: 'host',
255
- to: 'storage',
256
- mechanism: 'calls',
257
- file: 'src/hooks/useDrawingsHost.ts',
258
- span: { startLine: 45, endLine: 45, contentHash: 'fixture' },
259
- purl: stepPurl('src/hooks/useDrawingsHost.ts'),
260
- symbol: 'useDrawingsHost.openDrawing',
261
- annotation: 'Live step — its snippet still renders.',
262
- },
263
- {
264
- from: 'storage',
265
- to: 'runs',
266
- mechanism: 'writes',
267
- file: 'src/runs/subsystemModelRuns.ts',
268
- span: { startLine: 1, endLine: 1, contentHash: 'fixture' },
269
- purl: stepPurl('src/runs/subsystemModelRuns.ts'),
270
- symbol: 'SubsystemModelRunStore.write',
271
- annotation: 'Proposed seam — the file is not in the checkout yet.',
272
- },
273
- ],
274
- },
275
- // Neighbours for the wrapped title above: a couple of one-line titles and
276
- // one that wraps to two, so the header's padding reads against the rows on
277
- // either side of it rather than in isolation.
278
- {
279
- id: 'tl-open-drawing',
280
- title: 'Open drawing',
281
- steps: [
282
- {
283
- from: 'panel',
284
- to: 'host',
285
- mechanism: 'produces',
286
- file: 'src/panels/DrawingsLeftPanel.tsx',
287
- span: { startLine: 85, endLine: 85, contentHash: 'fixture' },
288
- purl: stepPurl('src/panels/DrawingsLeftPanel.tsx'),
289
- symbol: 'DrawingsLeftPanel.openDrawing',
290
- annotation: 'Emits the open intent up to the host.',
291
- },
292
- {
293
- from: 'host',
294
- to: 'storage',
295
- mechanism: 'calls',
296
- file: 'src/hooks/useDrawingsHost.ts',
297
- span: { startLine: 45, endLine: 45, contentHash: 'fixture' },
298
- purl: stepPurl('src/hooks/useDrawingsHost.ts'),
299
- symbol: 'useDrawingsHost.openDrawing',
300
- },
301
- ],
302
- },
303
- {
304
- id: 'tl-save-drawing',
305
- title: 'Save the drawing and refresh the list',
306
- steps: [
307
- {
308
- from: 'tab',
309
- to: 'fs',
310
- mechanism: 'calls',
311
- file: 'src/components/DrawingTabContent.tsx',
312
- span: { startLine: 112, endLine: 112, contentHash: 'fixture' },
313
- purl: stepPurl('src/components/DrawingTabContent.tsx'),
314
- symbol: 'DrawingTabContent.save',
315
- },
316
- {
317
- from: 'host',
318
- to: 'panel',
319
- mechanism: 'produces',
320
- file: 'src/hooks/useDrawingsHost.ts',
321
- span: { startLine: 64, endLine: 64, contentHash: 'fixture' },
322
- purl: stepPurl('src/hooks/useDrawingsHost.ts'),
323
- symbol: 'useDrawingsHost.onSaved',
324
- },
325
- ],
326
- },
327
- {
328
- id: 'tl-delete-drawing',
329
- title: 'Delete drawing',
330
- steps: [
331
- {
332
- from: 'host',
333
- to: 'fs',
334
- mechanism: 'calls',
335
- file: 'src/hooks/useDrawingsHost.ts',
336
- span: { startLine: 66, endLine: 66, contentHash: 'fixture' },
337
- purl: stepPurl('src/hooks/useDrawingsHost.ts'),
338
- symbol: 'useDrawingsHost.deleteDrawing',
339
- },
340
- {
341
- from: 'panel',
342
- to: 'storage',
343
- mechanism: 'calls',
344
- file: 'src/panels/DrawingsLeftPanel.tsx',
345
- span: { startLine: 56, endLine: 56, contentHash: 'fixture' },
346
- purl: stepPurl('src/panels/DrawingsLeftPanel.tsx'),
347
- symbol: 'DrawingsLeftPanel.scan',
348
- },
349
- ],
350
- },
351
- ];
352
-
353
- function ProposedMissingStepDemo() {
354
- const renderTrailViewer = useCallback(
355
- ({
356
- trail,
357
- stepIndex,
358
- onOpenFile,
359
- proposedAliases,
360
- resolveSymbol,
361
- onSymbolClick,
362
- onVisibleStepChange,
363
- }: TrailViewerContext) => (
364
- <PierreTrailCodeView
365
- trail={trail}
366
- stepIndex={stepIndex}
367
- readFile={readStoryFile}
368
- contextLines={4}
369
- onOpenFile={onOpenFile}
370
- proposedAliases={proposedAliases}
371
- resolveSymbol={resolveSymbol}
372
- onSymbolClick={onSymbolClick}
373
- onVisibleStepChange={onVisibleStepChange}
374
- />
375
- ),
376
- [],
377
- );
378
-
379
- return (
380
- <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
381
- <SubsystemComponentGraph
382
- components={proposedComponents}
383
- trails={proposedTrails}
384
- title="proposed seam with a missing file"
385
- description="The **Trails** panel lists four flows so the long, wrapped title (*Associate a finished run with its model (proposed)*) reads against its neighbours — two one-line titles and one (*Save the drawing and refresh the list*) that also wraps. Expand **Associate a finished run with its model** and click either step: the proposed step has no file in the checkout, so its snippet shows an inline *Proposed — … isn't in the local checkout yet.* placeholder instead of failing the whole flow."
386
- renderTrailViewer={renderTrailViewer}
387
- renderFileViewer={(file) => (
388
- <div
389
- style={{
390
- padding: 12,
391
- fontFamily: 'monospace',
392
- fontSize: 12,
393
- color: '#bbb',
394
- whiteSpace: 'pre',
395
- }}
396
- >
397
- {`// ${file}\n …`}
398
- </div>
399
- )}
400
- />
401
- </div>
402
- );
403
- }
404
-
405
- export const ProposedMissingStep: Story = {
406
- render: () => <ProposedMissingStepDemo />,
407
- };
408
-
409
224
  // --- Clickable constructs -------------------------------------------------
410
225
  //
411
226
  // A trail step's line is an edge between two constructs. When the host
@@ -554,7 +369,6 @@ function ClickableConstructsDemo() {
554
369
  trail,
555
370
  stepIndex,
556
371
  onOpenFile,
557
- proposedAliases,
558
372
  resolveSymbol,
559
373
  onSymbolClick,
560
374
  onVisibleStepChange,
@@ -565,7 +379,6 @@ function ClickableConstructsDemo() {
565
379
  readFile={readClickableFile}
566
380
  contextLines={4}
567
381
  onOpenFile={onOpenFile}
568
- proposedAliases={proposedAliases}
569
382
  resolveSymbol={resolveSymbol}
570
383
  onSymbolClick={onSymbolClick}
571
384
  onVisibleStepChange={onVisibleStepChange}
@@ -0,0 +1,160 @@
1
+ import React, { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
4
+ import {
5
+ DecorationPanel,
6
+ type DecorationPanelProps,
7
+ } from '../../subsystem/DecorationPanel';
8
+ import {
9
+ decorationTrailKey,
10
+ type SubsystemDecoration,
11
+ } from '../../subsystem/decoration';
12
+
13
+ /**
14
+ * PROTOTYPE: the decoration container as a reading surface.
15
+ *
16
+ * The fixture deliberately spans two models (desktop + sync service) and
17
+ * includes one draft trail + draft structure, to show that a decoration is
18
+ * additive and unverified — the base models stay untouched.
19
+ */
20
+ const meta = {
21
+ title: 'Components/DecorationPanel',
22
+ component: DecorationPanel,
23
+ parameters: { layout: 'fullscreen' },
24
+ decorators: [
25
+ (Story) => (
26
+ <ThemeProvider theme={defaultEditorTheme}>
27
+ <Story />
28
+ </ThemeProvider>
29
+ ),
30
+ ],
31
+ } satisfies Meta<typeof DecorationPanel>;
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof meta>;
35
+
36
+ const decoration: SubsystemDecoration = {
37
+ version: 1,
38
+ id: 'dec-local-first-sync',
39
+ title: 'Local-first sync',
40
+ description: [
41
+ 'How a drawing reaches disk **and** the sync service, in the order a reader should follow it.',
42
+ '',
43
+ '- The desktop stays the source of truth for the open tab.',
44
+ '- The service is a projection; it never blocks the save.',
45
+ '',
46
+ '> This is a reading path, not a verified model claim.',
47
+ ].join('\n'),
48
+ trails: [
49
+ {
50
+ kind: 'base',
51
+ modelId: 'sg-desktop',
52
+ trailId: 'tl-save',
53
+ stepAnchors: [{ stepKey: 'fixture', note: 'Write first; the sync hop is optional.' }],
54
+ },
55
+ { kind: 'base', modelId: 'sg-server', trailId: 'tl-persist' },
56
+ {
57
+ kind: 'draft',
58
+ modelId: 'sg-desktop',
59
+ trail: {
60
+ id: 'tl-planned-sync',
61
+ title: 'Planned sync (draft)',
62
+ steps: [
63
+ {
64
+ from: 'tab',
65
+ to: 'sync-agent',
66
+ mechanism: 'produces',
67
+ file: 'src/components/DrawingTabContent.tsx',
68
+ purl: 'pkg:github/principal-ai/desktop-app#src/components/DrawingTabContent.tsx',
69
+ symbol: 'DrawingTabContent.emitSaved',
70
+ },
71
+ {
72
+ from: 'sync-agent',
73
+ to: 'sync-service',
74
+ mechanism: 'calls',
75
+ file: 'src/sync/syncAgent.ts',
76
+ purl: 'pkg:github/principal-ai/desktop-app#src/sync/syncAgent.ts',
77
+ symbol: 'SyncAgent.push',
78
+ },
79
+ ],
80
+ },
81
+ },
82
+ ],
83
+ draft: {
84
+ components: [
85
+ {
86
+ alias: 'sync-agent',
87
+ name: 'SyncAgent',
88
+ construct: 'class',
89
+ purpose: 'queues a saved drawing for the sync service',
90
+ process: 'draw-host',
91
+ },
92
+ {
93
+ alias: 'sync-service',
94
+ name: 'SyncService',
95
+ construct: 'external',
96
+ purpose: 'remote projection of saved drawings',
97
+ },
98
+ ],
99
+ edges: [
100
+ { from: 'tab', to: 'sync-agent', mechanism: 'produces' },
101
+ { from: 'sync-agent', to: 'sync-service', mechanism: 'calls' },
102
+ ],
103
+ },
104
+ provenance: {
105
+ source: 'author',
106
+ author: 'you',
107
+ at: '2026-10-20T12:00:00.000Z',
108
+ },
109
+ };
110
+
111
+ const resolveTrail: NonNullable<DecorationPanelProps['resolveTrail']> = (ref) => {
112
+ if (ref.kind === 'draft') return undefined;
113
+ const key = decorationTrailKey(ref);
114
+ if (key === 'sg-desktop:tl-save') return { title: 'Save drawing', stepCount: 4 };
115
+ if (key === 'sg-server:tl-persist') return { title: 'Persist file', stepCount: 5 };
116
+ return undefined;
117
+ };
118
+
119
+ const modelLabels = new Map([
120
+ ['sg-desktop', 'desktop-app'],
121
+ ['sg-server', 'sync-service'],
122
+ ]);
123
+
124
+ function Demo() {
125
+ const [active, setActive] = useState(true);
126
+ const [selected, setSelected] = useState<string>('none');
127
+
128
+ return (
129
+ <div style={{ display: 'flex', height: '100vh', background: '#0f1115' }}>
130
+ <div style={{ width: 420, height: '100%', borderRight: '1px solid #2a2f3a' }}>
131
+ <DecorationPanel
132
+ decoration={decoration}
133
+ active={active}
134
+ onToggleActive={setActive}
135
+ resolveTrail={resolveTrail}
136
+ modelLabels={modelLabels}
137
+ onSelectTrail={(ref) => setSelected(decorationTrailKey(ref))}
138
+ onSelectDraftComponent={(component) => setSelected(component.alias)}
139
+ />
140
+ </div>
141
+ <div
142
+ style={{
143
+ flex: 1,
144
+ padding: 24,
145
+ color: '#9aa4b2',
146
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
147
+ fontSize: 12,
148
+ lineHeight: 1.6,
149
+ }}
150
+ >
151
+ <div>active: {String(active)}</div>
152
+ <div>selected: {selected}</div>
153
+ </div>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ export const TwoModels: Story = {
159
+ render: () => <Demo />,
160
+ };
@@ -2711,7 +2711,6 @@ function Inner({ model, onSelectNode, onSelectEdge, onTrailClick, onToggleContai
2711
2711
  >
2712
2712
  {selectedConstruct.component.alias}
2713
2713
  {selectedConstruct.role ? ` · ${selectedConstruct.role}` : ''}
2714
- {selectedConstruct.component.proposed ? ' · proposed' : ''}
2715
2714
  </div>
2716
2715
  </div>
2717
2716
  )}
@@ -0,0 +1,128 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test';
2
+ import { Window } from 'happy-dom';
3
+ import { cleanup, fireEvent, render } from '@testing-library/react/pure';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import { DecorationPanel, type DecorationPanelProps } from './DecorationPanel';
6
+ import { decorationTrailKey, type SubsystemDecoration } from './decoration';
7
+
8
+ // Same DOM shim as TrailsPanel.test: happy-dom lacks ResizeObserver, which
9
+ // themed-markdown uses to measure its slide container.
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.MouseEvent = dom.MouseEvent;
20
+ globals.FocusEvent = dom.FocusEvent;
21
+ globals.KeyboardEvent = dom.KeyboardEvent;
22
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
23
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
24
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
25
+ globals.localStorage = dom.localStorage;
26
+ globals.sessionStorage = dom.sessionStorage;
27
+ globals.matchMedia = dom.matchMedia.bind(dom);
28
+ globals.ResizeObserver = class {
29
+ observe() {}
30
+ unobserve() {}
31
+ disconnect() {}
32
+ };
33
+
34
+ afterEach(cleanup);
35
+
36
+ const decoration: SubsystemDecoration = {
37
+ version: 1,
38
+ id: 'dec-sync',
39
+ title: 'Local-first sync',
40
+ description: 'A **reading path** for the save flow.',
41
+ trails: [
42
+ { kind: 'base', modelId: 'sg-desktop', trailId: 'tl-save' },
43
+ { kind: 'base', modelId: 'sg-server', trailId: 'tl-persist' },
44
+ {
45
+ kind: 'draft',
46
+ modelId: 'sg-desktop',
47
+ trail: { id: 'tl-planned', title: 'Planned sync', steps: [] },
48
+ },
49
+ ],
50
+ draft: {
51
+ components: [
52
+ { alias: 'sync-agent', name: 'SyncAgent', construct: 'class' },
53
+ ],
54
+ edges: [{ from: 'tab', to: 'sync-agent', mechanism: 'produces' }],
55
+ },
56
+ provenance: { source: 'author', at: '2026-10-20T00:00:00.000Z' },
57
+ };
58
+
59
+ const resolveTrail: NonNullable<DecorationPanelProps['resolveTrail']> = (ref) => {
60
+ if (ref.kind === 'draft') return undefined;
61
+ const key = decorationTrailKey(ref);
62
+ if (key === 'sg-desktop:tl-save') return { title: 'Save drawing', stepCount: 4 };
63
+ if (key === 'sg-server:tl-persist') return { title: 'Persist file', stepCount: 5 };
64
+ return undefined;
65
+ };
66
+
67
+ function renderPanel(overrides: Partial<DecorationPanelProps> = {}) {
68
+ const props: DecorationPanelProps = {
69
+ decoration,
70
+ active: true,
71
+ onToggleActive: () => {},
72
+ resolveTrail,
73
+ modelLabels: new Map([
74
+ ['sg-desktop', 'desktop-app'],
75
+ ['sg-server', 'sync-service'],
76
+ ]),
77
+ ...overrides,
78
+ };
79
+ return render(
80
+ <ThemeProvider theme={defaultEditorTheme}>
81
+ <DecorationPanel {...props} />
82
+ </ThemeProvider>,
83
+ );
84
+ }
85
+
86
+ describe('DecorationPanel', () => {
87
+ test('renders the description, grouped trails, and draft structure', () => {
88
+ const { getByText, container } = renderPanel();
89
+ expect(container.textContent).toContain('reading path');
90
+ expect(getByText('desktop-app')).not.toBeNull();
91
+ expect(getByText('sync-service')).not.toBeNull();
92
+ expect(getByText('Save drawing')).not.toBeNull();
93
+ expect(getByText('Persist file')).not.toBeNull();
94
+ expect(getByText('Planned sync')).not.toBeNull();
95
+ expect(getByText('draft')).not.toBeNull();
96
+ expect(getByText('SyncAgent')).not.toBeNull();
97
+ expect(getByText('+ 1 draft edge')).not.toBeNull();
98
+ });
99
+
100
+ test('the toggle reports the next active state', () => {
101
+ const onToggleActive = mock(() => {});
102
+ const { getByLabelText } = renderPanel({ onToggleActive });
103
+ fireEvent.click(getByLabelText('Turn decoration off'));
104
+ expect(onToggleActive).toHaveBeenCalledWith(false);
105
+ });
106
+
107
+ test('selecting a trail hands the ref back to the host', () => {
108
+ const onSelectTrail = mock(() => {});
109
+ const { getByText } = renderPanel({ onSelectTrail });
110
+ fireEvent.click(getByText('Save drawing'));
111
+ expect(onSelectTrail).toHaveBeenCalledWith({
112
+ kind: 'base',
113
+ modelId: 'sg-desktop',
114
+ trailId: 'tl-save',
115
+ });
116
+ });
117
+
118
+ test('selecting a draft component hands the component back', () => {
119
+ const onSelectDraftComponent = mock(() => {});
120
+ const { getByText } = renderPanel({ onSelectDraftComponent });
121
+ fireEvent.click(getByText('SyncAgent'));
122
+ expect(onSelectDraftComponent).toHaveBeenCalledWith({
123
+ alias: 'sync-agent',
124
+ name: 'SyncAgent',
125
+ construct: 'class',
126
+ });
127
+ });
128
+ });