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.
|
// 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') {
|
||||||
|
|
|
||||||
|
|
@ -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) ───────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue