);
};
// Tag pill — subtler
const Tag = ({children, tone='indigo'}) => {
const tones = {
indigo: {bg:'rgba(107,83,255,0.10)', fg:'#B8AAFF', bd:'rgba(107,83,255,0.24)'},
pink: {bg:'rgba(255,111,168,0.10)', fg:'#FFC2D8', bd:'rgba(255,111,168,0.24)'},
ink: {bg:'rgba(239,234,224,0.04)', fg:'var(--fg-2)', bd:'var(--line)'},
}[tone];
return (
{children}
);
};
// Reveal-on-scroll wrapper — refined for smooth motion across all pages.
// - Triggers slightly before fully entering view (rootMargin) for a natural feel
// - On mount, anything already fully visible reveals immediately (no flash on reload mid-page)
// - Once revealed, stays revealed (no re-trigger jitter when scrolling back up)
const Reveal = ({children, delay=0, kind='reveal-up', as='div', style={}}) => {
const ref = React.useRef(null);
React.useEffect(()=>{
const el = ref.current; if(!el) return;
// If reduce-motion preferred, just show immediately, no animation
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { el.classList.add('in', 'no-anim'); return; }
// If already in viewport at mount (e.g. above the fold, or scroll position restored), reveal immediately
const rect = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
if (rect.top < vh * 0.85 && rect.bottom > 0) {
// small RAF so it transitions in cleanly even when immediate
requestAnimationFrame(()=> setTimeout(()=> el.classList.add('in'), Math.min(delay, 200)));
return;
}
const io = new IntersectionObserver((entries)=>{
entries.forEach(e=>{
if(e.isIntersecting){
setTimeout(()=>el.classList.add('in'), delay);
io.unobserve(el);
}
});
}, {threshold:0.08, rootMargin: '0px 0px -8% 0px'});
io.observe(el);
return ()=>io.disconnect();
}, [delay]);
const Tag = as;
return {children};
};
// Word-by-word reveal — same robustness as Reveal
const RevealWords = ({children, delay=0, gap=40}) => {
const ref = React.useRef(null);
React.useEffect(()=>{
const el = ref.current; if(!el) return;
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const trigger = () => {
const words = el.querySelectorAll('.word');
words.forEach((w,i)=>{ w.style.transitionDelay = `${delay + i*gap}ms`; });
el.classList.add('in');
};
if (reduce) { el.classList.add('in', 'no-anim'); return; }
const rect = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
if (rect.top < vh * 0.85 && rect.bottom > 0) {
requestAnimationFrame(()=> setTimeout(trigger, 50));
return;
}
const io = new IntersectionObserver((entries)=>{
entries.forEach(e=>{
if(e.isIntersecting){
trigger();
io.unobserve(el);
}
});
}, {threshold:0.1, rootMargin:'0px 0px -6% 0px'});
io.observe(el);
return ()=>io.disconnect();
}, [delay,gap]);
const splitText = (s) => s.split(/(\s+)/).map((p,i) => /\s/.test(p) ? p : {p});
const process = (node) => {
if (typeof node === 'string') return splitText(node);
if (Array.isArray(node)) return node.map(process);
if (React.isValidElement(node)) {
const newChildren = React.Children.map(node.props.children, ch =>
typeof ch === 'string' ? splitText(ch) : ch
);
return React.cloneElement(node, {key: node.key}, newChildren);
}
return node;
};
return {process(children)};
};
// Section opener — used at the top of each non-home page
const PageOpener = ({eyebrow, title, accent, lead, bg, bgLabel}) => (
{bg && (
{/* Base photograph, slow drift */}
{/* Cinematic gradient — bottom-heavy for type, dark sky at top */}
{/* Magenta / violet film wash — gives the "Netflix India" cast */}
{/* Tight vignette */}
{/* Soft film grain — heavier than before */}