// ════════════════════════════════════════════════════════════════════ // NexFlow · Scroll engine // ──────────────────────────────────────────────────────────────────── // Owns the *discrete* scroll-driven behaviours: // // 1. `.reveal` elements fade/translate in when ~25% in viewport. // 2. The 3D scene cross-fades between modes (workflow / agents / // chatbot / document / dashboard) based on the section currently // most in view. Per-section mode is read from the section's // `data-scene-mode` attribute, with a sensible fallback table. // // The *continuous* per-frame work — scroll progress bar, stage opacity, // CSS scroll vars, NexFlowScene.setProgress/setVelocity — was moved to // engine/motion.js so there is one rAF loop reading scrollY per frame // rather than two. // // No GSAP required: IntersectionObserver does the heavy lifting. // ════════════════════════════════════════════════════════════════════ (function () { 'use strict'; // ── 1. Reveal-on-scroll ──────────────────────────────────────── function startReveals() { const els = document.querySelectorAll('.reveal, .reveal-stagger'); if (!els.length) return; const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } }); }, { threshold: 0.25, rootMargin: '0px 0px -20px 0px' }); els.forEach(el => io.observe(el)); } // ── 2. Section → scene-mode mapping ──────────────────────────── // Map of section ids → scene mode. A section can also declare its // own mode via `data-scene-mode`; that wins. The Services tab in // Sections.jsx separately calls setMode() to override based on the // active service tile. const SECTION_MODE = { 'hero': 'workflow', 'market': 'workflow', 'services': null, // driven by Services tab interaction 'how': 'workflow', 'case-studies': 'document', 'pricing': 'dashboard', 'contact': 'chatbot', }; // Resolve a section element to its scene mode: an explicit // `data-scene-mode` wins, otherwise the id table above. Returns a string // (switch to it), `null` (controlled by another input — leave alone), or // `undefined` (unknown section — keep the current mode). Exposed so the // horizontal-scroll engine can drive modes by panel index instead of the // vertical IntersectionObserver, which is unreliable under hscroll. function resolveMode(el) { if (!el) return undefined; const explicit = el.getAttribute('data-scene-mode'); if (explicit) return explicit; return SECTION_MODE[el.id || '']; } window.NexFlowSceneModeFor = resolveMode; // A small priority lock — when the Services tab manually sets a mode // we want to respect it as the user scrolls within that section. The // Sections.jsx tab handler bumps `__nf_scene_lock_until` so that // observer events for `#services` defer for a short grace period. // (The lock is intentionally local to the IO callback, not global // state, to avoid surprising other consumers of setMode.) let _modesStarted = false; function startSceneModes() { // Guarded singleton: both the 'nexflow:react-mounted' event and // the 500ms fallback timeout call us. Without this guard each call // would attach a duplicate IntersectionObserver per section. if (_modesStarted) return; const sections = document.querySelectorAll( 'section[id], main section' ); if (!sections.length || !window.NexFlowScene) return; _modesStarted = true; let lastMode = 'workflow'; const visibility = new Map(); const io = new IntersectionObserver((entries) => { // Under the horizontal-scroll homepage, panel order doesn't map to // vertical intersection — hscroll.js drives scene modes by panel index // instead (via window.NexFlowSceneModeFor). Yield to it here. if (document.body.classList.contains('nf-h-on')) return; entries.forEach(e => { visibility.set(e.target, e.intersectionRatio); }); // Pick the section currently most in view let best = null, bestRatio = 0; visibility.forEach((ratio, el) => { if (ratio > bestRatio) { bestRatio = ratio; best = el; } }); if (!best || bestRatio < 0.10) return; const explicit = best.getAttribute('data-scene-mode'); const id = best.id || ''; let mode = explicit || SECTION_MODE[id]; // Section says "controlled by another input" — leave mode alone. if (mode === null) return; // Section we don't recognise — keep the last mode. if (!mode) return; if (mode !== lastMode) { lastMode = mode; window.NexFlowScene.setMode(mode); } }, { threshold: [0, 0.10, 0.25, 0.45, 0.65, 0.90], rootMargin: '-6% 0px -6% 0px', }); sections.forEach(s => io.observe(s)); } // Two triggers for first run, whichever lands first: // · App.jsx dispatches 'nexflow:react-mounted' from its mount // useEffect — the deterministic, fast path. // · A 500ms fallback covers the (unlikely) case where this script // parses after that event has already fired or the dispatch // never happens (e.g., React tree fails to mount). function bootstrap() { startReveals(); startSceneModes(); } window.addEventListener('nexflow:react-mounted', bootstrap); setTimeout(bootstrap, 500); // Re-run reveals on dynamic content (tab swaps surface new nodes) window.NexFlowReveal = startReveals; })();