customer-presentation/protected/platform.js
Jonathan Hvid 0c4b3a438e overview: pin-with-scrub-release on cards & roadmap; fix dot-nav
initCards and initRoadmap now use a pin-with-scrub-release pattern
instead of a simple scroll-tied fade. Each section fades in over ~50vh
as it approaches viewport centre, locks in place for 100vh of scroll
input (cards extends to 150vh and fades out while still pinned;
roadmap stays visible as the page ends), then releases. Scroll itself
is never blocked — wheel/keyboard/touch all advance scroll normally
against the pin budget. platform-cards is removed from bifrost's
sticky-damping list since the new pin handles the dwell.

Dot-nav fixes for the new pins:
- activatePage now also calls __platform.init() in the same tick as
  __bifrost.init(), so pin spacers exist before scrollTo reads
  target offsets. Previously platform's MutationObserver-driven init
  fired ~80ms after scrollTo, leaving roadmap.offsetTop pointing at
  the pre-spacer position (empty space between cards and roadmap).
- scrollTo walks the offsetParent chain via offsetTopWithin() instead
  of reading target.offsetTop directly. ScrollTrigger's pinSpacing
  wraps pinned sections in a pin-spacer with position:relative, which
  becomes the section's offsetParent and makes target.offsetTop
  return ~0 — collapsing every dot click to scrollY=0 (hero).
- getSceneAnchorOffset adds cases for platform-cards / platform-roadmap
  returning (section.height - vh) / 2, so the user lands exactly at
  the pin-engagement point with the full pin budget remaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:53:01 +02:00

