@principal-ai/subsystems-react 0.56.0 → 0.56.1

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 (32) hide show
  1. package/dist/index.d.ts +0 -6
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +0 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts +3 -3
  6. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.js +16 -16
  8. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  9. package/dist/pierre/trailSnippet.d.ts +10 -0
  10. package/dist/pierre/trailSnippet.d.ts.map +1 -0
  11. package/dist/pierre/trailSnippet.js +22 -0
  12. package/dist/pierre/trailSnippet.js.map +1 -0
  13. package/package.json +1 -1
  14. package/src/index.ts +0 -37
  15. package/src/pierre/PierreTrailCodeView.tsx +14 -23
  16. package/src/pierre/trailSnippet.test.ts +74 -0
  17. package/src/pierre/trailSnippet.ts +35 -0
  18. package/dist/subsystem/DecorationPanel.d.ts +0 -37
  19. package/dist/subsystem/DecorationPanel.d.ts.map +0 -1
  20. package/dist/subsystem/DecorationPanel.js +0 -147
  21. package/dist/subsystem/DecorationPanel.js.map +0 -1
  22. package/dist/subsystem/decoration.d.ts +0 -112
  23. package/dist/subsystem/decoration.d.ts.map +0 -1
  24. package/dist/subsystem/decoration.js +0 -54
  25. package/dist/subsystem/decoration.js.map +0 -1
  26. package/src/stories/Canvases/Subsystem/StepEmphasis.stories.tsx +0 -528
  27. package/src/stories/Components/DecorationPanel.stories.tsx +0 -160
  28. package/src/subsystem/DecorationPanel.test.tsx +0 -128
  29. package/src/subsystem/DecorationPanel.tsx +0 -334
  30. package/src/subsystem/decoration.test.ts +0 -72
  31. package/src/subsystem/decoration.ts +0 -151
  32. package/src/subsystem/stepEmphasis.test.ts +0 -110
