1/* =========================================================================== 2 FOOTER PARALLAX REVEAL (js/footer-parallax.js) â Osmo-style closer. 3 As the page scrolls to its end, the footer rises into view. Two scrubbed 4 tweens sell "surfacing from depth": 5 1) the footer's content is a slower layer â it lags the scroll (settling to 6 its natural position exactly when the footer fills the viewport), so the 7 foreground appears to lift off it; 8 2) a dark ink veil over the footer fades to nothing as it emerges. 9 Both are driven by ScrollTrigger's scrub, which is already fed by Lenis 10 (lenis.on("scroll", ScrollTrigger.update) in index.html) â so the motion 11 inherits the site's smooth-scroll easing and stays glassy. 12 =========================================================================== */ 13(() => { 14 const gsap = window.gsap; 15 const ScrollTrigger = window.ScrollTrigger; 16 const footer = document.querySelector(".pfooter"); 17 if (!footer || !gsap || !ScrollTrigger) return; 18 19 const inner = footer.querySelector(".pfooter__inner"); 20 const veil = footer.querySelector(".pfooter__veil"); 21 const wordmark = footer.querySelector(".pfooter__wordmark"); 22 23 // Respect reduced-motion: land everything at rest, no reveal. 24 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { 25 if (veil) gsap.set(veil, { opacity: 0 }); 26 return; 27 } 28 29 gsap.registerPlugin(ScrollTrigger); 30 31 // Phones (â¤820px): the footer is a content-height block (min-height:auto in 32 // footer.css), SHORTER than the viewport â so the desktop endpoints 33 // ("top top", "bottom bottom") sit PAST the page's maximum scroll and the 34 // scrub can never complete: the footer parks mid-reveal forever (wordmark 35 // sunk, columns high, veil half-dark â the "broken" mobile footer). "max" 36 // ends every scrub exactly at the absolute bottom of the page: always 37 // reachable, no viewport-height math, correct through iOS URL-bar resizes. 38 const narrow = window.matchMedia("(max-width: 820px)").matches; 39 40 // Phones: the document height drifts after load (late media, and on pages 41 // with content-visibility sections their estimatedâreal height swaps), so 42 // the scrub positions above go stale â the reveal then runs early, late, or 43 // never completes. Pages WITH the steady-glide system (pages.js) already 44 // watch for this and refresh at rest with a mid-glide guard; this covers 45 // the pages without it (about). Checked at fire time â script order and 46 // defer make load-time detection unreliable. 47 if (narrow && "ResizeObserver" in window) { 48 // Same safety rules as pages.js's observer: refresh only at scroll-rest 49 // and never inside a pinned intro (none on about, but the guard is cheap) 50 // â a mid-scroll refresh is a visible frame hitch. 51 let lastScrollTs = 0; 52 window.addEventListener("scroll", () => { lastScrollTs = performance.now(); }, { passive: true, capture: true }); 53 let lastDocH = 0; 54 let t = 0; 55 const tryRefresh = () => { 56 const st = window.__introTL && window.__introTL.scrollTrigger; 57 const y = window.scrollY || document.documentElement.scrollTop; 58 if (performance.now() - lastScrollTs < 300 || (st && y < st.end + window.innerHeight * 0.5)) { 59 t = setTimeout(tryRefresh, 500); // retry until clear â deferred, not dropped 60 return; 61 } 62 ScrollTrigger.refresh(); 63 }; 64 new ResizeObserver(() => { 65 const h = document.documentElement.scrollHeight; 66 if (Math.abs(h - lastDocH) <= 4) return; 67 lastDocH = h; 68 if (window.__steadyHeight) return; // pages.js's guarded observer owns this page 69 clearTimeout(t); 70 t = setTimeout(tryRefresh, 250); 71 }).observe(document.body); 72 } 73 74 const build = () => { 75 // (1) Content lags the scroll. The inner starts pulled up (negative yPercent) 76 // and eases to its natural position (0) exactly as the footer finishes 77 // revealing â so net motion reads slower than the page above it. 78 gsap.fromTo( 79 inner, 80 { yPercent: -11 }, 81 { 82 yPercent: 0, 83 ease: "none", 84 scrollTrigger: { 85 trigger: footer, 86 start: "top bottom", // footer's top enters at viewport bottom 87 end: narrow ? "max" : "top top", // fully shown (desktop: footer top hits viewport top) 88 scrub: true, 89 invalidateOnRefresh: true, 90 }, 91 } 92 ); 93 94 // (2) Ink veil fades off over the reveal, so the footer brightens as it 95 // surfaces, then holds clean (power1.out front-loads the fade). 96 if (veil) { 97 gsap.fromTo( 98 veil, 99 { opacity: 0.5 },
100 { 101 opacity: 0, 102 ease: "power1.out", 103 scrollTrigger: { 104 trigger: footer, 105 start: "top bottom", 106 end: narrow ? "max" : "top 15%", 107 scrub: true, 108 invalidateOnRefresh: true, 109 }, 110 } 111 ); 112 } 113 114 // (3) The giant wordmark LIFTS UP into place â it rises vertically (no 115 // sideways growth) as it clears the fold, the same "scrolls with you / 116 // lifts up" feel as the content layer above it. Desktop triggers on the 117 // wordmark itself; phones ride the footer trigger â the wordmark sits 118 // so close to the page's end that its own start/end can fall beyond 119 // the reachable scroll range. 120 if (wordmark) { 121 gsap.fromTo( 122 wordmark, 123 { yPercent: 55 }, // starts lower (pushed down, mostly below the fold) 124 { 125 yPercent: 0, // rises to rest as the footer finishes revealing 126 ease: "none", 127 scrollTrigger: { 128 trigger: narrow ? footer : wordmark, 129 start: "top bottom", 130 end: narrow ? "max" : "bottom bottom", 131 scrub: true, 132 invalidateOnRefresh: true, 133 }, 134 } 135 ); 136 } 137 138 ScrollTrigger.refresh(); 139 }; 140 141 if (document.readyState === "complete") build(); 142 else window.addEventListener("load", build, { once: true }); 143})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.