From 484962713142710d79b95d52b2685c234aacc137 Mon Sep 17 00:00:00 2001 From: jwright Date: Mon, 17 Aug 2026 20:00:48 +1000 Subject: [PATCH] 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) --- pilot/arc.tpl.html | 80 ++++++++++++++++++++++------------------------ 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/pilot/arc.tpl.html b/pilot/arc.tpl.html index c5cf0e4..a8bf644 100644 --- a/pilot/arc.tpl.html +++ b/pilot/arc.tpl.html @@ -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)); })();