@@ -1,128 +0,0 @@
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
- });
@@ -1,334 +0,0 @@
1
- /**
2
- * DecorationPanel — the reading surface for one `SubsystemDecoration`.
3
- *
4
- * A decoration is an additive, unverified layer over one or more subsystem
5
- * models: markdown prose, an ordered list of trails (each naming its own
6
- * model), and optional draft structure. This panel is the container's own
7
- * surface — it deliberately does not render the graph. Selecting a trail (or
8
- * a draft component) is handed back to the host, which decides whether to
9
- * focus a model, open a tab, or overlay the draft.
10
- *
11
- * Nothing here writes: the first prototype is read-only plus an on/off toggle.
12
- */
13
-
14
- import type { CSSProperties } from 'react';
15
- import { useTheme } from '@principal-ade/industry-theme';
16
- import { Box, Eye, EyeOff, Network, Route, Sparkles } from 'lucide-react';
17
- import { IndustryMarkdownSlide } from 'themed-markdown';
18
- import {
19
- decorationTrailId,
20
- groupDecorationTrailsByModel,
21
- type DecorationDraftComponent,
22
- type DecorationTrailRef,
23
- type SubsystemDecoration,
24
- } from './decoration';
25
-
26
- export interface DecorationPanelTrailMeta {
27
- title: string;
28
- stepCount?: number;
29
- }
30
-
31
- export interface DecorationPanelProps {
32
- decoration: SubsystemDecoration;
33
- /** Whether the decoration overlay is currently on. */
34
- active: boolean;
35
- onToggleActive: (active: boolean) => void;
36
- /** Click a trail row. The host focuses the owning model/trail. */
37
- onSelectTrail?: (ref: DecorationTrailRef) => void;
38
- /** Click a draft component. Omit to render the draft list read-only. */
39
- onSelectDraftComponent?: (component: DecorationDraftComponent) => void;
40
- /**
41
- * Resolve a base trail's display title/step count from its owning model.
42
- * Draft trails carry their content inline and need no resolver.
43
- */
44
- resolveTrail?: (ref: DecorationTrailRef) => DecorationPanelTrailMeta | undefined;
45
- /** Friendly model names, keyed by `modelId`. Falls back to the id. */
46
- modelLabels?: ReadonlyMap<string, string>;
47
- }
48
-
49
- export function DecorationPanel({
50
- decoration,
51
- active,
52
- onToggleActive,
53
- onSelectTrail,
54
- onSelectDraftComponent,
55
- resolveTrail,
56
- modelLabels,
57
- }: DecorationPanelProps) {
58
- const { theme } = useTheme();
59
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
60
- const secondary = theme.colors.textSecondary ?? muted;
61
- const warning = theme.colors.warning ?? secondary;
62
- const accent = theme.colors.accent ?? theme.colors.primary ?? theme.colors.text;
63
- const groups = groupDecorationTrailsByModel(decoration.trails);
64
- const draftComponents = decoration.draft?.components ?? [];
65
- const draftEdges = decoration.draft?.edges ?? [];
66
- const sectionLabel: CSSProperties = {
67
- fontFamily: theme.fonts.monospace,
68
- fontSize: theme.fontSizes[0],
69
- fontWeight: theme.fontWeights.semibold,
70
- letterSpacing: '0.06em',
71
- textTransform: 'uppercase',
72
- };
73
-
74
- return (
75
- <div
76
- style={{
77
- display: 'flex',
78
- flexDirection: 'column',
79
- width: '100%',
80
- height: '100%',
81
- minHeight: 0,
82
- background: theme.colors.background,
83
- color: theme.colors.text,
84
- }}
85
- >
86
- <header
87
- style={{
88
- flexShrink: 0,
89
- padding: '14px 16px 10px',
90
- borderBottom: `1px solid ${theme.colors.border}`,
91
- }}
92
- >
93
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
94
- <Sparkles size={15} strokeWidth={2} color={accent} aria-hidden />
95
- <h2
96
- style={{
97
- flex: 1,
98
- minWidth: 0,
99
- margin: 0,
100
- overflowWrap: 'anywhere',
101
- fontFamily: theme.fonts.monospace,
102
- fontSize: theme.fontSizes[2],
103
- fontWeight: theme.fontWeights.semibold,
104
- }}
105
- >
106
- {decoration.title}
107
- </h2>
108
- <button
109
- type="button"
110
- aria-pressed={active}
111
- aria-label={active ? 'Turn decoration off' : 'Turn decoration on'}
112
- title={active ? 'Turn decoration off' : 'Turn decoration on'}
113
- onClick={() => onToggleActive(!active)}
114
- style={{
115
- display: 'inline-flex',
116
- alignItems: 'center',
117
- gap: 4,
118
- flexShrink: 0,
119
- padding: '3px 8px',
120
- borderRadius: 4,
121
- border: `1px solid ${active ? accent : theme.colors.border}`,
122
- background: active ? `${accent}18` : 'transparent',
123
- color: active ? accent : muted,
124
- fontFamily: theme.fonts.monospace,
125
- fontSize: theme.fontSizes[0],
126
- cursor: 'pointer',
127
- }}
128
- >
129
- {active ? <Eye size={12} strokeWidth={2} /> : <EyeOff size={12} strokeWidth={2} />}
130
- {active ? 'On' : 'Off'}
131
- </button>
132
- </div>
133
- </header>
134
-
135
- <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 16px 18px' }}>
136
- {decoration.description && (
137
- <div style={{ fontSize: theme.fontSizes[1], lineHeight: theme.lineHeights.body }}>
138
- <IndustryMarkdownSlide
139
- content={decoration.description}
140
- slideIdPrefix={`decoration-${decoration.id}`}
141
- slideIndex={0}
142
- isVisible={true}
143
- theme={theme}
144
- disableScroll={true}
145
- disableBasePadding
146
- enableKeyboardScrolling={false}
147
- autoFocusOnVisible={false}
148
- />
149
- </div>
150
- )}
151
-
152
- <section
153
- style={{
154
- marginTop: 16,
155
- paddingTop: 14,
156
- borderTop: `1px solid ${theme.colors.border}`,
157
- }}
158
- >
159
- <div style={{ ...sectionLabel, color: secondary }}>
160
- Trails · {decoration.trails.length}
161
- </div>
162
- {groups.map((group) => (
163
- <div key={group.modelId}>
164
- <div
165
- style={{
166
- display: 'flex',
167
- alignItems: 'center',
168
- gap: 6,
169
- margin: '12px 0 3px',
170
- }}
171
- >
172
- <Network size={14} strokeWidth={2} color={secondary} aria-hidden />
173
- <span
174
- style={{
175
- minWidth: 0,
176
- overflow: 'hidden',
177
- textOverflow: 'ellipsis',
178
- whiteSpace: 'nowrap',
179
- fontFamily: theme.fonts.monospace,
180
- fontSize: theme.fontSizes[1],
181
- fontWeight: theme.fontWeights.semibold,
182
- color: theme.colors.text,
183
- }}
184
- >
185
- {modelLabels?.get(group.modelId) ?? group.modelId}
186
- </span>
187
- </div>
188
- {group.trails.map((ref) => {
189
- const meta = resolveTrail?.(ref);
190
- const title =
191
- meta?.title ??
192
- (ref.kind === 'draft' ? ref.trail.title : ref.trailId);
193
- const stepCount =
194
- meta?.stepCount ??
195
- (ref.kind === 'draft' ? ref.trail.steps.length : undefined);
196
- return (
197
- <button
198
- key={decorationTrailId(ref)}
199
- type="button"
200
- onClick={() => onSelectTrail?.(ref)}
201
- style={{
202
- display: 'flex',
203
- alignItems: 'center',
204
- gap: 8,
205
- width: '100%',
206
- minWidth: 0,
207
- padding: '6px 6px 6px 0',
208
- border: 'none',
209
- borderRadius: 4,
210
- background: 'transparent',
211
- color: theme.colors.text,
212
- textAlign: 'left',
213
- cursor: onSelectTrail ? 'pointer' : 'default',
214
- }}
215
- >
216
- <Route size={13} strokeWidth={2} color={muted} aria-hidden />
217
- <span
218
- style={{
219
- flex: 1,
220
- minWidth: 0,
221
- overflow: 'hidden',
222
- textOverflow: 'ellipsis',
223
- whiteSpace: 'nowrap',
224
- fontFamily: theme.fonts.monospace,
225
- fontSize: theme.fontSizes[1],
226
- }}
227
- >
228
- {title}
229
- </span>
230
- {ref.kind === 'draft' && (
231
- <span
232
- style={{
233
- flexShrink: 0,
234
- padding: '1px 5px',
235
- borderRadius: 3,
236
- border: `1px solid ${warning}`,
237
- color: warning,
238
- fontFamily: theme.fonts.monospace,
239
- fontSize: theme.fontSizes[0],
240
- }}
241
- >
242
- draft
243
- </span>
244
- )}
245
- {stepCount != null && (
246
- <span
247
- style={{
248
- flexShrink: 0,
249
- fontFamily: theme.fonts.monospace,
250
- fontSize: theme.fontSizes[0],
251
- color: secondary,
252
- }}
253
- >
254
- {stepCount}
255
- </span>
256
- )}
257
- </button>
258
- );
259
- })}
260
- </div>
261
- ))}
262
- </section>
263
-
264
- {(draftComponents.length > 0 || draftEdges.length > 0) && (
265
- <section style={{ marginTop: 18 }}>
266
- <div style={{ ...sectionLabel, color: secondary }}>
267
- Draft structure · not verified
268
- </div>
269
- {draftComponents.map((component) => (
270
- <button
271
- key={component.alias}
272
- type="button"
273
- onClick={() => onSelectDraftComponent?.(component)}
274
- style={{
275
- display: 'flex',
276
- alignItems: 'center',
277
- gap: 8,
278
- width: '100%',
279
- minWidth: 0,
280
- padding: '6px 6px 6px 0',
281
- border: 'none',
282
- borderRadius: 4,
283
- background: 'transparent',
284
- color: theme.colors.text,
285
- textAlign: 'left',
286
- cursor: onSelectDraftComponent ? 'pointer' : 'default',
287
- }}
288
- >
289
- <Box size={13} strokeWidth={2} color={muted} aria-hidden />
290
- <span
291
- style={{
292
- flex: 1,
293
- minWidth: 0,
294
- overflow: 'hidden',
295
- textOverflow: 'ellipsis',
296
- whiteSpace: 'nowrap',
297
- fontFamily: theme.fonts.monospace,
298
- fontSize: theme.fontSizes[1],
299
- }}
300
- >
301
- {component.name}
302
- </span>
303
- {component.construct && (
304
- <span
305
- style={{
306
- flexShrink: 0,
307
- fontFamily: theme.fonts.monospace,
308
- fontSize: theme.fontSizes[0],
309
- color: secondary,
310
- }}
311
- >
312
- {component.construct}
313
- </span>
314
- )}
315
- </button>
316
- ))}
317
- {draftEdges.length > 0 && (
318
- <div
319
- style={{
320
- marginTop: 4,
321
- fontFamily: theme.fonts.monospace,
322
- fontSize: theme.fontSizes[0],
323
- color: secondary,
324
- }}
325
- >
326
- + {draftEdges.length} draft edge{draftEdges.length === 1 ? '' : 's'}
327
- </div>
328
- )}
329
- </section>
330
- )}
331
- </div>
332
- </div>
333
- );
334
- }
@@ -1,72 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import type { SubsystemTrail } from './model';
3
- import {
4
- decorationModelIds,
5
- decorationTrailId,
6
- decorationTrailKey,
7
- findDecorationTrail,
8
- groupDecorationTrailsByModel,
9
- type DecorationTrailRef,
10
- } from './decoration';
11
-
12
- function ref(modelId: string, trailId: string): DecorationTrailRef {
13
- return { kind: 'base', modelId, trailId };
14
- }
15
-
16
- const draftTrail: SubsystemTrail = {
17
- id: 'planned-open',
18
- title: 'Planned open',
19
- steps: [],
20
- };
21
-
22
- describe('decoration trail refs', () => {
23
- test('keys are scoped by model', () => {
24
- expect(decorationTrailKey(ref('model-a', 'open'))).toBe('model-a:open');
25
- expect(decorationTrailKey(ref('model-a', 'open'))).not.toBe(
26
- decorationTrailKey(ref('model-b', 'open')),
27
- );
28
- });
29
-
30
- test('a draft trail keys like a base trail', () => {
31
- const draft: DecorationTrailRef = {
32
- kind: 'draft',
33
- modelId: 'model-a',
34
- trail: draftTrail,
35
- };
36
- expect(decorationTrailKey(draft)).toBe('model-a:planned-open');
37
- expect(decorationTrailId(draft)).toBe('planned-open');
38
- });
39
-
40
- test('groups trails by their own model, preserving authored order', () => {
41
- const groups = groupDecorationTrailsByModel([
42
- ref('web', 'open'),
43
- ref('server', 'handle'),
44
- ref('web', 'save'),
45
- ref('worker', 'consume'),
46
- ]);
47
- expect(groups.map((g) => g.modelId)).toEqual(['web', 'server', 'worker']);
48
- expect(groups[0]!.trails.map((t) => decorationTrailId(t))).toEqual([
49
- 'open',
50
- 'save',
51
- ]);
52
- expect(groups[1]!.trails.map((t) => decorationTrailId(t))).toEqual([
53
- 'handle',
54
- ]);
55
- });
56
-
57
- test('lists distinct models in first-seen order', () => {
58
- expect(
59
- decorationModelIds([
60
- ref('web', 'open'),
61
- ref('server', 'handle'),
62
- ref('web', 'save'),
63
- ]),
64
- ).toEqual(['web', 'server']);
65
- });
66
-
67
- test('finds a trail by model and trail id, not trail id alone', () => {
68
- const refs = [ref('web', 'open'), ref('server', 'open')];
69
- expect(findDecorationTrail(refs, 'server', 'open')?.modelId).toBe('server');
70
- expect(findDecorationTrail(refs, 'worker', 'open')).toBeUndefined();
71
- });
72
- });
@@ -1,151 +0,0 @@
1
- /**
2
- * Subsystem decoration — PROTOTYPE contract for the ephemeral, unverified layer.
3
- *
4
- * A decoration composes trails from one or more models, carries a markdown
5
- * description, and may add draft structure. The base model stays verified-only;
6
- * a decoration is never audited against source.
7
- *
8
- * **Each trail ref names its own model.** There is no decoration-level `base`
9
- * field: a decoration can mix models by construction, and a single-model
10
- * decoration is just the case where every ref happens to share a `modelId`.
11
- *
12
- * A trail can be either:
13
- * - `base` — a reference to a trail that already exists in a model; or
14
- * - `draft` — a trail that only exists in the decoration. This is what
15
- * migration needs when a base trail depends on draft components: the whole
16
- * trail moves into the decoration rather than dangling in the base.
17
- *
18
- * The contract is deliberately host-local and not part of the portable
19
- * subsystem document. If decorations become shareable, `modelId` needs a
20
- * portable identity key (the local `sg-...` ids are re-minted).
21
- */
22
-
23
- import type { SubsystemTrail, SubsystemTrailMechanism } from './model';
24
-
25
- export interface DecorationStepAnchor {
26
- /** Claim fingerprint of the anchored step (see `stepClaimKey`). */
27
- stepKey: string;
28
- /** Optional per-anchor note. The decoration description carries the prose. */
29
- note?: string;
30
- }
31
-
32
- interface DecorationTrailRefBase {
33
- /** Store-local model id. The trail id is only unique within this model. */
34
- modelId: string;
35
- /** Optional step-level anchors inside this trail. */
36
- stepAnchors?: DecorationStepAnchor[];
37
- }
38
-
39
- /** A trail that already exists in the base model. */
40
- export interface DecorationBaseTrailRef extends DecorationTrailRefBase {
41
- kind: 'base';
42
- trailId: string;
43
- }
44
-
45
- /** A speculative trail that exists only in the decoration. */
46
- export interface DecorationDraftTrailRef extends DecorationTrailRefBase {
47
- kind: 'draft';
48
- trail: SubsystemTrail;
49
- }
50
-
51
- export type DecorationTrailRef =
52
- | DecorationBaseTrailRef
53
- | DecorationDraftTrailRef;
54
-
55
- /**
56
- * Draft structure: a component that does not exist in source yet. No
57
- * `file`/`symbol` — those are the verification coordinates the base model
58
- * requires, and a draft component deliberately has none.
59
- */
60
- export interface DecorationDraftComponent {
61
- alias: string;
62
- name: string;
63
- /** Intended construct shape (`class`, `function`, …). */
64
- construct?: string;
65
- purpose?: string;
66
- module?: string;
67
- process?: string;
68
- }
69
-
70
- /** A speculative edge between base or draft aliases. */
71
- export interface DecorationDraftEdge {
72
- from: string;
73
- to: string;
74
- mechanism: SubsystemTrailMechanism;
75
- }
76
-
77
- export interface DecorationDraft {
78
- components?: DecorationDraftComponent[];
79
- edges?: DecorationDraftEdge[];
80
- }
81
-
82
- export interface DecorationProvenance {
83
- source: 'author' | 'agent';
84
- author?: string;
85
- /** ISO timestamp. */
86
- at: string;
87
- }
88
-
89
- export interface SubsystemDecoration {
90
- version: 1;
91
- id: string;
92
- title: string;
93
- /** Markdown, rendered like the model description. */
94
- description: string;
95
- /**
96
- * Ordered trails the decoration cares about. Order is the reading order;
97
- * `modelId` may vary from ref to ref. A trail is either a base ref or a
98
- * draft trail that lives only here.
99
- */
100
- trails: DecorationTrailRef[];
101
- /** Optional draft delta. Absent = a pure reading path. */
102
- draft?: DecorationDraft;
103
- provenance: DecorationProvenance;
104
- }
105
-
106
- /** The trail id for either kind of ref. */
107
- export function decorationTrailId(ref: DecorationTrailRef): string {
108
- return ref.kind === 'base' ? ref.trailId : ref.trail.id;
109
- }
110
-
111
- /** Stable key for a trail ref across models. `trailId` alone is not enough. */
112
- export function decorationTrailKey(
113
- ref: Pick<DecorationTrailRef, 'modelId'> & { trailId?: string; trail?: SubsystemTrail },
114
- ): string {
115
- const trailId = ref.trailId ?? ref.trail?.id ?? '';
116
- return `${ref.modelId}:${trailId}`;
117
- }
118
-
119
- /**
120
- * Group trail refs by their owning model, preserving first-seen model order
121
- * and the authored trail order within each group. The UI uses this to render
122
- * "which trails it cares about" without a separate model list.
123
- */
124
- export function groupDecorationTrailsByModel(
125
- refs: readonly DecorationTrailRef[],
126
- ): Array<{ modelId: string; trails: DecorationTrailRef[] }> {
127
- const groups = new Map<string, DecorationTrailRef[]>();
128
- for (const ref of refs) {
129
- const existing = groups.get(ref.modelId);
130
- if (existing) existing.push(ref);
131
- else groups.set(ref.modelId, [ref]);
132
- }
133
- return [...groups].map(([modelId, trails]) => ({ modelId, trails }));
134
- }
135
-
136
- /** The distinct models a decoration touches, in first-seen order. */
137
- export function decorationModelIds(
138
- refs: readonly DecorationTrailRef[],
139
- ): string[] {
140
- return [...new Set(refs.map((ref) => ref.modelId))];
141
- }
142
-
143
- export function findDecorationTrail(
144
- refs: readonly DecorationTrailRef[],
145
- modelId: string,
146
- trailId: string,
147
- ): DecorationTrailRef | undefined {
148
- return refs.find(
149
- (ref) => ref.modelId === modelId && decorationTrailId(ref) === trailId,
150
- );
151
- }