@principal-ai/subsystems-react 0.24.0 → 0.24.2

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 (46) hide show
  1. package/dist/index.d.ts +7 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.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 +2 -2
  8. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  9. package/dist/subsystem/ConstructsCatalog.d.ts +30 -0
  10. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -0
  11. package/dist/subsystem/ConstructsCatalog.js +329 -0
  12. package/dist/subsystem/ConstructsCatalog.js.map +1 -0
  13. package/dist/subsystem/DiagnosticToggle.d.ts +46 -0
  14. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -0
  15. package/dist/subsystem/DiagnosticToggle.js +95 -0
  16. package/dist/subsystem/DiagnosticToggle.js.map +1 -0
  17. package/dist/subsystem/IssueList.d.ts +103 -0
  18. package/dist/subsystem/IssueList.d.ts.map +1 -0
  19. package/dist/subsystem/IssueList.js +310 -0
  20. package/dist/subsystem/IssueList.js.map +1 -0
  21. package/dist/subsystem/SubsystemComponentGraph.d.ts +28 -0
  22. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  23. package/dist/subsystem/SubsystemComponentGraph.js +81 -58
  24. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  25. package/dist/subsystem/SubsystemFileTree.js +2 -2
  26. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  27. package/dist/subsystem/model.d.ts +13 -2
  28. package/dist/subsystem/model.d.ts.map +1 -1
  29. package/dist/subsystem/model.js +12 -0
  30. package/dist/subsystem/model.js.map +1 -1
  31. package/dist/subsystem/nodes.js +5 -5
  32. package/dist/subsystem/nodes.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/index.ts +37 -0
  35. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +287 -0
  36. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +138 -0
  37. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +130 -0
  38. package/src/subsystem/ComponentDeclaration.tsx +4 -1
  39. package/src/subsystem/ConstructsCatalog.tsx +604 -0
  40. package/src/subsystem/DiagnosticToggle.tsx +160 -0
  41. package/src/subsystem/IssueList.tsx +519 -0
  42. package/src/subsystem/SubsystemComponentGraph.tsx +119 -38
  43. package/src/subsystem/SubsystemFileTree.tsx +2 -2
  44. package/src/subsystem/model.test.ts +34 -0
  45. package/src/subsystem/model.ts +18 -2
  46. package/src/subsystem/nodes.tsx +5 -5
