@principal-ai/principal-view-react 0.16.63 → 0.16.65
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.
- package/dist/pierre/PierreThroughlineCodeView.d.ts +3 -1
- package/dist/pierre/PierreThroughlineCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreThroughlineCodeView.js +45 -11
- package/dist/pierre/PierreThroughlineCodeView.js.map +1 -1
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
- package/dist/subsystem/ComponentDeclaration.js +2 -1
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +2 -4
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +99 -53
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +3 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +28 -4
- package/dist/subsystem/model.js.map +1 -1
- package/package.json +1 -1
- package/src/pierre/PierreThroughlineCodeView.tsx +51 -12
- package/src/stories/Pierre/CodeView.stories.tsx +40 -17
- package/src/stories/Subsystem/ComponentGraph/GraphTitle.stories.tsx +0 -1
- package/src/subsystem/ComponentDeclaration.tsx +2 -1
- package/src/subsystem/SubsystemComponentGraph.tsx +130 -80
- package/src/subsystem/model.test.ts +5 -5
- package/src/subsystem/model.ts +29 -4
- package/src/components/GraphRenderer.test.tsx +0 -118
- package/src/subsystem/EdgeLegendModal.tsx +0 -149
|
@@ -32,11 +32,12 @@ import {
|
|
|
32
32
|
applyNodeChanges,
|
|
33
33
|
} from '@xyflow/react';
|
|
34
34
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
35
|
-
import {
|
|
35
|
+
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
|
36
36
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
37
37
|
import {
|
|
38
38
|
buildSubsystemGraph,
|
|
39
39
|
MECHANISM_COLOR,
|
|
40
|
+
MECHANISM_DESCRIPTIONS,
|
|
40
41
|
subsystemGraphLayoutKey,
|
|
41
42
|
type SubsystemComponentEdge,
|
|
42
43
|
type SubsystemComponent,
|
|
@@ -50,7 +51,6 @@ import { GraphLayoutCover } from './GraphLayoutCover';
|
|
|
50
51
|
import { ComponentDeclaration } from './ComponentDeclaration';
|
|
51
52
|
import type { ComponentVerificationState } from './ComponentDeclaration';
|
|
52
53
|
import { FileDrawer } from './FileDrawer';
|
|
53
|
-
import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
|
|
54
54
|
import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
55
55
|
|
|
56
56
|
/** Cap screen-space edge labels to this fraction of the edge's on-screen length. */
|
|
@@ -91,8 +91,6 @@ export interface SubsystemComponentGraphProps {
|
|
|
91
91
|
maxNodeWidth?: number;
|
|
92
92
|
/** Show edge labels (mechanism names) on the graph. @default true */
|
|
93
93
|
showEdgeLabels?: boolean;
|
|
94
|
-
/** Show the mechanism-legend button overlay on the canvas. @default true */
|
|
95
|
-
showLegend?: boolean;
|
|
96
94
|
/** Subsystem title displayed in the sidebar. */
|
|
97
95
|
title?: string;
|
|
98
96
|
/**
|
|
@@ -123,8 +121,8 @@ export interface SubsystemComponentGraphProps {
|
|
|
123
121
|
renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
|
|
124
122
|
/**
|
|
125
123
|
* Host-injected multi-snippet viewer for a focused throughline. When set,
|
|
126
|
-
* clicking a flow step
|
|
127
|
-
*
|
|
124
|
+
* clicking a flow step opens the bottom drawer with this content and
|
|
125
|
+
* updates it as the focused step changes.
|
|
128
126
|
*/
|
|
129
127
|
renderThroughlineViewer?: (ctx: ThroughlineViewerContext) => ReactNode;
|
|
130
128
|
/**
|
|
@@ -185,7 +183,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
185
183
|
measured: { w: number; h: number } | null;
|
|
186
184
|
}
|
|
187
185
|
|
|
188
|
-
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels,
|
|
186
|
+
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
189
187
|
const { theme } = useTheme();
|
|
190
188
|
const { fitView } = useReactFlow();
|
|
191
189
|
const viewport = useViewport();
|
|
@@ -197,8 +195,6 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
197
195
|
const [selected, setSelected] = useState<SubsystemComponent | null>(null);
|
|
198
196
|
/** Bottom drawer: single file or throughline multi-snippet mode. */
|
|
199
197
|
const [drawerTarget, setDrawerTarget] = useState<DrawerTarget | null>(null);
|
|
200
|
-
// Edge-legend modal visibility (opened from the canvas's top-left button).
|
|
201
|
-
const [legendOpen, setLegendOpen] = useState(false);
|
|
202
198
|
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
203
199
|
const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
|
|
204
200
|
// Throughline focus — selected flow (or step) is full strength; other
|
|
@@ -213,6 +209,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
213
209
|
// Throughline flows the user has expanded (via the title row). Closed by
|
|
214
210
|
// default so a graph with several flows doesn't dump every step list at once.
|
|
215
211
|
const [expandedThroughlines, setExpandedThroughlines] = useState<Set<string>>(new Set());
|
|
212
|
+
// Sidebar description visibility. Hidden by default so the files/flows
|
|
213
|
+
// panel gets the vertical room; the title-row toggle reveals it.
|
|
214
|
+
const [descriptionVisible, setDescriptionVisible] = useState(false);
|
|
215
|
+
const [descToggleHover, setDescToggleHover] = useState(false);
|
|
216
|
+
// `true` only when a description exists AND the user opened it.
|
|
217
|
+
const showDesc = !!description && descriptionVisible;
|
|
216
218
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
217
219
|
// closure over the effect deps) can toggle without a stale value.
|
|
218
220
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
@@ -688,7 +690,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
688
690
|
|
|
689
691
|
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
690
692
|
// frame the flow on the canvas. Selection state is cleared — the graph now
|
|
691
|
-
// reads as the narrative.
|
|
693
|
+
// reads as the narrative. No drawer: the code view only opens on a step
|
|
694
|
+
// click. A stale throughline drawer (from a previously focused flow's step)
|
|
695
|
+
// closes; an explicitly opened file drawer stays.
|
|
692
696
|
const focusThroughlineEdges = useCallback(
|
|
693
697
|
(tl: SubsystemThroughline) => {
|
|
694
698
|
setSelected(null);
|
|
@@ -696,18 +700,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
696
700
|
setFocusedStepIndex(null);
|
|
697
701
|
setFocusedThroughlineId(tl.id);
|
|
698
702
|
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
699
|
-
|
|
700
|
-
setDrawerTarget({ kind: 'throughline', throughlineId: tl.id, stepIndex: null });
|
|
701
|
-
} else if (tl.steps[0]) {
|
|
702
|
-
// Fallback: single-file snippet of the first step.
|
|
703
|
-
setDrawerTarget({
|
|
704
|
-
kind: 'file',
|
|
705
|
-
file: tl.steps[0].file,
|
|
706
|
-
startLine: tl.steps[0].line,
|
|
707
|
-
});
|
|
708
|
-
}
|
|
703
|
+
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
709
704
|
},
|
|
710
|
-
[fitFocusBounds
|
|
705
|
+
[fitFocusBounds],
|
|
711
706
|
);
|
|
712
707
|
|
|
713
708
|
const clearThroughlineFocus = useCallback(() => {
|
|
@@ -744,6 +739,50 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
744
739
|
[fitFocusBounds, renderThroughlineViewer],
|
|
745
740
|
);
|
|
746
741
|
|
|
742
|
+
// Arrow keys step through the focused throughline once a step is active
|
|
743
|
+
// (sidebar click or drawer open). Ignores typing targets and chords.
|
|
744
|
+
useEffect(() => {
|
|
745
|
+
if (focusedThroughlineId == null || focusedStepIndex == null || !throughlines) {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const tl = throughlines.find((t) => t.id === focusedThroughlineId);
|
|
749
|
+
if (!tl || tl.steps.length === 0) return;
|
|
750
|
+
|
|
751
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
752
|
+
const el = e.target as HTMLElement | null;
|
|
753
|
+
if (
|
|
754
|
+
el &&
|
|
755
|
+
(el.tagName === 'INPUT' ||
|
|
756
|
+
el.tagName === 'TEXTAREA' ||
|
|
757
|
+
el.tagName === 'SELECT' ||
|
|
758
|
+
el.isContentEditable)
|
|
759
|
+
) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
763
|
+
|
|
764
|
+
let next: number | null = null;
|
|
765
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
|
766
|
+
next = Math.min(tl.steps.length - 1, focusedStepIndex + 1);
|
|
767
|
+
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
|
768
|
+
next = Math.max(0, focusedStepIndex - 1);
|
|
769
|
+
} else {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (next === focusedStepIndex) return;
|
|
773
|
+
e.preventDefault();
|
|
774
|
+
focusThroughlineStep(tl, next);
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
window.addEventListener('keydown', onKeyDown);
|
|
778
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
779
|
+
}, [
|
|
780
|
+
focusedThroughlineId,
|
|
781
|
+
focusedStepIndex,
|
|
782
|
+
throughlines,
|
|
783
|
+
focusThroughlineStep,
|
|
784
|
+
]);
|
|
785
|
+
|
|
747
786
|
const toggleThroughlineCollapsed = useCallback((tlId: string) => {
|
|
748
787
|
setExpandedThroughlines((prev) => {
|
|
749
788
|
const next = new Set(prev);
|
|
@@ -813,10 +852,6 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
813
852
|
});
|
|
814
853
|
}, [dispEdges, selectedFlowStepNos]);
|
|
815
854
|
|
|
816
|
-
const usedMechanisms = useMemo(() => {
|
|
817
|
-
return new Set(edgeLabels.map((l) => l.mechanism));
|
|
818
|
-
}, [edgeLabels]);
|
|
819
|
-
|
|
820
855
|
// Unique source files across components → sidebar file trees.
|
|
821
856
|
const treeFilePaths = treeFiles;
|
|
822
857
|
|
|
@@ -860,7 +895,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
860
895
|
|
|
861
896
|
return (
|
|
862
897
|
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
863
|
-
{/* Sidebar: scrollable title/description on top, files or flows pinned
|
|
898
|
+
{/* Sidebar: scrollable title/description on top, files or flows pinned below.
|
|
899
|
+
The description hides by default so files/flows get the room; the
|
|
900
|
+
title-row toggle reveals it, and the lower panel yields back to 50%. */}
|
|
864
901
|
{!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
|
|
865
902
|
<div
|
|
866
903
|
style={{
|
|
@@ -875,7 +912,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
875
912
|
>
|
|
876
913
|
<div
|
|
877
914
|
style={{
|
|
878
|
-
flex: 1,
|
|
915
|
+
flex: showDesc ? 1 : '0 0 auto',
|
|
879
916
|
minHeight: 0,
|
|
880
917
|
overflowY: 'auto',
|
|
881
918
|
padding: '16px',
|
|
@@ -885,20 +922,54 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
885
922
|
}}
|
|
886
923
|
>
|
|
887
924
|
{sidebarExtra}
|
|
888
|
-
{title && (
|
|
889
|
-
<
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
925
|
+
{(title || description) && (
|
|
926
|
+
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
|
927
|
+
{title && (
|
|
928
|
+
<h2
|
|
929
|
+
style={{
|
|
930
|
+
margin: 0,
|
|
931
|
+
flex: 1,
|
|
932
|
+
minWidth: 0,
|
|
933
|
+
fontSize: theme.fontSizes[2],
|
|
934
|
+
fontWeight: 600,
|
|
935
|
+
color: theme.colors.text,
|
|
936
|
+
fontFamily: theme.fonts.monospace,
|
|
937
|
+
}}
|
|
938
|
+
>
|
|
939
|
+
{title}
|
|
940
|
+
</h2>
|
|
941
|
+
)}
|
|
942
|
+
{description && (
|
|
943
|
+
<button
|
|
944
|
+
type="button"
|
|
945
|
+
aria-expanded={descriptionVisible}
|
|
946
|
+
aria-label={descriptionVisible ? 'Hide description' : 'Show description'}
|
|
947
|
+
title={descriptionVisible ? 'Hide description' : 'Show description'}
|
|
948
|
+
onMouseEnter={() => setDescToggleHover(true)}
|
|
949
|
+
onMouseLeave={() => setDescToggleHover(false)}
|
|
950
|
+
onClick={() => setDescriptionVisible((v) => !v)}
|
|
951
|
+
style={{
|
|
952
|
+
display: 'inline-flex',
|
|
953
|
+
alignItems: 'center',
|
|
954
|
+
justifyContent: 'center',
|
|
955
|
+
flexShrink: 0,
|
|
956
|
+
width: 22,
|
|
957
|
+
height: 22,
|
|
958
|
+
padding: 0,
|
|
959
|
+
border: 'none',
|
|
960
|
+
borderRadius: 4,
|
|
961
|
+
background: descToggleHover ? theme.colors.border : 'transparent',
|
|
962
|
+
color: descToggleHover ? theme.colors.text : (theme.colors.textMuted ?? theme.colors.textSecondary),
|
|
963
|
+
cursor: 'pointer',
|
|
964
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
965
|
+
}}
|
|
966
|
+
>
|
|
967
|
+
{descriptionVisible ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
968
|
+
</button>
|
|
969
|
+
)}
|
|
970
|
+
</div>
|
|
900
971
|
)}
|
|
901
|
-
{description && (
|
|
972
|
+
{description && descriptionVisible && (
|
|
902
973
|
<div style={{ fontSize: theme.fontSizes[0], lineHeight: 1.5 }}>
|
|
903
974
|
<IndustryMarkdownSlide
|
|
904
975
|
content={description}
|
|
@@ -918,7 +989,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
918
989
|
{(treeFilePaths.length > 0 || hasThroughlines) && (
|
|
919
990
|
<div
|
|
920
991
|
style={{
|
|
921
|
-
height: '50%',
|
|
992
|
+
...(showDesc ? { height: '50%' as const } : { flex: 1, minHeight: 0 }),
|
|
922
993
|
minHeight: 160,
|
|
923
994
|
flexShrink: 0,
|
|
924
995
|
borderTop: `1px solid ${theme.colors.border}`,
|
|
@@ -939,7 +1010,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
939
1010
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
940
1011
|
}}
|
|
941
1012
|
>
|
|
942
|
-
{(['
|
|
1013
|
+
{(['flows', 'files'] as const).map((view) => (
|
|
943
1014
|
<button
|
|
944
1015
|
key={view}
|
|
945
1016
|
type="button"
|
|
@@ -1142,37 +1213,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1142
1213
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
1143
1214
|
<Controls showZoom showFitView showInteractive />
|
|
1144
1215
|
</ReactFlow>
|
|
1145
|
-
{/* Legend button — top-left overlay on the canvas; opens the modal. */}
|
|
1146
|
-
{showLegend !== false && usedMechanisms.size > 0 && (
|
|
1147
|
-
<button
|
|
1148
|
-
type="button"
|
|
1149
|
-
onClick={() => setLegendOpen(true)}
|
|
1150
|
-
style={{
|
|
1151
|
-
position: 'absolute',
|
|
1152
|
-
top: 10,
|
|
1153
|
-
left: 10,
|
|
1154
|
-
zIndex: 7,
|
|
1155
|
-
display: 'inline-flex',
|
|
1156
|
-
alignItems: 'center',
|
|
1157
|
-
gap: 6,
|
|
1158
|
-
padding: '4px 10px',
|
|
1159
|
-
fontSize: theme.fontSizes[0],
|
|
1160
|
-
fontFamily: theme.fonts.body,
|
|
1161
|
-
color: theme.colors.text,
|
|
1162
|
-
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1163
|
-
border: `1px solid ${theme.colors.border}`,
|
|
1164
|
-
borderRadius: 6,
|
|
1165
|
-
cursor: 'pointer',
|
|
1166
|
-
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1167
|
-
}}
|
|
1168
|
-
>
|
|
1169
|
-
<MapIcon size={13} />
|
|
1170
|
-
Legend
|
|
1171
|
-
</button>
|
|
1172
|
-
)}
|
|
1173
1216
|
{/* Graph title — non-interactive chip centered at the top of the canvas
|
|
1174
|
-
(clear of the top-
|
|
1175
|
-
|
|
1217
|
+
(clear of the top-right declaration card). Lets graph-only embeds
|
|
1218
|
+
name the subsystem they show. */}
|
|
1176
1219
|
{graphTitle && (
|
|
1177
1220
|
<div
|
|
1178
1221
|
style={{
|
|
@@ -1188,7 +1231,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1188
1231
|
padding: '6px 18px',
|
|
1189
1232
|
fontSize: theme.fontSizes[3],
|
|
1190
1233
|
fontWeight: 600,
|
|
1191
|
-
fontFamily: theme.fonts.
|
|
1234
|
+
fontFamily: theme.fonts.monospace,
|
|
1192
1235
|
color: theme.colors.text,
|
|
1193
1236
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1194
1237
|
border: `1px solid ${theme.colors.border}`,
|
|
@@ -1201,8 +1244,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1201
1244
|
</div>
|
|
1202
1245
|
)}
|
|
1203
1246
|
{/* Selected-component declaration — floating card over the canvas
|
|
1204
|
-
(top-right
|
|
1205
|
-
moves for it. */}
|
|
1247
|
+
(top-right). The graph never moves for it. */}
|
|
1206
1248
|
{selected && (
|
|
1207
1249
|
<div
|
|
1208
1250
|
style={{
|
|
@@ -1229,11 +1271,6 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1229
1271
|
</div>
|
|
1230
1272
|
)}
|
|
1231
1273
|
</div>
|
|
1232
|
-
<EdgeLegendModal
|
|
1233
|
-
open={legendOpen}
|
|
1234
|
-
mechanisms={usedMechanisms}
|
|
1235
|
-
onClose={() => setLegendOpen(false)}
|
|
1236
|
-
/>
|
|
1237
1274
|
<FileDrawer title={drawerTitle} onClose={() => setDrawerTarget(null)}>
|
|
1238
1275
|
{drawerTarget?.kind === 'throughline' &&
|
|
1239
1276
|
focusedThroughline &&
|
|
@@ -1370,6 +1407,15 @@ function ThroughlineFlow({
|
|
|
1370
1407
|
const [headerHover, setHeaderHover] = useState(false);
|
|
1371
1408
|
const [closeHover, setCloseHover] = useState(false);
|
|
1372
1409
|
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
1410
|
+
const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
1411
|
+
|
|
1412
|
+
// Keep DOM focus on the active step so the browser focus ring (and
|
|
1413
|
+
// subsequent arrow keys) follow arrow navigation, not the originally
|
|
1414
|
+
// clicked button.
|
|
1415
|
+
useEffect(() => {
|
|
1416
|
+
if (active?.stepIndex == null) return;
|
|
1417
|
+
stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
|
|
1418
|
+
}, [active?.stepIndex]);
|
|
1373
1419
|
|
|
1374
1420
|
return (
|
|
1375
1421
|
<div style={{ margin: '4px 0', borderRadius: 8 }}>
|
|
@@ -1467,6 +1513,9 @@ function ThroughlineFlow({
|
|
|
1467
1513
|
return (
|
|
1468
1514
|
<button
|
|
1469
1515
|
key={`${step.edgeId}-${i}`}
|
|
1516
|
+
ref={(el) => {
|
|
1517
|
+
stepButtonRefs.current[i] = el;
|
|
1518
|
+
}}
|
|
1470
1519
|
type="button"
|
|
1471
1520
|
onMouseEnter={() => setHoveredStep(i)}
|
|
1472
1521
|
onMouseLeave={() => setHoveredStep(null)}
|
|
@@ -1480,6 +1529,7 @@ function ThroughlineFlow({
|
|
|
1480
1529
|
textAlign: 'left',
|
|
1481
1530
|
borderRadius: 6,
|
|
1482
1531
|
border: 'none',
|
|
1532
|
+
outline: 'none',
|
|
1483
1533
|
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
1484
1534
|
cursor: 'pointer',
|
|
1485
1535
|
transition: 'background 120ms ease',
|
|
@@ -86,12 +86,12 @@ describe('subsystem graph model', () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
test('deriveNameFromSymbol is consistent per kind', () => {
|
|
89
|
-
// brace-bodied constructs wear {}; module uses the symbol as-is.
|
|
90
|
-
expect(deriveNameFromSymbol('SessionReader', 'class')).toBe('SessionReader
|
|
89
|
+
// brace-bodied constructs wear {}; classes render bare; module uses the symbol as-is.
|
|
90
|
+
expect(deriveNameFromSymbol('SessionReader', 'class')).toBe('SessionReader');
|
|
91
91
|
expect(deriveNameFromSymbol('SessionRecord', 'type_alias')).toBe('SessionRecord {}');
|
|
92
92
|
expect(deriveNameFromSymbol('transcript', 'module')).toBe('transcript');
|
|
93
|
-
// falls back to existing name when no symbol (
|
|
94
|
-
expect(deriveNameFromSymbol(undefined, 'class', 'SessionReader')).toBe('SessionReader
|
|
93
|
+
// falls back to existing name when no symbol (class stays bare).
|
|
94
|
+
expect(deriveNameFromSymbol(undefined, 'class', 'SessionReader')).toBe('SessionReader');
|
|
95
95
|
expect(deriveNameFromSymbol('', 'external', 'trail-viewer-host')).toBe('trail-viewer-host');
|
|
96
96
|
});
|
|
97
97
|
|
|
@@ -104,7 +104,7 @@ describe('subsystem graph model', () => {
|
|
|
104
104
|
// data-shaped constructs stay bare
|
|
105
105
|
expect(deriveNameFromSymbol('ROOT', 'store')).toBe('ROOT');
|
|
106
106
|
// brace bodies don't double up
|
|
107
|
-
expect(deriveNameFromSymbol('Foo {}', '
|
|
107
|
+
expect(deriveNameFromSymbol('Foo {}', 'interface')).toBe('Foo {}');
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
test('deriveNameFromSymbol uses JSX decoration for component stereotype', () => {
|
package/src/subsystem/model.ts
CHANGED
|
@@ -269,8 +269,10 @@ export function deriveNameFromSymbol(
|
|
|
269
269
|
|
|
270
270
|
// Decorations = what the drill-down shows. Framework stereotypes can override
|
|
271
271
|
// the language decoration (a React component wears `<>` instead of `()`).
|
|
272
|
-
// Executable constructs wear `()`; brace-bodied constructs
|
|
273
|
-
//
|
|
272
|
+
// Executable constructs wear `()`; brace-bodied constructs (interface,
|
|
273
|
+
// type_alias, enum) wear ` {}`. Classes render bare — the construct badge
|
|
274
|
+
// already says "class".
|
|
275
|
+
// Everything else (class, store, module, external) renders bare.
|
|
274
276
|
if (stereotype === 'component' && !name.startsWith('<')) {
|
|
275
277
|
return `<${name}>`;
|
|
276
278
|
}
|
|
@@ -278,8 +280,7 @@ export function deriveNameFromSymbol(
|
|
|
278
280
|
name = `${name}()`;
|
|
279
281
|
}
|
|
280
282
|
if (
|
|
281
|
-
(construct === '
|
|
282
|
-
construct === 'interface' ||
|
|
283
|
+
(construct === 'interface' ||
|
|
283
284
|
construct === 'type_alias' ||
|
|
284
285
|
construct === 'enum') &&
|
|
285
286
|
!name.endsWith('{}')
|
|
@@ -447,6 +448,30 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
|
|
|
447
448
|
'registers-into': 'dashed',
|
|
448
449
|
};
|
|
449
450
|
|
|
451
|
+
/** Mechanism → [description, verifiable-with-graphify]. Drives the "not
|
|
452
|
+
* directly verifiable" styling of edge labels. */
|
|
453
|
+
export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
|
|
454
|
+
['imports', 'import statement (code-level dependency)', true],
|
|
455
|
+
['imports_from', 'imported by (reverse dependency)', true],
|
|
456
|
+
['re_exports', 're-exports symbols from', true],
|
|
457
|
+
['defines', 'defines / declares symbol', true],
|
|
458
|
+
['calls', 'function/method call (call graph edge)', true],
|
|
459
|
+
['extends', 'class inheritance', true],
|
|
460
|
+
['inherits', 'class inheritance', true],
|
|
461
|
+
['implements', 'implements interface / protocol', true],
|
|
462
|
+
['mixes_in', 'applies mixin', true],
|
|
463
|
+
['uses', 'general dependency (import, call, or reference)', false],
|
|
464
|
+
['method', 'structural: has method / member', true],
|
|
465
|
+
['references', 'type / symbol reference (not a call)', true],
|
|
466
|
+
['contains', 'structural: contains / encapsulates', true],
|
|
467
|
+
['feeds', 'data flow: output feeds into input', false],
|
|
468
|
+
['produces', 'data flow: produces / outputs', false],
|
|
469
|
+
['writes', 'state access: mutates retained state', true],
|
|
470
|
+
['reads', 'state access: reads retained state', true],
|
|
471
|
+
['watches', 'observes retained state without owning it', false],
|
|
472
|
+
['registers-into', 'registration pattern', false],
|
|
473
|
+
];
|
|
474
|
+
|
|
450
475
|
/** Package color palette (derived deterministically from the package name). */
|
|
451
476
|
export function packageColor(name: string): string {
|
|
452
477
|
const palette = [
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
3
|
-
import { GraphRenderer } from './GraphRenderer';
|
|
4
|
-
import type { GraphConfiguration, NodeState, EdgeState } from '@principal-ai/principal-view-core';
|
|
5
|
-
|
|
6
|
-
describe('GraphRenderer', () => {
|
|
7
|
-
const testConfig: GraphConfiguration = {
|
|
8
|
-
metadata: {
|
|
9
|
-
name: 'Test Graph',
|
|
10
|
-
version: '1.0.0',
|
|
11
|
-
},
|
|
12
|
-
nodeTypes: {
|
|
13
|
-
user: {
|
|
14
|
-
shape: 'circle',
|
|
15
|
-
color: '#4CAF50',
|
|
16
|
-
dataSchema: {
|
|
17
|
-
userId: { type: 'string', required: true },
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
edgeTypes: {
|
|
22
|
-
connection: {
|
|
23
|
-
style: 'solid',
|
|
24
|
-
directed: true,
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
allowedConnections: [{ from: 'user', to: 'user', via: 'connection' }],
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const testNodes: NodeState[] = [
|
|
31
|
-
{
|
|
32
|
-
id: 'user-1',
|
|
33
|
-
type: 'user',
|
|
34
|
-
data: { userId: 'alice' },
|
|
35
|
-
createdAt: Date.now(),
|
|
36
|
-
updatedAt: Date.now(),
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
id: 'user-2',
|
|
40
|
-
type: 'user',
|
|
41
|
-
data: { userId: 'bob' },
|
|
42
|
-
createdAt: Date.now(),
|
|
43
|
-
updatedAt: Date.now(),
|
|
44
|
-
},
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
const testEdges: EdgeState[] = [
|
|
48
|
-
{
|
|
49
|
-
id: 'conn-1',
|
|
50
|
-
type: 'connection',
|
|
51
|
-
from: 'user-1',
|
|
52
|
-
to: 'user-2',
|
|
53
|
-
createdAt: Date.now(),
|
|
54
|
-
updatedAt: Date.now(),
|
|
55
|
-
},
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
it('should render without crashing', () => {
|
|
59
|
-
render(<GraphRenderer configuration={testConfig} nodes={testNodes} edges={testEdges} />);
|
|
60
|
-
|
|
61
|
-
expect(screen.getByText(/Graph Renderer/i)).toBeDefined();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('should display configuration name', () => {
|
|
65
|
-
render(<GraphRenderer configuration={testConfig} nodes={testNodes} edges={testEdges} />);
|
|
66
|
-
|
|
67
|
-
expect(screen.getByText(/Test Graph/i)).toBeDefined();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('should display node count', () => {
|
|
71
|
-
render(<GraphRenderer configuration={testConfig} nodes={testNodes} edges={testEdges} />);
|
|
72
|
-
|
|
73
|
-
expect(screen.getByText(/Nodes: 2/i)).toBeDefined();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should display edge count', () => {
|
|
77
|
-
render(<GraphRenderer configuration={testConfig} nodes={testNodes} edges={testEdges} />);
|
|
78
|
-
|
|
79
|
-
expect(screen.getByText(/Edges: 1/i)).toBeDefined();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('should render with empty nodes and edges', () => {
|
|
83
|
-
render(<GraphRenderer configuration={testConfig} nodes={[]} edges={[]} />);
|
|
84
|
-
|
|
85
|
-
expect(screen.getByText(/Nodes: 0/i)).toBeDefined();
|
|
86
|
-
expect(screen.getByText(/Edges: 0/i)).toBeDefined();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('should apply custom className', () => {
|
|
90
|
-
const { container } = render(
|
|
91
|
-
<GraphRenderer
|
|
92
|
-
configuration={testConfig}
|
|
93
|
-
nodes={testNodes}
|
|
94
|
-
edges={testEdges}
|
|
95
|
-
className="custom-class"
|
|
96
|
-
/>
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
const element = container.querySelector('.custom-class');
|
|
100
|
-
expect(element).toBeDefined();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('should apply custom width and height', () => {
|
|
104
|
-
const { container } = render(
|
|
105
|
-
<GraphRenderer
|
|
106
|
-
configuration={testConfig}
|
|
107
|
-
nodes={testNodes}
|
|
108
|
-
edges={testEdges}
|
|
109
|
-
width="500px"
|
|
110
|
-
height="400px"
|
|
111
|
-
/>
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const element = container.querySelector('div');
|
|
115
|
-
expect(element?.style.width).toBe('500px');
|
|
116
|
-
expect(element?.style.height).toBe('400px');
|
|
117
|
-
});
|
|
118
|
-
});
|