/* ============================================================ PRAVI — SHARED MOTION ENGINE Generalized from the Pravi Labs page so Home / About / Services / Careers / Resources share one identical cinematic motion layer. Layout & content are untouched — this only adds scroll reveals, word-level heading rises, scroll-progress, ambient blobs, an intro curtain (Home only), parallax, and chrome (nav/footer/cta) motion. IMPORTANT: This file is loaded ONLY on the five pages above, never on the Labs page, so the Labs page stays byte-for-byte unchanged. All animation is hand-built (CSS keyframes + IntersectionObserver + rAF scroll) — no external motion library (vanilla React via CDN). ============================================================ */ (function () { const { useRef, useEffect, useState } = React; const REDUCED = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; /* Intro curtain on-screen duration (ms). Above-fold hero reveals are held until this elapses so they play AS the curtain lifts (Home only). */ const INTRO_MS = 1450; /* =================================================================== REVEAL ENGINE — IntersectionObserver. Adds `.in` to reveal elements and `.seen` to section dividers. Non-destructive: only ever adds the class, so scroll-linked effects survive. Stagger handled in CSS via an inline `--d` delay. Also auto-tags shared chrome (footer cols, CTA) with reveal classes so the common components animate without editing common.jsx (which the Labs page also renders). =================================================================== */ function useReveal(rootRef) { useScrollFX(rootRef); useEffect(() => { if (!rootRef.current) return; const root = rootRef.current; /* Auto-tag shared chrome (only these pages load motion.jsx). */ const tag = (el, cls, d) => { if (!el || el.classList.contains(cls)) return; el.classList.add(cls); if (d != null) el.style.setProperty('--d', d + 'ms'); }; // Inject ambient scrolling background elements if not already present if (!root.querySelector('.ambient-scroller')) { const scroller = document.createElement('div'); scroller.className = 'ambient-scroller'; scroller.innerHTML = `
`; root.appendChild(scroller); } // Check if loader should show in this session lifecycle (fresh load or page reload) const isReload = window.performance && window.performance.getEntriesByType && window.performance.getEntriesByType('navigation')[0] && window.performance.getEntriesByType('navigation')[0].type === 'reload'; const hasPlayed = sessionStorage.getItem('pravi_loader_played'); const shouldShowLoader = (isReload || !hasPlayed) && !REDUCED; let hasLoader = !!root.querySelector('.cinematic-loader'); let loader = null; if (shouldShowLoader) { sessionStorage.setItem('pravi_loader_played', 'true'); if (!hasLoader) { hasLoader = true; loader = document.createElement('div'); loader.className = 'cinematic-loader'; loader.innerHTML = `
Pravi Research
`; document.body.appendChild(loader); setTimeout(() => { loader.classList.add('animate-content'); }, 80); setTimeout(() => { loader.classList.add('lift'); }, INTRO_MS); setTimeout(() => { loader.remove(); }, INTRO_MS + 1100); } } else { // Skip loader on internal navigation const existingReactLoader = root.querySelector('.cinematic-loader'); if (existingReactLoader) { existingReactLoader.remove(); } hasLoader = false; } // footer columns + brand root.querySelectorAll('.home-footer-grid > div').forEach((el, i) => tag(el, 'reveal', i * 80)); // footer bottom bar const fbar = root.querySelector('footer .container > div:last-child'); if (fbar && fbar.querySelector('a[href*="Privacy"]')) tag(fbar, 'reveal', 360); // Auto-stagger direct sibling reveals const revealElements = root.querySelectorAll('.reveal, .reveal-l, .reveal-r'); const parents = new Set(); revealElements.forEach(el => { if (el.parentElement) parents.add(el.parentElement); }); parents.forEach(parent => { const children = Array.from(parent.children).filter(c => c.classList.contains('reveal') || c.classList.contains('reveal-l') || c.classList.contains('reveal-r') ); if (children.length > 1) { children.forEach((child, i) => { if (!child.style.getPropertyValue('--d') && !child.style.transitionDelay) { child.style.setProperty('--d', (i * 75) + 'ms'); } }); } }); const cls = (el) => el.classList.contains('motion-sec') ? 'seen' : 'in'; const sel = '.reveal, .reveal-l, .reveal-r, .motion-sec, .motion-head'; const els = root.querySelectorAll(sel); if (REDUCED || !('IntersectionObserver' in window)) { els.forEach(el => el.classList.add(cls(el))); return; } const io = new IntersectionObserver((entries) => { entries.forEach(e => { const c = cls(e.target); if (e.isIntersecting) e.target.classList.add(c); else if (c === 'in') e.target.classList.remove('in'); // replay reveals on re-scroll }); }, { threshold: 0.12, rootMargin: '0px 0px -60px 0px' }); // If a Home-style intro curtain is present, hold observation until it lifts. const start = () => root.querySelectorAll(sel).forEach(el => io.observe(el)); let t; if (hasLoader) t = setTimeout(start, INTRO_MS); else start(); // Safety: never leave content invisible if something goes wrong. const safety = setTimeout(() => { if (REDUCED) return; root.querySelectorAll(sel).forEach(el => { const r = el.getBoundingClientRect(); if (r.top < window.innerHeight && r.bottom > 0) el.classList.add(cls(el)); }); }, (hasLoader ? INTRO_MS : 0) + 1600); return () => { if (t) clearTimeout(t); clearTimeout(safety); io.disconnect(); }; }, []); } /* =================================================================== SCROLL FX — single rAF-throttled listener → drives parallax (--scrollY on stage) and the top scroll-progress bar (--p, 0..1). =================================================================== */ function useScrollFX(rootRef) { useEffect(() => { if (!rootRef.current || REDUCED) return; const root = rootRef.current; if (root.dataset.scrollFxActive) return; root.dataset.scrollFxActive = 'true'; let ticking = false; const apply = () => { const y = window.scrollY || window.pageYOffset || 0; const max = (document.documentElement.scrollHeight - window.innerHeight) || 1; root.style.setProperty('--scrollY', y + 'px'); root.style.setProperty('--p', Math.min(1, Math.max(0, y / max)).toFixed(4)); // Viewport-relative, bounded parallax for images (no edge gaps on long pages). const vh = window.innerHeight || 1; root.querySelectorAll('.motion-par-img').forEach(el => { const r = el.getBoundingClientRect(); const center = r.top + r.height / 2; const pp = Math.max(-1, Math.min(1, (center - vh / 2) / (vh / 2))); el.style.setProperty('--pp', pp.toFixed(3)); }); ticking = false; }; const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(apply); } }; apply(); window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll, { passive: true }); return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); }; }, []); } /* =================================================================== SPLIT TEXT — word-level mask-rise for headings. Accepts an array of { t, c, br } segments. `c` = 'blue' | 'orange' colours those words, `br:true` inserts a hard line break (to preserve existing layout). Words animate when an ancestor `.reveal/.reveal-l/.reveal-r` gains `.in` (CSS-driven). Words & punctuation are preserved verbatim. =================================================================== */ function SplitText({ segments, base = 0, step = 55 }) { let wi = 0; return ( {segments.map((seg, si) => { if (seg.br) return
; const words = seg.t.split(/\s+/).filter(Boolean); const cc = seg.c === 'orange' ? ' split-orange' : seg.c === 'blue' ? ' split-blue' : ''; const ital = seg.i ? ' split-ital' : ''; return words.map((w, k) => { const d = base + (wi++) * step; return ( {w} {' '} ); }); })}
); } /* =================================================================== HEAD — mask-rise heading. Preserves the heading's existing markup, fonts and colours verbatim (em/i, line breaks, gradients) while the whole heading rises out of an overflow-hidden mask when scrolled in. Used instead of per-word SplitText because these headings mix Italiana and DM Serif fonts that per-word splitting would flatten. =================================================================== */ function Head({ tag = 'h2', className = '', style, d = 0, children }) { const Tag = tag; return ( {children} ); } /* small arrow used in buttons (matches Labs) */ function Arrow({ size = 16, color = 'currentColor' }) { return ( ); } /* =================================================================== INTRO LOADER — logo bars draw in, wordmark fades up, curtain lifts. Home only. `word` + `em` form the wordmark (e.g. "Pravi" / "Research"). =================================================================== */ function IntroLoader({ word = 'Pravi', em = 'Research' }) { const isReload = typeof window !== 'undefined' && window.performance && window.performance.getEntriesByType && window.performance.getEntriesByType('navigation')[0] && window.performance.getEntriesByType('navigation')[0].type === 'reload'; const hasPlayed = typeof sessionStorage !== 'undefined' && sessionStorage.getItem('pravi_loader_played'); const shouldShow = isReload || !hasPlayed; const [done, setDone] = useState(REDUCED || !shouldShow); const [animate, setAnimate] = useState(false); useEffect(() => { if (REDUCED || !shouldShow) return; const tAnimate = setTimeout(() => setAnimate(true), 80); const tDone = setTimeout(() => setDone(true), INTRO_MS); return () => { clearTimeout(tAnimate); clearTimeout(tDone); }; }, []); if (REDUCED || !shouldShow) return null; return ( ); } /* Thin scroll-progress bar (gradient, scaleX driven by --p on the stage). */ function ProgressBar() { return ; } /* Ambient drifting blurred colour blob. tone: 'blue' | 'orange'. */ function Blob({ tone = 'blue', className = '', style = {} }) { return