/**
 * Svelte action: animates a text element's content via JS RAF.
 *
 * Implemented modes:
 *   • fade-letters — wraps each character in a <span> and fades them in with stagger
 *   • bounce-in    — same wrapping, scale+translate pop per character
 *   • handwriting  — re-renders the text inside an <svg><text> with stroke + transparent
 *                    fill and animates `stroke-dashoffset` so the text appears to be
 *                    drawn left-to-right (works best with cursive/script fonts)
 *
 * Other modes (instant, typewriter, fade-words) are handled elsewhere by the
 * /present render path — this action no-ops for those.
 *
 * Each runner builds a `currentSeek(elapsedMs)` closure that snapshots the
 * setup state and computes the visual at any virtual time. The live RAF loop
 * calls it with real elapsed; the MP4/GIF export pipeline calls it via
 * `window.__seekAllTextAnims(virtualMs)` for frame-perfect captures.
 *
 * The action also registers a restart fn into `window.__svgAnimRestart` so the
 * server-side video export pipeline can reset all animations under the
 * virtual clock at the start of the slide hold (same pattern as FlowMarkers
 * and arrowClipDraw).
 */
/** Cheap deterministic hash → [0, 1). Used in scramble/glitch so virtual-time
 * seek (export) produces the same intermediate chars / jitter as live RAF.
 * Without determinism, MP4/GIF would scramble different letters every frame. */
function detRand(seed) {
    const x = Math.sin(seed) * 43758.5453;
    return x - Math.floor(x);
}
export function textAnimate(node, params) {
    let raf = 0;
    let originalContent = null;
    let activeMode = null;
    // Each runner stores a per-frame `seek(elapsed)` function here so external
    // drivers (the MP4/GIF export pipeline's virtual clock) can position the
    // animation at any time without depending on real-time RAF. Cleared on
    // restart/destroy. Real-time playback's RAF loop also calls this seek.
    let currentSeek = null;
    // Stable wrapper registered with the global seek registry. Used by the
    // export pipeline (`__seekAllTextAnims`) to drive all active animations
    // at the same virtual time as the rest of the frame capture.
    const seekWrapper = (elapsedMs) => { if (currentSeek)
        currentSeek(elapsedMs); };
    if (typeof window !== 'undefined') {
        (window.__textAnimSeek ||= []).push(seekWrapper);
    }
    function clearAnim() {
        if (raf)
            cancelAnimationFrame(raf);
        raf = 0;
        currentSeek = null;
    }
    function restore() {
        clearAnim();
        // Replace whatever we injected with the original content. Doing this on
        // every (re)start keeps the DOM clean when the user toggles modes.
        if (originalContent !== null)
            node.textContent = originalContent;
    }
    function effectiveDuration() {
        const requested = Math.max(50, params.duration || 800);
        if (params.slideDuration && params.slideDuration > 0 && params.loop) {
            return params.slideDuration / Math.max(1, Math.round(params.slideDuration / requested));
        }
        return requested;
    }
    /** Wraps each character of `text` in a <span class="ta-char">.  Spaces are
     * also wrapped (with a special class) so the per-letter animation runs
     * uniformly across word boundaries while still preserving spacing. */
    function wrapChars(text) {
        const frag = document.createElement('span');
        frag.className = 'ta-wrap';
        for (const ch of text) {
            const s = document.createElement('span');
            s.className = ch === ' ' ? 'ta-char ta-space' : 'ta-char';
            s.textContent = ch === ' ' ? ' ' : ch; // nbsp so spaces render as fixed-width in inline-block
            s.style.display = 'inline-block';
            // Inherit gradient background + clip so per-char wrapping doesn't
            // break gradient text. Parent text-element uses `background-clip:
            // text` + `color: transparent`; without these inherits, char spans
            // would only inherit transparent color and render invisible.
            s.style.backgroundImage = 'inherit';
            s.style.backgroundClip = 'inherit';
            s.style.webkitBackgroundClip = 'inherit';
            frag.appendChild(s);
        }
        return frag;
    }
    function wrapWords(text) {
        const wrap = document.createElement('span');
        wrap.className = 'ta-wrap';
        const words = [];
        const parts = text.split(/(\s+)/); // keep separators
        for (const p of parts) {
            if (/^\s+$/.test(p)) {
                wrap.appendChild(document.createTextNode(p));
            }
            else if (p.length > 0) {
                const w = document.createElement('span');
                w.className = 'ta-word';
                w.style.display = 'inline-block';
                w.style.backgroundImage = 'inherit';
                w.style.backgroundClip = 'inherit';
                w.style.webkitBackgroundClip = 'inherit';
                w.textContent = p;
                wrap.appendChild(w);
                words.push(w);
            }
        }
        return { wrap, words };
    }
    // ─── Standardized runner pattern ────────────────────────────────────
    //
    // Each runner: (1) sets up state (chars list, durations, etc.), (2) builds
    // `currentSeek(rawElapsed)` — the per-frame applier parameterized by ms,
    // (3) primes initial DOM state, (4) starts a real-time RAF loop that just
    // delegates to currentSeek with `performance.now() - start`.
    //
    // Loop modes wrap `rawElapsed` against the full cycle duration so seek
    // works correctly at any timestamp. One-shot modes use elapsed directly.
    function runFadeLetters() {
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = wrapChars(originalContent);
        node.appendChild(wrap);
        const chars = Array.from(wrap.querySelectorAll('.ta-char'));
        const stagger = params.stagger ?? Math.max(20, Math.min(60, 600 / Math.max(1, chars.length)));
        const perLetter = 280;
        const total = stagger * chars.length + perLetter;
        const cycle = Math.max(total, effectiveDuration());
        for (const c of chars)
            c.style.opacity = '0';
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < chars.length; i++) {
                const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
                chars[i].style.opacity = String(t);
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < total)
                raf = requestAnimationFrame(step);
            else
                raf = 0;
        }
        raf = requestAnimationFrame(step);
    }
    function runBounceIn() {
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = wrapChars(originalContent);
        node.appendChild(wrap);
        const chars = Array.from(wrap.querySelectorAll('.ta-char'));
        const stagger = params.stagger ?? Math.max(30, Math.min(80, 800 / Math.max(1, chars.length)));
        const perLetter = 380;
        const total = stagger * chars.length + perLetter;
        const cycle = Math.max(total, effectiveDuration());
        for (const c of chars) {
            c.style.opacity = '0';
            c.style.transform = 'translateY(0.4em) scale(0.6)';
            c.style.transformOrigin = '50% 80%';
        }
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < chars.length; i++) {
                const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
                const eased = 1 - Math.pow(1 - t, 3);
                const overshoot = Math.sin(t * Math.PI) * 0.1;
                const scale = 0.6 + (1 - 0.6) * eased + overshoot;
                const translate = (1 - eased) * 0.4;
                chars[i].style.opacity = String(eased);
                chars[i].style.transform = `translateY(${translate}em) scale(${scale})`;
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < total)
                raf = requestAnimationFrame(step);
            else
                raf = 0;
        }
        raf = requestAnimationFrame(step);
    }
    function runScrambleIn() {
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = wrapChars(originalContent);
        node.appendChild(wrap);
        const chars = Array.from(wrap.querySelectorAll('.ta-char'));
        const finalText = Array.from(originalContent);
        const stagger = params.stagger ?? Math.max(30, Math.min(80, 800 / Math.max(1, chars.length)));
        const settleMs = 500;
        const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&*';
        const total = stagger * chars.length + settleMs;
        const cycle = Math.max(total, effectiveDuration());
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < chars.length; i++) {
                const local = elapsed - i * stagger;
                if (local < 0) {
                    chars[i].textContent = ' ';
                    continue;
                }
                if (local >= settleMs) {
                    chars[i].textContent = finalText[i];
                    continue;
                }
                // Deterministic seed = char index + 60ms bucket → same char on
                // every frame at the same virtual time. Replaces Math.random.
                const bucket = Math.floor(local / 60);
                const idx = Math.floor(detRand(i * 7919 + bucket * 31) * alphabet.length);
                chars[i].textContent = finalText[i] === ' ' ? ' ' : alphabet[idx];
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < total)
                raf = requestAnimationFrame(step);
            else
                raf = 0;
        }
        raf = requestAnimationFrame(step);
    }
    function runSlotMachine() {
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = wrapChars(originalContent);
        node.appendChild(wrap);
        const chars = Array.from(wrap.querySelectorAll('.ta-char'));
        const finalText = Array.from(originalContent);
        const stagger = params.stagger ?? 60;
        const spinMs = 600;
        const reel = '0123456789!@#$%ABCDEFGH';
        const total = stagger * chars.length + spinMs;
        const cycle = Math.max(total, effectiveDuration());
        for (const c of chars)
            c.style.transform = 'translateY(-0.3em)';
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < chars.length; i++) {
                const local = elapsed - i * stagger;
                if (local < 0) {
                    chars[i].textContent = ' ';
                    continue;
                }
                if (local >= spinMs) {
                    chars[i].textContent = finalText[i];
                    chars[i].style.transform = 'translateY(0)';
                    continue;
                }
                const t = local / spinMs;
                const offsetY = (1 - t) * -0.3;
                chars[i].textContent = finalText[i] === ' ' ? ' ' : reel[((local / 60) | 0) % reel.length];
                chars[i].style.transform = `translateY(${offsetY}em)`;
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < total)
                raf = requestAnimationFrame(step);
            else
                raf = 0;
        }
        raf = requestAnimationFrame(step);
    }
    function runDrop() {
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = wrapChars(originalContent);
        node.appendChild(wrap);
        const chars = Array.from(wrap.querySelectorAll('.ta-char'));
        const stagger = params.stagger ?? Math.max(30, Math.min(90, 700 / Math.max(1, chars.length)));
        const perLetter = 520;
        const total = stagger * chars.length + perLetter;
        const cycle = Math.max(total, effectiveDuration());
        for (const c of chars) {
            c.style.opacity = '0';
            c.style.transform = 'translateY(-1.2em)';
        }
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < chars.length; i++) {
                const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
                let b;
                if (t < 0.5)
                    b = 4 * t * t * t;
                else
                    b = 1 - Math.pow(-2 * t + 2, 3) / 2;
                const overshoot = t > 0.7 ? Math.sin((t - 0.7) * Math.PI / 0.3) * 0.08 : 0;
                const translate = (1 - b) * -1.2 + overshoot;
                chars[i].style.opacity = String(Math.min(1, t * 2));
                chars[i].style.transform = `translateY(${translate}em)`;
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < total)
                raf = requestAnimationFrame(step);
            else
                raf = 0;
        }
        raf = requestAnimationFrame(step);
    }
    function runGlitch() {
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = wrapChars(originalContent);
        node.appendChild(wrap);
        const chars = Array.from(wrap.querySelectorAll('.ta-char'));
        const dur = 1100;
        const cycle = Math.max(dur, effectiveDuration());
        for (const c of chars) {
            c.style.opacity = '0';
        }
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < chars.length; i++) {
                const localStart = (i / Math.max(1, chars.length)) * (dur * 0.4);
                const t = Math.min(1, Math.max(0, (elapsed - localStart) / (dur - localStart)));
                // Deterministic jitter — same per-frame seed reproduces in export.
                const bucket = Math.floor(elapsed / 33);
                const j = (detRand(i * 1009 + bucket * 17) - 0.5) * 0.08 * (1 - t);
                const r1 = (detRand(i * 1031 + bucket * 23) - 0.5) * 4;
                const r2 = (detRand(i * 1049 + bucket * 29) - 0.5) * 4;
                const filter = t < 0.7
                    ? `drop-shadow(${r1}px 0 0 #06b6d4) drop-shadow(${r2}px 0 0 #ec4899)`
                    : 'none';
                chars[i].style.opacity = String(t);
                chars[i].style.transform = t < 0.85 ? `translate(${j}em, ${j}em)` : 'none';
                chars[i].style.filter = filter;
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < dur)
                raf = requestAnimationFrame(step);
            else {
                for (const c of chars) {
                    c.style.filter = 'none';
                    c.style.transform = 'none';
                }
                raf = 0;
            }
        }
        raf = requestAnimationFrame(step);
    }
    function runMarquee() {
        // Continuous horizontal scroll. Element overflow must be hidden via
        // the outer .text-element container styles — we just translate the
        // inner wrap leftwards by its own width then snap.
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = document.createElement('span');
        wrap.className = 'ta-wrap';
        wrap.style.display = 'inline-block';
        wrap.style.whiteSpace = 'nowrap';
        wrap.textContent = originalContent + '   ' + originalContent;
        node.style.overflow = 'hidden';
        node.style.whiteSpace = 'nowrap';
        node.appendChild(wrap);
        const cycle = effectiveDuration() || 6000;
        currentSeek = (raw) => {
            const pct = (raw % cycle) / cycle;
            wrap.style.transform = `translateX(${-50 * pct}%)`;
        };
        const start = performance.now();
        function step(now) {
            currentSeek?.(now - start);
            raf = requestAnimationFrame(step);
        }
        raf = requestAnimationFrame(step);
    }
    function runBlurIn() {
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = wrapChars(originalContent);
        node.appendChild(wrap);
        const chars = Array.from(wrap.querySelectorAll('.ta-char'));
        const stagger = params.stagger ?? Math.max(20, Math.min(60, 600 / Math.max(1, chars.length)));
        const perLetter = 600;
        const total = stagger * chars.length + perLetter;
        const cycle = Math.max(total, effectiveDuration());
        for (const c of chars) {
            c.style.opacity = '0';
            c.style.filter = 'blur(12px)';
        }
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < chars.length; i++) {
                const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
                const eased = 1 - Math.pow(1 - t, 2);
                chars[i].style.opacity = String(eased);
                chars[i].style.filter = `blur(${(1 - eased) * 12}px)`;
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < total)
                raf = requestAnimationFrame(step);
            else {
                for (const c of chars)
                    c.style.filter = 'none';
                raf = 0;
            }
        }
        raf = requestAnimationFrame(step);
    }
    function runStretch() {
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = wrapChars(originalContent);
        node.appendChild(wrap);
        const chars = Array.from(wrap.querySelectorAll('.ta-char'));
        const stagger = params.stagger ?? 50;
        const perLetter = 450;
        const total = stagger * chars.length + perLetter;
        const cycle = Math.max(total, effectiveDuration());
        for (const c of chars) {
            c.style.opacity = '0';
            c.style.transform = 'scaleY(0.1)';
            c.style.transformOrigin = '50% 100%';
        }
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < chars.length; i++) {
                const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perLetter));
                const eased = 1 - Math.pow(1 - t, 4);
                const overshoot = t > 0.8 ? Math.sin((t - 0.8) * Math.PI / 0.2) * 0.15 : 0;
                const sy = 0.1 + (1 - 0.1) * eased + overshoot;
                chars[i].style.opacity = String(Math.min(1, t * 1.5));
                chars[i].style.transform = `scaleY(${sy})`;
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < total)
                raf = requestAnimationFrame(step);
            else
                raf = 0;
        }
        raf = requestAnimationFrame(step);
    }
    function runSlideWords() {
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const { wrap, words } = wrapWords(originalContent);
        node.appendChild(wrap);
        const stagger = params.stagger ?? 100;
        const perWord = 500;
        const total = stagger * words.length + perWord;
        const cycle = Math.max(total, effectiveDuration());
        for (const w of words) {
            w.style.opacity = '0';
            w.style.transform = 'translateY(0.6em)';
        }
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < words.length; i++) {
                const t = Math.min(1, Math.max(0, (elapsed - i * stagger) / perWord));
                const eased = 1 - Math.pow(1 - t, 3);
                words[i].style.opacity = String(eased);
                words[i].style.transform = `translateY(${(1 - eased) * 0.6}em)`;
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < total)
                raf = requestAnimationFrame(step);
            else
                raf = 0;
        }
        raf = requestAnimationFrame(step);
    }
    function runWave() {
        // Continuous wave bob across letters. Each character oscillates with
        // a sine offset; phase is letter-index based so the wave rolls.
        originalContent = params.content ?? '';
        node.innerHTML = '';
        const wrap = wrapChars(originalContent);
        node.appendChild(wrap);
        const chars = Array.from(wrap.querySelectorAll('.ta-char'));
        const cycle = effectiveDuration() || 1800;
        const amp = 0.18;
        currentSeek = (raw) => {
            const t = raw / cycle;
            for (let i = 0; i < chars.length; i++) {
                const phase = t * Math.PI * 2 + (i / Math.max(1, chars.length)) * Math.PI * 1.4;
                chars[i].style.transform = `translateY(${Math.sin(phase) * amp}em)`;
            }
        };
        const start = performance.now();
        function step(now) {
            currentSeek?.(now - start);
            raf = requestAnimationFrame(step);
        }
        raf = requestAnimationFrame(step);
    }
    function runTypewriterErase() {
        originalContent = params.content ?? '';
        const chars = Array.from(originalContent);
        const speed = 50; // chars per second
        const charMs = 1000 / speed;
        const holdMs = 1200;
        const eraseMs = chars.length * charMs * 0.6;
        const cycle = chars.length * charMs + holdMs + eraseMs + 400;
        currentSeek = (raw) => {
            const t = raw % cycle;
            let shown;
            if (t < chars.length * charMs) {
                shown = Math.floor(t / charMs);
            }
            else if (t < chars.length * charMs + holdMs) {
                shown = chars.length;
            }
            else if (t < chars.length * charMs + holdMs + eraseMs) {
                const eraseProgress = (t - chars.length * charMs - holdMs) / eraseMs;
                shown = Math.max(0, chars.length - Math.floor(eraseProgress * chars.length));
            }
            else {
                shown = 0;
            }
            // Deterministic cursor blink — same on/off at the same virtual time.
            const cursor = Math.floor(raw / 500) % 2 === 0 ? '|' : '';
            node.textContent = chars.slice(0, shown).join('') + cursor;
        };
        const start = performance.now();
        function step(now) {
            currentSeek?.(now - start);
            raf = requestAnimationFrame(step);
        }
        raf = requestAnimationFrame(step);
    }
    function runHandwriting() {
        // Real per-character handwriting: each glyph is its own <text> element
        // with its own stroke-dasharray. We measure char positions from a single
        // hidden <text> first (the only reliable way to get kerning/spacing
        // matching what the browser would draw) then build per-glyph elements
        // laid out in absolute SVG coordinates. Each glyph is revealed in turn
        // with a small overlap so the effect reads like a continuous pen, not
        // a typewriter.
        originalContent = params.content ?? '';
        node.innerHTML = '';
        if (!originalContent)
            return;
        const color = params.color ?? '#ffffff';
        const fs = params.fontSize ?? 48;
        const ff = params.fontFamily ?? 'inherit';
        const fw = params.fontWeight ?? 400;
        const fst = params.fontStyle ?? 'normal';
        const align = params.textAlign ?? 'left';
        const svgNS = 'http://www.w3.org/2000/svg';
        const svg = document.createElementNS(svgNS, 'svg');
        svg.setAttribute('width', '100%');
        svg.setAttribute('height', '100%');
        const w = Math.max(1, node.clientWidth);
        const h = Math.max(1, node.clientHeight);
        svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
        svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
        svg.style.overflow = 'visible';
        node.appendChild(svg);
        // Step 1 — measure char positions from a hidden full-string render.
        const measure = document.createElementNS(svgNS, 'text');
        measure.setAttribute('x', '0');
        measure.setAttribute('y', '50%');
        measure.setAttribute('dominant-baseline', 'middle');
        measure.setAttribute('text-anchor', 'start');
        measure.setAttribute('visibility', 'hidden');
        measure.style.fontSize = `${fs}px`;
        measure.style.fontFamily = ff;
        measure.style.fontWeight = String(fw);
        measure.style.fontStyle = fst;
        measure.textContent = originalContent;
        svg.appendChild(measure);
        const charPositions = [];
        const charLengths = [];
        let totalWidth = 0;
        try {
            const t = measure;
            totalWidth = t.getComputedTextLength();
            for (let i = 0; i < originalContent.length; i++) {
                const pt = t.getStartPositionOfChar(i);
                charPositions.push(pt.x);
                const nextX = i + 1 < originalContent.length
                    ? t.getStartPositionOfChar(i + 1).x
                    : pt.x + (totalWidth - pt.x);
                charLengths.push(Math.max(1, nextX - pt.x));
            }
        }
        catch {
            totalWidth = w * 0.9;
            const stepW = totalWidth / Math.max(1, originalContent.length);
            for (let i = 0; i < originalContent.length; i++) {
                charPositions.push(i * stepW);
                charLengths.push(stepW);
            }
        }
        const baseX = align === 'center' ? (w - totalWidth) / 2
            : align === 'right' ? (w - totalWidth)
                : 0;
        svg.removeChild(measure);
        const glyphs = [];
        for (let i = 0; i < originalContent.length; i++) {
            const ch = originalContent[i];
            const t = document.createElementNS(svgNS, 'text');
            t.setAttribute('x', String(baseX + charPositions[i]));
            t.setAttribute('y', '50%');
            t.setAttribute('dominant-baseline', 'middle');
            t.setAttribute('text-anchor', 'start');
            t.setAttribute('fill', 'transparent');
            t.setAttribute('stroke', color);
            t.setAttribute('stroke-width', '1');
            t.setAttribute('stroke-linecap', 'round');
            t.setAttribute('stroke-linejoin', 'round');
            t.style.fontSize = `${fs}px`;
            t.style.fontFamily = ff;
            t.style.fontWeight = String(fw);
            t.style.fontStyle = fst;
            t.textContent = ch;
            svg.appendChild(t);
            const len = ch === ' ' ? 0 : Math.max(40, fs * 4 + charLengths[i] * 2.5);
            const gap = len * 4;
            t.style.strokeDasharray = `${len || 1} ${gap || 1}`;
            t.style.strokeDashoffset = String(len || 1);
            glyphs.push({ el: t, len });
        }
        const dur = effectiveDuration();
        const n = originalContent.length;
        const overlap = 0.6;
        const perChar = dur / Math.max(1, overlap * (n - 1) + 1);
        const stagger = perChar * overlap;
        const total = stagger * (n - 1) + perChar;
        const cycle = Math.max(total, dur);
        currentSeek = (raw) => {
            const elapsed = params.loop ? (raw % cycle) : raw;
            for (let i = 0; i < glyphs.length; i++) {
                const g = glyphs[i];
                if (g.len === 0)
                    continue;
                const localStart = i * stagger;
                const t = Math.min(1, Math.max(0, (elapsed - localStart) / perChar));
                const eased = 1 - Math.pow(1 - t, 3);
                g.el.style.strokeDashoffset = String(g.len * (1 - eased));
                if (t > 0.7) {
                    const fillT = (t - 0.7) / 0.3;
                    g.el.setAttribute('fill', color);
                    g.el.setAttribute('fill-opacity', String(fillT));
                }
                else {
                    g.el.setAttribute('fill', 'transparent');
                    g.el.removeAttribute('fill-opacity');
                }
            }
        };
        const start = performance.now();
        function step(now) {
            const elapsed = now - start;
            currentSeek?.(elapsed);
            if (params.loop || elapsed < total)
                raf = requestAnimationFrame(step);
            else
                raf = 0;
        }
        raf = requestAnimationFrame(step);
    }
    function run() {
        restore();
        activeMode = params.mode;
        if (!params.enabled)
            return;
        switch (params.mode) {
            case 'fade-letters':
                runFadeLetters();
                break;
            case 'bounce-in':
                runBounceIn();
                break;
            case 'handwriting':
                runHandwriting();
                break;
            case 'scramble-in':
                runScrambleIn();
                break;
            case 'slot-machine':
                runSlotMachine();
                break;
            case 'drop':
                runDrop();
                break;
            case 'glitch':
                runGlitch();
                break;
            case 'marquee':
                runMarquee();
                break;
            case 'blur-in':
                runBlurIn();
                break;
            case 'stretch':
                runStretch();
                break;
            case 'slide-words':
                runSlideWords();
                break;
            case 'wave':
                runWave();
                break;
            case 'typewriter-erase':
                runTypewriterErase();
                break;
            default: /* other modes handled elsewhere */ break;
        }
    }
    queueMicrotask(run);
    if (typeof window !== 'undefined') {
        const reg = (window.__svgAnimRestart ||= []);
        reg.push(run);
        node.__svgAnimRestart = run;
    }
    let lastKey = params.key;
    return {
        update(p) {
            const keyChanged = p.key !== lastKey || p.mode !== activeMode;
            params = p;
            if (keyChanged) {
                lastKey = p.key;
                queueMicrotask(run);
            }
        },
        destroy() {
            restore();
            if (typeof window !== 'undefined') {
                // Drop both registries (restart + seek) so a destroyed action's
                // closure can be GC'd cleanly.
                const reg = window.__svgAnimRestart;
                const fn = node.__svgAnimRestart;
                if (reg && fn) {
                    const idx = reg.indexOf(fn);
                    if (idx >= 0)
                        reg.splice(idx, 1);
                }
                const seekReg = window.__textAnimSeek;
                if (seekReg) {
                    const idx = seekReg.indexOf(seekWrapper);
                    if (idx >= 0)
                        seekReg.splice(idx, 1);
                }
            }
        }
    };
}
