@johndimm/constellations 1.0.1 → 1.0.3

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 (40) hide show
  1. package/App.tsx +360 -66
  2. package/FullPageConstellations.tsx +7 -4
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +67 -30
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +229 -250
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +2 -1
  10. package/components/NodeContextMenu.tsx +123 -3
  11. package/components/PeopleBrowserSidebar.tsx +15 -6
  12. package/components/Sidebar.tsx +46 -19
  13. package/components/TimelineView.tsx +1 -0
  14. package/hooks/useExpansion.ts +85 -230
  15. package/hooks/useGraphActions.ts +1 -0
  16. package/hooks/useGraphState.ts +75 -40
  17. package/hooks/useKioskMode.ts +1 -0
  18. package/hooks/useNodeClickHandler.ts +23 -15
  19. package/hooks/useSearchHandlers.ts +60 -21
  20. package/host.ts +1 -1
  21. package/index.css +17 -3
  22. package/index.tsx +5 -3
  23. package/package.json +4 -2
  24. package/services/aiService.ts +27 -0
  25. package/services/aiUtils.ts +285 -195
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +479 -0
  29. package/services/geminiService.ts +543 -736
  30. package/services/graphUtils.ts +128 -18
  31. package/services/imageService.ts +18 -0
  32. package/services/openAlexService.ts +1 -0
  33. package/services/resolveImageForTitle.ts +458 -0
  34. package/services/wikipediaImage.ts +1 -0
  35. package/services/wikipediaService.ts +79 -49
  36. package/sessionHandoff.ts +26 -0
  37. package/types.ts +3 -0
  38. package/utils/evidenceUtils.ts +1 -0
  39. package/utils/graphLogicUtils.ts +1 -0
  40. package/utils/wikiUtils.ts +14 -2
package/App.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
2
3
  import { buildWikiUrl } from './utils/wikiUtils';
3
4
  import { Key, Search, HelpCircle, Minimize2, Maximize2, ExternalLink } from 'lucide-react';
@@ -10,7 +11,7 @@ import AppNotifications from './components/AppNotifications';
10
11
  import AppConfirmDialog from './components/AppConfirmDialog';
11
12
  import HelpOverlay from './components/HelpOverlay';
12
13
  import { GraphNode, GraphLink } from './types';
13
- import { getApiKey, getEnvCacheUrl } from './services/aiUtils';
14
+ import { getApiKey, getEnvCacheUrl, readBundledEnv } from './services/aiUtils';
14
15
  import { useNodeClickHandler } from './hooks/useNodeClickHandler';
15
16
 
16
17
  import { useGraphState } from './hooks/useGraphState';
@@ -18,19 +19,59 @@ import { useKioskMode } from './hooks/useKioskMode';
18
19
  import { useExpansion } from './hooks/useExpansion';
19
20
  import { useSearchHandlers } from './hooks/useSearchHandlers';
20
21
  import { useGraphActions } from './hooks/useGraphActions';
22
+ import { buildHandoffFromLiveState, type ConstellationsSessionHandoffV1 } from './sessionHandoff';
21
23
 
22
24
  const PeopleBrowserSidebar = lazy(() => import('./components/PeopleBrowserSidebar'));
23
25
 
24
26
 
