fix: revert lazy scene-init - eager pin creation reserves pin-spacing so scenes no longer float over the next section; drop decode-gate that stalled with loading=lazy (kept intrinsic dims, will-change lifecycle, textContent legends)

This commit is contained in:
2026-08-17 20:00:48 +10:00
parent 50993bedec
commit 4849627131
+38 -42
View File
@@ -921,17 +921,17 @@ body:not(.fx) .slab{display:none}
units.push({ sec, man, scene, flat, legend });
});
// Reduced motion: leave the flat exploded hero in place (it IS the end state), no slabs, no scrub.
// Reduced motion / no GSAP: leave the flat exploded hero in place (it IS the end state).
if (RM || !window.gsap || !window.ScrollTrigger) return;
document.body.classList.add("fx");
gsap.registerPlugin(ScrollTrigger);
const wc = (u, on) => u.slabs && u.slabs.forEach(el => el.style.willChange = on ? "transform" : "auto");
const wc = (u, on) => u.slabs.forEach(el => el.style.willChange = on ? "transform" : "auto");
// Build a scene's slabs lazily, just before it approaches the viewport. The flat image stays the
// fallback until the slabs are appended, so a scene that never initialises still reads correctly.
const initScene = u => {
if (u.slabs) return;
// Build every scene's slabs up front so pinned ScrollTriggers reserve their pin-spacing in the
// document flow from the start. Creating pins lazily reflows the page and floats a scene over the
// following section. The flat hero stays the fallback until its slabs exist.
units.forEach(u => {
u.slabs = u.man.parts.map(p => {
const d = document.createElement("div");
d.className = "slab";
@@ -941,6 +941,7 @@ body:not(.fx) .slab{display:none}
u.scene.appendChild(d);
return d;
});
u.flat.style.visibility = "hidden";
const lis = u.legend ? [...u.legend.children] : [];
const n = lis.length;
u.apply = t => {
@@ -954,42 +955,37 @@ body:not(.fx) .slab{display:none}
u.scene.style.transform = "scale(" + (0.95 + 0.05 * t).toFixed(4) + ")";
if (n) lis.forEach((li, i) => li.classList.toggle("lit", t >= 0.9 * ((i + 0.5) / n)));
};
u.flat.style.visibility = "hidden";
const desktop = document.documentElement.scrollHeight - innerHeight > 200 &&
matchMedia("(min-width: 901px)").matches;
if (desktop) {
u.mode = "pin+scrub";
ScrollTrigger.create({
trigger: u.sec.querySelector(".pin"),
start: "top top", end: "+=1800", pin: true, scrub: 0.6,
onToggle: self => wc(u, self.isActive),
onUpdate: self => u.apply(self.progress),
onRefreshInit: () => u.apply(0)
});
u.apply(0);
} else {
u.mode = "assemble";
u.sec.querySelector(".pin").style.minHeight = "0";
u.apply(0); wc(u, true);
gsap.to({ v: 0 }, { v: 1, duration: 1.6, ease: "power3.out",
onUpdate: function () { u.apply(this.targets()[0].v); },
onComplete: () => wc(u, false) });
}
ScrollTrigger.refresh();
};
});
const boot = () => {
// initialise each scene shortly before it reaches the viewport; keep the flat image until then
const near = new IntersectionObserver((es, obs) => {
es.forEach(e => {
if (!e.isIntersecting) return;
const u = units.find(x => x.sec === e.target);
if (u) initScene(u);
obs.unobserve(e.target);
});
}, { rootMargin: "400px 0px 400px 0px" });
units.forEach(u => near.observe(u.sec));
const desktop = document.documentElement.scrollHeight - innerHeight > 200 &&
matchMedia("(min-width: 901px)").matches;
units.forEach(u => {
if (desktop) {
u.mode = "pin+scrub";
ScrollTrigger.create({
trigger: u.sec.querySelector(".pin"),
start: "top top", end: "+=1800", pin: true, scrub: 0.6,
onToggle: self => wc(u, self.isActive),
onUpdate: self => u.apply(self.progress),
onRefreshInit: () => u.apply(0)
});
u.apply(0);
} else {
u.mode = "assemble";
u.sec.querySelector(".pin").style.minHeight = "0";
u.apply(0);
// assemble once, when the scene scrolls in; keep will-change only for the tween's life
ScrollTrigger.create({
trigger: u.scene, start: "top 80%", once: true,
onEnter: () => { wc(u, true); gsap.to({ v: 0 }, {
v: 1, duration: 1.6, ease: "power3.out",
onUpdate: function () { u.apply(this.targets()[0].v); },
onComplete: () => wc(u, false) }); }
});
}
});
ScrollTrigger.refresh();
};
// the aperitif terminal types its transcript once, when it first enters view
document.querySelectorAll(".crt").forEach(crt => {
@@ -1002,7 +998,7 @@ body:not(.fx) .slab{display:none}
io.observe(crt);
});
Promise.all(units.map(u => u.flat.decode ? u.flat.decode().catch(() => {}) : Promise.resolve()))
.then(() => requestAnimationFrame(() => requestAnimationFrame(boot)));
// boot on the next frame; do not gate on decoding lazy images (that stalls with loading=lazy)
requestAnimationFrame(() => requestAnimationFrame(boot));
})();
</script>