@principal-ai/principal-view-react 0.16.44 → 0.16.46

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 (31) hide show
  1. package/dist/components/session-events/SessionEventFeed.d.ts +5 -4
  2. package/dist/components/session-events/SessionEventFeed.d.ts.map +1 -1
  3. package/dist/components/session-events/SessionEventFeed.js +102 -5
  4. package/dist/components/session-events/SessionEventFeed.js.map +1 -1
  5. package/dist/subsystem/ComponentDeclaration.d.ts +3 -1
  6. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  7. package/dist/subsystem/ComponentDeclaration.js +49 -75
  8. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  9. package/dist/subsystem/formatDeclaration.d.ts +11 -0
  10. package/dist/subsystem/formatDeclaration.d.ts.map +1 -0
  11. package/dist/subsystem/formatDeclaration.js +96 -0
  12. package/dist/subsystem/formatDeclaration.js.map +1 -0
  13. package/dist/subsystem/model.d.ts +11 -0
  14. package/dist/subsystem/model.d.ts.map +1 -1
  15. package/dist/subsystem/model.js.map +1 -1
  16. package/dist/subsystem/tokenizeComponent.d.ts +19 -0
  17. package/dist/subsystem/tokenizeComponent.d.ts.map +1 -0
  18. package/dist/subsystem/tokenizeComponent.js +61 -0
  19. package/dist/subsystem/tokenizeComponent.js.map +1 -0
  20. package/dist/subsystem/tokenizeFormatted.d.ts +15 -0
  21. package/dist/subsystem/tokenizeFormatted.d.ts.map +1 -0
  22. package/dist/subsystem/tokenizeFormatted.js +84 -0
  23. package/dist/subsystem/tokenizeFormatted.js.map +1 -0
  24. package/package.json +3 -2
  25. package/src/components/session-events/SessionEventFeed.tsx +97 -6
  26. package/src/stories/ComponentDeclarationAudit.stories.tsx +290 -0
  27. package/src/subsystem/ComponentDeclaration.tsx +61 -269
  28. package/src/subsystem/formatDeclaration.ts +116 -0
  29. package/src/subsystem/model.ts +24 -0
  30. package/src/subsystem/tokenizeComponent.ts +70 -0
  31. package/src/subsystem/tokenizeFormatted.ts +92 -0
