@principal-ai/principal-view-react 0.16.33 → 0.16.35

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 (36) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/ComponentDetail.d.ts +12 -0
  6. package/dist/subsystem/ComponentDetail.d.ts.map +1 -0
  7. package/dist/subsystem/ComponentDetail.js +95 -0
  8. package/dist/subsystem/ComponentDetail.js.map +1 -0
  9. package/dist/subsystem/EdgeLegendModal.d.ts +18 -0
  10. package/dist/subsystem/EdgeLegendModal.d.ts.map +1 -0
  11. package/dist/subsystem/EdgeLegendModal.js +98 -0
  12. package/dist/subsystem/EdgeLegendModal.js.map +1 -0
  13. package/dist/subsystem/FileDrawer.d.ts +16 -0
  14. package/dist/subsystem/FileDrawer.d.ts.map +1 -0
  15. package/dist/subsystem/FileDrawer.js +72 -0
  16. package/dist/subsystem/FileDrawer.js.map +1 -0
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  18. package/dist/subsystem/SubsystemComponentGraph.js +34 -182
  19. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  20. package/dist/subsystem/SubsystemFileTree.d.ts +10 -15
  21. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
  22. package/dist/subsystem/SubsystemFileTree.js +96 -93
  23. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  24. package/dist/subsystem/nodes.d.ts +4 -2
  25. package/dist/subsystem/nodes.d.ts.map +1 -1
  26. package/dist/subsystem/nodes.js +13 -26
  27. package/dist/subsystem/nodes.js.map +1 -1
  28. package/package.json +2 -1
  29. package/src/index.ts +1 -1
  30. package/src/stories/SubsystemComponentGraph.stories.tsx +8 -0
  31. package/src/subsystem/ComponentDetail.tsx +187 -0
  32. package/src/subsystem/EdgeLegendModal.tsx +146 -0
  33. package/src/subsystem/FileDrawer.tsx +105 -0
  34. package/src/subsystem/SubsystemComponentGraph.tsx +49 -316
  35. package/src/subsystem/SubsystemFileTree.tsx +102 -141
  36. package/src/subsystem/nodes.tsx +17 -35
@@ -32,40 +32,21 @@ import {
32
32
  applyNodeChanges,
33
33
  } from '@xyflow/react';
34
34
  import { useTheme } from '@principal-ade/industry-theme';
35
- import { X } from 'lucide-react';
35
+ import { Map as MapIcon } from 'lucide-react';
36
36
  import { IndustryMarkdownSlide } from 'themed-markdown';
37
37
  import {
38
38
  buildSubsystemGraph,
39
- KIND_COLOR,
40
39
  MECHANISM_COLOR,
41
- deriveNameFromSymbol,
42
40
  type SubsystemComponentEdge,
43
41
  type SubsystemComponent,
44
42
  type SubsystemEdgeMechanism,
45
43
  } from './model';
46
- import type { GraphifyComponentDetail } from '../graphify';
47
44
  import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
48
45
  import { SubsystemFileTree } from './SubsystemFileTree';
49
46
  import { GraphLayoutCover } from './GraphLayoutCover';
50
-
51
- const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
52
- ['imports', 'import statement (code-level dependency)', true],
53
- ['imports_from', 'imported by (reverse dependency)', true],
54
- ['re_exports', 're-exports symbols from', true],
55
- ['defines', 'defines / declares symbol', true],
56
- ['calls', 'function/method call (call graph edge)', true],
57
- ['extends', 'class inheritance', true],
58
- ['inherits', 'class inheritance', true],
59
- ['implements', 'implements interface / protocol', true],
60
- ['mixes_in', 'applies mixin', true],
61
- ['uses', 'general dependency (import, call, or reference)', false],
62
- ['method', 'structural: has method / member', true],
63
- ['references', 'type / symbol reference (not a call)', true],
64
- ['contains', 'structural: contains / encapsulates', true],
65
- ['feeds', 'data flow: output feeds into input', false],
66
- ['produces', 'data flow: produces / outputs', false],
67
- ['registers-into', 'registration pattern', false],
68
- ];
47
+ import { ComponentDetail } from './ComponentDetail';
48
+ import { FileDrawer } from './FileDrawer';
49
+ import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
69
50
 
