Adds a new standalone /deepdive page with its own platform.css/platform.js, wires it into the dot-nav as an external entry, and updates the dot-nav docblock to reflect the new seven-entry layout. Also drops in BUSINESS.md and reference material under architecture boxes/ and examples/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
318 lines
12 KiB
JavaScript
318 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')) : [];
|
|
|
|
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();
|
|
}
|
|
})();
|