@principal-ai/subsystems-react 0.24.0 → 0.24.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 (40) 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 +349 -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 +102 -0
  18. package/dist/subsystem/IssueList.d.ts.map +1 -0
  19. package/dist/subsystem/IssueList.js +282 -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 +80 -57
  24. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  25. package/dist/subsystem/model.d.ts +13 -2
  26. package/dist/subsystem/model.d.ts.map +1 -1
  27. package/dist/subsystem/model.js +12 -0
  28. package/dist/subsystem/model.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/index.ts +37 -0
  31. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +287 -0
  32. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +138 -0
  33. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +130 -0
  34. package/src/subsystem/ComponentDeclaration.tsx +4 -1
  35. package/src/subsystem/ConstructsCatalog.tsx +644 -0
  36. package/src/subsystem/DiagnosticToggle.tsx +160 -0
  37. package/src/subsystem/IssueList.tsx +488 -0
  38. package/src/subsystem/SubsystemComponentGraph.tsx +118 -36
  39. package/src/subsystem/model.test.ts +34 -0
  40. package/src/subsystem/model.ts +18 -2
@@ -7,6 +7,9 @@
7
7
  * inside one labeled boundary frame; nodes without one sit outside every
8
8
  * boundary. Clicking a component invokes `onSelect`.
9
9
  *
10
+ * When the model has components but no topology or walkthrough edges, the
11
+ * canvas is a constructs catalog (list + signature) instead of a graph.
12
+ *
10
13
  * This is a focused fork of the package's `GraphRenderer` pipeline (same ELK
11
14
  * edge routing, delayed fitView, Background/Controls/MiniMap, node/edge type
12
15
  * injection, onNodeClick) adapted to the subsystem model — read-only.
