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