938 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────
// protected/platform.js — Fenja AI Platform Architecture explainer
//
// Sections (in order):
// #platform-question — full-viewport framing statement (fade-in)
// #platform-layers — pinned scrubbed five-beat architecture build
// #platform-cards — "Choose your Capability" deployment options
// (final section; centred when at scroll end)
//
// Runs in two host pages:
// A. Inlined into protected/index.html's Overview page, after the
// Project Bifrost treasure-map — the customer-presentation flow.
// Scroller: #overview-scroll. Lenis + ScrollTrigger.scrollerProxy
// are set up by bifrost.js, so we just attach our triggers.
// B. Standalone protected/deepdive.html (the original /deepdive
// page). Scroller: #product-deepdive-scroll. We own Lenis +
// scrollerProxy here.
//
// Detection: at boot we look for #overview-scroll first; if present
// we wait for #page-overview to gain `is-active` (i.e. bifrost.js
// has run its init) and attach scene triggers without creating a
// second Lenis instance. Otherwise we fall back to the standalone
// deepdive path.
//
// 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('[platform] gsap/ScrollTrigger/Lenis missing; skipping init.');
return;
}
// Prefer the Overview's existing scroller when present — that's
// the inlined customer-presentation path, where bifrost.js owns
// Lenis + scrollerProxy and we must not create a second pair.
const overviewScroller = document.getElementById('overview-scroll');
const deepdiveScroller = document.getElementById('product-deepdive-scroll');
const integrated = !!overviewScroller && !!document.getElementById('platform-layers');
const scroller = integrated ? overviewScroller : deepdiveScroller;
if (!scroller) {
console.warn('[platform] no scroller (#overview-scroll or #product-deepdive-scroll) 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 + scrollerProxy: only when standalone. In the integrated
// path, bifrost.js already wired both onto #overview-scroll; we'd
// create a duplicate Lenis fighting the existing one if we ran
// this block.
if (!integrated && !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);
initWiki(gsap, ScrollTrigger, scroller, reduceMotion);
initCards(gsap, ScrollTrigger, scroller, reduceMotion);
initRoadmap(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: Deployment options (cards) ──────────────────
// 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 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: 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: section,
scroller,
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 ─────────────
//
// Beat 0 Anchor (Wiki pl-card) scales up & fades in centered.
// Beat 1 Left "Scattered knowledge" zone reveals; document
// icons stagger in; anchor fades to a quiet echo.
// Beat 2 Middle "Fenja AI Compiler" reveals; two scatter →
// compiler flow lines draw via strokeDashoffset.
// Beat 3 Right page stack composes — back, then middle, then
// front. Each card landing applies a blur to the
// card(s) beneath it ("each layer in front fogs the
// layers below"). Two compiler → stack flow lines
// draw alongside. Front-card <sup> citation markers
// fade in last.
// Beat 4 Trust beat — citation [1] lights up walnut, the
// source PDF icon on the left tints subtly, and a
// faint arc traces from citation back to PDF.
//
// Mirrors initLayers's timeline structure (BEAT = 1.0 second
// intervals, +=500% scroll range, pinType: 'transform').
function initWiki(gsap, ScrollTrigger, scroller, reduceMotion) {
const section = document.getElementById('wiki-deepdive');
if (!section) return;
const anchor = section.querySelector('.wd-anchor');
const zoneScatter = section.querySelector('.wd-zone--scatter');
const zoneCompiler = section.querySelector('.wd-zone--compiler');
const zoneWiki = section.querySelector('.wd-zone--wiki');
const compiler = section.querySelector('.wd-compiler');
const docs = Array.from(section.querySelectorAll('.wd-doc'));
const chevrons = Array.from(section.querySelectorAll('.wd-chevron'));
const stackBack = section.querySelector('.wd-stack-card[data-depth="back"]');
const stackMid = section.querySelector('.wd-stack-card[data-depth="mid"]');
const stackFront = section.querySelector('.wd-stack-card[data-depth="front"]');
const cites = Array.from(section.querySelectorAll('.wd-cite'));
const firstCite = section.querySelector('.wd-cite[data-cite="1"]');
const pairedSource = stackFront && stackFront.querySelector('.wd-stack-source[data-source="1"]');
/* Trust-beat source-tint target in the cluster. The arc that
used to connect them visually was removed; the citation
pulse + source PDF tint + paired source-row highlight
remain as the trust cues. */
const sourceDoc = section.querySelector('.wd-doc[data-doc="pdf"]');
/* Per-icon target opacity. --o is read from inline style so
foreground items fade to 1.0 and background-pile items fade
to their reduced opacity (≈0.45) — the layered cluster look
isn't flattened by the reveal animation. */
const docTargets = docs.map((d) => {
const raw = getComputedStyle(d).getPropertyValue('--o').trim();
const op = raw ? parseFloat(raw) : 1;
return { el: d, opacity: Number.isFinite(op) ? op : 1 };
});
if (!zoneScatter || !zoneCompiler || !zoneWiki) {
console.warn('[platform] wiki-deepdive DOM missing zones; skipping init.');
return;
}
if (reduceMotion) {
// CSS @media handles the unfold; nothing for JS to do.
return;
}
// Blur targets per-stack-position. Each card lands sharp;
// when the next layer arrives, it gains the blur listed here.
// Tuned to the deck's depth language — soft enough not to
// dominate, strong enough to read as "behind glass".
const BLUR_MID = 3; // back card receives this once mid lands
const BLUR_BACK = 7; // back card's blur deepens once front lands
const BLUR_MID_FINAL = 2; // mid card's blur once front lands
// Initial states.
if (anchor) gsap.set(anchor, { opacity: 0, scale: 0.85 });
gsap.set([zoneScatter, zoneCompiler, zoneWiki], { opacity: 0, y: 14 });
if (compiler) gsap.set(compiler, { scale: 0.94, opacity: 0 });
gsap.set(docs, { opacity: 0, y: 10 });
gsap.set(cites, { opacity: 0 });
if (chevrons.length) gsap.set(chevrons, { opacity: 0 });
// Stack: each card starts off-frame (translated right + down)
// and lands into its CSS-defined position via xPercent/yPercent
// delta. CSS owns absolute position; GSAP only moves the
// transform offset so we don't fight the layout.
[stackBack, stackMid, stackFront].forEach((c) => {
if (!c) return;
gsap.set(c, { opacity: 0, xPercent: 40, yPercent: 24, filter: 'blur(0px)' });
});
const BEAT = 1.0;
const tl = gsap.timeline({
scrollTrigger: {
trigger: '#wiki-deepdive',
scroller,
start: 'top top',
end: '+=500%',
pin: '.wd-pin',
pinType: 'transform',
scrub: 0.5,
invalidateOnRefresh: true,
},
});
// Beat 0 — anchor enters scaled up to the centre.
const t0 = 0 * BEAT;
if (anchor) {
tl.to(anchor, {
opacity: 1, scale: 1.6,
duration: 0.22, ease: 'power3.out',
}, t0);
}
// Beat 1 — scatter zone reveals; anchor fades.
const t1 = 1 * BEAT;
if (anchor) {
tl.to(anchor, { opacity: 0.18, scale: 1.4, duration: 0.16, ease: 'power2.in' }, t1);
}
tl.to(zoneScatter, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, t1 + 0.04);
// Each doc fades to its OWN target opacity (foreground icons
// to 1.0, background-pile icons to their --o value, ≈0.45).
docTargets.forEach((d, i) => {
tl.to(d.el, {
opacity: d.opacity, y: 0,
duration: 0.18, ease: 'power3.out',
}, t1 + 0.08 + i * 0.02);
});
// Beat 2 — compiler reveals; chevrons fade in (one between
// cluster ↔ compiler, one between compiler ↔ stack — they
// act as the directional cue the removed flow curves used to
// carry).
const t2 = 2 * BEAT;
tl.to(zoneCompiler, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, t2);
if (compiler) {
tl.to(compiler, {
opacity: 1, scale: 1,
duration: 0.20, ease: 'power3.out',
}, t2 + 0.04);
}
if (chevrons.length) {
tl.to(chevrons, {
opacity: 0.55,
duration: 0.18, ease: 'power2.out',
stagger: 0.06,
}, t2 + 0.10);
}
if (anchor) {
tl.to(anchor, { opacity: 0, duration: 0.10 }, t2 + 0.05);
}
// Beat 3 — page stack composes back → middle → front. As
// each card lands, the card(s) behind it pick up blur ("each
// layer in front fogs the layers below"). Two compiler →
// stack flow lines draw alongside; citation markers on the
// front card fade in after the front card has settled.
const t3 = 3 * BEAT;
tl.to(zoneWiki, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, t3);
// Back card lands first.
if (stackBack) {
tl.to(stackBack, {
opacity: 1, xPercent: 0, yPercent: 0,
duration: 0.22, ease: 'power3.out',
}, t3 + 0.04);
}
// Mid card lands; back card receives its first blur layer.
if (stackMid) {
tl.to(stackMid, {
opacity: 1, xPercent: 0, yPercent: 0,
duration: 0.22, ease: 'power3.out',
}, t3 + 0.24);
}
if (stackBack) {
tl.to(stackBack, {
filter: `blur(${BLUR_MID}px)`,
duration: 0.18, ease: 'power2.out',
}, t3 + 0.26);
}
// Front card lands; mid picks up its blur, back's deepens.
if (stackFront) {
tl.to(stackFront, {
opacity: 1, xPercent: 0, yPercent: 0,
duration: 0.24, ease: 'power3.out',
}, t3 + 0.44);
}
if (stackMid) {
tl.to(stackMid, {
filter: `blur(${BLUR_MID_FINAL}px)`,
duration: 0.18, ease: 'power2.out',
}, t3 + 0.46);
}
if (stackBack) {
tl.to(stackBack, {
filter: `blur(${BLUR_BACK}px)`,
duration: 0.20, ease: 'power2.out',
}, t3 + 0.46);
}
// Citations on the front card fade in last (the curving
// flow lines that previously drew alongside this beat were
// removed in the geometric-language pass — the chevrons in
// the gaps already carry direction).
tl.to(cites, {
opacity: 1,
duration: 0.18, ease: 'power2.out',
stagger: 0.05,
}, t3 + 0.62);
// Beat 4 — trust beat. Citation [1] lights in walnut, the
// source PDF gains its .is-source tint, and a thin arc draws
// back from the citation to the source doc. Subtle by design.
// Class toggles use paired onStart/onReverseComplete so the
// tint retreats cleanly when the user scrolls back up.
const t4 = 4 * BEAT;
if (firstCite) {
tl.to({}, {
duration: 0.001,
onStart: () => firstCite.classList.add('is-lit'),
onReverseComplete: () => firstCite.classList.remove('is-lit'),
}, t4);
tl.fromTo(firstCite,
{ scale: 1 },
{ scale: 1.4, duration: 0.12, ease: 'power2.out', transformOrigin: 'center bottom' },
t4);
tl.to(firstCite, { scale: 1, duration: 0.24, ease: 'power2.inOut' }, t4 + 0.14);
}
if (sourceDoc) {
tl.to({}, {
duration: 0.001,
onStart: () => sourceDoc.classList.add('is-source'),
onReverseComplete: () => sourceDoc.classList.remove('is-source'),
}, t4 + 0.04);
}
// Pair the front card's matching bottom source entry with
// the lit citation — readers see the in-text marker tied to
// its source row at the foot of the page, AND the arc back
// to the document in the cluster.
if (pairedSource) {
tl.to({}, {
duration: 0.001,
onStart: () => pairedSource.classList.add('is-paired'),
onReverseComplete: () => pairedSource.classList.remove('is-paired'),
}, t4 + 0.05);
}
}
// ─── 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 section = document.getElementById('platform-roadmap');
setupRoadmapMorph(reduceMotion);
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) {
targets.forEach(c => { c.style.opacity = '1'; c.style.transform = 'none'; });
return;
}
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 — 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',
});
}
// ─── Roadmap card morph (FLIP) ───────────────────────────────
// Replaces the previous backdropped modal. Clicking a card
// toggles .is-expanded on that card and .has-expanded on the
// row; CSS reconfigures the grid (4×1 → 6×2). We capture
// first/last rects of ALL four cards before and after the
// class flip and apply inverse transforms so the layout shift
// animates as a single continuous morph — the same DOM
// element grows into the featured panel while the others
// slide into row 2.
//
// - Esc, the in-card ×, and clicks outside the expanded
// card all collapse it.
// - Clicking a different card while one is open does a
// single sequenced collapse → expand morph (no abrupt
// swap).
// - prefers-reduced-motion: classes flip with no FLIP
// animation; the CSS @media block handles the cross-fade.
let _roadmapMorphWired = false;
function setupRoadmapMorph(reduceMotion) {
if (_roadmapMorphWired) return;
const row = document.querySelector('#platform-roadmap .rm-row');
if (!row) return;
const cards = Array.from(row.querySelectorAll('.rm-card'));
if (!cards.length) return;
const DURATION = 360; // ms — within the 300400 target
const EASE = 'cubic-bezier(0.2, 0, 0, 1)';
let activeCard = null; // currently expanded card, if any
let animating = false; // ignore re-entry during transition
// FLIP helper. Captures first rects, runs `mutate`, captures
// last rects, then transitions inverse transforms back to
// identity. `done` fires after the visual settles.
function flip(mutate, done) {
if (reduceMotion) {
mutate();
if (done) done();
return;
}
const first = cards.map((c) => c.getBoundingClientRect());
mutate();
const last = cards.map((c) => c.getBoundingClientRect());
cards.forEach((c, i) => {
const f = first[i];
const l = last[i];
const dx = f.left - l.left;
const dy = f.top - l.top;
const sx = l.width > 0 ? f.width / l.width : 1;
const sy = l.height > 0 ? f.height / l.height : 1;
c.style.transformOrigin = 'top left';
c.style.transition = 'none';
c.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
});
// Force a synchronous reflow so the inverse transforms
// commit before the transition starts.
void row.offsetHeight;
cards.forEach((c) => {
c.style.transition = `transform ${DURATION}ms ${EASE}`;
c.style.transform = '';
});
// Clean up at the end; fallback timeout in case transitionend
// gets dropped (browser quirk on hidden tabs etc.).
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
cards.forEach((c) => {
c.style.transition = '';
c.style.transform = '';
c.style.transformOrigin = '';
});
if (done) done();
}
const fallback = setTimeout(cleanup, DURATION + 80);
cards[0].addEventListener('transitionend', function once(e) {
if (e.propertyName !== 'transform') return;
cards[0].removeEventListener('transitionend', once);
clearTimeout(fallback);
cleanup();
});
}
function expand(card) {
if (animating || card === activeCard) return;
animating = true;
flip(() => {
row.classList.add('has-expanded');
card.classList.add('is-expanded');
card.setAttribute('aria-expanded', 'true');
const body = card.querySelector('.rm-card-body');
if (body) body.setAttribute('aria-hidden', 'false');
activeCard = card;
}, () => {
animating = false;
// Focus the close button so keyboard users can dismiss
// immediately with Enter.
const closeBtn = card.querySelector('.rm-card-close');
if (closeBtn) closeBtn.focus();
});
}
function collapse(thenExpand) {
if (animating || !activeCard) {
if (thenExpand) thenExpand();
return;
}
animating = true;
const card = activeCard;
flip(() => {
card.classList.remove('is-expanded');
card.setAttribute('aria-expanded', 'false');
const body = card.querySelector('.rm-card-body');
if (body) body.setAttribute('aria-hidden', 'true');
row.classList.remove('has-expanded');
activeCard = null;
}, () => {
animating = false;
// Return focus to the card so keyboard nav doesn't lose
// its place.
if (card && typeof card.focus === 'function') card.focus();
if (thenExpand) thenExpand();
});
}
function onCardActivate(card) {
if (animating) return;
if (activeCard === card) {
// Re-clicking the expanded card collapses it.
collapse();
return;
}
if (activeCard) {
// Sequenced collapse → expand for a smooth swap.
const next = card;
collapse(() => requestAnimationFrame(() => expand(next)));
} else {
expand(card);
}
}
cards.forEach((card) => {
card.addEventListener('click', (e) => {
// Close button inside the card has its own handler below;
// ignore here so the card click doesn't re-expand.
if (e.target.closest('.rm-card-close')) return;
onCardActivate(card);
});
card.addEventListener('keydown', (e) => {
if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('.rm-card-close')) {
e.preventDefault();
onCardActivate(card);
}
});
const closeBtn = card.querySelector('.rm-card-close');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
collapse();
});
}
});
// Esc closes from anywhere.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && activeCard) {
e.stopPropagation();
collapse();
}
});
// Outside click closes — anywhere that's not inside the
// currently expanded card.
document.addEventListener('click', (e) => {
if (!activeCard) return;
if (e.target.closest('.rm-card.is-expanded')) return;
// Re-activations of OTHER cards are handled in their own
// click listeners above; this catches clicks elsewhere.
if (e.target.closest('.rm-card')) return;
collapse();
});
_roadmapMorphWired = 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 ───────────────────────
// Two cases:
// A. Integrated into the Overview: wait for #page-overview to be
// active (bifrost.js's init has run), then attach scene
// triggers without re-creating Lenis.
// B. Standalone deepdive: wait for #page-product-deepdive to be
// active and own the full setup.
function tryInit() {
if (initialized) return;
const overviewActive = !!document.querySelector('#page-overview.is-active');
const deepdiveActive = !!document.querySelector('#page-product-deepdive.is-active');
if (!overviewActive && !deepdiveActive) return;
if (typeof window.gsap === 'undefined' ||
typeof window.ScrollTrigger === 'undefined' ||
typeof window.Lenis === 'undefined') return;
// Small delay: in the integrated path this lets bifrost.js finish
// wiring scrollerProxy + Lenis before we register triggers. In the
// standalone path it just lets layout settle, same as before.
setTimeout(init, overviewActive ? 140 : 60);
}
function attachObserver() {
const pages = [
document.getElementById('page-overview'),
document.getElementById('page-product-deepdive'),
].filter(Boolean);
if (!pages.length) return;
const observer = new MutationObserver(tryInit);
pages.forEach((p) => observer.observe(p, {
attributes: true,
attributeFilter: ['class'],
}));
tryInit();
}
window.__platform = { init, scrollTo };
// Backwards-compat: older code referred to `window.__deepdive`.
window.__deepdive = window.__platform;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', attachObserver, { once: true });
} else {
attachObserver();
}
})();