// Scroll utilities + shared luxury primitives // useScrollProgress — reports 0..1 progress through an element while it scrolls past viewport const useScrollProgress = (ref) => { const [p, setP] = React.useState(0); React.useEffect(() => { const el = ref.current; if (!el) return; // Find scrolling parent (artboard root) let scroller = el.parentElement; while (scroller && scroller !== document.body) { const o = getComputedStyle(scroller).overflowY; if (o === 'auto' || o === 'scroll') break; scroller = scroller.parentElement; } if (!scroller || scroller === document.body) scroller = window; let raf = 0; const onScroll = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { const rect = el.getBoundingClientRect(); const sRect = scroller === window ? { top: 0, height: window.innerHeight } : scroller.getBoundingClientRect(); const vh = sRect.height; const total = rect.height - vh; const passed = sRect.top - rect.top + 0; // negative when above const px = scroller === window ? -rect.top : (sRect.top - rect.top); const prog = Math.max(0, Math.min(1, px / total)); setP(prog); }); }; scroller.addEventListener('scroll', onScroll, { passive: true }); onScroll(); return () => { scroller.removeEventListener('scroll', onScroll); cancelAnimationFrame(raf); }; }, []); return p; }; const LReveal = ({ children, delay = 0, as: As = 'div', className = '', ...rest }) => { const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (!el) return; el.classList.add('armed'); const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setTimeout(() => el.classList.add('in'), delay); io.disconnect(); } }, { threshold: 0.05, rootMargin: '0px 0px -10% 0px' }); io.observe(el); // Failsafe — never leave content invisible const t = setTimeout(() => el.classList.add('in'), 2200 + delay); return () => { io.disconnect(); clearTimeout(t); }; }, [delay]); return {children}; }; // Letter-by-letter reveal for headline const LLetters = ({ text, className = '' }) => { const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (!el) return; const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { el.classList.add('in'); const spans = el.querySelectorAll('span'); spans.forEach((s, i) => { s.style.transitionDelay = `${i * 18}ms`; }); io.disconnect(); } }, { threshold: 0.4 }); io.observe(el); return () => io.disconnect(); }, [text]); return ( {text.split('').map((c, i) => ( {c === ' ' ? '\u00A0' : c} ))} ); }; const LCountUp = ({ to, suffix = '', decimals = 0, duration = 1800 }) => { const [v, setV] = React.useState(0); const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (!el) return; const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { const start = performance.now(); const tick = (now) => { const t = Math.min(1, (now - start) / duration); const eased = 1 - Math.pow(1 - t, 4); setV(to * eased); if (t < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); io.disconnect(); } }, { threshold: 0.4 }); io.observe(el); return () => io.disconnect(); }, [to, duration]); const display = decimals > 0 ? v.toFixed(decimals) : Math.round(v).toLocaleString(); return {display}{suffix}; }; const LIcon = ({ name, size = 18, stroke = 1.4 }) => { const paths = { arrR: , arrUR: , chevD: , chip: <>, lock: <>, shield: <>, plug: <>, globe: <>, }; return ( {paths[name]} ); }; Object.assign(window, { useScrollProgress, LReveal, LLetters, LCountUp, LIcon });