(function () { "use strict"; const SITE_JS_VERSION = "4.11.0"; console.log(`[site] global.js v${SITE_JS_VERSION} loaded`); if (typeof gsap === "undefined") { console.error("[site] GSAP not found. Enable it in Webflow Project Settings → Integrations."); throw new Error("[site] Missing dependency: gsap"); } // v4.0.0 dropped the Observer plugin — it was never used in this file and // its registration cost ~3KB of GSAP bundle for nothing. gsap.registerPlugin(ScrollTrigger); // ── Global perf config ──────────────────────────────────────────────────── // ignoreMobileResize stops ScrollTrigger from refreshing every time the // iOS / Android URL bar shows or hides. A refresh mid-scroll blows away // animation caches and was a big contributor to mobile Safari stutter. // // force3D is NOT enabled globally (it was in v3.0.x). Promoting every // transformed element to a GPU layer — sliders, dots, menu items — causes // layer explosion on Safari and makes compositing slower, not faster. // Instead, force3D is set per-tween on the specific animations that // genuinely benefit from a layer (parallax + footer unveil). ScrollTrigger.config({ ignoreMobileResize: true }); const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; // Safe Number parse — returns the fallback for NaN / Infinity / malformed // input. Protects against setInterval(fn, NaN) and similar footguns when // data-* attributes are mistyped in the CMS. const numOr = (v, fallback) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; }; // ── Lenis ───────────────────────────────────────────────────────────────── // Driven by GSAP's ticker — one RAF loop, not two. lagSmoothing(0) is the // Lenis-recommended pairing; it stops GSAP from re-timing after a long // background tab, which would otherwise show as a lurch on tab re-focus. // syncTouch: false keeps touch scrolling on the browser's native path. // // lerp 0.1 replaces the previous duration-based smoothing. The long tail // that `duration: 1` produces is a known Firefox / Safari stutter source // because every frame compounds fractional-pixel scroll work; lerp is // frame-rate linear and lands in ~5 frames at 60fps, which feels similar // but has far less per-frame compounding. If you need a looser feel, nudge // this down to 0.08 (slower) or 0.14 (tighter). const lenis = new Lenis({ lerp: 0.1, smoothWheel: true, smoothTouch: false, syncTouch: false, wheelMultiplier: 1, }); gsap.ticker.add((time) => lenis.raf(time * 1000)); gsap.ticker.lagSmoothing(0); lenis.on("scroll", ScrollTrigger.update); if (prefersReduced) lenis.stop(); // ── Single resize → ScrollTrigger refresh ───────────────────────────────── let _rTimer; const _refresh = () => { clearTimeout(_rTimer); _rTimer = setTimeout(() => ScrollTrigger.refresh(), 200); }; window.addEventListener("resize", _refresh, { passive: true }); window.addEventListener("orientationchange", _refresh, { passive: true }); // ── Shared matchMedia instance ───────────────────────────────────────────── const mm = gsap.matchMedia(); // ── Slider settings ──────────────────────────────────────────────────────── const DEFAULT_SETTINGS = { startIndex: 0, autoPlay: true, autoPlayDelay: 5000, pauseOnHover: true, swipeThreshold: 50, fadeOutDuration: 0.2, fadeInDuration: 0.6, fadePause: 0.06, fadeEase: "power4.out", dotClass: "quote-dot", dotInactiveAlpha: 0.5, dotActiveAlpha: 1, dotActiveWidth: "2rem", dotCloseDuration: 0.3, dotOpenDuration: 0.5, dotPause: 0.04, dotEase: "power4.out", counterFormat: "index", }; function readSettings(el) { const s = { ...DEFAULT_SETTINGS }, d = el.dataset; if (d.autoplay !== undefined) s.autoPlay = d.autoplay !== "false"; if (d.autoplayDelay !== undefined) s.autoPlayDelay = numOr(d.autoplayDelay, DEFAULT_SETTINGS.autoPlayDelay); if (d.swipeThreshold !== undefined) s.swipeThreshold = numOr(d.swipeThreshold, DEFAULT_SETTINGS.swipeThreshold); if (d.startIndex !== undefined) s.startIndex = numOr(d.startIndex, 0); if (d.counterFormat !== undefined) s.counterFormat = d.counterFormat; return s; } const activeSliders = []; document.addEventListener("visibilitychange", () => { activeSliders.forEach(c => document.hidden ? c.stopAutoPlay() : c.resetAutoPlay()); }); function createSliderCore(sliderEl, slides, settings, onAfterTransition) { let currentIndex = Math.min(settings.startIndex, slides.length - 1); let autoPlayTimer = null, touchStartX = 0, isHovered = false, isAnimating = false; let navigateNext = () => goToSlide(currentIndex + 1); let navigatePrev = () => goToSlide(currentIndex - 1); // will-change set once — avoids per-transition layer promotion churn. slides.forEach((slide, i) => { const active = i === currentIndex; slide.setAttribute("aria-hidden", active ? "false" : "true"); slide.style.willChange = "opacity"; gsap.set(slide, { autoAlpha: active ? 1 : 0, display: active ? "block" : "none" }); }); function goToSlide(targetIndex) { if (slides.length <= 1 || isAnimating) return; const nextIndex = ((targetIndex % slides.length) + slides.length) % slides.length; if (nextIndex === currentIndex) return; const outSlide = slides[currentIndex], inSlide = slides[nextIndex]; outSlide.setAttribute("aria-hidden", "true"); inSlide.setAttribute("aria-hidden", "false"); gsap.killTweensOf([outSlide, inSlide]); isAnimating = true; const tl = gsap.timeline({ onComplete: () => { currentIndex = nextIndex; isAnimating = false; onAfterTransition?.(currentIndex); }, }); tl.to(outSlide, { autoAlpha: 0, duration: settings.fadeOutDuration, ease: settings.fadeEase, onComplete: () => gsap.set(outSlide, { display: "none" }) }); if (settings.fadePause > 0) tl.to({}, { duration: settings.fadePause }); tl.set(inSlide, { display: "block", autoAlpha: 0 }); tl.to(inSlide, { autoAlpha: 1, duration: settings.fadeInDuration, ease: settings.fadeEase }); return tl; } const nextSlide = () => navigateNext(); const prevSlide = () => navigatePrev(); function startAutoPlay() { if (!settings.autoPlay || slides.length <= 1 || prefersReduced) return; stopAutoPlay(); autoPlayTimer = setInterval(() => { if (!isHovered && !isAnimating) nextSlide(); }, settings.autoPlayDelay); } function stopAutoPlay() { if (autoPlayTimer) { clearInterval(autoPlayTimer); autoPlayTimer = null; } } function resetAutoPlay() { if (settings.autoPlay && slides.length > 1) startAutoPlay(); } if (settings.pauseOnHover && slides.length > 1) { sliderEl.addEventListener("mouseenter", () => { isHovered = true; }); sliderEl.addEventListener("mouseleave", () => { isHovered = false; }); } if (slides.length > 1) { sliderEl.addEventListener("touchstart", e => { touchStartX = e.changedTouches[0].clientX; }, { passive: true }); sliderEl.addEventListener("touchend", e => { if (isAnimating) return; const dist = e.changedTouches[0].clientX - touchStartX; if (Math.abs(dist) < settings.swipeThreshold) return; dist < 0 ? nextSlide() : prevSlide(); resetAutoPlay(); }, { passive: true }); } const core = { goToSlide, nextSlide, prevSlide, startAutoPlay, stopAutoPlay, resetAutoPlay, getCurrentIndex: () => currentIndex, setNavigate: (next, prev) => { navigateNext = next; navigatePrev = prev; }, }; activeSliders.push(core); return core; } // ── Quote slider ─────────────────────────────────────────────────────────── function initQuoteSliders() { document.querySelectorAll("[data-quote-slider]").forEach(sliderEl => { if (sliderEl.dataset.initDone) return; sliderEl.dataset.initDone = "1"; const settings = readSettings(sliderEl); const dotsWrap = sliderEl.querySelector("[data-quote-dots]"); const allSlides = Array.from(sliderEl.querySelectorAll("[data-quote-slide]")); const slides = allSlides.filter(s => !dotsWrap?.contains(s)); if (!slides.length) return; const dots = buildDots(dotsWrap, slides, settings); dots.forEach((dot, i) => gsap.set(dot, { autoAlpha: i === 0 ? settings.dotActiveAlpha : settings.dotInactiveAlpha, width: i === 0 ? settings.dotActiveWidth : dot.dataset.baseWidth, })); const core = createSliderCore(sliderEl, slides, settings, null); function goToWithDots(targetIndex) { const fromIndex = core.getCurrentIndex(); const tl = core.goToSlide(targetIndex); if (tl && dots.length) tl.add(buildDotAnimation(dots, fromIndex, targetIndex, settings), 0); } core.setNavigate(() => goToWithDots(core.getCurrentIndex() + 1), () => goToWithDots(core.getCurrentIndex() - 1)); dots.forEach((dot, i) => { dot.addEventListener("click", () => { goToWithDots(i); core.resetAutoPlay(); }); dot.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goToWithDots(i); core.resetAutoPlay(); } }); }); core.startAutoPlay(); }); } // ── Gallery slider ───────────────────────────────────────────────────────── function initGallerySliders() { document.querySelectorAll("[data-gallery-slider]").forEach(sliderEl => { if (sliderEl.dataset.initDone) return; sliderEl.dataset.initDone = "1"; const settings = readSettings(sliderEl); const slides = Array.from(sliderEl.querySelectorAll("[data-gallery-slide]")); if (!slides.length) return; const prevBtn = sliderEl.querySelector("[data-gallery-prev]"); const nextBtn = sliderEl.querySelector("[data-gallery-next]"); const counterEl = sliderEl.querySelector("[data-gallery-counter]"); const total = slides.length; function updateCounter(i) { if (!counterEl) return; counterEl.textContent = settings.counterFormat === "fraction" ? `${i + 1} / ${total}` : `${i + 1} \u2014 ${total}`; counterEl.setAttribute("aria-label", `Image ${i + 1} of ${total}`); } updateCounter(0); const core = createSliderCore(sliderEl, slides, settings, updateCounter); if (prevBtn) prevBtn.addEventListener("click", () => { core.prevSlide(); core.resetAutoPlay(); }); if (nextBtn) nextBtn.addEventListener("click", () => { core.nextSlide(); core.resetAutoPlay(); }); sliderEl.addEventListener("keydown", e => { if (e.key === "ArrowLeft") { core.prevSlide(); core.resetAutoPlay(); } if (e.key === "ArrowRight") { core.nextSlide(); core.resetAutoPlay(); } }); core.startAutoPlay(); }); } // ── Dot helpers ──────────────────────────────────────────────────────────── function buildDots(dotsWrap, slides, settings) { if (!dotsWrap) return []; if (slides.length <= 1) { dotsWrap.style.display = "none"; return []; } const tmpl = dotsWrap.querySelector(`.${settings.dotClass}`) || dotsWrap.firstElementChild; dotsWrap.style.display = ""; dotsWrap.innerHTML = ""; return slides.map((_, i) => { const dot = tmpl ? tmpl.cloneNode(true) : document.createElement("div"); dot.classList.add(settings.dotClass); dot.setAttribute("role", "button"); dot.setAttribute("tabindex", "0"); dot.setAttribute("aria-label", `Go to slide ${i + 1}`); dotsWrap.appendChild(dot); dot.dataset.baseWidth = window.getComputedStyle(dot).width; return dot; }); } function buildDotAnimation(dots, fromIndex, toIndex, settings) { if (!dots.length) return gsap.timeline(); const to = ((toIndex % dots.length) + dots.length) % dots.length; const outDot = dots[fromIndex], inDot = dots[to]; const tl = gsap.timeline(); if (outDot && outDot !== inDot) tl.to(outDot, { width: outDot.dataset.baseWidth ?? "8px", autoAlpha: settings.dotInactiveAlpha, duration: settings.dotCloseDuration, ease: settings.dotEase }); if (settings.dotPause > 0) tl.to({}, { duration: settings.dotPause }); if (inDot) tl.to(inDot, { width: settings.dotActiveWidth, autoAlpha: settings.dotActiveAlpha, duration: settings.dotOpenDuration, ease: settings.dotEase }); return tl; } // ── Menu ─────────────────────────────────────────────────────────────────── function initMenu() { const openButtons = document.querySelectorAll("[data-menu-open]"); const closeButtons = document.querySelectorAll("[data-menu-close]"); const overlay = document.querySelector("[data-menu-overlay]"); const panel = document.querySelector("[data-menu-panel]"); const site = document.querySelector(".site"); if (!overlay || !panel || !site) { console.warn("[menu] Missing elements"); return; } if (overlay.dataset.initDone) return; overlay.dataset.initDone = "1"; let menuOpen = false; // Backdrop blur is SET statically and never animated. Animating // backdrop-filter is the single most expensive thing this file can do on // Safari — it blurs the entire viewport on every frame of the transition. // Fading opacity from 0 to 1 gives a near-identical visual result for // effectively zero paint cost. Blur reduced 30 → 24px (below the // diminishing-returns knee on every browser). gsap.set(overlay, { autoAlpha: 0, pointerEvents: "none", backdropFilter: "blur(24px) saturate(180%)", willChange: "opacity", }); gsap.set(panel, { autoAlpha: 1, clipPath: "inset(0 0 100% 0)", y: "0vh" }); gsap.set(site, { y: "0vh" }); const menuTl = gsap.timeline({ paused: true }) .to(overlay, { autoAlpha: 1, pointerEvents: "auto", duration: 0.7, ease: "power4.inOut" }, 0) .to(panel, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 1.2, ease: "power4.inOut" }, 0) .to(site, { y: "15vh", duration: 1.2, ease: "power4.inOut" }, 0); function openMenu() { if (!menuOpen) { menuOpen = true; menuTl.timeScale(1).play(); } } function closeMenu() { if (menuOpen) { menuOpen = false; menuTl.timeScale(1.5).reverse(); } } openButtons.forEach(btn => btn.addEventListener("click", openMenu)); closeButtons.forEach(btn => btn.addEventListener("click", closeMenu)); document.addEventListener("keydown", e => { if (e.key === "Escape" && menuOpen) closeMenu(); }); overlay.addEventListener("click", closeMenu); } // ── Image scroller ───────────────────────────────────────────────────────── function initImageScroller() { document.querySelectorAll("[data-img-scroll]").forEach(scroller => { if (scroller.dataset.initDone) return; scroller.dataset.initDone = "1"; const track = scroller.querySelector("[data-img-track]"); const prevBtn = scroller.querySelector("[data-img-prev]"); const nextBtn = scroller.querySelector("[data-img-next]"); if (!track) return; if (window.matchMedia("(pointer: coarse)").matches) { // Touch device — disable the custom drag/snap logic entirely and rely // on native CSS scroll. Force the correct overflow + touch-action so // CSS alone works regardless of any inherited styles. scroller.style.overflowX = "scroll"; scroller.style.overflowY = "hidden"; scroller.style.touchAction = "pan-x"; track.querySelectorAll("img").forEach(img => img.setAttribute("draggable", "false")); return; } const originals = Array.from(track.children); const N = originals.length; if (!N) return; const preFrag = document.createDocumentFragment(); originals.forEach(item => { const c = item.cloneNode(true); c.setAttribute("aria-hidden", "true"); preFrag.appendChild(c); }); track.insertBefore(preFrag, track.firstChild); originals.forEach(item => { const c = item.cloneNode(true); c.setAttribute("aria-hidden", "true"); track.appendChild(c); }); track.querySelectorAll("img").forEach(img => { img.setAttribute("draggable", "false"); img.addEventListener("dragstart", e => e.preventDefault()); }); // Shared state — lives outside init so listeners reference latest values. const allItems = Array.from(track.children); let scrollerW = 0, singleW = 0, allSnaps = [], origSnaps = []; const centerL = item => Math.round(item.offsetLeft - (scrollerW - item.offsetWidth) / 2); const nearest = sl => allSnaps.length ? allSnaps.reduce((b, s) => Math.abs(s - sl) < Math.abs(b - sl) ? s : b, allSnaps[0]) : sl; const nextSnap = sl => allSnaps.filter(s => s > sl + 2).sort((a, b) => a - b)[0] ?? nearest(sl); const prevSnap = sl => allSnaps.filter(s => s < sl - 2).sort((a, b) => b - a)[0] ?? nearest(sl); function loopCheck() { if (!origSnaps.length) return; const sl = scroller.scrollLeft; if (sl < origSnaps[0] - 2) scroller.scrollLeft += singleW; else if (sl > origSnaps[N - 1] + 2) scroller.scrollLeft -= singleW; } // rAF-batched scrollLeft writes. Writing scrollLeft inside a GSAP // onUpdate every tick forces synchronous layout on Firefox / Safari. // Coalescing through rAF keeps the writes aligned with paint, which // noticeably reduces drag / tween jitter on those browsers. let pendingSL = null, rafId = 0; const flushSL = () => { if (pendingSL !== null) { scroller.scrollLeft = pendingSL; pendingSL = null; } rafId = 0; }; const writeSL = v => { pendingSL = v; if (!rafId) rafId = requestAnimationFrame(flushSL); }; const proxy = { v: 0 }; let tween = null; function snapTo(target, dur = 0.65) { if (tween) tween.kill(); proxy.v = scroller.scrollLeft; tween = gsap.to(proxy, { v: target, duration: dur, ease: "expo.out", onUpdate() { writeSL(proxy.v); }, onComplete() { flushSL(); loopCheck(); } }); } function calcSnaps() { scrollerW = scroller.clientWidth; singleW = Math.round(track.scrollWidth / 3); allSnaps = allItems.map(centerL); origSnaps = allSnaps.slice(N, 2 * N); } // Listeners attached immediately — slider is interactive before images // decode. Handlers guard against empty allSnaps (pre-decode state). if (prevBtn) prevBtn.addEventListener("click", () => { if (allSnaps.length) snapTo(prevSnap(scroller.scrollLeft)); }); if (nextBtn) nextBtn.addEventListener("click", () => { if (allSnaps.length) snapTo(nextSnap(scroller.scrollLeft)); }); let active = false, axis = null, startX = 0, startY = 0, startSL = 0; scroller.addEventListener("pointerdown", e => { if (tween) { tween.kill(); tween = null; } active = true; axis = null; startX = e.clientX; startY = e.clientY; startSL = scroller.scrollLeft; scroller.setPointerCapture(e.pointerId); scroller.style.cursor = "grabbing"; }, { passive: true }); scroller.addEventListener("pointermove", e => { if (!active) return; const dx = e.clientX - startX, dy = e.clientY - startY; if (!axis) { if (Math.abs(dx) > Math.abs(dy) + 3) axis = "x"; else if (Math.abs(dy) > Math.abs(dx) + 3) axis = "y"; else return; } if (axis === "y") return; e.preventDefault(); writeSL(startSL - dx); }, { passive: false }); const onEnd = () => { if (!active) return; active = false; scroller.style.cursor = "grab"; flushSL(); if (axis !== "x" || !allSnaps.length) return; const sl = scroller.scrollLeft, absM = Math.abs(sl - startSL); if (absM < 20) snapTo(nearest(startSL), 0.45); else if (absM < scrollerW * 0.22) snapTo(sl > startSL ? nextSnap(startSL) : prevSnap(startSL), 0.6); else snapTo(nearest(sl), 0.7); }; scroller.addEventListener("pointerup", onEnd, { passive: true }); scroller.addEventListener("pointercancel", onEnd, { passive: true }); // ResizeObserver — fires only when the scroller itself changes size. let centredIndex = N, roTimer; new ResizeObserver(() => { if (allSnaps.length) { const idx = allSnaps.indexOf(nearest(scroller.scrollLeft)); if (idx !== -1) centredIndex = idx; } clearTimeout(roTimer); roTimer = setTimeout(() => { calcSnaps(); scroller.scrollLeft = allSnaps[centredIndex] ?? origSnaps[0] ?? 0; loopCheck(); }, 150); }).observe(scroller); // img.decode() — waits for full decode before setting initial scroll // position. Listeners are already live above so the slider responds to // interaction immediately. Snapping kicks in as soon as this resolves // (near-instant for normal-sized images). const allImgs = Array.from(track.querySelectorAll("img")); const decodeImg = img => img.decode ? img.decode().catch(() => {}) : Promise.resolve(); Promise.all(allImgs.map(img => { if (img.complete && img.naturalWidth > 0) return decodeImg(img); return new Promise(resolve => { img.addEventListener("load", () => decodeImg(img).then(resolve), { once: true }); img.addEventListener("error", resolve, { once: true }); }); })).then(() => requestAnimationFrame(() => requestAnimationFrame(() => { calcSnaps(); scroller.scrollLeft = origSnaps[0] ?? 0; loopCheck(); }))); }); } // ── Accordion ────────────────────────────────────────────────────────────── // // Wrapper: [data-accordion] — group scope. By default, only // ONE item inside a group may be // open at a time. Add // data-accordion="independent" // to allow multiple open. // // Required children: // [data-accordion-item] ............... each collapsible block. // [data-accordion-header] ............. click target (should be a //