/*
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");
}