1/* FAAV v2.0.67 â global: lenis + gsap setup, cursor, preloader/wipes, 2 menu, marquee, reveals (.rv), lotties, parallax */ 3(function () { 4 "use strict"; 5 6 var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; 7 var finePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches; 8 9 gsap.registerPlugin(ScrollTrigger, CustomEase); 10 CustomEase.create("site", "0.25,0.1,0.25,1"); 11 CustomEase.create("wipe", "0.561,0.095,0.25,1"); 12 CustomEase.create("outExpo", "0.16,1,0.3,1"); 13 // menu text: gentle start + long silky landing (outExpo read too snappy) 14 // r2: flatter tail â travel happens mid-curve, then a long creep into place 15 CustomEase.create("menuGlide", "0.45,0,0.07,1"); 16 17 /* ---------- Lenis smooth scroll ---------- */ 18 // jump-forward instead of pausing after frame hiccups (also on Lenis-free pages) 19 gsap.ticker.lagSmoothing(0); 20 var lenis = null; 21 if (!reduced && window.Lenis) { 22 // pages can pre-set window.FAAV_SCROLL to experiment with scroll feel 23 var scfg = window.FAAV_SCROLL || {}; 24 lenis = new Lenis({ 25 lerp: scfg.lerp != null ? scfg.lerp : 0.11, 26 wheelMultiplier: scfg.wheelMultiplier != null ? scfg.wheelMultiplier : 1 27 }); 28 lenis.on("scroll", ScrollTrigger.update); 29 gsap.ticker.add(function (time) { lenis.raf(time * 1000); }); 30 } 31 32 window.FAAV = { lenis: lenis, reduced: reduced, finePointer: finePointer }; 33 34 function stopScroll() { if (lenis) lenis.stop(); document.body.classList.add("no-scroll"); } 35 function startScroll() { if (lenis) lenis.start(); document.body.classList.remove("no-scroll"); } 36 window.FAAV.stopScroll = stopScroll; 37 window.FAAV.startScroll = startScroll; 38 39 /* ---------- Custom cursor ---------- */ 40 (function cursor() { 41 var dot = document.querySelector(".cursor-dot"); 42 var ring = document.querySelector(".cursor-ring"); 43 if (!dot || !ring || !finePointer) return; 44 var ringLabel = ring.querySelector("span"); 45 var dotX = gsap.quickTo(dot, "x", { duration: 0.12, ease: "power3.out" }); 46 var dotY = gsap.quickTo(dot, "y", { duration: 0.12, ease: "power3.out" }); 47 var ringX = gsap.quickTo(ring, "x", { duration: 0.45, ease: "power3.out" }); 48 var ringY = gsap.quickTo(ring, "y", { duration: 0.45, ease: "power3.out" }); 49 window.addEventListener("pointermove", function (e) { 50 dotX(e.clientX); dotY(e.clientY); 51 ringX(e.clientX); ringY(e.clientY); 52 }); 53 document.addEventListener("pointerover", function (e) { 54 var labelled = e.target.closest("[data-cursor]"); 55 if (labelled) { 56 ringLabel.textContent = labelled.getAttribute("data-cursor"); 57 ring.classList.add("is-label"); 58 ring.classList.remove("is-shrunk"); 59 return; 60 } 61 if (e.target.closest("a, button")) { 62 ring.classList.add("is-shrunk"); 63 ring.classList.remove("is-label"); 64 } else { 65 ring.classList.remove("is-shrunk", "is-label"); 66 } 67 }); 68 })(); 69 70 /* ---------- Reveals (fade + small rise â .rv system, main.css §8b) ---------- 71 One shared engine at the Wild Dreams pace. Classes are set right away so 72 content is hidden from first paint, but observation is armed on the intro 73 handshake â above-the-fold reveals begin as the wipe clears, not under it. */ 74 var rvStagger = parseFloat( 75 getComputedStyle(document.documentElement).getPropertyValue("--rv-stagger") 76 ) || 90; 77 78 var revealIO = "IntersectionObserver" in window 79 ? new IntersectionObserver(function (entries) { 80 entries.forEach(function (entry) { 81 if (entry.isIntersecting) { 82 entry.target.classList.add("in"); 83 revealIO.unobserve(entry.target); 84 } 85 }); 86 }, { threshold: 0.08, rootMargin: "0px 0px -7% 0px" }) 87 : null; 88 89 // reveal(elements, { media: [subset] | true }) â a call is one stagger group. 90 // Dedupe via data-rv-armed, not the class: some pages bake .rv into the HTML 91 // so content is hidden pre-JS, and those still need arming here. 92 function reveal(elements, opts) { 93 opts = opts || {}; 94 var media = opts.media || []; 95 var items = Array.prototype.filter.call(elements, function (el) { 96 return el && !el.dataset.rvArmed; 97 }); 98 if (!items.length) return;
99 items.forEach(function (el, index) { 100 el.dataset.rvArmed = "1"; 101 el.classList.add("rv"); 102 el.style.setProperty("--rv-delay", Math.min(index, 8) * rvStagger + "ms"); 103 if (media === true || media.indexOf(el) !== -1) el.classList.add("rv-media"); 104 }); 105 if (!revealIO) { 106 items.forEach(function (el) { el.classList.add("in"); }); 107 return; 108 } 109 window.FAAV.onIntro(function () { 110 items.forEach(function (el) { revealIO.observe(el); }); 111 }); 112 } 113 window.FAAV.reveal = reveal; 114 // legacy alias â the SplitType line reveal was retired in v2.0.31 115 window.FAAV.revealLines = function (el) { reveal([el]); }; 116 117 /* ---------- Services / clients hover ---------- 118 The ink overlay that wipes up over a row draws its own copy of the label 119 (main.css §12 renders it from data-label), so the text is composed against 120 its background before the wipe rather than recoloured during it. Rows 121 without a .service-name â the clients closer note â get nothing, and its 122 ::before is switched off in CSS anyway. */ 123 function labelServiceRows() { 124 document.querySelectorAll(".service-row .service-name").forEach(function (name) { 125 name.closest(".service-row").setAttribute("data-label", name.textContent.trim()); 126 }); 127 } 128 129 function initAutoReveals() { 130 // Blog auto-reveals (no HTML hooks needed â future posts get them free): 131 // entries as one staggered media group, article body per child on scroll 132 var entries = document.querySelectorAll(".post-entry"); 133 if (entries.length) reveal(entries, { media: true }); 134 document.querySelectorAll(".article-body").forEach(function (body) { 135 Array.prototype.forEach.call(body.children, function (child) { 136 reveal([child], { media: child.tagName === "FIGURE" ? [child] : [] }); 137 }); 138 }); 139 // article head + player as one staggered group (label â title â deck â 140 // video); must run before the declared-hooks loop so the h1's own 141 // data-reveal doesn't arm it solo first 142 var articleHead = document.querySelector(".article-head"); 143 if (articleHead) { 144 var headGroup = Array.prototype.slice.call(articleHead.children); 145 var articlePlayer = document.querySelector(".article-player .proj-player"); 146 if (articlePlayer) headGroup.push(articlePlayer); 147 reveal(headGroup, { media: articlePlayer ? [articlePlayer] : [] }); 148 } 149 // declared hooks â "lines" and "fade" are the same style now 150 document.querySelectorAll("[data-reveal]").forEach(function (el) { 151 reveal([el], { media: el.getAttribute("data-reveal") === "media" ? [el] : [] }); 152 }); 153 } 154 155 /* ---------- Lottie ---------- */ 156 function initLotties() { 157 if (!window.lottie) return; 158 // looping lotties only run while on screen â all of them playing at once 159 // is the main scroll-jank source on media-heavy pages 160 var visPlay = "IntersectionObserver" in window 161 ? new IntersectionObserver(function (entries) { 162 entries.forEach(function (entry) { 163 var anim = entry.target._lottie; 164 if (!anim) return; 165 if (entry.isIntersecting) anim.play(); 166 else anim.pause(); 167 }); 168 }, { rootMargin: "15% 0%" }) 169 : null; 170 document.querySelectorAll("[data-lottie]").forEach(function (el) { 171 var scrubbed = el.getAttribute("data-lottie-scrub") != null; 172 var anim = lottie.loadAnimation({ 173 container: el, 174 renderer: "svg", 175 loop: el.getAttribute("data-lottie-loop") !== "false", 176 autoplay: !scrubbed && !reduced && !visPlay, 177 path: el.getAttribute("data-lottie") 178 }); 179 el._lottie = anim; 180 if (!scrubbed && !reduced && visPlay) visPlay.observe(el); 181 if (scrubbed && !reduced) { 182 anim.addEventListener("DOMLoaded", function () { 183 var frames = anim.totalFrames - 1; 184 var lastFrame = -1; 185 ScrollTrigger.create({ 186 trigger: el.closest("[data-scrub-scene]") || el, 187 start: "top bottom", 188 end: "bottom top", 189 scrub: true, 190 onUpdate: function (self) { 191 var frame = Math.round(self.progress * frames); 192 if (frame === lastFrame) return;
192 // skip sub-frame redraws 193 lastFrame = frame; 194 anim.goToAndStop(frame, true); 195 } 196 }); 197 }); 198 } 199 }); 200 } 201 202 /* ---------- Mouse parallax ---------- */ 203 function initParallax() { 204 if (reduced || !finePointer) return; 205 var items = document.querySelectorAll("[data-parallax]"); 206 if (!items.length) return; 207 var setters = []; 208 items.forEach(function (el) { 209 var depth = parseFloat(el.getAttribute("data-parallax")) || 20; 210 setters.push({ 211 x: gsap.quickTo(el, "x", { duration: 1.2, ease: "power3.out" }), 212 y: gsap.quickTo(el, "y", { duration: 1.2, ease: "power3.out" }), 213 depth: depth 214 }); 215 }); 216 window.addEventListener("pointermove", function (e) { 217 var nx = (e.clientX / window.innerWidth) - 0.5; 218 var ny = (e.clientY / window.innerHeight) - 0.5; 219 setters.forEach(function (s) { 220 s.x(nx * s.depth); 221 s.y(ny * s.depth); 222 }); 223 }); 224 } 225 226 /* ---------- Marquee ---------- */ 227 function initMarquee() { 228 document.querySelectorAll(".marquee").forEach(function (mq) { 229 var tracks = mq.querySelectorAll(".marquee-track"); 230 if (!tracks.length || reduced) return; 231 var tween = gsap.to(tracks, { 232 xPercent: -100, 233 duration: 22, 234 ease: "none", 235 repeat: -1 236 }); 237 // scroll velocity nudges the marquee speed 238 if (lenis) { 239 lenis.on("scroll", function (e) { 240 var v = Math.min(Math.abs(e.velocity || 0) / 10, 3); 241 gsap.to(tween, { timeScale: 1 + v, duration: 0.3, overwrite: "auto" }); 242 }); 243 } 244 }); 245 } 246 247 /* ---------- Menu overlay ---------- 248 Open and close are separate, deliberate sequences (a fast .reverse() of 249 the open felt rough). CSS header color swaps track the overlay via 250 --menu-swap (main.css §2/§9). */ 251 (function menu() { 252 var toggle = document.querySelector(".menu-toggle"); 253 var overlay = document.querySelector(".menu-overlay"); 254 if (!toggle || !overlay) return; 255 var links = overlay.querySelectorAll(".menu-link"); 256 // sublinks exit via their CONTAINER: the links themselves carry CSS 257 // translate + transitions that poison GSAP's transform cache (a stale 258 // 0.4em base got baked in as a ~18px jump); the plain inner div has none 259 var subInner = overlay.querySelector(".menu-sub-inner"); 260 var foot = overlay.querySelector(".menu-foot"); 261 var open = false; 262 263 // Projects dropdown 264 var dropToggle = overlay.querySelector(".menu-link--toggle"); 265 var dropGroup = dropToggle && dropToggle.closest(".menu-group"); 266 function collapseDrop() { 267 if (!dropGroup) return; 268 dropGroup.classList.remove("open"); 269 dropToggle.setAttribute("aria-expanded", "false"); 270 } 271 if (dropToggle) { 272 dropToggle.addEventListener("click", function () { 273 var expanded = dropGroup.classList.toggle("open"); 274 dropToggle.setAttribute("aria-expanded", String(expanded)); 275 }); 276 } 277 278 var currentTl = null; // kill the whole timeline on toggle â killTweensOf 279 // alone leaves the old onComplete armed 280 281 var header = document.querySelector(".site-header"); 282 283 function openMenu() { 284 if (currentTl) currentTl.kill(); 285 document.body.classList.remove("menu-closing"); // rapid reopen mid-close 286 gsap.killTweensOf([overlay, links, subInner, foot, header]); 287 if (header) gsap.set(header, { opacity: 1 }); 288 // clear anything a killed close left on the sublink container so the 289 // CSS-driven dropdown reveal regains full control 290 if (subInner) gsap.set(subInner, { clearProps: "all" }); 291 currentTl = gsap.timeline() 292 .set(overlay, { visibility: "visible" }) 293 .fromTo(overlay, { clipPath: "inset(0 0 100% 0)" }, 294 { clipPath: "inset(0 0 0% 0)", duration: 0.85, ease: "wipe" }) 295 .fromTo(links, { yPercent: 115, autoAlpha: 0 }, 296 { yPercent: 0, autoAlpha: 1, duration: 1.6, ease: "menuGlide", stagger: 0.08 }, "-=0.5") 297 .fromTo(foot, { autoAlpha: 0 }, 298 { autoAlpha: 1, duration: 1, ease: "power2.out" }, "-=1.1"); 299 } 300 301 function closeMenu() { 302 if (currentTl) currentTl.kill(); 303 // hold the header bar transparent for the whole ride out (CSS rule); 304 // released on complete so the paper bar eases back over the page 305 document.body.classList.add("menu-closing"); 306 gsap.killTweensOf([overlay, links, subInner, foot, header]); 307 currentTl = gsap.timeline({ 308 onComplete: function () { 309 document.body.classList.remove("menu-closing"); 310 if (header) gsap.set(header, { clearProps: "opacity" }); 311 gsap.set(overlay, { visibility: "hidden", clipPath: "inset(0 0 100% 0)" }); 312 collapseDrop(); // reopen with the dropdown closed 313 if (subInner) gsap.set(subInner, { clearProps: "all" }); // CSS regains the dropdown 314 } 315 }) 316 // motion leads, fade follows â fading in place before moving read as a 317 // "ghost dim" bug (opacity and y in one slow-start tween) 318 .to(links, { yPercent: -26, duration: 0.55, ease: "power2.in", stagger: 0.03 }, 0) 319 .to(links, { autoAlpha: 0, duration: 0.4, ease: "power1.in", stagger: 0.03 }, 0.12); 320 // open Projects sublinks ride out with everything else (they used to 321 // sit frozen over the closing menu); container-level tween, slotted 322 // between the Projects link (0.03) and Reel (0.06) in the cascade 323 if (subInner) { 324 currentTl 325 .to(subInner, { yPercent: -26, duration: 0.55, ease: "power2.in" }, 0.045) 326 .to(subInner, { autoAlpha: 0, duration: 0.4, ease: "power1.in" }, 0.165); 327 } 328 currentTl 329 .to(foot, { autoAlpha: 0, duration: 0.3 }, 0) 330 // ABSOLUTE position (was "-=0.25" â 0.39): relative tail positions 331 // shift when tweens are appended â the header tweens below once 332 // pushed the wipe to 0.85s and the close flashed white/black/white 333 .to(overlay, { clipPath: "inset(0 0 100% 0)", duration: 0.7, ease: "wipe" }, 0.39); 334 // the header rides the same exit: contents fade out with the ink, 335 // then fade back in (page-styled) timed to the wipe edge clearing 336 // the top (~1.09s). OPACITY ONLY â never visibility: a hidden header 337 // let mid-close clicks fall through to the page (on home that could 338 // pop the reel lightbox), and clicking where Menu lives must al
338ways 339 // reach the toggle 340 if (header) { 341 currentTl 342 .to(header, { opacity: 0, duration: 0.3, ease: "power1.in" }, 0) 343 .to(header, { opacity: 1, duration: 0.45, ease: "power2.out" }, 0.95); 344 } 345 } 346 347 toggle.addEventListener("click", function () { 348 open = !open; 349 document.body.classList.toggle("menu-open", open); 350 // labels come from the markup (data-label-open/close) so FR pages need no JS dictionary 351 toggle.querySelector(".menu-toggle-txt").textContent = open 352 ? (toggle.dataset.labelClose || "Close") 353 : (toggle.dataset.labelOpen || "Menu"); 354 if (open) { stopScroll(); openMenu(); } 355 else { startScroll(); closeMenu(); } 356 }); 357 358 })(); 359 360 /* ---------- Page transitions (wipes) ---------- */ 361 var wipeTop = document.querySelector(".wipe:not(.wipe--under)"); 362 var wipeUnder = document.querySelector(".wipe--under"); 363 // menu-origin transitions arrive with a top-to-bottom reveal (handshake 364 // below); read-and-clear so an aborted navigation can't leak the flag 365 var wipeDown = sessionStorage.getItem("faavWipeDown") === "1"; 366 sessionStorage.removeItem("faavWipeDown"); 367 368 function wipeOutThen(href) { 369 if (reduced || !wipeTop) { window.location.href = href; return; } 370 stopScroll(); 371 var tl = gsap.timeline({ 372 onComplete: function () { window.location.href = href; } 373 }); 374 if (document.body.classList.contains("menu-open")) { 375 // From the open menu the ink panel over the ink overlay reads as 376 // nothing, and closing the menu first exposed the old page. Instead the 377 // screen stays covered the whole way: paper wipes DOWN over the menu, 378 // ink wipes DOWN over the paper, and the arriving page reveals 379 // top-to-bottom to continue the downward motion. 380 sessionStorage.setItem("faavWipeDown", "1"); 381 tl.set([wipeUnder, wipeTop], { transformOrigin: "50% 0%", scaleY: 0 }) 382 .to(wipeUnder, { scaleY: 1, duration: 0.65, ease: "wipe" }, 0) 383 .to(wipeTop, { scaleY: 1, duration: 0.65, ease: "wipe" }, 0.3); 384 return; 385 } 386 // single ink sweep â the paper-deep under panel read as a second wipe 387 tl.set(wipeTop, { transformOrigin: "50% 100%" }); 388 // the page itself lifts away under the panel 389 var main = document.querySelector("main"); 390 if (main) tl.to(main, { autoAlpha: 0, y: -30, duration: 0.7, ease: "power2.inOut" }, 0); 391 tl.to(wipeTop, { scaleY: 1, duration: 0.9, ease: "wipe" }, 0); 392 } 393 394 document.addEventListener("click", function (e) { 395 var a = e.target.closest("a"); 396 if (!a) return; 397 var href = a.getAttribute("href"); 398 if (!href || a.target === "_blank" || href.indexOf("#") === 0 || 399 /^(mailto:|tel:|https?:\/\/)/.test(href)) return; 400 e.preventDefault(); 401 (transitionMode() === "dip" ? dipOutThen : wipeOutThen)(href); 402 }); 403 404 // Intro handshake: pages can arm listeners before or after the dispatch 405 function fireIntro() { 406 document.documentElement.setAttribute("data-intro", "1"); 407 document.dispatchEvent(new CustomEvent("faav:intro")); 408 } 409 window.FAAV.onIntro = function (fn) { 410 if (document.documentElement.getAttribute("data-intro") === "1") { fn(); return; } 411 document.addEventListener("faav:intro", fn, { once: true }); 412 }; 413 414 // Incoming reveal: pages without a preloader start covered by the wipe 415 function wipeIn() { 416 if (!wipeTop) { setTimeout(fireIntro, 100); return; } 417 // origin top = reveal rises from the bottom (default); menu-origin 418 // arrivals reveal top-to-bottom instead (origin bottom, edge falls) 419 gsap.set(wipeTop, { scaleY: 1, transformOrigin: wipeDown ? "50% 100%" : "50% 0%" }); 420 gsap.set(wipeUnder, { scaleY: 0 }); 421 gsap.to(wipeTop, { scaleY: 0, duration: 1, ease: "wipe", delay: 0.1 }); 422 // reveals are armed on the intro â they begin as the page uncovers 423 setTimeout(fireIntro, 600); 424 } 425 426 /* ---------- Dip-fade transition (THE transition since 2026-07-17) ---------- 427 Approved over the scaleY wipe in the feel lab. The old wipe stays reachable 428 via localStorage "faavTransition" = "wipe" (?t=wipe, or the "t" key on 429 localhost) for future comparisons; anything else = dip. */ 430 function transitionMode() { 431 return localStorage.getItem("faavTransition") === "wipe" ? "wipe" : "dip"; 432 } 433 (function syncModeFromQuery() { 434 var t = new URLSearchParams(location.search).get("t"); 435 if (t === "dip" || t === "wipe") localStorage.setItem("faavTransition", t); 436 })(); 437 438 function cssVar(name) { 439 return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); 440 } 441 442 // One tweak point. Curve grid for one-line swaps:
443 // Snappy: out 0.35 "sine.inOut" / in 0.55 "sine.out" 444 // Recommended: out 0.45 "sine.inOut" / in 0.7 "power2.out" 445 // Languid: out 0.6 "power1.inOut"/ in 0.95 "power2.out" 446 var DIP = { 447 colors: { 448 paper: cssVar("--paper") || "#f6f5f2", 449 ink: cssVar("--ink") || "#131311" 450 }, 451 out: { duration: 0.55, ease: "sine.inOut" }, // page â color 452 outMenu: { duration: 0.4, ease: "sine.out" }, // open menu â black (already dark) 453 hold: 0.12, // beat of solid color on arrival 454 in: { duration: 0.95, ease: "power2.out" }, // color â page 455 introAtMs: 350, // when .rv reveals arm during fade-in 456 maxWaitMs: 1500 // cap on waiting for window "load" 457 }; 458 459 var dipEl = null; 460 function ensureDip(color) { 461 if (!dipEl) { 462 dipEl = document.createElement("div"); 463 dipEl.className = "dip"; 464 dipEl.setAttribute("aria-hidden", "true"); 465 dipEl.style.cssText = 466 "position:fixed;inset:0;z-index:451;pointer-events:none;opacity:0;will-change:opacity;"; 467 document.body.appendChild(dipEl); 468 } 469 dipEl.style.background = color; 470 return dipEl; 471 } 472 473 function dipOutThen(href) { 474 if (reduced) { window.location.href = href; return; } 475 stopScroll(); 476 // From the open ink menu the dip goes to black so the cover reads as the 477 // menu melting into darkness; page-to-page dips go to paper (every page's 478 // canvas), so the fade lands on the same color it left. 479 var fromMenu = document.body.classList.contains("menu-open"); 480 var colorKey = fromMenu ? "ink" : "paper"; 481 sessionStorage.setItem("faavDip", colorKey); 482 var cfg = fromMenu ? DIP.outMenu : DIP.out; 483 gsap.to(ensureDip(DIP.colors[colorKey]), { 484 opacity: 1, duration: cfg.duration, ease: cfg.ease, 485 onComplete: function () { window.location.href = href; } 486 }); 487 } 488 489 function dipIn() { 490 if (!dipEl) { setTimeout(fireIntro, 100); return; } 491 var started = false; 492 function start() { 493 if (started) return; 494 started = true; 495 // double rAF: first frame of the new page paints under the cover before 496 // the fade starts, so the fade never overlaps initial layout work 497 requestAnimationFrame(function () { requestAnimationFrame(function () { 498 gsap.to(dipEl, { opacity: 0, duration: DIP.in.duration, ease: DIP.in.ease, delay: DIP.hold }); 499 setTimeout(fireIntro, DIP.introAtMs); 500 }); }); 501 } 502 // hold the cover until the page has actually finished loading (fonts, 503 // images, hero media) so the fade never reveals a half-built page â but 504 // never strand it behind the cover longer than maxWaitMs 505 if (document.readyState === "complete") start(); 506 else { 507 window.addEventListener("load", start, { once: true }); 508 setTimeout(start, DIP.maxWaitMs); 509 } 510 } 511 512 // Arrival: read-and-clear unconditionally (mode flips can't leak the flag), 513 // then cover immediately â same unconditional-cover parity as wipeIn(). The 514 // preloader check keeps home on its own intro. 515 var dipArrive = sessionStorage.getItem("faavDip"); 516 sessionStorage.removeItem("faavDip"); 517 if (!reduced && transitionMode() === "dip" && !document.querySelector(".preloader")) { 518 ensureDip(DIP.colors[dipArrive] || DIP.colors.paper).style.opacity = "1"; 519 } 520 // hand-off from the first-paint CSS cover (inline head script + main.css §8) 521 // â same synchronous pass as the .dip cover above, so no gap between them. 522 // The inline root background exists for heavy pages (e.g. Wild Dreams) where 523 // Chrome's paint-hold gives up before render-blocking CSS is ready and 524 // flashes a bare white compositor frame. 525 document.documentElement.removeAttribute("data-dip"); 526 document.documentElement.style.background = ""; 527 528 // bfcache restore: clear the dip cover (separate from the wipe's handler) 529 window.addEventListener("pageshow", function (e) { 530 if (e.persisted && dipEl) { 531 gsap.killTweensOf(dipEl); 532 gsap.set(dipEl, { opacity: 0 }); 533 startScroll(); 534 } 535 }); 536 537 // Localhost-only: press "t" to flip modes; a badge announces what the next 538 // click will use. Inert on the deployed site. 539 if (/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) { 540 document.addEventListener("keydown", function (e) { 541 if (e.key !== "t" || e.metaKey || e.ctrlKey || e.altKey) return;
542 if (/^(input|textarea|select)$/i.test(e.target.tagName)) return; 543 var next = transitionMode() === "dip" ? "wipe" : "dip"; 544 localStorage.setItem("faavTransition", next); 545 var badge = document.createElement("div"); 546 badge.textContent = "transition: " + next; 547 badge.style.cssText = 548 "position:fixed;left:16px;bottom:16px;z-index:600;padding:8px 14px;" + 549 "background:#131311;color:#f6f5f2;font:12px/1 monospace;border-radius:99px;" + 550 "pointer-events:none;"; 551 document.body.appendChild(badge); 552 setTimeout(function () { badge.remove(); }, 1200); 553 }); 554 } 555 556 /* ---------- Preloader (home only) ---------- */ 557 function preloader() { 558 var pre = document.querySelector(".preloader"); 559 if (!pre) { (transitionMode() === "dip" ? dipIn : wipeIn)(); return; } 560 var countEl = pre.querySelector(".preloader-count"); 561 var logo = pre.querySelector(".preloader-logo svg"); 562 stopScroll(); 563 if (reduced) { 564 pre.remove(); startScroll(); 565 fireIntro(); 566 return; 567 } 568 var counter = { v: 0 }; 569 var finished = false; 570 function finish() { 571 if (finished) return; 572 finished = true; 573 pre.remove(); 574 startScroll(); 575 } 576 var tl = gsap.timeline({ onComplete: finish }); 577 setTimeout(finish, 3800); // hard fallback: never strand the page behind the preloader 578 tl.fromTo(logo, { yPercent: 110 }, { yPercent: 0, duration: 0.9, ease: "outExpo" }) 579 .to(counter, { 580 v: 100, duration: 1.4, ease: "power2.inOut", 581 onUpdate: function () { countEl.textContent = String(Math.round(counter.v)).padStart(3, "0") + " %"; } 582 }, 0.2) 583 .to(pre, { yPercent: -100, duration: 0.8, ease: "wipe" }, "+=0.05"); 584 setTimeout(fireIntro, 1700); 585 } 586 587 // bfcache restore: make sure wipes are cleared 588 window.addEventListener("pageshow", function (e) { 589 if (e.persisted && wipeTop) { 590 gsap.set([wipeTop, wipeUnder], { scaleY: 0 }); 591 var main = document.querySelector("main"); 592 if (main) gsap.set(main, { autoAlpha: 1, y: 0 }); 593 startScroll(); 594 } 595 }); 596 597 /* ---------- Video lightbox (Mux player via [data-lightbox-mux], or Vimeo iframe via [data-lightbox-url]) ---------- */ 598 function initLightbox() { 599 var box = document.querySelector(".lightbox"); 600 if (!box) return; 601 var frame = box.querySelector(".lightbox-frame"); 602 var closeBtn = box.querySelector(".lightbox-close"); 603 function show() { 604 box.classList.add("is-open"); 605 window.FAAV.stopScroll(); 606 } 607 function openMux(id) { 608 frame.innerHTML = '<mux-player playback-id="' + id + 609 '" stream-type="on-demand" autoplay playsinline title="Faav player"></mux-player>'; 610 show(); 611 } 612 function openFrame(url) { 613 frame.innerHTML = '<iframe src="' + url + 614 '" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen title="Faav player"></iframe>'; 615 show(); 616 } 617 function close() { 618 if (!box.classList.contains("is-open")) return; 619 box.classList.remove("is-open"); 620 frame.innerHTML = ""; 621 window.FAAV.startScroll(); 622 } 623 document.querySelectorAll("[data-lightbox-url], [data-lightbox-mux]").forEach(function (el) { 624 el.addEventListener("click", function (e) { 625 e.preventDefault(); 626 // opened from inside the menu (e.g. the Reel link): close it first 627 if (document.body.classList.contains("menu-open")) { 628 var mt = document.querySelector(".menu-toggle"); 629 if (mt) mt.click(); 630 } 631 var mux = el.getAttribute("data-lightbox-mux"); 632 if (mux) openMux(mux); 633 else openFrame(el.getAttribute("data-lightbox-url")); 634 }); 635 }); 636 closeBtn.addEventListener("click", close); 637 box.addEventListener("click", function (e) { if (e.target === box) close(); }); 638 document.addEventListener("keydown", function (e) { if (e.key === "Escape") close(); }); 639 } 640 641 /* ---------- Boot ---------- */ 642 window.addEventListener("DOMContentLoaded", function () { 643 labelServiceRows(); 644 initAutoReveals(); 645 initLotties(); 646 initParallax(); 647 initMarquee(); 648 initLightbox(); 649 preloader(); 650 ScrollTrigger.refresh(); 651 }); 652})(); 653 654/* ---------- Analytics ---------- 655 Cloudflare Web Analytics, enabled on the faavstudio.com ZONE (token 656 55f04f1fâ¦): the edge injects the cookieless beacon into every text/html 657 response on www â 404.html included â so nothing lives in the repo. Not 658 injected on *.pages.dev hosts, which keeps staging/previews silent. GA4 (G-KBZ3GT65TY) was removed in 659 v2.0.67 â with Consent Mode's analytics_storage denied it never surfaced a 660 single session in reports (2 sessions in 6 weeks vs 126 on the Webflow tag). */
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.