PageSourceSearch

https://www.faavstudio.com/js/global.js?v=2.0.67

js faavstudio.com collected 2026-09-29 04:41:33 UTC 28,845 bytes, 660 lines download raw bytes

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.