/* ============================================================ LENIS SETUP ============================================================ */ const lenis = new LocomotiveScroll({ autoStart: false, lenisOptions: { lerp: 0.1, duration: 1.2, orientation: "vertical", gestureOrientation: "vertical", smoothWheel: true, smoothTouch: false, wheelMultiplier: 1, touchMultiplier: 2, easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), }, }); lenis.lenisInstance.on("scroll", ScrollTrigger.update); gsap.ticker.lagSmoothing(0); /* ============================================================ GSAP PLUGIN REGISTRATION ============================================================ */ gsap.registerPlugin(ScrollTrigger, CustomEase); CustomEase.create("loader", "0.65, 0.01, 0.05, 0.99"); // >>> FIX 1: Ignore the "resize" mobile browsers fire when the address bar // slides in/out. That vertical-only resize is what triggers a mid-scroll // ScrollTrigger.refresh() and shoves the hero + ticker around on scroll. ScrollTrigger.config({ ignoreMobileResize: true }); /* ============================================================ HERO TICKER — PRE-BUILD ============================================================ */ const tickerRegistry = new Map(); document.querySelectorAll(".h1-yellow").forEach((el) => { const data = setupTicker(el); if (data) tickerRegistry.set(el, data); }); initLogoRevealLoader(lenis); /* ============================================================ PRELOADER ============================================================ */ function initLogoRevealLoader(lenis) { const wrap = document.querySelector("[data-load-wrap]"); if (!wrap) { // autoStart is off, so pages without a loader must start scroll here. lenis.start(); return; } const bg = wrap.querySelector("[data-load-bg]"); const progressBar = wrap.querySelector("[data-load-progress]"); const logo = wrap.querySelector("[data-load-logo]"); const textElement = wrap.querySelector("[data-load-text]"); const menuFont = document.querySelector(".dropdown_trigger"); const navLogoSvg = document.querySelector(".nav-logo_layout"); const resetTargets = Array.from( wrap.querySelectorAll("[data-load-reset]:not([data-load-text])") ); const counter = { value: 0 }; if (sessionStorage.getItem("loaderPlayed")) { gsap.set(wrap, { display: "block", pointerEvents: "none" }); if (bg) gsap.set(bg, { backgroundColor: "rgba(0,0,0,0)" }); if (progressBar) gsap.set(progressBar, { scaleY: 1, autoAlpha: 0 }); if (logo) gsap.set(logo, { clipPath: "inset(0% 0% 0% 0%)", autoAlpha: 0 }); if (navLogoSvg) gsap.set(navLogoSvg, { autoAlpha: 1 }); if (textElement) gsap.set(textElement, { autoAlpha: 0 }); if (menuFont) gsap.set(menuFont, { autoAlpha: 1 }); if (resetTargets.length) gsap.set(resetTargets, { autoAlpha: 1 }); lenis.start(); initScrollTimeline(); return; } sessionStorage.setItem("loaderPlayed", "true"); // Loader chrome missing on this page — skip the reveal animation but keep // the page working (start scroll + init the scroll timeline as usual). if (!bg || !progressBar || !logo) { if (navLogoSvg) gsap.set(navLogoSvg, { autoAlpha: 1 }); if (menuFont) gsap.set(menuFont, { autoAlpha: 1 }); if (textElement) gsap.set(textElement, { autoAlpha: 0 }); if (resetTargets.length) gsap.set(resetTargets, { autoAlpha: 1 }); lenis.start(); initScrollTimeline(); return; } // Stop the Locomotive render loop AND Lenis's own input handler. Stopping // only the wrapper leaves Lenis banking wheel input, which then replays as // a jump when scroll resumes. lenis.stop(); lenis.lenisInstance.stop(); gsap.set(progressBar, { scaleY: 0, transformOrigin: "bottom center" }); gsap.set(logo, { clipPath: "inset(100% 0% 0% 0%)" }); if (menuFont) { gsap.set(menuFont, { autoAlpha: 0 }); } if (navLogoSvg) { gsap.set(navLogoSvg, { autoAlpha: 0 }); } if (textElement) { gsap.set(textElement, { autoAlpha: 0 }); textElement.textContent = "0%"; } // >>> FIX 4: Pre-place the NAV at its scroll-start position while the loader // still covers the screen, so it doesn't jump when the scroll timeline inits. // (The hero line's resting position at scroll-top is y:0 — its natural spot — // so it must NOT be pre-offset, or it gets stuck at the top of the screen.) (() => { const heroSection = document.querySelector(".hero_section"); const navWrap = document.querySelector("[data-nav-wrap]"); if (!heroSection || !navWrap) return; const dist = (heroSection.getBoundingClientRect().height / 2) * 0.475 - navWrap.getBoundingClientRect().height / 2; gsap.set(navWrap, { y: dist }); })(); const loadTimeline = gsap .timeline({ defaults: { ease: "loader", duration: 5, }, onComplete: () => { lenis.lenisInstance.start(); lenis.start(); initScrollTimeline(); }, }) .set(wrap, { display: "block" }) .to(progressBar, { scaleY: 1 }) .to(logo, { clipPath: "inset(0% 0% 0% 0%)" }, "<") .to( counter, { value: 100, duration: 5, ease: "loader", onUpdate: () => { if (textElement) { textElement.textContent = Math.round(counter.value) + "%"; } }, }, "<" ) .add("hideContent", ">-1") .to(bg, { backgroundColor: "rgba(0,0,0,0)", duration: 2 }, "hideContent") .to(progressBar, { autoAlpha: 0, duration: 1 }, "hideContent") .to(logo, { autoAlpha: 0, duration: 1 }, "hideContent") .set(wrap, { pointerEvents: "none" }); if (textElement) { if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => { gsap.set(textElement, { autoAlpha: 1 }); }); } else { gsap.set(textElement, { autoAlpha: 1 }); } loadTimeline.to(textElement, { autoAlpha: 0, duration: 1 }, "hideContent"); } if (menuFont) { loadTimeline.to(menuFont, { autoAlpha: 1, duration: 1 }, "hideContent"); } if (navLogoSvg) { loadTimeline.to(navLogoSvg, { autoAlpha: 1, duration: 1 }, "hideContent"); } if (resetTargets.length) { loadTimeline.set(resetTargets, { autoAlpha: 1 }, 0); } } /* ============================================================ MASTER SCROLL TIMELINE INIT ============================================================ */ function initScrollTimeline() { tickerRegistry.forEach((data, el) => animateTicker(el, data)); initHeroScroll(); // replaces initNavScroll() + initHeroTextScroll() initIntroAnimation(); initInvestorSection(); initWhereSection(); initFundsSection(); // initCtaScroll(); initNavTheme(); ScrollTrigger.refresh(); window.addEventListener("load", () => ScrollTrigger.refresh()); } /* ============================================================ HERO SECTION — NAV + HERO TEXT ============================================================ */ function initHeroScroll() { const navWrap = document.querySelector("[data-nav-wrap]"); const heroSection = document.querySelector(".hero_section"); const heroText = document.querySelector(".hero_section .u-h1.is-hero"); if (!navWrap || !heroSection || !heroText) return; // >>> FIX 2: Distance now reads 100svh straight from the hero box itself. // Because .hero_section is 200svh, half of it IS one svh — the exact unit // the layout uses — and svh does NOT change when the URL bar slides. So the // JS and CSS can't disagree, and this value stays rock-steady during scroll. // (This replaces the old window.innerHeight version, which changed with the // address bar and caused the placement to drift on mobile.) const getDistance = () => { const viewport = heroSection.getBoundingClientRect().height / 2; const ownHeight = navWrap.getBoundingClientRect().height; return viewport * 0.475 - ownHeight / 2; }; const tl = gsap.timeline({ defaults: { ease: "none" }, scrollTrigger: { trigger: ".hero_section", start: "top top", end: "bottom bottom", scrub: true, invalidateOnRefresh: true, }, }); tl.fromTo( navWrap, { y: () => getDistance() }, { y: 0, immediateRender: true }, 0 ).to(heroText, { y: () => -getDistance() }, 0); } // Phase 1: function setupTicker(el) { const words = (el.getAttribute("data-words") || el.textContent) .split(",") .map((w) => w.trim()) .filter(Boolean); if (words.length < 2) return null; const scrubAmount = el.hasAttribute("data-scrub") ? parseFloat(el.getAttribute("data-scrub")) : true; el.textContent = ""; el.style.display = "inline-block"; el.style.verticalAlign = "top"; el.style.whiteSpace = "nowrap"; el.style.overflowX = "visible"; el.style.overflowY = "hidden"; const track = document.createElement("span"); track.style.display = "block"; track.style.willChange = "transform"; const wordEls = words.map((word) => { const wordEl = document.createElement("span"); wordEl.style.display = "block"; wordEl.style.paddingBottom = "0.18em"; wordEl.textContent = word; track.appendChild(wordEl); return wordEl; }); el.appendChild(track); const measure = document.createElement("span"); measure.style.cssText = "position:absolute; visibility:hidden; white-space:nowrap; top:-9999px; left:-9999px;"; document.body.appendChild(measure); const state = { lineHeight: 0, widths: [] }; function measureAll() { const computed = getComputedStyle(el); measure.style.font = computed.font; measure.style.letterSpacing = computed.letterSpacing; state.widths = words.map((word) => { measure.textContent = word; return measure.getBoundingClientRect().width; }); state.lineHeight = wordEls[0].getBoundingClientRect().height; el.style.height = `${state.lineHeight}px`; el.style.width = `${Math.max(...state.widths)}px`; ScrollTrigger.refresh(); } measureAll(); document.fonts?.ready.then(measureAll); // >>> FIX 3: Only remeasure on a real WIDTH change (rotation / desktop // resize). A height-only "resize" on mobile is just the URL bar sliding — // ignoring it stops the ticker from re-laying-out and calling refresh() // mid-scroll, which was shoving the ticker words + hero line down. let _tickerW = window.innerWidth; window.addEventListener("resize", () => { if (window.innerWidth === _tickerW) return; _tickerW = window.innerWidth; measureAll(); }); const groupId = el.getAttribute("data-ticker-group"); let images = []; if (groupId) { const imageWrap = document.querySelector( `[data-ticker-images="${groupId}"]` ); if (imageWrap) { images = Array.from(imageWrap.querySelectorAll("[data-ticker-image]")); images.forEach((img, i) => { gsap.set(img, { clipPath: i === 0 ? "inset(0% 0% 0% 0%)" : "inset(100% 0% 0% 0%)", }); }); } } return { words, track, wordEls, scrubAmount, state, images }; } // Phase 2: function animateTicker(el, data) { const { track, words, scrubAmount, state, images } = data; const heroWrap = el.closest(".hero_section"); if (!heroWrap) return; const steps = words.length - 1; const tl = gsap.timeline({ scrollTrigger: { trigger: heroWrap, start: "top top", end: "bottom bottom", scrub: scrubAmount, invalidateOnRefresh: true, }, }); tl.to( track, { y: () => -(words.length - 1) * state.lineHeight, duration: steps, ease: "none", }, 0 ); if (images.length === words.length) { images.forEach((img, i) => { if (i === 0) return; tl.fromTo( img, { clipPath: "inset(100% 0% 0% 0%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: 1, ease: "none" }, i - 1 ); tl.to( images[i - 1], { clipPath: "inset(0% 0% 100% 0%)", duration: 1, ease: "none" }, i - 1 ); }); } } /* ============================================================ INTRO SECTION ============================================================ */ function initIntroAnimation() { const section = document.querySelector(".intro_section"); if (!section) return; const images = gsap.utils.toArray("[data-intro-img]"); const lines = gsap.utils.toArray("[data-intro-line]"); if (!images.length) return; const total = images.length; gsap.set(images, { opacity: 0 }); gsap.set(images[0], { opacity: 1 }); gsap.set(lines, { color: "rgba(234, 235, 228, 0.2)" }); // if (lines.length) gsap.set(lines[0], { color: "#FFFFD2" }); const SWITCH_START = "top 30%"; const SWITCH_END = "bottom 80%"; function setActive(i) { lines.forEach((line, idx) => { gsap.to(line, { color: idx <= i ? "#FFFFD2" : "rgba(234, 235, 228, 0.2)", duration: 1, ease: "loader", overwrite: true, }); }); const activeImg = Math.max(i, 0); images.forEach((img, idx) => { gsap.to(img, { opacity: idx === activeImg ? 1 : 0, duration: 1, ease: "loader", overwrite: true, }); }); } let currentIndex = -1; ScrollTrigger.create({ trigger: section, start: SWITCH_START, end: SWITCH_END, scrub: true, invalidateOnRefresh: true, onUpdate: (self) => { const index = self.progress <= 0 ? -1 : Math.min(total - 1, Math.floor(self.progress * total)); if (index !== currentIndex) { currentIndex = index; setActive(currentIndex); } }, }); } /* ============================================================ INVESTOR SECTION ============================================================ */ function initInvestorSection() { if (!document.querySelector(".investor_section")) return; const mm = gsap.matchMedia(); mm.add( { isMobile: "(max-width: 430px)", isDesktop: "(min-width: 431px)", }, (context) => { const { isMobile } = context.conditions; const $items = $( isMobile ? ".investor_section .investor-mobi-line" : ".investor_section .investor-item_wrap" ); if (!$items.length) return; gsap.to($items, { width: "100%", duration: 1.6, ease: "expo.out", stagger: 0.5, scrollTrigger: { trigger: ".investor_section", start: "top 40%", once: true, }, }); } ); } /* ============================================================ WHERE SECTION ============================================================ */ function initWhereSection() { document.querySelectorAll(".where_section").forEach((component) => { if (component.dataset.scriptInitialized) return; component.dataset.scriptInitialized = "true"; const wrap = component.querySelector(".where-content_wrap"); const track = component.querySelector(".where-content_track"); if (!wrap || !track) return; const mmWhere = gsap.matchMedia(); mmWhere.add("(prefers-reduced-motion: no-preference)", () => { const getDistance = () => track.scrollWidth - wrap.offsetWidth; const tween = gsap.to(track, { x: () => -getDistance(), ease: "none", }); const trigger = ScrollTrigger.create({ trigger: component, start: "bottom bottom", end: () => `+=${getDistance()}`, pin: true, animation: tween, scrub: 1, invalidateOnRefresh: true, }); // ---- Resize fix ---- let resizeTimer; const onResize = () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { ScrollTrigger.refresh(); }, 200); }; window.addEventListener("resize", onResize); // Clean up when matchMedia reverts (e.g. reduced-motion toggles on) return () => { window.removeEventListener("resize", onResize); clearTimeout(resizeTimer); trigger.kill(); }; }); }); } function initHorizontalParallax(component, containerAnimation) { component.querySelectorAll("[img-scroll-x]").forEach((wrapper) => { const img = wrapper.querySelector("img"); if (!img) return; gsap.fromTo( img, { xPercent: -8 }, { xPercent: 8, ease: "none", scrollTrigger: { trigger: wrapper, containerAnimation, start: "left right", end: "right left", scrub: true, }, } ); }); } /* ============================================================ FUNDS SECTION ============================================================ */ function initFundsSection() { const section = document.querySelector(".funds_section"); if (!section || section.dataset.scriptInitialized) return; section.dataset.scriptInitialized = "true"; const pinTarget = section.querySelector(".funds-content_wrap"); const items = Array.from(section.querySelectorAll(".funds-img_wrap")); const nameEl = section.querySelector(".funds-heading"); const industryEl = section.querySelector(".funds-industry"); const dateEl = section.querySelector(".funds-date"); const listTextEls = Array.from( section.querySelectorAll(".funds-list-item_text") ); if (!pinTarget || items.length === 0) return; items.forEach((item, i) => { item.style.zIndex = items.length - i; item.style.clipPath = "inset(0% 0% 0% 0%)"; }); let currentIndex = -1; function setActiveText(index) { if (index === currentIndex) return; currentIndex = index; const item = items[index]; if (!item) return; nameEl.textContent = item.dataset.investmentName || ""; industryEl.textContent = item.dataset.investmentIndustry || ""; dateEl.textContent = item.dataset.investmentDate || ""; const activeName = item.dataset.investmentName || ""; listTextEls.forEach((textEl) => { const isMatch = textEl.textContent.trim() === activeName.trim(); textEl.classList.toggle("is-active", isMatch); }); } setActiveText(0); const scrollDistancePerItem = window.innerHeight; const totalScrollDistance = scrollDistancePerItem * (items.length - 1); ScrollTrigger.create({ trigger: pinTarget, start: "bottom bottom", end: () => "+=" + totalScrollDistance, pin: true, scrub: true, invalidateOnRefresh: true, onUpdate: (self) => { const progress = self.progress; const rawIndex = progress * (items.length - 1); const activeIndex = Math.min(items.length - 1, Math.floor(rawIndex)); setActiveText(activeIndex); const localProgress = rawIndex - activeIndex; items.forEach((item, i) => { if (i < activeIndex) { item.style.clipPath = "inset(0% 0% 100% 0%)"; } else if (i === activeIndex) { const clipAmount = i === items.length - 1 ? 0 : localProgress * 100; item.style.clipPath = `inset(0% 0% ${clipAmount}% 0%)`; } else { item.style.clipPath = "inset(0% 0% 0% 0%)"; } }); }, }); } /* ============================================================ CTA SECTION ============================================================ */ function initCtaScroll() { const section = document.querySelector(".cta_section"); if (!section || section.dataset.ctaScriptInitialized) return; section.dataset.ctaScriptInitialized = "true"; if (!section.querySelector(".cta-info_wrap")) return; const mmCta = gsap.matchMedia(); mmCta.add( { isDesktop: "(min-width: 768px)", isMobile: "(max-width: 767px)", }, (context) => { const { isDesktop } = context.conditions; const moveDistance = isDesktop ? "-40vh" : "-10vh"; gsap .timeline({ scrollTrigger: { trigger: section, start: "top top", end: "+=100%", pin: true, scrub: 1, invalidateOnRefresh: true, }, }) .to(".cta-info_wrap", { y: moveDistance, ease: "none", }); } ); } /* ============================================================ MENU OPEN/CLOSE ============================================================ */ function initDropdownToggle() { const trigger = document.querySelector(".dropdown_trigger"); const wrap = trigger?.nextElementSibling; if (!trigger || !wrap?.classList.contains("dropdown_wrap")) return; trigger.addEventListener("click", (e) => { e.preventDefault(); wrap.classList.toggle("is-open"); }); document.addEventListener("click", (e) => { if (!trigger.contains(e.target) && !wrap.contains(e.target)) { wrap.classList.remove("is-open"); } }); wrap .querySelectorAll("a") .forEach((link) => link.addEventListener("click", () => wrap.classList.remove("is-open")) ); } initDropdownToggle(); /* ============================================================ NON-SCROLLTRIGGER / STANDALONE BEHAVIOUR ============================================================ */ // Image Parallax $("[img-scroll]").each(function () { const parallaxImg = $(this).find("img"); if (!parallaxImg.length) return; gsap.fromTo( parallaxImg, { yPercent: -10, scale: 1.2 }, { yPercent: 10, scale: 1.2, ease: "linear", scrollTrigger: { trigger: this, start: "top bottom", end: "bottom top", scrub: true, }, } ); }); // Services / Image Section Hover (function () { let isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0; let leftItem = $(".services-item_left"); let centerItem = $(".services-item_center"); let rightItem = $(".services-item_right"); if (!leftItem.length && !centerItem.length && !rightItem.length) return; let leftImg = leftItem.find(".services-img"); let centerImg = centerItem.find(".services-img"); let rightImg = rightItem.find(".services-img"); if (isTouchDevice) { [leftImg, centerImg, rightImg].forEach((img) => { img.css("clip-path", "none"); }); return; } let currentState = "center"; leftImg.css("clip-path", "inset(0 0 0 100%)"); centerImg.css("clip-path", "inset(0 0 0 0)"); rightImg.css("clip-path", "inset(0 100% 0 0)"); [leftImg, centerImg, rightImg].forEach((img) => { img.css("transition", "clip-path 1.2s cubic-bezier(0.16, 1, 0.3, 1)"); }); leftItem.on("mouseenter", function () { leftImg.css("clip-path", "inset(0 0 0 0)"); centerImg.css("clip-path", "inset(0 100% 0 0)"); rightImg.css("clip-path", "inset(0 100% 0 0)"); currentState = "left"; }); centerItem.on("mouseenter", function () { leftImg.css("clip-path", "inset(0 0 0 100%)"); rightImg.css("clip-path", "inset(0 100% 0 0)"); centerImg.css("clip-path", "inset(0 0 0 0)"); currentState = "center"; }); rightItem.on("mouseenter", function () { leftImg.css("clip-path", "inset(0 0 0 100%)"); centerImg.css("clip-path", "inset(0 0 0 100%)"); rightImg.css("clip-path", "inset(0 0 0 0)"); currentState = "right"; }); let container = $(".services_layout"); if (container.length) { container.on("mouseleave", function () { leftImg.css("clip-path", "inset(0 0 0 100%)"); centerImg.css("clip-path", "inset(0 0 0 0)"); rightImg.css("clip-path", "inset(0 100% 0 0)"); currentState = "center"; }); } })(); // Footer Logo Hover Effect if (window.matchMedia("(pointer: fine)").matches) { const svg = document.querySelector(".footer_svg"); if (svg) { const letters = Array.from(svg.querySelectorAll("path")); function updateHighlight(clientX) { let closestIndex = -1; let closestDist = Infinity; letters.forEach((el, i) => { const rect = el.getBoundingClientRect(); const center = rect.left + rect.width / 2; const dist = Math.abs(clientX - center); if (dist < closestDist) { closestDist = dist; closestIndex = i; } }); letters.forEach((el, i) => { el.classList.remove("is-active", "is-adjacent"); if (i === closestIndex) el.classList.add("is-active"); else if (Math.abs(i - closestIndex) === 1) el.classList.add("is-adjacent"); }); } svg.addEventListener("mousemove", (e) => updateHighlight(e.clientX)); svg.addEventListener("mouseleave", () => { letters.forEach((el) => el.classList.remove("is-active", "is-adjacent")); }); } } // Contact Infinite Scroll document.addEventListener("DOMContentLoaded", () => { const scroller = document.querySelector(".contact-right"); if (!scroller) return; const copies = scroller.querySelectorAll(".contact-locations_wrap"); if (copies.length < 3) return; copies.forEach((copy, i) => { if (i !== 0) copy.setAttribute("aria-hidden", "true"); }); let copyHeight = 0; let ticking = false; function measure() { copyHeight = copies[0].getBoundingClientRect().height; scroller.scrollTop = copyHeight; } function onScroll() { if (ticking) return; ticking = true; requestAnimationFrame(() => { const maxScroll = scroller.scrollHeight - scroller.clientHeight; if (scroller.scrollTop <= 0) { scroller.scrollTop += copyHeight; } else if (scroller.scrollTop >= maxScroll) { scroller.scrollTop -= copyHeight; } ticking = false; }); } measure(); scroller.addEventListener("scroll", onScroll, { passive: true }); let resizeTimer; window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(measure, 150); }); // ---- Drive the scroller from wheel / touch anywhere on the page ---- const SPEED = 1; // bump to taste function nudge(delta) { if (!delta) return; scroller.scrollTop += delta * SPEED; } window.addEventListener( "wheel", (e) => { // Over the scroller its own native scroll already handles it — // skip so we don't apply the delta twice. if (scroller.contains(e.target)) return; // deltaMode 1 = lines (Firefox), 2 = pages; normalise to px. const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1; nudge(e.deltaY * unit); }, { passive: true } ); let lastTouchY = null; window.addEventListener( "touchstart", (e) => { lastTouchY = e.touches[0].clientY; }, { passive: true } ); window.addEventListener( "touchmove", (e) => { if (scroller.contains(e.target) || lastTouchY === null) return; const y = e.touches[0].clientY; nudge(lastTouchY - y); lastTouchY = y; }, { passive: true } ); // ---- Hover: brief wheel cooldown so entering doesn't jump the list ---- let wheelCooldownUntil = 0; const WHEEL_COOLDOWN_MS = 250; scroller.addEventListener("mouseenter", () => { wheelCooldownUntil = performance.now() + WHEEL_COOLDOWN_MS; }); scroller.addEventListener( "wheel", (e) => { if (performance.now() < wheelCooldownUntil) { e.preventDefault(); } }, { passive: false } ); }); /* ================================================================== Cursor Hover Effects ------------------------------------------------------------------ Markup: data-cursor="nav" → square locks to the left of the element data-cursor="square" → square follows the pointer data-cursor-text="Copy Email" → labelled square follows the pointer data-cursor-text-color="#37322F" → overrides the label's text color for this element only (defaults to CONFIG.textColor if omitted) data-cursor-text-active="Copied!" → label swaps to this on click data-cursor-text-active-color="#37322F" → text color for the active label (defaults to data-cursor-text-color, then CONFIG.textColor) data-cursor-text-failed="Copy failed" → label shown if the copy doesn't land data-cursor-text-duration="1400" → ms to hold the swapped label (0 = hold until the pointer leaves) data-copy="hi@studio.com" → value written to the clipboard. Leave the value empty to copy the element's own text, or the address from a mailto: / tel: href. ================================================================== */ (function () { "use strict"; var CONFIG = { color: "#F2F274", textColor: "#EAEBE4", size: 12, lag: 0.12, offsetX: 18, offsetY: 10, gap: 14, navGap: 16, duration: 220, ease: "cubic-bezier(.16,1,.3,1)", activeDuration: 1400, }; if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); /* ---------- clipboard ---------- */ function valueToCopy(el) { var val = el.getAttribute("data-copy"); if (val) return val; var href = el.getAttribute("href") || ""; if (/^(mailto:|tel:)/i.test(href)) { return href.replace(/^(mailto:|tel:)/i, "").split("?")[0]; } return (el.textContent || "").trim(); } function writeClipboard(str) { if (navigator.clipboard && window.isSecureContext) { return navigator.clipboard.writeText(str); } /* fallback for non-secure contexts (http, some staging setups) */ return new Promise(function (resolve, reject) { var ta = document.createElement("textarea"); ta.value = str; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, str.length); var ok = false; try { ok = document.execCommand("copy"); } catch (err) { ok = false; } document.body.removeChild(ta); ok ? resolve() : reject(new Error("execCommand copy failed")); }); } function init() { if (document.getElementById("dcursor")) return; var wrap = document.createElement("div"); wrap.id = "dcursor"; wrap.setAttribute("aria-hidden", "true"); var shape = document.createElement("div"); shape.id = "dcursor-shape"; var text = document.createElement("span"); text.id = "dcursor-text"; wrap.appendChild(shape); wrap.appendChild(text); document.body.appendChild(wrap); wrap.style.setProperty("--dc-color", CONFIG.color); wrap.style.setProperty("--dc-text-color", CONFIG.textColor); wrap.style.setProperty("--dc-size", CONFIG.size + "px"); wrap.style.setProperty("--dc-gap", CONFIG.gap + "px"); wrap.style.setProperty("--dc-dur", CONFIG.duration + "ms"); wrap.style.setProperty("--dc-ease", CONFIG.ease); function rest() { wrap.classList.remove("is-square", "is-text"); // Don't reset --dc-text-color here: label() always sets it explicitly // on the way in, and resetting it here fights any fade-out transition, // causing a visible flash back to the default color. } function square() { wrap.classList.add("is-square"); wrap.classList.remove("is-text"); } function label(str, color) { if (text.textContent !== str) text.textContent = str; wrap.style.setProperty("--dc-text-color", color || CONFIG.textColor); wrap.classList.add("is-square", "is-text"); } var mx = window.innerWidth / 2, my = window.innerHeight / 2; var cx = mx, cy = my; var lockedTo = null; /* which element the pointer is over, and which one is showing its swapped label after a click */ var hoverEl = null; var activeEl = null; var activeTimer = null; function clearActive() { if (activeTimer) { clearTimeout(activeTimer); activeTimer = null; } activeEl = null; } /* the state hovering this element implies */ function applyHoverState(el) { var mode = el.getAttribute("data-cursor"); var str = el.getAttribute("data-cursor-text"); if (mode === "nav") { lockedTo = el; square(); } else if (str) { lockedTo = null; label(str, el.getAttribute("data-cursor-text-color")); } else if (mode === "square") { lockedTo = null; square(); } else { lockedTo = null; rest(); } } function showActiveLabel(el, str, color) { if (hoverEl !== el) return; /* pointer already moved on */ var hold = parseInt(el.getAttribute("data-cursor-text-duration"), 10); if (isNaN(hold)) hold = CONFIG.activeDuration; clearActive(); activeEl = el; lockedTo = null; label(str, color); if (hold > 0) { activeTimer = setTimeout(function () { activeTimer = null; var was = activeEl; activeEl = null; if (was && was === hoverEl) applyHoverState(was); }, hold); } } document.addEventListener( "mousemove", function (e) { mx = e.clientX; my = e.clientY; }, { passive: true } ); (function tick() { if (lockedTo && (!lockedTo.isConnected || !lockedTo.offsetParent)) { lockedTo = null; rest(); } var tx, ty; if (lockedTo) { var r = lockedTo.getBoundingClientRect(); tx = r.left - CONFIG.navGap; ty = r.top + r.height / 2; } else { tx = mx + CONFIG.offsetX; ty = my + CONFIG.offsetY; } var lag = reduceMotion.matches ? 1 : CONFIG.lag; cx += (tx - cx) * lag; cy += (ty - cy) * lag; wrap.style.transform = "translate3d(" + cx.toFixed(2) + "px," + cy.toFixed(2) + "px,0)"; requestAnimationFrame(tick); })(); var SELECTOR = "[data-cursor],[data-cursor-text],[data-cursor-text-active],[data-copy]"; document.addEventListener("mouseover", function (e) { var target = e.target; if (!target || target.nodeType !== 1 || !target.closest) return; var el = target.closest(SELECTOR); if (!el) return; hoverEl = el; /* moving between children of the clicked element shouldn't wipe out "Copied!" */ if (el === activeEl) return; clearActive(); applyHoverState(el); }); document.addEventListener("mouseout", function (e) { var target = e.target; if (!target || target.nodeType !== 1 || !target.closest) return; var el = target.closest(SELECTOR); if (!el) return; if (e.relatedTarget && el.contains(e.relatedTarget)) return; if (hoverEl === el) hoverEl = null; clearActive(); lockedTo = null; rest(); }); document.addEventListener("click", function (e) { var target = e.target; if (!target || target.nodeType !== 1 || !target.closest) return; var el = target.closest("[data-copy],[data-cursor-text-active]"); if (!el) return; var done = el.getAttribute("data-cursor-text-active") || "Copied!"; var doneColor = el.getAttribute("data-cursor-text-active-color") || el.getAttribute("data-cursor-text-color"); /* no data-copy → just the label swap, link behaviour untouched */ if (!el.hasAttribute("data-copy")) { if (el.getAttribute("data-cursor-text-active")) showActiveLabel(el, done, doneColor); return; } e.preventDefault(); var val = valueToCopy(el); if (!val) return; writeClipboard(val).then( function () { showActiveLabel(el, done, doneColor); }, function () { showActiveLabel( el, el.getAttribute("data-cursor-text-failed") || "Copy failed", doneColor ); } ); }); document.documentElement.addEventListener("mouseleave", function () { hoverEl = null; clearActive(); lockedTo = null; rest(); }); window.addEventListener("blur", function () { hoverEl = null; clearActive(); lockedTo = null; rest(); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })(); /* ================================================================== Team List Click & Expand — desktop + mobile ================================================================== */ document.addEventListener("DOMContentLoaded", () => { const section = document.querySelector(".team_wrap"); if (!section || section.dataset.teamInitialized) return; section.dataset.teamInitialized = "true"; const list = section.querySelector(".team_list"); if (!list) return; const FADE_MS = 300; const mobileQuery = window.matchMedia("(max-width: 991px)"); const isMobile = () => mobileQuery.matches; const buildPanel = (wrap) => { if (!wrap) return null; const nameEl = wrap.querySelector(".team-item_name"); const roleEl = wrap.querySelector(".team-item_role"); const bioEl = wrap.querySelector(".team-item_bio"); if (!nameEl || !roleEl || !bioEl) return null; return { wrap, nameEl, roleEl, bioEl, surnameEl: wrap.querySelector(".team-item_surname"), educationEl: wrap.querySelector(".team-item_education"), linkedinEl: wrap.querySelector(".team-item_linkedin"), defaults: { name: nameEl.textContent, role: roleEl.textContent, bioHtml: bioEl.innerHTML, }, fadeTimer: null, }; }; const desktopPanel = buildPanel( section.querySelector(".team-col_wrap.is-desktop") || section.querySelector(".team-col_wrap:not(.is-mobile)") ); const mobilePanel = buildPanel( section.querySelector(".team-col_wrap.is-mobile") ); if (!desktopPanel && !mobilePanel) return; /* ---------- mobile scroll lock ---------- Lenis has smoothTouch:false, so lenis.stop() does NOT block native touch scrolling on phones. The lock therefore does two things: 1. stops Lenis (wrapper + instance) for wheel / trackpad 2. blocks touchmove everywhere EXCEPT inside the open panel, so a long bio can still scroll within itself. Deliberately NO overflow:hidden on html/body — the panel is position:sticky, and an overflow ancestor breaks sticky, leaving the panel stranded off-screen. */ let scrollLocked = false; let savedScrollY = 0; const hasLenis = () => typeof lenis !== "undefined" && lenis && lenis.lenisInstance; // True only if the touch target sits inside a genuinely scrollable box // within the panel. Otherwise the touch would fall through and scroll // the page, which drifts the position and causes the jump on close. const touchCanScrollPanel = (target) => { if (!mobilePanel || !mobilePanel.wrap.contains(target)) return false; let el = target; while (el) { const oy = getComputedStyle(el).overflowY; if ( (oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight ) { return true; } if (el === mobilePanel.wrap) break; el = el.parentElement; } return false; }; const preventTouch = (e) => { if (touchCanScrollPanel(e.target)) return; e.preventDefault(); }; const lockScroll = () => { if (scrollLocked) return; scrollLocked = true; // Capture BEFORE stopping Lenis — this is the position we return to. savedScrollY = window.scrollY; if (hasLenis()) { if (lenis.stop) lenis.stop(); if (lenis.lenisInstance.stop) lenis.lenisInstance.stop(); } document.addEventListener("touchmove", preventTouch, { passive: false }); }; const restoreScroll = () => { window.scrollTo(0, savedScrollY); if (hasLenis()) { lenis.lenisInstance.scrollTo(savedScrollY, { immediate: true, force: true, }); } }; const unlockScroll = () => { if (!scrollLocked) return; scrollLocked = false; document.removeEventListener("touchmove", preventTouch); if (hasLenis()) { if (lenis.lenisInstance.start) lenis.lenisInstance.start(); if (lenis.start) lenis.start(); } // Restore now, and again next frame so it lands AFTER Locomotive's // deferred start() work, which would otherwise override it. restoreScroll(); requestAnimationFrame(restoreScroll); }; const swapWithFade = (panel, applyChanges) => { clearTimeout(panel.fadeTimer); panel.wrap.classList.add("is-fading"); panel.fadeTimer = setTimeout(() => { applyChanges(); panel.wrap.classList.remove("is-fading"); }, FADE_MS); }; const applyData = (panel, item) => { const fullName = (item.dataset.name || "").trim(); const surname = (item.dataset.surname || "").trim(); let firstName = fullName; if (surname && fullName.toLowerCase().endsWith(surname.toLowerCase())) { firstName = fullName.slice(0, fullName.length - surname.length).trim(); } panel.wrap.classList.add("is-active"); panel.nameEl.textContent = firstName; if (panel.surnameEl) panel.surnameEl.textContent = surname; panel.roleEl.textContent = item.dataset.role || ""; panel.bioEl.textContent = item.dataset.bio || ""; if (panel.educationEl) panel.educationEl.textContent = item.dataset.education || ""; if (panel.linkedinEl) { const url = item.dataset.linkedin || ""; panel.linkedinEl.setAttribute("href", url); panel.linkedinEl.style.display = url ? "" : "none"; } }; const resetPanel = (panel) => { panel.wrap.classList.remove("is-active"); panel.nameEl.textContent = panel.defaults.name; if (panel.surnameEl) panel.surnameEl.textContent = ""; panel.roleEl.textContent = panel.defaults.role; panel.bioEl.innerHTML = panel.defaults.bioHtml; if (panel.educationEl) panel.educationEl.textContent = ""; if (panel.linkedinEl) panel.linkedinEl.style.display = ""; }; const clearActiveItems = () => { list.querySelectorAll(".team_list-item.is-active").forEach((el) => { el.classList.remove("is-active"); }); }; const setActiveItem = (item) => { const alreadyActive = item.classList.contains("is-active"); section.classList.add("is-active"); clearActiveItems(); item.classList.add("is-active"); // Desktop: fade swap (skip if re-clicking the already-shown member) if (desktopPanel && !alreadyActive) { swapWithFade(desktopPanel, () => applyData(desktopPanel, item)); } if (mobilePanel) { applyData(mobilePanel, item); if (isMobile()) { section.classList.add("is-mobile-open"); lockScroll(); } } }; const closeMobilePanel = () => { section.classList.remove("is-mobile-open", "is-active"); clearActiveItems(); // unlockScroll restores the position captured at open. unlockScroll(); }; const resetDesktop = () => { section.classList.remove("is-active"); clearActiveItems(); if (desktopPanel) swapWithFade(desktopPanel, () => resetPanel(desktopPanel)); if (mobilePanel) resetPanel(mobilePanel); }; document.addEventListener("click", (e) => { const closeBtn = e.target.closest(".team-close_wrap"); if (closeBtn && section.contains(closeBtn)) { if (isMobile()) { closeMobilePanel(); } else if (section.classList.contains("is-active")) { resetDesktop(); } return; } const item = e.target.closest(".team_list-item"); if (item && list.contains(item)) { setActiveItem(item); return; } if (e.target.closest(".team-col_wrap")) return; if (!isMobile() && section.classList.contains("is-active")) resetDesktop(); }); list.addEventListener("keydown", (e) => { if (e.key !== "Enter" && e.key !== " ") return; const item = e.target.closest(".team_list-item"); if (!item) return; e.preventDefault(); setActiveItem(item); }); mobileQuery.addEventListener("change", (mq) => { // Rotating / resizing out of mobile while the panel is open: close it // and release the lock so the desktop layout isn't left frozen. if (!mq.matches) { section.classList.remove("is-mobile-open"); unlockScroll(); } }); }); /* ================================================================== Locomotive Scroll Effects ================================================================== */ $("[parallax-wrap]").each(function () { let parallaxWrap = $(this); let parallaxEl = parallaxWrap.find("[parallax-down]"); if (!parallaxEl.length) return; onDesktop(() => { let clipOutTl = gsap.timeline({ scrollTrigger: { trigger: parallaxWrap, start: "top center", end: "bottom center", scrub: true, invalidateOnRefresh: true, }, }); clipOutTl.fromTo( parallaxEl, { marginTop: 0 }, { marginTop: "auto", ease: "linear" } ); }); }); // VALUES: UNCLIP + ITEM STACKING $(function () { const $section = $(".val_wrap"); const $imgWrap = $section.find(".val-img_wrap"); const $images = $imgWrap.find(".val-img-fill"); const $items = $section.find(".val-item_wrap"); if (!$section.length || !$imgWrap.length || !$images.length) return; const CLIPPED = "inset(100% 0% 0% 0%)"; const UNCLIPPED = "inset(0% 0% 0% 0%)"; const STACK_TOP = window.innerWidth <= 991 ? 90 : 75; const ROW_GAP = window.innerWidth <= 991 ? 60 : 100; // ---- image wrap position ---- const bottomOffset = () => parseFloat(getComputedStyle($imgWrap[0]).bottom) || 0; const wrapBottom = () => window.innerHeight - bottomOffset(); const wrapTop = () => wrapBottom() - $imgWrap.outerHeight(); const naturalDocTop = (el) => { let y = 0; while (el) { y += el.offsetTop; el = el.offsetParent; } return y; }; // ---- item stacking ---- const layoutStack = () => { $items.each(function (index) { $(this).css({ top: STACK_TOP + index * ROW_GAP + "px", zIndex: index + 1, }); }); $imgWrap.css("zIndex", $items.length + 10); }; layoutStack(); ScrollTrigger.addEventListener("refreshInit", layoutStack); // ---- unclip ---- gsap.set($images, { clipPath: CLIPPED, willChange: "clip-path" }); gsap.set($images.eq(0), { clipPath: UNCLIPPED }); $items.each(function (index) { if (index === 0) return; const $img = $images.eq(index); if (!$img.length) return; const itemEl = this; gsap.to($img[0], { clipPath: UNCLIPPED, ease: "none", scrollTrigger: { start: () => naturalDocTop(itemEl) - wrapBottom(), end: () => naturalDocTop(itemEl) - wrapTop(), scrub: true, invalidateOnRefresh: true, }, }); }); }); /* ================================================================== Investments list — accordion toggle ================================================================== */ const initInvestmentsAccordion = () => { const items = document.querySelectorAll(".investments_item"); if (!items.length) return; const closeOthers = true; let refreshQueued = false; const refreshPageHeight = () => { if (refreshQueued) return; refreshQueued = true; requestAnimationFrame(() => { refreshQueued = false; if (typeof lenis !== "undefined" && lenis?.lenisInstance?.resize) { lenis.lenisInstance.resize(); } if (window.ScrollTrigger) { window.ScrollTrigger.refresh(); } }); }; const setState = (item, open) => { item.classList.toggle("is-active", open); item.setAttribute("aria-expanded", String(open)); }; items.forEach((item) => { if (item.dataset.accordionInitialized) return; item.dataset.accordionInitialized = "true"; item.setAttribute("role", "button"); item.setAttribute("tabindex", "0"); item.setAttribute("aria-expanded", "false"); item.querySelectorAll(".inv-dropdown").forEach((dropdown) => { dropdown.addEventListener("transitionend", (event) => { if (event.propertyName === "grid-template-rows") refreshPageHeight(); }); }); const toggle = () => { const willOpen = !item.classList.contains("is-active"); if (willOpen && closeOthers) { items.forEach((other) => { if (other !== item) setState(other, false); }); } setState(item, willOpen); }; item.addEventListener("click", (event) => { if (event.target.closest("a")) return; toggle(); }); item.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; if (event.target.closest("a")) return; event.preventDefault(); toggle(); }); }); }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initInvestmentsAccordion); } else { initInvestmentsAccordion(); } /* ================================================================== Nav Light & Dark Modes ================================================================== */ function initNavTheme() { const navs = document.querySelectorAll(".nav_wrap, .alt-nav_wrap"); const sections = document.querySelectorAll("[data-nav]"); if (!navs.length || !sections.length) return; const SWITCH_LINE = 5; const setState = (state) => { navs.forEach((nav) => { nav.dataset.navState = state; }); }; sections.forEach((section) => { const state = section.dataset.nav; if (!state) return; ScrollTrigger.create({ trigger: section, start: `top ${SWITCH_LINE}%`, end: `bottom ${SWITCH_LINE}%`, invalidateOnRefresh: true, onEnter: () => setState(state), onEnterBack: () => setState(state), }); }); const applyCurrent = () => { const line = window.innerHeight * (SWITCH_LINE / 100); let current = null; sections.forEach((section) => { if (section.getBoundingClientRect().top <= line) current = section; }); if (current && current.dataset.nav) setState(current.dataset.nav); }; applyCurrent(); ScrollTrigger.addEventListener("refresh", applyCurrent); window.refreshNavTheme = () => ScrollTrigger.refresh(); } /* ================================================================== Background Color Switch ================================================================== */ const wrap = document.querySelector(".bg_transition"); if (wrap) { const from = [164, 157, 151]; // #A49D97 const to = [234, 235, 228]; // #EAEBE4 let ticking = false; const update = () => { const { top, height } = wrap.getBoundingClientRect(); const progress = Math.min(Math.max(-top / height, 0), 1); const rgb = from.map((c, i) => Math.round(c + (to[i] - c) * progress)); wrap.style.backgroundColor = `rgb(${rgb.join(",")})`; ticking = false; }; const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(update); }; window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll); update(); } /* ================================================================== Background Color Switch 2 ================================================================== */ const wrap2 = document.querySelector(".bg_transition_2"); if (wrap2) { const from = [110, 104, 99]; // #6E6863 const to = [55, 50, 47]; // #37322F let ticking = false; const update = () => { const { top, height } = wrap2.getBoundingClientRect(); const progress = Math.min(Math.max(-top / height, 0), 1); const rgb = from.map((c, i) => Math.round(c + (to[i] - c) * progress)); wrap2.style.backgroundColor = `rgb(${rgb.join(",")})`; ticking = false; }; const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(update); }; window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll); update(); } /* ================================================================== Business Items Tap & Hover ================================================================== */ document.addEventListener("DOMContentLoaded", () => { const items = document.querySelectorAll(".bus-item_wrap"); if (!items.length) return; items.forEach((item) => { item.addEventListener("click", (e) => { const wasActive = item.classList.contains("is-active"); items.forEach((i) => i.classList.remove("is-active")); if (!wasActive) item.classList.add("is-active"); }); }); document.addEventListener("click", (e) => { if (![...items].some((i) => i.contains(e.target))) { items.forEach((i) => i.classList.remove("is-active")); } }); }); window.addEventListener( "touchstart", function onFirstTouch() { document.documentElement.classList.add("is-touch"); window.removeEventListener("touchstart", onFirstTouch); }, { passive: true } ); /* ================================================================== Announcement Card ================================================================== */ document.addEventListener("DOMContentLoaded", function () { var close = document.querySelector(".ann-close_wrap"); var wrap = document.querySelector(".ann_wrap"); if (close && wrap) { close.addEventListener("click", function () { wrap.style.display = "none"; }); } }); /* ================================================================== Cookie Banner Move ================================================================== */ // window.addEventListener('load', () => { // const els = document.querySelectorAll('.fs-cc-banner_component, .fs-cc-prefs_form'); // if (!els.length) return; // let ticking = false; // function update() { // const threshold = window.innerHeight * 1.75; // const scrolled = window.scrollY >= threshold; // els.forEach(el => el.classList.toggle('cc-scrolled', scrolled)); // ticking = false; // } // window.addEventListener('scroll', () => { // if (!ticking) { // requestAnimationFrame(update); // ticking = true; // } // }, { passive: true }); // update(); // }); /* ============================================================ NAV LOGO SPIN ============================================================ */ initNavSymbolSpin(); function initNavSymbolSpin() { const symbols = gsap.utils.toArray(".nav_symbol"); if (!symbols.length) return; gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => { const remPx = () => parseFloat(getComputedStyle(document.documentElement).fontSize); const tweens = symbols.map((symbol) => gsap.to(symbol, { rotate: 2160, ease: "none", scrollTrigger: { start: 0, end: () => 1200 * remPx(), scrub: true, invalidateOnRefresh: true, }, }) ); return () => tweens.forEach((t) => t.scrollTrigger?.kill()); }); }