customer-presentation/protected/platform.js
Jonathan Hvid e3439d6c8f deepdive: add Beat-5 'Everything Client-Managed' frame around platform stack
Why: closes the Beat-5 summary with a visible boundary + label so the
"client-managed" point lands visually, not just in copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:11:05 +02:00

322 lines
12 KiB
JavaScript

// ─────────────────────────────────────────────────────────────
// 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')) : [];
const frame = section.querySelector('.pl-canvas-frame');
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 });
if (frame) gsap.set(frame, { opacity: 0 });
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; the copy stage swaps to the summary text and the
// "Everything Client-Managed" frame fades in around the stack.
const t5 = 4 * BEAT;
fadeOutPrev(4, t5);
fadeInCopy(4, t5);
if (frame) tl.to(frame, { opacity: 1, duration: 0.20, ease: 'power2.out' }, t5 + 0.10);
}
// ─── "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();
}
})();