// ════════════════════════════════════════════════════════════════════ // NexFlow · Motion coordinator // ──────────────────────────────────────────────────────────────────── // A single rAF loop reads window.scrollY once per frame, derives: // // progress · 0..1 across the whole document // velocity · signed, smoothed, normalised to ±1 // sectionProgress · 0..1 through the section under the viewport mid-line // // and forwards them to four consumers, in this order: // // 1. CSS custom props on :root (--sp, --sv, --section-p) // so any Pillar B polish can ride on the same signal without JS. // 2. The scroll-progress bar width. // 3. NexFlowScene.setOpacity() · hero → ambient fade (was scroll.js) // 4. NexFlowScene.setProgress() · camera dolly + per-mode scroll bind // NexFlowScene.setVelocity() · subtle roll + shader contrast lift // NexFlowScene.setSectionProgress() // // scroll.js still owns the discrete things (reveals IO, mode-swap IO). // We intentionally keep this file pure per-frame state — no IOs here. // // Layout is cached on load + resize + `nexflow:react-mounted` event so // section offsets are correct after the React tree paints. // ════════════════════════════════════════════════════════════════════ (function (global) { 'use strict'; // ── State ───────────────────────────────────────────────────────── let lastY = (typeof window !== 'undefined') ? window.scrollY : 0; let lastT = (typeof performance !== 'undefined') ? performance.now() : 0; let smoothedV = 0; let progress = 0; let sectionP = 0; let activeSec = null; let bar = null; let sections = []; let cachedDocH = 0; let cachedWinH = 0; let started = false; // ── Layout cache ────────────────────────────────────────────────── // Reading offsetTop/offsetHeight every frame would be fine on modern // browsers as long as no writes happen between, but we already write // CSS vars + stage opacity per frame — caching avoids forced reflow. function cacheLayout() { cachedDocH = Math.max( document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0 ); cachedWinH = window.innerHeight; // Reading scope mirrors scroll.js's: any
with an id, plus // unidentified sections inside
. Both engines stay in sync. sections = Array.from(document.querySelectorAll('main section, section[id]')); } // Pick the section whose vertical span contains the viewport // mid-line. Linear scan over ~10 sections — negligible cost. function pickSection(y) { const vc = y + cachedWinH * 0.5; for (let i = 0; i < sections.length; i++) { const s = sections[i]; const top = s.offsetTop; const h = s.offsetHeight; if (vc >= top && vc < top + h) return s; } return null; } // ── Frame loop ──────────────────────────────────────────────────── function tick(now) { requestAnimationFrame(tick); const dt = Math.max(0.001, (now - lastT) * 0.001); // seconds, never zero lastT = now; // ── Layout-aware scroll source ───────────────────────────────── // v10's horizontal-scroll homepage locks the body at overflow:hidden, // so window.scrollY is pinned at 0 and the whole engine goes dead. // When body.nf-h-on is set, read the horizontal track (main.scrollLeft) // instead — this single switch revives the camera dolly, shader drift, // particle coupling, the workflow packet's scroll-pull and the bar. // The vertical path below is kept numerically identical (pos === y). const horizontal = !!(document.body && document.body.classList.contains('nf-h-on')); const hMain = horizontal ? document.getElementById('main') : null; let pos, denom, viewExtent; if (horizontal && hMain) { pos = hMain.scrollLeft; viewExtent = Math.max(1, hMain.clientWidth); denom = Math.max(1, hMain.scrollWidth - hMain.clientWidth); } else { pos = window.scrollY; viewExtent = Math.max(1, cachedWinH); denom = Math.max(1, cachedDocH - cachedWinH); } const dpos = pos - lastY; lastY = pos; // Velocity: px/sec → normalised to ±1 around 4000 px/sec (a "fast" // user scroll). Smoothed with a moderate EMA so a single big tick // doesn't yank the scene. Decays toward 0 when the page is idle. const instantV = dpos / dt; const normV = Math.max(-1, Math.min(1, instantV / 4000)); smoothedV += (normV - smoothedV) * 0.15; // Page progress (0..1 across the whole track, vertical OR horizontal) progress = Math.max(0, Math.min(1, pos / denom)); // Section progress if (horizontal && hMain) { // Under hscroll each panel is one viewport wide; use the fractional // position within the current panel (0 at a settled panel, sweeping // 0→1 as you cross to the next) to drive the per-mode scroll-pull. const frac = pos / viewExtent; sectionP = frac - Math.floor(frac); } else { // Vertical: pick the section spanning the viewport mid-line. const sec = pickSection(pos); if (sec) { activeSec = sec; const top = sec.offsetTop; const h = Math.max(1, sec.offsetHeight); const local = (pos + cachedWinH * 0.5 - top) / h; sectionP = Math.max(0, Math.min(1, local)); } else if (!activeSec) { sectionP = 0; } } // 1 · CSS custom props (clamped, 4dp to keep paint cheap) const root = document.documentElement; root.style.setProperty('--sp', progress.toFixed(4)); root.style.setProperty('--sv', Math.abs(smoothedV).toFixed(4)); root.style.setProperty('--section-p', sectionP.toFixed(4)); // 2 · Scroll progress bar if (bar) bar.style.width = (progress * 100).toFixed(2) + '%'; // 3 + 4 · Scene const NS = global.NexFlowScene; if (NS) { // Hero → ambient stage opacity. viewExtent is the page's "first // screen" extent (window height vertically, panel width horizontally), // so the fade reads identically in both layouts. const heroFade = pos / Math.max(1, viewExtent * 0.55); const ambient = 0.55; const stageOp = heroFade < 1 ? (1 - (1 - ambient) * heroFade) : ambient; if (NS.setOpacity) NS.setOpacity(stageOp); if (NS.setProgress) NS.setProgress(progress); if (NS.setVelocity) NS.setVelocity(smoothedV); if (NS.setSectionProgress) NS.setSectionProgress(sectionP); } } // ── Public API ──────────────────────────────────────────────────── function start() { if (started) return; started = true; bar = document.querySelector('.scroll-progress'); cacheLayout(); window.addEventListener('resize', cacheLayout, { passive: true }); // React mounts after this script parses; the App effect dispatches // 'nexflow:react-mounted' so we re-cache section offsets once the // tree is in the DOM. Belt + braces: we also re-cache on any // subsequent firing (tabs that change layout, etc). window.addEventListener('nexflow:react-mounted', cacheLayout); // One frame after first paint — sections may still be measuring. requestAnimationFrame(() => requestAnimationFrame(cacheLayout)); lastT = performance.now(); requestAnimationFrame(tick); } global.NexFlowMotion = { getProgress: () => progress, getVelocity: () => smoothedV, getSectionProgress: () => sectionP, getActiveSection: () => activeSec, refreshLayout: cacheLayout, }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start, { once: true }); } else { start(); } })(window);