release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:56:50 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187059 additions and 14005 deletions
+251
View File
@@ -0,0 +1,251 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { API_BASE } from '@/lib/api';
import { NOMINATIM_DEBOUNCE_MS } from '@/lib/constants';
/* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */
export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, lng: number) => void; onOpenChange?: (open: boolean) => void }) {
const [open, setOpen] = useState(false);
useEffect(() => { onOpenChange?.(open); }, [open]);
const [value, setValue] = useState('');
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchAbortRef = useRef<AbortController | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
// Close when clicking outside
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setValue('');
setResults([]);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Parse raw coordinate input: "31.8, 34.8" or "31.8 34.8" or "-12.3, 45.6"
const parseCoords = (s: string): { lat: number; lng: number } | null => {
const m = s.trim().match(/^([+-]?\d+\.?\d*)[,\s]+([+-]?\d+\.?\d*)$/);
if (!m) return null;
const lat = parseFloat(m[1]),
lng = parseFloat(m[2]);
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) return { lat, lng };
return null;
};
const handleSearch = async (q: string) => {
setValue(q);
// Check for raw coordinates first
const coords = parseCoords(q);
if (coords) {
setResults([{ label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, ...coords }]);
return;
}
// Geocode with Nominatim (debounced)
if (timerRef.current) clearTimeout(timerRef.current);
if (searchAbortRef.current) searchAbortRef.current.abort();
if (q.trim().length < 2) {
setResults([]);
return;
}
timerRef.current = setTimeout(async () => {
setLoading(true);
searchAbortRef.current = new AbortController();
const signal = searchAbortRef.current.signal;
try {
// Try backend proxy first (has caching + rate-limit compliance)
const res = await fetch(
`${API_BASE}/api/geocode/search?q=${encodeURIComponent(q)}&limit=5`,
{ signal },
);
if (res.ok) {
const data = await res.json();
const mapped = (data?.results || []).map(
(r: { label: string; lat: number; lng: number }) => ({
label: r.label,
lat: r.lat,
lng: r.lng,
}),
);
setResults(mapped);
} else {
// Backend proxy returned an error — fall back to direct Nominatim
console.warn(`[Locate] Proxy returned HTTP ${res.status}, falling back to Nominatim`);
const directRes = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
{ headers: { 'Accept-Language': 'en' }, signal },
);
const data = await directRes.json();
setResults(
data.map((r: { display_name: string; lat: string; lon: string }) => ({
label: r.display_name,
lat: parseFloat(r.lat),
lng: parseFloat(r.lon),
})),
);
}
} catch (err) {
if ((err as Error)?.name !== 'AbortError') {
// Proxy completely failed — try direct Nominatim as last resort
try {
const directRes = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
{ headers: { 'Accept-Language': 'en' } },
);
const data = await directRes.json();
setResults(
data.map((r: { display_name: string; lat: string; lon: string }) => ({
label: r.display_name,
lat: parseFloat(r.lat),
lng: parseFloat(r.lon),
})),
);
} catch {
setResults([]);
}
} else {
setResults([]);
}
} finally {
setLoading(false);
}
}, NOMINATIM_DEBOUNCE_MS);
};
const handleSelect = (r: { lat: number; lng: number }) => {
onLocate(r.lat, r.lng);
setOpen(false);
setValue('');
setResults([]);
};
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-2 bg-[var(--bg-primary)]/80 border border-[var(--border-primary)] px-5 py-2 text-[11px] font-mono tracking-[0.15em] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-800 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
LOCATE
</button>
);
}
return (
<div ref={containerRef} className="relative w-[520px]">
<div className="flex items-center gap-2 bg-[var(--bg-primary)] border border-cyan-800/60 px-4 py-2.5 shadow-[0_0_20px_rgba(0,255,255,0.1)]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-cyan-500 flex-shrink-0"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={inputRef}
value={value}
onChange={(e) => handleSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false);
setValue('');
setResults([]);
}
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0]);
}}
placeholder="Enter coordinates (31.8, 34.8) or place name..."
className="flex-1 bg-transparent text-[12px] text-[var(--text-primary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
/>
{loading && (
<div className="w-3 h-3 border border-cyan-500 border-t-transparent rounded-full animate-spin" />
)}
<button
onClick={() => {
setOpen(false);
setValue('');
setResults([]);
}}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
{results.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] overflow-hidden shadow-[0_-8px_30px_rgba(0,0,0,0.4)] max-h-[200px] overflow-y-auto styled-scrollbar">
{results.map((r, i) => (
<button
key={i}
onClick={() => handleSelect(r)}
className="w-full text-left px-3 py-2 hover:bg-cyan-950/40 transition-colors border-b border-[var(--border-primary)]/50 last:border-0 flex items-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-cyan-500 flex-shrink-0"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span className="text-[11px] text-[var(--text-secondary)] font-mono truncate">
{r.label}
</span>
</button>
))}
</div>
)}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
/* ── SENTINEL HUB — first-time info modal ── */
export function SentinelInfoModal({ onClose }: { onClose: () => void }) {
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/90"
onClick={onClose}
/>
<div className="relative z-[10001] w-[520px] max-h-[80vh] bg-[var(--bg-secondary)] border border-purple-500/30 shadow-2xl shadow-purple-900/20 overflow-y-auto styled-scrollbar">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold tracking-wider text-purple-300 font-mono">
SENTINEL HUB IMAGERY
</h2>
<button
onClick={onClose}
className="text-[var(--text-muted)] hover:text-white transition-colors text-xl leading-none"
>
&times;
</button>
</div>
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed">
You now have access to ESA Sentinel-2 satellite imagery directly on the map.
This uses the Copernicus Data Space Ecosystem with your own credentials.
</p>
<div className="space-y-2">
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">AVAILABLE LAYERS</h3>
<div className="grid grid-cols-2 gap-2">
{[
{ name: 'True Color', desc: 'Natural RGB — see terrain, cities, water' },
{ name: 'False Color IR', desc: 'Near-infrared — vegetation in red' },
{ name: 'NDVI', desc: 'Vegetation health index (green = healthy)' },
{ name: 'Moisture Index', desc: 'Soil & vegetation moisture levels' },
].map((l) => (
<div key={l.name} className="p-2 border border-purple-900/30 bg-purple-950/10">
<div className="text-[10px] font-mono text-white">{l.name}</div>
<div className="text-[9px] text-[var(--text-muted)]">{l.desc}</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">USAGE LIMITS (FREE TIER)</h3>
<div className="p-3 border border-[var(--border-primary)] bg-[var(--bg-primary)]/40 space-y-1.5">
<div className="flex justify-between text-[10px] font-mono">
<span className="text-[var(--text-muted)]">Monthly budget</span>
<span className="text-purple-300">10,000 requests</span>
</div>
<div className="flex justify-between text-[10px] font-mono">
<span className="text-[var(--text-muted)]">Cost per tile</span>
<span className="text-purple-300">0.25 PU (256&times;256px)</span>
</div>
<div className="flex justify-between text-[10px] font-mono">
<span className="text-[var(--text-muted)]">~Viewport loads/month</span>
<span className="text-purple-300">~500 (20 tiles each)</span>
</div>
<div className="flex justify-between text-[10px] font-mono">
<span className="text-[var(--text-muted)]">Empty tiles</span>
<span className="text-green-400">FREE (no data = no charge)</span>
</div>
</div>
</div>
<div className="space-y-2">
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">HOW IT WORKS</h3>
<ul className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed space-y-1 list-disc list-inside">
<li>Sentinel-2 revisits every ~5 days not every location has data every day</li>
<li>The date slider picks the end of a time window; zoomed out uses wider windows</li>
<li>Black patches = no satellite pass on that date range (normal)</li>
<li>Best results at zoom 8-14 closer = sharper imagery (10m resolution)</li>
<li>Cloud filter auto-skips tiles with {'>'} 30% cloud cover</li>
</ul>
</div>
<button
onClick={onClose}
className="w-full py-2.5 bg-purple-500/20 border border-purple-500/40 text-purple-300 hover:bg-purple-500/30 transition-colors text-[11px] font-mono tracking-wider"
>
GOT IT
</button>
</div>
</div>
</div>
);
}
+4
View File
@@ -60,6 +60,10 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean {
if (joined === 'system/update') return true;
if (pathSegments[0] === 'settings') return true;
if (joined === 'mesh/infonet/ingest') return true;
// mesh/peers and all tools/* use require_local_operator on the backend and
// need X-Admin-Key injected on the server-side proxy leg.
if (pathSegments[0] === 'mesh' && pathSegments[1] === 'peers') return true;
if (pathSegments[0] === 'tools') return true;
return false;
}
+4 -6
View File
@@ -12,13 +12,11 @@ const NO_STORE_HEADERS = {
Pragma: 'no-cache',
};
function cookieOptions(req: NextRequest) {
const host = req.headers.get('host') ?? '';
const isLoopback = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
function cookieOptions() {
return {
httpOnly: true,
sameSite: 'strict' as const,
secure: process.env.NODE_ENV === 'production' && !isLoopback,
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: COOKIE_MAX_AGE,
};
@@ -82,7 +80,7 @@ export async function POST(req: NextRequest) {
}
const sessionToken = createAdminSessionToken(adminKey, COOKIE_MAX_AGE);
const res = NextResponse.json({ ok: true }, { headers: NO_STORE_HEADERS });
res.cookies.set(COOKIE_NAME, sessionToken, cookieOptions(req));
res.cookies.set(COOKIE_NAME, sessionToken, cookieOptions());
return res;
}
@@ -93,7 +91,7 @@ export async function DELETE(req: NextRequest) {
}
const res = NextResponse.json({ ok: true }, { headers: NO_STORE_HEADERS });
res.cookies.set(COOKIE_NAME, '', {
...cookieOptions(req),
...cookieOptions(),
maxAge: 0,
});
return res;
+70 -6
View File
@@ -136,19 +136,19 @@ textarea:disabled {
background: rgba(10, 14, 20, 0.96);
border-radius: 2px;
border: 1px solid rgba(8, 145, 178, 0.35);
padding: 10px 14px;
padding: 12px 16px;
color: #d1d5db;
font-family:
'JetBrains Mono', var(--font-roboto-mono), 'Roboto Mono', monospace, 'Microsoft YaHei', 'PingFang SC',
'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', sans-serif;
font-size: 11px;
min-width: 220px;
max-width: 320px;
font-size: 13px;
min-width: 240px;
max-width: 380px;
}
.map-popup-title {
font-weight: 700;
font-size: 13px;
font-size: 16px;
margin-bottom: 6px;
letter-spacing: 0.1em;
text-transform: uppercase;
@@ -159,12 +159,18 @@ textarea:disabled {
}
.map-popup-subtitle {
font-size: 9px;
font-size: 12px;
margin-bottom: 6px;
letter-spacing: 1.5px;
text-transform: uppercase;
}
/* Hide MapLibre logo & attribution */
.maplibregl-ctrl-logo,
.maplibregl-ctrl-attrib {
display: none !important;
}
/* MapLibre Popup Overrides */
.maplibregl-popup-content {
background: transparent !important;
@@ -458,3 +464,61 @@ textarea:disabled {
.crt ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* ── THREAT ALERT ACCESSIBILITY PATTERNS ── */
/* Border patterns for color-blind differentiation beyond hue */
.threat-border-critical {
border-width: 3px !important;
border-style: solid !important;
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4), inset 0 0 12px rgba(239, 68, 68, 0.1);
animation: threat-pulse-critical 2s ease-in-out infinite;
}
.threat-border-high {
border-width: 2px !important;
border-style: solid !important;
}
.threat-border-medium {
border-width: 2px !important;
border-style: dashed !important;
}
.threat-border-low {
border-width: 1px !important;
border-style: dotted !important;
}
@keyframes threat-pulse-critical {
0%, 100% {
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4), inset 0 0 12px rgba(239, 68, 68, 0.1);
}
50% {
box-shadow: 0 0 20px rgba(239, 68, 68, 0.6), inset 0 0 20px rgba(239, 68, 68, 0.15);
}
}
/* ── FEED HEALTH INDICATOR DOTS ── */
.feed-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
margin-right: 3px;
vertical-align: middle;
}
.feed-dot-healthy {
background: #22c55e;
box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
}
.feed-dot-stale {
background: #eab308;
box-shadow: 0 0 4px rgba(234, 179, 8, 0.5);
}
.feed-dot-offline {
background: #ef4444;
box-shadow: 0 0 4px rgba(239, 68, 68, 0.5);
}
+192 -369
View File
@@ -11,7 +11,7 @@ import MarketsPanel from '@/components/MarketsPanel';
import FilterPanel from '@/components/FilterPanel';
import FindLocateBar from '@/components/FindLocateBar';
import TopRightControls from '@/components/TopRightControls';
import PredictionsPanel from '@/components/PredictionsPanel';
import TimelinePanel from '@/components/TimelinePanel';
import SettingsPanel from '@/components/SettingsPanel';
import MapLegend from '@/components/MapLegend';
import ScaleBar from '@/components/ScaleBar';
@@ -19,19 +19,28 @@ import MeshTerminal from '@/components/MeshTerminal';
import MeshChat from '@/components/MeshChat';
import InfonetTerminal from '@/components/InfonetTerminal';
import { leaveWormhole, fetchWormholeState } from '@/mesh/wormholeClient';
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
import ShodanPanel from '@/components/ShodanPanel';
import AIIntelPanel from '@/components/AIIntelPanel';
import GlobalTicker from '@/components/GlobalTicker';
import ErrorBoundary from '@/components/ErrorBoundary';
import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal';
import ChangelogModal, { useChangelog } from '@/components/ChangelogModal';
import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard';
import type { ShodanSearchMatch } from '@/types/shodan';
import { NOMINATIM_DEBOUNCE_MS } from '@/lib/constants';
import { API_BASE } from '@/lib/api';
import { useDataPolling, LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
import { useBackendStatus, useDataKey } from '@/hooks/useDataStore';
import { useReverseGeocode } from '@/hooks/useReverseGeocode';
import { useRegionDossier } from '@/hooks/useRegionDossier';
import { useAgentActions } from '@/hooks/useAgentActions';
import { useFeedHealth } from '@/hooks/useFeedHealth';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import KeyboardShortcutsOverlay from '@/components/KeyboardShortcutsOverlay';
import AlertToast from '@/components/AlertToast';
import { useAlertToasts } from '@/hooks/useAlertToasts';
import { useWatchlist } from '@/hooks/useWatchlist';
import WatchlistWidget from '@/components/WatchlistWidget';
import {
requestSecureMeshTerminalLauncherOpen,
subscribeMeshTerminalOpen,
@@ -40,257 +49,15 @@ import {
hasSentinelInfoBeenSeen,
markSentinelInfoSeen,
hasSentinelCredentials,
getSentinelUsage,
} from '@/lib/sentinelHub';
import { LocateBar } from './LocateBar';
import { SentinelInfoModal } from './SentinelInfoModal';
import SarAoiEditorModal from '@/components/SarAoiEditorModal';
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
/* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */
function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, lng: number) => void; onOpenChange?: (open: boolean) => void }) {
const [open, setOpen] = useState(false);
useEffect(() => { onOpenChange?.(open); }, [open]);
const [value, setValue] = useState('');
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchAbortRef = useRef<AbortController | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
// Close when clicking outside
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setValue('');
setResults([]);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Parse raw coordinate input: "31.8, 34.8" or "31.8 34.8" or "-12.3, 45.6"
const parseCoords = (s: string): { lat: number; lng: number } | null => {
const m = s.trim().match(/^([+-]?\d+\.?\d*)[,\s]+([+-]?\d+\.?\d*)$/);
if (!m) return null;
const lat = parseFloat(m[1]),
lng = parseFloat(m[2]);
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) return { lat, lng };
return null;
};
const handleSearch = async (q: string) => {
setValue(q);
// Check for raw coordinates first
const coords = parseCoords(q);
if (coords) {
setResults([{ label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, ...coords }]);
return;
}
// Geocode with Nominatim (debounced)
if (timerRef.current) clearTimeout(timerRef.current);
if (searchAbortRef.current) searchAbortRef.current.abort();
if (q.trim().length < 2) {
setResults([]);
return;
}
timerRef.current = setTimeout(async () => {
setLoading(true);
searchAbortRef.current = new AbortController();
const signal = searchAbortRef.current.signal;
try {
// Try backend proxy first (has caching + rate-limit compliance)
const res = await fetch(
`${API_BASE}/api/geocode/search?q=${encodeURIComponent(q)}&limit=5`,
{ signal },
);
if (res.ok) {
const data = await res.json();
const mapped = (data?.results || []).map(
(r: { label: string; lat: number; lng: number }) => ({
label: r.label,
lat: r.lat,
lng: r.lng,
}),
);
setResults(mapped);
} else {
// Backend proxy returned an error — fall back to direct Nominatim
console.warn(`[Locate] Proxy returned HTTP ${res.status}, falling back to Nominatim`);
const directRes = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
{ headers: { 'Accept-Language': 'en' }, signal },
);
const data = await directRes.json();
setResults(
data.map((r: { display_name: string; lat: string; lon: string }) => ({
label: r.display_name,
lat: parseFloat(r.lat),
lng: parseFloat(r.lon),
})),
);
}
} catch (err) {
if ((err as Error)?.name !== 'AbortError') {
// Proxy completely failed — try direct Nominatim as last resort
try {
const directRes = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
{ headers: { 'Accept-Language': 'en' } },
);
const data = await directRes.json();
setResults(
data.map((r: { display_name: string; lat: string; lon: string }) => ({
label: r.display_name,
lat: parseFloat(r.lat),
lng: parseFloat(r.lon),
})),
);
} catch {
setResults([]);
}
} else {
setResults([]);
}
} finally {
setLoading(false);
}
}, NOMINATIM_DEBOUNCE_MS);
};
const handleSelect = (r: { lat: number; lng: number }) => {
onLocate(r.lat, r.lng);
setOpen(false);
setValue('');
setResults([]);
};
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-2 bg-[var(--bg-primary)]/80 border border-[var(--border-primary)] px-5 py-2 text-[11px] font-mono tracking-[0.15em] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-800 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
LOCATE
</button>
);
}
return (
<div ref={containerRef} className="relative w-[520px]">
<div className="flex items-center gap-2 bg-[var(--bg-primary)] border border-cyan-800/60 px-4 py-2.5 shadow-[0_0_20px_rgba(0,255,255,0.1)]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-cyan-500 flex-shrink-0"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={inputRef}
value={value}
onChange={(e) => handleSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false);
setValue('');
setResults([]);
}
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0]);
}}
placeholder="Enter coordinates (31.8, 34.8) or place name..."
className="flex-1 bg-transparent text-[12px] text-[var(--text-primary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
/>
{loading && (
<div className="w-3 h-3 border border-cyan-500 border-t-transparent rounded-full animate-spin" />
)}
<button
onClick={() => {
setOpen(false);
setValue('');
setResults([]);
}}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
{results.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] overflow-hidden shadow-[0_-8px_30px_rgba(0,0,0,0.4)] max-h-[200px] overflow-y-auto styled-scrollbar">
{results.map((r, i) => (
<button
key={i}
onClick={() => handleSelect(r)}
className="w-full text-left px-3 py-2 hover:bg-cyan-950/40 transition-colors border-b border-[var(--border-primary)]/50 last:border-0 flex items-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-cyan-500 flex-shrink-0"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span className="text-[11px] text-[var(--text-secondary)] font-mono truncate">
{r.label}
</span>
</button>
))}
</div>
)}
</div>
);
}
// LocateBar and SentinelInfoModal extracted to page-local modules (Sprint 4B)
export default function Dashboard() {
const viewBoundsRef = useRef<{ south: number; west: number; north: number; east: number } | null>(null);
@@ -303,6 +70,11 @@ export default function Dashboard() {
setSelectedEntity,
);
// Agent can push satellite imagery to the same full-screen viewer as right-click,
// and can fly the map to a point (e.g. sar_focus_aoi). The hook is invoked
// below — after setFlyToLocation is declared — so the fly_to callback can
// close over it without hitting a temporal dead zone.
const [uiVisible, setUiVisible] = useState(true);
const [leftOpen, setLeftOpen] = useState(true);
const [rightOpen, setRightOpen] = useState(true);
@@ -331,12 +103,15 @@ export default function Dashboard() {
}, [tickerOpen]);
const [settingsOpen, setSettingsOpen] = useState(false);
const [legendOpen, setLegendOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalLaunchToken, setTerminalLaunchToken] = useState(0);
const [infonetOpen, setInfonetOpen] = useState(false);
const [meshChatLaunchRequest, setMeshChatLaunchRequest] = useState<{
tab: 'infonet' | 'meshtastic' | 'dms';
gate?: string;
peerId?: string;
showSas?: boolean;
nonce: number;
} | null>(null);
const [dmCount, setDmCount] = useState(0);
@@ -344,6 +119,14 @@ export default function Dashboard() {
const [locateBarOpen, setLocateBarOpen] = useState(false);
const [measureMode, setMeasureMode] = useState(false);
const [measurePoints, setMeasurePoints] = useState<{ lat: number; lng: number }[]>([]);
const [pinPlacementMode, setPinPlacementMode] = useState(false);
// SAR AOI editor + map drop mode
const [sarAoiEditorOpen, setSarAoiEditorOpen] = useState(false);
const [sarAoiDropMode, setSarAoiDropMode] = useState(false);
const [sarAoiDroppedCoords, setSarAoiDroppedCoords] = useState<{ lat: number; lng: number } | null>(null);
const sarAoiListChangedRef = useRef(0);
const [sarAoiListVersion, setSarAoiListVersion] = useState(0);
const openMeshTerminal = useCallback(() => {
setTerminalOpen(true);
@@ -412,8 +195,18 @@ export default function Dashboard() {
global_incidents: true,
day_night: true,
correlations: true,
contradictions: true,
uap_sightings: true,
// Biosurveillance
wastewater: true,
// CrowdThreat
crowdthreat: true,
// Shodan
shodan_overlay: false,
// AI Intel
ai_intel: true,
// SAR (Synthetic Aperture Radar)
sar: true,
});
const [shodanResults, setShodanResults] = useState<ShodanSearchMatch[]>([]);
const [, setShodanQueryLabel] = useState('');
@@ -421,6 +214,33 @@ export default function Dashboard() {
useDataPolling();
const backendStatus = useBackendStatus();
const spaceWeather = useDataKey('space_weather');
const feedHealth = useFeedHealth();
// Global keyboard shortcuts
useKeyboardShortcuts({
toggleLeft: () => setLeftOpen((p) => !p),
toggleRight: () => setRightOpen((p) => !p),
toggleMarkets: () => setTickerOpen((p) => !p),
openSettings: () => setSettingsOpen(true),
openLegend: () => setLegendOpen((p) => !p),
openShortcuts: () => setShortcutsOpen((p) => !p),
deselectEntity: () => {
if (shortcutsOpen) { setShortcutsOpen(false); return; }
if (settingsOpen) { setSettingsOpen(false); return; }
if (legendOpen) { setLegendOpen(false); return; }
setSelectedEntity(null);
},
focusSearch: () => {
const el = document.querySelector<HTMLInputElement>('[data-search-input]');
el?.focus();
},
});
// Alert toast notifications for high-severity news
const { toasts, dismiss: dismissToast } = useAlertToasts();
// Persistent entity watchlist
const { items: watchlistItems, removeFromWatchlist, clearWatchlist } = useWatchlist();
// Notify backend of layer toggles so it can skip disabled fetchers / stop streams.
// After the POST completes, dispatch a custom event so useDataPolling immediately
@@ -459,17 +279,33 @@ export default function Dashboard() {
const [leftMeshExpanded, setLeftMeshExpanded] = useState(true);
const [leftShodanMinimized, setLeftShodanMinimized] = useState(true);
const launchMeshChatTab = useCallback((tab: 'infonet' | 'meshtastic' | 'dms', gate?: string) => {
setLeftOpen(true);
setLeftMeshExpanded(true);
setMeshChatLaunchRequest({ tab, gate, nonce: Date.now() });
}, []);
const launchMeshChatTab = useCallback(
(
tab: 'infonet' | 'meshtastic' | 'dms',
gate?: string,
peerId?: string,
showSas?: boolean,
) => {
setLeftOpen(true);
setLeftMeshExpanded(true);
setMeshChatLaunchRequest({ tab, gate, peerId, showSas, nonce: Date.now() });
},
[],
);
const openLiveGateFromShell = useCallback((gate: string) => {
setInfonetOpen(false);
launchMeshChatTab('infonet', gate);
}, [launchMeshChatTab]);
const openDeadDropFromShell = useCallback(
(peerId: string, options?: { showSas?: boolean }) => {
setInfonetOpen(false);
launchMeshChatTab('dms', undefined, peerId, Boolean(options?.showSas));
},
[launchMeshChatTab],
);
// Right panel: which panel is "focused" (expanded). null = none focused, all normal.
const [rightFocusedPanel, setRightFocusedPanel] = useState<string | null>(null);
@@ -557,6 +393,13 @@ export default function Dashboard() {
ts: number;
} | null>(null);
// Agent fly_to handler (sar_focus_aoi etc.) — wired here now that
// setFlyToLocation is in scope. show_image is routed through
// useAgentActions at the top of Dashboard.
useAgentActions(handleMapRightClick, ({ lat, lng }) => {
setFlyToLocation({ lat, lng, ts: Date.now() });
});
// Eavesdrop Mode State
const [isEavesdropping] = useState(false);
const [, setEavesdropLocation] = useState<{ lat: number; lng: number } | null>(null);
@@ -601,6 +444,15 @@ export default function Dashboard() {
setTrackedScanner={setTrackedScanner}
shodanResults={shodanResults}
shodanStyle={shodanStyle}
pinPlacementMode={pinPlacementMode}
onPinPlaced={() => setPinPlacementMode(false)}
sarAoiDropMode={sarAoiDropMode}
onSarAoiDropped={(coords) => {
setSarAoiDropMode(false);
setSarAoiDroppedCoords(coords);
setSarAoiEditorOpen(true);
}}
sarAoiListVersion={sarAoiListVersion}
/>
</ErrorBoundary>
@@ -628,22 +480,18 @@ export default function Dashboard() {
>
S H A D O W <span className="text-cyan-400">B R O K E R</span>
</h1>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">
<span className="text-[11px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">
GLOBAL THREAT INTERCEPT
</span>
</div>
</motion.div>
{/* SYSTEM METRICS TOP LEFT */}
<div className="absolute top-2 left-6 text-[8px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none hud-zone">
<div className="absolute top-2 left-6 text-[11px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none hud-zone">
OPTIC VIS:113 SRC:180 DENS:1.42 0.8ms
</div>
{/* SYSTEM METRICS TOP RIGHT */}
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-[var(--text-muted)] z-[200] pointer-events-none hud-zone">
<div>RTX</div>
<div>VSR</div>
</div>
{/* SYSTEM METRICS TOP RIGHT — removed, label moved into TimelineScrubber */}
{/* LEFT HUD CONTAINER — mirrors right side: one scroll container, scrollbar on LEFT edge */}
<motion.div
@@ -661,6 +509,7 @@ export default function Dashboard() {
shodanResultCount={shodanResults.length}
onSettingsClick={() => setSettingsOpen(true)}
onLegendClick={() => setLegendOpen(true)}
onOpenSarAoiEditor={() => setSarAoiEditorOpen(true)}
gibsDate={gibsDate}
setGibsDate={setGibsDate}
gibsOpacity={gibsOpacity}
@@ -712,6 +561,15 @@ export default function Dashboard() {
onMinimizedChange={setLeftShodanMinimized}
/>
</div>
{/* 4. AI INTEL (Below Shodan) */}
<div className="contents" style={{ direction: 'ltr' }}>
<AIIntelPanel
onFlyTo={handleFlyTo}
pinPlacementMode={pinPlacementMode}
onPinPlacementModeChange={setPinPlacementMode}
/>
</div>
</motion.div>
{/* LEFT SIDEBAR TOGGLE TAB — aligns with Data Layers section */}
@@ -734,10 +592,10 @@ export default function Dashboard() {
</button>
</motion.div>
{/* RIGHT SIDEBAR TOGGLE TAB — aligns with Oracle Predictions section */}
{/* RIGHT SIDEBAR TOGGLE TAB */}
<motion.div
className="absolute right-0 top-[12.5rem] z-[201] pointer-events-auto hud-zone"
animate={{ x: rightOpen ? -344 : 0 }}
animate={{ x: rightOpen ? -424 : 0 }}
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
>
<button
@@ -756,8 +614,8 @@ export default function Dashboard() {
{/* RIGHT HUD CONTAINER — slides off right edge when hidden */}
<motion.div
className="absolute right-6 top-24 bottom-9 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2 pl-2 hud-zone"
animate={{ x: rightOpen ? 0 : 360 }}
className="absolute right-6 top-24 bottom-9 w-[400px] flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2 pl-2 hud-zone"
animate={{ x: rightOpen ? 0 : 440 }}
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
>
<TopRightControls
@@ -788,10 +646,10 @@ export default function Dashboard() {
{/* GLOBAL TICKER REPLACES MARKETS PANEL - RENDERED OUTSIDE THIS DIV */}
{/* ORACLE PREDICTIONS */}
{/* EVENT TIMELINE */}
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'predictions' ? 'hidden' : ''}`}>
<ErrorBoundary name="PredictionsPanel">
<PredictionsPanel />
<ErrorBoundary name="TimelinePanel">
<TimelinePanel />
</ErrorBoundary>
</div>
@@ -812,9 +670,14 @@ export default function Dashboard() {
selectedEntity={selectedEntity}
regionDossier={regionDossier}
regionDossierLoading={regionDossierLoading}
onArticleClick={(idx, lat, lng) => {
onArticleClick={(idx, lat, lng, title) => {
if (lat !== undefined && lng !== undefined) {
setFlyToLocation({ lat, lng, ts: Date.now() });
// Also highlight the corresponding map alert
if (title) {
const alertKey = `${title}|${lat},${lng}`;
setSelectedEntity({ id: alertKey, type: 'news' });
}
}
}}
/>
@@ -837,15 +700,15 @@ export default function Dashboard() {
/>
<div
className="bg-[#0a0a0a]/90 border border-cyan-900/40 px-5 py-1.5 flex items-center gap-5 border-b-2 border-b-cyan-800 cursor-pointer backdrop-blur-sm"
className="bg-[#0a0a0a]/90 border border-cyan-900/40 px-6 py-2 flex items-center gap-6 border-b-2 border-b-cyan-800 cursor-pointer backdrop-blur-sm"
onClick={cycleStyle}
>
{/* Coordinates */}
<div className="flex flex-col items-center min-w-[120px]">
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
<div className="flex flex-col items-center min-w-[140px]">
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
COORDINATES
</div>
<div className="text-[11px] text-cyan-400 font-mono font-bold tracking-wide">
<div className="text-[14px] text-cyan-400 font-mono font-bold tracking-wide">
{mouseCoords
? `${mouseCoords.lat.toFixed(4)}, ${mouseCoords.lng.toFixed(4)}`
: '0.0000, 0.0000'}
@@ -856,11 +719,11 @@ export default function Dashboard() {
<div className="w-px h-6 bg-[var(--border-primary)]" />
{/* Location name */}
<div className="flex flex-col items-center min-w-[160px] max-w-[280px]">
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
<div className="flex flex-col items-center min-w-[180px] max-w-[320px]">
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
LOCATION
</div>
<div className="text-[10px] text-[var(--text-secondary)] font-mono truncate max-w-[280px]">
<div className="text-[13px] text-[var(--text-secondary)] font-mono truncate max-w-[320px]">
{locationLabel || 'Hover over map...'}
</div>
</div>
@@ -870,10 +733,10 @@ export default function Dashboard() {
{/* Style preset (compact) */}
<div className="flex flex-col items-center">
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
STYLE
</div>
<div className="text-[11px] text-cyan-400 font-mono font-bold">
<div className="text-[14px] text-cyan-400 font-mono font-bold">
{activeStyle}
</div>
</div>
@@ -889,11 +752,11 @@ export default function Dashboard() {
className="flex flex-col items-center"
title={`Kp Index: ${sw?.kp_index ?? 'N/A'}`}
>
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
SOLAR
</div>
<div
className={`text-[11px] font-mono font-bold ${
className={`text-[14px] font-mono font-bold ${
(sw?.kp_index ?? 0) >= 5
? 'text-red-400'
: (sw?.kp_index ?? 0) >= 4
@@ -906,6 +769,20 @@ export default function Dashboard() {
</div>
);
})()}
{/* Divider */}
<div className="w-px h-6 bg-[var(--border-primary)]" />
{/* Feed Health */}
<div className="flex items-center gap-3">
{feedHealth.map((f) => (
<div key={f.label} className="flex items-center gap-1 text-[10px] font-mono tracking-wider">
<span className={`feed-dot feed-dot-${f.status}`} />
<span className="text-[var(--text-muted)]">{f.label}</span>
<span className="text-cyan-400 font-bold">{f.count}</span>
</div>
))}
</div>
</div>
</motion.div>
)}
@@ -953,6 +830,15 @@ export default function Dashboard() {
style={{ backgroundSize: '100% 4px' }}
></div>
{/* WATCHLIST WIDGET */}
<WatchlistWidget
items={watchlistItems}
onRemove={removeFromWatchlist}
onClear={clearWatchlist}
onFlyTo={handleFlyTo}
/>
{/* SETTINGS PANEL */}
<ErrorBoundary name="SettingsPanel">
<SettingsPanel isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
@@ -963,6 +849,16 @@ export default function Dashboard() {
<MapLegend isOpen={legendOpen} onClose={() => setLegendOpen(false)} />
</ErrorBoundary>
{/* KEYBOARD SHORTCUTS OVERLAY */}
<KeyboardShortcutsOverlay isOpen={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
{/* ALERT TOAST NOTIFICATIONS */}
<AlertToast
toasts={toasts}
onDismiss={dismissToast}
onFlyTo={handleFlyTo}
/>
{/* ONBOARDING MODAL */}
{showOnboarding && (
<OnboardingModal
@@ -979,91 +875,21 @@ export default function Dashboard() {
<ChangelogModal onClose={() => setShowChangelog(false)} />
)}
{/* SENTINEL HUB — first-time info modal */}
{/* SENTINEL HUB — first-time info modal (extracted to SentinelInfoModal.tsx) */}
{showSentinelInfo && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/90"
onClick={() => setShowSentinelInfo(false)}
/>
<div className="relative z-[10001] w-[520px] max-h-[80vh] bg-[var(--bg-secondary)] border border-purple-500/30 shadow-2xl shadow-purple-900/20 overflow-y-auto styled-scrollbar">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold tracking-wider text-purple-300 font-mono">
SENTINEL HUB IMAGERY
</h2>
<button
onClick={() => setShowSentinelInfo(false)}
className="text-[var(--text-muted)] hover:text-white transition-colors text-xl leading-none"
>
&times;
</button>
</div>
<SentinelInfoModal onClose={() => setShowSentinelInfo(false)} />
)}
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed">
You now have access to ESA Sentinel-2 satellite imagery directly on the map.
This uses the Copernicus Data Space Ecosystem with your own credentials.
</p>
<div className="space-y-2">
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">AVAILABLE LAYERS</h3>
<div className="grid grid-cols-2 gap-2">
{[
{ name: 'True Color', desc: 'Natural RGB — see terrain, cities, water' },
{ name: 'False Color IR', desc: 'Near-infrared — vegetation in red' },
{ name: 'NDVI', desc: 'Vegetation health index (green = healthy)' },
{ name: 'Moisture Index', desc: 'Soil & vegetation moisture levels' },
].map((l) => (
<div key={l.name} className="p-2 border border-purple-900/30 bg-purple-950/10">
<div className="text-[10px] font-mono text-white">{l.name}</div>
<div className="text-[9px] text-[var(--text-muted)]">{l.desc}</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">USAGE LIMITS (FREE TIER)</h3>
<div className="p-3 border border-[var(--border-primary)] bg-[var(--bg-primary)]/40 space-y-1.5">
<div className="flex justify-between text-[10px] font-mono">
<span className="text-[var(--text-muted)]">Monthly budget</span>
<span className="text-purple-300">10,000 requests</span>
</div>
<div className="flex justify-between text-[10px] font-mono">
<span className="text-[var(--text-muted)]">Cost per tile</span>
<span className="text-purple-300">0.25 PU (256&times;256px)</span>
</div>
<div className="flex justify-between text-[10px] font-mono">
<span className="text-[var(--text-muted)]">~Viewport loads/month</span>
<span className="text-purple-300">~500 (20 tiles each)</span>
</div>
<div className="flex justify-between text-[10px] font-mono">
<span className="text-[var(--text-muted)]">Empty tiles</span>
<span className="text-green-400">FREE (no data = no charge)</span>
</div>
</div>
</div>
<div className="space-y-2">
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">HOW IT WORKS</h3>
<ul className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed space-y-1 list-disc list-inside">
<li>Sentinel-2 revisits every ~5 days not every location has data every day</li>
<li>The date slider picks the end of a time window; zoomed out uses wider windows</li>
<li>Black patches = no satellite pass on that date range (normal)</li>
<li>Best results at zoom 8-14 closer = sharper imagery (10m resolution)</li>
<li>Cloud filter auto-skips tiles with {'>'} 30% cloud cover</li>
</ul>
</div>
<button
onClick={() => setShowSentinelInfo(false)}
className="w-full py-2.5 bg-purple-500/20 border border-purple-500/40 text-purple-300 hover:bg-purple-500/30 transition-colors text-[11px] font-mono tracking-wider"
>
GOT IT
</button>
</div>
</div>
</div>
{/* SAR AOI EDITOR — portals to document.body internally */}
{(sarAoiEditorOpen || sarAoiDropMode) && (
<SarAoiEditorModal
onClose={() => { setSarAoiEditorOpen(false); setSarAoiDropMode(false); }}
onRequestMapPick={() => { setSarAoiEditorOpen(false); setSarAoiDropMode(true); }}
pickedCoords={sarAoiDroppedCoords}
onPickConsumed={() => setSarAoiDroppedCoords(null)}
onAoiListChanged={() => setSarAoiListVersion((v) => v + 1)}
dropModeActive={sarAoiDropMode}
/>
)}
{/* MESH TERMINAL */}
@@ -1081,13 +907,10 @@ export default function Dashboard() {
onClose={() => {
setInfonetOpen(false);
// Shut down Wormhole when the terminal closes so it doesn't stay running
fetchWormholeState(false)
.then((s) => {
if (s?.ready || s?.running) return leaveWormhole();
})
.catch(() => {});
void teardownWormholeOnClose(fetchWormholeState, leaveWormhole);
}}
onOpenLiveGate={openLiveGateFromShell}
onOpenDeadDrop={openDeadDropFromShell}
/>
{/* BACKEND DISCONNECTED BANNER */}
@@ -1099,9 +922,9 @@ export default function Dashboard() {
</span>
</div>
)}
{/* BOTTOM TICKER TOGGLE TAB — moved to right to avoid Shodan overlap */}
{/* BOTTOM TICKER TOGGLE TAB — moved to center-right to avoid panel overlap */}
<motion.div
className={`absolute bottom-0 right-[22rem] z-[8001] pointer-events-auto hud-zone transition-opacity duration-300 ${tickerOpen ? 'opacity-100' : 'opacity-40 hover:opacity-100'}`}
className={`absolute bottom-0 right-[28rem] z-[8001] pointer-events-auto hud-zone transition-opacity duration-300 ${tickerOpen ? 'opacity-100' : 'opacity-40 hover:opacity-100'}`}
animate={{ y: tickerOpen ? -28 : 0 }}
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
>