/* Animates .js-inline-number spans using GSAP count-up + ScrollTrigger. Deferred-loaded; never imported unconditionally. Do not apply opacity, y, scale, or any spatial tween to inline number spans — causes layout reflow. Do not load gsap at module level — dynamic import is mandatory. Do not animate numbers inside headings — those are excluded at SSR pre-wrap time. RFC-0041: Created as shared GSAP inline number animation module in @warpgogol/werkstatt-shared/client-scripts. */ // @ai-invariant: RFC-0041. No spatial tweens on inline elements. Dynamic GSAP import only. export interface InlineNumberAnimationOptions { prefersReducedMotion?: boolean; } /** * Animates all `.js-inline-number` spans found in the document using GSAP count-up. * Duration is read from each span's `data-duration` attribute (default 3.0 s). * No spatial tweens — only the numeric value is animated to prevent CLS. * Safe to call multiple times — spans are guarded with `data-gsap-ready`. * No-ops when `prefersReducedMotion` is true (sets final values synchronously). */ export async function initInlineNumberAnimation( options: InlineNumberAnimationOptions = {}, ): Promise { const { prefersReducedMotion = false } = options; const spans = document.querySelectorAll(".js-inline-number"); if (spans.length === 0) return; // Locale from document lang attribute, fallback to de-DE const locale = document.documentElement.lang || "de-DE"; const formatNumber = (value: number) => new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(Math.round(value)); // Reduced-motion: set final values immediately, no GSAP if (prefersReducedMotion) { spans.forEach((el) => { if (el.dataset.gsapReady === "true") return; el.dataset.gsapReady = "true"; const numeric = Number(el.dataset.numeric ?? 0); el.textContent = formatNumber(numeric); }); return; } // Dynamically import GSAP — excluded from orchestrator's initial bundle const { gsap } = await import("gsap"); const { ScrollTrigger } = await import("gsap/ScrollTrigger"); gsap.registerPlugin(ScrollTrigger); // Wire ScrollTrigger to Lenis: on every Lenis scroll tick, feed the current // scroll position into ScrollTrigger so it doesn't rely on native scroll events. type LenisScrollEvent = { scroll: number }; type LenisInstance = { on: (event: string, cb: (e: LenisScrollEvent) => void) => void }; const wireLenis = (lenis: LenisInstance) => { lenis.on("scroll", (e: LenisScrollEvent) => { void e; // position already reflected in window.scrollY by Lenis native scroll ScrollTrigger.update(); }); // Defer refresh to next rAF so layout is fully settled before positions are measured requestAnimationFrame(() => ScrollTrigger.refresh()); }; const existingLenis = (window as unknown as Record)["wgLenis"] as LenisInstance | undefined; if (existingLenis) { wireLenis(existingLenis); } else { window.addEventListener( "lenis:ready", (e) => { const { lenis } = (e as CustomEvent<{ lenis: LenisInstance }>).detail; if (lenis) wireLenis(lenis); }, { once: true }, ); } spans.forEach((el) => { if (el.dataset.gsapReady === "true") return; el.dataset.gsapReady = "true"; const endValue = Number(el.dataset.numeric ?? 0); const duration = Number(el.dataset.duration ?? 3.0); const counter = { value: 0 }; // Only count-up tween — NO spatial tweens (y/opacity) on inline elements gsap.to(counter, { value: endValue, duration, ease: "power2.out", scrollTrigger: { trigger: el, start: "top 85%", once: true, fastScrollEnd: true, }, onUpdate: () => { el.textContent = formatNumber(counter.value); }, onComplete: () => { // Ensure exact final value (avoids floating-point display artifacts) el.textContent = formatNumber(endValue); }, }); }); document.documentElement.setAttribute("data-inline-number-initialized", "true"); }