/* Initializes GSAP + ScrollTrigger animated stat counters for sections marked data-animated="true". Deferred-loaded; never imported unconditionally. Do not animate sections other than those with data-animated="true". Do not import gsap at module level — dynamic import is required for deferred loading. Do not use this for non-counter animations (parallax, reveal, stagger of sections). RFC-0040: Created as shared GSAP counter initialization module in @warpgogol/werkstatt-shared/client-scripts. RFC-0758: Extended selector to support .dynamic-status-block__stat. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. */ // @ai-invariant: RFC-0040. Do not import gsap at the top level. Always dynamic-import inside initGsapCounter. import { parseNumeric } from "./counter-utils.ts"; export interface GsapCounterOptions { prefersReducedMotion?: boolean; locale?: string; } /** * Finds all `.js-stat-counter` elements within sections marked `data-animated="true"` * and animates them using GSAP + ScrollTrigger. * Safe to call multiple times — elements are guarded with `data-gsap-ready`. * No-ops when `prefersReducedMotion` is true (sets final values synchronously). */ export async function initGsapCounter(options: GsapCounterOptions = {}): Promise { const { prefersReducedMotion = false, locale = "de-DE" } = options; const sections = document.querySelectorAll("[data-animated='true']"); if (sections.length === 0) return; const formatNumber = (value: number, decimals = 0) => new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }).format(value); // Reduced-motion: reveal all cards and set final counter values immediately, no GSAP needed if (prefersReducedMotion) { sections.forEach((section) => { section .querySelectorAll( ".impact-section__stat, .hero__stat, .section-stats__item, .dynamic-status-block__stat", ) .forEach((statEl) => { if (statEl.dataset.gsapReady === "true") return; statEl.dataset.gsapReady = "true"; statEl.style.opacity = "1"; statEl.style.transform = "none"; const valueEl = statEl.querySelector(".js-stat-counter"); if (valueEl) { const numeric = Number(valueEl.dataset.numeric); const decimals = Number(valueEl.dataset.decimals ?? 0); if (!isNaN(numeric)) valueEl.textContent = formatNumber(numeric, decimals); } const prefixEl = statEl.querySelector(".js-stat-prefix"); const suffixEl = statEl.querySelector(".js-stat-suffix"); if (prefixEl) { prefixEl.style.opacity = "1"; prefixEl.style.transform = "none"; } if (suffixEl) { suffixEl.style.opacity = "1"; suffixEl.style.transform = "none"; } }); }); return; } // Dynamically import GSAP so it is excluded from the orchestrator's initial bundle const { gsap } = await import("gsap"); const { ScrollTrigger } = await import("gsap/ScrollTrigger"); gsap.registerPlugin(ScrollTrigger); // Wire ScrollTrigger to Lenis for correct scroll position tracking const wireLenis = (lenis: { on: (event: string, cb: (...args: unknown[]) => void) => void }) => { lenis.on("scroll", () => ScrollTrigger.update()); }; const existingLenis = (window as unknown as Record)["wgLenis"] as { on: (event: string, cb: (...args: unknown[]) => void) => void } | undefined; if (existingLenis) { wireLenis(existingLenis); ScrollTrigger.refresh(); } else { window.addEventListener( "lenis:ready", (e) => { const { lenis } = (e as CustomEvent<{ lenis: typeof existingLenis }>).detail; if (lenis) { wireLenis(lenis); ScrollTrigger.refresh(); } }, { once: true }, ); } sections.forEach((section) => { const allStats = section.querySelectorAll( ".impact-section__stat, .hero__stat, .section-stats__item, .dynamic-status-block__stat", ); allStats.forEach((statEl) => { const valueEl = statEl.querySelector(".js-stat-counter"); // Guard against double-init (keyed on the stat card, not the counter span) if (statEl.dataset.gsapReady === "true") return; statEl.dataset.gsapReady = "true"; const tl = gsap.timeline({ defaults: { ease: "power3.out" }, scrollTrigger: { trigger: statEl, start: "top 82%", once: true, fastScrollEnd: true, }, }); // Card reveal — runs for every stat (counter or static) tl.fromTo(statEl, { opacity: 0, y: 18 }, { opacity: 1, y: 0, duration: 0.7 }); if (valueEl) { const endValue = Number(valueEl.dataset.numeric ?? 0); const startValue = parseNumeric(valueEl.dataset.start) ?? 0; const decimals = Number(valueEl.dataset.decimals ?? 0); const duration = Number(valueEl.dataset.duration ?? 1.8); const prefixEl = statEl.querySelector(".js-stat-prefix"); const suffixEl = statEl.querySelector(".js-stat-suffix"); const counter = { value: startValue }; // Number count-up tl.to( counter, { value: endValue, duration, ease: "power2.out", onUpdate: () => { valueEl.textContent = formatNumber(counter.value, decimals); }, }, 0.08, ); // Prefix reveal if (prefixEl) { tl.fromTo(prefixEl, { opacity: 0, y: 4 }, { opacity: 1, y: 0, duration: 0.45 }, 0.14); } // Suffix reveal if (suffixEl) { tl.fromTo(suffixEl, { opacity: 0, y: 4 }, { opacity: 1, y: 0, duration: 0.45 }, 0.18); } } }); }); document.documentElement.setAttribute("data-gsap-counter-initialized", "true"); }