// ───────────────────────────────────────────────────────────── // 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 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 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(); } })();