/* ===================================================================== GRUG GROUP — shared CORE (helpers + chrome) Loaded on every page before grug-sections.jsx. Exposes window.GRUG with helpers + chrome components. ===================================================================== */ /* ---- Resource map (replaces per-page resource tags) ---- */ window.__resources = { logoGrug: './assets/web/logo-grug-2026.png', bjSunset: './assets/web/jet-supersonic-coast-sunset.jpg', bjDay: './assets/web/jet-supersonic-coast-day.jpg', bjAlps: './assets/web/jet-supersonic-1.jpg', bjTopdown: './assets/web/jet-supersonic-topdown.jpg', bjWhite: './assets/web/jet-supersonic-white.jpg', ghostCity: './assets/web/ghost-x-passenger-city.jpg', ghostCargo: './assets/web/ghost-x-cargo-tarmac.jpg', sbxFront: './assets/web/aircraft-render-front.png', clientNavy: './assets/web/client-navy-evtol-carrier.jpg', clientNasa: './assets/web/client-nasa-titan-explorer.jpg', clientStealthFleet: './assets/web/client-usaf-stealth-fleet.jpg', clientStealthSunset: './assets/web/client-stealth-runway-sunset.jpg', clientStealthClouds: './assets/web/client-stealth-clouds.jpg', clientVeltro: './assets/web/client-veltro-nyc.jpg', clientVtolNyc: './assets/web/client-vtol-manhattan-sunset.jpg', clientJetx: './assets/web/client-jetx-ground.jpg', clientVeloce: './assets/web/client-veloce-jetworks-board.jpg', teamNelson: './assets/web/team-nelson.jpg', teamUrupagua: './assets/web/team-urupagua.jpg', clientHelipad: './assets/web/client-evtol-helipad-coast.jpg', doggyHero: './assets/web/doggy-drone-hero.jpg', doggyWheel: './assets/web/doggy-drone-wheelchair.jpg', doggyApp: './assets/web/doggy-drone-app.jpg', doggyDetail: './assets/web/doggy-drone-detail.jpg', logoJetx: './assets/web/logo-jetx.png', logoJetxReal: './assets/web/logo-jetx-real.png', awardXtech: './assets/web/award-xtech.png', awardSbirAf: './assets/web/award-sbir-af.png', awardAfwerx: './assets/web/award-afwerx.png', awardArmySbir: './assets/web/award-army-sbir.jpg', logoMatrix: './assets/web/logo-matrix.png', logoNalwa: './assets/web/logo-nalwa.png', reelVideo: './assets/web/reel-aircraft.mp4', reelVideo2: './assets/web/reel-02.mp4', reelVideo3: './assets/web/reel-03.mp4', reelVideo4: './assets/web/reel-04.mp4', reelVideo5: './assets/web/reel-05.mp4', processBoard: './assets/web/process-showcase.jpg', extraNasa: './assets/web/extra-nasa-titan.jpg', extraDoggy: './assets/web/extra-doggy-paw.jpg', extraEvtol: './assets/web/extra-evtol-city.jpg', favoriteAlpine: './assets/web/favorite-alpine-jet.jpg', ryzrSide: './assets/web/ryzr-speedster-side-pano.jpg', ryzrRooftop: './assets/web/ryzr-speedster-rooftop-dusk.jpg', ryzr3View: './assets/web/ryzr-speedster-3view.jpg', ryzrSkyline: './assets/web/ryzr-speedster-skyline-sunset.jpg', nalwaExterior: './assets/web/nalwa-5x-exterior.png', nalwaMountains: './assets/web/nalwa-mountains.png', nalwaCabin: './assets/web/nalwa-cabin-2seat.png', nalwaCockpit: './assets/web/nalwa-cockpit.png', nalwaCruise: './assets/web/nalwa-cruise.png', prototypeHover: './assets/web/prototype-hover.jpg', prototypeGround: './assets/web/prototype-ground.jpg', protoAirframe: './assets/web/proto-airframe.png', protoFluidic: './assets/web/proto-fluidic.png', protoTwinFans: './assets/web/proto-twin-fans.jpg', protoHinge: './assets/web/proto-nozzle-hinge.jpg', protoNozzleFlow: './assets/web/proto-nozzle-flow.jpg', protoNozzleSide: './assets/web/proto-nozzle-side.jpg', protoVane: './assets/web/proto-vane.jpg', nalwaProtoPark: './assets/web/nalwa-hover-park.jpg', nalwaProtoRooftop: './assets/web/nalwa-hover-rooftop.jpg', nalwaProtoGround: './assets/web/nalwa-ground-pad.jpg', nalwaProtoClouds: './assets/web/nalwa-cruise-clouds.jpg', }; const R = () => window.__resources || {}; const { useState, useEffect, useRef } = React; /* ============ HOOKS ============ */ const useReveal = () => { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; // Already in (or near) the viewport on mount → reveal immediately. const inView = () => { const r = el.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; return r.top < vh * 0.92 && r.bottom > 0; }; if (inView()) { el.classList.add('is-visible'); return; } let done = false; const reveal = () => { if (!done) { done = true; el.classList.add('is-visible'); } }; let io; if ('IntersectionObserver' in window) { io = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) { reveal(); io.disconnect(); } }); }, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' }); io.observe(el); } else { reveal(); } // Safety net: never let content stay hidden. If nothing fired, reveal. const onScroll = () => { if (inView()) { reveal(); cleanup(); } }; const fallback = setTimeout(reveal, 2600); window.addEventListener('scroll', onScroll, { passive: true }); function cleanup() { window.removeEventListener('scroll', onScroll); clearTimeout(fallback); if (io) io.disconnect(); } return cleanup; }, []); return ref; }; const useScrolled = (threshold = 24) => { const [scrolled, setScrolled] = useState(false); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > threshold); window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); return () => window.removeEventListener('scroll', onScroll); }, [threshold]); return scrolled; }; const useCountUp = (target, duration = 1800) => { const ref = useRef(null); const [value, setValue] = useState(0); useEffect(() => { let raf; const t0 = performance.now() + 350; // tiny delay after mount, then always animate const tick = (t) => { if (t < t0) { raf = requestAnimationFrame(tick); return; } const p = Math.min(1, (t - t0) / duration); const eased = 1 - Math.pow(1 - p, 3); setValue(Math.round(eased * target)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [target, duration]); return [ref, value]; }; /* ============ SCROLL PROGRESS BAR ============ */ const ScrollProgress = () => { const fillRef = useRef(null); useEffect(() => { const onScroll = () => { const h = document.documentElement; const max = (h.scrollHeight - h.clientHeight) || 1; const pct = Math.min(100, Math.max(0, (window.scrollY / max) * 100)); if (fillRef.current) fillRef.current.style.width = pct + '%'; }; window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); return () => window.removeEventListener('scroll', onScroll); }, []); return (
); }; /* ============ CUSTOM CURSOR ============ */ const CursorFollower = () => { const ringRef = useRef(null); const dotRef = useRef(null); useEffect(() => { if (window.matchMedia('(hover: none)').matches) return; const ring = ringRef.current, dot = dotRef.current; let tx = 0, ty = 0, rx = 0, ry = 0, raf, idleTimer; const onMove = (e) => { tx = e.clientX; ty = e.clientY; if (dot) { dot.style.transform = `translate(${tx}px, ${ty}px) translate(-50%, -50%)`; } if (!ring.classList.contains('is-ready')) ring.classList.add('is-ready'); if (!dot.classList.contains('is-ready')) dot.classList.add('is-ready'); ring.classList.remove('is-idle'); dot.classList.remove('is-idle'); clearTimeout(idleTimer); idleTimer = setTimeout(() => { ring.classList.add('is-idle'); dot.classList.add('is-idle'); }, 2400); }; const tick = () => { rx += (tx - rx) * 0.18; ry += (ty - ry) * 0.18; if (ring) ring.style.transform = `translate(${rx}px, ${ry}px) translate(-50%, -50%)`; raf = requestAnimationFrame(tick); }; const onOver = (e) => { const el = e.target.closest && e.target.closest('a, button, [role="button"], .grug-project, .grug-cap-row, .grug-client'); if (el) ring.classList.add('is-hot'); }; const onOut = (e) => { const el = e.target.closest && e.target.closest('a, button, [role="button"], .grug-project, .grug-cap-row, .grug-client'); if (el) ring.classList.remove('is-hot'); }; const onDown = () => ring.classList.add('is-pressed'); const onUp = () => ring.classList.remove('is-pressed'); const onLeave = () => { ring.classList.remove('is-ready'); dot.classList.remove('is-ready'); }; window.addEventListener('mousemove', onMove, { passive: true }); window.addEventListener('mouseover', onOver, true); window.addEventListener('mouseout', onOut, true); window.addEventListener('mousedown', onDown); window.addEventListener('mouseup', onUp); document.addEventListener('mouseleave', onLeave); raf = requestAnimationFrame(tick); return () => { cancelAnimationFrame(raf); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseover', onOver, true); window.removeEventListener('mouseout', onOut, true); window.removeEventListener('mousedown', onDown); window.removeEventListener('mouseup', onUp); document.removeEventListener('mouseleave', onLeave); }; }, []); return ( <> > ); }; /* ============ ANIMATED ICONS ============ */ const Ico = { pencil: ( ), design: ( ), render: ( ), cabin: ( ), strategy: ( ), }; /* ============ SECTION INDEX ============ */ const SectionIndex = ({ no, title, meta }) => ( ); /* ============ NAV — multipage with mobile drawer ============ */ const NAV_ITEMS = [ { label: 'Home', href: 'index.html', key: 'home' }, { label: 'Portfolio', href: 'portfolio.html', key: 'portfolio' }, { label: 'Capabilities', href: 'capabilities.html', key: 'capabilities' }, { label: 'About', href: 'about.html', key: 'about' }, { label: 'Contact', href: 'contact.html', key: 'contact' }, ]; const Nav = ({ active }) => { const scrolled = useScrolled(); const [open, setOpen] = useState(false); useEffect(() => { document.body.style.overflow = open ? 'hidden' : ''; return () => { document.body.style.overflow = ''; }; }, [open]); return ({sub}
}