@principal-ai/subsystems-react 0.55.0 → 0.55.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 (129) hide show
  1. package/README.md +2 -1
  2. package/dist/graphify/index.d.ts +2 -0
  3. package/dist/graphify/index.d.ts.map +1 -1
  4. package/dist/graphify/index.js +1 -0
  5. package/dist/graphify/index.js.map +1 -1
  6. package/dist/graphify/symbolFocus.d.ts +49 -0
  7. package/dist/graphify/symbolFocus.d.ts.map +1 -0
  8. package/dist/graphify/symbolFocus.js +139 -0
  9. package/dist/graphify/symbolFocus.js.map +1 -0
  10. package/dist/index.d.ts +7 -5
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/pierre/PierreTrailCodeView.d.ts +1 -1
  15. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  16. package/dist/stories/Canvases/Aggregate/aggregateViewFixture.d.ts.map +1 -0
  17. package/dist/stories/Canvases/Aggregate/aggregateViewFixture.js.map +1 -0
  18. package/dist/stories/Canvases/Subsystem/fixtures.d.ts.map +1 -0
  19. package/dist/stories/Canvases/Subsystem/fixtures.js.map +1 -0
  20. package/dist/subsystem/ComponentDeclaration.d.ts +1 -1
  21. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  22. package/dist/subsystem/ComponentDeclaration.js +1 -1
  23. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  24. package/dist/subsystem/ConstructsCatalog.d.ts +1 -1
  25. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  26. package/dist/subsystem/SubsystemComponentGraph.d.ts +36 -2
  27. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  28. package/dist/subsystem/SubsystemComponentGraph.js +250 -35
  29. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  30. package/dist/subsystem/SymbolFocusedView.d.ts +30 -0
  31. package/dist/subsystem/SymbolFocusedView.d.ts.map +1 -0
  32. package/dist/subsystem/SymbolFocusedView.js +24 -0
  33. package/dist/subsystem/SymbolFocusedView.js.map +1 -0
  34. package/dist/subsystem/SymbolInspectionCard.d.ts +1 -1
  35. package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
  36. package/dist/subsystem/SymbolInspectionCard.js +1 -1
  37. package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
  38. package/dist/subsystem/TrailsPanel.d.ts +16 -1
  39. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  40. package/dist/subsystem/TrailsPanel.js +71 -9
  41. package/dist/subsystem/TrailsPanel.js.map +1 -1
  42. package/dist/subsystem/graphChrome.d.ts +13 -0
  43. package/dist/subsystem/graphChrome.d.ts.map +1 -1
  44. package/dist/subsystem/graphChrome.js +19 -1
  45. package/dist/subsystem/graphChrome.js.map +1 -1
  46. package/dist/subsystem/model.d.ts +40 -4
  47. package/dist/subsystem/model.d.ts.map +1 -1
  48. package/dist/subsystem/model.js +38 -7
  49. package/dist/subsystem/model.js.map +1 -1
  50. package/dist/subsystem/{declarationRef.d.ts → sourceSpan.d.ts} +1 -1
  51. package/dist/subsystem/sourceSpan.d.ts.map +1 -0
  52. package/dist/subsystem/{declarationRef.js → sourceSpan.js} +1 -1
  53. package/dist/subsystem/sourceSpan.js.map +1 -0
  54. package/package.json +2 -2
  55. package/src/graphify/index.ts +6 -0
  56. package/src/graphify/symbolFocus.test.ts +163 -0
  57. package/src/graphify/symbolFocus.ts +190 -0
  58. package/src/index.ts +9 -2
  59. package/src/pierre/PierreTrailCodeView.tsx +1 -1
  60. package/src/stories/{Subsystem/AggregateGraph → Canvases/Aggregate}/AggregateFrameGraph.stories.tsx +1 -1
  61. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4CardGrow.stories.tsx +1 -1
  62. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4Component.stories.tsx +1 -1
  63. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4Container.stories.tsx +1 -1
  64. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4NodeGallery.stories.tsx +1 -1
  65. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4ToComponentDrill.stories.tsx +1 -1
  66. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4Transition.stories.tsx +1 -1
  67. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/AnimatedProgression.stories.tsx +1 -1
  68. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Appearance.stories.tsx +1 -1
  69. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Basics.stories.tsx +1 -1
  70. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Captures.stories.tsx +1 -1
  71. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Constructs.stories.tsx +2 -2
  72. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/CustomEntities.stories.tsx +1 -1
  73. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Deprecated.stories.tsx +1 -1
  74. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/DetailPanel.stories.tsx +2 -2
  75. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Diagnostics.stories.tsx +1 -1
  76. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/EdgeViews.stories.tsx +1 -1
  77. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/FrameworkStereotype.stories.tsx +1 -1
  78. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/GraphTitle.stories.tsx +1 -1
  79. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Issues.stories.tsx +1 -1
  80. package/src/stories/Canvases/Subsystem/LayoutDirection.stories.tsx +122 -0
  81. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/ModuleBadges.stories.tsx +1 -1
  82. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/ModulePathNesting.stories.tsx +1 -1
  83. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Modules.stories.tsx +1 -1
  84. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/NodeAnatomy.stories.tsx +1 -1
  85. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Packages.stories.tsx +1 -1
  86. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Processes.stories.tsx +1 -1
  87. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Proposed.stories.tsx +1 -1
  88. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/ProposedTrails.stories.tsx +1 -1
  89. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Reorder.stories.tsx +1 -1
  90. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Scenarios.stories.tsx +1 -1
  91. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Spotlights.stories.tsx +1 -1
  92. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/StoreValueType.stories.tsx +1 -1
  93. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/TrailAutoplay.stories.tsx +1 -1
  94. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Trails.stories.tsx +78 -2
  95. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/WorkspacePackageFrames.stories.tsx +1 -1
  96. package/src/stories/Canvases/SymbolFocusedView/SymbolFocusedView.stories.tsx +163 -0
  97. package/src/stories/{Subsystem → Components}/AgentsPanel.stories.tsx +1 -1
  98. package/src/stories/{Subsystem/ComponentDeclarationAudit.stories.tsx → Components/ComponentDeclaration/Audit.stories.tsx} +4 -4
  99. package/src/stories/{Subsystem/ComponentDeclarationSymbols.stories.tsx → Components/ComponentDeclaration/Symbols.stories.tsx} +5 -5
  100. package/src/stories/{Subsystem → Components}/ConstructsCatalog.stories.tsx +2 -2
  101. package/src/stories/{Subsystem/Sidebar → Components}/GraphSidebar.stories.tsx +4 -4
  102. package/src/stories/{Subsystem/IssueIcons.stories.tsx → Components/IssueList.stories.tsx} +1 -1
  103. package/src/stories/{Subsystem/StoreValueTypeDeclaration.stories.tsx → Components/StoreValueType.stories.tsx} +1 -1
  104. package/src/subsystem/ComponentDeclaration.tsx +2 -2
  105. package/src/subsystem/ConstructsCatalog.tsx +1 -1
  106. package/src/subsystem/SubsystemComponentGraph.tsx +334 -28
  107. package/src/subsystem/SymbolFocusedView.tsx +80 -0
  108. package/src/subsystem/SymbolInspectionCard.tsx +2 -2
  109. package/src/subsystem/TrailsPanel.tsx +114 -5
  110. package/src/subsystem/graphChrome.tsx +22 -1
  111. package/src/subsystem/layoutDirection.test.ts +26 -0
  112. package/src/subsystem/model.test.ts +17 -0
  113. package/src/subsystem/model.ts +70 -10
  114. package/src/subsystem/{declarationRef.test.ts → sourceSpan.test.ts} +1 -1
  115. package/dist/stories/Subsystem/AggregateGraph/aggregateViewFixture.d.ts.map +0 -1
  116. package/dist/stories/Subsystem/AggregateGraph/aggregateViewFixture.js.map +0 -1
  117. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +0 -1
  118. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +0 -1
  119. package/dist/subsystem/declarationRef.d.ts.map +0 -1
  120. package/dist/subsystem/declarationRef.js.map +0 -1
  121. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +0 -351
  122. /package/dist/stories/{Subsystem/AggregateGraph → Canvases/Aggregate}/aggregateViewFixture.d.ts +0 -0
  123. /package/dist/stories/{Subsystem/AggregateGraph → Canvases/Aggregate}/aggregateViewFixture.js +0 -0
  124. /package/dist/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/fixtures.d.ts +0 -0
  125. /package/dist/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/fixtures.js +0 -0
  126. /package/src/graphify/__fixtures__/{ego-samples.json → symbol-focused-samples.json} +0 -0
  127. /package/src/stories/{Subsystem/AggregateGraph → Canvases/Aggregate}/aggregateViewFixture.ts +0 -0
  128. /package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/fixtures.ts +0 -0
  129. /package/src/subsystem/{declarationRef.ts → sourceSpan.ts} +0 -0