70
51
  export interface SubsystemComponentGraphProps {
71
52
  components: SubsystemComponent[];
@@ -132,6 +113,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
132
113
  const [selected, setSelected] = useState<SubsystemComponent | null>(null);
133
114
  // File currently shown in the bottom drawer (repo-root-relative path).
134
115
  const [openFile, setOpenFile] = useState<string | null>(null);
116
+ // Edge-legend modal visibility (opened from the canvas's top-left button).
117
+ const [legendOpen, setLegendOpen] = useState(false);
118
+ // Component the pointer is over (null on leave) → transient tree highlight.
119
+ const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
135
120
  // Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
136
121
  // closure over the effect deps) can toggle without a stale value.
137
122
  const selectedRef = useRef<SubsystemComponent | null>(null);
@@ -226,10 +211,12 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
226
211
  };
227
212
  SUBSYSTEM_CALLBACKS.onEdgeSelect = selectEdge;
228
213
  SUBSYSTEM_CALLBACKS.maxNodeWidth = maxNodeWidth;
214
+ SUBSYSTEM_CALLBACKS.onHover = setHoveredComponentId;
229
215
  return () => {
230
216
  SUBSYSTEM_CALLBACKS.onSelect = undefined;
231
217
  SUBSYSTEM_CALLBACKS.onEdgeSelect = undefined;
232
218
  SUBSYSTEM_CALLBACKS.maxNodeWidth = undefined;
219
+ SUBSYSTEM_CALLBACKS.onHover = undefined;
233
220
  };
234
221
  }, [components, onSelect, maxNodeWidth, selectEdge]);
235
222
 
@@ -393,14 +380,18 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
393
380
  return new Set(edgeLabels.map((l) => l.mechanism));
394
381
  }, [edgeLabels]);
395
382
 
396
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
397
-
398
383
  // Unique source files across components → sidebar file tree.
399
384
  const files = useMemo(
400
385
  () => Array.from(new Set(components.map((c) => c.file).filter(Boolean))).sort(),
401
386
  [components],
402
387
  );
403
388
 
389
+ // The hovered node's file (null when not hovering / file-less component).
390
+ const hoveredFile = useMemo(() => {
391
+ if (!hoveredComponentId) return null;
392
+ return components.find((c) => c.id === hoveredComponentId)?.file ?? null;
393
+ }, [components, hoveredComponentId]);
394
+
404
395
  // Drawer content renderer: prefer the path-keyed viewer; fall back to the
405
396
  // legacy component-keyed one via a file → first-component lookup.
