@principal-ai/subsystems-react 0.38.0 → 0.40.0

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 (72) hide show
  1. package/dist/components/maintain-events/MaintainEventLog.d.ts +35 -0
  2. package/dist/components/maintain-events/MaintainEventLog.d.ts.map +1 -0
  3. package/dist/components/maintain-events/MaintainEventLog.js +66 -0
  4. package/dist/components/maintain-events/MaintainEventLog.js.map +1 -0
  5. package/dist/components/maintain-events/MaintainLivePanel.d.ts +15 -0
  6. package/dist/components/maintain-events/MaintainLivePanel.d.ts.map +1 -0
  7. package/dist/components/maintain-events/MaintainLivePanel.js +49 -0
  8. package/dist/components/maintain-events/MaintainLivePanel.js.map +1 -0
  9. package/dist/components/maintain-events/index.d.ts +8 -0
  10. package/dist/components/maintain-events/index.d.ts.map +1 -0
  11. package/dist/components/maintain-events/index.js +6 -0
  12. package/dist/components/maintain-events/index.js.map +1 -0
  13. package/dist/graphify/consolidated.d.ts +8 -0
  14. package/dist/graphify/consolidated.d.ts.map +1 -1
  15. package/dist/graphify/construct.d.ts +18 -0
  16. package/dist/graphify/construct.d.ts.map +1 -1
  17. package/dist/graphify/construct.js +23 -0
  18. package/dist/graphify/construct.js.map +1 -1
  19. package/dist/graphify/index.d.ts +2 -2
  20. package/dist/graphify/index.d.ts.map +1 -1
  21. package/dist/graphify/index.js +1 -1
  22. package/dist/graphify/index.js.map +1 -1
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/subsystem/IssueList.d.ts +49 -3
  28. package/dist/subsystem/IssueList.d.ts.map +1 -1
  29. package/dist/subsystem/IssueList.js +180 -79
  30. package/dist/subsystem/IssueList.js.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.d.ts +15 -1
  32. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  33. package/dist/subsystem/SubsystemComponentGraph.js +464 -14
  34. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  35. package/dist/subsystem/formatDeclaration.js +19 -5
  36. package/dist/subsystem/formatDeclaration.js.map +1 -1
  37. package/dist/subsystem/model.d.ts +38 -0
  38. package/dist/subsystem/model.d.ts.map +1 -1
  39. package/dist/subsystem/model.js.map +1 -1
  40. package/dist/subsystem/nodes.d.ts.map +1 -1
  41. package/dist/subsystem/nodes.js +124 -67
  42. package/dist/subsystem/nodes.js.map +1 -1
  43. package/dist/subsystem/symbolRefs.d.ts.map +1 -1
  44. package/dist/subsystem/symbolRefs.js +2 -0
  45. package/dist/subsystem/symbolRefs.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/components/maintain-events/MaintainEventLog.tsx +152 -0
  48. package/src/components/maintain-events/MaintainLivePanel.tsx +99 -0
  49. package/src/components/maintain-events/index.ts +10 -0
  50. package/src/graphify/consolidated.ts +8 -0
  51. package/src/graphify/construct.test.ts +61 -1
  52. package/src/graphify/construct.ts +33 -0
  53. package/src/graphify/index.ts +6 -1
  54. package/src/index.ts +9 -0
  55. package/src/stories/MaintainEvents/MaintainEventLog.stories.tsx +66 -0
  56. package/src/stories/MaintainEvents/MaintainLivePanel.stories.tsx +77 -0
  57. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +625 -0
  58. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +7 -1
  59. package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +40 -73
  60. package/src/stories/Subsystem/ComponentGraph/StoreValueType.stories.tsx +261 -0
  61. package/src/stories/Subsystem/StoreValueTypeDeclaration.stories.tsx +182 -0
  62. package/src/stories/data/maintain-live-events.json +554 -0
  63. package/src/subsystem/IssueList.focus.test.tsx +113 -0
  64. package/src/subsystem/IssueList.icon.test.tsx +95 -0
  65. package/src/subsystem/IssueList.tsx +267 -111
  66. package/src/subsystem/SubsystemComponentGraph.tsx +541 -13
  67. package/src/subsystem/formatDeclaration.test.ts +64 -0
  68. package/src/subsystem/formatDeclaration.ts +20 -5
  69. package/src/subsystem/model.ts +46 -0
  70. package/src/subsystem/nodes.group.test.tsx +52 -0
  71. package/src/subsystem/nodes.tsx +102 -2
  72. package/src/subsystem/symbolRefs.ts +2 -0