@@ -32,7 +32,7 @@ import {
32
32
  applyNodeChanges,
33
33
  } from '@xyflow/react';
34
34
  import { useTheme } from '@principal-ade/industry-theme';
35
- import { FileText, X } from 'lucide-react';
35
+ import { FileText, ArrowDown, ArrowRight, Unlink, X } from 'lucide-react';
36
36
  import { IndustryMarkdownSlide } from 'themed-markdown';
37
37
  import {
38
38
  buildSubsystemGraph,
@@ -45,17 +45,19 @@ import {
45
45
  MECHANISM_DESCRIPTIONS,
46
46
  subsystemGraphLayoutKey,
47
47
  trailStepGraphEdgeId,
48
+ unroutedEdgeIds,
48
49
  type SubsystemComponentEdge,
49
50
  type SubsystemComponent,
50
51
  type SubsystemEdgeProvenance,
51
52
  type SubsystemEdgeView,
53
+ type SubsystemLayoutDirection,
52
54
  type SubsystemGraphifyRelation,
53
55
  type SubsystemNodeIssue,
54
56
  type SubsystemRegionIssue,
55
57
  type SubsystemTrail,
56
58
  } from './model';
57
59
  import { ConstructsCatalog } from './ConstructsCatalog';
58
- import type { SubsystemOpenFileOptions } from './declarationRef';
60
+ import type { SubsystemOpenFileOptions } from './sourceSpan';
59
61
  import {
60
62
  SubsystemAgentsPanel,
61
63
  type SubsystemAgentsPanelProps,
@@ -80,7 +82,7 @@ import {
80
82
  } from './IssueList';
81
83
  import { SubsystemFileTree } from './SubsystemFileTree';
82
84
  import { GraphLayoutCover } from './GraphLayoutCover';
83
- import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
85
+ import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle, LAYOUT_FLIP_CLASS, LAYOUT_FLIP_EDGE_MS, LAYOUT_FLIP_NODE_MS } from './graphChrome';
84
86
  import { ComponentDeclaration } from './ComponentDeclaration';
85
87
  import type { ComponentVerificationState } from './ComponentDeclaration';
86
88
  import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
@@ -160,6 +162,8 @@ interface PersistedViewState {
160
162
  sidebarWidth?: number;
161
163
  sidebarView?: 'files' | 'trails';
162
164
  diagnosticsTab?: 'issues' | 'agents';
165
+ /** Flow direction chosen with the canvas toggle, restored on the next visit. */
166
+ layoutDirection?: SubsystemLayoutDirection;
163
167
  }
164
168
 
165
169
  const VIEW_STATE_PREFIX = 'principal.subsystems.viewState.';
@@ -216,6 +220,40 @@ export interface SubsystemComponentGraphProps {
216
220
  * `line` for this to have any effect. @default false
217
221
  */
218
222
  orderByLine?: boolean;
223
+ /**
224
+ * Flow direction of the layered layout. `horizontal` (default) runs layers
225
+ * left-to-right; `vertical` runs them top-to-bottom. Edge-less containment
226
+ * graphs keep their DOWN + grid treatment either way.
227
+ *
228
+ * Pass this to control the direction from the host; leave it unset to let
229
+ * the graph own it (seeded by `defaultLayoutDirection`, updated by the
230
+ * built-in toggle, and persisted under `persistKey`).
231
+ */
232
+ layoutDirection?: SubsystemLayoutDirection;
233
+ /**
234
+ * Initial flow direction when uncontrolled. Ignored when `layoutDirection`
235
+ * is provided. @default 'horizontal'
236
+ */
237
+ defaultLayoutDirection?: SubsystemLayoutDirection;
238
+ /**
239
+ * Fired when the built-in toggle (or a host control) requests a different
240
+ * direction. Hosts that pass `layoutDirection` must update it here; hosts
241
+ * that don't can use it to observe the change.
242
+ */
243
+ onLayoutDirectionChange?: (direction: SubsystemLayoutDirection) => void;
244
+ /**
245
+ * Hide the built-in horizontal/vertical toggle in the canvas corner.
246
+ * @default false
247
+ */
248
+ hideLayoutToggle?: boolean;
249
+ /**
250
+ * Refit the camera to the new layout after a layout flip — a direction
251
+ * change or a trail-focus change. On by default: a vertical layout grows
252
+ * downward and would otherwise leave the graph half off-screen. Turn off for
253
+ * embeds that own their own camera.
254
+ * @default true
255
+ */
256
+ refocusOnLayoutFlip?: boolean;
219
257
  /**
220
258
  * Deep-link target: when set, the matching trail is selected on mount
221
259
  * — its steps expanded and its flow focused on the canvas. Hosts use this
@@ -574,7 +612,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
574
612
  measured: { w: number; h: number } | null;
575
613
  }
576
614
 
577
- function Inner({ components, trails, graphifyRelations, orderByLine, initialTrailId, onTrailSelect, onTrailStepHover, onReorderTrails, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, title, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, codeProcessKeys, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
615
+ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirection: layoutDirectionProp, defaultLayoutDirection = 'horizontal', onLayoutDirectionChange, hideLayoutToggle = false, refocusOnLayoutFlip = true, initialTrailId, onTrailSelect, onTrailStepHover, onReorderTrails, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, title, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, codeProcessKeys, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
578
616
  const { theme } = useTheme();
579
617
  const { fitView, fitBounds, screenToFlowPosition } = useReactFlow();
580
618
  const viewport = useViewport();
@@ -595,6 +633,151 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
595
633
  absoluteRects: new Map(),
596
634
  });
597
635
  const [layoutReady, setLayoutReady] = useState(false);
636
+
637
+ // --- Layout direction -----------------------------------------------------
638
+ // The graph owns direction unless the host passes `layoutDirection`. A change
639
+ // is sequenced so old edge routes never cut across the new layout: edges fade
640
+ // out, ELK re-lays out while the nodes slide to their new slots, then the
641
+ // edges fade back in. Two values keep that honest: `requestedDirection` is
642
+ // what the toggle shows, `committedDirection` is what ELK last ran with.
643
+ const [ownDirection, setOwnDirection] = useState<SubsystemLayoutDirection>(
644
+ () => persisted.layoutDirection ?? defaultLayoutDirection,
645
+ );
646
+ const requestedDirection = layoutDirectionProp ?? ownDirection;
647
+ const [committedDirection, setCommittedDirection] =
648
+ useState<SubsystemLayoutDirection>(requestedDirection);
649
+ const [layoutFlipping, setLayoutFlipping] = useState(false);
650
+ const flipSawPendingLayoutRef = useRef(false);
651
+ /** Pending fade-out timer; non-null only between request and commit. */
652
+ const flipFadeTimerRef = useRef<number | null>(null);
653
+ /** Set when a layout flip starts; the camera refits once layout lands. */
654
+ const refitAfterLayoutRef = useRef(false);
655
+
656
+ /** Solo layout: only this trail's components and edges are laid out. */
657
+ const [layoutTrailId, setLayoutTrailId] = useState<string | null>(null);
658
+ const [committedTrailId, setCommittedTrailId] = useState<string | null>(null);
659
+
660
+ const changeLayoutDirection = useCallback(
661
+ (next: SubsystemLayoutDirection) => {
662
+ if (next === requestedDirection) return;
663
+ if (layoutDirectionProp == null) setOwnDirection(next);
664
+ onLayoutDirectionChange?.(next);
665
+ },
666
+ [layoutDirectionProp, onLayoutDirectionChange, requestedDirection],
667
+ );
668
+
669
+ const toggleLayoutFocus = useCallback((trailId: string) => {
670
+ setLayoutTrailId((cur) => (cur === trailId ? null : trailId));
671
+ }, []);
672
+
673
+ // A focused trail that disappears (model reload) releases the solo layout
674
+ // instead of pinning the canvas to a slice that no longer exists.
675
+ useEffect(() => {
676
+ if (layoutTrailId && !(trails ?? []).some((t) => t.id === layoutTrailId)) {
677
+ setLayoutTrailId(null);
678
+ }
679
+ }, [trails, layoutTrailId]);
680
+
681
+ const requestedLayoutKey = `${requestedDirection}|${layoutTrailId ?? ''}`;
682
+ const committedLayoutKey = `${committedDirection}|${committedTrailId ?? ''}`;
683
+
684
+ // Beat 1: request → fade the edges out, then commit the direction / focus
685
+ // (which re-runs ELK). Reduced-motion users get the new layout immediately.
686
+ useEffect(() => {
687
+ if (requestedLayoutKey === committedLayoutKey) {
688
+ // A host can revert a request before the fade-out commits. Drop the
689
+ // pending flip instead of leaving the canvas edge-less until the safety
690
+ // timeout. A normal commit also lands here (request now equals
691
+ // committed), but by then the timer ref is already null — and the
692
+ // pending refit must survive that pass.
693
+ if (flipFadeTimerRef.current != null) {
694
+ clearTimeout(flipFadeTimerRef.current);
695
+ flipFadeTimerRef.current = null;
696
+ setLayoutFlipping(false);
697
+ refitAfterLayoutRef.current = false;
698
+ }
699
+ return;
700
+ }
701
+ refitAfterLayoutRef.current = refocusOnLayoutFlip;
702
+ const reduceMotion =
703
+ typeof window !== 'undefined' &&
704
+ typeof window.matchMedia === 'function' &&
705
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
706
+ if (reduceMotion) {
707
+ setCommittedDirection(requestedDirection);
708
+ setCommittedTrailId(layoutTrailId);
709
+ return;
710
+ }
711
+ flipSawPendingLayoutRef.current = false;
712
+ setLayoutFlipping(true);
713
+ flipFadeTimerRef.current = window.setTimeout(() => {
714
+ flipFadeTimerRef.current = null;
715
+ setCommittedDirection(requestedDirection);
716
+ setCommittedTrailId(layoutTrailId);
717
+ }, LAYOUT_FLIP_EDGE_MS);
718
+ return () => {
719
+ if (flipFadeTimerRef.current != null) {
720
+ clearTimeout(flipFadeTimerRef.current);
721
+ flipFadeTimerRef.current = null;
722
+ }
723
+ };
724
+ }, [
725
+ requestedLayoutKey,
726
+ committedLayoutKey,
727
+ refocusOnLayoutFlip,
728
+ requestedDirection,
729
+ layoutTrailId,
730
+ ]);
731
+
732
+ // Beat 3: the committed layout has settled → the node slide is over, fade
733
+ // the edges back in. The ref keeps the OLD `layoutReady` (still true until
734
+ // the new pass 1 commits) from ending the flip before the layout swaps.
735
+ useEffect(() => {
736
+ if (!layoutFlipping) return;
737
+ if (!layoutReady) {
738
+ flipSawPendingLayoutRef.current = true;
739
+ return;
740
+ }
741
+ if (!flipSawPendingLayoutRef.current) return;
742
+ const t = setTimeout(() => setLayoutFlipping(false), LAYOUT_FLIP_NODE_MS);
743
+ return () => clearTimeout(t);
744
+ }, [layoutFlipping, layoutReady]);
745
+
746
+ // Camera: a direction flip moves the whole graph, and a vertical layout grows
747
+ // downward, so keeping the old viewport would leave most of it off-screen.
748
+ // Refit once the new layout is final — for both the animated flip and the
749
+ // reduced-motion instant swap. The edge reveal is separate (beat 3 above).
750
+ // Keyed on `built` as well as `layoutReady`: whichever of the two passes
751
+ // lands last is the one that has the final positions.
752
+ useEffect(() => {
753
+ if (!layoutReady || !refitAfterLayoutRef.current) return;
754
+ refitAfterLayoutRef.current = false;
755
+ requestAnimationFrame(() => {
756
+ fitView({
757
+ padding: 0.1,
758
+ includeHiddenNodes: false,
759
+ minZoom: 0.05,
760
+ maxZoom: 2,
761
+ duration: LAYOUT_FLIP_NODE_MS,
762
+ });
763
+ });
764
+ }, [built, layoutReady, fitView]);
765
+
766
+ // Persist the choice so a model reopens the way it was left.
767
+ useEffect(() => {
768
+ if (!persistKey) return;
769
+ writeViewState(persistKey, { layoutDirection: requestedDirection });
770
+ }, [persistKey, requestedDirection]);
771
+
772
+ // Safety net: never leave the canvas edge-less if a layout pass stalls
773
+ // (measurement never lands, ELK hangs). The edges come back; the layout
774
+ // stays wherever it got to.
775
+ useEffect(() => {
776
+ if (!layoutFlipping) return;
777
+ const t = setTimeout(() => setLayoutFlipping(false), 5000);
778
+ return () => clearTimeout(t);
779
+ }, [layoutFlipping]);
780
+
598
781
  const [selected, setSelected] = useState<SubsystemComponent | null>(null);
599
782
  /** Bottom drawer: single file or trail multi-snippet mode. */
600
783
  const [drawerTarget, setDrawerTarget] = useState<DrawerTarget | null>(null);
@@ -717,8 +900,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
717
900
  // Trails draws runtime step edges. Without visible tabs (no trails,
718
901
  // or a sidebar-less embed) fall back to trail edges.
719
902
  const sidebarTabsVisible = !hideSidebar && (trails?.length ?? 0) > 0;
720
- const resolvedEdgeView: SubsystemEdgeView =
721
- edgeView ??
903
+ // A solo trail layout only draws that flow's steps, so the trail edge source
904
+ // is the only one that can render anything.
905
+ const resolvedEdgeView: SubsystemEdgeView = committedTrailId
906
+ ? 'trails'
907
+ : edgeView ??
722
908
  (sidebarTabsVisible
723
909
  ? (sidebarView === 'trails' ? 'trails' : 'graphify')
724
910
  : (trails?.length ?? 0) > 0
@@ -893,7 +1079,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
893
1079
  }, [drawerStepIndex, focusedTrail, visibleStepIndex]);
894
1080
 
895
1081
  // Refresh selected component when the components list updates (e.g. verify
896
- // writes back declarationRef).
1082
+ // writes back declarationSpan).
897
1083
  useEffect(() => {
898
1084
  if (!selected) return;
899
1085
  const fresh = components.find((c) => c.alias === selected.alias);
@@ -902,17 +1088,40 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
902
1088
 
903
1089
  // Pass 1: build with estimated widths so React Flow can measure the DOM.
904
1090
  // The pane stays hidden until Pass 2 completes. Key off layout-affecting
905
- // fields only — declarationRef updates after verify must not re-run ELK.
1091
+ // fields only — declarationSpan updates after verify must not re-run ELK.
1092
+ // Solo layout: a trail focus filters the model down to that flow's endpoints
1093
+ // (and drops graphify relations) before it reaches ELK. Built from the
1094
+ // COMMITTED trail, not the requested one, so the swap happens behind the
1095
+ // edge fade like every other layout flip.
1096
+ const layoutDoc = useMemo(() => {
1097
+ if (!committedTrailId) return { components, trails };
1098
+ const trail = (trails ?? []).find((t) => t.id === committedTrailId);
1099
+ if (!trail) return { components, trails };
1100
+ const aliases = new Set<string>();
1101
+ for (const step of trail.steps) {
1102
+ aliases.add(step.from);
1103
+ aliases.add(step.to);
1104
+ }
1105
+ return {
1106
+ components: components.filter((c) => aliases.has(c.alias)),
1107
+ trails: [trail],
1108
+ };
1109
+ }, [components, trails, committedTrailId]);
1110
+ const layoutRelations = committedTrailId ? undefined : graphifyRelations;
1111
+
906
1112
  const layoutKey = useMemo(
907
- () => subsystemGraphLayoutKey({ components, trails, graphifyRelations }),
908
- [components, trails, graphifyRelations],
1113
+ () =>
1114
+ subsystemGraphLayoutKey({
1115
+ components: layoutDoc.components,
1116
+ trails: layoutDoc.trails,
1117
+ graphifyRelations: layoutRelations,
1118
+ }),
1119
+ [layoutDoc, layoutRelations],
909
1120
  );
910
- const componentsRef = useRef(components);
911
- const trailsRef = useRef(trails);
912
- const graphifyRelationsRef = useRef(graphifyRelations);
913
- componentsRef.current = components;
914
- trailsRef.current = trails;
915
- graphifyRelationsRef.current = graphifyRelations;
1121
+ const layoutDocRef = useRef(layoutDoc);
1122
+ const layoutRelationsRef = useRef(layoutRelations);
1123
+ layoutDocRef.current = layoutDoc;
1124
+ layoutRelationsRef.current = layoutRelations;
916
1125
 
917
1126
  // Track measured dimensions from React Flow's dimension changes.
918
1127
  // These arrive as { type: 'dimensions', id, dimensions } in onNodesChange.
@@ -929,17 +1138,15 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
929
1138
  prevMeasuredSigRef.current = '';
930
1139
  // Bump generation so any in-flight Pass 2 from the prior layoutKey is ignored.
931
1140
  pass2GenRef.current += 1;
932
- const doc = {
933
- components: componentsRef.current,
934
- trails: trailsRef.current,
935
- };
1141
+ const doc = layoutDocRef.current;
936
1142
  void buildSubsystemGraph(doc, {
937
1143
  maxNodeWidth,
938
1144
  showEdgeLabels,
939
1145
  showSingletonFrames,
940
1146
  moduleNesting,
941
- graphifyRelations: graphifyRelationsRef.current,
1147
+ graphifyRelations: layoutRelationsRef.current,
942
1148
  orderByLine,
1149
+ layoutDirection: committedDirection,
943
1150
  codeProcessKeys,
944
1151
  })
945
1152
  .then(({ nodes, edges: e, absoluteRects }) => {
@@ -963,7 +1170,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
963
1170
  setLayoutReady(true);
964
1171
  });
965
1172
  return () => { alive = false; };
966
- }, [layoutKey, maxNodeWidth, showEdgeLabels, moduleNesting]);
1173
+ }, [layoutKey, maxNodeWidth, showEdgeLabels, moduleNesting, committedDirection]);
967
1174
 
