// ───────────────────────────────────────────────────────────── // protected/bifrost.js — Project Bifrost scenes inside the // Overview page of the timeline. // // This file: // 1. Wraps site-2's six scroll-bound scenes (hero → architecture // stack → words → aurora arc → treasure-map → join CTA) so // they run inside the Overview page, not as a standalone site. // 2. Rewires Lenis smooth scroll + GSAP ScrollTrigger so the // scroller is the Overview's internal scrolling container — // never the window — so the three-page Timeline/Overview/ // Timeline model (each fixed-viewport) keeps working. // 3. Drives the Europe map's opacity from the scroller's scroll // position: fully visible at the top, fades to 0 as the user // scrolls into the hero, fades back in on scrolling up. // 4. Is a lazy-init module. Nothing happens at page load; the // dot-nav handler in timeline.js calls window.__bifrost.init() // the first time the Overview page becomes active. // // CSP: 'script-src self'. No inline scripts anywhere. // ───────────────────────────────────────────────────────────── (function () { 'use strict'; // Lazy single-shot init flag — nav can click the Overview pip // multiple times; we only wire everything up once. let initialized = false; let refreshScheduled = false; // Shared state between init() and the public surface (scrollTo etc). // These are assigned once during init(); scrollTo reads them to drive // the Overview's internal scroller instead of window scroll. let lenisInstance = null; let scrollerEl = null; // A tiny helper: schedule a ScrollTrigger.refresh() on the next // animation frame, de-duplicating calls within the same frame. function scheduleRefresh() { if (refreshScheduled || !window.ScrollTrigger) return; refreshScheduled = true; requestAnimationFrame(() => { refreshScheduled = false; window.ScrollTrigger.refresh(); }); } function init() { if (initialized) { // Already booted — just re-measure, in case layout shifted while // the page was inactive (e.g. user resized the window on Timeline). scheduleRefresh(); return; } initialized = true; // Guard: vendor libs must have loaded. if (typeof window.gsap === 'undefined' || typeof window.ScrollTrigger === 'undefined' || typeof window.Lenis === 'undefined') { console.warn('[bifrost] Vendor libraries (gsap/ScrollTrigger/Lenis) missing; skipping init.'); return; } const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // Reveal-on-load elements: if reduced motion, just make them visible // and bail before registering any ScrollTrigger-bound tweens. if (reduceMotion) { document.querySelectorAll('#page-overview [data-reveal], #page-overview [data-reveal-lines]').forEach(el => { el.style.opacity = '1'; el.style.transform = 'none'; }); // The hero-wrap is hidden by CSS (`.js .hero-wrap { opacity: 0 }`) // to prevent a flash during page activation — unhide it here since // we're skipping the fade-in tween. const heroWrap = document.querySelector('.hero-wrap'); if (heroWrap) heroWrap.style.opacity = '1'; // Still fade the Europe map fully in — it's the scene background. const mapEl = document.getElementById('overview-globe'); if (mapEl) mapEl.style.opacity = '1'; return; } // ─── Scroller setup ────────────────────────────────────────── // // The Overview is a fixed-position .page that contains one // scrollable child: `#overview-scroll`. All six scenes live // inside it. Lenis drives wheel input on that element; // ScrollTrigger reads scroll from the same element. const scroller = document.getElementById('overview-scroll'); if (!scroller) { console.error('[bifrost] #overview-scroll not found'); return; } scrollerEl = scroller; const gsap = window.gsap; const ScrollTrigger = window.ScrollTrigger; const Lenis = window.Lenis; gsap.registerPlugin(ScrollTrigger); // Lenis wired to the Overview's internal scroller, NOT the window. 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/write scroll on this scroller. ScrollTrigger.scrollerProxy(scroller, { scrollTop(value) { if (arguments.length) { scroller.scrollTop = value; } return scroller.scrollTop; }, getBoundingClientRect() { // The scroller occupies the full viewport (inset: 0 on its parent). return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight }; }, // Scrollbars are hidden via CSS; pinType is 'transform' for nested scrollers. pinType: 'transform', }); // Every ScrollTrigger below implicitly targets our scroller. ScrollTrigger.defaults({ scroller }); // Pump Lenis via GSAP's ticker; notify ScrollTrigger on each scroll. lenis.on('scroll', ScrollTrigger.update); gsap.ticker.add((time) => lenis.raf(time * 1000)); gsap.ticker.lagSmoothing(0); // Expose lenis to scrollTo() via the shared closure var. lenisInstance = lenis; // ─── Europe map fade ───────────────────────────────────────── // // Fully visible at scrollTop=0. Fades to 0 between 20% and 80% // of viewport height. Fades back in on scrolling up. Opacity // ceiling is 0.42 — matches the original .page-overview.is-active // .overview-globe svg look. const mapSvg = document.querySelector('#overview-globe svg'); const MAP_MAX_OPACITY = 0.42; if (mapSvg) { // Initial: visible (map is the hero backdrop). We'll drive opacity // directly on every scroll tick, so kill any CSS opacity transition // (the existing rule animates opacity over 900ms — would be janky). mapSvg.style.transition = 'none'; mapSvg.style.opacity = MAP_MAX_OPACITY.toFixed(3); } function updateMapOpacity() { if (!mapSvg) return; const vh = window.innerHeight; const y = scroller.scrollTop; // Between 0.20 × vh and 0.80 × vh, ramp from full to zero. const startFade = vh * 0.20; const endFade = vh * 0.80; let t; if (y <= startFade) { t = 0; // fully visible } else if (y >= endFade) { t = 1; // fully hidden } else { t = (y - startFade) / (endFade - startFade); } mapSvg.style.opacity = (MAP_MAX_OPACITY * (1 - t)).toFixed(3); } lenis.on('scroll', updateMapOpacity); // Initial paint updateMapOpacity(); // ─── Scroll-spy for the dot-nav ────────────────────────────── // // As the user scrolls through the Overview, update the active dot // to match the scene currently in view. Called from lenis.on('scroll') // at display rate; debounced implicitly by requestAnimationFrame // through the shared refresh scheduler. // // Logic: a scene is "active" when its top is above the viewport's // midpoint AND its bottom is below it. For stacked pinned scenes // (S2) the pin duration makes "bottom" go well past the viewport, // so the first-match wins — scenes are checked top-to-bottom. const sceneOrder = [ 'hero', 'stack-scene', 'words-scene', 'bifrost', 'bifrost-meaning', 'bifrost-join', ]; let lastActiveScene = null; function updateActiveSceneDot() { if (typeof window.__setActiveDot !== 'function') return; const midY = window.innerHeight * 0.5; let visibleId = sceneOrder[0]; for (const id of sceneOrder) { const el = document.getElementById(id); if (!el) continue; const r = el.getBoundingClientRect(); // A scene whose top is at or above the midline, but whose // bottom hasn't scrolled past the midline yet. if (r.top <= midY && r.bottom > midY) { visibleId = id; break; } // Edge case: scrolled past — keep latest seen as fallback. if (r.top <= midY) visibleId = id; } if (visibleId !== lastActiveScene) { lastActiveScene = visibleId; window.__setActiveDot('page-overview', visibleId); } } lenis.on('scroll', updateActiveSceneDot); // Initial paint: set "hero" active since we start at top. requestAnimationFrame(updateActiveSceneDot); // ─── Topography parallax ───────────────────────────────────── // // Concentric-ring topographic layer that sits behind the Europe // map (z-index 0). Generated at runtime — same formula as the // entrance page's .currents pattern but rotated/offset so it reads // as a visual sibling rather than a duplicate. // // Parallax speed: 0.15× of the scroller's scrollTop. Slow enough to // feel atmospheric, fast enough that the layer doesn't appear // static on casual scroll. Applied as a translateY composed WITH // the 40° rotate from CSS (so we build the full transform here to // avoid clobbering the CSS rotate). const topoWrap = document.getElementById('overview-topography'); if (topoWrap && !topoWrap.querySelector('svg')) { const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNS, 'svg'); const W = 1600, H = 1600, cx = W * 0.5, cy = H * 0.5; svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); // Slightly different parameters from the entrance page's currents // so the rings read as "related but not identical": // - more rings (34 vs 26) — denser // - smaller step (28 vs 32) — tighter // - larger amplitude (32 vs 26) — wavier // - lower base opacity — quieter behind the map const RINGS = 34, BASE_R = 60, STEP = 28, AMP = 32; for (let i = 0; i < RINGS; i++) { const r = BASE_R + i * STEP, segs = 280; const p1 = (i * 0.7) % (Math.PI * 2); const p2 = (i * 1.4 + 1.3) % (Math.PI * 2); const a1 = AMP * (0.9 + (i % 5) * 0.08); const a2 = AMP * 0.35; let d = ''; for (let s = 0; s <= segs; s++) { const t = (s / segs) * Math.PI * 2; const rr = r + a1 * Math.sin(t * 3 + p1 + i * 0.15) + a2 * Math.sin(t * 5 + p2 + i * 0.22) + AMP * 0.18 * Math.sin(t * 7 + i); const x = cx + Math.cos(t) * rr; const y = cy + Math.sin(t) * rr * 0.92; d += (s === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1); } d += ' Z'; const path = document.createElementNS(svgNS, 'path'); path.setAttribute('d', d); path.setAttribute('fill', 'none'); path.setAttribute('stroke', '#383831'); path.setAttribute('stroke-width', '1'); path.setAttribute('stroke-linejoin', 'round'); // Lower opacity than entrance page — sits behind the map, // shouldn't compete for attention. path.setAttribute('opacity', (i % 3 === 0 ? 0.07 : 0.04).toString()); svg.appendChild(path); } topoWrap.appendChild(svg); } const PARALLAX_SPEED = 0.15; const topoSvg = topoWrap ? topoWrap.querySelector('svg') : null; function updateTopographyParallax() { if (!topoSvg) return; const y = scroller.scrollTop * -PARALLAX_SPEED; // Rotate comes from CSS (40deg); we compose our translateY into // the same transform string so both apply. `rotate` first so the // translate happens in screen space, not the rotated local space. topoSvg.style.transform = `translateY(${y.toFixed(1)}px) rotate(40deg)`; } lenis.on('scroll', updateTopographyParallax); // Initial paint updateTopographyParallax(); // ─── Sticky-scroll damping ─────────────────────────────────── // // When a scene's vertical center is close to the viewport center, // damp Lenis's velocity briefly — scenes "catch" the eye instead // of flying past at full scroll speed. Applied only to non-pinned // scenes (S1, S3, S4, S5, S6). S2 is already pinned + scrubbed // by GSAP ScrollTrigger; adding damping on top would make its // card-fall feel like a drag. // // Implementation: on each scroll tick, if the active scene is in // the "damp zone" (±20% of viewport height from viewport center), // scale Lenis's next frame velocity by a damping factor (0.82). // When outside the zone, pass through unchanged. Keeps power-users // happy — they can still scroll fast, just feel a soft hold at // scene boundaries. const STICKY_SCENES = new Set([ 'hero', 'words-scene', 'bifrost', 'bifrost-meaning', 'bifrost-join', ]); // note: 'stack-scene' deliberately excluded (already pinned) const DAMP_ZONE_VH = 0.20; // ±20% of viewport height from center const DAMP_FACTOR = 0.82; // multiply velocity while in zone const DAMP_MAX_MS = 300; // cap: never damp for longer than this per scene-crossing let dampedUntil = 0; // timestamp (ms); damping active while > now let lastDampedScene = null; // id of the scene we last entered function updateStickyDamping() { const now = performance.now(); const midY = window.innerHeight * 0.5; const zone = window.innerHeight * DAMP_ZONE_VH; let inZone = false, zoneScene = null; for (const id of STICKY_SCENES) { const el = document.getElementById(id); if (!el) continue; const r = el.getBoundingClientRect(); // Use the scene's vertical midpoint as the "attractor." const sceneMid = (r.top + r.bottom) * 0.5; if (Math.abs(sceneMid - midY) < zone) { inZone = true; zoneScene = id; break; } } if (inZone) { // New scene entered the zone → arm a fresh damping window. // Reusing the same scene (still in zone, already damped) doesn't // extend — DAMP_MAX_MS is a hard cap per entry to avoid an // indefinite hold. if (zoneScene !== lastDampedScene) { lastDampedScene = zoneScene; dampedUntil = now + DAMP_MAX_MS; } if (now < dampedUntil && lenis.velocity) { // Lenis exposes .velocity (a number). Multiplying in place // dampens the next-frame step. Small, contained effect — no // chance of interfering with ScrollTrigger. lenis.velocity *= DAMP_FACTOR; } } else { // Left the zone — reset so the NEXT time we enter a scene, // we get a fresh damping window. if (lastDampedScene !== null) { lastDampedScene = null; dampedUntil = 0; } } } lenis.on('scroll', updateStickyDamping); // ─── Site-2 scene animations ───────────────────────────────── // (transplanted verbatim; all ScrollTriggers below automatically // use the Overview scroller via ScrollTrigger.defaults above.) // Script 1 body — HERO + SCENE 2 (architecture stack) + SCENE 3 (words) + SCENE 4 (bifrost arc) /* ------------------------------------------------------------- HERO — single overall fade-in. The wrap is hidden via CSS (`.js .hero-wrap { opacity: 0 }`) so the hero stays invisible during the page-activation transition, then fades in once Bifrost has booted. ------------------------------------------------------------- */ gsap.to('.hero-wrap', { opacity: 1, duration: 1.0, ease: 'power2.out', delay: 0.1, }); /* ------------------------------------------------------------- ARCHITECTURE — two-phase scrubbed sequence Phase A (0.00 – 0.45): each of 4 layer-cards falls from above and lands at a progressively higher Y offset so the previous card's bottom strip peeks out below. Only the topmost card's eyebrow is visible at any time. Phase B (0.50 – 1.00): the stack rearranges into a 2x2 grid on the right side. Body text in each card fades out; eyebrow stays. Explanatory copy crossfades on the LEFT, three panels: ~0.55 "All the capabilities to solve business use cases" ~0.70 "Full client control / Complete sovereignty" ~0.85 "Built in Denmark / For Europe" ------------------------------------------------------------- */ const theatre = document.querySelector('.layer-theatre'); const cards = gsap.utils.toArray('.layer-card'); const copyLayers = gsap.utils.toArray('.copy-layer'); // Each card lands N pixels higher than the previous — previous's // bottom strip is visible below. const STACK_OFFSET_PER_CARD = 22; // px, upward // Compute grid target positions. In .in-grid mode, each card-box is // 20vw square and centered (via margin:auto) inside its full-width // parent .layer-card. We translate the parent card so the box lands // at the correct grid-cell position. function computeGridPlan() { const W = theatre.offsetWidth; const H = theatre.offsetHeight; const vw = window.innerWidth; const cellSize = vw * 0.17; // matches .in-grid .card-box width (17vw) const gap = Math.max(14, vw * 0.014); const totalW = 2 * cellSize + gap; const totalH = 2 * cellSize + gap; // Right-anchor grid so it sits flush with the right side of the theatre const gridRight = W * 0.99; const gridStartX = gridRight - totalW; const gridStartY = (H - totalH) / 2; // Grid cell centers (in theatre coordinates), reading order: TL, TR, BL, BR const centers = [ { cx: gridStartX + cellSize / 2, cy: gridStartY + cellSize / 2 }, { cx: gridStartX + cellSize / 2 + cellSize + gap, cy: gridStartY + cellSize / 2 }, { cx: gridStartX + cellSize / 2, cy: gridStartY + cellSize / 2 + cellSize + gap }, { cx: gridStartX + cellSize / 2 + cellSize + gap, cy: gridStartY + cellSize / 2 + cellSize + gap }, ]; // In grid mode the card-box's horizontal center is the theatre horizontal // center (via margin:auto). That's our anchor for dx computations. const theatreCx = W / 2; const theatreCy = H / 2; return { cellSize, theatreCx, theatreCy, centers }; } // Initial state — hide everything, set card translations. // Cards are positioned via left:0/right:0 + top:50% in CSS; we use // yPercent:-50 to center vertically (so `y` animations remain additive). cards.forEach((card, i) => { gsap.set(card, { xPercent: 0, yPercent: -50, opacity: 0, x: 0, y: 0, rotation: 0, scale: 1 }); gsap.set(card.querySelector('.card-eyebrow'), { opacity: 0 }); }); // Copy layers vertically centered in copy-stage via yPercent: -50. // The animation uses `y` for the little drop-in offset (which is additive // to yPercent, so centering is preserved). copyLayers.forEach(el => gsap.set(el, { yPercent: -50, opacity: 0, y: 20 })); const stackTl = gsap.timeline({ scrollTrigger: { trigger: '#stack-scene', start: 'top top', end: '+=5000', // 5.5 viewports — more scroll for the new sequence scrub: 0.6, pin: '.stack-pin', pinSpacing: true, anticipatePin: 1, invalidateOnRefresh: true, } }); // -------- Phase A: card landings -------- // Card i lands at y = -i * STACK_OFFSET_PER_CARD (above baseline). // Its eyebrow fades IN on landing; the previous card's eyebrow fades OUT. cards.forEach((card, i) => { const landingY = -i * STACK_OFFSET_PER_CARD; const t = i * 0.105; // each card gets ~10.5% of timeline // Y motion — starts above viewport. Distance reduced to -900 so the // visible portion of the fall (from viewport top down to landing) is // a meaningful share of the animation rather than being swallowed by // off-screen travel that the user never sees. stackTl .fromTo(card, { y: -900, rotation: (i % 2 === 0 ? -4 : 4), scale: 0.97 }, { y: landingY, rotation: 0, scale: 1, duration: 0.09, ease: 'power3.out' }, t); // Opacity ramps up across most of the fall so the user sees the card // traveling rather than just popping in at the end. stackTl.fromTo(card, { opacity: 0 }, { opacity: 1, duration: 0.065, ease: 'power2.out' }, t + 0.015); // Settle bounce stackTl .to(card, { y: landingY + 4, duration: 0.012, ease: 'power1.out' }, t + 0.092) .to(card, { y: landingY, duration: 0.02, ease: 'power2.inOut' }, t + 0.105); // This card's eyebrow fades in stackTl.to(card.querySelector('.card-eyebrow'), { opacity: 1, duration: 0.025, ease: 'power2.out' }, t + 0.06); // Previous card's eyebrow fades out (it's now covered) if (i > 0) { stackTl.to(cards[i - 1].querySelector('.card-eyebrow'), { opacity: 0, duration: 0.02, ease: 'power2.in' }, t); } }); // Short hold after all 4 have landed (0.42 to 0.50) // -------- Phase B: rearrange to grid + fade copy -------- // Phase A's 4th card (Agents) finishes its fade-in around timeline 0.42, // but Lenis + scrub:0.6 adds smoothing so visually cards settle around // 0.55 of scroll progress. Starting Phase B at 0.58 ensures the user // sees the complete stack briefly before the grid morph begins. const PHASE_B_START = 0.58; // Transition each card to its grid cell. The .in-grid class // (applied via a separate ScrollTrigger at Phase B start) restructures // each card-box into a 30vw square centered within its full-width card. // GSAP only needs to translate — scale stays 1. // // The card's effective visual center in grid phase is the card-box's // center, which is the theatre horizontal center (margin:auto). So // dx = targetCellCenterX − theatreCenterX, dy = same for Y. function scheduleGridTransition() { const plan = computeGridPlan(); // Target scales for the morph. Cards start as wide rectangles // (~1324×526 at 1440vw) and need to morph to squares (~288×288). // Using independent scaleX/scaleY lets the rectangle SHAPE-CHANGE // into a square as it shrinks — so at morph-end the pre-snap and // post-snap aspect ratios match and the .in-grid CSS handoff is // imperceptible. Without this, ending at uniform scale would leave // a flat 2.5:1 rectangle that pops to a 1:1 square on snap. const vw = window.innerWidth; const cardRect = cards[0].getBoundingClientRect(); const cardW = cardRect.width || vw; const cardH = cardRect.height || 600; const targetW = vw * 0.17; // matches .in-grid .card-box width (17vw) const targetH = targetW; // square const targetScaleX = targetW / cardW; const targetScaleY = targetH / cardH; cards.forEach((card, i) => { const target = plan.centers[i]; const dx = target.cx - plan.theatreCx; const dy = target.cy - plan.theatreCy; const content = card.querySelector('.card-content'); const gridLabel = card.querySelector('.card-grid-label'); const brain = card.querySelector('.card-brain'); // Translate card to grid-cell position AND morph its SHAPE from // wide rectangle to square via independent scaleX/scaleY. Ending // at the exact target aspect ratio means the CSS .in-grid snap // (where card-box becomes aspect-ratio 1:1) produces no visual // change — the user sees a continuous morph. stackTl.to(card, { x: dx, y: dy, scaleX: targetScaleX, scaleY: targetScaleY, rotation: 0, duration: 0.14, ease: 'power2.inOut', transformOrigin: 'center center' }, PHASE_B_START); // COUNTER-SCALE the brain to prevent it being visually squeezed // by the card's non-uniform scale. Without this, the brain would // appear horizontally compressed (stretched tall/narrow) during // the morph because scaleX (0.22) is 2.5× more compressed than // scaleY (0.55). // // Applying additional scaleX = targetScaleY / targetScaleX (~2.5) // to the brain combines with the card's scale multiplicatively: // brain.visual.scaleX = card.scaleX × brain.scaleX // = 0.22 × 2.5 = 0.55 = card.scaleY // giving the brain UNIFORM visual scaling (both axes reduced by // card.scaleY factor), preserving its natural aspect ratio. // // Using transformOrigin: 'right center' on the brain keeps its // right edge anchored and expands the scale LEFTWARD into the // card's interior — not rightward into blank space or adjacent // cards. The brain already sits on the right side of the card // (grid column), so this keeps it where the user expects it. // // Content (title+body) and grid-label are NOT counter-scaled — // content fades to 0 opacity early in the morph, masking any // distortion; grid-label is tiny text, distortion barely visible. const counterScaleX = targetScaleY / targetScaleX; stackTl.to(brain, { scaleX: counterScaleX, duration: 0.14, ease: 'power2.inOut', transformOrigin: 'right center', immediateRender: false }, PHASE_B_START); // INSTANT scale reset at the end of the morph window. Using a // tiny duration (0.00001) with immediateRender:false means scale // jumps from targetScale to 1 essentially in a single scrub frame // — no visible ramp (0.00001 of a 1-second timeline is far below // one render frame). Piggy-back the .in-grid CSS class toggle on // the FIRST card's scale-reset tween via onStart (forward) and // onReverseComplete (backward), so the scale snap and the class // apply happen in the same GSAP render pass. Previously the class // toggle was a separate tween or a separate ScrollTrigger; either // way GSAP and ScrollTrigger didn't guarantee same-frame // execution, producing a visible moment where scale=1 but // box=1324 (the "becomes large briefly" glitch the user saw). const resetVars = { scaleX: 1, scaleY: 1, duration: 0.00001, immediateRender: false, }; if (i === 0) { resetVars.onStart = function() { theatre.classList.add('in-grid'); }; resetVars.onReverseComplete = function() { theatre.classList.remove('in-grid'); }; } stackTl.to(card, resetVars, PHASE_B_START + 0.14); // Reset brain counter-scale atomically with the card's scale // snap. After this, CSS .in-grid takes over layout (brain fills // the square flex-column centered, with no inline scaleX). stackTl.to(brain, { scaleX: 1, duration: 0.00001, immediateRender: false }, PHASE_B_START + 0.14); // Crossfade: the old text content fades out while the grid label // fades in. Both run alongside the scale/translate so all changes // happen simultaneously as a single coherent morph. stackTl.to(content, { opacity: 0, duration: 0.08, ease: 'power2.in' }, PHASE_B_START); stackTl.to(gridLabel, { opacity: 0.88, duration: 0.08, ease: 'power2.out' }, PHASE_B_START + 0.06); // Fade the outside-box eyebrow out as we transition to grid. stackTl.to(card.querySelector('.card-eyebrow'), { opacity: 0, duration: 0.06, ease: 'power2.in' }, PHASE_B_START); }); } scheduleGridTransition(); // (Class-toggle is now piggy-backed on card[0]'s scale-reset tween // above — see the i === 0 branch. Keeping them on the same tween // guarantees they fire in the same GSAP render pass.) // On resize we need to recompute. ScrollTrigger.invalidateOnRefresh // only rebuilds positions if our tweens use function-based values or // we kill/rebuild. Simplest: rebuild timeline entirely on resize. let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => ScrollTrigger.refresh(), 250); }); // -------- Copy layer crossfade on the LEFT (during grid phase) -------- const FADE = 0.025; const swap = (fromIdx, toIdx, pos) => { if (fromIdx !== null) { stackTl.to(copyLayers[fromIdx], { opacity: 0, y: -14, duration: FADE, ease: 'power2.in' }, pos); } stackTl.fromTo(copyLayers[toIdx], { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: FADE, ease: 'power2.out' }, pos + FADE + 0.002); }; // 3 panels: capabilities → sovereignty → Denmark stackTl.fromTo(copyLayers[0], { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: FADE * 1.5, ease: 'power2.out' }, PHASE_B_START + 0.08); swap(0, 1, 0.77); // sovereignty swap(1, 2, 0.90); // Denmark // Clean exit: fade the whole stack-pin contents just before the pin // releases, so the scroll gap before #words-scene shows clean paper // rather than stack content receding away. stackTl.to('.layer-theatre', { opacity: 0, duration: 0.03, ease: 'power2.in' }, 0.97); /* ------------------------------------------------------------- SCENE 3 — WORDS fly in one at a time, driven by scroll ------------------------------------------------------------- */ // Before capturing the .words spans, rebuild the sentence with the // user's first name if we have one. window.__fenjaFirstName is set // by timeline.js's /auth/me fetch. Falls back to the no-name variant // already in the DOM (see public/entrance.html's static fallback). // // Sentence shape: // With name: "This is why we've invited you, Erik. // To ensure Fenja AI is not just built for you — but // with you." // No name: "This is why we've invited you. To ensure // Fenja AI is not just built for you — but // with you." // // We rebuild the .words paragraph in place. The hi-classed spans are // the ones that fly in from center with extra weight (see below). (function rebuildWordsSentence() { const wordsP = document.getElementById('words-sentence'); if (!wordsP) return; const firstName = (typeof window.__fenjaFirstName === 'string') ? window.__fenjaFirstName.trim() : null; // Build the token list. Each token is { text, hi }. Whitespace // between tokens is handled by natural text-wrap — each .w has // `display: inline-block` plus normal spacing between siblings. let tokens; if (firstName) { tokens = [ { text: 'This' }, { text: 'is' }, { text: 'why' }, { text: 'we\u2019ve' }, { text: 'invited' }, { text: 'you,' }, { text: firstName + '.', hi: true }, { text: 'To' }, { text: 'ensure' }, { text: 'Fenja' }, { text: 'AI' }, { text: 'is' }, { text: 'not' }, { text: 'just' }, { text: 'built' }, { text: 'for' }, { text: 'you' }, { text: '\u2014' }, { text: 'but' }, { text: 'with', hi: true }, { text: 'you.', hi: true }, ]; } else { // No name — structurally identical layout so the same fly-in // curves work without retuning. "you." after "invited" gets .hi // to carry the weight the name would've carried. tokens = [ { text: 'This' }, { text: 'is' }, { text: 'why' }, { text: 'we\u2019ve' }, { text: 'invited' }, { text: 'you.', hi: true }, { text: 'To' }, { text: 'ensure' }, { text: 'Fenja' }, { text: 'AI' }, { text: 'is' }, { text: 'not' }, { text: 'just' }, { text: 'built' }, { text: 'for' }, { text: 'you' }, { text: '\u2014' }, { text: 'but' }, { text: 'with', hi: true }, { text: 'you.', hi: true }, ]; } // Flush the fallback content, rebuild. Using explicit createElement // rather than innerHTML so firstName is never HTML-interpolated. wordsP.textContent = ''; tokens.forEach((t, i) => { const span = document.createElement('span'); span.className = t.hi ? 'w hi' : 'w'; span.textContent = t.text; wordsP.appendChild(span); // Preserve natural whitespace between tokens (critical for text-wrap). if (i < tokens.length - 1) wordsP.appendChild(document.createTextNode(' ')); }); })(); const wordEls = gsap.utils.toArray('.words .w'); // Give each word a random fly-in vector (stable per word), and a scale pop. // The "with them" words (marked .hi) come in from center with more weight. const rnd = (i, seed) => { // simple deterministic pseudo-random so layout is stable per word const s = Math.sin((i + 1) * seed) * 10000; return s - Math.floor(s); }; wordEls.forEach((w, i) => { const hi = w.classList.contains('hi'); const fromX = hi ? 0 : (rnd(i, 12.9898) - 0.5) * 220; const fromY = hi ? 80 : (rnd(i, 78.233) - 0.5) * 160; const rot = hi ? 0 : (rnd(i, 37.719) - 0.5) * 16; gsap.set(w, { opacity: 0, x: fromX, y: fromY, rotate: rot, scale: hi ? 1.05 : 0.9, filter: 'blur(6px)', }); }); const wordsTl = gsap.timeline({ scrollTrigger: { trigger: '#words-scene', start: 'top top', end: 'bottom bottom', scrub: 0.4, } }); wordEls.forEach((w, i) => { const hi = w.classList.contains('hi'); const dur = hi ? 0.14 : 0.1; wordsTl.to(w, { opacity: 1, x: 0, y: 0, rotate: 0, scale: 1, filter: 'blur(0px)', duration: dur, ease: 'power3.out', }, i * 0.055); if (hi) { wordsTl.to(w, { scale: 1.0, duration: 0.05 }, '>-0.02'); } }); /* ------------------------------------------------------------- SCENE 4 — PROJECT BIFROST REVEAL Arc draws in, then the words settle. ------------------------------------------------------------- */ const arcMain = document.getElementById('arcMain'); const arcThin = document.getElementById('arcThin'); const arcHalo = document.getElementById('arcHalo'); [arcMain, arcThin, arcHalo].forEach(el => { if (!el) return; const len = el.getTotalLength(); el.style.strokeDasharray = len; el.style.strokeDashoffset = len; }); const tokens = gsap.utils.toArray('.bifrost-name .token'); tokens.forEach((t, i) => { gsap.set(t, { opacity: 0, y: 40, filter: 'blur(10px)' }); }); const bifrostTl = gsap.timeline({ scrollTrigger: { trigger: '#bifrost', start: 'top top', end: 'bottom bottom', scrub: 0.6, } }); bifrostTl .to('.arc-wrap', { opacity: 1, duration: 0.1, ease: 'power2.out' }, 0.02) .to(arcHalo, { strokeDashoffset: 0, duration: 0.45, ease: 'power2.inOut' }, 0.02) .to(arcMain, { strokeDashoffset: 0, duration: 0.5, ease: 'power2.inOut' }, 0.05) .to(arcThin, { strokeDashoffset: 0, duration: 0.45, ease: 'power2.inOut' }, 0.12) .to('.bifrost-eyebrow', { opacity: 1, y: 0, duration: 0.1, ease: 'power2.out' }, 0.30) .fromTo('.bifrost-eyebrow', { y: 20 }, { y: 0, duration: 0.1 }, 0.30) .to(tokens[0], { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.15, ease: 'power3.out' }, 0.40) .to(tokens[1], { opacity: 1, y: 0, filter: 'blur(0px)', duration: 0.2, ease: 'power3.out' }, 0.50) .to('.bifrost-sub', { opacity: 1, y: 0, duration: 0.15, ease: 'power2.out' }, 0.68) .fromTo('.bifrost-sub', { y: 20 }, { y: 0, duration: 0.15 }, 0.68); // Slight parallax on the arc while the user continues to scroll bifrostTl.to('.arc-wrap', { y: -40, duration: 0.3, ease: 'none' }, 0.5); /* ------------------------------------------------------------- On load — kick hero animation ------------------------------------------------------------- */ // Ensure ScrollTrigger measures correctly after fonts load. if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => ScrollTrigger.refresh()); } // Script 2 body — SCENE 5 (treasure-map) + SCENE 6 (join CTA + footer) /* ================================================================= PROJECT BIFROST — "What it means" treasure-map + summary cards. Self-contained additive IIFE. Reads the global `gsap` and `ScrollTrigger` registered by the existing scripts above; does not touch any pre-existing timelines or DOM nodes. ================================================================= */ /* ------------------------------------------------------------- SCENE 5 — Treasure-map path draw + per-stop reveals ------------------------------------------------------------- */ // -------- Build the path geometry from actual dot positions -------- // // The treasure-map path needs to thread cleanly through each dot. // The dots are positioned by CSS grid/flex, so their Y positions in // the canvas depend on the rendered height of each stop's content // (which varies with viewport width and font metrics). We compute // the path here AFTER layout, so it always passes through the dots. // // Coordinate system: the SVG viewBox is "0 0 100 200" with // preserveAspectRatio="none", so X is normalised to canvas width // and Y to canvas height. We measure each dot's centre in canvas // coordinates, normalise, and emit a chain of cubic bezier segments // — each one bowing out to alternating sides for a meandering feel. const mapCanvasEl = document.querySelector('.map-canvas'); const pathBg = document.getElementById('mapPathBg'); const pathDraw = document.getElementById('mapPathDraw'); let drawScrollTrigger = null; function buildMapPath() { if (!mapCanvasEl || !pathDraw || !pathBg) return; const dots = mapCanvasEl.querySelectorAll('.dot-anchor'); if (dots.length < 2) return; const cb = mapCanvasEl.getBoundingClientRect(); if (cb.width === 0 || cb.height === 0) return; // Normalise dot centres to viewBox units (0-100 X, 0-200 Y) const pts = Array.from(dots).map((d) => { const r = d.getBoundingClientRect(); return { x: ((r.left + r.width / 2 - cb.left) / cb.width) * 100, y: ((r.top + r.height / 2 - cb.top) / cb.height) * 200, }; }); // Build path: M to first, then cubic beziers to each subsequent // dot. Control points sit at the same X as the dots (so the path // exits/enters each dot along the vertical axis) but bow out to // alternating sides between them — gives a Nordic-river feel. const segs = [`M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`]; for (let i = 1; i < pts.length; i++) { const a = pts[i - 1]; const b = pts[i]; // Bow direction alternates by segment index. Amplitude is in // viewBox X units (0-100) — clamped down on tall segments to // avoid the path drifting outside the canvas. const bowDir = (i % 2 === 1) ? 1 : -1; const bowAmount = Math.min(20, (b.y - a.y) * 0.18); const cx1 = 50 + bowAmount * bowDir; const cy1 = a.y + (b.y - a.y) * 0.35; const cx2 = 50 + bowAmount * bowDir; const cy2 = a.y + (b.y - a.y) * 0.65; segs.push(`C ${cx1.toFixed(2)} ${cy1.toFixed(2)}, ${cx2.toFixed(2)} ${cy2.toFixed(2)}, ${b.x.toFixed(2)} ${b.y.toFixed(2)}`); } const d = segs.join(' '); pathBg.setAttribute('d', d); pathDraw.setAttribute('d', d); // Re-measure the drawn path's length and reset the dash offset // so the draw-in animation covers the full new geometry. const len = pathDraw.getTotalLength(); pathDraw.style.strokeDasharray = len; // If the scroll-trigger animation already played to completion, // keep the path drawn; otherwise hide it pending scroll. const trig = drawScrollTrigger; if (trig && trig.progress >= 1) { pathDraw.style.strokeDashoffset = 0; } else { pathDraw.style.strokeDashoffset = len; } } // Build initially after a paint requestAnimationFrame(buildMapPath); // Animate the accent path drawing in as the user scrolls down // through the meaning section if (pathDraw) { const tween = gsap.to(pathDraw, { strokeDashoffset: 0, ease: 'none', scrollTrigger: { trigger: '#bifrost-meaning', start: 'top 65%', end: 'bottom 75%', scrub: 0.6, invalidateOnRefresh: true, onRefresh: () => { // Re-measure dasharray on every ScrollTrigger refresh so // the animation stays in sync with any path changes const len = pathDraw.getTotalLength(); pathDraw.style.strokeDasharray = len; }, } }); drawScrollTrigger = tween.scrollTrigger; } // Rebuild the path on resize (debounced) since dot positions move let mapPathResizeTimer; window.addEventListener('resize', () => { clearTimeout(mapPathResizeTimer); mapPathResizeTimer = setTimeout(() => { buildMapPath(); ScrollTrigger.refresh(); }, 220); }); // Mobile fallback — the SVG path is hidden and replaced by a CSS // pseudo-element rail. Drive its progress with a CSS custom prop // so the same scroll range animates a vertical fill. const mapCanvas = document.querySelector('.map-canvas'); if (mapCanvas) { const railObj = { p: 0 }; gsap.to(railObj, { p: 100, ease: 'none', scrollTrigger: { trigger: '#bifrost-meaning', start: 'top 65%', end: 'bottom 75%', scrub: 0.6, onUpdate: () => { mapCanvas.style.setProperty('--rail-progress', railObj.p + '%'); } } }); } // Per-stop reveal — dot pops in first, then content + image rise // and fade in alongside each other. ToggleActions: play forward // when entering, reverse when scrolling back up past the trigger. gsap.utils.toArray('.map-stop').forEach((stop) => { const dot = stop.querySelector('.dot'); const contentBits = stop.querySelectorAll('.stop-content > *'); const image = stop.querySelector('.stop-image'); const tl = gsap.timeline({ scrollTrigger: { trigger: stop, start: 'top 78%', toggleActions: 'play none none reverse', } }); // Dot pops in with a subtle back ease — feels like a pin // dropping into the map if (dot) { tl.to(dot, { scale: 1, opacity: 1, duration: 0.55, ease: 'back.out(2)', }); } // Text bits stagger in if (contentBits.length) { tl.to(contentBits, { opacity: 1, y: 0, duration: 0.7, stagger: 0.08, ease: 'power3.out', }, '-=0.35'); } // Image animates in alongside the text (overlapping for unity) if (image) { tl.to(image, { opacity: 1, y: 0, duration: 0.9, ease: 'power3.out', }, '-=0.6'); } }); /* ------------------------------------------------------------- SCENE 6 — Join section: scroll-triggered reveals + CTA click ------------------------------------------------------------- */ // Reveal the CTA panel when the section scrolls into view. // Captured to a variable so the click handler can kill this // ScrollTrigger once the user has joined — otherwise scrolling up // and back down would re-play the reveal and the CTA would fade // back in over the confirmation. const ctaRevealTween = gsap.to('.join-cta', { opacity: 1, y: 0, duration: 0.9, ease: 'power3.out', scrollTrigger: { trigger: '#bifrost-join', start: 'top 70%', toggleActions: 'play none none reverse', } }); // Reveal the three footer marks in sequence gsap.to('.join-footer > *', { opacity: 1, y: 0, duration: 0.8, stagger: 0.14, ease: 'power3.out', scrollTrigger: { trigger: '.join-footer', start: 'top 88%', toggleActions: 'play none none reverse', } }); // CTA click handler — crossfade CTA out, confirmation in, then stagger // the checkmarks on each list item so the list feels like it's // filling in as the user reads it. const joinBtn = document.getElementById('joinBtn'); const joinCTA = document.getElementById('joinCTA'); const joinConfirm = document.getElementById('joinConfirm'); if (joinBtn && joinCTA && joinConfirm) { joinBtn.addEventListener('click', () => { if (joinBtn.disabled) return; joinBtn.disabled = true; // Record the click on the server. Fire-and-forget — the UI // transitions below run regardless of network outcome so a // temporary failure doesn't trap the user in a broken state. // The server uses INSERT OR IGNORE keyed on email, so repeat // clicks from the same user are safely deduplicated. fetch('/api/bifrost-join', { method: 'POST', credentials: 'same-origin', }).catch(() => { // Network/server error — intentionally swallowed. An admin // listing missing entries can follow up out-of-band. }); // Kill the CTA's scroll-reveal trigger so scrolling up + back // down can't replay the reveal and bring the CTA back over the // confirmation. After click, the CTA stays in whatever state // the click-timeline puts it in (fading out, then hidden). if (ctaRevealTween && ctaRevealTween.scrollTrigger) { ctaRevealTween.scrollTrigger.kill(); } const items = joinConfirm.querySelectorAll('.confirm-list li'); const tl = gsap.timeline(); // Fade the CTA out tl.to(joinCTA, { opacity: 0, y: -16, duration: 0.5, ease: 'power2.in', onComplete: () => { joinCTA.setAttribute('aria-hidden', 'true'); joinCTA.style.pointerEvents = 'none'; } }); // Fade the confirmation in tl.fromTo(joinConfirm, { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.7, ease: 'power3.out', onStart: () => { joinConfirm.setAttribute('aria-hidden', 'false'); joinConfirm.style.pointerEvents = 'auto'; }, }, '-=0.1'); // Stagger the circle+check markers by toggling `.is-checked` // on each list item — CSS handles the pop-in transition. items.forEach((li, i) => { gsap.delayedCall(0.45 + i * 0.16, () => { li.classList.add('is-checked'); }); }); }); } /* ------------------------------------------------------------- Refresh ScrollTrigger after fonts and images load so positions are accurate — large embedded illustrations affect layout. ------------------------------------------------------------- */ if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => ScrollTrigger.refresh()); } // Refresh once illustrations have laid out const illustrations = document.querySelectorAll('#bifrost-meaning img'); let pending = illustrations.length; if (pending === 0) ScrollTrigger.refresh(); illustrations.forEach((img) => { if (img.complete) { if (--pending === 0) ScrollTrigger.refresh(); } else { img.addEventListener('load', () => { if (--pending === 0) ScrollTrigger.refresh(); }); img.addEventListener('error', () => { if (--pending === 0) ScrollTrigger.refresh(); }); } }); // ─── One consolidated resize handler ───────────────────────── // Site 2 had two separate resize listeners; we defer to ScrollTrigger's // own handling + our de-duped refresh. Scroller-relative measurements // get recalculated whenever ScrollTrigger.refresh fires. let resizeT = null; window.addEventListener('resize', () => { clearTimeout(resizeT); resizeT = setTimeout(scheduleRefresh, 220); }); // After fonts load, re-measure (headline wrap can shift positions). if (document.fonts && document.fonts.ready) { document.fonts.ready.then(scheduleRefresh); } // After all illustrations load, re-measure (treasure-map stops change height). const bgIllustrations = document.querySelectorAll('#page-overview .stop-illust'); let pendingBg = bgIllustrations.length; if (pendingBg === 0) scheduleRefresh(); bgIllustrations.forEach((el) => { // Illustrations are CSS-background images; use an Image() to listen for load const bg = getComputedStyle(el).backgroundImage; const url = bg && bg.match(/url\("?([^")]+)"?\)/); if (!url) { if (--pendingBg === 0) scheduleRefresh(); return; } const img = new Image(); img.onload = img.onerror = () => { if (--pendingBg === 0) scheduleRefresh(); }; img.src = url[1]; }); // Final refresh after everything wires up scheduleRefresh(); } /** * Smooth-scroll the Overview's internal scroller to a scene. * Called by the dot-nav click handler in timeline.js. * * @param {string} sceneId id of the scene section (e.g. "stack-scene") * — see sceneOrder[] inside init(). * Special value "hero" scrolls to top (0). */ function scrollTo(sceneId) { if (!scrollerEl) return; // init() hasn't run yet — ignore const target = document.getElementById(sceneId); if (!target) return; // "hero" is the first scene and sits at scrollTop 0. Scrolling to // the scene element directly works in most cases but produces a tiny // non-zero offset (padding / border) — hard-code 0 for hero. const scrollY = sceneId === 'hero' ? 0 : target.offsetTop; if (lenisInstance && typeof lenisInstance.scrollTo === 'function') { // Lenis does the smooth animation. `immediate: false` uses the // same easing as wheel input — feels consistent. lenisInstance.scrollTo(scrollY, { immediate: false }); } else { // Fallback for pre-init / reduced-motion: hard-jump. scrollerEl.scrollTo({ top: scrollY, behavior: 'smooth' }); } } // Public surface — timeline.js calls these when the Overview tab // activates (init) or when a dot-nav button targeting a scene is // clicked (scrollTo). window.__bifrost = { init, scrollTo }; })();