// 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 (
);
};
Object.assign(window, { useScrollProgress, LReveal, LLetters, LCountUp, LIcon });