968
1175
  // Pass 2: once every leaf node has a measured dimension, re-run ELK.
969
1176
  // Group parents are sized by ELK, not measured — exclude them or pass 2
@@ -989,8 +1196,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
989
1196
  const measuredHeights = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.height]));
990
1197
  const gen = ++pass2GenRef.current;
991
1198
  void buildSubsystemGraph(
992
- { components, trails },
993
- { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames, moduleNesting, graphifyRelations, orderByLine, codeProcessKeys },
1199
+ layoutDoc,
1200
+ { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames, moduleNesting, graphifyRelations: layoutRelations, orderByLine, layoutDirection: committedDirection, codeProcessKeys },
994
1201
  )
995
1202
  .then(({ nodes, edges: e, absoluteRects }) => {
996
1203
  if (gen !== pass2GenRef.current) return;
@@ -1003,7 +1210,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1003
1210
  // Reveal Pass 1 layout rather than leaving the cover up forever.
1004
1211
  setLayoutReady(true);
1005
1212
  });
1006
- }, [built.nodes, components, trails, graphifyRelations, orderByLine, maxNodeWidth, showEdgeLabels, moduleNesting]);
1213
+ }, [built.nodes, layoutDoc, layoutRelations, orderByLine, maxNodeWidth, showEdgeLabels, moduleNesting, committedDirection]);
1007
1214
 