@@ -0,0 +1,290 @@
1
+ import React from 'react';
2
+ import '@xyflow/react/dist/style.css';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import { ComponentDeclaration } from '../subsystem/ComponentDeclaration';
6
+ import type { SubsystemComponent } from '../subsystem/model';
7
+ import type { GraphifyComponentDetail } from '../graphify';
8
+
9
+ const meta = {
10
+ title: 'Subsystem/Component Declaration Audit',
11
+ component: ComponentDeclaration,
12
+ parameters: { layout: 'padded' },
13
+ tags: ['autodocs'],
14
+ argTypes: {
15
+ constrained: { control: 'boolean', description: 'Toggle 80ch max-width wrapping' },
16
+ },
17
+ args: {
18
+ constrained: true,
19
+ },
20
+ decorators: [
21
+ (Story, ctx) => (
22
+ <ThemeProvider theme={defaultEditorTheme}>
23
+ <div style={{ maxWidth: ctx.args.constrained ? '80ch' : 'none', margin: '0 auto' }}>
24
+ <Story />
25
+ </div>
26
+ </ThemeProvider>
27
+ ),
28
+ ],
29
+ } satisfies Meta<typeof ComponentDeclaration>;
30
+ export default meta;
31
+ type Story = StoryObj<typeof ComponentDeclaration>;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // All cases — one SubsystemComponent per shape, rendered stacked
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const cases: { label: string; component: SubsystemComponent }[] = [
38
+ {
39
+ label: 'function — no params',
40
+ component: {
41
+ id: 'fn-void',
42
+ name: 'flush',
43
+ kind: 'function',
44
+ file: 'src/event-processing/sink.ts',
45
+ purl: 'pkg:github/principal-ai/agent-monitoring',
46
+ symbol: 'flush',
47
+ detail: {
48
+ kind: 'function',
49
+ parameters: [],
50
+ } satisfies GraphifyComponentDetail,
51
+ },
52
+ },
53
+ {
54
+ label: 'function — single param',
55
+ component: {
56
+ id: 'fn-one',
57
+ name: 'normalize',
58
+ kind: 'function',
59
+ file: 'src/session/SessionReader.ts',
60
+ purl: 'pkg:github/principal-ai/agent-monitoring',
61
+ symbol: 'SessionReader.normalize',
62
+ detail: {
63
+ kind: 'function',
64
+ parameters: [{ name: 'session', type: 'SessionRecord' }],
65
+ returnType: 'SessionEvent[]',
66
+ } satisfies GraphifyComponentDetail,
67
+ },
68
+ },
69
+ {
70
+ label: 'function — two params',
71
+ component: {
72
+ id: 'fn-two',
73
+ name: 'normalizeSession',
74
+ kind: 'function',
75
+ file: 'src/event-processing/normalize.ts',
76
+ purl: 'pkg:github/principal-ai/agent-monitoring',
77
+ symbol: 'normalizeSession',
78
+ detail: {
79
+ kind: 'function',
80
+ parameters: [
81
+ { name: 'session', type: 'SessionRecord' },
82
+ { name: 'limit', type: 'number' },
83
+ ],
84
+ returnType: 'SessionEvent[]',
85
+ } satisfies GraphifyComponentDetail,
86
+ },
87
+ },
88
+ {
89
+ label: 'function — rich params (positional, union, callback, generic return)',
90
+ component: {
91
+ id: 'fn-rich',
92
+ name: 'mergeSessions',
93
+ kind: 'function',
94
+ file: 'src/session/merge.ts',
95
+ purl: 'pkg:github/principal-ai/agent-monitoring',
96
+ symbol: 'mergeSessions',
97
+ detail: {
98
+ kind: 'function',
99
+ parameters: [
100
+ { name: 'sessions', type: 'SessionRecord[]' },
101
+ { type: 'MergeOptions' },
102
+ { name: 'strategy', type: "'append' | 'replace' | 'skip'" },
103
+ { name: 'onConflict', type: '((a: SessionRecord, b: SessionRecord) => SessionRecord)' },
104
+ ],
105
+ returnType: 'Promise<Map<string, SessionEvent[]>>',
106
+ } satisfies GraphifyComponentDetail,
107
+ },
108
+ },
109
+ {
110
+ label: 'class — no body',
111
+ component: {
112
+ id: 'class-empty',
113
+ name: 'EmptyClass',
114
+ kind: 'class',
115
+ file: 'src/empty.ts',
116
+ purl: 'pkg:github/principal-ai/agent-monitoring',
117
+ symbol: 'EmptyClass',
118
+ detail: {
119
+ kind: 'class',
120
+ } satisfies GraphifyComponentDetail,
121
+ },
122
+ },
123
+ {
124
+ label: 'class — properties only',
125
+ component: {
126
+ id: 'class-props',
127
+ name: 'Config',
128
+ kind: 'class',
129
+ file: 'src/config.ts',
130
+ purl: 'pkg:github/principal-ai/agent-monitoring',
131
+ symbol: 'Config',
132
+ detail: {
133
+ kind: 'class',
134
+ properties: [
135
+ { name: 'host', type: 'string' },
136
+ { name: 'port', type: 'number' },
137
+ { name: 'tls', type: 'boolean' },
138
+ ],
139
+ } satisfies GraphifyComponentDetail,
140
+ },
141
+ },
142
+ {
143
+ label: 'class — methods + properties + extends + implements',
144
+ component: {
145
+ id: 'class-rich',
146
+ name: 'EventProcessor',
147
+ kind: 'class',
148
+ file: 'src/event-processing/EventProcessor.ts',
149
+ purl: 'pkg:github/principal-ai/agent-monitoring',
150
+ symbol: 'EventProcessor',
151
+ detail: {
152
+ kind: 'class',
153
+ methods: [
154
+ { name: 'process', parameters: [{ type: 'RawEvent' }, { type: 'ProcessingOptions' }], returnType: 'ProcessedEvent' },
155
+ { name: 'batch', parameters: [{ type: 'RawEvent[]' }], returnType: 'Promise<ProcessedEvent[]>' },
156
+ { name: 'onError', parameters: [{ type: 'Error' }] },
157
+ { name: 'dispose' },
158
+ ],
159
+ properties: [
160
+ { name: 'queue', type: 'RawEvent[]' },
161
+ { name: 'options', type: 'Required<ProcessingOptions>' },
162
+ { name: 'retryLimit', type: 'number' },
163
+ ],
164
+ extends: ['BaseProcessor'],
165
+ implements: ['Disposable', 'EventEmitterLike'],
166
+ } satisfies GraphifyComponentDetail,
167
+ },
168
+ },
169
+ {
170
+ label: 'type — properties',
171
+ component: {
172
+ id: 'type-props',
173
+ name: 'SessionRecord',
174
+ kind: 'type',
175
+ file: 'src/session/transcript.ts',
176
+ purl: 'pkg:github/principal-ai/agent-monitoring',
177
+ symbol: 'SessionRecord',
178
+ detail: {
179
+ kind: 'type',
180
+ properties: [
181
+ { name: 'id', type: 'string' },
182
+ { name: 'admittedSeq', type: 'number' },
183
+ ],
184
+ } satisfies GraphifyComponentDetail,
185
+ },
186
+ },
187
+ {
188
+ label: 'type — no properties',
189
+ component: {
190
+ id: 'type-empty',
191
+ name: 'BrandedId',
192
+ kind: 'type',
193
+ file: 'src/types.ts',
194
+ purl: 'pkg:github/principal-ai/agent-monitoring',
195
+ symbol: 'BrandedId',
196
+ detail: {
197
+ kind: 'type',
198
+ } satisfies GraphifyComponentDetail,
199
+ },
200
+ },
201
+ {
202
+ label: 'module — imports + exports',
203
+ component: {
204
+ id: 'mod-full',
205
+ name: 'index',
206
+ kind: 'module',
207
+ file: 'src/index.ts',
208
+ purl: 'pkg:github/principal-ai/agent-monitoring',
209
+ symbol: 'index',
210
+ detail: {
211
+ kind: 'module',
212
+ imports: [
213
+ { name: './session/SessionReader' },
214
+ { name: './event-processing/EventProcessor' },
215
+ ],
216
+ exports: ['SessionReader', 'EventProcessor'],
217
+ symbols: ['SessionReader', 'EventProcessor', 'normalizeSession'],
218
+ } satisfies GraphifyComponentDetail,
219
+ },
220
+ },
221
+ {
222
+ label: 'module — no detail (fallback)',
223
+ component: {
224
+ id: 'mod-plain',
225
+ name: 'utils',
226
+ kind: 'module',
227
+ file: 'src/utils.ts',
228
+ purl: 'pkg:github/principal-ai/agent-monitoring',
229
+ symbol: 'utils',
230
+ },
231
+ },
232
+ {
233
+ label: 'external',
234
+ component: {
235
+ id: 'ext',
236
+ name: 'trail-viewer',
237
+ kind: 'external',
238
+ file: '',
239
+ purl: 'pkg:npm/@principal-ai/trail-viewer',
240
+ symbol: '',
241
+ detail: {
242
+ kind: 'external',
243
+ label: 'pkg:npm/@principal-ai/trail-viewer',
244
+ } satisfies GraphifyComponentDetail,
245
+ },
246
+ },
247
+ {
248
+ label: 'function — no symbol (uses name fallback)',
249
+ component: {
250
+ id: 'fn-no-symbol',
251
+ name: 'anonymousHelper',
252
+ kind: 'function',
253
+ file: 'src/helpers.ts',
254
+ purl: 'pkg:github/principal-ai/agent-monitoring',
255
+ detail: {
256
+ kind: 'function',
257
+ parameters: [{ name: 'input', type: 'string' }],
258
+ returnType: 'void',
259
+ } satisfies GraphifyComponentDetail,
260
+ },
261
+ },
262
+ ];
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Story — all cases stacked
266
+ // ---------------------------------------------------------------------------
267
+
268
+ export const AllCases: StoryObj<{ constrained: boolean }> = {
269
+ render: ({ constrained }) => (
270
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
271
+ {cases.map((c) => (
272
+ <div key={c.label}>
273
+ <div
274
+ style={{
275
+ fontFamily: 'monospace',
276
+ fontSize: 11,
277
+ color: '#888',
278
+ marginBottom: 4,
279
+ textTransform: 'uppercase',
280
+ letterSpacing: '0.05em',
281
+ }}
282
+ >
283
+ {c.label}
284
+ </div>
285
+ <ComponentDeclaration component={c.component} maxWidth={constrained ? '80ch' : undefined} />
286
+ </div>
287
+ ))}
288
+ </div>
289
+ ),
290
+ };
@@ -6,15 +6,16 @@
6
6
  * comments. File content lives in the bottom FileDrawer, not here.