25
27
  type AppProps = {
26
28
  mode?: 'standalone' | 'extension';
29
+ /** When true, fill the parent box and size the graph with ResizeObserver instead of the viewport. */
30
+ embedded?: boolean;
27
31
  hideHeader?: boolean;
28
32
  hideControlPanel?: boolean;
33
+ /**
34
+ * When `hideControlPanel` is true, the Chrome extension still shows `ExtensionControls`.
35
+ * Set to `false` for host-embedded “graph only” (e.g. Soundings player) — no left rail, no micro toolbar.
36
+ */
37
+ showExtensionWhenPanelHidden?: boolean;
29
38
  hideSidebar?: boolean;
30
- externalSearch?: { term: string; id: number } | null;
31
- onExternalSearchConsumed?: (id: number) => void;
39
+ externalSearch?: { term: string; id: string | number } | null;
40
+ onExternalSearchConsumed?: (id: string | number) => void;
32
41
  onNodeNavigate?: (node: GraphNode) => void;
33
42
  renderEvidencePopup?: (selectedLink: GraphLink | null, onClose: () => void) => React.ReactNode;
43
+ /** When these strings match a node title (substring, case-insensitive), that node is expanded once per search. */
44
+ autoExpandMatchTitles?: string[] | null;
45
+ /**
46
+ * When set (Soundings player / constellations page), the first match from `autoExpandMatchTitles`
47
+ * also **selects** the node, and we wait for `nowPlayingKey` to change before re-applying.
48
+ * String should change when album/track from the player updates.
49
+ */
50
+ nowPlayingKey?: string | null;
51
+ /** e.g. optional cleanup (e.g. document class) when leaving full-screen; use with `closeHref` for navigation. */
52
+ onClose?: () => void;
53
+ /** If set, close control is an `<a href>` (see `AppHeader`); e.g. `/` (Trailer Vision) or `/player` (Soundings). */
54
+ closeHref?: string;
55
+ /** Soundings: create a new DJ channel seeded from the right-clicked graph node. */
56
+ onNewChannelFromNode?: (node: GraphNode) => void;
57
+ /**
58
+ * Restored graph from player embed (session handoff) — avoids re-running searches.
59
+ * Only used on full-screen mount; not for embedded mode.
60
+ */
61
+ initialSession?: ConstellationsSessionHandoffV1 | null;
62
+ /**
63
+ * When embedded in an app that already shows a top nav (e.g. Trailer Vision ~44px), set to that
64
+ * height in px so fixed toolbars sit below the host nav and clicks reach the right layer.
65
+ */
66
+ hostNavOffsetPx?: number;
67
+ /**
68
+ * When `embedded`, panels default to `position:absolute` in the constellations root. Set
69
+ * `true` for full-viewport overlay hosts (e.g. Soundings) so the control rail and details use
70
+ * viewport-anchored layout + classic max-heights; keeps blur/shadows aligned to the window edge.
71
+ */
72
+ useViewportForPanels?: boolean;
73
+ /** When set, ControlPanel shows a link to the graph settings page. */
74
+ settingsHref?: string;
34
75
  };
35
76
 
