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>
852 lines
32 KiB
JavaScript
852 lines
32 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
||
// 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 300–400 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();
|
||
}
|
||
})();
|