7
7
  */
8
8
 
9
- import { Fragment, useState, type ReactNode } from 'react';
9
+ import { useEffect, useState, type ReactNode } from 'react';
10
10
  import { AlignLeft, FileText } from 'lucide-react';
11
11
  import { useTheme } from '@principal-ade/industry-theme';
12
12
  import {
13
13
  KIND_COLOR,
14
- deriveNameFromSymbol,
15
14
  formatPurl,
16
15
  type SubsystemComponent,
16
+ type SubsystemDeclTokenKind,
17
17
  } from './model';
18
+ import { tokenizeComponent } from './tokenizeComponent';
18
19
 
19
20
  /** Shared affordance for clickable text pieces. */
20
21
  const clickableStyle = {
@@ -54,10 +55,12 @@ export interface ComponentDeclarationProps {
54
55
  /** Related-name click (types, callers/callees, implementors, …) → select
55
56
  * that component if one matches; unmatched refs no-op. */
56
57
  onRelatedSelect?: (ref: string) => void;
58
+ /** Max width of the declaration panel (CSS value). Defaults to none. */
59
+ maxWidth?: string | number;
57
60
  }
58
61
 
59
62
  /** Declaration panel for the selected component — its definition, code-style. */
60
- export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }: ComponentDeclarationProps) {
63
+ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _onRelatedSelect, maxWidth }: ComponentDeclarationProps) {
61
64
  const { theme } = useTheme();
62
65
  const [fileHovered, setFileHovered] = useState(false);
63
66
  // Header visibility toggles — off by default so the panel opens as pure
@@ -66,26 +69,26 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }:
66
69
  const [showPurpose, setShowPurpose] = useState(false);