@@ -20,11 +20,12 @@ import { ReactFlow, ReactFlowProvider, useReactFlow, useViewport, applyNodeChang
20
20
  import { useTheme } from '@principal-ade/industry-theme';
21
21
  import { FileText, X } from 'lucide-react';
22
22
  import { IndustryMarkdownSlide } from 'themed-markdown';
23
- import { buildSubsystemGraph, deriveGraphEdges, isConstructsOnlyModel, isRelationMechanism, isWalkthroughMechanism, edgeColor, MECHANISM_DESCRIPTIONS, subsystemGraphLayoutKey, walkthroughStepGraphEdgeId, } from './model';
23
+ import { buildSubsystemGraph, deriveGraphEdges, isConstructsOnlyModel, moduleGroupNodeId, isRelationMechanism, isWalkthroughMechanism, edgeColor, MECHANISM_DESCRIPTIONS, subsystemGraphLayoutKey, walkthroughStepGraphEdgeId, } from './model';
24
24
  import { ConstructsCatalog } from './ConstructsCatalog';
25
+ import { MaintainLivePanel, } from '../components/maintain-events/MaintainLivePanel';
25
26
  import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
26
27
  import { SubsystemDiagnosticToggle } from './DiagnosticToggle';
27
- import { SubsystemIssueList, } from './IssueList';
28
+ import { SubsystemIssueList, issueCategory, issueKindOrder, issueRung, ISSUE_KIND_ICON, ISSUE_RUNG_ORDER, } from './IssueList';
28
29
  import { SubsystemFileTree } from './SubsystemFileTree';
29
30
  import { GraphLayoutCover } from './GraphLayoutCover';
30
31
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
@@ -120,7 +121,7 @@ const WalkthroughDrawerContent = memo(function WalkthroughDrawerContent({ render
120
121
  onSymbolClick,
121
122
  }) }));
122
123
  });