406
397
  const fileViewer = useMemo(() => {
@@ -419,8 +410,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
419
410
 
420
411
  return (
421
412
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
422
- {/* Sidebar: scrollable title/description/legend on top, file tree pinned to the bottom half */}
423
- {(title || description || usedMechanisms.size > 0 || sidebarExtra || sidebarAfterDescription || files.length > 0) && (
413
+ {/* Sidebar: scrollable title/description on top, file tree pinned to the bottom half */}
414
+ {(title || description || sidebarExtra || sidebarAfterDescription || files.length > 0) && (
424
415
  <div
425
416
  style={{
426
417
  width: 340,
@@ -473,34 +464,12 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
473
464
  </div>
474
465
  )}
475
466
  {sidebarAfterDescription}
476
- {usedMechanisms.size > 0 && (
477
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
478
- <span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, textTransform: 'uppercase', color: muted, fontWeight: 600 }}>
479
- Edge Legend
480
- </span>
481
- {MECHANISM_DESCRIPTIONS.filter(([m]) => usedMechanisms.has(m)).map(([mechanism, desc, verifiable]) => (
482
- <div key={mechanism} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: theme.fontSizes[0] }}>
483
- <span
484
- style={{
485
- width: 8,
486
- height: 8,
487
- borderRadius: verifiable ? '50%' : '30% 70% 70% 30% / 30% 30% 70% 70%',
488
- background: MECHANISM_COLOR[mechanism],
489
- border: verifiable ? 'none' : `1px dashed ${MECHANISM_COLOR[mechanism]}`,
490
- flexShrink: 0,
491
- }}
492
- />
493
- <span style={{ fontFamily: theme.fonts.monospace, color: MECHANISM_COLOR[mechanism], fontWeight: 500 }}>{mechanism}</span>
494
- <span style={{ color: muted }}>{desc}</span>
495
- </div>
496
- ))}
497
- </div>
498
- )}
499
467
  </div>
500
468
  {files.length > 0 && (
501
469
  <SubsystemFileTree
502
470
  files={files}
503
471
  selectedFile={selected?.file ?? openFile}
472
+ hoveredFile={hoveredFile}
504
473
  onSelectFile={onTreeSelectFile}
505
474
  />
506
475
  )}
@@ -594,287 +563,51 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
594
563
  <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
595
564
  <Controls showZoom showFitView showInteractive />
596
565
  </ReactFlow>
597
- {selected && <ComponentDetail component={selected} />}
598
- <FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
599
- {fileViewer && openFile ? fileViewer(openFile) : null}
600
- </FileDrawer>
601
- {/* Startup cover — hides measurement, layout swap, and camera settle. */}
602
- <GraphLayoutCover revealed={layoutReady} />
603
- {canvasOverlay}
604
- </div>
605
- </div>
606
- );
607
- }
608
-
609
- /** Detail panel for the selected component — the verifiable identity + sources
610
- * moved off the canvas so nodes stay minimal. File content lives in the
611
- * bottom FileDrawer, not here. */
612
- function ComponentDetail({ component }: { component: SubsystemComponent }) {
613
- const { theme } = useTheme();
614
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
615
- const color = KIND_COLOR[component.kind] ?? '#888';
616
- const displayName = deriveNameFromSymbol(component.symbol, component.kind, component.name, component.file);
617
- const rows: Array<[string, string]> = [];
618
- if (component.symbol) rows.push(['Symbol', component.symbol]);
619
- if (component.file) rows.push(['File', component.file]);
620
- if (component.purpose) rows.push(['Purpose', component.purpose]);
621
- rows.push(['PURL', component.purl]);
622
-
623
- return (
624
- <div
625
- style={{
626
- borderTop: `1px solid ${theme.colors.border}`,
627
- background: theme.colors.background,
628
- padding: '10px 12px',
629
- fontFamily: theme.fonts.body,
630
- }}
631
- >
632
- <div
633
- style={{
634
- display: 'flex',
635
- alignItems: 'center',
636
- gap: 8,
637
- fontSize: theme.fontSizes[1],
638
- fontWeight: 600,
639
- marginBottom: 6,
640
- }}
641
- >
642
- <span style={{ color }}>{displayName}</span>
643
- <span
644
- style={{
645
- fontSize: theme.fontSizes[0] * 0.8,
646
- fontFamily: theme.fonts.monospace,
647
- textTransform: 'uppercase',
648
- color,
649
- }}
650
- >
651
- {component.kind}
652
- </span>
653
- </div>
654
- {rows.map(([k, v]) => (
655
- <div
656
- key={k}
657
- style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5 }}
658
- >
659
- <span
660
- style={{
661
- width: 64,
662
- flexShrink: 0,
663
- color: muted,
664
- fontFamily: theme.fonts.monospace,
665
- textTransform: 'uppercase',
666
- fontSize: theme.fontSizes[0] * 0.8,
667
- }}
668
- >
669
- {k}
670
- </span>
671
- <span style={{ color: theme.colors.text, fontFamily: theme.fonts.monospace }}>
672
- {v}
673
- </span>
674
- </div>
675
- ))}
676
- {component.detail && <GraphifyDetailSections detail={component.detail} />}
677
- </div>
678
- );
679
- }
680
-
681
- /** Bottom panel that slides up from the bottom of the graph area to show a
682
- * file's contents — opened by node clicks and sidebar file-tree clicks
683
- * alike. Sits in normal flow (canvas shrinks while open, nothing covered)
684
- * and animates via height; stays mounted so open/close animates. */
685
- function FileDrawer({
686
- file,
687
- onClose,
688
- children,
689
- }: {
690
- file: string | null;
691
- onClose: () => void;
692
- children?: ReactNode;
693
- }) {
694
- const { theme } = useTheme();
695
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
696
- const open = file !== null;
697
-
698
- useEffect(() => {
699
- if (!open) return;
700
- const onKey = (e: KeyboardEvent) => {
701
- if (e.key === 'Escape') onClose();
702
- };
703
- window.addEventListener('keydown', onKey);
704
- return () => window.removeEventListener('keydown', onKey);
705
- }, [open, onClose]);
706
-
707
- return (
708
- <div
709
- style={{
710
- position: 'relative',
711
- // Above the absolute edge-label overlay (zIndex 5), which spans the
712
- // whole graph-area container including this panel's slice.
713
- zIndex: 6,
714
- flexShrink: 0,
715
- height: open ? '45%' : 0,
716
- minHeight: 0,
717
- overflow: 'hidden',
718
- display: 'flex',
719
- flexDirection: 'column',
720
- background: theme.colors.background,
721
- borderTop: open ? `1px solid ${theme.colors.border}` : 'none',
722
- transition: 'height 200ms ease',
723
- }}
724
- >
725
- <div
726
- style={{
727
- display: 'flex',
728
- alignItems: 'center',
729
- gap: 8,
730
- padding: '6px 10px',
731
- borderBottom: `1px solid ${theme.colors.border}`,
732
- flexShrink: 0,
733
- }}
734
- >
735
- <span
736
- title={file ?? undefined}
737
- style={{
738
- flex: 1,
739
- minWidth: 0,
740
- overflow: 'hidden',
741
- textOverflow: 'ellipsis',
742
- whiteSpace: 'nowrap',
743
- fontFamily: theme.fonts.monospace,
744
- fontSize: theme.fontSizes[0],
745
- color: muted,
746
- }}
747
- >
748
- {file}
749
- </span>
566
+ {/* Legend button top-left overlay on the canvas; opens the modal. */}
567
+ {usedMechanisms.size > 0 && (
750
568
  <button
751
569
  type="button"
752
- onClick={onClose}
753
- aria-label="Close file"
570
+ onClick={() => setLegendOpen(true)}
754
571
  style={{
572
+ position: 'absolute',
573
+ top: 10,
574
+ left: 10,
575
+ zIndex: 7,
755
576
  display: 'inline-flex',
756
577
  alignItems: 'center',
757
- justifyContent: 'center',
758
- width: 22,
759
- height: 22,
760
- padding: 0,
761
- border: 'none',
762
- borderRadius: 4,
763
- background: 'transparent',
578
+ gap: 6,
579
+ padding: '4px 10px',
580
+ fontSize: theme.fontSizes[0],
581
+ fontFamily: theme.fonts.body,
764
582
  color: theme.colors.text,
583
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
584
+ border: `1px solid ${theme.colors.border}`,
585
+ borderRadius: 6,
765
586
  cursor: 'pointer',
587
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
766
588
  }}
767
589
  >
768
- <X size={14} />
590
+ <MapIcon size={13} />
591
+ Legend
769
592
  </button>
593
+ )}
594
+ <EdgeLegendModal
595
+ open={legendOpen}
596
+ mechanisms={usedMechanisms}
597
+ onClose={() => setLegendOpen(false)}
598
+ />
599
+ {selected && <ComponentDetail component={selected} />}
600
+ <FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
601
+ {fileViewer && openFile ? fileViewer(openFile) : null}
602
+ </FileDrawer>
603
+ {/* Startup cover — hides measurement, layout swap, and camera settle. */}
604
+ <GraphLayoutCover revealed={layoutReady} />
605
+ {canvasOverlay}
770
606
  </div>
771
- <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>{children}</div>
772
607
  </div>
773
608
  );
774
609
  }