@@ -0,0 +1,604 @@
1
+ /**
2
+ * ConstructsCatalog — master/detail for a constructs-only subsystem
3
+ * (components, no topology or walkthrough edges).
4
+ *
5
+ * Left: the model's constructs as a toggle list. Right: signatures for
6
+ * every construct currently on (`ComponentDeclaration`, stacked). File
7
+ * opens still use the bottom FileDrawer when the host injects a viewer.
8
+ */
9
+
10
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
+ import type { ReactNode } from 'react';
12
+ import { useTheme } from '@principal-ade/industry-theme';
13
+ import { IndustryMarkdownSlide } from 'themed-markdown';
14
+ import { ChevronDown, ChevronUp } from 'lucide-react';
15
+ import {
16
+ constructBadgeColor,
17
+ constructBadgeLabel,
18
+ deriveNameFromSymbol,
19
+ rightBadgeColor,
20
+ rightBadgeLabel,
21
+ type SubsystemComponent,
22
+ } from './model';
23
+ import type { SubsystemOpenFileOptions } from './declarationRef';
24
+ import { ComponentDeclaration } from './ComponentDeclaration';
25
+ import type { ComponentVerificationState } from './ComponentDeclaration';
26
+ import { SubsystemDiagnosticToggle, type SubsystemDiagnostic } from './DiagnosticToggle';
27
+ import { FileDrawer } from './FileDrawer';
28
+ import { componentColor } from '../pierre/constructColors';
29
+ import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
30
+
31
+ export interface ConstructsCatalogProps {
32
+ components: SubsystemComponent[];
33
+ onSelect?: (componentId: string) => void;
34
+ title?: string;
35
+ hideSidebar?: boolean;
36
+ description?: string;
37
+ diagnostic?: SubsystemDiagnostic;
38
+ sidebarExtra?: ReactNode;
39
+ sidebarAfterDescription?: ReactNode;
40
+ renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
41
+ renderFileView?: (component: SubsystemComponent) => ReactNode;
42
+ onFileSelect?: (file: string) => void;
43
+ onVerifyComponent?: (componentId: string) => void;
44
+ componentVerification?: ComponentVerificationState | null;
45
+ }
46
+
47
+ const FileDrawerContent = memo(function FileDrawerContent({
48
+ render,
49
+ file,
50
+ startLine,
51
+ }: {
52
+ render: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
53
+ file: string;
54
+ startLine?: number;
55
+ }) {
56
+ return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
57
+ });
58
+
59
+ function resolveRelated(
60
+ components: SubsystemComponent[],
61
+ ref: string,
62
+ ): SubsystemComponent | undefined {
63
+ const clean = ref.replace(/\(\)$/, '');
64
+ return components.find(
65
+ (c) =>
66
+ c.id === clean ||
67
+ c.name === clean ||
68
+ c.symbol === clean ||
69
+ c.symbol?.replace(/\(\)$/, '') === clean,
70
+ );
71
+ }
72
+
73
+ export function ConstructsCatalog({
74
+ components,
75
+ onSelect,
76
+ title,
77
+ hideSidebar,
78
+ description,
79
+ diagnostic,
80
+ sidebarExtra,
81
+ sidebarAfterDescription,
82
+ renderFileViewer,
83
+ renderFileView,
84
+ onFileSelect,
85
+ onVerifyComponent,
86
+ componentVerification,
87
+ }: ConstructsCatalogProps) {
88
+ const { theme, mode } = useTheme();
89
+ const pierreTheme = resolvePierreSyntaxThemeName(mode);
90
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
91
+ const [visibleIds, setVisibleIds] = useState<string[]>(() =>
92
+ components[0] ? [components[0].id] : [],
93
+ );
94
+ const [focusedId, setFocusedId] = useState<string | null>(
95
+ () => components[0]?.id ?? null,
96
+ );
97
+ const [descriptionVisible, setDescriptionVisible] = useState(false);
98
+ const [descToggleHover, setDescToggleHover] = useState(false);
99
+ const [drawer, setDrawer] = useState<{ file: string; startLine?: number } | null>(
100
+ null,
101
+ );
102
+ const focusedRef = useRef<string | null>(focusedId);
103
+ focusedRef.current = focusedId;
104
+
105
+ const visibleSet = useMemo(() => new Set(visibleIds), [visibleIds]);
106
+ const visibleComponents = useMemo(
107
+ () => components.filter((c) => visibleSet.has(c.id)),
108
+ [components, visibleSet],
109
+ );
110
+
111
+ useEffect(() => {
112
+ if (!focusedId) return;
113
+ document.getElementById(`construct-${focusedId}`)?.scrollIntoView({ block: 'nearest' });
114
+ }, [focusedId]);
115
+
116
+ useEffect(() => {
117
+ const last = visibleIds[visibleIds.length - 1];
118
+ if (!last) return;
119
+ document
120
+ .getElementById(`construct-signature-${last}`)
121
+ ?.scrollIntoView({ block: 'nearest' });
122
+ }, [visibleIds]);
123
+
124
+ // Drop ids that left the model; if nothing remains, show the first construct.
125
+ useEffect(() => {
126
+ const ids = new Set(components.map((c) => c.id));
127
+ setVisibleIds((prev) => {
128
+ const next = prev.filter((id) => ids.has(id));
129
+ if (next.length > 0 || components.length === 0) return next;
130
+ return [components[0]!.id];
131
+ });
132
+ setFocusedId((prev) => {
133
+ if (prev && ids.has(prev)) return prev;
134
+ return components[0]?.id ?? null;
135
+ });
136
+ }, [components]);
137
+
138
+ const toggle = useCallback(
139
+ (id: string) => {
140
+ setFocusedId(id);
141
+ setVisibleIds((prev) =>
142
+ prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
143
+ );
144
+ onSelect?.(id);
145
+ },
146
+ [onSelect],
147
+ );
148
+
149
+ const show = useCallback(
150
+ (id: string) => {
151
+ setFocusedId(id);
152
+ setVisibleIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
153
+ onSelect?.(id);
154
+ },
155
+ [onSelect],
156
+ );
157
+
158
+ const focusAt = useCallback(
159
+ (id: string) => {
160
+ setFocusedId(id);
161
+ },
162
+ [],
163
+ );
164
+
165
+ const onOpenFile = useCallback(
166
+ (file: string, opts?: SubsystemOpenFileOptions) => {
167
+ const startLine = opts?.startLine;
168
+ setDrawer((prev) => {
169
+ if (prev?.file === file && prev.startLine === startLine) return null;
170
+ return { file, startLine };
171
+ });
172
+ onFileSelect?.(file);
173
+ },
174
+ [onFileSelect],
175
+ );
176
+
177
+ const onRelatedSelect = useCallback(
178
+ (ref: string) => {
179
+ const comp = resolveRelated(components, ref);
180
+ if (!comp) return;
181
+ show(comp.id);
182
+ },
183
+ [components, show],
184
+ );
185
+
186
+ const fileViewer = useMemo(() => {
187
+ if (renderFileViewer) return renderFileViewer;
188
+ if (renderFileView) {
189
+ const byFile = new Map(
190
+ components.filter((c) => c.file).map((c) => [c.file, c] as const),
191
+ );
192
+ return (file: string, _opts?: SubsystemOpenFileOptions) => {
193
+ const comp = byFile.get(file);
194
+ return comp ? renderFileView(comp) : null;
195
+ };
196
+ }
197
+ return undefined;
198
+ }, [renderFileViewer, renderFileView, components]);
199
+
200
+ const fileViewerRef = useRef(fileViewer);
201
+ fileViewerRef.current = fileViewer;
202
+ const renderDrawerContent = useCallback(
203
+ (file: string, opts?: SubsystemOpenFileOptions) =>
204
+ fileViewerRef.current?.(file, opts) ?? null,
205
+ [],
206
+ );
207
+
208
+ const moveFocus = useCallback(
209
+ (delta: number) => {
210
+ if (components.length === 0) return;
211
+ const idx = Math.max(
212
+ 0,
213
+ components.findIndex((c) => c.id === focusedRef.current),
214
+ );
215
+ const next = components[(idx + delta + components.length) % components.length]!;
216
+ focusAt(next.id);
217
+ },
218
+ [components, focusAt],
219
+ );
220
+
221
+ useEffect(() => {
222
+ const onKey = (e: KeyboardEvent) => {
223
+ const el = e.target as HTMLElement | null;
224
+ if (
225
+ el &&
226
+ (el.tagName === 'INPUT' ||
227
+ el.tagName === 'TEXTAREA' ||
228
+ el.tagName === 'SELECT' ||
229
+ el.isContentEditable)
230
+ ) {
231
+ return;
232
+ }
233
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
234
+ if (e.key === 'ArrowDown') {
235
+ e.preventDefault();
236
+ moveFocus(1);
237
+ } else if (e.key === 'ArrowUp') {
238
+ e.preventDefault();
239
+ moveFocus(-1);
240
+ } else if (e.key === 'Home') {
241
+ e.preventDefault();
242
+ if (components[0]) focusAt(components[0].id);
243
+ } else if (e.key === 'End') {
244
+ e.preventDefault();
245
+ const last = components[components.length - 1];
246
+ if (last) focusAt(last.id);
247
+ } else if (e.key === ' ' || e.key === 'Enter') {
248
+ if (focusedRef.current) {
249
+ e.preventDefault();
250
+ toggle(focusedRef.current);
251
+ }
252
+ } else if (e.key === 'Escape' && drawer) {
253
+ setDrawer(null);
254
+ }
255
+ };
256
+ window.addEventListener('keydown', onKey);
257
+ return () => window.removeEventListener('keydown', onKey);
258
+ }, [components, drawer, moveFocus, focusAt, toggle]);
259
+
260
+ const showChrome =
261
+ !hideSidebar &&
262
+ !!(title || description || diagnostic || sidebarExtra || sidebarAfterDescription);
263
+ const showDesc = !!description && descriptionVisible;
264
+
265
+ return (
266
+ <div
267
+ data-testid="constructs-catalog"
268
+ style={{
269
+ width: '100%',
270
+ height: '100%',
271
+ minHeight: 0,
272
+ display: 'flex',
273
+ flexDirection: 'row',
274
+ background: theme.colors.background,
275
+ }}
276
+ >
277
+ <aside
278
+ style={{
279
+ width: 280,
280
+ minWidth: 200,
281
+ maxWidth: 360,
282
+ flexShrink: 0,
283
+ borderRight: `1px solid ${theme.colors.border}`,
284
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
285
+ display: 'flex',
286
+ flexDirection: 'column',
287
+ overflow: 'hidden',
288
+ }}
289
+ >
290
+ {showChrome && (
291
+ <div
292
+ style={{
293
+ flex: showDesc ? '0 0 auto' : undefined,
294
+ padding: '16px 16px 8px',
295
+ display: 'flex',
296
+ flexDirection: 'column',
297
+ gap: 12,
298
+ }}
299
+ >
300
+ {sidebarExtra}
301
+ {(title || description || diagnostic) && (
302
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
303
+ {title && (
304
+ <h2
305
+ style={{
306
+ margin: 0,
307
+ flex: 1,
308
+ minWidth: 0,
309
+ fontSize: theme.fontSizes[2],
310
+ fontWeight: 600,
311
+ color: theme.colors.text,
312
+ fontFamily: theme.fonts.monospace,
313
+ }}
314
+ >
315
+ {title}
316
+ </h2>
317
+ )}
318
+ <div
319
+ style={{
320
+ display: 'flex',
321
+ alignItems: 'center',
322
+ gap: 4,
323
+ flexShrink: 0,
324
+ marginLeft: title ? undefined : 'auto',
325
+ }}
326
+ >
327
+ {diagnostic && <SubsystemDiagnosticToggle {...diagnostic} />}
328
+ {description && (
329
+ <button
330
+ type="button"
331
+ aria-expanded={descriptionVisible}
332
+ aria-label={
333
+ descriptionVisible ? 'Hide description' : 'Show description'
334
+ }
335
+ title={
336
+ descriptionVisible ? 'Hide description' : 'Show description'
337
+ }
338
+ onMouseEnter={() => setDescToggleHover(true)}
339
+ onMouseLeave={() => setDescToggleHover(false)}
340
+ onClick={() => setDescriptionVisible((v) => !v)}
341
+ style={{
342
+ display: 'inline-flex',
343
+ alignItems: 'center',
344
+ justifyContent: 'center',
345
+ flexShrink: 0,
346
+ width: 22,
347
+ height: 22,
348
+ padding: 0,
349
+ border: 'none',
350
+ borderRadius: 4,
351
+ background: descToggleHover ? theme.colors.border : 'transparent',
352
+ color: descToggleHover ? theme.colors.text : muted,
353
+ cursor: 'pointer',
354
+ }}
355
+ >
356
+ {descriptionVisible ? (
357
+ <ChevronUp size={14} />
358
+ ) : (
359
+ <ChevronDown size={14} />
360
+ )}
361
+ </button>
362
+ )}
363
+ </div>
364
+ </div>
365
+ )}
366
+ {showDesc && (
367
+ <div style={{ fontSize: theme.fontSizes[0], lineHeight: 1.5 }}>
368
+ <IndustryMarkdownSlide
369
+ content={description!}
370
+ slideIdPrefix="constructs-desc"
371
+ slideIndex={0}
372
+ isVisible={true}
373
+ theme={theme}
374
+ disableScroll={true}
375
+ disableBasePadding
376
+ enableKeyboardScrolling={false}
377
+ autoFocusOnVisible={false}
378
+ />
379
+ </div>
380
+ )}
381
+ {sidebarAfterDescription}
382
+ </div>
383
+ )}
384
+ <div
385
+ style={{
386
+ padding: '10px 16px 6px',
387
+ fontSize: theme.fontSizes[0],
388
+ fontFamily: theme.fonts.monospace,
389
+ letterSpacing: 0.4,
390
+ textTransform: 'uppercase',
391
+ color: muted,
392
+ flexShrink: 0,
393
+ }}
394
+ >
395
+ Constructs
396
+ </div>
397
+ <ul
398
+ role="listbox"
399
+ aria-label="Constructs"
400
+ aria-multiselectable="true"
401
+ aria-activedescendant={focusedId ? `construct-${focusedId}` : undefined}
402
+ style={{
403
+ listStyle: 'none',
404
+ margin: 0,
405
+ padding: '0 8px 12px',
406
+ overflowY: 'auto',
407
+ flex: 1,
408
+ minHeight: 0,
409
+ }}
410
+ >
411
+ {components.map((c) => {
412
+ const visible = visibleSet.has(c.id);
413
+ const focused = c.id === focusedId;
414
+ const color = componentColor(c, pierreTheme);
415
+ const badgeColor = constructBadgeColor(c) ?? color;
416
+ const badgeLabel = constructBadgeLabel(c);
417
+ const displayName = deriveNameFromSymbol(
418
+ c.symbol,
419
+ c.construct,
420
+ c.name,
421
+ c.file,
422
+ c.stereotype,
423
+ );
424
+ const role = rightBadgeLabel(c);
425
+ const roleColor = rightBadgeColor(c);
426
+ return (
427
+ <li key={c.id} role="presentation">
428
+ <button
429
+ type="button"
430
+ id={`construct-${c.id}`}
431
+ role="option"
432
+ aria-selected={visible}
433
+ data-testid={`construct-row-${c.id}`}
434
+ onClick={() => toggle(c.id)}
435
+ style={{
436
+ display: 'flex',
437
+ flexDirection: 'column',
438
+ alignItems: 'stretch',
439
+ gap: 2,
440
+ width: '100%',
441
+ margin: '2px 0',
442
+ padding: '8px 10px 8px 12px',
443
+ textAlign: 'left',
444
+ border: 'none',
445
+ borderRadius: 6,
446
+ background: visible
447
+ ? theme.colors.background
448
+ : 'transparent',
449
+ cursor: 'pointer',
450
+ boxShadow: visible
451
+ ? `inset 0 0 0 1px ${theme.colors.border}`
452
+ : undefined,
453
+ outline: focused && !visible ? `1px dotted ${muted}` : undefined,
454
+ outlineOffset: -1,
455
+ opacity: visible ? 1 : 0.72,
456
+ }}
457
+ >
458
+ <span
459
+ style={{
460
+ display: 'flex',
461
+ alignItems: 'baseline',
462
+ gap: 8,
463
+ minWidth: 0,
464
+ }}
465
+ >
466
+ <span
467
+ style={{
468
+ flex: 1,
469
+ minWidth: 0,
470
+ overflow: 'hidden',
471
+ textOverflow: 'ellipsis',
472
+ whiteSpace: 'nowrap',
473
+ fontFamily: theme.fonts.monospace,
474
+ fontSize: theme.fontSizes[1],
475
+ color,
476
+ fontWeight: visible ? 600 : 500,
477
+ }}
478
+ >
479
+ {displayName}
480
+ </span>
481
+ {role && roleColor && (
482
+ <span
483
+ style={{
484
+ flexShrink: 0,
485
+ fontFamily: theme.fonts.monospace,
486
+ fontSize: theme.fontSizes[0],
487
+ letterSpacing: 0.4,
488
+ textTransform: 'uppercase',
489
+ color: roleColor,
490
+ }}
491
+ >
492
+ {role}
493
+ </span>
494
+ )}
495
+ </span>
496
+ {badgeLabel && (
497
+ <span
498
+ style={{
499
+ display: 'flex',
500
+ alignItems: 'center',
501
+ minWidth: 0,
502
+ }}
503
+ >
504
+ <span
505
+ style={{
506
+ flex: 1,
507
+ minWidth: 0,
508
+ overflow: 'hidden',
509
+ textOverflow: 'ellipsis',
510
+ whiteSpace: 'nowrap',
511
+ fontFamily: theme.fonts.monospace,
512
+ fontSize: theme.fontSizes[0],
513
+ letterSpacing: 0.4,
514
+ textTransform: 'uppercase',
515
+ color: badgeColor,
516
+ }}
517
+ >
518
+ {badgeLabel}
519
+ </span>
520
+ </span>
521
+ )}
522
+ </button>
523
+ </li>
524
+ );
525
+ })}
526
+ </ul>
527
+ </aside>
528
+ <div
529
+ style={{
530
+ flex: 1,
531
+ minWidth: 0,
532
+ minHeight: 0,
533
+ display: 'flex',
534
+ flexDirection: 'column',
535
+ }}
536
+ >
537
+ <div
538
+ data-testid="construct-signature"
539
+ style={{
540
+ flex: 1,
541
+ minHeight: 0,
542
+ overflow: 'auto',
543
+ padding: 16,
544
+ display: 'flex',
545
+ flexDirection: 'column',
546
+ gap: 12,
547
+ }}
548
+ >
549
+ {visibleComponents.length > 0 ? (
550
+ visibleComponents.map((c) => (
551
+ <div
552
+ key={c.id}
553
+ id={`construct-signature-${c.id}`}
554
+ style={{
555
+ border: `1px solid ${theme.colors.border}`,
556
+ borderRadius: 8,
557
+ overflow: 'hidden',
558
+ flexShrink: 0,
559
+ }}
560
+ >
561
+ <ComponentDeclaration
562
+ component={c}
563
+ onOpenFile={fileViewer ? onOpenFile : undefined}
564
+ defaultShowFile={!!fileViewer}
565
+ onRelatedSelect={onRelatedSelect}
566
+ onVerify={onVerifyComponent}
567
+ verification={
568
+ componentVerification && focusedId === c.id
569
+ ? componentVerification
570
+ : undefined
571
+ }
572
+ />
573
+ </div>
574
+ ))
575
+ ) : (
576
+ <p
577
+ style={{
578
+ margin: 0,
579
+ color: muted,
580
+ fontFamily: theme.fonts.body,
581
+ fontSize: theme.fontSizes[1],
582
+ }}
583
+ >
584
+ Toggle constructs on the left to show their signatures.
585
+ </p>
586
+ )}
587
+ </div>
588
+ <FileDrawer
589
+ title={drawer?.file ?? null}
590
+ onClose={() => setDrawer(null)}
591
+ fillHeight={drawer != null && drawer.startLine == null}
592
+ >
593
+ {drawer ? (
594
+ <FileDrawerContent
595
+ render={renderDrawerContent}
596
+ file={drawer.file}
597
+ startLine={drawer.startLine}
598
+ />
599
+ ) : null}
600
+ </FileDrawer>
601
+ </div>
602
+ </div>
603
+ );
604
+ }