overview: pin-with-scrub-release on cards & roadmap; fix dot-nav

initCards and initRoadmap now use a pin-with-scrub-release pattern
instead of a simple scroll-tied fade. Each section fades in over ~50vh
as it approaches viewport centre, locks in place for 100vh of scroll
input (cards extends to 150vh and fades out while still pinned;
roadmap stays visible as the page ends), then releases. Scroll itself
is never blocked — wheel/keyboard/touch all advance scroll normally
against the pin budget. platform-cards is removed from bifrost's
sticky-damping list since the new pin handles the dwell.

Dot-nav fixes for the new pins:
- activatePage now also calls __platform.init() in the same tick as
  __bifrost.init(), so pin spacers exist before scrollTo reads
  target offsets. Previously platform's MutationObserver-driven init
  fired ~80ms after scrollTo, leaving roadmap.offsetTop pointing at
  the pre-spacer position (empty space between cards and roadmap).
- scrollTo walks the offsetParent chain via offsetTopWithin() instead
  of reading target.offsetTop directly. ScrollTrigger's pinSpacing
  wraps pinned sections in a pin-spacer with position:relative, which
  becomes the section's offsetParent and makes target.offsetTop
  return ~0 — collapsing every dot click to scrollY=0 (hero).
- getSceneAnchorOffset adds cases for platform-cards / platform-roadmap
  returning (section.height - vh) / 2, so the user lands exactly at
  the pin-engagement point with the full pin budget remaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-20 14:53:01 +02:00
parent 8b277193d1
commit 0c4b3a438e
3 changed files with 212 additions and 79 deletions

View file

