/* ============================================================
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 = `
PraviResearch
`;
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 (
{word}{em}
);
}
/* Thin scroll-progress bar (gradient, scaleX driven by --p on the stage). */
function ProgressBar() {
return