1008
1215
  // After Pass 1 commits, try Pass 2 immediately with retained measurements.
1009
1216
  // Same-id live updates often get no new React Flow `dimensions` events, so
@@ -1652,6 +1859,12 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1652
1859
  return proposedStampedEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
1653
1860
  }, [proposedStampedEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
1654
1861
 
1862
+ // Gated on layoutReady so the pre-reveal Pass 1 swap never flashes a count.
1863
+ const unroutedIds = useMemo(
1864
+ () => (layoutReady ? unroutedEdgeIds(dispEdges) : []),
1865
+ [dispEdges, layoutReady],
1866
+ );
1867
+
1655
1868
  const onNodesChange = useCallback(
1656
1869
  (changes: NodeChange[]) => {
1657
1870
  // Capture dimension changes (React Flow's measurement callback).
@@ -1681,6 +1894,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1681
1894
  );
1682
1895
 
1683
1896
  useEffect(() => {
1897
+ // A layout flip runs its own camera refit when the new layout lands. This
1898
+ // mount/edit fit would otherwise fire mid-flip and fight it.
1899
+ if (layoutFlipping) return;
1684
1900
  const t = setTimeout(() => {
1685
1901
  fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 200 });
1686
1902
  }, 200);
@@ -2768,7 +2984,13 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2768
2984
  focusedStepIndex={focusedStepIndex}
2769
2985
  hoveredTrailStep={hoveredTrailStep}
2770
2986
  onToggleCollapsed={toggleTrailCollapsed}
2771
- onFocusFlow={focusTrailEdges}
2987
+ onFocusFlow={(tl) => {
2988
+ // While a solo layout is on, following the selection keeps the canvas
2989
+ // and the highlighted flow in agreement instead of leaving the graph
2990
+ // pinned to a flow the user just navigated away from.
2991
+ if (layoutTrailId != null) setLayoutTrailId(tl.id);
2992
+ focusTrailEdges(tl);
2993
+ }}
2772
2994
  onClearFocus={clearTrailFocus}
2773
2995
  onFocusStep={focusTrailStep}
2774
2996
  onHoverStep={(tl, i) => {
@@ -2785,6 +3007,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2785
3007
  }}
2786
3008
  onReorder={onReorderTrails}
2787
3009
  proposedAliases={proposedAliases}
3010
+ layoutFocusedTrailId={layoutTrailId}
3011
+ onFocusLayout={toggleLayoutFocus}
2788
3012
  />
2789
3013
  ) : treeFilePaths.length > 0 ? (
2790
3014
  <>
@@ -2851,6 +3075,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2851
3075
  overlays shift with the canvas, not the labels. Collapses when the
2852
3076
  file drawer fills the column (whole-file reading). */}
2853
3077
  <div
3078
+ className={layoutFlipping ? LAYOUT_FLIP_CLASS : undefined}
2854
3079
  style={{
2855
3080
  position: 'relative',
2856
3081
  flex: collapseCanvas ? 0 : 1,
@@ -2870,6 +3095,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2870
3095
  overflow: 'hidden',
2871
3096
  pointerEvents: 'none',
2872
3097
  zIndex: 5,
3098
+ opacity: layoutFlipping ? 0 : 1,
3099
+ transition: `opacity ${LAYOUT_FLIP_EDGE_MS}ms ease`,
2873
3100
  }}
2874
3101
  >
2875
3102
  {edgeLabels.map((lbl) => {
@@ -3044,6 +3271,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
3044
3271
  nodeTypes={nodeTypes}
3045
3272
  edgeTypes={edgeTypes}
3046
3273
  className={GRAPH_CANVAS_CLASS}
3274
+ // The key above remounts ReactFlow when the node/edge SET changes (a
3275
+ // trail-focus layout, a filtered model). A remount otherwise resets the
3276
+ // viewport to identity — a visible jump to scale 1 before the post-flip
3277
+ // refit. Seed the new instance with the viewport the old one had.
3278
+ defaultViewport={viewport}
3047
3279
  {...GRAPH_NAV_PROPS}
3048
3280
  onNodeClick={onNodeClick}
3049
3281
  onNodeDoubleClick={onNodeDoubleClick}
@@ -3062,6 +3294,78 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
3062
3294
  >
3063
3295
  <GraphChrome />
3064
3296
  </ReactFlow>
3297
+ {!hideLayoutToggle && (
3298
+ <button
3299
+ type="button"
3300
+ onClick={() =>
3301
+ changeLayoutDirection(
3302
+ requestedDirection === 'horizontal' ? 'vertical' : 'horizontal',
3303
+ )
3304
+ }
3305
+ disabled={layoutFlipping}
3306
+ title={
3307
+ requestedDirection === 'horizontal'
3308
+ ? 'Switch to a vertical layout (top-to-bottom)'
3309
+ : 'Switch to a horizontal layout (left-to-right)'
3310
+ }
3311
+ aria-label={
3312
+ requestedDirection === 'horizontal'
3313
+ ? 'Switch to vertical layout'
3314
+ : 'Switch to horizontal layout'
3315
+ }
3316
+ style={{
3317
+ position: 'absolute',
3318
+ top: 10,
3319
+ right: 10,
3320
+ zIndex: 6,
3321
+ display: 'inline-flex',
3322
+ alignItems: 'center',
3323
+ gap: 5,
3324
+ padding: '4px 9px',
3325
+ border: `1px solid ${theme.colors.border}`,
3326
+ borderRadius: 6,
3327
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
3328
+ color: theme.colors.textSecondary,
3329
+ fontFamily: theme.fonts.monospace,
3330
+ fontSize: theme.fontSizes[0],
3331
+ cursor: layoutFlipping ? 'default' : 'pointer',
3332
+ opacity: layoutFlipping ? 0.55 : 1,
3333
+ }}
3334
+ >
3335
+ {requestedDirection === 'horizontal' ? (
3336
+ <ArrowDown size={13} aria-hidden="true" />
3337
+ ) : (
3338
+ <ArrowRight size={13} aria-hidden="true" />
3339
+ )}
3340
+ {requestedDirection === 'horizontal' ? 'Vertical' : 'Horizontal'}
3341
+ </button>
3342
+ )}
3343
+ {unroutedIds.length > 0 && (
3344
+ <div
3345
+ role="status"
3346
+ title={`Edges with no routed path (not drawn):\n${unroutedIds.join('\n')}`}
3347
+ style={{
3348
+ position: 'absolute',
3349
+ top: 10,
3350
+ left: 10,
3351
+ zIndex: 6,
3352
+ display: 'inline-flex',
3353
+ alignItems: 'center',
3354
+ gap: 6,
3355
+ padding: '4px 10px',
3356
+ fontSize: theme.fontSizes[0],
3357
+ fontFamily: theme.fonts.body,
3358
+ color: theme.colors.text,
3359
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
3360
+ border: `1px solid ${theme.colors.warning ?? '#d4a017'}`,
3361
+ borderRadius: 6,
3362
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
3363
+ }}
3364
+ >
3365
+ <Unlink size={13} color={theme.colors.warning ?? '#d4a017'} aria-hidden />
3366
+ {unroutedIds.length} {unroutedIds.length === 1 ? 'edge' : 'edges'} not drawn
3367
+ </div>
3368
+ )}
3065
3369
  {/* Graph / trail / step titles — non-interactive chips at the top
3066
3370
  of the canvas. Graph-only embeds use these without opening the sidebar. */}
3067
3371
  {(graphTitle || overlayTrailTitle || overlayTrailStep) && (
@@ -3322,8 +3626,10 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
3322
3626
  </div>
3323
3627
  </div>
3324
3628
  )}
3325
- {/* Startup cover — hides measurement, layout swap, and camera settle. */}
3326
- <GraphLayoutCover revealed={layoutReady} />
3629
+ {/* Startup cover — hides measurement, layout swap, and camera settle.
3630
+ Kept away during a direction flip: the flip is meant to be watched,
3631
+ and the cover would swallow the node slide. */}
3632
+ <GraphLayoutCover revealed={layoutReady || layoutFlipping} />
3327
3633
  {canvasOverlay}
3328
3634
  {liveEvents ? <MaintainLivePanel {...liveEvents} /> : null}
3329
3635
  </div>
@@ -0,0 +1,80 @@
1
+ /**
2
+ * SymbolFocusedView — one graphify symbol and its radius-1 neighborhood on the
3
+ * subsystem canvas.
4
+ *
5
+ * This is the public entry point for the "focused symbol" canvas: the host
6
+ * hands over the center node plus the neighbors and edges graphify already
7
+ * extracted, and the view projects them onto the shared component graph
8
+ * (`toSymbolFocusedView`). Edges keep their raw graphify verbs and draw from
9
+ * the graphify palette, so derived facts stay visually distinct from authored
10
+ * subsystem mechanisms.
11
+ *
12
+ * No trails, no drawers for a model: a symbol focus is read, not walked. Hover
13
+ * and select still work, and double-clicking a node opens its file like any
14
+ * other component node.
15
+ */
16
+
17
+ import { useMemo } from 'react';
18
+ import type {
19
+ SymbolFocusedViewInput,
20
+ SymbolFocusedViewOptions,
21
+ } from '../graphify/symbolFocus';
22
+ import { toSymbolFocusedView } from '../graphify/symbolFocus';
23
+ import type { SubsystemComponentGraphProps } from './SubsystemComponentGraph';
24
+ import { SubsystemComponentGraph } from './SubsystemComponentGraph';
25
+
26
+ /**
27
+ * Canvas chrome that is meaningful for a derived, trail-less projection. The
28
+ * authored-model inputs (`components`, `trails`, `graphifyRelations`) and the
29
+ * trail deep-link/playback props are deliberately not forwarded: the view
30
+ * owns them.
31
+ */
32
+ type SymbolFocusedViewChrome = Pick<
33
+ SubsystemComponentGraphProps,
34
+ | 'title'
35
+ | 'description'
36
+ | 'orderByLine'
37
+ | 'showEdgeLabels'
38
+ | 'showSingletonFrames'
39
+ | 'hideSidebar'
40
+ | 'hideDrawer'
41
+ | 'maxNodeWidth'
42
+ | 'persistKey'
43
+ | 'onSelect'
44
+ | 'onEdgeSelect'
45
+ | 'onFileSelect'
46
+ | 'componentVerification'
47
+ | 'onInspectSymbol'
48
+ | 'canvasOverlay'
49
+ >;
50
+
51
+ export interface SymbolFocusedViewProps
52
+ extends SymbolFocusedViewInput,
53
+ SymbolFocusedViewOptions,
54
+ SymbolFocusedViewChrome {}
55
+
56
+ export function SymbolFocusedView({
57
+ center,
58
+ neighbors,
59
+ edges,
60
+ repoPurl,
61
+ centerConstruct,
62
+ ...chrome
63
+ }: SymbolFocusedViewProps) {
64
+ const { components, graphifyRelations } = useMemo(
65
+ () =>
66
+ toSymbolFocusedView(
67
+ { center, neighbors, edges },
68
+ { repoPurl, ...(centerConstruct ? { centerConstruct } : {}) },
69
+ ),
70
+ [center, neighbors, edges, repoPurl, centerConstruct],
71
+ );
72
+
73
+ return (
74
+ <SubsystemComponentGraph
75
+ components={components}
76
+ graphifyRelations={graphifyRelations}
77
+ {...chrome}
78
+ />
79
+ );
80
+ }
@@ -15,8 +15,8 @@ import { ExternalLink } from 'lucide-react';
15
15
  import { useTheme } from '@principal-ade/industry-theme';
16
16
  import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
17
17
  import { sourceLangForPath, type SourceSyntaxLang } from '../pierre/sourceLang';
18
- import type { SubsystemOpenFileOptions } from './declarationRef';
19
- import { parseSourceLocation } from './declarationRef';
18
+ import type { SubsystemOpenFileOptions } from './sourceSpan';
19
+ import { parseSourceLocation } from './sourceSpan';
20
20
  import { generateDeclarationString } from './formatDeclaration';
21
21
  import type {
22
22
  SubsystemComponent,