123
- function Inner({ components, relations, walkthroughs, graphifyRelations, orderByLine, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, walkthroughFocusDurationMs = 300, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, hideDrawer = false, persistKey }) {
124
+ function Inner({ components, relations, walkthroughs, graphifyRelations, orderByLine, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, walkthroughFocusDurationMs = 300, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, hideDrawer = false, persistKey, liveEvents }) {
124
125
  const { theme } = useTheme();
125
126
  const { fitView, fitBounds } = useReactFlow();
126
127
  const viewport = useViewport();
@@ -161,13 +162,19 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
161
162
  const [hoveredWalkthroughStep, setHoveredWalkthroughStep] = useState(null);
162
163
  // Sidebar bottom half: which panel is shown when walkthroughs exist.
163
164
  const [sidebarView, setSidebarView] = useState(() => walkthroughs?.length ? 'walkthroughs' : 'files');
165
+ // Temporary edge-vocabulary override. Focusing a relation finding whose edge
166
+ // belongs to the *other* vocabulary flips the canvas so the edge is actually
167
+ // visible; it reverts when the focus is undone (see `unfocusIssueTarget`) or
168
+ // when the diagnostics list closes. Null = use the host-pinned / tab view.
169
+ const [edgeViewOverride, setEdgeViewOverride] = useState(null);
164
170
  // One edge vocabulary at a time. When the caller doesn't pick, the sidebar's
165
171
  // Files / Walkthroughs tab picks: Files draws topology relation edges,
166
172
  // Walkthroughs draws runtime hop edges. Without visible tabs (no walkthroughs,
167
173
  // or a sidebar-less embed) fall back to the model: a flow-only graph defaults
168
174
  // to walkthrough edges rather than empty, everything else to relations.
169
175
  const sidebarTabsVisible = !hideSidebar && (walkthroughs?.length ?? 0) > 0;
170
- const resolvedEdgeView = edgeView ??
176
+ const resolvedEdgeView = edgeViewOverride ??
177
+ edgeView ??
171
178
  (sidebarTabsVisible
172
179
  ? (sidebarView === 'walkthroughs' ? 'walkthroughs' : 'relations')
173
180
  : relations.length === 0 && (walkthroughs?.length ?? 0) > 0
@@ -249,6 +256,11 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
249
256
  // closure over the effect deps) can toggle without a stale value.
250
257
  const selectedRef = useRef(null);
251
258
  selectedRef.current = selected;
259
+ // The module frame an issue card framed. A module target selects no node and
260
+ // no edge, so nothing else records that focus — without this the canvas has
261
+ // no way to tell a module focus it still owns from one a later click took
262
+ // over, and the camera would stay parked on the region forever.
263
+ const issueModuleFocusRef = useRef(null);
252
264
  // Ref mirror of the open file drawer target for tree-click toggle.
253
265
  const openFileRef = useRef(null);
254
266
  const openFile = fileOverlay?.file ??
@@ -532,12 +544,239 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
532
544
  const { nodes, edges: convertedEdges } = built;
533
545
  const xyflowNodesBase = nodes;
534
546
  const baseEdges = convertedEdges;
547
+ // Resolve an issue's component target to the matching node. `id` is the
548
+ // stable alias; `label` may be an alias, name, or symbol. Non-component
549
+ // targets (relation / module / flow / repo) have no single node → null.
550
+ const issueComponent = useCallback((issue) => {
551
+ const target = issue.target;
552
+ if (target?.kind !== 'component')
553
+ return null;
554
+ return (components.find((c) => (target.id != null && c.alias === target.id) ||
555
+ c.alias === target.label ||
556
+ c.name === target.label ||
557
+ c.symbol === target.label) ?? null);
558
+ }, [components]);
559
+ // Resolve a `relation` issue target to the display edge it flags. The target
560
+ // names the two endpoints (`from → to`); match an edge joining both, in
561
+ // either direction, preferring the target's `detail` mechanism when it names
562
+ // one. Null when no display edge matches.
563
+ const issueEdge = useCallback((issue) => {
564
+ const target = issue.target;
565
+ if (target?.kind !== 'relation')
566
+ return null;
567
+ const parts = `${target.id ?? ''} → ${target.label}`
568
+ .split('→')
569
+ .map((p) => p.replace(/\(\)$/, '').trim())
570
+ .filter(Boolean);
571
+ const joins = (e) => parts.includes(e.source) && parts.includes(e.target);
572
+ const matches = baseEdges.filter(joins);
573
+ const byMechanism = target.detail
574
+ ? matches.filter((e) => e.data?.mechanism ===
575
+ target.detail)
576
+ : matches;
577
+ return byMechanism[0] ?? matches[0] ?? null;
578
+ }, [baseEdges]);
579
+ // A `module` issue target names a boundary frame, so focusing the card frames
580
+ // that region on the canvas. Null when the target names no frame (a singleton
581
+ // dropped from the layout, or a region key nothing declares).
582
+ const issueModuleNodeId = useCallback((issue) => {
583
+ const target = issue.target;
584
+ if (target?.kind !== 'module')
585
+ return null;
586
+ const key = target.id ?? target.label;
587
+ if (!components.some((c) => c.module?.trim() === key))
588
+ return null;
589
+ return moduleGroupNodeId(key);
590
+ }, [components]);
591
+ // A `step` issue target names ONE step of a walkthrough — the walkthrough id in
592
+ // `id`, the 0-based step position in `stepIndex`. Validated against the loaded
593
+ // walkthroughs so a finding left over from an edited flow resolves to null
594
+ // rather than framing whatever now happens to sit at that index.
595
+ const issueStep = useCallback((issue) => {
596
+ const target = issue.target;
597
+ if (target?.kind !== 'step')
598
+ return null;
599
+ if (target.id == null || target.stepIndex == null)
600
+ return null;
601
+ const walkthrough = walkthroughs?.find((t) => t.id === target.id);
602
+ if (!walkthrough?.steps[target.stepIndex])
603
+ return null;
604
+ return { walkthrough, stepIndex: target.stepIndex };
605
+ }, [walkthroughs]);
606
+ // Per-node diagnostics badge: fold each component-targeted finding that maps
607
+ // to a verification rung into one badge per node — worst severity wins, the
608
+ // chip shows the earliest failing rung, and the count tallies the findings.
609
+ const issueBadgeByAlias = useMemo(() => {
610
+ const map = new Map();
611
+ if (!issues?.length)
612
+ return map;
613
+ for (const issue of issues) {
614
+ const rung = issueRung(issue.kind);
615
+ if (!rung)
616
+ continue;
617
+ const comp = issueComponent(issue);
618
+ if (!comp)
619
+ continue;
620
+ const prev = map.get(comp.alias);
621
+ map.set(comp.alias, {
622
+ severity: prev?.severity === 'error' || issue.severity === 'error'
623
+ ? 'error'
624
+ : 'info',
625
+ rung: prev != null && ISSUE_RUNG_ORDER[prev.rung] <= ISSUE_RUNG_ORDER[rung]
626
+ ? prev.rung
627
+ : rung,
628
+ count: (prev?.count ?? 0) + 1,
629
+ });
630
+ }
631
+ return map;
632
+ }, [issues, issueComponent]);
633
+ // Per-frame diagnostics badge: the same fold, but for findings about a
634
+ // BOUNDARY rather than a construct. Keyed by the region's React Flow node id.
635
+ // Only kinds with a dedicated frame icon qualify (`ISSUE_KIND_ICON`) — a
636
+ // finding with no icon of its own has nothing to badge the frame with, and
637
+ // borrowing its layer's icon would imply the frame is at fault for it.
638
+ const regionIssueByNodeId = useMemo(() => {
639
+ const map = new Map();
640
+ if (!issues?.length)
641
+ return map;
642
+ for (const issue of issues) {
643
+ const target = issue.target;
644
+ if (target?.kind !== 'module' || !ISSUE_KIND_ICON[issue.kind])
645
+ continue;
646
+ const id = moduleGroupNodeId(target.id ?? target.label);
647
+ const prev = map.get(id);
648
+ map.set(id, {
649
+ severity: prev?.severity === 'error' || issue.severity === 'error' ? 'error' : 'info',
650
+ kind: prev != null && issueKindOrder(prev.kind) <= issueKindOrder(issue.kind)
651
+ ? prev.kind
652
+ : issue.kind,
653
+ count: (prev?.count ?? 0) + 1,
654
+ });
655
+ }
656
+ return map;
657
+ }, [issues]);
658
+ // Expanded verification layers in the diagnostics list. When any layer with
659
+ // findings is expanded, the canvas dims everything that layer does not
660
+ // implicate (see `issueFocus`). The list publishes this through
661
+ // `onExpandedCategoriesChange`; empty while every layer is collapsed.
662
+ const [expandedIssueCategories, setExpandedIssueCategories] = useState([]);
663
+ // Stable setter — the list re-emits on mount and on every toggle, so compare
664
+ // before storing to avoid churn (and to keep the list effect's dep stable).
665
+ const handleExpandedCategoriesChange = useCallback((cats) => {
666
+ setExpandedIssueCategories((prev) => prev.length === cats.length && prev.every((c, i) => c === cats[i])
667
+ ? prev
668
+ : cats);
669
+ }, []);
670
+ // Resolve a component name / symbol / alias reference to a component alias.
671
+ const resolveAlias = useCallback((ref) => {
672
+ const clean = ref.replace(/\(\)$/, '').trim();
673
+ const c = components.find((x) => x.alias === clean ||
674
+ x.name === clean ||
675
+ (x.symbol != null && x.symbol.replace(/\(\)$/, '') === clean));
676
+ return c?.alias ?? null;
677
+ }, [components]);
678
+ // Graph elements implicated by the expanded layers' findings — the bright set
679
+ // the canvas keeps while everything else dims. Derived from each finding's
680
+ // target:
681
+ // component → that node
682
+ // relation → the edge it flags (+ its two endpoint nodes)
683
+ // module → every component in that module
684
+ // walkthrough → the flow's nodes + its hop edges
685
+ // repo → every component of that repo
686
+ // graph → whole-graph finding; implicates nothing specific
687
+ // Returns null when nothing is expanded, no expanded layer has findings, or
688
+ // the findings implicate nothing — so no dimming is applied.
689
+ const issueFocus = useMemo(() => {
690
+ if (!issues?.length || expandedIssueCategories.length === 0)
691
+ return null;
692
+ const active = new Set(expandedIssueCategories);
693
+ const activeIssues = issues.filter((i) => active.has(issueCategory(i)));
694
+ if (activeIssues.length === 0)
695
+ return null;
696
+ const nodeIds = new Set();
697
+ const edgeIds = new Set();
698
+ for (const issue of activeIssues) {
699
+ const t = issue.target;
700
+ if (!t)
701
+ continue; // whole-graph finding — no specific element
702
+ if (t.kind === 'component') {
703
+ const a = resolveAlias(t.id ?? t.label);
704
+ if (a)
705
+ nodeIds.add(a);
706
+ }
707
+ else if (t.kind === 'relation') {
708
+ // The target names the two endpoints (`from → to`); light the edge and
709
+ // its endpoints. Falls back to endpoints alone if no edge matches.
710
+ const edge = issueEdge(issue);
711
+ if (edge) {
712
+ edgeIds.add(edge.id);
713
+ nodeIds.add(edge.source);
714
+ nodeIds.add(edge.target);
715
+ }
716
+ else {
717
+ for (const part of `${t.id ?? ''} → ${t.label}`.split('→')) {
718
+ const a = resolveAlias(part);
719
+ if (a)
720
+ nodeIds.add(a);
721
+ }
722
+ }
723
+ }
724
+ else if (t.kind === 'module') {
725
+ const key = t.id ?? t.label;
726
+ const prefix = `${key.replace(/\/$/, '')}/`;
727
+ for (const c of components) {
728
+ if (c.module === key ||
729
+ c.file === key ||
730
+ (c.file !== '' && c.file.endsWith(key)) ||
731
+ c.file.startsWith(prefix)) {
732
+ nodeIds.add(c.alias);
733
+ }
734
+ }
735
+ }
736
+ else if (t.kind === 'walkthrough') {
737
+ const wt = (walkthroughs ?? []).find((w) => w.id === t.id || w.title === t.label);
738
+ if (wt) {
739
+ for (const s of wt.steps) {
740
+ nodeIds.add(s.from);
741
+ nodeIds.add(s.to);
742
+ edgeIds.add(walkthroughStepGraphEdgeId(s));
743
+ }
744
+ }
745
+ }
746
+ else if (t.kind === 'repo') {
747
+ const key = t.id ?? t.label;
748
+ for (const c of components) {
749
+ if (c.purl === key)
750
+ nodeIds.add(c.alias);
751
+ }
752
+ }
753
+ }
754
+ if (nodeIds.size === 0 && edgeIds.size === 0)
755
+ return null;
756
+ // An edge whose endpoints both sit in the bright set stays lit too, so a
757
+ // focused cluster doesn't read as isolated nodes.
758
+ for (const e of baseEdges) {
759
+ if (nodeIds.has(e.source) && nodeIds.has(e.target))
760
+ edgeIds.add(e.id);
761
+ }
762
+ return { nodeIds, edgeIds };
763
+ }, [issues, expandedIssueCategories, components, walkthroughs, baseEdges, resolveAlias, issueEdge]);
764
+ const issueFocusNodeIds = issueFocus?.nodeIds ?? null;
765
+ const issueFocusEdgeIds = issueFocus?.edgeIds ?? null;
535
766
  // When an edge is selected, dim every other edge + its label to focus it.
536
767
  const [selectedEdgeId, setSelectedEdgeId] = useState(null);
537
768
  // Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
538
769
  // without a stale closure value.
539
770
  const selectedEdgeIdRef = useRef(null);
540
771
  selectedEdgeIdRef.current = selectedEdgeId;
772
+ // Ref mirrors of the focused step, so `unfocusIssueTarget` (a useCallback
773
+ // over early deps) can tell a step focus it still owns from one the user has
774
+ // since moved elsewhere in the walkthrough panel.
775
+ const focusedStepRef = useRef(null);
776
+ focusedStepRef.current =
777
+ focusedWalkthroughId != null && focusedStepIndex != null
778
+ ? { walkthroughId: focusedWalkthroughId, stepIndex: focusedStepIndex }
779
+ : null;
541
780
  // Edge ids in walkthrough focus (an active flow's edge set, or a single
542
781
  // step's edge). Used to frame the camera. `null` = no walkthrough focus.
543
782
  const focusEdgeIds = useMemo(() => {
@@ -682,12 +921,22 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
682
921
  anyOpened: openedNodeIds != null,
683
922
  anySelected: brightNodeIds != null || previewNodeIds != null,
684
923
  });
685
- const dimmed = previewNodeIds
686
- ? vis.hidden || !memberAliases.some((alias) => previewNodeIds.has(alias))
924
+ // `brightNodeIds` is the focused step's endpoints. It has to be a dim
925
+ // source, not just a bright set: without an expanded flow,
926
+ // `flowElementVisibility` reports non-participants as *hidden*, and the
927
+ // noOpenedFlow escape below discards that — leaving them neither hidden
928
+ // nor dimmed. Naming the participants as the dim source dims everything
929
+ // outside the focused step however the flow got focused.
930
+ const dimSource = previewNodeIds ?? brightNodeIds ?? issueFocusNodeIds;
931
+ const dimmed = dimSource
932
+ ? vis.hidden || !memberAliases.some((alias) => dimSource.has(alias))
687
933
  : vis.dimmed;
688
934
  const hidden = noOpenedFlow ? false : vis.hidden;
689
935
  // Host override wins over the library's derived frame color.
690
936
  const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
937
+ // Boundary findings badge the FRAME, not a member — a region's shape is
938
+ // the fault, so no leaf construct carries it.
939
+ const regionIssue = regionIssueByNodeId.get(n.id);
691
940
  return {
692
941
  ...n,
693
942
  hidden,
@@ -696,6 +945,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
696
945
  ...n.data,
697
946
  ...(color != null && { color }),
698
947
  ...(dimmed && { dimmed: true }),
948
+ ...(regionIssue && { issue: regionIssue }),
699
949
  },
700
950
  };
701
951
  }
@@ -712,9 +962,16 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
712
962
  anyOpened: openedNodeIds != null,
713
963
  anySelected: brightNodeIds != null || previewNodeIds != null,
714
964
  });
715
- const dimmed = previewNodeIds ? (vis.hidden || !previewNodeIds.has(n.id)) : vis.dimmed;
965
+ // `brightNodeIds` (the focused step's endpoints) is a dim source as well
966
+ // as a bright set — see the group branch above.
967
+ const dimSource = previewNodeIds ?? brightNodeIds ?? issueFocusNodeIds;
968
+ const dimmed = dimSource ? (vis.hidden || !dimSource.has(n.id)) : vis.dimmed;
716
969
  const hidden = noOpenedFlow ? false : vis.hidden;
717
- if (fileMatch === undefined && !isSelected && !dimmed) {
970
+ // Component findings key by alias, boundary findings by region node id.
971
+ // The two id spaces are disjoint (`module:` / `process:` are prefixed), so
972
+ // one lookup covers both node kinds.
973
+ const issueBadge = issueBadgeByAlias.get(n.id) ?? regionIssueByNodeId.get(n.id);
974
+ if (fileMatch === undefined && !isSelected && !dimmed && !issueBadge) {
718
975
  const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data;
719
976
  return { ...n, hidden, data: rest };
720
977
  }
@@ -726,10 +983,11 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
726
983
  ...(fileMatch !== undefined && { fileMatch }),
727
984
  ...(isSelected && { isSelected }),
728
985
  ...(dimmed && { dimmed: true }),
986
+ ...(issueBadge && { issue: issueBadge }),
729
987
  },
730
988
  };
731
989
  });
732
- }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, boundaryColors]);
990
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, issueFocusNodeIds, issueBadgeByAlias, regionIssueByNodeId, boundaryColors]);
733
991
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
734
992
  const baseEdgesKey = useMemo(() => convertedEdges.map((e) => e.id).sort().join(','), [convertedEdges]);
735
993
  const dispEdges = useMemo(() => {
@@ -755,7 +1013,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
755
1013
  }
756
1014
  return isWalkthroughMechanism(mechanism);
757
1015
  };
758
- if (openedEdgeIds || focusEdgeIds || previewEdgeIds) {
1016
+ if (openedEdgeIds || focusEdgeIds || previewEdgeIds || issueFocusEdgeIds) {
759
1017
  return baseEdges.map((e) => {
760
1018
  const vis = flowElementVisibility({
761
1019
  inOpened: openedEdgeIds?.has(e.id) === true,
@@ -763,7 +1021,8 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
763
1021
  anyOpened: openedEdgeIds != null,
764
1022
  anySelected: focusEdgeIds != null,
765
1023
  });
766
- const dimmed = previewEdgeIds ? vis.hidden || !previewEdgeIds.has(e.id) : vis.dimmed;
1024
+ const dimSource = previewEdgeIds ?? issueFocusEdgeIds;
1025
+ const dimmed = dimSource ? vis.hidden || !dimSource.has(e.id) : vis.dimmed;
767
1026
  const hidden = vis.hidden || !edgeInView(e);
768
1027
  return { ...paint(e, dimmed), hidden };
769
1028
  });
@@ -775,7 +1034,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
775
1034
  }));
776
1035
  }
777
1036
  return baseEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
778
- }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, resolvedEdgeView]);
1037
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
779
1038
  const onNodesChange = useCallback((changes) => {
780
1039
  // Capture dimension changes (React Flow's measurement callback).
781
1040
  // Group parents are ELK-sized — ignore their measurements.
@@ -817,6 +1076,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
817
1076
  return;
818
1077
  }
819
1078
  setSelected(comp);
1079
+ issueModuleFocusRef.current = null;
820
1080
  if (comp.alias)
821
1081
  onSelect?.(comp.alias);
822
1082
  }
@@ -830,6 +1090,8 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
830
1090
  setSelectedEdgeId(null);
831
1091
  setFocusedWalkthroughId(null);
832
1092
  setFocusedStepIndex(null);
1093
+ setEdgeViewOverride(null);
1094
+ issueModuleFocusRef.current = null;
833
1095
  }, []);
834
1096
  // Sidebar file trees — one per repo on multi-repo graphs, each under its
835
1097
  // own owner-avatar header. Clicking a header collapses that repo's tree.
@@ -843,6 +1105,14 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
843
1105
  const [diagnosticsOpen, setDiagnosticsOpen] = useState(showIssues ?? (issues?.length ?? 0) > 0);
844
1106
  const controlledIssues = showIssues !== undefined;
845
1107
  const issuesActive = controlledIssues ? showIssues : diagnosticsOpen;
1108
+ // Layer focus only applies while the diagnostics list is on screen — closing
1109
+ // it clears any dimming the expanded layers were driving.
1110
+ useEffect(() => {
1111
+ if (!issuesActive) {
1112
+ setExpandedIssueCategories([]);
1113
+ setEdgeViewOverride(null);
1114
+ }
1115
+ }, [issuesActive]);
846
1116
  const toggleIssues = () => {
847
1117
  if (!controlledIssues)
848
1118
  setDiagnosticsOpen((v) => !v);
@@ -1023,6 +1293,52 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1023
1293
  zoomOnWalkthroughFocus,
1024
1294
  walkthroughFocusDurationMs,
1025
1295
  ]);
1296
+ // A node's laid-out rect in absolute flow coords. `absoluteRects` is the
1297
+ // authority; the fallback covers an unparented group node, whose `position` is
1298
+ // already absolute. Nested groups are parent-relative, so they are skipped
1299
+ // rather than mis-boxed.
1300
+ const rectForNode = useCallback((id) => {
1301
+ const r = built.absoluteRects.get(id);
1302
+ if (r)
1303
+ return r;
1304
+ const n = built.nodes.find((x) => x.id === id);
1305
+ if (!n?.position || n.parentId)
1306
+ return undefined;
1307
+ return {
1308
+ x: n.position.x,
1309
+ y: n.position.y,
1310
+ width: n.width ?? 0,
1311
+ height: n.height ?? 0,
1312
+ };
1313
+ }, [built.absoluteRects, built.nodes]);
1314
+ // Frame a set of nodes by their laid-out rects. Unlike `fitFocusBounds` this
1315
+ // takes node ids, so it works for boundary frames too — React Flow's own
1316
+ // `fitView({ nodes })` can't be relied on for group nodes here, and the rects
1317
+ // are already in absolute flow coords (grouped child `position`s are
1318
+ // parent-relative and would skew the box).
1319
+ const fitNodeRects = useCallback((ids, padding) => {
1320
+ let minX = Infinity;
1321
+ let minY = Infinity;
1322
+ let maxX = -Infinity;
1323
+ let maxY = -Infinity;
1324
+ for (const id of ids) {
1325
+ const r = rectForNode(id);
1326
+ if (!r)
1327
+ continue;
1328
+ minX = Math.min(minX, r.x);
1329
+ minY = Math.min(minY, r.y);
1330
+ maxX = Math.max(maxX, r.x + r.width);
1331
+ maxY = Math.max(maxY, r.y + r.height);
1332
+ }
1333
+ if (!Number.isFinite(minX) || !Number.isFinite(minY))
1334
+ return;
1335
+ fitBounds({
1336
+ x: minX,
1337
+ y: minY,
1338
+ width: Math.max(1, maxX - minX),
1339
+ height: Math.max(1, maxY - minY),
1340
+ }, { padding, duration: walkthroughFocusDurationMs });
1341
+ }, [rectForNode, fitBounds, walkthroughFocusDurationMs]);
1026
1342
  // Zoom back out to the full diagram after the last expanded walkthrough
1027
1343
  // closes (visibility restores non-flow nodes that were hidden).
1028
1344
  const fitOverview = useCallback(() => {
@@ -1376,6 +1692,140 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1376
1692
  setFocusedStepIndex(null);
1377
1693
  onSelect?.(comp.alias);
1378
1694
  }, [components, onSelect]);
1695
+ // Issue click → select + frame the target on the canvas. Component targets
1696
+ // frame their node; relation targets frame their edge (endpoints + the routed
1697
+ // line); other kinds (module / flow / repo) have no single element and just
1698
+ // forward. Selections / focus that would hide the target are cleared first,
1699
+ // then the camera flies to it on the next frame. The host's `onSelectIssue`
1700
+ // still fires afterwards for its own detail.
1701
+ const focusIssueTarget = useCallback((issue) => {
1702
+ const comp = issueComponent(issue);
1703
+ const edge = comp ? null : issueEdge(issue);
1704
+ const step = comp || edge ? null : issueStep(issue);
1705
+ if (comp) {
1706
+ setSelected(comp);
1707
+ setSelectedEdgeId(null);
1708
+ setFocusedWalkthroughId(null);
1709
+ setFocusedStepIndex(null);
1710
+ setHoveredWalkthroughStep(null);
1711
+ issueModuleFocusRef.current = null;
1712
+ onSelect?.(comp.alias);
1713
+ requestAnimationFrame(() => {
1714
+ fitView({
1715
+ nodes: [{ id: comp.alias }],
1716
+ padding: 0.4,
1717
+ duration: 300,
1718
+ minZoom: 0.05,
1719
+ maxZoom: 1.5,
1720
+ });
1721
+ });
1722
+ }
1723
+ else if (edge) {
1724
+ setSelected(null);
1725
+ setFocusedWalkthroughId(null);
1726
+ setFocusedStepIndex(null);
1727
+ setHoveredWalkthroughStep(null);
1728
+ setSelectedEdgeId(edge.id);
1729
+ issueModuleFocusRef.current = null;
1730
+ // Flip to the vocabulary this edge belongs to, so a hop flagged in the
1731
+ // relations view (or vice versa) is actually drawn while focused.
1732
+ const d = edge.data;
1733
+ const mechanism = d?.mechanism ?? 'uses';
1734
+ setEdgeViewOverride(d?.provenance === 'graphify' || isRelationMechanism(mechanism)
1735
+ ? 'relations'
1736
+ : 'walkthroughs');
1737
+ requestAnimationFrame(() => {
1738
+ fitFocusBounds(new Set([edge.id]));
1739
+ });
1740
+ }
1741
+ else if (step) {
1742
+ // A step finding focuses the step itself, through the same entry point
1743
+ // the walkthrough panel uses — so the drawer, the step numbering, the
1744
+ // dim-mode hover preview, and the camera all behave identically whether
1745
+ // the step was reached from the panel or from a diagnostics card.
1746
+ issueModuleFocusRef.current = null;
1747
+ focusWalkthroughStep(step.walkthrough, step.stepIndex);
1748
+ }
1749
+ else {
1750
+ // Nothing selectable — but a module target names a boundary frame, so
1751
+ // frame that region.
1752
+ const moduleNodeId = issueModuleNodeId(issue);
1753
+ if (moduleNodeId) {
1754
+ setSelected(null);
1755
+ setSelectedEdgeId(null);
1756
+ setFocusedWalkthroughId(null);
1757
+ setFocusedStepIndex(null);
1758
+ setHoveredWalkthroughStep(null);
1759
+ issueModuleFocusRef.current = moduleNodeId;
1760
+ requestAnimationFrame(() => {
1761
+ fitNodeRects(new Set([moduleNodeId]), 0.4);
1762
+ });
1763
+ }
1764
+ }
1765
+ onSelectIssue?.(issue);
1766
+ }, [
1767
+ issueComponent,
1768
+ issueEdge,
1769
+ issueStep,
1770
+ issueModuleNodeId,
1771
+ focusWalkthroughStep,
1772
+ onSelect,
1773
+ onSelectIssue,
1774
+ fitView,
1775
+ fitFocusBounds,
1776
+ fitNodeRects,
1777
+ ]);
1778
+ // Collapsing the issue card again undoes the focus: drop the node / edge
1779
+ // selection (only when it is still this issue's target — another card may
1780
+ // have taken it over) and zoom back out to the whole graph. A module target
1781
+ // frames a region rather than selecting anything, so its ownership lives in
1782
+ // `issueModuleFocusRef`; without this branch a module focus never unwinds and
1783
+ // the camera never zooms back out.
1784
+ const unfocusIssueTarget = useCallback((issue) => {
1785
+ const comp = issueComponent(issue);
1786
+ if (comp) {
1787
+ if (selectedRef.current?.alias !== comp.alias)
1788
+ return;
1789
+ setSelected(null);
1790
+ setSelectedEdgeId(null);
1791
+ }
1792
+ else {
1793
+ const edge = issueEdge(issue);
1794
+ const step = edge ? null : issueStep(issue);
1795
+ const moduleNodeId = edge || step ? null : issueModuleNodeId(issue);
1796
+ if (edge) {
1797
+ if (selectedEdgeIdRef.current !== edge.id)
1798
+ return;
1799
+ setSelectedEdgeId(null);
1800
+ setEdgeViewOverride(null);
1801
+ }
1802
+ else if (step) {
1803
+ // Only unwind a step focus this card still owns. A dim-mode graph
1804
+ // keeps its step focus in hover state instead (owned by the pointer,
1805
+ // not the card), and correctly leaves that alone here.
1806
+ const owned = focusedStepRef.current;
1807
+ if (owned?.walkthroughId !== step.walkthrough.id ||
1808
+ owned?.stepIndex !== step.stepIndex) {
1809
+ return;
1810
+ }
1811
+ setFocusedWalkthroughId(null);
1812
+ setFocusedStepIndex(null);
1813
+ setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
1814
+ }
1815
+ else if (moduleNodeId) {
1816
+ // Only unwind framing this card still owns.
1817
+ if (issueModuleFocusRef.current !== moduleNodeId)
1818
+ return;
1819
+ issueModuleFocusRef.current = null;
1820
+ }
1821
+ else {
1822
+ return;
1823
+ }
1824
+ }
1825
+ requestAnimationFrame(() => {
1826
+ fitView({ padding: 0.1, duration: 300, minZoom: 0.05, maxZoom: 2 });
1827
+ });
1828
+ }, [issueComponent, issueEdge, issueStep, issueModuleNodeId, fitView]);
1379
1829
  // Construct tokens inside a walkthrough snippet. A step's line is an edge
