customer-presentation/protected/platform.js
Jonathan Hvid fb815768e2 customer-presentation: convert deck from Bifrost invitation to customer-facing
Why: pivot the experience from a personal invitation for Project Bifrost
participants to a customer-facing presentation that can be shown to
prospects like Novo Nordisk while still mentioning Bifrost in context.

Major changes:
- Entrance: re-worded title/body away from "invitation" into "introduction"; kept Fenja AI / Project Bifrost definition blocks.
- Timeline: page-sub reworked to also speak to highly-regulated private orgs (data, IP, regulated workflows, US-vendor dependency) alongside public sector.
- "Backed by Innovationsfonden" pairs with new "Part of BioInnovation Institute AI Lab" line on entrance and Scene 1 hero.
- Removed: stack-scene (4 capabilities) and words-scene ("This is why we've invited you") — archived at protected/_archive/stack-scene.html for restore.
- Removed: bifrost-join CTA + Innovationsfonden footer section.
- Inlined the standalone /deepdive architecture explainer into #overview-scroll after #bifrost-meaning; platform.js detects scroller and skips its own Lenis setup when integrated.
- New: Wiki deep-dive section (#wiki-deepdive) — scattered knowledge cluster → Fenja AI Compiler → layered page stack with citations, plus pinned scrubbed beat-by-beat reveal.
- New: Implementation roadmap section (#platform-roadmap) — four stage cards + GOVERN & SCALE band + footer, with click-to-expand card-morph (FLIP-based; same DOM element grows into the featured panel).
- Dot-nav: 4 → 8 entries — Welcome / Timeline / Fenja introduction / Project Bifrost / Architecture / Wiki / Deployment / Roadmap.
- Deployment options: scroll-tied fade-in for the whole section + sticky-damping at centre for a subtle dwell stop.

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

852 lines
32 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) ──────────────────
// 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.
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 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,
scrollTrigger: {
trigger: '#platform-cards',
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,
},
});
}
// ─── 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 — 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.
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');
// 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 (reduceMotion) {
stages.forEach(c => { c.style.opacity = '1'; });
if (band) band.style.opacity = '1';
if (foot) foot.style.opacity = '1';
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,
},
});
tl.to(stages, {
opacity: 1,
y: 0,
duration: 0.6,
ease: 'power3.out',
stagger: 0.08,
clearProps: '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) ───────────────────────────────
// 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();
}
})();