diff --git a/protected/bifrost.js b/protected/bifrost.js index 70f732b..b52c3aa 100644 --- a/protected/bifrost.js +++ b/protected/bifrost.js @@ -352,12 +352,12 @@ // change after init. function collectStickyTargets() { const targets = []; - // platform-cards is included so once the Deployment Options - // section is centred, the wheel multiplier drops — the - // reader has to scroll a few extra ticks to continue, which - // pairs with the scroll-tied fade-in to give the section a - // subtle "stop" feel. - const sceneIds = ['hero', 'bifrost', 'platform-cards']; + // platform-cards is NOT a sticky-damping target — it has its + // own GSAP pin (see initCards in platform.js) which provides + // the "stop" feel. Layering both would slow wheel input to + // 0.35× during the pin and turn the 100vh budget into a + // ~285vh slog. platform-roadmap, same. + const sceneIds = ['hero', 'bifrost']; sceneIds.forEach(id => { const el = document.getElementById(id); if (el) targets.push(el); @@ -742,16 +742,51 @@ // 85% of vh so the reader arrives on the fully-drawn arc + // wordmark, regardless of display size. // + // platform-cards / platform-roadmap — pinned at 'center center', so + // the section's "intended landing" is the scroll position where + // section.centre aligns with viewport.centre. That position is + // section.offsetTop + (section.height - vh) / 2. For sections + // shorter than the viewport this offset is negative — the user + // lands just before the section's natural top, but the pin is + // engaged so visually they see the section centred at full + // opacity with the entire pin budget still ahead of them. + // // hero — short reveal tween; offsetTop is already the correct // landing spot so offset is 0. function getSceneAnchorOffset(sceneId) { const vh = window.innerHeight; switch (sceneId) { - case 'bifrost': return Math.round(vh * 0.85); - default: return 0; + case 'bifrost': + return Math.round(vh * 0.85); + case 'platform-cards': + case 'platform-roadmap': { + const el = document.getElementById(sceneId); + if (!el) return 0; + return Math.round((el.offsetHeight - vh) / 2); + } + default: + return 0; } } + // Section's offset within the scroller's content. Walks the + // offsetParent chain summing offsetTop. Necessary because + // ScrollTrigger's pinSpacing wraps pinned sections in a pin-spacer + // div (position: relative), which becomes the section's offsetParent + // — so `target.offsetTop` alone returns ~0 and scrollTo lands the + // user at the top of the page instead of the requested section. + function offsetTopWithin(el, scroller) { + let offset = 0; + let current = el; + while (current && current !== scroller) { + offset += current.offsetTop; + const parent = current.offsetParent; + if (!parent) break; + current = parent; + } + return offset; + } + function scrollTo(sceneId) { if (!scrollerEl) return; // init() hasn't run yet — ignore const target = document.getElementById(sceneId); @@ -760,7 +795,7 @@ // "hero" is the first scene and sits at scrollTop 0. Scrolling to // the scene element directly works in most cases but produces a tiny // non-zero offset (padding / border) — hard-code 0 for hero. - const base = sceneId === 'hero' ? 0 : target.offsetTop; + const base = sceneId === 'hero' ? 0 : offsetTopWithin(target, scrollerEl); const scrollY = base + getSceneAnchorOffset(sceneId); if (lenisInstance && typeof lenisInstance.scrollTo === 'function') { diff --git a/protected/platform.js b/protected/platform.js index 2ac70eb..f57392d 100644 --- a/protected/platform.js +++ b/protected/platform.js @@ -265,41 +265,128 @@ } // ─── Section A: Deployment options (cards) ────────────────── - // Scroll-tied fade-in for the WHOLE section content (header - // + four cards). Scrub ties opacity directly to scroll - // position so the section "arrives" as the reader scrolls - // into it, reaching full clarity when its top is well into - // the viewport. Paired with a sticky-damping entry in - // bifrost.js (added to sceneIds in collectStickyTargets) - // so once centred the wheel resists further scroll briefly - // — a subtle stop feel. + // Pin-with-scrub-and-release. Two ScrollTriggers share the + // section as trigger element: + // + // Phase A — fade IN (no pin), ~50vh of scroll input. + // Section moves up naturally; opacity 0→1 as + // its centre approaches viewport centre. Phase + // A's end is the exact scroll position at which + // the pin engages, so the perceived arrival is + // one continuous motion (no snap from "fading" + // to "pinned"). + // + // Phase B+C — PIN with scrubbed timeline, ~150vh of scroll + // input total. + // • 0 → 100vh of pin = HOLD at full opacity. + // The "deliberate pause"; scroll itself is + // never blocked, the pin just holds the + // section visually fixed at viewport + // centre while wheel/keyboard/touch input + // is spent against the budget. + // • 100 → 150vh of pin = fade OUT, still + // pinned. Section dissolves in place; + // pin releases at opacity 0, so the next + // content immediately scrolls into view. + // + // Bundling pin + fade-out into one trigger eliminates the + // cross-trigger dependency that previously had Phase C's + // range collapsing to a single frame (Lenis can use CSS + // transform on the scroller content, in which case + // scroller.scrollTop reads 0 and BCR-based scroll math + // gives a moving target instead of a fixed pin-release + // position). It also avoids a render-order race where + // Phase A would keep writing opacity=1 every scroll event + // past its range, fighting Phase C's writes. + // + // Trade-off vs the literal spec ("fade-out AFTER pin + // releases"): the fade happens while the section is still + // pinned at viewport centre rather than while scrolling + // upward after release. Visually the difference is subtle — + // the section dwells, dissolves, then the next content + // arrives. Implementation is rock solid. + // + // NEVER use CSS scroll-snap for this — snap is non-breakable + // and gives no scroll-budget mechanic. Users must always be + // able to break through by continuing to scroll. + // + // Reduced-motion: skip the fade and the pin entirely. The + // section appears at full opacity and scrolls past normally. function initCards(gsap, ScrollTrigger, scroller, reduceMotion) { - const head = document.querySelector('#platform-cards .platform-cards-head'); - const cards = Array.from(document.querySelectorAll('#platform-cards .platform-card')); + const section = document.getElementById('platform-cards'); + if (!section) return; + const head = section.querySelector('.platform-cards-head'); + const cards = Array.from(section.querySelectorAll('.platform-card')); const targets = [head, ...cards].filter(Boolean); if (!targets.length) return; if (reduceMotion) { targets.forEach(c => { c.style.opacity = '1'; c.style.transform = 'none'; }); return; } - gsap.set(targets, { opacity: 0, y: 24 }); - gsap.to(targets, { - opacity: 1, - y: 0, - ease: 'power2.out', - stagger: 0.05, + + gsap.set(targets, { opacity: 0, y: 18 }); + + // Phase A — fade IN, ~50vh (center bottom → center center). + ScrollTrigger.create({ + trigger: section, + scroller, + start: 'center bottom', + end: 'center center', + scrub: 0.5, + animation: gsap.fromTo( + targets, + { opacity: 0, y: 18 }, + { opacity: 1, y: 0, ease: 'none' } + ), + }); + + // Phase B+C — pin for 150vh with scrubbed timeline. + // Timeline duration = 1.5 units; ScrollTrigger maps scroll + // progress 0→1 across the 150vh pin onto timeline 0→1.5. + // t = 0 → 1.0 : HOLD. A dummy tween on an empty + // object reserves the time slot so the + // timeline's totalDuration is 1.5 + // rather than 0.5; nothing is written + // to the targets, so opacity stays at + // Phase A's settled =1. + // t = 1.0 → 1.5 : fade OUT. fromTo with explicit from- + // state {opacity:1, y:0} guarantees the + // fade actually starts at full opacity + // regardless of what GSAP captures or + // when — the previous gsap.to here was + // silently recording opacity=0 (the + // initial gsap.set value) as its from, + // so it animated 0→0 during the visible + // range and only snapped to 0 once past + // the end. immediateRender:false keeps + // the from-state off the targets until + // the playhead actually reaches t=1. + const pinTl = gsap.timeline({ scrollTrigger: { - trigger: '#platform-cards', + trigger: section, scroller, - // Begin fading in as the section's top enters the - // viewport from below; finish by the time it's well - // inside (top at 35% from viewport top) so the section - // is fully clear before the reader reaches centre. - start: 'top bottom', - end: 'top 35%', - scrub: 0.6, + start: 'center center', + end: '+=150%', + pin: true, + pinSpacing: true, + pinType: 'transform', + scrub: 0.5, }, }); + pinTl + .to({}, { duration: 1 }, 0) + .fromTo( + targets, + { opacity: 1, y: 0 }, + { + opacity: 0, + y: -18, + ease: 'none', + duration: 0.5, + immediateRender: false, + }, + 1 + ); } // ─── Wiki deep-dive — pinned scrubbed five-beat ───────────── @@ -546,62 +633,61 @@ } } - // ─── Implementation roadmap — fade-in stagger + card morph - // Mirrors initCards for the reveal. After the reveal, each - // card is click-to-expand — same DOM element morphs into the - // featured panel via a FLIP layout animation (see - // setupRoadmapMorph below). The expanded card's content - // (intro + key activities) is already in the HTML; we just - // toggle the .is-expanded class and animate the layout shift. + // ─── Implementation roadmap — pin-with-scrub-release + card morph + // Same pin pattern as initCards (Phase A + Phase B), but no + // Phase C fade-out: this is the page's final section and stays + // visible as the page ends. Header (eyebrow + title), stages, + // band, and foot all fade in together so the section arrives + // as one unit — anything inside the section that wasn't part + // of the reveal would look disjointed once the pin engages and + // freezes the section at viewport centre. + // + // Click-to-expand (setupRoadmapMorph) is wired regardless of + // reduced-motion; it's a discrete interaction, not ambient. function initRoadmap(gsap, ScrollTrigger, scroller, reduceMotion) { - const stages = document.querySelectorAll('#platform-roadmap .rm-card'); - const band = document.querySelector('#platform-roadmap .rm-band'); - const foot = document.querySelector('#platform-roadmap .rm-foot'); + const section = document.getElementById('platform-roadmap'); - // Click-to-expand always wires up (even under reduced-motion), - // since the expansion is a discrete interaction rather than - // an ambient animation. setupRoadmapMorph(reduceMotion); - if (!stages.length) return; + if (!section) return; + const header = section.querySelector('.platform-cards-head'); + const stages = Array.from(section.querySelectorAll('.rm-card')); + const band = section.querySelector('.rm-band'); + const foot = section.querySelector('.rm-foot'); + const targets = [header, ...stages, band, foot].filter(Boolean); + if (!targets.length) return; + if (reduceMotion) { - stages.forEach(c => { c.style.opacity = '1'; }); - if (band) band.style.opacity = '1'; - if (foot) foot.style.opacity = '1'; + targets.forEach(c => { c.style.opacity = '1'; c.style.transform = 'none'; }); return; } - gsap.set(stages, { opacity: 0, y: 24 }); - if (band) gsap.set(band, { opacity: 0, y: 16 }); - if (foot) gsap.set(foot, { opacity: 0, y: 12 }); - const tl = gsap.timeline({ - scrollTrigger: { - trigger: '#platform-roadmap', - scroller, - start: 'top 70%', - once: true, - }, + gsap.set(targets, { opacity: 0, y: 18 }); + + // Phase A — fade IN, ~50vh (center bottom → center center). + ScrollTrigger.create({ + trigger: section, + scroller, + start: 'center bottom', + end: 'center center', + scrub: 0.5, + animation: gsap.fromTo( + targets, + { opacity: 0, y: 18 }, + { opacity: 1, y: 0, ease: 'none' } + ), }); - tl.to(stages, { - opacity: 1, - y: 0, - duration: 0.6, - ease: 'power3.out', - stagger: 0.08, - clearProps: 'transform', + + // Phase B — pin for 100vh of scroll input. No fade-out. + ScrollTrigger.create({ + trigger: section, + scroller, + start: 'center center', + end: '+=100%', + pin: true, + pinSpacing: true, + pinType: 'transform', }); - if (band) { - tl.to(band, { - opacity: 1, y: 0, - duration: 0.5, ease: 'power2.out', - }, '-=0.25'); - } - if (foot) { - tl.to(foot, { - opacity: 1, y: 0, - duration: 0.4, ease: 'power2.out', - }, '-=0.25'); - } } // ─── Roadmap card morph (FLIP) ─────────────────────────────── diff --git a/protected/timeline.js b/protected/timeline.js index d8d3c27..3b885f6 100644 --- a/protected/timeline.js +++ b/protected/timeline.js @@ -394,6 +394,18 @@ function activatePage(targetId, scrollToId) { // the user can't scroll before ScrollTriggers are wired up. setTimeout(() => { window.__bifrost.init(); + // Run platform.js's init in the SAME tick. Without this, + // platform's own MutationObserver fires it ~140ms after + // page activation — too late for the scrollTo below, which + // reads target.offsetTop. Platform installs pin spacers for + // #platform-cards and #platform-roadmap; those spacers shift + // sections downstream of cards by 150vh, so scrolling to + // pre-spacer offsetTop lands the user in empty space. + // __platform.init is idempotent (guarded by `initialized`), + // so calling it here is a no-op if it already ran. + if (window.__platform && typeof window.__platform.init === 'function') { + window.__platform.init(); + } // After init resolves, scroll to the requested scene (or top // if none specified). Bifrost exposes scrollTo() which drives // Lenis on the overview's internal scroller.