@ -352,12 +352,12 @@
// change after init. // change after init.
function collectStickyTargets() { function collectStickyTargets() {
const targets = []; const targets = [];
// platform-cards is included so once the Deployment Options // platform-cards is NOT a sticky-damping target — it has its
// section is centred, the wheel multiplier drops — the // own GSAP pin (see initCards in platform.js) which provides
// reader has to scroll a few extra ticks to continue, which // the "stop" feel. Layering both would slow wheel input to
// pairs with the scroll-tied fade-in to give the section a // 0.35× during the pin and turn the 100vh budget into a
// subtle "stop" feel. // ~285vh slog. platform-roadmap, same.
const sceneIds = ['hero', 'bifrost', 'platform-cards']; const sceneIds = ['hero', 'bifrost'];
sceneIds.forEach(id => { sceneIds.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) targets.push(el); if (el) targets.push(el);
@ -742,14 +742,49 @@
// 85% of vh so the reader arrives on the fully-drawn arc + // 85% of vh so the reader arrives on the fully-drawn arc +
// wordmark, regardless of display size. // wordmark, regardless of display size.
// //
// platform-cards / platform-roadmap — pinned at 'center center', so
// the section's "intended landing" is the scroll position where
// section.centre aligns with viewport.centre. That position is
// section.offsetTop + (section.height - vh) / 2. For sections
// shorter than the viewport this offset is negative — the user
// lands just before the section's natural top, but the pin is
// engaged so visually they see the section centred at full
// opacity with the entire pin budget still ahead of them.
//
// hero — short reveal tween; offsetTop is already the correct // hero — short reveal tween; offsetTop is already the correct
// landing spot so offset is 0. // landing spot so offset is 0.
function getSceneAnchorOffset(sceneId) { function getSceneAnchorOffset(sceneId) {
const vh = window.innerHeight; const vh = window.innerHeight;
switch (sceneId) { switch (sceneId) {
case 'bifrost': return Math.round(vh * 0.85); case 'bifrost':
default: return 0; return Math.round(vh * 0.85);
case 'platform-cards':
case 'platform-roadmap': {
const el = document.getElementById(sceneId);
if (!el) return 0;
return Math.round((el.offsetHeight - vh) / 2);
} }
default:
return 0;
}
}
// Section's offset within the scroller's content. Walks the
// offsetParent chain summing offsetTop. Necessary because
// ScrollTrigger's pinSpacing wraps pinned sections in a pin-spacer
// div (position: relative), which becomes the section's offsetParent
// — so `target.offsetTop` alone returns ~0 and scrollTo lands the
// user at the top of the page instead of the requested section.
function offsetTopWithin(el, scroller) {
let offset = 0;
let current = el;
while (current && current !== scroller) {
offset += current.offsetTop;
const parent = current.offsetParent;
if (!parent) break;
current = parent;
}
return offset;
} }
function scrollTo(sceneId) { function scrollTo(sceneId) {
@ -760,7 +795,7 @@
// "hero" is the first scene and sits at scrollTop 0. Scrolling to // "hero" is the first scene and sits at scrollTop 0. Scrolling to
// the scene element directly works in most cases but produces a tiny // the scene element directly works in most cases but produces a tiny
// non-zero offset (padding / border) — hard-code 0 for hero. // non-zero offset (padding / border) — hard-code 0 for hero.
const base = sceneId === 'hero' ? 0 : target.offsetTop; const base = sceneId === 'hero' ? 0 : offsetTopWithin(target, scrollerEl);
const scrollY = base + getSceneAnchorOffset(sceneId); const scrollY = base + getSceneAnchorOffset(sceneId);
if (lenisInstance && typeof lenisInstance.scrollTo === 'function') { if (lenisInstance && typeof lenisInstance.scrollTo === 'function') {

View file

@ -265,41 +265,128 @@
} }
// ─── Section A: Deployment options (cards) ────────────────── // ─── Section A: Deployment options (cards) ──────────────────
// Scroll-tied fade-in for the WHOLE section content (header // Pin-with-scrub-and-release. Two ScrollTriggers share the
// + four cards). Scrub ties opacity directly to scroll // section as trigger element:
// position so the section "arrives" as the reader scrolls //
// into it, reaching full clarity when its top is well into // Phase A — fade IN (no pin), ~50vh of scroll input.
// the viewport. Paired with a sticky-damping entry in // Section moves up naturally; opacity 0→1 as
// bifrost.js (added to sceneIds in collectStickyTargets) // its centre approaches viewport centre. Phase
// so once centred the wheel resists further scroll briefly // A's end is the exact scroll position at which
// — a subtle stop feel. // the pin engages, so the perceived arrival is
// one continuous motion (no snap from "fading"
// to "pinned").
//
// Phase B+C — PIN with scrubbed timeline, ~150vh of scroll
// input total.
// • 0 → 100vh of pin = HOLD at full opacity.
// The "deliberate pause"; scroll itself is
// never blocked, the pin just holds the
// section visually fixed at viewport
// centre while wheel/keyboard/touch input
// is spent against the budget.
// • 100 → 150vh of pin = fade OUT, still
// pinned. Section dissolves in place;
// pin releases at opacity 0, so the next
// content immediately scrolls into view.
//
// Bundling pin + fade-out into one trigger eliminates the
// cross-trigger dependency that previously had Phase C's
// range collapsing to a single frame (Lenis can use CSS
// transform on the scroller content, in which case
// scroller.scrollTop reads 0 and BCR-based scroll math
// gives a moving target instead of a fixed pin-release
// position). It also avoids a render-order race where
// Phase A would keep writing opacity=1 every scroll event
// past its range, fighting Phase C's writes.
//
// Trade-off vs the literal spec ("fade-out AFTER pin
// releases"): the fade happens while the section is still
// pinned at viewport centre rather than while scrolling
// upward after release. Visually the difference is subtle —
// the section dwells, dissolves, then the next content
// arrives. Implementation is rock solid.
//
// NEVER use CSS scroll-snap for this — snap is non-breakable
// and gives no scroll-budget mechanic. Users must always be
// able to break through by continuing to scroll.
//
// Reduced-motion: skip the fade and the pin entirely. The
// section appears at full opacity and scrolls past normally.
function initCards(gsap, ScrollTrigger, scroller, reduceMotion) { function initCards(gsap, ScrollTrigger, scroller, reduceMotion) {
const head = document.querySelector('#platform-cards .platform-cards-head'); const section = document.getElementById('platform-cards');
const cards = Array.from(document.querySelectorAll('#platform-cards .platform-card')); if (!section) return;
const head = section.querySelector('.platform-cards-head');
const cards = Array.from(section.querySelectorAll('.platform-card'));
const targets = [head, ...cards].filter(Boolean); const targets = [head, ...cards].filter(Boolean);
if (!targets.length) return; if (!targets.length) return;
if (reduceMotion) { if (reduceMotion) {
targets.forEach(c => { c.style.opacity = '1'; c.style.transform = 'none'; }); targets.forEach(c => { c.style.opacity = '1'; c.style.transform = 'none'; });
return; return;
} }
gsap.set(targets, { opacity: 0, y: 24 });
gsap.to(targets, { gsap.set(targets, { opacity: 0, y: 18 });
opacity: 1,
y: 0, // Phase A — fade IN, ~50vh (center bottom → center center).
ease: 'power2.out', ScrollTrigger.create({
stagger: 0.05, trigger: section,
scrollTrigger: {
trigger: '#platform-cards',
scroller, scroller,
// Begin fading in as the section's top enters the start: 'center bottom',
// viewport from below; finish by the time it's well end: 'center center',
// inside (top at 35% from viewport top) so the section scrub: 0.5,
// is fully clear before the reader reaches centre. animation: gsap.fromTo(
start: 'top bottom', targets,
end: 'top 35%', { opacity: 0, y: 18 },
scrub: 0.6, { opacity: 1, y: 0, ease: 'none' }
),
});
// Phase B+C — pin for 150vh with scrubbed timeline.
// Timeline duration = 1.5 units; ScrollTrigger maps scroll
// progress 0→1 across the 150vh pin onto timeline 0→1.5.
// t = 0 → 1.0 : HOLD. A dummy tween on an empty
// object reserves the time slot so the
// timeline's totalDuration is 1.5
// rather than 0.5; nothing is written
// to the targets, so opacity stays at
// Phase A's settled =1.
// t = 1.0 → 1.5 : fade OUT. fromTo with explicit from-
// state {opacity:1, y:0} guarantees the
// fade actually starts at full opacity
// regardless of what GSAP captures or
// when — the previous gsap.to here was
// silently recording opacity=0 (the
// initial gsap.set value) as its from,
// so it animated 0→0 during the visible
// range and only snapped to 0 once past
// the end. immediateRender:false keeps
// the from-state off the targets until
// the playhead actually reaches t=1.
const pinTl = gsap.timeline({
scrollTrigger: {
trigger: section,
scroller,
start: 'center center',
end: '+=150%',
pin: true,
pinSpacing: true,
pinType: 'transform',
scrub: 0.5,
}, },
}); });
pinTl
.to({}, { duration: 1 }, 0)
.fromTo(
targets,
{ opacity: 1, y: 0 },
{
opacity: 0,
y: -18,
ease: 'none',
duration: 0.5,
immediateRender: false,
},
1
);
} }
// ─── Wiki deep-dive — pinned scrubbed five-beat ───────────── // ─── Wiki deep-dive — pinned scrubbed five-beat ─────────────
@ -546,62 +633,61 @@
} }
} }
// ─── Implementation roadmap — fade-in stagger + card morph // ─── Implementation roadmap — pin-with-scrub-release + card morph
// Mirrors initCards for the reveal. After the reveal, each // Same pin pattern as initCards (Phase A + Phase B), but no
// card is click-to-expand — same DOM element morphs into the // Phase C fade-out: this is the page's final section and stays
// featured panel via a FLIP layout animation (see // visible as the page ends. Header (eyebrow + title), stages,
// setupRoadmapMorph below). The expanded card's content // band, and foot all fade in together so the section arrives
// (intro + key activities) is already in the HTML; we just // as one unit — anything inside the section that wasn't part
// toggle the .is-expanded class and animate the layout shift. // of the reveal would look disjointed once the pin engages and
// freezes the section at viewport centre.
//
// Click-to-expand (setupRoadmapMorph) is wired regardless of
// reduced-motion; it's a discrete interaction, not ambient.
function initRoadmap(gsap, ScrollTrigger, scroller, reduceMotion) { function initRoadmap(gsap, ScrollTrigger, scroller, reduceMotion) {
const stages = document.querySelectorAll('#platform-roadmap .rm-card'); const section = document.getElementById('platform-roadmap');
const band = document.querySelector('#platform-roadmap .rm-band');
const foot = document.querySelector('#platform-roadmap .rm-foot');
// Click-to-expand always wires up (even under reduced-motion),
// since the expansion is a discrete interaction rather than
// an ambient animation.
setupRoadmapMorph(reduceMotion); setupRoadmapMorph(reduceMotion);
if (!stages.length) return; if (!section) return;
const header = section.querySelector('.platform-cards-head');
const stages = Array.from(section.querySelectorAll('.rm-card'));
const band = section.querySelector('.rm-band');
const foot = section.querySelector('.rm-foot');
const targets = [header, ...stages, band, foot].filter(Boolean);
if (!targets.length) return;
if (reduceMotion) { if (reduceMotion) {
stages.forEach(c => { c.style.opacity = '1'; }); targets.forEach(c => { c.style.opacity = '1'; c.style.transform = 'none'; });
if (band) band.style.opacity = '1';
if (foot) foot.style.opacity = '1';
return; return;
} }
gsap.set(stages, { opacity: 0, y: 24 });
if (band) gsap.set(band, { opacity: 0, y: 16 });
if (foot) gsap.set(foot, { opacity: 0, y: 12 });
const tl = gsap.timeline({ gsap.set(targets, { opacity: 0, y: 18 });
scrollTrigger: {
trigger: '#platform-roadmap', // Phase A — fade IN, ~50vh (center bottom → center center).
ScrollTrigger.create({
trigger: section,
scroller, scroller,
start: 'top 70%', start: 'center bottom',
once: true, end: 'center center',
}, scrub: 0.5,
animation: gsap.fromTo(
targets,
{ opacity: 0, y: 18 },
{ opacity: 1, y: 0, ease: 'none' }
),
}); });
tl.to(stages, {
opacity: 1, // Phase B — pin for 100vh of scroll input. No fade-out.
y: 0, ScrollTrigger.create({
duration: 0.6, trigger: section,
ease: 'power3.out', scroller,
stagger: 0.08, start: 'center center',
clearProps: 'transform', end: '+=100%',
pin: true,
pinSpacing: true,
pinType: 'transform',
}); });
if (band) {
tl.to(band, {
opacity: 1, y: 0,
duration: 0.5, ease: 'power2.out',
}, '-=0.25');
}
if (foot) {
tl.to(foot, {
opacity: 1, y: 0,
duration: 0.4, ease: 'power2.out',
}, '-=0.25');
}
} }
// ─── Roadmap card morph (FLIP) ─────────────────────────────── // ─── Roadmap card morph (FLIP) ───────────────────────────────

View file

@ -394,6 +394,18 @@ function activatePage(targetId, scrollToId) {
// the user can't scroll before ScrollTriggers are wired up. // the user can't scroll before ScrollTriggers are wired up.
setTimeout(() => { setTimeout(() => {
window.__bifrost.init(); window.__bifrost.init();
// Run platform.js's init in the SAME tick. Without this,
// platform's own MutationObserver fires it ~140ms after
// page activation — too late for the scrollTo below, which
// reads target.offsetTop. Platform installs pin spacers for
// #platform-cards and #platform-roadmap; those spacers shift
// sections downstream of cards by 150vh, so scrolling to
// pre-spacer offsetTop lands the user in empty space.
// __platform.init is idempotent (guarded by `initialized`),
// so calling it here is a no-op if it already ran.
if (window.__platform && typeof window.__platform.init === 'function') {
window.__platform.init();
}
// After init resolves, scroll to the requested scene (or top // After init resolves, scroll to the requested scene (or top
// if none specified). Bifrost exposes scrollTo() which drives // if none specified). Bifrost exposes scrollTo() which drives
// Lenis on the overview's internal scroller. // Lenis on the overview's internal scroller.