/* Shared hooks + components → window */ const { useState, useEffect, useRef, useCallback } = React; /* ---- scroll reveal: observes .reveal nodes, adds .in ---- Robust against throttled/hidden documents: the initial above-fold pass and the safety timer run WITHOUT requestAnimationFrame (which freezes when the tab/iframe is not visible), so content is never left permanently invisible. ---- */ function useReveals(route){ useEffect(() => { const reveal = (el) => el.classList.add('in'); const force = () => document.querySelectorAll('.reveal').forEach(el => el.classList.add('shown')); const vh = () => window.innerHeight || 800; // If the document is not visible (offscreen / background tab), CSS // transitions freeze at their start frame — so reveal everything // instantly with no transition instead of leaving it invisible. if (document.visibilityState !== 'visible') force(); const onVis = () => { if (document.visibilityState !== 'visible') force(); }; document.addEventListener('visibilitychange', onVis); // visible users: reveal above-fold now, observe the rest on scroll document.querySelectorAll('.reveal:not(.in)').forEach(el => { if (el.getBoundingClientRect().top < vh() * 0.95) reveal(el); }); const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting){ reveal(e.target); io.unobserve(e.target); } }); }, { threshold: 0.12, rootMargin: '0px 0px -6% 0px' }); document.querySelectorAll('.reveal:not(.in)').forEach(el => io.observe(el)); const onScroll = () => { document.querySelectorAll('.reveal:not(.in)').forEach(el => { if (el.getBoundingClientRect().top < vh() * 0.9) reveal(el); }); }; window.addEventListener('scroll', onScroll, { passive:true }); // Detect a throttled/offscreen document (rAF frozen): if a frame // hasn't fired shortly, CSS transitions are frozen too — so reveal // everything instantly. Real visible tabs fire rAF in ~16ms and keep // full scroll-in animations. let rafFired = false; requestAnimationFrame(() => { rafFired = true; }); const throttleCheck = setTimeout(() => { if (!rafFired) force(); }, 350); return () => { io.disconnect(); clearTimeout(throttleCheck); window.removeEventListener('scroll', onScroll); document.removeEventListener('visibilitychange', onVis); }; }, [route]); } /* ---- a reveal wrapper ---- */ function Reveal({ as='div', d, className='', children, ...rest }){ const Tag = as; return {children}; } /* ---- nav ---- */ function Nav({ route, go }){ const [scrolled, setScrolled] = useState(false); const [open, setOpen] = useState(false); // Only mount the mobile menu/burger on narrow screens, so desktop can // NEVER show the overlay's contents even if the stylesheet is stale. const mq = '(max-width:720px)'; const [isMobile, setIsMobile] = useState( typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(mq).matches : false ); useEffect(() => { const m = window.matchMedia(mq); const onChange = () => setIsMobile(m.matches); onChange(); m.addEventListener ? m.addEventListener('change', onChange) : m.addListener(onChange); return () => { m.removeEventListener ? m.removeEventListener('change', onChange) : m.removeListener(onChange); }; }, []); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 24); onScroll(); window.addEventListener('scroll', onScroll, { passive:true }); return () => window.removeEventListener('scroll', onScroll); }, []); // close the menu if we grow past the breakpoint while it's open useEffect(() => { if (!isMobile && open) setOpen(false); }, [isMobile]); // lock body scroll + close on Escape while the mobile menu is open useEffect(() => { document.body.style.overflow = open ? 'hidden' : ''; const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; window.addEventListener('keydown', onKey); return () => { document.body.style.overflow=''; window.removeEventListener('keydown', onKey); }; }, [open]); const links = [['home','Home','01'],['about','About','02'],['contact','Contact','03']]; const nav = (id) => { setOpen(false); go(id); }; return ( ); } /* ---- footer ---- */ function Footer({ go }){ const S = window.SITE; return ( ); } /* ---- custom cursor (home page only) ---- */ function Cursor(){ const dot = useRef(null), ring = useRef(null); useEffect(() => { if (window.matchMedia('(hover:none)').matches) return; let rx=window.innerWidth/2, ry=window.innerHeight/2, dx=rx, dy=ry, raf; const move = (e) => { dx=e.clientX; dy=e.clientY; if(dot.current) dot.current.style.transform=`translate(${dx}px,${dy}px) translate(-50%,-50%)`; }; const loop = () => { rx += (dx-rx)*0.18; ry += (dy-ry)*0.18; if(ring.current) ring.current.style.transform=`translate(${rx}px,${ry}px) translate(-50%,-50%)`; raf=requestAnimationFrame(loop); }; const over = (e) => { if(e.target.closest('.hov,a,button,input,textarea')) ring.current?.classList.add('hot'); }; const out = (e) => { if(e.target.closest('.hov,a,button,input,textarea')) ring.current?.classList.remove('hot'); }; window.addEventListener('mousemove', move); document.addEventListener('mouseover', over); document.addEventListener('mouseout', out); loop(); return () => { cancelAnimationFrame(raf); window.removeEventListener('mousemove',move); document.removeEventListener('mouseover',over); document.removeEventListener('mouseout',out); }; }, []); return (<>
); } /* ---- starfield (constellation of dark dots on cream) ---- */ function Starfield({ density=0.00012 }){ const ref = useRef(null); useEffect(() => { const cvs = ref.current, ctx = cvs.getContext('2d'); let w,h,stars=[],raf,mx=0.5,my=0.5; const dpr = Math.min(window.devicePixelRatio||1, 2); const build = () => { w = cvs.clientWidth; h = cvs.clientHeight; cvs.width = w*dpr; cvs.height = h*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); const n = Math.max(40, Math.floor(w*h*density)); stars = Array.from({length:n}, () => ({ x:Math.random()*w, y:Math.random()*h, r:Math.random()*1.5+0.4, z:Math.random()*0.9+0.1, tw:Math.random()*Math.PI*2, sp:Math.random()*0.6+0.2, })); }; const draw = (t) => { ctx.clearRect(0,0,w,h); const ox=(mx-0.5)*24, oy=(my-0.5)*24; stars.forEach(s => { const a = 0.18 + 0.16*Math.sin(t*0.001*s.sp + s.tw); ctx.beginPath(); ctx.fillStyle = `rgba(30,26,21,${a*s.z})`; ctx.arc(s.x+ox*s.z, s.y+oy*s.z, s.r, 0, Math.PI*2); ctx.fill(); }); raf = requestAnimationFrame(draw); }; const onMove = (e) => { mx=e.clientX/window.innerWidth; my=e.clientY/window.innerHeight; }; build(); raf=requestAnimationFrame(draw); window.addEventListener('resize', build); window.addEventListener('mousemove', onMove); return () => { cancelAnimationFrame(raf); window.removeEventListener('resize',build); window.removeEventListener('mousemove',onMove); }; }, []); return ; } /* ---- ticker / marquee ---- */ function Ticker({ items, sep='✦' }){ const row = [...items, ...items, ...items]; return ( ); } Object.assign(window, { useReveals, Reveal, Nav, Footer, Cursor, Starfield, Ticker });