// ───────────────────────────────────────────────────────────── // protected/platform.js — Product Deepdive page // // Owns #page-product-deepdive: a self-contained top-level page // reached via the "Product Deepdive" dot. Sections (in order): // #platform-question — full-viewport framing statement (fade-in) // #platform-layers — pinned scrubbed four-beat architecture build // #platform-cards — "Choose your Capability" deployment options // (final section; centred when at scroll end) // // This page has its OWN internal scroller (#product-deepdive-scroll) // with its OWN Lenis instance and its OWN ScrollTrigger.scrollerProxy // — fully isolated from bifrost.js's setup on #overview-scroll. Every // ScrollTrigger created here passes `scroller` explicitly so it never // inherits ScrollTrigger.defaults from bifrost. // // Self-defers init until #page-product-deepdive gains `is-active`, // so vendor libs are loaded and the scroller has real dimensions. // // CSP: 'script-src self'. No inline scripts anywhere. // ───────────────────────────────────────────────────────────── (function () { 'use strict'; let initialized = false; let scrollerEl = null; let lenisInstance = null; function init() { if (initialized) return; if (typeof window.gsap === 'undefined' || typeof window.ScrollTrigger === 'undefined' || typeof window.Lenis === 'undefined') { console.warn('[deepdive] gsap/ScrollTrigger/Lenis missing; skipping init.'); return; } const scroller = document.getElementById('product-deepdive-scroll'); if (!scroller) { console.warn('[deepdive] #product-deepdive-scroll not found; skipping init.'); return; } initialized = true; scrollerEl = scroller; const gsap = window.gsap; const ScrollTrigger = window.ScrollTrigger; const Lenis = window.Lenis; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; gsap.registerPlugin(ScrollTrigger); // Lenis on the deepdive's internal scroller (NOT the window). // Mirrors bifrost.js's pattern so wheel/touch input drives this // scroller smoothly while the architecture scrub stays buttery. if (!reduceMotion) { const lenis = new Lenis({ wrapper: scroller, content: scroller.firstElementChild, duration: 1.15, easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), smoothWheel: true, wheelMultiplier: 1, touchMultiplier: 1.5, }); // Tell ScrollTrigger how to read scroll on this scroller. pinType // 'transform' is required because the scroller is itself an // overflow-scroll element rather than the window. ScrollTrigger.scrollerProxy(scroller, { scrollTop(value) { if (arguments.length) { scroller.scrollTop = value; } return scroller.scrollTop; }, getBoundingClientRect() { return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight }; }, pinType: 'transform', }); lenis.on('scroll', ScrollTrigger.update); gsap.ticker.add((time) => lenis.raf(time * 1000)); gsap.ticker.lagSmoothing(0); lenisInstance = lenis; } initQuestion(gsap, ScrollTrigger, scroller, reduceMotion); initLayers(gsap, ScrollTrigger, scroller, reduceMotion); initCards(gsap, ScrollTrigger, scroller, reduceMotion); // Refresh now that the page is laid out and triggers exist. if (!reduceMotion) ScrollTrigger.refresh(); } // ─── "The Layers" entry section ───────────────────────────── // Pinned scrubbed five-beat build. // // Beat 1 — Foundation wrapper + Language model card. // Beat 2 — Wiki + Routines & memory cards (stagger). // Beat 3 — Tools wrapper + 4 cards (stagger). // Beat 4 — Agents wrapper + 4 cards (stagger). // Beat 5 — Closing summary copy panel; diagram is fully // assembled and unchanged. // // Layout invariant: the canvas reserves its full assembled // height from the start, with each .pl-group at its final // vertical slot. Reveals are pure opacity/translate — no card // ever moves once it has settled, because the Foundation grid // is 3-col throughout (slots for Wiki and Routines exist from // frame 0, just invisible) and Tools/Agents wrappers occupy // their layout space (opacity 0) from frame 0. The .pl-pin // header (title + subtitle) is statically rendered — visible // before any beat fires, untouched by the timeline. function initLayers(gsap, ScrollTrigger, scroller, reduceMotion) { const section = document.getElementById('platform-layers'); if (!section) return; const copies = Array.from(section.querySelectorAll('.pl-copy-step')); const groupF = section.querySelector('[data-layer="foundation"]'); const groupT = section.querySelector('[data-layer="tools"]'); const groupA = section.querySelector('[data-layer="agents"]'); const cardsF = groupF ? Array.from(groupF.querySelectorAll('.pl-card')) : []; const cardsT = groupT ? Array.from(groupT.querySelectorAll('.pl-card')) : []; const cardsA = groupA ? Array.from(groupA.querySelectorAll('.pl-card')) : []; if (!groupF || !groupT || !groupA || copies.length !== 5 || cardsF.length !== 3 || cardsT.length !== 4 || cardsA.length !== 4) { console.warn('[deepdive] platform-layers DOM mismatch — expected 5 copy steps, 3 layer groups, 3+4+4 cards.'); return; } if (reduceMotion) { // CSS @media handles the unfold; nothing for JS to do. return; } // Initial states. All three layer wrappers occupy their final // grid slots from frame 0; only opacity is animated for the // wrappers themselves. Cards animate y+opacity within their // pre-allocated grid cells. Text panels fade + 14px translate. gsap.set([groupF, groupT, groupA], { opacity: 0 }); gsap.set([...cardsF, ...cardsT, ...cardsA], { opacity: 0, y: 24 }); gsap.set(copies, { opacity: 0, y: 14 }); const BEAT = 1.0; const tl = gsap.timeline({ scrollTrigger: { trigger: '#platform-layers', scroller, start: 'top top', end: '+=500%', pin: '.pl-pin', pinType: 'transform', scrub: 0.5, }, }); // Helper — previous-copy fade-out. Skipped on Beat 1. function fadeOutPrev(i, t) { if (i === 0) return; tl.to(copies[i - 1], { opacity: 0, y: -12, duration: 0.06, ease: 'power2.in' }, t); } // Helper — new-copy fade-in. function fadeInCopy(i, t) { tl.to(copies[i], { opacity: 1, y: 0, duration: 0.10, ease: 'power2.out' }, t + 0.26); } // Beat 1 — Foundation wrapper appears, Language model card lands. const t1 = 0 * BEAT; fadeOutPrev(0, t1); tl.to(groupF, { opacity: 1, duration: 0.10, ease: 'power3.out' }, t1 + 0.06); tl.to(cardsF[0], { opacity: 1, y: 0, duration: 0.20, ease: 'power3.out' }, t1 + 0.10); fadeInCopy(0, t1); // Beat 2 — Wiki + Routines cards stagger in alongside Language model. const t2 = 1 * BEAT; fadeOutPrev(1, t2); tl.to([cardsF[1], cardsF[2]], { opacity: 1, y: 0, duration: 0.20, ease: 'power3.out', stagger: 0.04, }, t2 + 0.06); fadeInCopy(1, t2); // Beat 3 — Tools wrapper appears, 4 cards stagger left-to-right. const t3 = 2 * BEAT; fadeOutPrev(2, t3); tl.to(groupT, { opacity: 1, duration: 0.10, ease: 'power3.out' }, t3 + 0.06); tl.to(cardsT, { opacity: 1, y: 0, duration: 0.20, ease: 'power3.out', stagger: 0.04, }, t3 + 0.10); fadeInCopy(2, t3); // Beat 4 — Agents wrapper appears, 4 cards stagger left-to-right. const t4 = 3 * BEAT; fadeOutPrev(3, t4); tl.to(groupA, { opacity: 1, duration: 0.10, ease: 'power3.out' }, t4 + 0.06); tl.to(cardsA, { opacity: 1, y: 0, duration: 0.20, ease: 'power3.out', stagger: 0.04, }, t4 + 0.10); fadeInCopy(3, t4); // Beat 5 — closing summary panel. Diagram is fully assembled // by now; only the copy stage swaps to the summary text. const t5 = 4 * BEAT; fadeOutPrev(4, t5); fadeInCopy(4, t5); } // ─── "The Platform" Part A: The Question ──────────────────── // Full-viewport question moment; just the title + subtitle, with // a simple stagger fade-in. Same gate the cards use. function initQuestion(gsap, ScrollTrigger, scroller, reduceMotion) { const els = document.querySelectorAll( '#platform-question .pq-title, #platform-question .pq-body' ); if (!els.length) return; if (reduceMotion) { els.forEach(e => { e.style.opacity = '1'; }); return; } gsap.set(els, { opacity: 0, y: 18 }); gsap.to(els, { opacity: 1, y: 0, duration: 0.7, ease: 'power3.out', stagger: 0.15, scrollTrigger: { trigger: '#platform-question', scroller, start: 'top 70%', once: true, }, }); } // ─── Section A: Cards ──────────────────────────────────────── function initCards(gsap, ScrollTrigger, scroller, reduceMotion) { const cards = document.querySelectorAll('#platform-cards .platform-card'); if (!cards.length) return; if (reduceMotion) { cards.forEach(c => { c.style.opacity = '1'; }); return; } gsap.set(cards, { opacity: 0, y: 24 }); gsap.to(cards, { opacity: 1, y: 0, duration: 0.6, ease: 'power3.out', stagger: 0.08, scrollTrigger: { trigger: '#platform-cards', scroller, start: 'top 70%', once: true, }, }); } // ─── Public scrollTo (used when the dot is re-clicked while // already on the deepdive page) ────────────────────────────── function scrollTo(top) { if (!initialized || !scrollerEl) return; const y = typeof top === 'number' ? top : 0; if (lenisInstance && typeof lenisInstance.scrollTo === 'function') { lenisInstance.scrollTo(y, { immediate: false }); } else { scrollerEl.scrollTo({ top: y, behavior: 'smooth' }); } } // ─── Lazy auto-init on page activation ─────────────────────── // We can't piggyback on bifrost's init (different page) and the // dot-nav handler in timeline.js doesn't know about us. Instead, // observe #page-product-deepdive for the `is-active` class flip // that activatePage() applies — then init a beat later so the // browser has applied layout. function tryInit() { if (initialized) return; const page = document.getElementById('page-product-deepdive'); if (!page || !page.classList.contains('is-active')) return; if (typeof window.gsap === 'undefined' || typeof window.ScrollTrigger === 'undefined' || typeof window.Lenis === 'undefined') return; setTimeout(init, 60); } function attachObserver() { const page = document.getElementById('page-product-deepdive'); if (!page) return; new MutationObserver(tryInit).observe(page, { attributes: true, attributeFilter: ['class'], }); tryInit(); } window.__deepdive = { init, scrollTo }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', attachObserver, { once: true }); } else { attachObserver(); } })();