@principal-ai/principal-view-react 0.16.32 → 0.16.34

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.
@@ -9,7 +9,7 @@ const meta = {
9
9
  title: 'Subsystem/Component Graph',
10
10
  component: SubsystemComponentGraph,
11
11
  parameters: {
12
- layout: 'padded',
12
+ layout: 'fullscreen',
13
13
  },
14
14
  tags: ['autodocs'],
15
15
  decorators: [
@@ -70,13 +70,14 @@ function TwoNodeDemo({ showEdgeLabels = true }: { showEdgeLabels?: boolean }) {
70
70
  const [selected, setSelected] = useState<string | null>(null);
71
71
  const [selectedEdge, setSelectedEdge] = useState<SubsystemComponentEdge | null>(null);
72
72
  return (
73
- <div style={{ width: '100%', maxWidth: 640, height: 360, display: 'flex', flexDirection: 'column' }}>
73
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
74
74
  <SubsystemComponentGraph
75
75
  components={twoNodeComponents}
76
76
  edges={twoNodeEdges}
77
77
  onSelect={(id) => setSelected(id)}
78
78
  onEdgeSelect={(e) => setSelectedEdge(e)}
79
79
  showEdgeLabels={showEdgeLabels}
80
+ showFileBadges={false}
80
81
  />
81
82
  <div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
82
83
  {selectedEdge
@@ -134,8 +135,8 @@ function PlaygroundDemo({ leftCount, rightCount, showEdgeLabels }: { leftCount:
134
135
  }
135
136
 
136
137
  return (
137
- <div style={{ width: '100%', height: 400 }}>
138
- <SubsystemComponentGraph components={comps} edges={edgeList} showEdgeLabels={showEdgeLabels} />
138
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
139
+ <SubsystemComponentGraph components={comps} edges={edgeList} showEdgeLabels={showEdgeLabels} showFileBadges={false} />
139
140
  </div>
140
141
  );
141
142
  }
@@ -194,12 +195,13 @@ function V2ReaderDemo() {
194
195
  const [selected, setSelected] = useState<string | null>(null);
195
196
  const [selectedEdge, setSelectedEdge] = useState<SubsystemComponentEdge | null>(null);
196
197
  return (
197
- <div style={{ width: '100%', maxWidth: 1280, height: 560, display: 'flex', flexDirection: 'column' }}>
198
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
198
199
  <SubsystemComponentGraph
199
200
  components={v2ReaderComponents}
200
201
  edges={v2ReaderEdges}
201
202
  onSelect={(id) => setSelected(id)}
202
203
  onEdgeSelect={(e) => setSelectedEdge(e)}
204
+ showFileBadges={false}
203
205
  />
204
206
  <div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
205
207
  {selectedEdge
@@ -221,7 +223,7 @@ export const V2ReaderSubsystem: Story = {
221
223
  // ---------------------------------------------------------------------------
222
224
  export const Empty: Story = {
223
225
  render: () => (
224
- <div style={{ width: '100%' }}>
226
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
225
227
  <SubsystemComponentGraph components={[]} edges={[]} />
226
228
  </div>
227
229
  ),
@@ -309,8 +311,8 @@ const investigateOnlyEdges = edges([
309
311
 
310
312
  export const InvestigateOnly: Story = {
311
313
  render: () => (
312
- <div style={{ width: '100%', height: 400 }}>
313
- <SubsystemComponentGraph components={investigateOnlyComponents} edges={investigateOnlyEdges} />
314
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
315
+ <SubsystemComponentGraph components={investigateOnlyComponents} edges={investigateOnlyEdges} showFileBadges={false} />
314
316
  </div>
315
317
  ),
316
318
  };
@@ -318,11 +320,12 @@ export const InvestigateOnly: Story = {
318
320
  /** Same pipeline with a narrower `maxNodeWidth` (140) — long names wrap. */
319
321
  export const NarrowMaxWidth: Story = {
320
322
  render: () => (
321
- <div style={{ width: '100%', height: 400 }}>
323
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
322
324
  <SubsystemComponentGraph
323
325
  components={investigateOnlyComponents}
324
326
  edges={investigateOnlyEdges}
325
327
  maxNodeWidth={140}
328
+ showFileBadges={false}
326
329
  />
327
330
  </div>
328
331
  ),
@@ -386,11 +389,12 @@ const namingConventionComponents: SubsystemComponent[] = [
386
389
  * at their word boundaries (camelCase, snake_case, PascalCase, acronyms). */
387
390
  export const NamingConventions: Story = {
388
391
  render: () => (
389
- <div style={{ width: '100%', height: 460 }}>
392
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
390
393
  <SubsystemComponentGraph
391
394
  components={namingConventionComponents}
392
395
  edges={[]}
393
396
  maxNodeWidth={180}
397
+ showFileBadges={false}
394
398
  />
395
399
  </div>
396
400
  ),
@@ -500,11 +504,12 @@ const detailKindComponents: SubsystemComponent[] = [
500
504
  function DetailKindsDemo() {
501
505
  const [selected, setSelected] = useState<string | null>(null);
502
506
  return (
503
- <div style={{ width: '100%', maxWidth: 1280, height: 420, display: 'flex', flexDirection: 'column' }}>
507
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
504
508
  <SubsystemComponentGraph
505
509
  components={detailKindComponents}
506
510
  edges={[]}
507
511
  onSelect={(id) => setSelected(id)}
512
+ showFileBadges={false}
508
513
  />
509
514
  <div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
510
515
  {selected ? `selected: ${selected}` : 'click a component to see its GraphifyComponentDetail'}
@@ -575,11 +580,12 @@ const resolvedComponents: SubsystemComponent[] = [
575
580
  function MinimalVsResolvedDemo({ resolved }: { resolved: boolean }) {
576
581
  const [selected, setSelected] = useState<string | null>(null);
577
582
  return (
578
- <div style={{ width: '100%', maxWidth: 1280, height: 380, display: 'flex', flexDirection: 'column' }}>
583
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
579
584
  <SubsystemComponentGraph
580
585
  components={resolved ? resolvedComponents : minimalComponents}
581
586
  edges={sharedEdges}
582
587
  onSelect={(id) => setSelected(id)}
588
+ showFileBadges={false}
583
589
  />
584
590
  <div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
585
591
  {resolved
@@ -651,11 +657,12 @@ const investigationEdges = edges([
651
657
  function InvestigationDemo() {
652
658
  const [selected, setSelected] = useState<string | null>(null);
653
659
  return (
654
- <div style={{ width: '100%', maxWidth: 1280, height: 360, display: 'flex', flexDirection: 'column' }}>
660
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
655
661
  <SubsystemComponentGraph
656
662
  components={investigationComponents}
657
663
  edges={investigationEdges}
658
664
  onSelect={(id) => setSelected(id)}
665
+ showFileBadges={false}
659
666
  />
660
667
  <div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
661
668
  read-only investigation snapshot (grok 019fd2a9) — components analyzed, not edited
@@ -742,11 +749,12 @@ const mermaidEdges = edges([
742
749
  function MermaidDemo() {
743
750
  const [selected, setSelected] = useState<string | null>(null);
744
751
  return (
745
- <div style={{ width: '100%', maxWidth: 1280, height: 500, display: 'flex', flexDirection: 'column' }}>
752
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
746
753
  <SubsystemComponentGraph
747
754
  components={mermaidComponents}
748
755
  edges={mermaidEdges}
749
756
  onSelect={(id) => setSelected(id)}
757
+ showFileBadges={false}
750
758
  />
751
759
  <div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
752
760
  mermaid diagram rendering pipeline — lazy → render → zoom → modal
@@ -0,0 +1,69 @@
1
+ /**
2
+ * GraphLayoutCover — opaque overlay that hides a graph's startup sequence
3
+ * (initial measurement render, layout swap, camera settle animation).
4
+ *
5
+ * Must sit inside a `position: relative` ancestor sized to the canvas; it
6
+ * covers exactly that box. Renders covered from mount and fades out
7
+ * `holdMs` after `revealed` flips true, then stays transparent until the
8
+ * next `revealed → false` reset.
9
+ */
10
+
11
+ import { useEffect, useState } from 'react';
12
+ import { useTheme } from '@principal-ade/industry-theme';
13
+
14
+ export interface GraphLayoutCoverProps {
15
+ /** True once the final layout is ready and the camera has settled. */
16
+ revealed: boolean;
17
+ /** How long to stay fully opaque after `revealed`. @default 650 */
18
+ holdMs?: number;
19
+ /** Label shown while covered. @default 'Laying out graph…' */
20
+ label?: string;
21
+ }
22
+
23
+ export function GraphLayoutCover({
24
+ revealed,
25
+ holdMs = 650,
26
+ label = 'Laying out graph…',
27
+ }: GraphLayoutCoverProps) {
28
+ const { theme } = useTheme();
29
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
30
+ const [coverUp, setCoverUp] = useState(true);
31
+
32
+ useEffect(() => {
33
+ if (!revealed) {
34
+ setCoverUp(true);
35
+ return;
36
+ }
37
+ const t = setTimeout(() => setCoverUp(false), holdMs);
38
+ return () => clearTimeout(t);
39
+ }, [revealed, holdMs]);
40
+
41
+ return (
42
+ <div
43
+ style={{
44
+ position: 'absolute',
45
+ inset: 0,
46
+ zIndex: 15,
47
+ background: theme.colors.background,
48
+ display: 'flex',
49
+ alignItems: 'center',
50
+ justifyContent: 'center',
51
+ opacity: coverUp ? 1 : 0,
52
+ transition: 'opacity 200ms ease',
53
+ pointerEvents: 'none',
54
+ }}
55
+ >
56
+ {coverUp && (
57
+ <span
58
+ style={{
59
+ color: muted,
60
+ fontFamily: theme.fonts.monospace,
61
+ fontSize: theme.fontSizes[0],
62
+ }}
63
+ >
64
+ {label}
65
+ </span>
66
+ )}
67
+ </div>
68
+ );
69
+ }
@@ -32,6 +32,7 @@ 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
36
  import { IndustryMarkdownSlide } from 'themed-markdown';
36
37
  import {
37
38
  buildSubsystemGraph,
@@ -44,6 +45,8 @@ import {
44
45
  } from './model';
45
46
  import type { GraphifyComponentDetail } from '../graphify';
46
47
  import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
48
+ import { SubsystemFileTree } from './SubsystemFileTree';
49
+ import { GraphLayoutCover } from './GraphLayoutCover';
47
50
 
48
51
  const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
49
52
  ['imports', 'import statement (code-level dependency)', true],
@@ -75,6 +78,9 @@ export interface SubsystemComponentGraphProps {
75
78
  maxNodeWidth?: number;
76
79
  /** Show edge labels (mechanism names) on the graph. @default true */
77
80
  showEdgeLabels?: boolean;
81
+ /** Show the click-to-open file chip under each component name.
82
+ * @default true */
83
+ showFileBadges?: boolean;
78
84
  /** Subsystem title displayed in the sidebar. */
79
85
  title?: string;
80
86
  /** Markdown description rendered in the sidebar. */
@@ -86,11 +92,23 @@ export interface SubsystemComponentGraphProps {
86
92
  /** Rendered in the sidebar under the description (e.g. selection inspector). */
87
93
  sidebarAfterDescription?: ReactNode;
88
94
  /**
89
- * Rendered inside the selected-component detail panel when the component
90
- * has a `file`. Host-injected reader/renderer so this package stays free
91
- * of fs and code-view dependencies.
95
+ * Host-injected reader/renderer for the bottom file drawer, keyed by
96
+ * repo-root-relative path. Opening happens on node click (component with a
97
+ * `file`) and sidebar file-tree click. Keeps this package free of fs and
98
+ * code-view dependencies.
99
+ */
100
+ renderFileViewer?: (file: string) => ReactNode;
101
+ /**
102
+ * Legacy component-keyed variant, kept for backward compatibility. When
103
+ * `renderFileViewer` is absent, drawer content resolves via the first
104
+ * component whose `file` matches the opened path.
92
105
  */
93
106
  renderFileView?: (component: SubsystemComponent) => ReactNode;
107
+ /**
108
+ * Called when a file in the sidebar file tree is clicked (repo-root-relative
109
+ * path). The tree is derived from the components' `file` values.
110
+ */
111
+ onFileSelect?: (file: string) => void;
94
112
  }
95
113
 
96
114
  const nodeTypes: NodeTypes = {
@@ -105,7 +123,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
105
123
  measured: { w: number; h: number } | null;
106
124
  }
107
125
 
108
- function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView }: InnerProps) {
126
+ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showFileBadges, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect }: InnerProps) {
109
127
  const { theme } = useTheme();
110
128
  const { fitView } = useReactFlow();
111
129
  const viewport = useViewport();
@@ -115,10 +133,17 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
115
133
  });
116
134
  const [layoutReady, setLayoutReady] = useState(false);
117
135
  const [selected, setSelected] = useState<SubsystemComponent | null>(null);
136
+ // File currently shown in the bottom drawer (repo-root-relative path).
137
+ const [openFile, setOpenFile] = useState<string | null>(null);
138
+ // Component the pointer is over (null on leave) → transient tree highlight.
139
+ const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
118
140
  // Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
119
141
  // closure over the effect deps) can toggle without a stale value.
120
142
  const selectedRef = useRef<SubsystemComponent | null>(null);
121
143
  selectedRef.current = selected;
144
+ // Ref mirror of `openFile` for the tree-click toggle.
145
+ const openFileRef = useRef<string | null>(null);
146
+ openFileRef.current = openFile;
122
147
 
123
148
  // Pass 1: build with estimated widths so React Flow can measure the DOM.
124
149
  // The pane stays hidden until Pass 2 completes.
@@ -193,6 +218,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
193
218
  const comp = components.find((c) => c.id === id);
194
219
  if (comp) {
195
220
  // Clicking the already-selected node unselects it (toggle off).
221
+ // Selection is independent of the file drawer — nodes never open it.
196
222
  if (selectedRef.current?.id === comp.id) {
197
223
  setSelected(null);
198
224
  setSelectedEdgeId(null);
@@ -205,10 +231,12 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
205
231
  };
206
232
  SUBSYSTEM_CALLBACKS.onEdgeSelect = selectEdge;
207
233
  SUBSYSTEM_CALLBACKS.maxNodeWidth = maxNodeWidth;
234
+ SUBSYSTEM_CALLBACKS.onHover = setHoveredComponentId;
208
235
  return () => {
209
236
  SUBSYSTEM_CALLBACKS.onSelect = undefined;
210
237
  SUBSYSTEM_CALLBACKS.onEdgeSelect = undefined;
211
238
  SUBSYSTEM_CALLBACKS.maxNodeWidth = undefined;
239
+ SUBSYSTEM_CALLBACKS.onHover = undefined;
212
240
  };
213
241
  }, [components, onSelect, maxNodeWidth, selectEdge]);
214
242
 
@@ -216,6 +244,24 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
216
244
  const xyflowNodesBase = nodes as Node[];
217
245
  const baseEdges = convertedEdges as Edge[];
218
246
 
247
+ // While a file is open in the drawer, tag each node with whether its
248
+ // component lives in that file — the node renderer spotlights matches and
249
+ // dims non-matches (mirrors the edge-dimming behavior on selection).
250
+ const dispNodes = useMemo(() => {
251
+ let ns = xyflowNodesBase.map((n) => ({
252
+ ...n,
253
+ data: { ...(n.data as object), showFileBadges },
254
+ }));
255
+ if (!openFile) return ns;
256
+ return ns.map((n) => {
257
+ const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
258
+ return {
259
+ ...n,
260
+ data: { ...(n.data as object), fileMatch: comp?.file === openFile },
261
+ };
262
+ });
263
+ }, [xyflowNodesBase, openFile, showFileBadges]);
264
+
219
265
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
220
266
  const baseEdgesKey = useMemo(
221
267
  () => convertedEdges.map((e) => e.id).sort().join(','),
@@ -248,7 +294,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
248
294
  pendingMeasuredRef.current = true;
249
295
  }
250
296
  }
251
- const result = applyNodeChanges(changes, xyflowNodesBase);
297
+ const result = applyNodeChanges(changes, dispNodes);
252
298
  // After applying changes, check if we should trigger pass 2.
253
299
  if (pendingMeasuredRef.current) {
254
300
  // Use microtask so the state update from applyNodeChanges commits first.
@@ -256,7 +302,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
256
302
  }
257
303
  return result;
258
304
  },
259
- [xyflowNodesBase, triggerPass2],
305
+ [dispNodes, triggerPass2],
260
306
  );
261
307
  const onEdgesChange = useCallback(
262
308
  (_changes: EdgeChange[]) => dispEdges,
@@ -270,12 +316,36 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
270
316
  return () => clearTimeout(t);
271
317
  }, [baseNodesKey, baseEdgesKey, fitView]);
272
318
 
319
+ // When the file drawer opens/closes the canvas resizes — reframe once the
320
+ // height transition settles: onto the matching (spotlit) nodes while a file
321
+ // is open, back to the whole graph when it closes.
322
+ const prevOpenFileRef = useRef<string | null>(null);
323
+ useEffect(() => {
324
+ const prev = prevOpenFileRef.current;
325
+ prevOpenFileRef.current = openFile;
326
+ if (!layoutReady || prev === openFile) return;
327
+ const t = setTimeout(() => {
328
+ if (openFile) {
329
+ const ids = xyflowNodesBase
330
+ .filter((n) => (n.data as { component?: SubsystemComponent } | undefined)?.component?.file === openFile)
331
+ .map((n) => ({ id: n.id }));
332
+ if (ids.length > 0) {
333
+ fitView({ nodes: ids, padding: 0.35, minZoom: 0.05, maxZoom: 2, duration: 250 });
334
+ }
335
+ } else {
336
+ fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 250 });
337
+ }
338
+ }, 230);
339
+ return () => clearTimeout(t);
340
+ }, [openFile, layoutReady, xyflowNodesBase, fitView]);
341
+
273
342
  const onNodeClick: NodeMouseHandler = useCallback(
274
343
  (_e, node: Node) => {
275
344
  const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
276
345
  setSelectedEdgeId(null);
277
346
  if (node.type === 'subsystem-component' && comp) {
278
347
  // Clicking the already-selected node unselects it (toggle off).
348
+ // Selection is independent of the file drawer — nodes never open it.
279
349
  if (selected?.id === comp.id) {
280
350
  setSelected(null);
281
351
  return;
@@ -300,6 +370,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
300
370
  setSelectedEdgeId(null);
301
371
  }, []);
302
372
 
373
+ // Sidebar file-tree click → toggle in the bottom drawer (+ host hook only
374
+ // when opening, so hosts don't see close events).
375
+ const onTreeSelectFile = useCallback(
376
+ (file: string) => {
377
+ if (openFileRef.current === file) {
378
+ setOpenFile(null);
379
+ return;
380
+ }
381
+ setOpenFile(file);
382
+ onFileSelect?.(file);
383
+ },
384
+ [onFileSelect],
385
+ );
386
+
303
387
  // Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
304
388
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
305
389
  // the actual edge path (not node-center approximations).
@@ -322,23 +406,60 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
322
406
 
323
407
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
324
408
 
409
+ // Unique source files across components → sidebar file tree.
410
+ const files = useMemo(
411
+ () => Array.from(new Set(components.map((c) => c.file).filter(Boolean))).sort(),
412
+ [components],
413
+ );
414
+
415
+ // The hovered node's file (null when not hovering / file-less component).
416
+ const hoveredFile = useMemo(() => {
417
+ if (!hoveredComponentId) return null;
418
+ return components.find((c) => c.id === hoveredComponentId)?.file ?? null;
419
+ }, [components, hoveredComponentId]);
420
+
421
+ // Drawer content renderer: prefer the path-keyed viewer; fall back to the
422
+ // legacy component-keyed one via a file → first-component lookup.
423
+ const fileViewer = useMemo(() => {
424
+ if (renderFileViewer) return renderFileViewer;
425
+ if (renderFileView) {
426
+ const byFile = new Map(
427
+ components.filter((c) => c.file).map((c) => [c.file, c] as const),
428
+ );
429
+ return (file: string) => {
430
+ const comp = byFile.get(file);
431
+ return comp ? renderFileView(comp) : null;
432
+ };
433
+ }
434
+ return undefined;
435
+ }, [renderFileViewer, renderFileView, components]);
436
+
325
437
  return (
326
- <div style={{ visibility: layoutReady ? 'visible' : 'hidden', width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
327
- {/* Sidebar with title and description */}
328
- {(title || description || usedMechanisms.size > 0 || sidebarExtra || sidebarAfterDescription) && (
438
+ <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
439
+ {/* Sidebar: scrollable title/description/legend on top, file tree pinned to the bottom half */}
440
+ {(title || description || usedMechanisms.size > 0 || sidebarExtra || sidebarAfterDescription || files.length > 0) && (
329
441
  <div
330
442
  style={{
331
- width: 280,
332
- minWidth: 280,
443
+ width: 340,
444
+ minWidth: 340,
333
445
  borderRight: `1px solid ${theme.colors.border}`,
334
446
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
335
- padding: '16px',
336
- overflowY: 'auto',
337
447
  display: 'flex',
338
448
  flexDirection: 'column',
339
- gap: 12,
449
+ overflow: 'hidden',
340
450
  }}
341
451
  >
452
+ <div
453
+ style={{
454
+ flex: 1,
455
+ minHeight: 0,
456
+ overflowY: 'auto',
457
+ padding: '16px',
458
+ display: 'flex',
459
+ flexDirection: 'column',
460
+ gap: 12,
461
+ }}
462
+ >
342
463
  {sidebarExtra}
343
464
  {title && (
344
465
  <h2
@@ -392,6 +513,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
392
513
  ))}
393
514
  </div>
394
515
  )}
516
+ </div>
517
+ {files.length > 0 && (
518
+ <SubsystemFileTree
519
+ files={files}
520
+ selectedFile={selected?.file ?? openFile}
521
+ hoveredFile={hoveredFile}
522
+ onSelectFile={onTreeSelectFile}
523
+ />
524
+ )}
395
525
  </div>
396
526
  )}
397
527
 
@@ -449,7 +579,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
449
579
  )}
450
580
  <ReactFlow
451
581
  key={`${baseNodesKey}-${baseEdgesKey}`}
452
- nodes={xyflowNodesBase}
582
+ nodes={dispNodes}
453
583
  edges={dispEdges}
454
584
  nodeTypes={nodeTypes}
455
585
  edgeTypes={edgeTypes}
@@ -482,7 +612,12 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
482
612
  <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
483
613
  <Controls showZoom showFitView showInteractive />
484
614
  </ReactFlow>
485
- {selected && <ComponentDetail component={selected} renderFileView={renderFileView} />}
615
+ {selected && <ComponentDetail component={selected} />}
616
+ <FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
617
+ {fileViewer && openFile ? fileViewer(openFile) : null}
618
+ </FileDrawer>
619
+ {/* Startup cover — hides measurement, layout swap, and camera settle. */}
620
+ <GraphLayoutCover revealed={layoutReady} />
486
621
  {canvasOverlay}
487
622
  </div>
488
623
  </div>
@@ -490,8 +625,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
490
625
  }
491
626
 
492
627
  /** Detail panel for the selected component — the verifiable identity + sources
493
- * moved off the canvas so nodes stay minimal. */
494
- function ComponentDetail({ component, renderFileView }: { component: SubsystemComponent; renderFileView?: (component: SubsystemComponent) => ReactNode }) {
628
+ * moved off the canvas so nodes stay minimal. File content lives in the
629
+ * bottom FileDrawer, not here. */
630
+ function ComponentDetail({ component }: { component: SubsystemComponent }) {
495
631
  const { theme } = useTheme();
496
632
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
497
633
  const color = KIND_COLOR[component.kind] ?? '#888';
@@ -556,19 +692,101 @@ function ComponentDetail({ component, renderFileView }: { component: SubsystemCo
556
692
  </div>
557
693
  ))}
558
694
  {component.detail && <GraphifyDetailSections detail={component.detail} />}
559
- {renderFileView && component.file && (
560
- <div
695
+ </div>
696
+ );
697
+ }
698
+
699
+ /** Bottom panel that slides up from the bottom of the graph area to show a
700
+ * file's contents — opened by node clicks and sidebar file-tree clicks
701
+ * alike. Sits in normal flow (canvas shrinks while open, nothing covered)
702
+ * and animates via height; stays mounted so open/close animates. */
703
+ function FileDrawer({
704
+ file,
705
+ onClose,
706
+ children,
707
+ }: {
708
+ file: string | null;
709
+ onClose: () => void;
710
+ children?: ReactNode;
711
+ }) {
712
+ const { theme } = useTheme();
713
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
714
+ const open = file !== null;
715
+
716
+ useEffect(() => {
717
+ if (!open) return;
718
+ const onKey = (e: KeyboardEvent) => {
719
+ if (e.key === 'Escape') onClose();
720
+ };
721
+ window.addEventListener('keydown', onKey);
722
+ return () => window.removeEventListener('keydown', onKey);
723
+ }, [open, onClose]);
724
+
725
+ return (
726
+ <div
727
+ style={{
728
+ position: 'relative',
729
+ // Above the absolute edge-label overlay (zIndex 5), which spans the
730
+ // whole graph-area container including this panel's slice.
731
+ zIndex: 6,
732
+ flexShrink: 0,
733
+ height: open ? '45%' : 0,
734
+ minHeight: 0,
735
+ overflow: 'hidden',
736
+ display: 'flex',
737
+ flexDirection: 'column',
738
+ background: theme.colors.background,
739
+ borderTop: open ? `1px solid ${theme.colors.border}` : 'none',
740
+ transition: 'height 200ms ease',
741
+ }}
742
+ >
743
+ <div
744
+ style={{
745
+ display: 'flex',
746
+ alignItems: 'center',
747
+ gap: 8,
748
+ padding: '6px 10px',
749
+ borderBottom: `1px solid ${theme.colors.border}`,
750
+ flexShrink: 0,
751
+ }}
752
+ >
753
+ <span
754
+ title={file ?? undefined}
561
755
  style={{
562
- marginTop: 8,
563
- maxHeight: 320,
564
- overflow: 'auto',
565
- border: `1px solid ${theme.colors.border}`,
566
- borderRadius: 6,
756
+ flex: 1,
757
+ minWidth: 0,
758
+ overflow: 'hidden',
759
+ textOverflow: 'ellipsis',
760
+ whiteSpace: 'nowrap',
761
+ fontFamily: theme.fonts.monospace,
762
+ fontSize: theme.fontSizes[0],
763
+ color: muted,
567
764
  }}
568
765
  >
569
- {renderFileView(component)}
570
- </div>
571
- )}
766
+ {file}
767
+ </span>
768
+ <button
769
+ type="button"
770
+ onClick={onClose}
771
+ aria-label="Close file"
772
+ style={{
773
+ display: 'inline-flex',
774
+ alignItems: 'center',
775
+ justifyContent: 'center',
776
+ width: 22,
777
+ height: 22,
778
+ padding: 0,
779
+ border: 'none',
780
+ borderRadius: 4,
781
+ background: 'transparent',
782
+ color: theme.colors.text,
783
+ cursor: 'pointer',
784
+ }}
785
+ >
786
+ <X size={14} />
787
+ </button>
788
+ </div>
789
+ <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>{children}</div>
572
790
  </div>
573
791
  );
574
792
  }