@principal-ai/subsystems-react 0.25.1 → 0.26.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 (33) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.d.ts +5 -0
  6. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.d.ts.map +1 -0
  7. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.js +4 -0
  8. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.js.map +1 -0
  9. package/dist/subsystem/SubsystemAggregateGraph.d.ts +70 -0
  10. package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -0
  11. package/dist/subsystem/SubsystemAggregateGraph.js +386 -0
  12. package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -0
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
  14. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  15. package/dist/subsystem/SubsystemComponentGraph.js +25 -4
  16. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  17. package/dist/subsystem/graphChrome.d.ts +27 -0
  18. package/dist/subsystem/graphChrome.d.ts.map +1 -0
  19. package/dist/subsystem/graphChrome.js +30 -0
  20. package/dist/subsystem/graphChrome.js.map +1 -0
  21. package/dist/subsystem/model.d.ts +7 -0
  22. package/dist/subsystem/model.d.ts.map +1 -1
  23. package/dist/subsystem/model.js +35 -0
  24. package/dist/subsystem/model.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/index.ts +8 -0
  27. package/src/stories/Subsystem/ComponentGraph/AggregateFrameGraph.stories.tsx +61 -0
  28. package/src/stories/Subsystem/ComponentGraph/aggregateViewFixture.ts +8 -0
  29. package/src/subsystem/SubsystemAggregateGraph.tsx +610 -0
  30. package/src/subsystem/SubsystemComponentGraph.tsx +29 -18
  31. package/src/subsystem/graphChrome.tsx +41 -0
  32. package/src/subsystem/model.test.ts +17 -0
  33. package/src/subsystem/model.ts +34 -0
