// The Mirror - scroll effects. External file: script-src 'self' holds, no inline JS. // Design constraints: no perpetual work - scroll reads are coalesced into a single rAF, // per-scene work only runs while that scene intersects the viewport, the pulse loop // runs only while its scene is visible AND the tab is visible AND motion is allowed, // and a change to prefers-reduced-motion takes effect live without a reload. (function () { "use strict"; var motionQuery = matchMedia("(prefers-reduced-motion: reduce)"); var bar = document.getElementById("bar"); var s20 = document.querySelector("#s20 img"); var ring = document.getElementById("ring"); var s22 = document.getElementById("s22"); var glows = s22 ? [].slice.call(s22.querySelectorAll(".s22-glow")) : []; var s24 = document.querySelector("#s24 img"); var pulse = document.getElementById("pulse"); // visibility ledger: IntersectionObserver marks which scenes are on screen var visible = {}; var watched = [ ["s20", s20 && s20.closest(".scene")], ["s21", ring && ring.closest(".scene")], ["s22", s22 && s22.closest(".scene")], ["s23", pulse && pulse.closest(".scene")], ["s24", s24 && s24.closest(".scene")], ].filter(function (w) { return w[1]; }); var io = new IntersectionObserver(function (entries) { entries.forEach(function (e) { var hit = watched.find(function (w) { return w[1] === e.target; }); if (hit) visible[hit[0]] = e.isIntersecting; }); if (visible.s23) startPulse(); // (re)arm the breather when its scene returns onScroll(); // anchor jumps and observer-only transitions update without a scroll event }, { rootMargin: "10% 0px" }); watched.forEach(function (w) { io.observe(w[1]); }); // progress of an element through the viewport, guarded against zero heights function prog(el) { var r = el.getBoundingClientRect(); var span = innerHeight + r.height; if (!span) return 0; return Math.max(0, Math.min(1, (innerHeight - r.top) / span)); } // scroll work: one rAF per burst, only touching scenes that are on screen var ticking = false; function frame() { ticking = false; var d = document.documentElement; var denom = d.scrollHeight - innerHeight; if (bar) bar.style.width = (denom > 0 ? (100 * d.scrollTop / denom) : 0) + "%"; if (motionQuery.matches) return; if (visible.s20 && s20) { var p20 = Math.min(1, prog(s20) * 1.6); s20.style.transform = "scale(" + (1.07 - 0.07 * p20) + ") translateY(" + (26 - 26 * p20) + "px)"; } if (visible.s21 && ring) ring.style.transform = "rotate(" + (prog(ring) * 300) + "deg)"; if (visible.s22 && glows.length) { var p22 = prog(s22); glows.forEach(function (g, i) { g.style.opacity = Math.max(0, Math.min(0.85, (p22 - (0.18 + i * 0.22)) * 6)); }); } if (visible.s24 && s24) s24.style.transform = "translateY(" + ((prog(s24) - 0.5) * -26) + "px) scale(1.03)"; } function onScroll() { if (!ticking) { ticking = true; requestAnimationFrame(frame); } } addEventListener("scroll", onScroll, { passive: true }); addEventListener("resize", onScroll); // the jam's breathing glow: runs only while worth running, stops itself otherwise var breathing = false; function startPulse() { if (breathing || !pulse || motionQuery.matches || document.hidden || !visible.s23) return; breathing = true; var t0 = null; requestAnimationFrame(function breathe(t) { if (motionQuery.matches || document.hidden || !visible.s23) { breathing = false; pulse.style.opacity = 0.25; return; } if (t0 === null) t0 = t; pulse.style.opacity = 0.18 + 0.2 * (0.5 + 0.5 * Math.sin((t - t0) / 520)); requestAnimationFrame(breathe); }); } document.addEventListener("visibilitychange", function () { if (!document.hidden) { startPulse(); onScroll(); } }); // live preference change: clear transforms so the page settles to its static state function motionChanged() { if (motionQuery.matches) { [s20, ring, s24].forEach(function (el) { if (el) el.style.transform = ""; }); glows.forEach(function (g) { g.style.opacity = 0.35; }); if (pulse) pulse.style.opacity = 0.25; } else { startPulse(); onScroll(); } } if (motionQuery.addEventListener) motionQuery.addEventListener("change", motionChanged); startPulse(); onScroll(); })();