@@ -37,6 +40,7 @@ import { IndustryMarkdownSlide } from 'themed-markdown';
37
40
  import {
38
41
  buildSubsystemGraph,
39
42
  deriveGraphEdges,
43
+ isConstructsOnlyModel,
40
44
  isRelationMechanism,
41
45
  isWalkthroughMechanism,
42
46
  MECHANISM_COLOR,
@@ -50,8 +54,11 @@ import {
50
54
  type SubsystemRelation,
51
55
  type SubsystemWalkthrough,
52
56
  } from './model';
57
+ import { ConstructsCatalog } from './ConstructsCatalog';
53
58
  import type { SubsystemOpenFileOptions } from './declarationRef';
54
59
  import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
60
+ import { SubsystemDiagnosticToggle, type SubsystemDiagnostic } from './DiagnosticToggle';
61
+ import { SubsystemIssueList, type SubsystemIssue } from './IssueList';
55
62
  import { SubsystemFileTree } from './SubsystemFileTree';
56
63
  import { GraphLayoutCover } from './GraphLayoutCover';
57
64
  import { ComponentDeclaration } from './ComponentDeclaration';
@@ -160,6 +167,29 @@ export interface SubsystemComponentGraphProps {
160
167
  sidebarExtra?: ReactNode;
161
168
  /** Rendered in the sidebar under the description (e.g. selection inspector). */
162
169
  sidebarAfterDescription?: ReactNode;
170
+ /**
171
+ * Diagnostics status chip in the sidebar title row (beside the description
172
+ * toggle). Shows the last verification pass's state as an icon color + count;
173
+ * `onOpen` opens the full report. Omit to hide the chip.
174
+ */
175
+ diagnostic?: SubsystemDiagnostic;
176
+ /**
177
+ * Verification issues for this graph (audit findings). When diagnostics are
178
+ * active (see `showIssues`) the sidebar's bottom panel becomes the issue
179
+ * list instead of the file tree / walkthroughs.
180
+ */
181
+ issues?: SubsystemIssue[];
182
+ /**
183
+ * Force the sidebar into diagnostics mode. Defaults to `true` whenever
184
+ * `issues` is non-empty (and nothing else has been forced).
185
+ */
186
+ showIssues?: boolean;
187
+ /** Click an issue — focus its target on the graph / open detail. */
188
+ onSelectIssue?: (issue: SubsystemIssue) => void;
189
+ /** Apply an issue's deterministic fix. */
190
+ onApplyIssueFix?: (issue: SubsystemIssue) => void;
191
+ /** Hover an issue — transiently highlight its target (null on leave). */
192
+ onHoverIssue?: (issue: SubsystemIssue | null) => void;
163
193
  /**
164
194
  * Host-injected reader/renderer for the bottom file drawer, keyed by
165
195
  * repo-root-relative path. Opening happens on declaration/file-tree clicks.
@@ -274,7 +304,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
274
304
  measured: { w: number; h: number } | null;
275
305
  }
276
306
 
277
- function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
307
+ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
278
308
  const { theme } = useTheme();
279
309
  const { fitView } = useReactFlow();
280
310
  const viewport = useViewport();
@@ -935,6 +965,10 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
935
965
  // own owner-avatar header. Clicking a header collapses that repo's tree.
936
966
  const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
937
967
  const hasWalkthroughs = useMemo(() => (walkthroughs?.length ?? 0) > 0, [walkthroughs]);
968
+ // Diagnostics mode: the bottom panel shows the issue list instead of the
969
+ // file tree / walkthroughs. Explicit `showIssues` wins; otherwise any issues
970
+ // present turn it on.
971
+ const issuesActive = showIssues ?? (issues?.length ?? 0) > 0;
938
972
  const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
939
973
  const toggleRepoCollapsed = useCallback((key: string) => {
940
974
  setCollapsedRepos((prev) => {
@@ -1426,7 +1460,7 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1426
1460
  {/* Sidebar: scrollable title/description on top, files or flows pinned below.
1427
1461
  The description hides by default so files/flows get the room; the
1428
1462
  title-row toggle reveals it, and the lower panel yields back to 50%. */}
1429
- {!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
1463
+ {!hideSidebar && (title || description || diagnostic || issuesActive || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
1430
1464
  <div
1431
1465
  style={{
1432
1466
  width: sidebarWidth,
@@ -1450,7 +1484,7 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1450
1484
  }}
1451
1485
  >
1452
1486
  {sidebarExtra}
1453
- {(title || description) && (
1487
+ {(title || description || diagnostic) && (
1454
1488
  <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
1455
1489
  {title && (
1456
1490
  <h2
@@ -1467,34 +1501,47 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1467
1501
  {title}
1468
1502
  </h2>
1469
1503
  )}
1470
- {description && (
1471
- <button
1472
- type="button"
1473
- aria-expanded={descriptionVisible}
1474
- aria-label={descriptionVisible ? 'Hide description' : 'Show description'}
1475
- title={descriptionVisible ? 'Hide description' : 'Show description'}
1476
- onMouseEnter={() => setDescToggleHover(true)}
1477
- onMouseLeave={() => setDescToggleHover(false)}
1478
- onClick={() => setDescriptionVisible((v) => !v)}
1479
- style={{
1480
- display: 'inline-flex',
1481
- alignItems: 'center',
1482
- justifyContent: 'center',
1483
- flexShrink: 0,
1484
- width: 22,
1485
- height: 22,
1486
- padding: 0,
1487
- border: 'none',
1488
- borderRadius: 4,
1489
- background: descToggleHover ? theme.colors.border : 'transparent',
1490
- color: descToggleHover ? theme.colors.text : (theme.colors.textMuted ?? theme.colors.textSecondary),
1491
- cursor: 'pointer',
1492
- transition: 'background 120ms ease, color 120ms ease',
1493
- }}
1494
- >
1495
- {descriptionVisible ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
1496
- </button>
1497
- )}
1504
+ <div
1505
+ style={{
1506
+ display: 'flex',
1507
+ alignItems: 'center',
1508
+ gap: 4,
1509
+ flexShrink: 0,
1510
+ // Title's flex:1 already pushes this cluster right; without a
1511
+ // title, keep the actions flush right instead of flush-left.
1512
+ marginLeft: title ? undefined : 'auto',
1513
+ }}
1514
+ >
1515
+ {diagnostic && <SubsystemDiagnosticToggle {...diagnostic} />}
1516
+ {description && (
1517
+ <button
1518
+ type="button"
1519
+ aria-expanded={descriptionVisible}
1520
+ aria-label={descriptionVisible ? 'Hide description' : 'Show description'}
1521
+ title={descriptionVisible ? 'Hide description' : 'Show description'}
1522
+ onMouseEnter={() => setDescToggleHover(true)}
1523
+ onMouseLeave={() => setDescToggleHover(false)}
1524
+ onClick={() => setDescriptionVisible((v) => !v)}
1525
+ style={{
1526
+ display: 'inline-flex',
1527
+ alignItems: 'center',
1528
+ justifyContent: 'center',
1529
+ flexShrink: 0,
1530
+ width: 22,
1531
+ height: 22,
1532
+ padding: 0,
1533
+ border: 'none',
1534
+ borderRadius: 4,
1535
+ background: descToggleHover ? theme.colors.border : 'transparent',
1536
+ color: descToggleHover ? theme.colors.text : (theme.colors.textMuted ?? theme.colors.textSecondary),
1537
+ cursor: 'pointer',
1538
+ transition: 'background 120ms ease, color 120ms ease',
1539
+ }}
1540
+ >
1541
+ {descriptionVisible ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
1542
+ </button>
1543
+ )}
1544
+ </div>
1498
1545
  </div>
1499
1546
  )}
1500
1547
  {description && descriptionVisible && (
@@ -1515,7 +1562,7 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1515
1562
  )}
1516
1563
  {sidebarAfterDescription}
1517
1564
  </div>
1518
- {(treeFilePaths.length > 0 || hasWalkthroughs) && (
1565
+ {(treeFilePaths.length > 0 || hasWalkthroughs || issuesActive) && (
1519
1566
  <div
1520
1567
  style={{
1521
1568
  ...(showDesc ? { height: '50%' as const } : { flex: 1, minHeight: 0 }),
@@ -1527,6 +1574,15 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1527
1574
  overflow: 'hidden',
1528
1575
  }}
1529
1576
  >
1577
+ {issuesActive ? (
1578
+ <SubsystemIssueList
1579
+ issues={issues ?? []}
1580
+ onSelectIssue={onSelectIssue}
1581
+ onApplyFix={onApplyIssueFix}
1582
+ onHoverIssue={onHoverIssue}
1583
+ />
1584
+ ) : (
1585
+ <>
1530
1586
  {hasWalkthroughs && (
1531
1587
  <div
1532
1588
  role="tablist"
@@ -1640,13 +1696,15 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1640
1696
  })}
1641
1697
  </>
1642
1698
  ) : null}
1699
+ </>
1700
+ )}
1643
1701
  </div>
1644
1702
  )}
1645
1703
  </div>
1646
1704
  )}
1647
1705
 
1648
1706
  {/* Drag handle between sidebar and canvas — resize the left panel. */}
1649
- {!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
1707
+ {!hideSidebar && (title || description || diagnostic || issuesActive || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
1650
1708
  <div
1651
1709
  onMouseDown={onSidebarResizeStart}
1652
1710
  aria-label="Resize sidebar"
@@ -2435,6 +2493,12 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
2435
2493
  return () => ro.disconnect();
2436
2494
  }, []);
2437
2495
 
2496
+ const constructsOnly = isConstructsOnlyModel({
2497
+ components: props.components,
2498
+ relations: props.relations,
2499
+ walkthroughs: props.walkthroughs,
2500
+ });
2501
+
2438
2502
  return (
2439
2503
  <div
2440
2504
  ref={wrapRef}
@@ -2448,9 +2512,27 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
2448
2512
  }}
2449
2513
  >
2450
2514
  <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
2451
- <ReactFlowProvider>
2452
- <Inner {...props} measured={size} />
2453
- </ReactFlowProvider>
2515
+ {constructsOnly ? (
2516
+ <ConstructsCatalog
2517
+ components={props.components}
2518
+ onSelect={props.onSelect}
2519
+ title={props.title}
2520
+ hideSidebar={props.hideSidebar}
2521
+ description={props.description}
2522
+ diagnostic={props.diagnostic}
2523
+ sidebarExtra={props.sidebarExtra}
2524
+ sidebarAfterDescription={props.sidebarAfterDescription}
2525
+ renderFileViewer={props.renderFileViewer}
2526
+ renderFileView={props.renderFileView}
2527
+ onFileSelect={props.onFileSelect}
2528
+ onVerifyComponent={props.onVerifyComponent}
2529
+ componentVerification={props.componentVerification}
2530
+ />
2531
+ ) : (
2532
+ <ReactFlowProvider>
2533
+ <Inner {...props} measured={size} />
2534
+ </ReactFlowProvider>
2535
+ )}
2454
2536
  </div>
2455
2537
  </div>
2456
2538
  );
@@ -13,6 +13,7 @@ import {
13
13
  buildSubsystemGraph,
14
14
  componentPackageKey,
15
15
  deriveNameFromSymbol,
16
+ isConstructsOnlyModel,
16
17
  constructBadgeLabel,
17
18
  constructBadgeColor,
18
19
  FRAMEWORK_BADGE_COLOR,
@@ -642,3 +643,36 @@ describe('subsystem graph model', () => {
642
643
  expect(subsystemGraphLayoutKey(base)).toBe(subsystemGraphLayoutKey(withRef));
643
644
  });
644
645
  });
646
+
647
+ describe('isConstructsOnlyModel', () => {
648
+ test('true when components exist and there are no edges', () => {
649
+ expect(isConstructsOnlyModel({ components: comps, relations: [], walkthroughs: [] })).toBe(true);
650
+ expect(isConstructsOnlyModel({ components: comps })).toBe(true);
651
+ });
652
+
653
+ test('false when empty, or when relations or walkthrough hops exist', () => {
654
+ expect(isConstructsOnlyModel({ components: [], relations: [] })).toBe(false);
655
+ expect(isConstructsOnlyModel({ components: comps, relations })).toBe(false);
656
+ expect(
657
+ isConstructsOnlyModel({
658
+ components: comps,
659
+ relations: [],
660
+ walkthroughs: [
661
+ {
662
+ id: 'wt',
663
+ title: 'flow',
664
+ steps: [
665
+ {
666
+ from: 'transcript',
667
+ to: 'reader',
668
+ mechanism: 'calls',
669
+ file: 'transcript.ts',
670
+ line: 1,
671
+ },
672
+ ],
673
+ },
674
+ ],
675
+ }),
676
+ ).toBe(false);
677
+ });
678
+ });
@@ -331,8 +331,8 @@ export function walkthroughStepGraphEdgeId(
331
331
  }
332
332
 
333
333
  export function deriveGraphEdges(doc: {
334
- relations?: SubsystemRelation[];
335
- walkthroughs?: SubsystemWalkthrough[];
334
+ relations?: readonly SubsystemRelation[];
335
+ walkthroughs?: readonly SubsystemWalkthrough[];
336
336
  }): SubsystemComponentEdge[] {
337
337
  const byId = new Map<string, SubsystemComponentEdge>();
338
338
  for (const r of doc.relations ?? []) {
@@ -363,6 +363,22 @@ export function deriveGraphEdges(doc: {
363
363
  return [...byId.values()];
364
364
  }
365
365
 
366
+ /**
367
+ * True when the model has components but no topology or walkthrough edges.
368
+ * Those snapshots are a catalog of declarations, not a graph.
369
+ */
370
+ export function isConstructsOnlyModel(doc: {
371
+ components: readonly { id: string }[];
372
+ relations?: readonly SubsystemRelation[];
373
+ walkthroughs?: readonly SubsystemWalkthrough[];
374
+ }): boolean {
375
+ if (doc.components.length === 0) return false;
376
+ return deriveGraphEdges({
377
+ relations: doc.relations,
378
+ walkthroughs: doc.walkthroughs,
379
+ }).length === 0;
380
+ }
381
+
366
382
  /**
367
383
  * Derive a consistent display `name` from a code `symbol` + construct.
368
384
  *