- Stack title bar moves from top-left next to the site-mark to
centered at ~14vh so the title anchors visually to the cards
below. Font size bumped to clamp(2rem, 3.6vw, 3rem).
- Counter ("1 / 4 … 4 / 4") relocates from the title bar into
each .layer-card as a .card-counter element in the top-right of
each card-box. No longer driven by ScrollTrigger onUpdate —
each card carries its own number, so stacked + grid phases
both read correctly without JS. Grid phase shrinks the counter
so it doesn't compete with the per-cell label.
- SCENE_ANCHOR_OFFSET for stack-scene drops from 1800 back to 0,
so clicking the "Capabilities" dot lands at the top of the
pin — the title and first card come in together instead of
starting mid-stack.
Welcome step: the "desktop experience" aside and its CSS are
removed. Users now see only the two definitions before the CTA.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1263 lines
52 KiB
JavaScript
1263 lines
52 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
||
// 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',
|
||
];
|
||
// Not every scene has a dot in the nav — words-scene and bifrost-meaning
|
||
// are intermediate sections with no standalone dot. Map them to the
|
||
// nearest surviving upstream dot so the nav stays highlighted through
|
||
// those sections instead of going blank.
|
||
const sceneToDot = {
|
||
'hero': 'hero',
|
||
'stack-scene': 'stack-scene',
|
||
'words-scene': 'stack-scene',
|
||
'bifrost': 'bifrost',
|
||
'bifrost-meaning': 'bifrost',
|
||
'bifrost-join': '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', sceneToDot[visibleId] || 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 — 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, <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;
|
||
|
||
// 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).
|
||
*/
|
||
// Per-scene scroll offsets. Added to the scene's offsetTop when a
|
||
// dot-nav button anchors to it, so the reader lands AFTER the scene's
|
||
// initial reveal rather than at an empty pre-scrub frame.
|
||
//
|
||
// stack-scene — offset 0 (top of the pin) so the reader lands right
|
||
// when the title appears and the first card starts its fall, and
|
||
// sees the full progression through all 4 landings.
|
||
//
|
||
// bifrost — section is 200vh with a scrubbed reveal that runs from
|
||
// top-top to bottom-bottom (100vh scroll range). The sub-headline
|
||
// fades in at ~0.83 of that. Offset is computed per viewport as
|
||
// 85% of vh so the reader arrives on the fully-drawn arc +
|
||
// wordmark, regardless of display size.
|
||
//
|
||
// hero, bifrost-join — short reveal tweens; offsetTop is already
|
||
// the correct landing spot so offset is 0.
|
||
function getSceneAnchorOffset(sceneId) {
|
||
const vh = window.innerHeight;
|
||
switch (sceneId) {
|
||
case 'bifrost': return Math.round(vh * 0.85);
|
||
default: return 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 base = sceneId === 'hero' ? 0 : target.offsetTop;
|
||
const scrollY = base + getSceneAnchorOffset(sceneId);
|
||
|
||
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 };
|
||
})();
|