customer-presentation/protected/bifrost.js
Arlind Ukshini 04c665e51c small fixes
2026-04-23 12:41:18 +02:00

1241 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// ─────────────────────────────────────────────────────────────
// protected/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';
});
// 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 (wheel-multiplier attenuation) ────────────
//
// Previous implementation multiplied Lenis's `.velocity` per scroll
// event — ineffective, because Lenis overwrites velocity from the
// next wheel event before that damped value is consumed.
//
// New approach: directly reduce Lenis's `wheelMultiplier` when the
// viewport center is near a sticky target. The user's wheel input
// is attenuated at the source — their next scroll produces less
// delta, so the scene noticeably holds. When the zone is exited,
// the multiplier is restored.
//
// Targets are:
// - Non-pinned scenes (hero, words-scene, bifrost, bifrost-join)
// - The treasure map (bifrost-meaning) AND each of its three
// stops individually — previously the whole 300vh section was
// one target, so users flew through the individual stops.
//
// stack-scene (S2) is deliberately excluded — it's GSAP-pinned and
// scrubbed; damping on top makes its card-fall feel like a drag.
const BASE_WHEEL_MULT = 1.0;
const BASE_TOUCH_MULT = 1.5;
const STICKY_WHEEL_MULT = 0.35; // 65% reduction while in a sticky zone
const STICKY_TOUCH_MULT = 0.55;
const STICKY_ZONE_VH = 0.18; // ±18% of viewport height from its center
// Collect sticky targets. IDs of scenes + DOM refs for the three
// individual treasure-map stops. Cached once — the list doesn't
// change after init.
function collectStickyTargets() {
const targets = [];
const sceneIds = ['hero', 'words-scene', 'bifrost', 'bifrost-join'];
sceneIds.forEach(id => {
const el = document.getElementById(id);
if (el) targets.push(el);
});
// Treasure map intro + three stops each get their own sticky zone.
const mapSection = document.getElementById('bifrost-meaning');
if (mapSection) {
// Each .map-stop is a separate card along the path — sticky them
// individually so the user dwells on each one.
mapSection.querySelectorAll('.map-stop').forEach(el => targets.push(el));
}
return targets;
}
const stickyTargets = collectStickyTargets();
let wheelMultState = BASE_WHEEL_MULT;
function updateStickyDamping() {
const midY = window.innerHeight * 0.5;
const zone = window.innerHeight * STICKY_ZONE_VH;
let inZone = false;
for (const el of stickyTargets) {
const r = el.getBoundingClientRect();
const elMid = (r.top + r.bottom) * 0.5;
if (Math.abs(elMid - midY) < zone) {
inZone = true;
break;
}
}
const nextMult = inZone ? STICKY_WHEEL_MULT : BASE_WHEEL_MULT;
if (nextMult !== wheelMultState) {
wheelMultState = nextMult;
// Lenis reads options.wheelMultiplier on every wheel event —
// re-assigning takes effect immediately.
lenis.options.wheelMultiplier = nextMult;
lenis.options.touchMultiplier = inZone ? STICKY_TOUCH_MULT : BASE_TOUCH_MULT;
}
}
lenis.on('scroll', updateStickyDamping);
// Initial paint (we likely start at hero which is itself a target).
requestAnimationFrame(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 — staggered intro on load
------------------------------------------------------------- */
const heroTl = gsap.timeline({ defaults: { ease: 'power3.out' } });
// Split the hero title into lines-ish spans for a nicer reveal
const heroTitle = document.querySelector('.hero-title');
if (heroTitle) {
// preserve <br>, wrap visible text chunks in spans
const walk = (node) => {
const kids = [...node.childNodes];
kids.forEach(k => {
if (k.nodeType === Node.TEXT_NODE && k.textContent.trim()) {
const frag = document.createDocumentFragment();
k.textContent.split(/(\s+)/).forEach(tok => {
if (tok.trim()) {
const w = document.createElement('span');
w.className = 'htw';
w.style.display = 'inline-block';
w.style.overflow = 'hidden';
const inner = document.createElement('span');
inner.style.display = 'inline-block';
inner.style.transform = 'translateY(110%)';
inner.style.willChange = 'transform';
inner.textContent = tok;
w.appendChild(inner);
frag.appendChild(w);
} else {
frag.appendChild(document.createTextNode(tok));
}
});
node.replaceChild(frag, k);
} else if (k.nodeType === Node.ELEMENT_NODE && k.tagName !== 'BR') {
walk(k);
}
});
};
walk(heroTitle);
}
heroTl
.from('.eyebrow', { opacity: 0, y: 14, duration: 0.7 }, 0.15)
.to('.hero-title .htw > span', { y: '0%', duration: 1.05, stagger: 0.045, ease: 'power4.out' }, 0.2)
.from('.hero-lede', { opacity: 0, y: 20, duration: 0.9 }, 0.7)
.from('.hero-foot', { opacity: 0, y: 14, duration: 0.8 }, 0.9);
/* -------------------------------------------------------------
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, <hi>Erik.</hi>
// To ensure Fenja AI is not just built for you — but
// <hi>with</hi> <hi>you.</hi>"
// No name: "This is why we've invited <hi>you.</hi> To ensure
// Fenja AI is not just built for you — but
// <hi>with</hi> <hi>you.</hi>"
//
// 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;
// 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 };
})();