import { useEffect, useRef, useState } from 'react' import { Home, Cpu, BarChart3, Flag, Sun, Moon, Navigation } from 'lucide-react' import { Button } from '../ui/neon-button' import { LimelightNav, type NavItem } from '../ui/limelight-nav' import { getScroll } from '../../lib/landing/scroll' import { useTwinStore } from '../../lib/twin/store' import { getLenisRef } from '../../lib/landing/lenis-ref' // Section ids in nav order - index maps 1:1 to the limelight items below. // MUST stay in the same top-to-bottom order the sections actually render in // LandingPage (problem → matrix → how → blind-spots → trust). The scroll-spy // interpolates between consecutive section tops, so a mismatch here makes the // limelight indicator track the wrong link. const SECTION_IDS = ['problem', 'matrix', 'how', 'blind-spots', 'trust'] /** * Sticky top nav. Condenses with a frosted background after the hero. */ export function Nav({ visible }: { visible: boolean }) { const [scrolled, setScrolled] = useState(false) // Active section index (0=Hero … 3=Findings). Derived from a continuous // scroll position but ROUNDED to the nearest section, so the light always // sits centered on the highlighted word (the label uses the same rounding). const [navActive, setNavActive] = useState(0) // When a nav item is clicked we jump the light straight to the target and // ignore the scroll-spy until the smooth-scroll actually lands there - so the // light glides directly instead of pausing on every section it passes over. const jumpTargetRef = useRef(-1) const jumpTimerRef = useRef | null>(null) // Continuous progress state for the limelight const [navProgress, setNavProgress] = useState(0) const lastProgressRef = useRef(0) useEffect(() => { const onScroll = () => setScrolled(window.scrollY > window.innerHeight * 0.6) window.addEventListener('scroll', onScroll, { passive: true }) return () => window.removeEventListener('scroll', onScroll) }, []) // Scroll-spy driven by a rAF loop reading the SAME Lenis-synced scroll value // the 3D scene uses (getScroll). This removes the two lag sources: the // IntersectionObserver hysteresis AND the smooth-scroll/CSS-transition delay. // We compute a continuous position, then ROUND to the active section so the // light stays centered on the highlighted word (it uses the same rounding). useEffect(() => { let raf = 0 let last = -1 const measure = () => { // Offset by 100px so sections activate when they sit nicely below the nav. const navOffset = 100 return SECTION_IDS.map((id) => { const el = document.getElementById(id) return el ? el.getBoundingClientRect().top + window.scrollY - navOffset : null }) } const remeasure = () => {} // We no longer cache tops on mount, we'll measure continuously to ensure // pixel-perfect alignment even if the layout shifts slightly (fonts loading etc). const loop = () => { raf = requestAnimationFrame(loop) const tops = measure() const { scrollY } = getScroll() // Find the bracketing sections and interpolate between their tops. let p = 0 const maxScroll = document.documentElement.scrollHeight - window.innerHeight if (scrollY >= maxScroll - 10) { // Force active state to the last section when at the absolute bottom p = tops.length - 1 } else { for (let i = 0; i < tops.length; i++) { const top = tops[i] if (top == null) continue if (scrollY >= top) { const next = tops[i + 1] if (next != null && next > top) { const t = Math.min(1, (scrollY - top) / (next - top)) p = i + t } else { p = i } } } } const active = Math.round(p) // While a click-jump is in flight, suppress intermediate sections - only // release the lock once the scroll has reached the clicked target. if (jumpTargetRef.current !== -1) { if (active === jumpTargetRef.current) { jumpTargetRef.current = -1 last = active } return } // We round p to 3 decimal places to prevent micro-jitter react updates const smoothP = Math.round(p * 1000) / 1000 if (smoothP !== lastProgressRef.current) { lastProgressRef.current = smoothP setNavProgress(smoothP) } if (active !== last) { last = active setNavActive(active) } } raf = requestAnimationFrame(loop) return () => { cancelAnimationFrame(raf) } }, []) const go = (id: string) => { const index = SECTION_IDS.indexOf(id) if (index !== -1) { // Send the light straight to the target and lock out intermediate spy // updates until the scroll arrives. jumpTargetRef.current = index setNavActive(index) // Failsafe: release the lock even if the scroll never lands exactly on the // target (e.g. the user scrolls manually mid-jump). if (jumpTimerRef.current) clearTimeout(jumpTimerRef.current) jumpTimerRef.current = setTimeout(() => (jumpTargetRef.current = -1), 1500) } const el = document.getElementById(id) if (el) { const navOffset = 100 const targetY = el.getBoundingClientRect().top + window.scrollY - navOffset const lenis = getLenisRef() if (lenis) { lenis.scrollTo(targetY) } else { window.scrollTo({ top: targetY, behavior: 'smooth' }) } } } // Center limelight nav. Order matches SECTION_IDS so scroll-spy maps 1:1. const navItems: NavItem[] = [ { id: 'problem', icon: , label: 'PROBLEM', onClick: () => go('problem') }, { id: 'matrix', icon: , label: 'THE MATRIX', onClick: () => go('matrix') }, { id: 'how', icon: , label: 'HOW IT WORKS', onClick: () => go('how') }, { id: 'blind-spots', icon: , label: 'BLIND SPOTS', onClick: () => go('blind-spots') }, { id: 'trust', icon: , label: 'TRUST', onClick: () => go('trust') }, ] return (
{/* Wordmark */} {/* Center links - limelight text nav, absolutely centered so it never clashes with the wordmark or CTA regardless of their widths. */}
{/* Right cluster: theme toggle + CTA */}
) } /** * Dark/Light theme toggle. Sets the app-wide theme, which the dashboard map + * panels follow (light theme → light basemap, dark → dark basemap). */ function ThemeToggle() { const theme = useTwinStore((s) => s.theme) const toggleTheme = useTwinStore((s) => s.toggleTheme) const light = theme === 'light' return ( ) }