@@ -20,9 +20,6 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
20
20
  import {
21
21
  ReactFlow,
22
22
  ReactFlowProvider,
23
- Background,
24
- BackgroundVariant,
25
- Controls,
26
23
  useReactFlow,
27
24
  useViewport,
28
25
  type NodeTypes,
@@ -61,6 +58,7 @@ import { SubsystemDiagnosticToggle, type SubsystemDiagnostic } from './Diagnosti
61
58
  import { SubsystemIssueList, type SubsystemIssue } from './IssueList';
62
59
  import { SubsystemFileTree } from './SubsystemFileTree';
63
60
  import { GraphLayoutCover } from './GraphLayoutCover';
61
+ import { GRAPH_NAV_PROPS, GraphChrome } from './graphChrome';
64
62
  import { ComponentDeclaration } from './ComponentDeclaration';
65
63
  import type { ComponentVerificationState } from './ComponentDeclaration';
66
64
  import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
@@ -101,6 +99,14 @@ export interface SubsystemComponentGraphProps {
101
99
  * (unselected ones dimmed); everything else is hidden.
102
100
  */
103
101
  walkthroughs?: SubsystemWalkthrough[];
102
+ /**
103
+ * Deep-link target: when set, the matching walkthrough is selected on mount
104
+ * — its steps expanded and its flow focused on the canvas. Hosts use this
105
+ * when opening the graph from a walkthrough row in a list. Re-applies on a
106
+ * new id (or a remount); in-tab selection afterwards stays owned by the
107
+ * graph.
108
+ */
109
+ initialWalkthroughId?: string | null;
104
110
  onSelect?: (componentAlias: string) => void;
105
111
  /** Called when an edge is clicked (relationship / mechanism + refs seam). */
106
112
  onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
@@ -307,7 +313,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
307
313
  measured: { w: number; h: number } | null;
308
314
  }
309
315
 
310
- 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) {
316
+ function Inner({ components, relations, walkthroughs, initialWalkthroughId, 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) {
311
317
  const { theme } = useTheme();
312
318
  const { fitView } = useReactFlow();
313
319
  const viewport = useViewport();
@@ -1138,6 +1144,23 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1138
1144
  setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
1139
1145
  }, []);
1140
1146
 
1147
+ // Host deep-link: opening the graph from a walkthrough row selects that
1148
+ // flow — expand its steps, focus its edges, and switch the sidebar to
1149
+ // Walkthroughs. Guarded by a ref so a later in-tab selection isn't yanked
1150
+ // back; a remount (or a new id) re-applies it.
1151
+ const appliedInitialWalkthroughRef = useRef<string | null>(null);
1152
+ useEffect(() => {
1153
+ if (!initialWalkthroughId) return;
1154
+ if (appliedInitialWalkthroughRef.current === initialWalkthroughId) return;
1155
+ if (!layoutReady || !walkthroughs?.length) return;
1156
+ const tl = walkthroughs.find((t) => t.id === initialWalkthroughId);
1157
+ if (!tl) return;
1158
+ appliedInitialWalkthroughRef.current = initialWalkthroughId;
1159
+ setSidebarView('walkthroughs');
1160
+ setExpandedWalkthroughs((prev) => new Set(prev).add(tl.id));
1161
+ focusWalkthroughEdges(tl);
1162
+ }, [initialWalkthroughId, layoutReady, walkthroughs, focusWalkthroughEdges]);
1163
+
1141
1164
  // Switching sidebar panels also switches the edge vocabulary. Leaving the
1142
1165
  // Walkthroughs panel drops its canvas state (focus, expanded flows, selected
1143
1166
  // edge) so the Files view's topology edges aren't gated by a flow the user
@@ -1847,24 +1870,13 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1847
1870
  edges={dispEdges}
1848
1871
  nodeTypes={nodeTypes}
1849
1872
  edgeTypes={edgeTypes}
1850
- minZoom={0.05}
1851
- maxZoom={4}
1873
+ {...GRAPH_NAV_PROPS}
1852
1874
  onNodeClick={onNodeClick}
1853
1875
  onEdgeClick={onEdgeClick}
1854
1876
  onNodesChange={onNodesChange}
1855
1877
  onEdgesChange={onEdgesChange}
1856
1878
  proOptions={{ hideAttribution: true }}
1857
- nodesDraggable={false}
1858
- elementsSelectable
1859
- selectNodesOnDrag={false}
1860
- nodesConnectable={false}
1861
- edgesReconnectable={false}
1862
1879
  onPaneClick={onPaneClick}
1863
- panOnDrag
1864
- panOnScroll
1865
- zoomOnScroll={false}
1866
- zoomOnPinch
1867
- zoomOnDoubleClick={false}
1868
1880
  style={{
1869
1881
  width: '100%',
1870
1882
  height: '100%',
@@ -1873,8 +1885,7 @@ function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, me
1873
1885
  background: theme.colors.background,
1874
1886
  }}
1875
1887
  >
1876
- <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
1877
- <Controls showZoom showFitView showInteractive />
1888
+ <GraphChrome />
1878
1889
  </ReactFlow>
1879
1890
  {/* Graph / walkthrough / step titles — non-interactive chips at the top
1880
1891
  of the canvas. Graph-only embeds use these without opening the sidebar. */}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared navigation surface for read-only graph canvases.
3
+ *
4
+ * Every graph in this package (component graph, aggregate graph) is a React
5
+ * Flow wrapper and must present the same interaction language: drag/scroll to
6
+ * pan, pinch to zoom (never scroll-zoom or double-click-zoom), and the
7
+ * Background + Controls chrome. Spreading `GRAPH_NAV_PROPS` and rendering
8
+ * `<GraphChrome />` keeps them from drifting apart.
9
+ */
10
+
11
+ import {
12
+ Background,
13
+ BackgroundVariant,
14
+ Controls,
15
+ type ReactFlowProps,
16
+ } from '@xyflow/react';
17
+
18
+ export const GRAPH_NAV_PROPS = {
19
+ minZoom: 0.05,
20
+ maxZoom: 4,
21
+ panOnDrag: true,
22
+ panOnScroll: true,
23
+ zoomOnScroll: false,
24
+ zoomOnPinch: true,
25
+ zoomOnDoubleClick: false,
26
+ nodesDraggable: false,
27
+ elementsSelectable: true,
28
+ selectNodesOnDrag: false,
29
+ nodesConnectable: false,
30
+ edgesReconnectable: false,
31
+ } satisfies Partial<ReactFlowProps>;
32
+
33
+ /** Background dots + zoom/fit/interactive widget; render inside `<ReactFlow>`. */
34
+ export function GraphChrome() {
35
+ return (
36
+ <>
37
+ <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
38
+ <Controls showZoom showFitView showInteractive />
39
+ </>
40
+ );
41
+ }
@@ -28,6 +28,7 @@ import {
28
28
  formatPurl,
29
29
  packageColor,
30
30
  subsystemGraphLayoutKey,
31
+ describeConstructBreakdown,
31
32
  } from './model';
32
33
  import type { SubsystemComponent, SubsystemComponentEdge } from './model';
33
34
 
@@ -727,6 +728,22 @@ describe('isConstructsOnlyModel', () => {
727
728
  });
728
729
  });