67
70
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
68
71
  const color = KIND_COLOR[component.kind] ?? '#888';
69
- const displayName = deriveNameFromSymbol(component.symbol, component.kind, component.name, component.file);
70
72
 
71
- // Token styles keyword / name / member / type / string / punctuation.
72
- const kw = (text: string) => <span style={{ color: theme.colors.secondary }}>{text}</span>;
73
- const nm = (text: string) => <span style={{ color }}>{text}</span>;
74
- const mb = (text: string) => <span style={{ color: theme.colors.info }}>{text}</span>;
75
- // Type tokens are links into the graph (other components often own them).
76
- const ty = (text: string) => (
77
- <DetailLink onClick={onRelatedSelect ? () => onRelatedSelect(text) : undefined}>
78
- <span style={{ color: theme.colors.accent }}>{text}</span>
79
- </DetailLink>
80
- );
81
- const str = (text: string) => <span style={{ color: theme.colors.success }}>{text}</span>;
82
- const pn = (text: string) => <span style={{ color: muted }}>{text}</span>;
73
+ // Token-kind theme-color map (used by the token iterator below).
74
+ const tokenColor: Record<SubsystemDeclTokenKind, string> = {
75
+ keyword: theme.colors.secondary,
76
+ name: color,
77
+ member: theme.colors.info,
78
+ type: theme.colors.accent,
79
+ punctuation: muted,
80
+ string: theme.colors.success,
81
+ newline: '', // not rendered — drives line breaks
82
+ };
83
83
 
84
- const line = (children: ReactNode, key?: string, indent?: boolean) => (
85
- <div key={key} style={indent ? { paddingLeft: 16 } : undefined}>
86
- {children}
87
- </div>
88
- );
84
+ const line = (children: ReactNode, key?: string, indent?: boolean | number) => {
85
+ const paddingLeft = typeof indent === 'number' ? indent : indent ? 16 : 0;
86
+ return (
87
+ <div key={key} style={{ display: 'flex', alignItems: 'center', whiteSpace: 'pre', minHeight: 18, ...(paddingLeft ? { paddingLeft } : {}) }}>
88
+ {children}
89
+ </div>
90
+ );
91
+ };
89
92
  const commentStyle = { color: muted, fontStyle: 'italic' } as const;
90
93
  const commentLine = (text: string, key?: string, indent?: boolean, onClick?: () => void) =>
91
94
  line(
@@ -109,15 +112,6 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }:
109
112
  // `// implemented by:`, …) are deliberately not rendered — the graph's
110
113
  // edges carry interactions, and duplicated name lists read as clutter. The
111
114
  // data stays on the Graphify detail types for other surfaces.
112
- // Comma-join rendered tokens without raw-array key churn.
113
- const list = (items: ReactNode[]) =>
114
- items.map((item, i) => (
115
- <Fragment key={i}>
116
- {i > 0 ? ', ' : null}
117
- {item}
118
- </Fragment>
119
- ));
120
-
121
115
  const lines: ReactNode[] = [];
122
116
  // Repo identity — owner avatar + name for GitHub-hosted purls; anything
123
117
  // else falls back to the formatted purl as a quiet comment. The avatar
@@ -207,246 +201,42 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }:
207
201
  ),
208
202
  );
209
203
 
210
- const detail = component.detail;
211
- // Detail facets may be hand-authored (see `detailProvenance`) and the wire
212
- // carries no guarantee that every typed array is present — treat all of
213
- // them as optional so a partial detail degrades to fewer lines, never a
214
- // render crash.
215
- switch (detail?.kind ?? component.kind) {
216
- case 'class': {
217
- const members: ReactNode[] = [];
218
- if (detail?.kind === 'class') {
219
- (detail.methods ?? []).forEach((m, i) =>
220
- members.push(
221
- line(
222
- <>
223
- {mb(m.name)}
224
- {pn('(')}
225
- {(m.parameters ?? []).length > 0 &&
226
- list(
227
- (m.parameters ?? []).map((p, j) => (
228
- <Fragment key={j}>
229
- {p.name ? (
230
- <>
231
- {mb(p.name)}
232
- {pn(': ')}
233
- </>
234
- ) : null}
235
- {ty(p.type)}
236
- </Fragment>
237
- )),
238
- )}
239
- {pn(')')}
240
- {m.returnType ? (
241
- <>
242
- {pn(': ')}
243
- {ty(m.returnType)}
244
- </>
245
- ) : null}
246
- </>,
247
- `m${i}`,
248
- true,
249
- ),
250
- ),
251
- );
252
- (detail.properties ?? []).forEach((prop, i) =>
253
- members.push(
254
- line(
255
- <>
256
- {mb(prop.name)}
257
- {prop.type ? (
258
- <>
259
- {pn(': ')}
260
- {ty(prop.type)}
261
- </>
262
- ) : null}
263
- </>,
264
- `p${i}`,
265
- true,
266
- ),
267
- ),
268
- );
269
- }
270
- const cls = detail?.kind === 'class' ? detail : undefined;
271
- const hasBody = members.length > 0;
272
- lines.push(
273
- line(
274
- <>
275
- {kw('class')}
276
- {' '}
277
- {nm(displayName)}
278
- {cls && (cls.extends ?? []).length > 0 && (
279
- <>
280
- {' '}
281
- {kw('extends')}
282
- {' '}
283
- {list((cls.extends ?? []).map((e, i) => <Fragment key={i}>{ty(e)}</Fragment>))}
284
- </>
285
- )}
286
- {cls && (cls.implements ?? []).length > 0 && (
287
- <>
288
- {' '}
289
- {kw('implements')}
290
- {' '}
291
- {list((cls.implements ?? []).map((e, i) => <Fragment key={i}>{ty(e)}</Fragment>))}
292
- </>
293
- )}
294
- {hasBody ? pn(' {') : null}
295
- </>,
296
- 'decl',
297
- ),
298
- );
299
- lines.push(...members);
300
- if (hasBody) lines.push(line(pn('}'), 'end'));
301
- break;
204
+ // --- Declaration tokens ------------------------------------------------
205
+ // Tokenize asynchronously via Prettier + highlight.js. Pre-tokenized
206
+ // tokens from the wire skip the pipeline entirely.
207
+ const [tokens, setTokens] = useState(component.tokens ?? []);
208
+ useEffect(() => {
209
+ if (component.tokens) {
210
+ setTokens(component.tokens);
211
+ return;
302
212
  }
303
- case 'function': {
304
- const fn = detail?.kind === 'function' ? detail : undefined;
305
- const params = fn?.parameters ?? [];
306
- lines.push(
307
- line(
308
- <>
309
- {kw('function')}
310
- {' '}
311
- {nm(displayName)}
312
- {pn('(')}
313
- {params.length > 0 &&
314
- list(
315
- params.map((param, i) => (
316
- <Fragment key={i}>
317
- {param.name ? (
318
- <>
319
- {mb(param.name)}
320
- {pn(': ')}
321
- </>
322
- ) : null}
323
- {ty(param.type)}
324
- </Fragment>
325
- )),
326
- )}
327
- {pn(')')}
328
- {fn?.returnType ? (
329
- <>
330
- {pn(': ')}
331
- {ty(fn.returnType)}
332
- </>
333
- ) : null}
334
- {pn(';')}
335
- </>,
336
- 'decl',
337
- ),
338
- );
339
- // Caller/callee relationship comments (`// called by:` / `// calls:`)
340
- // are deliberately not rendered here — the graph's edges carry
341
- // interactions, and duplicated call lists read as clutter. The data
342
- // stays on `GraphifyFunctionDetail` for other surfaces.
343
- break;
344
- }
345
- case 'type': {
346
- const tpe = detail?.kind === 'type' ? detail : undefined;
347
- const hasBody = ((tpe?.properties ?? []).length) > 0;
348
- lines.push(
349
- line(
350
- <>
351
- {kw('interface')}
352
- {' '}
353
- {nm(displayName)}
354
- {hasBody ? pn(' {') : null}
355
- </>,
356
- 'decl',
357
- ),
358
- );
359
- if (tpe) {
360
- (tpe.properties ?? []).forEach((prop, i) =>
361
- lines.push(
362
- line(
363
- <>
364
- {mb(prop.name)}
365
- {prop.type ? (
366
- <>
367
- {pn(': ')}
368
- {ty(prop.type)}
369
- </>
370
- ) : null}
371
- {pn(';')}
372
- </>,
373
- `p${i}`,
374
- true,
375
- ),
376
- ),
377
- );
378
- }
379
- if (hasBody) lines.push(line(pn('}'), 'end'));
380
- break;
381
- }
382
- case 'module': {
383
- const mod = detail?.kind === 'module' ? detail : undefined;
384
- if (mod) {
385
- (mod.imports ?? []).forEach((imp, i) =>
386
- lines.push(
387
- line(
388
- <>
389
- {kw('import')}
390
- {' '}
391
- {pn("'")}
392
- {str(imp.name)}
393
- {pn("';")}
394
- </>,
395
- `imp${i}`,
396
- ),
397
- ),
398
- );
399
- if ((mod.exports ?? []).length > 0)
400
- lines.push(
401
- line(
402
- <>
403
- {kw('export')}
404
- {' '}
405
- {pn('{ ')}
406
- {list((mod.exports ?? []).map((e, i) => <Fragment key={i}>{mb(e)}</Fragment>))}
407
- {pn(' }')}
408
- {pn(';')}
409
- </>,
410
- 'exports',
411
- ),
412
- );
413
- if ((mod.symbols ?? []).length > 0)
414
- lines.push(commentLine(`// defines: ${(mod.symbols ?? []).join(', ')}`, 'symbols'));
415
- } else {
416
- lines.push(
417
- line(
418
- <>
419
- {kw('module')}
420
- {' '}
421
- {nm(displayName)}
422
- </>,
423
- 'decl',
424
- ),
425
- );
426
- }
427
- break;
428
- }
429
- case 'external': {
430
- lines.push(
431
- line(
432
- <>
433
- {kw('external')}
434
- {' '}
435
- {detail?.kind === 'external' ? (
436
- <>
437
- {pn("'")}
438
- {str(detail.label)}
439
- {pn("'")}
440
- </>
441
- ) : (
442
- nm(displayName)
443
- )}
444
- </>,
445
- 'decl',
446
- ),
447
- );
448
- break;
213
+ let cancelled = false;
214
+ tokenizeComponent(component).then((t) => {
215
+ if (!cancelled) setTokens(t);
216
+ });
217
+ return () => { cancelled = true; };
218
+ }, [component]);
219
+ const declLines: ReactNode[][] = [[]];
220
+ let di = 0;
221
+ for (const tok of tokens) {
222
+ if (tok.kind === 'newline') {
223
+ declLines.push([]);
224
+ di++;
225
+ continue;
449
226
  }
227
+ declLines[di].push(
228
+ <span key={`${di}-${declLines[di].length}`} style={{ color: tokenColor[tok.kind] }}>
229
+ {tok.text}
230
+ </span>,
231
+ );
232
+ }
233
+ for (let li = 0; li < declLines.length; li++) {
234
+ if (declLines[li].length === 0) continue;
235
+ lines.push(
236
+ <div key={`decl-${li}`} style={{ display: 'flex', alignItems: 'center', whiteSpace: 'pre', minHeight: 18 }}>
237
+ {declLines[li]}
238
+ </div>,
239
+ );
450
240
  }
451
241
 
452
242
  // Purpose prose follows the declaration — identity and syntax first, the
@@ -474,7 +264,9 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }:
474
264
  fontFamily: theme.fonts.monospace,
475
265
  fontSize: theme.fontSizes[1],
476
266
  lineHeight: 1.7,
477
- wordBreak: 'break-word',
267
+ ...(maxWidth != null
268
+ ? { maxWidth, wordBreak: 'break-word' }
269
+ : { overflowX: 'auto' }),
478
270
  }}
479
271
  >
480
272
  {lines}