/* 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 (
{row.map((it,i)=>({it}{sep}))}
);
}
Object.assign(window, { useReveals, Reveal, Nav, Footer, Cursor, Starfield, Ticker });