729
730
 
731
+ describe('describeConstructBreakdown', () => {
732
+ test('counts by construct, most common first', () => {
733
+ expect(describeConstructBreakdown(['function', 'class', 'function'])).toBe(
734
+ '2 functions · 1 class',
735
+ );
736
+ });
737
+
738
+ test('ignores blank constructs and handles empties', () => {
739
+ expect(describeConstructBreakdown(['', ' ', 'store'])).toBe('1 store');
740
+ expect(describeConstructBreakdown([])).toBe('empty');
741
+ expect(describeConstructBreakdown(['interface', 'type_alias', 'enum', 'custom_entity'])).toBe(
742
+ '1 custom_entity · 1 enum · 1 interface · +1 more',
743
+ );
744
+ });
745
+ });
746
+
730
747
  describe('module badge labels', () => {
731
748
  test('moduleBadgeLabel keeps the full path when it fits the module width', () => {
732
749
  expect(moduleBadgeLabel('src/main.ts', 1000)).toBe('src/main.ts');
@@ -435,6 +435,40 @@ export function deriveNameFromSymbol(
435
435
  return name;
436
436
  }
437
437
 
438
+ const CONSTRUCT_PLURALS: Record<string, string> = {
439
+ class: 'classes',
440
+ function: 'functions',
441
+ method: 'methods',
442
+ interface: 'interfaces',
443
+ type_alias: 'type aliases',
444
+ enum: 'enums',
445
+ store: 'stores',
446
+ external: 'externals',
447
+ custom_entity: 'custom entities',
448
+ };
449
+
450
+ /**
451
+ * One-line summary of what a group of declarations contains, e.g.
452
+ * `6 functions · 2 classes`. Used for aggregate frame boxes, which describe
453
+ * their contents (constructs) rather than their container. Empty/blank
454
+ * constructs are ignored. At most three groups; the rest folds into `+N more`.
455
+ */
456
+ export function describeConstructBreakdown(constructs: readonly string[]): string {
457
+ const counts = new Map<string, number>();
458
+ for (const raw of constructs) {
459
+ const c = (raw ?? '').trim();
460
+ if (!c) continue;
461
+ counts.set(c, (counts.get(c) ?? 0) + 1);
462
+ }
463
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
464
+ const parts = ranked.slice(0, 3).map(([c, n]) => {
465
+ if (n === 1) return `1 ${c}`;
466
+ return `${n} ${CONSTRUCT_PLURALS[c] ?? `${c}s`}`;
467
+ });
468
+ if (ranked.length > 3) parts.push(`+${ranked.length - 3} more`);
469
+ return parts.join(' · ') || 'empty';
470
+ }
471
+
438
472
  /**
439
473
  * Human-readable purl identity — drops the `pkg:<type>/` scheme wrapper and
440
474
  * trailing version, keeping the package / owner-repo identity: