@johndimm/constellations 1.0.0 → 1.0.2

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 (39) hide show
  1. package/App.tsx +352 -70
  2. package/FullPageConstellations.tsx +7 -5
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +69 -29
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +46 -371
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +1 -0
  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/embedded.css +38 -0
  15. package/hooks/useExpansion.ts +61 -229
  16. package/hooks/useGraphActions.ts +1 -0
  17. package/hooks/useGraphState.ts +75 -40
  18. package/hooks/useKioskMode.ts +1 -0
  19. package/hooks/useNodeClickHandler.ts +23 -15
  20. package/hooks/useSearchHandlers.ts +57 -19
  21. package/host.ts +1 -1
  22. package/index.css +17 -3
  23. package/package.json +4 -1
  24. package/services/aiService.ts +23 -0
  25. package/services/aiUtils.ts +216 -207
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +467 -0
  29. package/services/geminiService.ts +532 -733
  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 +56 -46
  36. package/types.ts +3 -0
  37. package/utils/evidenceUtils.ts +1 -0
  38. package/utils/graphLogicUtils.ts +1 -0
  39. 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);
317
+ useEffect(() => {
318
+ handleStartSearchRef.current = handleStartSearch;
319
+ }, [handleStartSearch]);
320
+ const onExternalSearchConsumedRef = useRef(onExternalSearchConsumed);
321
+ useEffect(() => {
322
+ onExternalSearchConsumedRef.current = onExternalSearchConsumed;
323
+ }, [onExternalSearchConsumed]);
324
+
239
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 && (
@@ -393,11 +669,6 @@ const App: React.FC<AppProps> = ({
393
669
  selectedKioskDomainId={selectedKioskDomainId}
394
670
  onSelectKioskDomain={(id) => { setSelectedKioskDomainId(id); setPathStart(''); setPathEnd(''); }}
395
671
  onUpdateKioskDomains={setKioskDomains}
396
- onClear={handleClear}
397
- onClearCache={cacheEnabled ? handleClearCache : undefined}
398
- onToggleHelp={() => setShowHelp(!showHelp)}
399
- showHelp={showHelp}
400
- onExpandAllLeafNodes={handleExpandAllLeafNodes}
401
672
  isProcessing={isProcessing}
402
673
  isCompact={isCompact}
403
674
  onToggleCompact={() => setIsCompact(!isCompact)}
@@ -405,18 +676,13 @@ const App: React.FC<AppProps> = ({
405
676
  onToggleTimeline={() => setIsTimelineMode(!isTimelineMode)}
406
677
  isTextOnly={isTextOnly}
407
678
  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
679
  isCollapsed={panelCollapsed}
680
+ settingsHref={settingsHref}
418
681
  onSetCollapsed={setPanelCollapsed}
419
682
  onOpenPeopleBrowser={handleOpenPeopleBrowser}
683
+ offsetTopClass={controlPanelOffsetClass}
684
+ constrainToParentHeight={controlPanelConstrainToParent}
685
+ pinToViewport={useViewportPanels}
420
686
  />
421
687
  )}
422
688
 
@@ -428,6 +694,8 @@ const App: React.FC<AppProps> = ({
428
694
  onCollapseChange={setSidebarCollapsed}
429
695
  externalToggleSignal={sidebarToggleSignal}
430
696
  isAdminMode={isAdminMode}
697
+ useAbsoluteLayout={sidePanelUseAbsolute}
698
+ offsetTopClass={sidebarOffsetClass}
431
699
  />
432
700
  )}
433
701
 
@@ -436,6 +704,8 @@ const App: React.FC<AppProps> = ({
436
704
  <Suspense fallback={null}>
437
705
  <PeopleBrowserSidebar
438
706
  isOpen={peopleBrowserOpen}
707
+ useAbsoluteLayout={sidePanelUseAbsolute}
708
+ offsetTopClass={peopleBrowserOffsetClass}
439
709
  onClose={() => setPeopleBrowserOpen(false)}
440
710
  onSelectPerson={(name) => {
441
711
  setExploreTerm(name);
@@ -449,14 +719,15 @@ const App: React.FC<AppProps> = ({
449
719
  />
450
720
  </Suspense>
451
721
 
452
- {contextMenu && (
722
+ {contextMenu && contextMenuNodeLive && (
453
723
  <NodeContextMenu
454
- node={contextMenu.node}
724
+ node={contextMenuNodeLive}
455
725
  x={contextMenu.x}
456
726
  y={contextMenu.y}
457
727
  onExpandLeaves={handleExpandLeaves}
458
728
  onAddMore={handleExpandMore}
459
729
  onFindBetterPhoto={handleFindBetterImage}
730
+ onNewChannelFromNode={onNewChannelFromNode}
460
731
  onDelete={handleSmartDelete}
461
732
  onClose={() => setContextMenu(null)}
462
733
  isProcessing={isProcessing}
@@ -473,6 +744,17 @@ const App: React.FC<AppProps> = ({
473
744
  />
474
745
  </div>
475
746
 
747
+ <AppHeader
748
+ showHeader={!hideHeader}
749
+ panelCollapsed={panelCollapsed}
750
+ setPanelCollapsed={setPanelCollapsed}
751
+ selectedNode={selectedNode}
752
+ sidebarCollapsed={sidebarCollapsed}
753
+ setSidebarToggleSignal={setSidebarToggleSignal}
754
+ onClose={onClose}
755
+ closeHref={closeHref}
756
+ offsetTopClass={headerOffsetClass}
757
+ />
476
758
  </div>
477
759
  );
478
760
  };
@@ -1,19 +1,21 @@
1
1
  "use client";
2
2
  import { lazy, Suspense, type ComponentProps, type FC, type ReactNode } from "react";
3
- import "./index.css";
4
3
 
5
4
  const App = lazy(() => import("./App"));
6
5
 
7
6
  type AppProps = ComponentProps<typeof App>;
8
7
 
9
8
  /**
10
- * Embeddable full-page constellations for host apps (Soundings, Trailer Vision, etc.).
11
- * 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.
12
13
  */
13
14
  export type FullPageConstellationsProps = Omit<AppProps, "embedded" | "useViewportForPanels"> & {
14
15
  /**
15
- * - `fixed-overlay` — full-viewport layer above the app (e.g. Soundings, `z-[100]`).
16
- * - `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)]`).
17
19
  */
18
20
  layout: "fixed-overlay" | "below-app-chrome";
19
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 {