1380
1830
  // between its `from`/`to` components, so a token naming either of those
1381
1831
  // constructs should navigate to that construct's declaration. Index the
@@ -1527,7 +1977,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1527
1977
  display: 'flex',
1528
1978
  flexDirection: 'column',
1529
1979
  overflow: 'hidden',
1530
- }, children: issuesActive ? (_jsx(SubsystemIssueList, { issues: issues ?? [], focusCategory: focusIssueCategory, onSelectIssue: onSelectIssue, onApplyFix: onApplyIssueFix, onHoverIssue: onHoverIssue })) : (_jsxs(_Fragment, { children: [hasWalkthroughs && (_jsx("div", { role: "tablist", "aria-label": "Sidebar view", style: {
1980
+ }, children: issuesActive ? (_jsx(SubsystemIssueList, { issues: issues ?? [], focusCategory: focusIssueCategory, onSelectIssue: focusIssueTarget, onDeselectIssue: unfocusIssueTarget, onApplyFix: onApplyIssueFix, onHoverIssue: onHoverIssue, onExpandedCategoriesChange: handleExpandedCategoriesChange })) : (_jsxs(_Fragment, { children: [hasWalkthroughs && (_jsx("div", { role: "tablist", "aria-label": "Sidebar view", style: {
1531
1981
  display: 'flex',
1532
1982
  width: '100%',
1533
1983
  flexShrink: 0,
@@ -1782,7 +2232,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1782
2232
  fontFamily: theme.fonts.monospace,
1783
2233
  fontSize: theme.fontSizes[0],
1784
2234
  color: theme.colors.textMuted ?? theme.colors.textSecondary,
1785
- }, children: fileOverlay.file }), _jsx(FileOverlayCloseButton, { onClose: closeFileOverlay })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: _jsx(FileDrawerContent, { render: renderDrawerContent, file: fileOverlay.file, startLine: fileOverlay.startLine, fullFile: true }) })] })), _jsx(GraphLayoutCover, { revealed: layoutReady }), canvasOverlay] })] }));
2235
+ }, children: fileOverlay.file }), _jsx(FileOverlayCloseButton, { onClose: closeFileOverlay })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: _jsx(FileDrawerContent, { render: renderDrawerContent, file: fileOverlay.file, startLine: fileOverlay.startLine, fullFile: true }) })] })), _jsx(GraphLayoutCover, { revealed: layoutReady }), canvasOverlay, liveEvents ? _jsx(MaintainLivePanel, { ...liveEvents }) : null] })] }));
1786
2236
  }
1787
2237
  /** Sidebar header for one repo's tree: owner avatar + repo name + file count.
1788
2238
  * Clicking toggles the tree's visibility. */