36
77
  const ExtensionControls: React.FC<{
@@ -124,26 +165,57 @@ const ExtensionControls: React.FC<{
124
165
  };
125
166
 
126
167
  const App: React.FC<AppProps> = ({
168
+ embedded = false,
127
169
  hideHeader = false,
128
170
  hideControlPanel = false,
171
+ showExtensionWhenPanelHidden = true,
129
172
  hideSidebar = false,
130
173
  externalSearch = null,
131
174
  onExternalSearchConsumed,
132
175
  onNodeNavigate,
133
- renderEvidencePopup
176
+ renderEvidencePopup,
177
+ autoExpandMatchTitles = null,
178
+ nowPlayingKey = null,
179
+ onClose,
180
+ closeHref,
181
+ onNewChannelFromNode,
182
+ initialSession: initialSessionProp = null,
183
+ hostNavOffsetPx = 0,
184
+ useViewportForPanels = false,
185
+ settingsHref,
134
186
  }) => {
135
- const ENABLE_WEB_SEARCH = String((import.meta as any)?.env?.VITE_ENABLE_WEB_SEARCH || '').trim().toLowerCase() === 'true' || (import.meta as any)?.env?.VITE_ENABLE_WEB_SEARCH === '1';
136
- const ENABLE_ACADEMIC_CORPORA = (import.meta as any)?.env?.VITE_ENABLE_ACADEMIC_CORPORA !== 'false' && (import.meta as any)?.env?.VITE_ENABLE_ACADEMIC_CORPORA !== '0';
187
+ const initialSession = initialSessionProp && initialSessionProp.graph?.nodes?.length
188
+ ? initialSessionProp
189
+ : null;
190
+ const skipPlayerBootstrapRef = useRef(!!initialSession);
191
+ const wsRaw = readBundledEnv('VITE_ENABLE_WEB_SEARCH');
192
+ const ENABLE_WEB_SEARCH = String(wsRaw).trim().toLowerCase() === 'true' || wsRaw === '1';
193
+ const acadRaw = readBundledEnv('VITE_ENABLE_ACADEMIC_CORPORA');
194
+ const ENABLE_ACADEMIC_CORPORA = acadRaw !== 'false' && acadRaw !== '0';
137
195
 
138
196
  const cacheBaseUrl = getEnvCacheUrl();
139
197
  const cacheEnabled = !!cacheBaseUrl;
140
198
 
199
+ const [graphHostEl, setGraphHostEl] = useState<HTMLDivElement | null>(null);
200
+
201
+ useEffect(() => {
202
+ if (embedded) return;
203
+ const root = document.documentElement;
204
+ root.classList.add("constellations-standalone");
205
+ return () => root.classList.remove("constellations-standalone");
206
+ }, [embedded]);
207
+
141
208
  const {
142
209
  isAdminMode, kioskDomains, setKioskDomains, selectedKioskDomainId, setSelectedKioskDomainId,
143
210
  selectedKioskDomain, kioskSeedTerms
144
211
  } = useKioskMode();
145
212
 
146
- const state = useGraphState({ cacheEnabled, cacheBaseUrl });
213
+ const state = useGraphState({
214
+ cacheEnabled,
215
+ cacheBaseUrl,
216
+ boundElement: embedded ? graphHostEl : undefined,
217
+ initialSession
218
+ });
147
219
  const {
148
220
  graphData, setGraphData, nodes, links, graphDataRef,
149
221
  isProcessing, setIsProcessing, selectedNode, setSelectedNode,
@@ -158,7 +230,7 @@ const App: React.FC<AppProps> = ({
158
230
  contextMenu, setContextMenu, panelCollapsed, setPanelCollapsed,
159
231
  sidebarCollapsed, setSidebarCollapsed, sidebarToggleSignal, setSidebarToggleSignal,
160
232
  peopleBrowserOpen, setPeopleBrowserOpen, savedGraphs, setSavedGraphs,
161
- searchMode, setSearchMode, loadNodeImage, handleFindBetterImage, saveCacheNodeMeta
233
+ searchMode, setSearchMode, lockedPair, loadNodeImage, handleFindBetterImage, saveCacheNodeMeta
162
234
  } = state;
163
235
 
164
236
  const { fetchAndExpandNode, saveCacheExpansion } = useExpansion({
@@ -167,8 +239,7 @@ const App: React.FC<AppProps> = ({
167
239
  cacheEnabled, cacheBaseUrl, ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH,
168
240
  loadNodeImage, saveCacheNodeMeta,
169
241
  setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds,
170
- setSelectedNode, setSelectedLink, exploreTerm: '', isTextOnly, graphRef,
171
- setNotification,
242
+ setSelectedNode, setSelectedLink, exploreTerm: '', isTextOnly, graphRef
172
243
  });
173
244
 
174
245
  const [showHelp, setShowHelp] = useState(false);
@@ -180,7 +251,8 @@ const App: React.FC<AppProps> = ({
180
251
  graphDataRef, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef,
181
252
  setLockedPair: state.setLockedPair, dimensions, cacheEnabled, cacheBaseUrl, loadNodeImage, fetchAndExpandNode,
182
253
  setNotification, setSelectedNode, setSelectedLink, setPathNodeIds, setPendingAutoExpandId: () => { },
183
- showControlPanel: !hideControlPanel, selectedKioskDomain, graphRef
254
+ showControlPanel: !hideControlPanel, selectedKioskDomain, graphRef,
255
+ initialSession
184
256
  });
185
257
 
186
258
  const {
@@ -201,8 +273,6 @@ const App: React.FC<AppProps> = ({
201
273
  graphData,
202
274
  setExpandingNodeId,
203
275
  setNewChildNodeIds,
204
- /** DevTools: see why a click opened the menu vs expanded (Vite terminal will not show this). */
205
- onDebug: (message) => console.info("[Constellations]", message),
206
276
  onNavigate: onNodeNavigate ? (node) => {
207
277
  onNodeNavigate(node);
208
278
  } : undefined,
@@ -219,6 +289,13 @@ const App: React.FC<AppProps> = ({
219
289
  getMenuPosition: (node, event) => ({ x: event?.clientX ?? 0, y: event?.clientY ?? 0 })
220
290
  });
221
291
 
292
+ const handleNodeContextMenu = useCallback((event: MouseEvent, node: GraphNode) => {
293
+ event.preventDefault();
294
+ event.stopPropagation();
295
+ setSelectedNode(node);
296
+ setContextMenu({ node, x: event.clientX, y: event.clientY });
297
+ }, [setSelectedNode, setContextMenu]);
298
+
222
299
  useEffect(() => {
223
300
  const checkKey = async () => {
224
301
  if (cacheBaseUrl) {
@@ -236,11 +313,83 @@ const App: React.FC<AppProps> = ({
236
313
  checkKey();
237
314
  }, [setIsKeyReady]);
238
315
 
316
+ const handleStartSearchRef = useRef(handleStartSearch);
239
317
  useEffect(() => {
318
+ handleStartSearchRef.current = handleStartSearch;
319
+ }, [handleStartSearch]);
320
+ const onExternalSearchConsumedRef = useRef(onExternalSearchConsumed);
321
+ useEffect(() => {
322
+ onExternalSearchConsumedRef.current = onExternalSearchConsumed;
323
+ }, [onExternalSearchConsumed]);
324
+
325
+ useEffect(() => {
326
+ if (skipPlayerBootstrapRef.current) return;
240
327
  if (!externalSearch?.term) return;
241
- handleStartSearch(externalSearch.term);
242
- if (externalSearch?.id !== undefined) onExternalSearchConsumed?.(externalSearch.id);
243
- }, [externalSearch?.id, handleStartSearch, onExternalSearchConsumed]);
328
+ handleStartSearchRef.current(externalSearch.term);
329
+ if (externalSearch?.id !== undefined) {
330
+ onExternalSearchConsumedRef.current?.(externalSearch.id);
331
+ }
332
+ }, [externalSearch?.id, externalSearch?.term]);
333
+
334
+ const autoExpandDoneRef = useRef<Set<string>>(new Set());
335
+ const pendingNpFocusRef = useRef(false);
336
+
337
+ useEffect(() => {
338
+ if (nowPlayingKey != null && nowPlayingKey !== "") {
339
+ pendingNpFocusRef.current = true;
340
+ } else {
341
+ pendingNpFocusRef.current = false;
342
+ }
343
+ }, [nowPlayingKey]);
344
+
345
+ useEffect(() => {
346
+ if (!autoExpandMatchTitles?.length) return;
347
+ if (!nodes.length || isProcessing) return;
348
+ const syncFromPlayer = nowPlayingKey != null && nowPlayingKey !== "";
349
+ if (syncFromPlayer && !pendingNpFocusRef.current) return;
350
+
351
+ const searchSig = `${searchId}`;
352
+ for (const raw of autoExpandMatchTitles) {
353
+ const want = raw.trim();
354
+ if (!want) continue;
355
+ const expandOnceKey = `${searchSig}::${want.toLowerCase()}`;
356
+ if (autoExpandDoneRef.current.has(expandOnceKey) && !syncFromPlayer) continue;
357
+
358
+ const wl = want.toLowerCase();
359
+ const found = nodes.find((n) => {
360
+ if (n.isLoading) return false;
361
+ const nt = (n.title || '').toLowerCase();
362
+ if (syncFromPlayer) {
363
+ return nt === wl || nt.includes(wl) || wl.includes(nt);
364
+ }
365
+ if (n.expanded) return false;
366
+ return nt === wl || nt.includes(wl) || wl.includes(nt);
367
+ });
368
+ if (!found) continue;
369
+
370
+ if (syncFromPlayer) {
371
+ setSelectedNode(found);
372
+ setSelectedLink(null);
373
+ setContextMenu(null);
374
+ pendingNpFocusRef.current = false;
375
+ }
376
+ if (!autoExpandDoneRef.current.has(expandOnceKey) && !found.expanded) {
377
+ autoExpandDoneRef.current.add(expandOnceKey);
378
+ void fetchAndExpandNode(found, false, false);
379
+ }
380
+ break;
381
+ }
382
+ }, [
383
+ nodes,
384
+ isProcessing,
385
+ searchId,
386
+ autoExpandMatchTitles,
387
+ nowPlayingKey,
388
+ fetchAndExpandNode,
389
+ setSelectedNode,
390
+ setSelectedLink,
391
+ setContextMenu
392
+ ]);
244
393
 
245
394
  useEffect(() => {
246
395
  const handlePopState = () => {
@@ -251,10 +400,79 @@ const App: React.FC<AppProps> = ({
251
400
  return () => window.removeEventListener('popstate', handlePopState);
252
401
  }, [setPeopleBrowserOpen]);
253
402
 
403
+ const handoffSelectionRestored = useRef(false);
404
+ useEffect(() => {
405
+ if (!initialSession?.selectedNodeId || handoffSelectionRestored.current) return;
406
+ const id = initialSession.selectedNodeId;
407
+ const n = nodes.find((x) => String(x.id) === String(id));
408
+ if (n) {
409
+ setSelectedNode(n);
410
+ handoffSelectionRestored.current = true;
411
+ }
412
+ }, [initialSession, nodes, setSelectedNode]);
413
+
414
+ useEffect(() => {
415
+ if (!initialSession) return;
416
+ if (!nodes.length) return;
417
+ skipPlayerBootstrapRef.current = false;
418
+ }, [initialSession, nodes.length]);
419
+
420
+ useEffect(() => {
421
+ if (!embedded) {
422
+ return undefined;
423
+ }
424
+ (window as any).__soundingsConstellationsGetHandoff = () => {
425
+ try {
426
+ return buildHandoffFromLiveState({
427
+ graph: graphDataRef.current,
428
+ exploreTerm,
429
+ pathStart,
430
+ pathEnd,
431
+ searchMode,
432
+ isCompact,
433
+ isTimelineMode,
434
+ isTextOnly,
435
+ searchId,
436
+ lockedPair,
437
+ pathNodeIds,
438
+ selectedNodeId: selectedNode?.id
439
+ });
440
+ } catch {
441
+ return null;
442
+ }
443
+ };
444
+ return () => {
445
+ try { delete (window as any).__soundingsConstellationsGetHandoff; } catch { /* empty */ }
446
+ };
447
+ }, [
448
+ embedded,
449
+ graphData,
450
+ exploreTerm,
451
+ pathStart,
452
+ pathEnd,
453
+ searchMode,
454
+ isCompact,
455
+ isTimelineMode,
456
+ isTextOnly,
457
+ searchId,
458
+ lockedPair,
459
+ pathNodeIds,
460
+ selectedNode
461
+ ]);
462
+
463
+ const handlePathSearchRef = useRef(handlePathSearch);
464
+ useEffect(() => {
465
+ handlePathSearchRef.current = handlePathSearch;
466
+ }, [handlePathSearch]);
467
+
254
468
  // Auto-start search if ?q= parameter is present in URL
255
469
  const urlQueryProcessedRef = useRef(false);
256
470
  useEffect(() => {
257
471
  if (urlQueryProcessedRef.current) return;
472
+ if (skipPlayerBootstrapRef.current) {
473
+ urlQueryProcessedRef.current = true;
474
+ return;
475
+ }
258
476
 
259
477
  const params = new URLSearchParams(window.location.search);
260
478
  const queryParam = params.get('q');
@@ -263,15 +481,15 @@ const App: React.FC<AppProps> = ({
263
481
 
264
482
  if (queryParam && isKeyReady && nodes.length === 0) {
265
483
  urlQueryProcessedRef.current = true;
266
- handleStartSearch(queryParam);
484
+ handleStartSearchRef.current(queryParam);
267
485
  } else if (startParam && endParam && isKeyReady && nodes.length === 0) {
268
486
  urlQueryProcessedRef.current = true;
269
487
  setSearchMode('connect');
270
488
  setPathStart(startParam);
271
489
  setPathEnd(endParam);
272
- handlePathSearch(startParam, endParam);
490
+ handlePathSearchRef.current(startParam, endParam);
273
491
  }
274
- }, [isKeyReady, nodes.length, handleStartSearch, handlePathSearch, setSearchMode, setPathStart, setPathEnd]);
492
+ }, [isKeyReady, nodes.length, setSearchMode, setPathStart, setPathEnd]);
275
493
 
276
494
  const applyGraphData = useCallback((data: any, sourceLabel: string) => {
277
495
  try {
@@ -311,7 +529,7 @@ const App: React.FC<AppProps> = ({
311
529
 
312
530
  if (!isKeyReady) {
313
531
  return (
314
- <div className="flex flex-col items-center justify-center w-screen h-screen bg-slate-900 text-white space-y-6">
532
+ <div className={`flex flex-col items-center justify-center bg-slate-900 text-white space-y-6 ${embedded ? "h-full w-full" : "h-screen w-screen"}`}>
315
533
  <h1 className="text-4xl font-bold bg-gradient-to-r from-indigo-400 via-purple-400 to-cyan-400 bg-clip-text text-transparent">Constellations</h1>
316
534
  <button onClick={async () => { if ((window as any).aistudio) { await (window as any).aistudio.openSelectKey(); setIsKeyReady(true); } }} className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-xl font-medium transition-all hover:scale-105">
317
535
  <Key size={20} className="inline mr-2" /> Select API Key
@@ -320,9 +538,70 @@ const App: React.FC<AppProps> = ({
320
538
  );
321
539
  }
322
540
 
541
+ // Fresh graph row for the open context menu (stale `node` would miss isLoading/expanded updates).
542
+ const contextMenuNodeLive: GraphNode | null = contextMenu
543
+ ? (nodes.find((n) => String(n.id) === String(contextMenu.node.id)) ?? contextMenu.node)
544
+ : null;
545
+
546
+ // Match `pt-14` on the main column: avoid sizing the graph to full window height, which
547
+ // makes the SVG overflow under the bar and steal clicks. AppHeader is `absolute` inside this
548
+ // root (not `fixed` to the viewport) so the whole UI shares one stacking context with the graph.
549
+ const HEADER_PX = 56;
550
+ const graphWidth = dimensions.width;
551
+ const graphHeight = hideHeader
552
+ ? dimensions.height
553
+ : Math.max(200, dimensions.height - HEADER_PX);
554
+
555
+ /**
556
+ * In-flow (absolute) panels: positioned relative to `main` — the host’s nav (e.g. Trailer h-11)
557
+ * is *outside* the constellations root, so `top-14` (below the in-root header) is enough.
558
+ * When `hideHeader`, there is no in-app bar — use `top-2` and pin the control rail with
559
+ * `constrainToParentHeight` (see `ControlPanel`) so we do not reserve a fake 56px gap.
560
+ *
561
+ * `position: fixed` panels (Sidebar, people browser) use *viewport* coordinates. When embedded
562
+ * with an in-app header, we used `top-[6.25rem]` for host nav + const bar; with `hideHeader`
563
+ * embeds, align to the content box with `top-2` like the control bar.
564
+ */
565
+ const inHostSizedBox = embedded;
566
+ const headerOffsetClass = inHostSizedBox
567
+ ? "top-0"
568
+ : (hostNavOffsetPx > 0 ? "top-11" : "top-0");
569
+ /** No in-app `AppHeader`: `top-2` for both embedded and full-viewport (e.g. Soundings / Trailer) */
570
+ const inFlowPanelTopClass = inHostSizedBox
571
+ ? (hideHeader ? "top-2" : "top-14")
572
+ : (hostNavOffsetPx > 0 ? "top-[6.25rem]" : (hideHeader ? "top-2" : "top-14"));
573
+ /** Embedded + host bar (e.g. Soundings /player) uses `hostNavOffsetPx`; full-page overlay with only
574
+ * Constellations header uses `top-14` / `top-16` like non-embedded. */
575
+ const viewportFixedTopClass = inHostSizedBox
576
+ ? (hideHeader ? "top-2" : (hostNavOffsetPx > 0 ? "top-[6.25rem]" : "top-14"))
577
+ : (hostNavOffsetPx > 0 ? "top-[6.25rem]" : (hideHeader ? "top-2" : "top-14"));
578
+ const peopleBrowserFixedTopClass = inHostSizedBox
579
+ ? (hideHeader ? "top-2" : (hostNavOffsetPx > 0 ? "top-28" : "top-16"))
580
+ : (hostNavOffsetPx > 0 ? "top-[6.25rem]" : (hideHeader ? "top-2" : "top-16"));
581
+ /** In-layout absolute rails (Tight embed under host chrome). Full-viewport overlay uses `fixed` + max-h. */
582
+ const useViewportPanels = Boolean(embedded && useViewportForPanels);
583
+ const controlPanelOffsetClass = hideHeader
584
+ ? "top-2 bottom-2"
585
+ : inFlowPanelTopClass;
586
+ const controlPanelConstrainToParent = hideHeader && !hideControlPanel && !useViewportPanels;
587
+ const sidebarOffsetClass = useViewportPanels
588
+ ? viewportFixedTopClass
589
+ : (embedded ? inFlowPanelTopClass : viewportFixedTopClass);
590
+ const peopleBrowserOffsetClass = useViewportPanels
591
+ ? peopleBrowserFixedTopClass
592
+ : (embedded ? inFlowPanelTopClass : peopleBrowserFixedTopClass);
593
+ const sidePanelUseAbsolute = embedded && !useViewportPanels;
594
+ const showExtensionControls =
595
+ hideControlPanel && showExtensionWhenPanelHidden;
596
+
323
597
  return (
324
- <div className="w-screen h-screen bg-slate-950 overflow-hidden font-sans text-slate-200 selection:bg-indigo-500/30">
325
- {hideControlPanel && (
598
+ <div
599
+ ref={embedded ? (n) => setGraphHostEl(n) : undefined}
600
+ className={`${
601
+ embedded ? "relative w-full h-full" : "relative h-screen w-screen"
602
+ } bg-slate-950 overflow-hidden font-sans text-slate-200 selection:bg-indigo-500/30`}
603
+ >
604
+ {showExtensionControls && (
326
605
  <ExtensionControls
327
606
  isTimelineMode={isTimelineMode}
328
607
  onToggle={setIsTimelineMode}
@@ -338,41 +617,38 @@ const App: React.FC<AppProps> = ({
338
617
  <HelpOverlay
339
618
  isOpen={showHelp}
340
619
  onClose={() => setShowHelp(false)}
341
- isExtension={hideControlPanel}
620
+ isExtension={showExtensionControls}
342
621
  onOpenPeopleBrowser={handleOpenPeopleBrowser}
343
622
  />
344
- <AppHeader
345
- showHeader={!hideHeader}
346
- panelCollapsed={panelCollapsed}
347
- setPanelCollapsed={setPanelCollapsed}
348
- showBrowse={peopleBrowserOpen}
349
- handleOpenPeopleBrowser={handleOpenPeopleBrowser}
350
- selectedNode={selectedNode}
351
- sidebarCollapsed={sidebarCollapsed}
352
- setSidebarCollapsed={setSidebarCollapsed}
353
- setSidebarToggleSignal={setSidebarToggleSignal}
354
- onReset={handleClear}
355
- />
356
623
 
357
- <div className={`relative w-full h-full transition-all duration-500 ease-in-out ${!hideHeader ? 'pt-14' : ''}`}>
358
- <Graph
359
- ref={graphRef}
360
- nodes={nodes}
361
- links={links}
362
- onNodeClick={onNodeClick}
363
- onLinkClick={(link) => { setSelectedLink(link); setSelectedNode(null); setContextMenu(null); }}
364
- width={dimensions.width}
365
- height={dimensions.height}
366
- isCompact={isCompact}
367
- isTimelineMode={isTimelineMode}
368
- isTextOnly={isTextOnly}
369
- searchId={searchId}
370
- selectedNode={selectedNode}
371
- highlightKeepIds={deletePreview ? deletePreview.keepIds : pathNodeIds}
372
- highlightDropIds={deletePreview ? deletePreview.dropIds : []}
373
- expandingNodeId={expandingNodeId}
374
- newChildNodeIds={newChildNodeIds}
375
- />
624
+ <div
625
+ className={`relative z-0 w-full min-h-0 h-full transition-all duration-500 ease-in-out ${!hideHeader ? "pt-14" : ""}`}
626
+ >
627
+ <div className="pointer-events-auto relative z-0 min-h-0 w-full overflow-hidden" style={{ height: graphHeight, maxHeight: "100%" }}>
628
+ <Graph
629
+ ref={graphRef}
630
+ nodes={nodes}
631
+ links={links}
632
+ onNodeClick={onNodeClick}
633
+ onNodeContextMenu={handleNodeContextMenu}
634
+ onLinkClick={(link) => {
635
+ setSelectedLink(link);
636
+ setSelectedNode(null);
637
+ setContextMenu(null);
638
+ }}
639
+ width={graphWidth}
640
+ height={graphHeight}
641
+ isCompact={isCompact}
642
+ isTimelineMode={isTimelineMode}
643
+ isTextOnly={isTextOnly}
644
+ searchId={searchId}
645
+ selectedNode={selectedNode}
646
+ highlightKeepIds={deletePreview ? deletePreview.keepIds : pathNodeIds}
647
+ highlightDropIds={deletePreview ? deletePreview.dropIds : []}
648
+ expandingNodeId={expandingNodeId}
649
+ newChildNodeIds={newChildNodeIds}
650
+ />
651
+ </div>
376
652
 
377
653
 
378
654
  {!hideControlPanel && (
@@ -395,9 +671,16 @@ const App: React.FC<AppProps> = ({
395
671
  onUpdateKioskDomains={setKioskDomains}
396
672
  onClear={handleClear}
397
673
  onClearCache={cacheEnabled ? handleClearCache : undefined}
674
+ onExpandAllLeafNodes={handleExpandAllLeafNodes}
675
+ onSave={handleSaveGraph}
676
+ onLoad={(name) => handleLoadGraph(name, applyGraphData)}
677
+ onDeleteGraph={handleDeleteGraph}
678
+ onImport={(data) => handleImport(data, applyGraphData)}
679
+ savedGraphs={savedGraphs}
680
+ helpHover={helpHover}
681
+ onHelpHoverChange={setHelpHover}
398
682
  onToggleHelp={() => setShowHelp(!showHelp)}
399
683
  showHelp={showHelp}
400
- onExpandAllLeafNodes={handleExpandAllLeafNodes}
401
684
  isProcessing={isProcessing}
402
685
  isCompact={isCompact}
403
686
  onToggleCompact={() => setIsCompact(!isCompact)}
@@ -405,18 +688,13 @@ const App: React.FC<AppProps> = ({
405
688
  onToggleTimeline={() => setIsTimelineMode(!isTimelineMode)}
406
689
  isTextOnly={isTextOnly}
407
690
  onToggleTextOnly={() => setIsTextOnly(!isTextOnly)}
408
- onPrune={handlePrune}
409
- error={error}
410
- onSave={handleSaveGraph}
411
- onLoad={(name) => handleLoadGraph(name, applyGraphData)}
412
- onDeleteGraph={handleDeleteGraph}
413
- onImport={(e) => handleImport(e, applyGraphData)}
414
- savedGraphs={savedGraphs}
415
- helpHover={helpHover}
416
- onHelpHoverChange={setHelpHover}
417
691
  isCollapsed={panelCollapsed}
692
+ settingsHref={settingsHref}
418
693
  onSetCollapsed={setPanelCollapsed}
419
694
  onOpenPeopleBrowser={handleOpenPeopleBrowser}
695
+ offsetTopClass={controlPanelOffsetClass}
696
+ constrainToParentHeight={controlPanelConstrainToParent}
697
+ pinToViewport={useViewportPanels}
420
698
  />
421
699
  )}
422
700
 
@@ -428,6 +706,8 @@ const App: React.FC<AppProps> = ({
428
706
  onCollapseChange={setSidebarCollapsed}
429
707
  externalToggleSignal={sidebarToggleSignal}
430
708
  isAdminMode={isAdminMode}
709
+ useAbsoluteLayout={sidePanelUseAbsolute}
710
+ offsetTopClass={sidebarOffsetClass}
431
711
  />
432
712
  )}
433
713
 
@@ -436,6 +716,8 @@ const App: React.FC<AppProps> = ({
436
716
  <Suspense fallback={null}>
437
717
  <PeopleBrowserSidebar
438
718
  isOpen={peopleBrowserOpen}
719
+ useAbsoluteLayout={sidePanelUseAbsolute}
720
+ offsetTopClass={peopleBrowserOffsetClass}
439
721
  onClose={() => setPeopleBrowserOpen(false)}
440
722
  onSelectPerson={(name) => {
441
723
  setExploreTerm(name);
@@ -449,14 +731,15 @@ const App: React.FC<AppProps> = ({
449
731
  />
450
732
  </Suspense>
451
733
 
452
- {contextMenu && (
734
+ {contextMenu && contextMenuNodeLive && (
453
735
  <NodeContextMenu
454
- node={contextMenu.node}
736
+ node={contextMenuNodeLive}
455
737
  x={contextMenu.x}
456
738
  y={contextMenu.y}
457
739
  onExpandLeaves={handleExpandLeaves}
458
740
  onAddMore={handleExpandMore}
459
741
  onFindBetterPhoto={handleFindBetterImage}
742
+ onNewChannelFromNode={onNewChannelFromNode}
460
743
  onDelete={handleSmartDelete}
461
744
  onClose={() => setContextMenu(null)}
462
745
  isProcessing={isProcessing}
@@ -473,6 +756,17 @@ const App: React.FC<AppProps> = ({
473
756
  />
474
757
  </div>
475
758
 
759
+ <AppHeader
760
+ showHeader={!hideHeader}
761
+ panelCollapsed={panelCollapsed}
762
+ setPanelCollapsed={setPanelCollapsed}
763
+ selectedNode={selectedNode}
764
+ sidebarCollapsed={sidebarCollapsed}
765
+ setSidebarToggleSignal={setSidebarToggleSignal}
766
+ onClose={onClose}
767
+ closeHref={closeHref}
768
+ offsetTopClass={headerOffsetClass}
769
+ />
476
770
  </div>
477
771
  );
478
772
  };
@@ -6,13 +6,16 @@ const App = lazy(() => import("./App"));
6
6
  type AppProps = ComponentProps<typeof App>;
7
7
 
8
8
  /**
9
- * Embeddable full-page constellations for host apps (Soundings, Trailer Vision, etc.).
10
- * Always renders in embedded mode (ResizeObserver, handoff window hook) with full chrome.
9
+ * One code path for "full page" constellations inside a host (Soundings, Trailer Vision, etc.):
10
+ * always `embedded` (ResizeObserver, handoff window hook) + full control panel + details sidebar
11
+ * unless the host overrides. The in-app `AppHeader` (panel toggles, title, close) is shown by default;
12
+ * set `hideHeader` when the host already supplies equivalent chrome.
11
13
  */
12
14
  export type FullPageConstellationsProps = Omit<AppProps, "embedded" | "useViewportForPanels"> & {
13
15
  /**
14
- * - `fixed-overlay` — full-viewport layer above the app (e.g. Soundings, `z-[100]`).
15
- * - `below-app-chrome` — fills route shell below site nav; parent must supply height.
16
+ * - `fixed-overlay` — e.g. Soundings: full-viewport layer above the app (`z-[100]`).
17
+ * - `below-app-chrome` — e.g. Trailer: fills the route shell below the site nav; parent must
18
+ * supply height (e.g. `h-[calc(100dvh-2.75rem)]`).
16
19
  */
17
20
  layout: "fixed-overlay" | "below-app-chrome";
18
21
  /** Wrapped around the constellations root; use for a host nav link row, etc. */
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import React from 'react';
2
3
 
3
4
  interface AppConfirmDialogProps {