775
610
 
776
- /** Renders the graphify drill-down payload for a component. */
777
- function GraphifyDetailSections({ detail }: { detail: GraphifyComponentDetail }) {
778
- const { theme } = useTheme();
779
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
780
-
781
- const label = (k: string) => (
782
- <span
783
- style={{
784
- width: 64,
785
- flexShrink: 0,
786
- color: muted,
787
- fontFamily: theme.fonts.monospace,
788
- textTransform: 'uppercase',
789
- fontSize: theme.fontSizes[0] * 0.8,
790
- }}
791
- >
792
- {k}
793
- </span>
794
- );
795
- const chip = (name: string, meta?: string, key?: string | number) => (
796
- <span
797
- key={key}
798
- style={{
799
- fontFamily: theme.fonts.monospace,
800
- fontSize: theme.fontSizes[0],
801
- color: theme.colors.text,
802
- border: `1px solid ${theme.colors.border}`,
803
- borderRadius: 4,
804
- padding: '1px 6px',
805
- marginRight: 6,
806
- marginBottom: 4,
807
- display: 'inline-block',
808
- }}
809
- >
810
- {name}
811
- {meta ? <span style={{ color: muted }}> {meta}</span> : ''}
812
- </span>
813
- );
814
- const row = (k: string, children: ReactNode) => (
815
- <div
816
- style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5, marginBottom: 4 }}
817
- >
818
- {label(k)}
819
- <div style={{ minWidth: 0 }}>{children}</div>
820
- </div>
821
- );
822
-
823
- switch (detail.kind) {
824
- case 'class':
825
- return (
826
- <>
827
- {detail.methods.length > 0 &&
828
- row('methods', detail.methods.map((m, i) => chip(m.name, m.returnType, i)))}
829
- {detail.properties.length > 0 &&
830
- row('props', detail.properties.map((p, i) => chip(p.name, p.type, i)))}
831
- {detail.extends.length > 0 && row('extends', detail.extends.map((e, i) => chip(e, undefined, i)))}
832
- {detail.implements.length > 0 && row('impl', detail.implements.map((e, i) => chip(e, undefined, i)))}
833
- {detail.instantiations.length > 0 &&
834
- row('insts', detail.instantiations.map((i, idx) => chip(i.name, undefined, idx)))}
835
- {detail.references.length > 0 &&
836
- row('refs', detail.references.map((r, idx) => chip(r.name, undefined, idx)))}
837
- </>
838
- );
839
- case 'function':
840
- return (
841
- <>
842
- {detail.parameters.length > 0 &&
843
- row('params', detail.parameters.map((p, i) => chip(p.type, undefined, i)))}
844
- {detail.returnType && row('return', <span>{detail.returnType}</span>)}
845
- {detail.callers.length > 0 &&
846
- row('callers', detail.callers.map((c, i) => chip(c.name, undefined, i)))}
847
- {detail.callees.length > 0 &&
848
- row('callees', detail.callees.map((c, i) => chip(c.name, undefined, i)))}
849
- </>
850
- );
851
- case 'type':
852
- return (
853
- <>
854
- {detail.properties.length > 0 &&
855
- row('props', detail.properties.map((p, i) => chip(p.name, p.type, i)))}
856
- {detail.usedBy.length > 0 &&
857
- row('used by', detail.usedBy.map((u, i) => chip(u.name, undefined, i)))}
858
- {detail.implementors.length > 0 &&
859
- row('impl', detail.implementors.map((e, i) => chip(e, undefined, i)))}
860
- </>
861
- );
862
- case 'module':
863
- return (
864
- <>
865
- {detail.exports.length > 0 &&
866
- row('exports', detail.exports.map((e, i) => chip(e, undefined, i)))}
867
- {detail.imports.length > 0 &&
868
- row('imports', detail.imports.map((i, idx) => chip(i.name, undefined, idx)))}
869
- {detail.symbols.length > 0 &&
870
- row('symbols', detail.symbols.map((s, i) => chip(s, undefined, i)))}
871
- </>
872
- );
873
- case 'external':
874
- return row('source', <span>{detail.label}</span>);
875
- }
876
- }
877
-
878
611
  export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
879
612
  const wrapRef = useRef<HTMLDivElement>(null);
880
613
  const [size, setSize] = useState<{ w: number; h: number } | null>(null);