<?php
if ( ! defined( 'ABSPATH' ) ) exit;
/**
 * Impulse Shared Helpers
 * 
 * Shared functions for rendering canvas-based background animations
 * and sanitizing richtext content.
 * Used by both impulse-preview.php (editor) and impulse-page.php (frontend).
 * 
 * @package Instinctor
 * @since 3.0.44
 */

/**
 * Sanitize richtext editor content - only allows tags the toolbar can create.
 * Strips pasted HTML junk (divs, spans with styles, headings, tables, etc).
 */
/**
 * Detect and convert Markdown to HTML.
 * Only runs when content has no HTML block tags but contains markdown syntax.
 */
function impulse_markdown_to_html( $text ) {
    $lines     = explode( "\n", str_replace( "\r\n", "\n", $text ) );
    $output    = array();
    $in_ul     = false;
    $in_ol     = false;
    $para      = array();

    $close_lists = function() use ( &$in_ul, &$in_ol, &$output ) {
        if ( $in_ul ) { $output[] = '</ul>'; $in_ul = false; }
        if ( $in_ol ) { $output[] = '</ol>'; $in_ol = false; }
    };

    $flush_para = function() use ( &$para, &$output ) {
        if ( ! empty( $para ) ) {
            $text = implode( ' ', $para );
            if ( trim( $text ) !== '' ) {
                $output[] = '<p>' . $text . '</p>';
            }
            $para = array();
        }
    };

    foreach ( $lines as $line ) {
        $raw = rtrim( $line );

        // Blank line — close open blocks
        if ( trim( $raw ) === '' ) {
            $flush_para();
            $close_lists();
            continue;
        }

        // ATX heading: # Title
        if ( preg_match( '/^(#{1,6})[ \t]+(.+)$/', $raw, $m ) ) {
            $flush_para();
            $close_lists();
            $lvl      = strlen( $m[1] );
            $output[] = '<h' . $lvl . '>' . impulse_md_inline( $m[2] ) . '</h' . $lvl . '>';
            continue;
        }

        // Horizontal rule: ---, ***, ___
        if ( preg_match( '/^([-*_])\1{2,}[ \t]*$/', $raw ) ) {
            $flush_para();
            $close_lists();
            $output[] = '<hr>';
            continue;
        }

        // Unordered list item: -, *, +
        if ( preg_match( '/^[-*+][ \t]+(.+)$/', $raw, $m ) ) {
            $flush_para();
            if ( $in_ol ) { $output[] = '</ol>'; $in_ol = false; }
            if ( ! $in_ul ) { $output[] = '<ul>'; $in_ul = true; }
            $output[] = '<li>' . impulse_md_inline( $m[1] ) . '</li>';
            continue;
        }

        // Ordered list item: 1. 2. etc.
        if ( preg_match( '/^\d+\.[ \t]+(.+)$/', $raw, $m ) ) {
            $flush_para();
            if ( $in_ul ) { $output[] = '</ul>'; $in_ul = false; }
            if ( ! $in_ol ) { $output[] = '<ol>'; $in_ol = true; }
            $output[] = '<li>' . impulse_md_inline( $m[1] ) . '</li>';
            continue;
        }

        // Blockquote: > text
        if ( preg_match( '/^>[ \t]*(.*)$/', $raw, $m ) ) {
            $flush_para();
            $close_lists();
            $output[] = '<blockquote>' . impulse_md_inline( $m[1] ) . '</blockquote>';
            continue;
        }

        // Regular text — accumulate into paragraph
        $close_lists();
        $para[] = impulse_md_inline( $raw );
    }

    $flush_para();
    $close_lists();

    return implode( "\n", $output );
}

/**
 * Apply inline markdown: bold, italic, code, links.
 */
function impulse_md_inline( $text ) {
    // HTML-encode first (preserve existing entities)
    $text = htmlspecialchars( $text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false );
    // Bold + italic: ***text***
    $text = preg_replace( '/\*{3}(.+?)\*{3}/', '<strong><em>$1</em></strong>', $text );
    // Bold: **text**
    $text = preg_replace( '/\*{2}(.+?)\*{2}/', '<strong>$1</strong>', $text );
    // Italic: *text* (single asterisk)
    $text = preg_replace( '/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/', '<em>$1</em>', $text );
    // Inline code: `code`
    $text = preg_replace( '/`(.+?)`/', '<code>$1</code>', $text );
    // Links: [label](url)
    $text = preg_replace(
        '/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/',
        '<a href="$2" target="_blank" rel="noopener">$1</a>',
        $text
    );
    return $text;
}

/**
 * Return true if $content looks like Markdown (no HTML block tags, has MD syntax).
 */
function impulse_is_markdown( $content ) {
    // Already has real HTML block tags — treat as HTML, not markdown
    if ( preg_match( '/<(p|div|h[1-6]|ul|ol|li|blockquote|pre|hr|table)\b/i', $content ) ) {
        return false;
    }
    // Needs at least one markdown indicator AT LINE START. The numeric-list
    // pattern `\d+\.[ \t]` was missing its `^` anchor, so any decimal followed
    // by a space anywhere in the content (e.g. "$29.90. ") falsely flagged
    // text as markdown — which then HTML-escaped any inline <a> tags. Anchor
    // every pattern to a line start.
    return (bool) preg_match( '/^#{1,6}[ \t]|^\*{2}|^[-*+][ \t]|^\d+\.[ \t]|^-{3,}[ \t]*$|^>[ \t]/m', $content );
}

function impulse_richtext_allowed() {
    static $allowed = null;
    if ($allowed === null) {
        $allowed = [
            // Inline formatting
            'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [], 's' => [], 'strike' => [], 'del' => [],
            'a' => ['href' => [], 'target' => [], 'rel' => []],
            'br' => [], 'span' => ['style' => []],
            // Block elements (needed when HTML is pasted in HTML mode)
            'p' => ['style' => [], 'class' => []],
            'div' => ['style' => [], 'class' => []],
            'h1' => ['style' => []], 'h2' => ['style' => []], 'h3' => ['style' => []],
            'h4' => ['style' => []], 'h5' => ['style' => []], 'h6' => ['style' => []],
            'ul' => [], 'ol' => [], 'li' => [],
            'blockquote' => [], 'pre' => [], 'code' => [],
            'hr' => [], 'table' => [], 'thead' => [], 'tbody' => [], 'tr' => [], 'th' => ['style' => []], 'td' => ['style' => []],
        ];
    }
    return $allowed;
}

function impulse_kses_richtext($content) {
    // Recognized escaper is wp_kses() itself; renderers that need an inline
    // recognized escaper call wp_kses($x, impulse_richtext_allowed()) directly.
    return wp_kses($content, impulse_richtext_allowed());
}


/**
 * Build a normalized layer config array from raw layer data.
 * 
 * @param array $layer Raw layer data with 'effect', 'settings', etc.
 * @return array Normalized config for ImpulseAnimations.init()
 */
function impulse_build_layer_config($layer) {
    $ls = $layer['settings'] ?? [];

    // Typed defaults for the common keys, used ONLY to fill gaps below.
    // History: this used to be an exclusive allowlist that hand-copied a
    // fixed set of keys out of $ls. Any effect param the list didn't name
    // was silently dropped on the server-rendered (initial) paint — the
    // value sat in the DB and in the panel slider, so it "reappeared" only
    // after the user nudged the slider, because the editor's live-update
    // path (admin-core animSaveAndUpdate) passes settings through with NO
    // whitelist. Ring Trace's radius_px / origin_angle / hold_pct /
    // gradient_spin / delay / stroke_type / loop / show_head — and EVERY
    // effect's per-device *_tablet / *_mobile overrides — fell through that
    // gap. Now we pass all saved keys through and only fill the unset
    // common keys from these defaults, so first paint matches the live
    // update. Sergei 2026-05-28.
    static $defaults = [
        'origin_x' => 50, 'origin_y' => 50, 'color' => '#3b82f6', 'line_color' => '',
        'speed' => 1, 'ring_count' => 5, 'thickness' => 2, 'spacing' => 50,
        'line_count' => 12, 'dash_length' => 20, 'gap' => 15, 'arm_count' => 3,
        'tightness' => 5, 'max_size' => 80, 'ray_count' => 16, 'thickness_decay' => 0,
        'opacity_decay' => 0, 'life' => 3, 'count' => 30, 'min_size' => 20,
        'softness' => 50, 'complexity' => 5, 'spread' => 50, 'direction' => 'down',
        'wobble' => 30, 'amplitude' => 40, 'frequency' => 2, 'band_count' => 3,
        'band_width' => 80, 'position' => 'bottom', 'wave_radius' => 150,
        'cell_size' => 30, 'glow' => 30, 'link_distance' => 120, 'wave_count' => 3,
        'color_variation' => 0, 'opacity_variation' => 0, 'depth_blur' => 0,
        'size' => 3, 'radius' => 80, 'fill_mode' => 'wave',
        // Burst line decay (burst); concentric depth parallax (concentric).
        // Gradient is implicit: renderer enables it when color_2 has visible
        // alpha — no boolean flag.
        'line_decay' => 0, 'depth_3d' => 0,
        'color_2' => 'rgba(0,0,0,0)',
        // Lava uses 4 stops; other effects ignore color_3/color_4.
        'color_3' => 'rgba(0,0,0,0)', 'color_4' => 'rgba(0,0,0,0)',
        // mouse_reactivity → concentric depth parallax; gradient_mouse →
        // cursor gradient rotation; mouse_sens → lava u_mousePosition.
        'mouse_reactivity' => 0, 'gradient_mouse' => 0, 'mouse_sens' => 5,
        // Booleans.
        'fill' => false, 'show_rings' => true, 'show_outline' => true,
    ];

    $config = [];

    // 1. Pass through every saved key — effect-specific params and per-device
    //    *_tablet / *_mobile overrides included. Numeric strings become
    //    numbers; booleans and non-numeric strings are preserved as-is.
    //    Mirrors the editor's no-whitelist live-update serialisation.
    foreach ($ls as $k => $v) {
        if (is_string($v) && $v !== '' && is_numeric($v)) {
            $config[$k] = $v + 0; // int or float
        } else {
            $config[$k] = $v;
        }
    }

    // 2. Fill any common key the user never set (absent / blank) with its
    //    typed default, so downstream effects keep their sane baselines.
    foreach ($defaults as $k => $dv) {
        if (!array_key_exists($k, $config) || $config[$k] === '' || $config[$k] === null) {
            $config[$k] = $dv;
        }
    }

    // 3. effect lives at the layer level, not inside settings.
    $config['effect'] = $layer['effect'] ?? 'rings';

    return $config;
}

/**
 * Render a section-scoped motion background (currently lava only).
 *
 * Page mode doesn't carry the screen_cloud_effect machinery (that's scene
 * mode's job), but the user occasionally wants a single hero section with
 * a WebGL Lava bg. This helper emits a canvas absolutely positioned
 * behind the section content + an inline init script that spawns the
 * standalone ImpulseMotionLava engine on it. Polls for the engine global
 * in case the script tag hasn't executed yet at parse time.
 *
 * Renders an outer wrapper at z-index 0 (sits behind .impulse-container
 * which is z-index 1 in the section stack) so existing content overlays
 * cleanly. position:absolute, inset:0 — fills whatever parent height the
 * section has been laid out to.
 *
 * Currently only `lava` is supported; other motion_bg values are no-ops.
 * Add more cases (wave / 3d / magma) here if the need arises.
 *
 * @param array  $settings   Section settings array (reads motion_bg* keys).
 * @param string $elementId  Section element ID (for unique canvas/wrapper id).
 */
function impulse_render_section_motion_bg($settings, $elementId) {
    $mb = $settings['motion_bg'] ?? '';
    if ($mb !== 'lava') return;

    $c1   = $settings['motion_bg_color1'] ?? '#ef008f';
    $c2   = $settings['motion_bg_color2'] ?? '#6ec3f4';
    $c3   = $settings['motion_bg_color3'] ?? '#7038ff';
    $c4   = $settings['motion_bg_color4'] ?? '#ffba27';
    $spd  = isset($settings['motion_bg_speed']) ? floatval($settings['motion_bg_speed']) : 1.0;

    $wrapId   = 'imp-motion-bg-' . $elementId;
    $canvasId = $wrapId . '-c';

    // z-index 0 places this BEHIND .impulse-container (z-index 1) so
    // section children stay readable. The wrapper inherits the section's
    // border-radius via inherit so the lava is clipped to the section's
    // rounded corners if any.
    echo '<div class="impulse-motion-bg" id="' . esc_attr($wrapId) . '" data-motion-bg="lava"'
       . ' data-c1="' . esc_attr($c1) . '" data-c2="' . esc_attr($c2) . '"'
       . ' data-c3="' . esc_attr($c3) . '" data-c4="' . esc_attr($c4) . '"'
       . ' data-speed="' . esc_attr((string) $spd) . '"'
       . ' style="position:absolute;inset:0;z-index:0;pointer-events:none;overflow:hidden;border-radius:inherit">';
    echo '<canvas id="' . esc_attr($canvasId) . '" style="display:block;width:100%;height:100%"></canvas>';
    echo '</div>';

    // Inline init — polls for ImpulseMotionLava in case the engine script
    // hasn't run yet. Stores the instance on the wrapper element so we
    // can find it later (live preview updates, destroy on unmount).
    //
    // CRITICAL — DO NOT touch canvas.width/canvas.height directly here.
    // Writing those properties RESETS the WebGL context (clears bindings,
    // uniforms, viewport — everything). The engine's constructor calls
    // resize() itself which sets the correct dpr-scaled buffer size and
    // rebuilds the plane geometry. We only call inst.resize() if the
    // wrapper resizes after init — never poke canvas.width.
    $_lavaJs = '(function(){'
       . 'var _wid=' . wp_json_encode($wrapId) . ',_cid=' . wp_json_encode($canvasId) . ',_n=0;'
       . 'function _g(){'
       . 'var w=document.getElementById(_wid),c=document.getElementById(_cid);'
       . 'if(!w||!c)return;'
       . 'if(typeof ImpulseMotionLava==="undefined"){if(_n++<200)setTimeout(_g,30);return;}'
       . 'try{'
       . 'var inst=new ImpulseMotionLava(c,{colors:[w.dataset.c1,w.dataset.c2,w.dataset.c3,w.dataset.c4],speed:parseFloat(w.dataset.speed)||1});'
       . 'inst.play();'
       . 'w._impMotionInst=inst;'
       . 'if(typeof ResizeObserver!=="undefined"){'
       . 'var _rTimer=null;'
       . 'new ResizeObserver(function(){if(_rTimer)clearTimeout(_rTimer);_rTimer=setTimeout(function(){if(inst&&typeof inst.resize==="function"&&w.offsetWidth>0&&w.offsetHeight>0)inst.resize();},80);}).observe(w);'
       . '}'
       . '}catch(e){}'
       . '}_g();'
       . '})();';
    if (function_exists('wp_add_inline_script')) wp_add_inline_script('instinctor-frontend-js', $_lavaJs, 'after');
}

/**
 * Render animation layers (HTML wrappers + canvas + init scripts).
 *
 * @param array  $animLayers  Array of layer configs from settings.
 * @param string $idPrefix    DOM ID prefix (e.g. 'section-anim-123' or 'page-anim').
 * @param bool   $fullscreen  true = position:fixed viewport, false = position:absolute within parent.
 * @param bool   $isPreview   true = editor preview iframe, false = frontend.
 */
function impulse_render_animation_layers($animLayers, $idPrefix, $fullscreen, $isPreview) {
    if (empty($animLayers)) return;
    
    if (is_string($animLayers)) {
        $animLayers = json_decode($animLayers, true) ?: [];
    }
    if (empty($animLayers)) return;
    
    $baseStyle = $fullscreen
        ? 'position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;overflow:hidden'
        : 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;overflow:hidden';
    
    $cssClass = $fullscreen ? 'impulse-page-animation' : 'impulse-section-animation';
    
    // Render HTML wrappers with canvas
    foreach ($animLayers as $idx => $layer) {
        // Per-layer visibility flag (eye-icon toggle). Hidden layers stay in
        // the data structure (so the toggle is non-destructive) but neither
        // wrapper nor init script is emitted.
        if (isset($layer['_visible']) && $layer['_visible'] === false) continue;
        // No effect picked → no animation. The user must explicitly choose an
        // effect button before anything renders. Without this, toggling the
        // section-animation checkbox or adding an empty layer would auto-start
        // a default 'rings' effect, which surprised users.
        $layerEffect = $layer['effect'] ?? '';
        if ($layerEffect === '' || $layerEffect === 'none') continue;
        // Per-effect tier check — free bundled environments always render,
        // others require active platform account (skip silently on free tier)
        if (!$isPreview && instinctor_env_locked($layerEffect)) {
            continue;
        }
        // Lava is a WebGL effect implemented by impulse-motion-lava.js
        // (separate engine, used by scene-mode cloud effects). Flag the
        // asset so the page footer enqueues it — without this the lava
        // layer would silently no-op when impulse-animations.js checks
        // for `typeof ImpulseMotionLava === 'function'`.
        if ($layerEffect === 'lava') {
            $GLOBALS['impulse_needs_motion_lava'] = true;
        }
        $opacity = floatval($layer['opacity'] ?? 100) / 100;
        $blend = $layer['blend_mode'] ?? 'normal';
        $zIndex = intval($layer['z_index'] ?? 0);
        $layerAnimId = $idPrefix . '-' . $idx;
        echo '<div class="' . esc_attr($cssClass) . '" id="' . esc_attr($layerAnimId) . '"';
        if (!$isPreview && $zIndex !== 0) echo ' data-depth="' . (int) $zIndex . '"';
        echo ' style="' . esc_attr($baseStyle . ';z-index:' . $zIndex . ';opacity:' . $opacity . ';mix-blend-mode:' . $blend) . '">';
        echo '<canvas data-layer="0" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none"></canvas>';
        echo '</div>';
    }
    
    // Render init scripts
    foreach ($animLayers as $layerIdx => $layer) {
        if (isset($layer['_visible']) && $layer['_visible'] === false) continue;
        $layerEffect = $layer['effect'] ?? '';
        if ($layerEffect === '' || $layerEffect === 'none') continue;
        if (!$isPreview && instinctor_env_locked($layerEffect)) {
            continue;
        }
        $layerConfig = impulse_build_layer_config($layer);
        $layerAnimId = $idPrefix . '-' . $layerIdx;
        $jsConfig = wp_json_encode(['layers' => [$layerConfig], 'fullscreen' => $fullscreen]);
        if ($jsConfig !== false) {
            // Wait for ImpulseAnimations to load — frontend defers the script,
            // so a direct call would throw "ImpulseAnimations is not defined"
            // and the canvas would sit empty. Poll until the module lands, then
            // init. Editor doesn't need this guard but the helper is shared.
            $_layerJs = '(function(){var _id="' . esc_js($layerAnimId) . '",_c=' . $jsConfig . ',_p=' . ($isPreview ? 'true' : 'false') . ',_n=0;function _g(){if(window.ImpulseAnimations){ImpulseAnimations.init(_id,_c,_p);}else if(_n++<100){setTimeout(_g,40);}}_g();})();';
            if (function_exists('wp_add_inline_script')) wp_add_inline_script('instinctor-frontend-js', $_layerJs, 'after');
        }
    }
}

/**
 * Per-child entrance-animation attributes for "decompose" blocks (gig_grid,
 * and any future repeating-children block that should fly its children in
 * separately on scroll reveal rather than as a single flat object).
 *
 * Mirrors impulse_get_animation_attrs() in templates/impulse-page.php but
 * adds a per-index stagger (idx * $staggerSec) to animation-delay so cards
 * trigger in sequence once the wrapper hits the IntersectionObserver
 * threshold. Returns ['class','style'] ready to slot into the child element.
 *
 * Defined in the shared helpers file so both the frontend renderer
 * (templates/impulse-page.php) and the editor preview renderer
 * (admin/views/impulse-preview.php) can call it without duplicating the
 * keyframe-name mapping or the intensity → CSS-var math.
 *
 *   $settings    parent block settings (panel-driven entrance_animation +
 *                entrance_intensity + animation_duration + animation_delay
 *                + unveil_direction).
 *   $idx         zero-based child index.
 *   $staggerSec  seconds between successive children. 80ms reads as a
 *                lively-but-not-rushed cascade at typical card sizes.
 *
 * Returns ['class' => '', 'style' => ''] when entrance is 'none' so the
 * caller can spread the result unconditionally.
 */
function impulse_decompose_attrs(array $settings, int $idx, float $staggerSec = 0.08): array
{
    $animation = $settings['entrance_animation'] ?? 'none';
    if ($animation === '' || $animation === 'none') return ['class' => '', 'style' => ''];

    $duration  = floatval($settings['animation_duration'] ?? 0.6);
    $baseDelay = floatval($settings['animation_delay']    ?? 0);
    $intensity = intval($settings['entrance_intensity']   ?? 50);
    $t = $intensity / 100;

    $style = '';
    if ($animation === 'emerge') {
        $style .= '--ea-scale:' . number_format(1 - ($t * 0.2), 3) . ';';
        $style .= '--ea-blur:'  . number_format($t * 8, 1) . 'px;';
        $animName = 'impulse-emerge';
    } elseif ($animation === 'surface') {
        $style .= '--ea-depth:' . intval(-($t * 120)) . 'px;';
        $animName = 'impulse-surface';
    } elseif ($animation === 'unveil') {
        $dir = $settings['unveil_direction'] ?? 'up';
        $animName = 'impulse-unveil-' . preg_replace('/[^a-z]/', '', strtolower($dir));
    } elseif ($animation === 'resolve') {
        $style .= '--ea-blur:' . number_format($t * 20, 1) . 'px;';
        $animName = 'impulse-resolve';
    } else {
        // Legacy preset names — same fallback map as impulse_get_animation_attrs.
        $legacyMap = [
            'fade'     => 'fade-in',
            'rise'     => 'surface',
            'fall'     => 'surface',
            'dissolve' => 'fade-in',
            'zoom'     => 'emerge',
        ];
        $animName = 'impulse-' . ($legacyMap[$animation] ?? $animation);
    }

    $delay = $baseDelay + ($idx * $staggerSec);
    $style .= 'animation-name:' . $animName . ';';
    $style .= 'animation-duration:' . $duration . 's;';
    $style .= 'animation-timing-function:cubic-bezier(0.16, 1, 0.3, 1);';
    $style .= 'animation-delay:' . number_format($delay, 3) . 's;';
    // animation-play-state:paused matches the wrapper-level path — the
    // IntersectionObserver in impulse-interactions.js toggles .impulse-visible
    // which flips this to `running`. Without it the cards fire on page load
    // (before the user has scrolled to them) and the entrance is missed.
    $style .= 'animation-play-state:paused;';

    return ['class' => 'impulse-animated', 'style' => $style];
}

/**
 * Output JS for advanced hover effects.
 * Called inside <script> tags from frontend / preview renderers (Image block).
 */

/**
 * Output all hover effect CSS for the image block.
 * Call once per page in the <style> block.
 * Image context:   .impulse-img-hover[data-hover="EFFECT"]
 */
function impulse_effects_css() {
    // ── CSS-only effects (image block via data-hover) ─────────────────────
    echo '.impulse-img-hover{position:relative;display:block}';
    // CSS effects that clip their content need overflow:hidden on the wrapper
    echo '.impulse-img-hover[data-hover="shine"],.impulse-img-hover[data-hover="zoom"],.impulse-img-hover[data-hover="tilt"],.impulse-img-hover[data-hover="blur"],.impulse-img-hover[data-hover="portal"],.impulse-img-hover[data-hover="slices"],.impulse-img-hover[data-hover="chromatic"],.impulse-img-hover[data-hover="thermal"],.impulse-img-hover[data-hover="burn"],.impulse-img-hover[data-hover="glitch"],.impulse-img-hover[data-hover="hologram"],.impulse-img-hover[data-hover="pixelate"]{overflow:hidden}';
    echo '.impulse-img-hover[data-hover="shine"]::before{content:\'\';position:absolute;top:0;left:-75%;width:50%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.3),transparent);transform:skewX(-25deg);transition:none;pointer-events:none;z-index:2}';
    echo '.impulse-img-hover[data-hover="shine"]:hover::before{animation:impulse-img-shine 1s ease forwards}';
    echo '@keyframes impulse-img-shine{0%{left:-75%}100%{left:125%}}';
    echo '.impulse-img-hover[data-hover="zoom"] img{transition:transform 0.8s ease}';
    echo '.impulse-img-hover[data-hover="zoom"]:hover img{transform:scale(1.08)}';
    echo '.impulse-img-hover[data-hover="tilt"]{perspective:600px}';
    echo '.impulse-img-hover[data-hover="tilt"] img{transition:transform 0.7s ease}';
    echo '.impulse-img-hover[data-hover="tilt"]:hover img{transform:rotateY(4deg) rotateX(2deg) scale(1.03)}';
    echo '.impulse-img-hover[data-hover="blur"] img{filter:blur(8px) brightness(0.9);transform:scale(1.04);transition:filter 0.6s ease,transform 0.6s ease}';
    echo '.impulse-img-hover[data-hover="blur"]:hover img{filter:blur(0px) brightness(1);transform:scale(1)}';
    // JS-driven effects need overflow:visible on the wrapper
    echo '.impulse-img-hover[data-hover="tilt3d"]{overflow:visible!important}';
    echo '.impulse-img-hover[data-hover="flip3d"]{overflow:visible!important;transform-style:preserve-3d;cursor:pointer}';
    echo '.impulse-img-hover[data-hover="magnetic"]{overflow:visible!important}';

    // Cell Grid and Freeform cells clip slices/magnetic via overflow:hidden — override with :has()
    echo '.impulse-rows-cell:has([data-hover="slices"]),.impulse-rows-cell:has([data-hover="magnetic"]),.impulse-layout-cell:has([data-hover="slices"]),.impulse-layout-cell:has([data-hover="magnetic"]){overflow:visible!important}';
    echo '.impulse-cell-content>.impulse-el[data-type="image"]:has([data-hover="slices"]),.impulse-cell-content>.impulse-el[data-type="image"]:has([data-hover="magnetic"]){overflow:visible!important}';
}

/** List of effects that require JS (not CSS-only). Used by the image renderer. */
function impulse_js_effects() {
    return ['depth_glass', 'pixelate', 'glitch', 'hologram', 'portal',
            'tilt3d', 'flip3d', 'slices', 'chromatic', 'magnetic',
            'thermal', 'burn', 'preview'];
}

/** Effects where the item wrapper needs overflow:visible (for image block). */
function impulse_overflow_visible_effects() {
    return ['tilt3d', 'flip3d', 'magnetic'];
}

function impulse_effect_js($selector, $effect, $itemSelector = '.impulse-img-hover') {
    echo 'setTimeout(function(){try{';
    echo 'var g=document.querySelector(' . wp_json_encode($selector) . ');';
    echo 'if(!g)return;';
    echo 'var items=g.querySelectorAll(' . wp_json_encode($itemSelector) . ');if(!items.length)return;';
    
    switch ($effect) {
        case 'depth_glass':
            echo <<<'JS'
var depths=[];
g.style.perspective='1200px';
g.style.perspectiveOrigin='50% 50%';
items.forEach(function(item,i){
    var z=-20-Math.random()*100;
    depths.push(z);
    item.style.transform='translateZ('+z+'px)';
    item.style.willChange='transform';
    item.dataset.dg=z;
    item.addEventListener('mouseenter',function(){
        this.style.transition='transform 1.4s cubic-bezier(0.16,1,0.3,1),box-shadow 1.4s ease';
        this.style.transform='translateZ(30px) scale(1.03)';
        this.style.boxShadow='0 25px 80px rgba(0,0,0,0.35)';
        this.style.zIndex='10';
    });
    item.addEventListener('mouseleave',function(){
        this.style.transition='transform 1.4s cubic-bezier(0.16,1,0.3,1),box-shadow 1.4s ease';
        this.style.transform='translateZ('+z+'px)';
        this.style.boxShadow='';
        this.style.zIndex='';
    });
});
g.addEventListener('mousemove',function(e){
    var r=g.getBoundingClientRect();
    var mx=(e.clientX-r.left)/r.width-0.5;
    var my=(e.clientY-r.top)/r.height-0.5;
    g.style.perspectiveOrigin=(50+mx*30)+'% '+(50+my*30)+'%';
});
g.addEventListener('mouseleave',function(){
    g.style.perspectiveOrigin='50% 50%';
});
JS;
            break;
            
        case 'pixelate':
            echo <<<'JS'
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    var c=document.createElement('canvas');
    c.style.cssText='position:absolute;inset:0;width:100%;height:100%;image-rendering:pixelated;image-rendering:crisp-edges;pointer-events:none;z-index:1';
    item.appendChild(c);
    var ctx=c.getContext('2d');
    var ok=true;
    function draw(lv){
        if(!ok)return;
        var w=Math.max(4,lv);
        var asp=img.naturalWidth/img.naturalHeight||1;
        var h=Math.max(4,Math.round(w/asp));
        c.width=w;c.height=h;
        ctx.imageSmoothingEnabled=false;
        try{ctx.drawImage(img,0,0,w,h);}catch(e){ok=false;c.style.display='none';}
    }
    function init(){if(img.naturalWidth)draw(6);}
    if(img.complete)init();else img.addEventListener('load',init);
    var tid,curLv=6;
    item.addEventListener('mouseenter',function(){
        clearInterval(tid);
        tid=setInterval(function(){
            curLv=Math.min(curLv*2,512);
            if(curLv>=256){c.style.opacity='0';clearInterval(tid);return;}
            draw(curLv);
        },50);
    });
    item.addEventListener('mouseleave',function(){
        clearInterval(tid);
        c.style.opacity='1';
        curLv=256;
        tid=setInterval(function(){
            curLv=Math.max(Math.floor(curLv/2),6);
            draw(curLv);
            if(curLv<=6)clearInterval(tid);
        },50);
    });
    c.style.transition='opacity 0.15s';
});
JS;
            break;
            
        case 'glitch':
            echo <<<'JS'
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    function initGlitch(){
        var url=img.src;
        for(var i=0;i<2;i++){
            var l=document.createElement('div');
            l.className='impulse-glitch-l';
            l.style.cssText='position:absolute;inset:0;background:url('+url+') center/cover no-repeat;pointer-events:none;z-index:2;opacity:0';
            item.appendChild(l);
        }
        var layers=item.querySelectorAll('.impulse-glitch-l');
        var raf,active=true;
        function loop(){
            if(!active)return;
            layers.forEach(function(l){
                if(Math.random()>0.65){
                    var t=Math.random()*80;
                    var h=5+Math.random()*25;
                    var ox=(Math.random()-0.5)*24;
                    l.style.clipPath='inset('+t+'% 0 '+(100-t-h)+'% 0)';
                    l.style.transform='translateX('+ox+'px)';
                    l.style.opacity='0.85';
                    l.style.filter=Math.random()>0.5?'hue-rotate('+(Math.random()*60-30)+'deg)':'none';
                }else{
                    l.style.opacity='0';
                }
            });
            raf=setTimeout(function(){requestAnimationFrame(loop);},80+Math.random()*180);
        }
        loop();
        item.addEventListener('mouseenter',function(){
            active=false;clearTimeout(raf);
            layers.forEach(function(l){l.style.opacity='0';l.style.clipPath='';l.style.transform='';});
        });
        item.addEventListener('mouseleave',function(){
            active=true;loop();
        });
    }
    if(img.complete&&img.naturalWidth)initGlitch();
    else img.addEventListener('load',initGlitch);
});
JS;
            break;
            
        case 'hologram':
            echo <<<'JS'
var uid='holo-'+Math.random().toString(36).substr(2,6);
var svgNS='http://www.w3.org/2000/svg';
var svg=document.createElementNS(svgNS,'svg');
svg.style.cssText='position:absolute;width:0;height:0;overflow:hidden';
svg.innerHTML='<filter id="'+uid+'-r"><feColorMatrix values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/></filter><filter id="'+uid+'-b"><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0"/></filter>';
g.appendChild(svg);
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    function initHolo(){
        var url=img.src;
        var chR=document.createElement('div');
        chR.style.cssText='position:absolute;inset:0;background:url('+url+') center/cover no-repeat;pointer-events:none;z-index:2;mix-blend-mode:multiply;opacity:0.6;transform:translate(3px,-1px);transition:transform 0.9s ease,opacity 0.9s ease;filter:url(#'+uid+'-r)';
        var chB=document.createElement('div');
        chB.style.cssText='position:absolute;inset:0;background:url('+url+') center/cover no-repeat;pointer-events:none;z-index:2;mix-blend-mode:screen;opacity:0.5;transform:translate(-3px,1px);transition:transform 0.9s ease,opacity 0.9s ease;filter:url(#'+uid+'-b)';
        var scan=document.createElement('div');
        scan.style.cssText='position:absolute;inset:0;pointer-events:none;z-index:3;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.06) 2px,rgba(0,0,0,0.06) 4px);transition:opacity 0.9s ease';
        item.appendChild(chR);item.appendChild(chB);item.appendChild(scan);
        img.style.transition='filter 0.9s ease';
        img.style.filter='saturate(0.6) brightness(0.9)';
        item.addEventListener('mouseenter',function(){
            chR.style.transform='translate(0,0)';chR.style.opacity='0';
            chB.style.transform='translate(0,0)';chB.style.opacity='0';
            scan.style.opacity='0';
            img.style.filter='saturate(1) brightness(1)';
        });
        item.addEventListener('mouseleave',function(){
            chR.style.transform='translate(3px,-1px)';chR.style.opacity='0.6';
            chB.style.transform='translate(-3px,1px)';chB.style.opacity='0.5';
            scan.style.opacity='1';
            img.style.filter='saturate(0.6) brightness(0.9)';
        });
    }
    if(img.complete&&img.naturalWidth)initHolo();
    else img.addEventListener('load',initHolo);
});
JS;
            break;
            
        case 'portal':
            echo <<<'JS'
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    item.style.perspective='800px';
    item.style.overflow='hidden';
    // Vignette overlay — heavy at rest, lifts on hover
    var vig=document.createElement('div');
    vig.style.cssText='position:absolute;inset:0;pointer-events:none;z-index:2;box-shadow:inset 0 0 70px rgba(0,0,0,0.6);transition:box-shadow 0.65s ease;border-radius:inherit';
    item.appendChild(vig);
    // Image zoomed-in at rest (always fills container), eases to normal on hover
    img.style.transition='transform 0.65s cubic-bezier(0.16,1,0.3,1),filter 0.55s ease';
    img.style.filter='brightness(0.72) saturate(0.75)';
    img.style.transform='scale(1.12)';
    img.style.transformOrigin='center center';
    img.style.willChange='transform';
    item.addEventListener('mouseenter',function(){
        img.style.transform='scale(1.0)';
        img.style.filter='brightness(1) saturate(1)';
        vig.style.boxShadow='inset 0 0 18px rgba(0,0,0,0.08)';
    });
    item.addEventListener('mouseleave',function(){
        img.style.transform='scale(1.12)';
        img.style.filter='brightness(0.72) saturate(0.75)';
        vig.style.boxShadow='inset 0 0 70px rgba(0,0,0,0.6)';
    });
});
JS;
            break;

        case 'tilt3d':
            echo <<<'JS'
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    // Card wrapper — preserve-3d so child layers (shine, edge glow) orbit with the rotation.
    var card=document.createElement('div');
    card.style.cssText='position:relative;width:100%;overflow:hidden;border-radius:inherit;transform-style:preserve-3d;will-change:transform,box-shadow;';
    var imgParent=img.parentNode;
    var child=(imgParent&&imgParent.tagName==='A'&&imgParent.parentNode===item)?imgParent:img;
    item.insertBefore(card, child);
    card.appendChild(child);
    img.style.display='block';
    img.style.width='100%';
    img.style.height='auto';
    item.style.overflow='visible';
    item.style.perspective='1400px';
    var overlay=item.querySelector('.impulse-image-overlay');
    if(overlay) card.appendChild(overlay);
    // Inner specular layer — sits in front, follows cursor like light hitting the surface.
    var shine=document.createElement('div');
    shine.style.cssText='position:absolute;inset:0;pointer-events:none;border-radius:inherit;opacity:0;mix-blend-mode:overlay;transform:translateZ(24px);transition:opacity 0.25s ease;';
    card.appendChild(shine);
    // Subtle edge rim that reveals at tilt — sells the card as a thick object.
    var rim=document.createElement('div');
    rim.style.cssText='position:absolute;inset:0;pointer-events:none;border-radius:inherit;opacity:0;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.22);transition:opacity 0.25s ease;';
    card.appendChild(rim);
    // Target + current state eased via RAF so mouse jitter doesn't chatter the tilt.
    var tdx=0,tdy=0,cdx=0,cdy=0,hov=false,rafId=null;
    var MAX_Y=18,MAX_X=14,SCALE=1.06;
    function ease(){
        cdx+=(tdx-cdx)*0.12;
        cdy+=(tdy-cdy)*0.12;
        var mag=Math.sqrt(cdx*cdx+cdy*cdy);
        var rotY=cdx*MAX_Y,rotX=-cdy*MAX_X;
        var sc=hov?SCALE:1;
        card.style.transform='rotateY('+rotY.toFixed(2)+'deg) rotateX('+rotX.toFixed(2)+'deg) translateZ('+(hov?20:0)+'px) scale('+sc+')';
        // Shadow anchors to cursor direction — cast from the card toward the opposite corner.
        var shOx=(-cdx*36).toFixed(1),shOy=(-cdy*28).toFixed(1);
        var shBlur=(26+mag*48).toFixed(1);
        var shAlpha=hov?(0.30+mag*0.35).toFixed(2):'0';
        card.style.boxShadow=shOx+'px '+shOy+'px '+shBlur+'px rgba(0,0,0,'+shAlpha+')';
        // Shine tracks cursor — brightest under the pointer, falls off radially.
        shine.style.background='radial-gradient(circle at '+(50+cdx*100)+'% '+(50+cdy*100)+'%,rgba(255,255,255,0.55) 0%,rgba(255,255,255,0.08) 35%,transparent 70%)';
        var settled=hov?(Math.abs(tdx-cdx)<0.005&&Math.abs(tdy-cdy)<0.005):(Math.abs(cdx)<0.005&&Math.abs(cdy)<0.005);
        if(!settled){rafId=requestAnimationFrame(ease);}else{rafId=null;if(!hov){card.style.transform='rotateY(0deg) rotateX(0deg) translateZ(0) scale(1)';card.style.boxShadow='none';}}
    }
    function kick(){if(rafId===null)rafId=requestAnimationFrame(ease);}
    item.addEventListener('mouseenter',function(){hov=true;shine.style.opacity='1';rim.style.opacity='1';kick();});
    item.addEventListener('mousemove',function(e){
        var r=item.getBoundingClientRect();
        tdx=(e.clientX-r.left)/r.width-0.5;
        tdy=(e.clientY-r.top)/r.height-0.5;
        kick();
    });
    item.addEventListener('mouseleave',function(){hov=false;tdx=0;tdy=0;shine.style.opacity='0';rim.style.opacity='0';kick();});
});
JS;
            break;

        case 'slices':
            echo <<<'JS'
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    var inited=false;
    function getSize(){
        var w=img.offsetWidth,h=img.offsetHeight;
        if(!w||!h){var r=img.getBoundingClientRect();w=Math.round(r.width);h=Math.round(r.height);}
        // fill_container inside flex/cell-grid cells: height:100% chain collapses to 0; walk up DOM
        if(w&&!h){var el=item;while(el&&!h){el=el.parentElement;if(el)h=el.offsetHeight||0;}}
        return {w:w,h:h};
    }
    function init(){
        if(inited)return;
        var sz=getSize();
        if(!sz.w||!sz.h)return; // wait for retry
        inited=true;
        var w=sz.w,h=sz.h;
        // Position wrap exactly over img (not at item 0,0) — item may have padding/border
        // or extra layout that offsets img; a mismatch causes visible vertical/horizontal shift.
        var ox=img.offsetLeft||0, oy=img.offsetTop||0;
        var n=8,sh=h/n,bp=getComputedStyle(img).objectPosition||'50% 50%';
        var strips=[],wrap=document.createElement('div');
        wrap.style.cssText='position:absolute;left:'+ox+'px;top:'+oy+'px;width:'+w+'px;height:'+h+'px;z-index:2;pointer-events:none;';
        for(var i=0;i<n;i++){
            var s=document.createElement('div');
            s.style.cssText='position:absolute;left:0;right:0;top:'+(i*sh)+'px;height:'+sh+'px;overflow:hidden;will-change:transform;';
            var si=img.cloneNode(false);
            si.removeAttribute('class');si.removeAttribute('loading');
            si.style.cssText='position:absolute;left:0;top:-'+(i*sh)+'px;width:'+w+'px;height:'+h+'px;object-fit:cover;object-position:'+bp+';display:block;filter:none;';
            s.appendChild(si);
            wrap.appendChild(s);
            var dir=(i%2===0?1:-1);
            s.style.transform='translateX('+(dir*22)+'px)';
            strips.push({el:s,dir:dir});
        }
        item.appendChild(wrap);
        // Use visibility (not opacity) to hide the source img — keeps layout intact AND
        // guarantees the strip overlay sits exactly where the img was, no reflow shift.
        img.style.visibility='hidden';
        item.addEventListener('mouseenter',function(){
            strips.forEach(function(st,i){
                st.el.style.transition='transform 0.45s cubic-bezier(0.16,1,0.3,1) '+(i*18)+'ms';
                st.el.style.transform='translateX(0)';
            });
            setTimeout(function(){img.style.visibility='visible';wrap.style.display='none';},340);
        });
        item.addEventListener('mouseleave',function(){
            wrap.style.display='';
            img.style.visibility='hidden';
            strips.forEach(function(st){
                st.el.style.transition='none';
                st.el.style.transform='translateX('+(st.dir*22)+'px)';
            });
        });
    }
    // Retry with backoff until dimensions are available (needed when inside flex cells)
    function tryInit(attempt){
        if(inited)return;
        init();
        if(!inited&&attempt<15)setTimeout(function(){tryInit(attempt+1);},50+attempt*30);
    }
    if(img.complete&&img.naturalWidth)tryInit(0);
    else img.addEventListener('load',function(){tryInit(0);});
});
JS;
            break;

        case 'flip3d':
            echo <<<'JS'
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    // Lock item dimensions before reparenting img (prevents collapse when img leaves flow)
    var r=item.getBoundingClientRect();
    if(r.width&&r.height){
        item.style.width=r.width+'px';
        item.style.height=r.height+'px';
    }
    item.style.perspective='900px';
    item.style.overflow='visible';
    var front=document.createElement('div');
    front.style.cssText='position:absolute;inset:0;backface-visibility:hidden;overflow:hidden;border-radius:inherit;';
    // Move img (or its anchor parent) into front face
    var imgParent=img.parentNode;
    var frontChild=(imgParent&&imgParent.tagName==='A'&&imgParent.parentNode===item)?imgParent:img;
    item.insertBefore(front,frontChild);
    front.appendChild(frontChild);
    // Ensure img fills front face
    img.style.width='100%';
    img.style.height='100%';
    img.style.objectFit='cover';
    img.style.display='block';
    var back=document.createElement('div');
    back.style.cssText='position:absolute;inset:0;backface-visibility:hidden;overflow:hidden;border-radius:inherit;transform:rotateY(180deg);background:#1d2327;display:flex;align-items:center;justify-content:center;';
    var backImg=img.cloneNode(false);
    backImg.removeAttribute('class');
    backImg.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;filter:brightness(0.5) blur(4px);transform:scaleX(-1);';
    var label=document.createElement('div');
    label.style.cssText='position:relative;z-index:1;color:#fff;font-size:13px;font-weight:500;letter-spacing:0.04em;text-shadow:0 1px 3px rgba(0,0,0,0.6);text-align:center;pointer-events:none;';
    label.textContent=img.alt||'';
    back.appendChild(backImg);
    back.appendChild(label);
    var inner=document.createElement('div');
    inner.style.cssText='position:absolute;inset:0;transform-style:preserve-3d;transition:transform 1.1s cubic-bezier(0.16,1,0.3,1);transform:rotateY(180deg);';
    item.appendChild(inner);
    inner.appendChild(front);
    inner.appendChild(back);
    item.addEventListener('mouseenter',function(){ inner.style.transform='rotateY(0deg)'; });
    item.addEventListener('mouseleave',function(){ inner.style.transform='rotateY(180deg)'; });
});
JS;
            break;
            
        case 'chromatic':
            echo <<<'JS'
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    function init(){
        var bp=getComputedStyle(img).objectPosition||'50% 50%';
        var sid='ca'+Math.random().toString(36).slice(2);
        var svg=document.createElementNS('http://www.w3.org/2000/svg','svg');
        svg.style.cssText='position:absolute;width:0;height:0;overflow:hidden;';
        svg.innerHTML='<defs><filter id="'+sid+'r" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/></filter><filter id="'+sid+'g" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0"/></filter><filter id="'+sid+'b" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0"/></filter></defs>';
        document.body.appendChild(svg);
        function mkLayer(fid,tx,ty){
            var d=document.createElement('div');
            d.style.cssText='position:absolute;inset:0;overflow:hidden;mix-blend-mode:screen;transition:transform 1.1s cubic-bezier(0.16,1,0.3,1);transform:translate('+tx+'px,'+ty+'px);';
            var ci=img.cloneNode(false);ci.removeAttribute('class');ci.removeAttribute('loading');
            ci.style.cssText='width:100%;height:100%;object-fit:cover;object-position:'+bp+';display:block;filter:url(#'+fid+');';
            d.appendChild(ci);return d;
        }
        var wrap=document.createElement('div');
        // At rest: RGB split visible, black bg, original hidden
        wrap.style.cssText='position:absolute;inset:0;z-index:2;pointer-events:none;opacity:1;transition:opacity 0.3s ease;background:#000;';
        var lr=mkLayer(sid+'r',-8,-4),lg=mkLayer(sid+'g',0,0),lb=mkLayer(sid+'b',8,4);
        wrap.appendChild(lr);wrap.appendChild(lg);wrap.appendChild(lb);
        item.appendChild(wrap);
        img.style.opacity='0';
        img.style.transition='opacity 0.3s ease';
        item.addEventListener('mouseenter',function(){
            lr.style.transform=lg.style.transform=lb.style.transform='translate(0,0)';
            // Show image behind wrap first, then snap wrap away — no blink
            setTimeout(function(){
                img.style.transition='none';
                img.style.opacity='1';
            }, 900);
            setTimeout(function(){ wrap.style.display='none'; }, 950);
        });
        item.addEventListener('mouseleave',function(){
            wrap.style.display='';
            img.style.transition='none';
            img.style.opacity='0';
            // Snap channels back to split position instantly
            lr.style.transition=lg.style.transition=lb.style.transition='none';
            lr.style.transform='translate(-8px,-4px)';
            lg.style.transform='translate(0,0)';
            lb.style.transform='translate(8px,4px)';
            setTimeout(function(){
                lr.style.transition=lg.style.transition=lb.style.transition='transform 1.1s cubic-bezier(0.16,1,0.3,1)';
            },30);
        });
    }
    if(img.complete&&img.naturalWidth)setTimeout(init,50);
    else img.addEventListener('load',function(){setTimeout(init,50);});
});
JS;
            break;

        case 'magnetic':
            echo <<<'JS'
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    var inited=false;
    function getSize(){
        var r=img.getBoundingClientRect();
        var w=Math.round(r.width),h=Math.round(r.height);
        if(!w||!h){w=img.offsetWidth;h=img.offsetHeight;}
        // fill_container inside flex/cell-grid cells: height:100% chain collapses to 0; walk up DOM
        if(w&&!h){var el=item;while(el&&!h){el=el.parentElement;if(el)h=el.offsetHeight||0;}}
        return {w:w,h:h};
    }
    function init(){
        if(inited)return;
        var sz=getSize();
        if(!sz.w||!sz.h)return;
        inited=true;
        var w=sz.w,h=sz.h;
        var cols=3,rows=3,tw=w/cols,th=h/rows;
        var bp=getComputedStyle(img).objectPosition||'50% 50%';
        var tiles=[],wrap=document.createElement('div');
        // Use explicit pixel dimensions — inset:0 collapses to 0 when item has height:0 (flex/fill_container)
        wrap.style.cssText='position:absolute;left:0;top:0;width:'+w+'px;height:'+h+'px;z-index:2;pointer-events:none;overflow:visible;opacity:0;transition:opacity 0.2s ease;';
        for(var r=0;r<rows;r++){
            for(var cc=0;cc<cols;cc++){
                var cx=cc*tw,cy=r*th;
                var t=document.createElement('div');
                t.style.cssText='position:absolute;left:'+cx+'px;top:'+cy+'px;width:'+tw+'px;height:'+th+'px;overflow:hidden;will-change:transform;transition:transform 0.08s ease;';
                var ti=img.cloneNode(false);
                ti.removeAttribute('class');ti.removeAttribute('loading');ti.removeAttribute('style');
                ti.style.cssText='position:absolute;left:-'+cx+'px;top:-'+cy+'px;width:'+w+'px;height:'+h+'px;object-fit:cover;object-position:'+bp+';display:block;filter:none;max-width:none;';
                t.appendChild(ti);wrap.appendChild(t);
                tiles.push({el:t,cx:cx+tw/2,cy:cy+th/2});
            }
        }
        item.appendChild(wrap);
        img.style.transition='opacity 0.2s ease';
        item.addEventListener('mouseenter',function(){img.style.opacity='0';wrap.style.opacity='1';});
        item.addEventListener('mousemove',function(e){
            var r2=item.getBoundingClientRect(),mx=e.clientX-r2.left,my=e.clientY-r2.top;
            tiles.forEach(function(t){
                var dx=mx-t.cx,dy=my-t.cy,dist=Math.sqrt(dx*dx+dy*dy)||1;
                var str=Math.max(0,1-dist/(Math.min(w,h)*0.55))*28;
                t.el.style.transform='translate('+(dx/dist*str)+'px,'+(dy/dist*str)+'px)';
            });
        });
        item.addEventListener('mouseleave',function(){
            img.style.opacity='1';wrap.style.opacity='0';
            tiles.forEach(function(t){
                t.el.style.transition='transform 0.5s cubic-bezier(0.16,1,0.3,1)';
                t.el.style.transform='translate(0,0)';
                setTimeout(function(){t.el.style.transition='transform 0.08s ease';},500);
            });
        });
    }
    function tryInit(attempt){
        if(inited)return;
        init();
        if(!inited&&attempt<15)setTimeout(function(){tryInit(attempt+1);},50+attempt*30);
    }
    if(img.complete&&img.naturalWidth)tryInit(0);
    else img.addEventListener('load',function(){tryInit(0);});
});
JS;
            break;
        case 'thermal':
            echo <<<'JS'
var tSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');
tSvg.style.cssText='position:absolute;width:0;height:0;overflow:hidden;';
tSvg.innerHTML='<defs><filter id="thermal-lut" color-interpolation-filters="sRGB"><feComponentTransfer><feFuncR type="table" tableValues="0 0 0.1 0.4 0.8 1 1"/><feFuncG type="table" tableValues="0 0 0 0.3 0.5 0.2 0"/><feFuncB type="table" tableValues="0.2 0.6 0.8 0.4 0.1 0 0"/></feComponentTransfer><feColorMatrix type="saturate" values="4"/><feComponentTransfer><feFuncR type="linear" slope="1.3" intercept="-0.1"/><feFuncG type="linear" slope="1.2" intercept="-0.05"/></feComponentTransfer></filter></defs>';
document.body.appendChild(tSvg);
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    // Thermal overlay: clone with filter applied, fades out on hover
    function init(){
        var bp=getComputedStyle(img).objectPosition||'50% 50%';
        var overlay=document.createElement('div');
        overlay.style.cssText='position:absolute;inset:0;z-index:2;pointer-events:none;overflow:hidden;transition:opacity 1.1s ease;border-radius:inherit;';
        var oi=img.cloneNode(false);
        oi.removeAttribute('class');oi.removeAttribute('loading');oi.removeAttribute('style');
        oi.style.cssText='width:100%;height:100%;object-fit:cover;object-position:'+bp+';display:block;filter:url(#thermal-lut) contrast(1.4) brightness(1.1);';
        overlay.appendChild(oi);
        item.appendChild(overlay);
        // Hover: fade out thermal overlay, revealing normal image
        item.addEventListener('mouseenter',function(){ overlay.style.opacity='0'; });
        item.addEventListener('mouseleave',function(){ overlay.style.opacity='1'; });
    }
    if(img.complete&&img.naturalWidth)setTimeout(init,50);
    else img.addEventListener('load',function(){setTimeout(init,50);});
});
JS;
            break;

        case 'burn':
            echo <<<'JS'
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    var sid='burn'+Math.random().toString(36).slice(2);
    var svg=document.createElementNS('http://www.w3.org/2000/svg','svg');
    svg.style.cssText='position:absolute;width:0;height:0;overflow:hidden;';
    svg.innerHTML='<defs><filter id="'+sid+'" x="-10%" y="-10%" width="120%" height="120%"><feTurbulence type="fractalNoise" baseFrequency="0.035 0.06" numOctaves="3" seed="5" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale="32" xChannelSelector="R" yChannelSelector="G" result="dist"/><feComponentTransfer in="dist"><feFuncR type="linear" slope="1.1" intercept="-0.05"/><feFuncG type="linear" slope="0.85" intercept="0.05"/></feComponentTransfer></filter></defs>';
    document.body.appendChild(svg);
    var disp=svg.querySelector('feDisplacementMap'),sc=32,rafId=null;
    // At rest: maximum distortion
    img.style.filter='url(#'+sid+')';
    img.style.transition='none';
    // Hover: settle to clear
    item.addEventListener('mouseenter',function(){
        if(rafId)cancelAnimationFrame(rafId);
        function settle(){sc=Math.max(sc-1.8,0);disp.setAttribute('scale',sc);if(sc>0)rafId=requestAnimationFrame(settle);else img.style.filter='';}
        rafId=requestAnimationFrame(settle);
    });
    // Leave: distort again
    item.addEventListener('mouseleave',function(){
        img.style.filter='url(#'+sid+')';
        if(rafId)cancelAnimationFrame(rafId);
        function distort(){sc=Math.min(sc+2,32);disp.setAttribute('scale',sc);if(sc<32)rafId=requestAnimationFrame(distort);}
        rafId=requestAnimationFrame(distort);
    });
});
JS;
            break;

        case 'preview':
            echo <<<'JS'
var pvTimeout=null;
var pv=document.createElement('div');
pv.style.cssText='position:fixed;z-index:100000;pointer-events:none;opacity:0;transition:opacity 0.5s ease;border-radius:4px;overflow:hidden;display:none;max-width:80vw;max-height:80vh';
document.body.appendChild(pv);
var pvImg=document.createElement('img');
pvImg.style.cssText='display:block;width:100%;height:100%;object-fit:contain;border-radius:4px';
pv.appendChild(pvImg);
var pvActive=false;
items.forEach(function(item){
    var img=item.querySelector('img');
    if(!img)return;
    item.style.cursor='pointer';
    item.addEventListener('mouseenter',function(e){
        pvTimeout=setTimeout(function(){
            if(!img.src)return;
            pvImg.src=img.src;
            var nw=img.naturalWidth||800,nh=img.naturalHeight||600;
            var userMaxW=(typeof pvMaxW!=='undefined'&&pvMaxW>0)?pvMaxW:600;
            var maxW=Math.min(userMaxW,window.innerWidth*0.9),maxH=window.innerHeight*0.85;
            var w=Math.max(200,Math.min(nw,maxW));
            var scale=w/nw;
            var h=Math.min(nh*scale,maxH);
            if(h<nh*scale){scale=h/nh;w=Math.max(200,nw*scale);}
            pv.style.width=Math.round(w)+'px';pv.style.height=Math.round(h)+'px';
            pv.style.display='block';
            pvActive=true;
            setTimeout(function(){pv.style.opacity='1';},10);
            posPv(e);
        },300);
    });
    item.addEventListener('mousemove',function(e){
        if(pvActive) posPv(e);
    });
    item.addEventListener('mouseleave',function(){
        clearTimeout(pvTimeout);
        pv.style.opacity='0';
        pvActive=false;
        setTimeout(function(){if(!pvActive)pv.style.display='none';},550);
    });
});
function posPv(e){
    var pw=pv.offsetWidth||300,ph=pv.offsetHeight||200;
    var vw=window.innerWidth,vh=window.innerHeight;
    var x=e.clientX+16,y=e.clientY-Math.round(ph/2);
    if(x+pw>vw-10)x=e.clientX-pw-16;
    if(x<10)x=10;
    if(y<10)y=10;
    if(y+ph>vh-10)y=Math.max(10,vh-ph-10);
    pv.style.left=x+'px';pv.style.top=y+'px';
}
JS;
            break;
    }
    
    echo '}catch(e){console.warn("Impulse hover effect error:",e)}},0);';
}

/**
 * Resolve a Grid / Cell grid / Freeform content width to a responsive CSS value.
 *
 * Accepts two setting keys:
 *   - width_preset (string): compact | standard | wide | full | custom
 *   - max_width (int px):    legacy pixel value — used when preset is 'custom' or absent
 *
 * Legacy behaviour: if no width_preset is set, the raw pixel value is categorised
 * to the closest preset (keeps old pages rendering sensibly without editing them).
 *
 * Returns a CSS value safe to drop into `max-width: {value};`. Always responsive
 * via `min(…vw)` so very wide viewports are capped but narrow viewports breathe.
 */
if (!function_exists('impulse_resolve_content_width')) {
    /**
     * Resolve a Grid / Cell grid / Freeform content width to a CSS value.
     *
     * Lookup order (first hit wins):
     *   1. width_pct        — The Grid / Cell grid / Freeform panel's
     *                         "Content width" slider writes here. Named for
     *                         legacy reasons; the visible label is
     *                         "Content width" but the key is width_pct.
     *   2. max_content_width — Grid panel pixel slider; pixel cap fallback
     *   3. max_width         — Legacy pixel cap from old data
     *   4. 90%               — final fallback
     *
     * NOTE: `content_width` is NOT in the lookup chain. That key belongs to
     * the SECTION block's panel; the section reads/writes it directly via
     * inline style. The two sliders share a visible label ("Content width")
     * but write to different keys, which has caused live-preview to apply a
     * value the renderer's resolver then ignored. Adding content_width to
     * this resolver caused grid drags to "squeeze then snap back" when a
     * stale content_width value existed in the same element's settings —
     * the resolver picked content_width over the freshly-saved width_pct.
     * Keeping the chain narrow to keys this block family actually owns.
     * Locked 2026-05-27 (revert of the 2026-05-27 morning attempt).
     */
    function impulse_resolve_content_width(array $settings): string {
        // width_pct (grid / rows / columns panel slider, 30..100 %).
        if (isset($settings['width_pct']) && $settings['width_pct'] !== '') {
            $pct = intval($settings['width_pct']);
            if ($pct < 30)  $pct = 30;
            if ($pct > 100) $pct = 100;
            return $pct . '%';
        }
        // max_content_width (grid panel pixel slider).
        $px = intval($settings['max_content_width'] ?? 0);
        if ($px > 0) return 'min(' . $px . 'px, 96vw)';
        // max_width (legacy pixel cap).
        $px = intval($settings['max_width'] ?? 0);
        if ($px > 0) return 'min(' . $px . 'px, 96vw)';
        return '90%';
    }
}

/**
 * Materialise tablet/mobile per-device defaults from desktop values when
 * the user hasn't set an explicit override. Mirrors autoMobileDefaults()
 * in admin-core.js. Used by heading + animator renderers so the editor
 * preview on mobile/tablet renders at the right size BEFORE the user
 * touches a setting (the JS scales the panel sliders for display; this
 * scales the actual PHP-rendered geometry to match).
 *
 * No-op for desktop. Each per-device key is set ONLY when missing so
 * explicit overrides win. Modifies $settings in place — caller's copy
 * stays scoped to its own render pass.
 *
 * Lives in the shared helpers file (previously only in
 * templates/impulse-page.php) so the editor preview path
 * (admin/views/impulse-preview.php) also picks it up — Sergei reported
 * "Title size 2-3x bigger on mobile tab, normalizes on settings change"
 * which was the editor reading raw desktop size while the JS panel
 * showed the auto-scaled value. Locked 2026-05-26.
 */
if (!function_exists('impulse_auto_mobile_defaults')) {
    function impulse_auto_mobile_defaults(array &$settings, string $type, string $device): void {
        if ($device === 'desktop') return;
        $isTablet = $device === 'tablet';
        $suffix   = '_' . $device;

        $scale = function(string $key, float $t, float $m) use (&$settings, $suffix, $isTablet): void {
            if (isset($settings[$key . $suffix])) return;
            $base = floatval($settings[$key] ?? null);
            if ($base === 0.0 && !isset($settings[$key])) return;
            $settings[$key . $suffix] = (int) round($base * ($isTablet ? $t : $m));
        };

        // Unified title block (2026-06 type rename: heading + animator
        // merged into `title`). Static-mode titles carry line_1_* keys,
        // animated/image-mode titles carry la_* keys — $scale() skips
        // keys the element doesn't have, so one branch serves both.
        if ($type === 'title') {
            $scale('line_1_size',  0.80, 0.62);
            $ls = intval($settings['letter_spacing'] ?? 0);
            if ($ls > 0) $scale('letter_spacing', 0.70, 0.50);
            $scale('line_1_depth', 0.70, 0.35);
            $scale('mouse_sens',   0.60, 0.00);
            $scale('orbit_speed',  0.60, 0.00);
            $scale('la_font_size',   0.80, 0.62);
            $scale('la_image_width', 0.80, 0.65);
            $lb = intval($settings['la_gap_below'] ?? 0);
            if ($lb > 0) $scale('la_gap_below', 0.75, 0.50);
            $scale('la_tilt',  0.80, 0.60);
            $scale('la_depth', 0.85, 0.70);
        }
    }
}

/**
 * Widow killer: glue the last two words together with a non-breaking
 * space so a single trailing word can't dangle on its own line. Used
 * by flip_box titles + body text where short copy on narrow cards is
 * especially prone to lone-word widows.
 *
 * No-op for empty strings, strings without an internal space, or
 * strings that already contain HTML (richtext markup is left alone
 * so we don't fight the editor's own break hints).
 *
 * Locked 2026-05-26 — previously called from impulse-page.php and
 * flip_box.php without a definition, which caused every flip_box on
 * the page to fail-render under the central try/catch.
 */
if (!function_exists('impulse_keep_last_words')) {
    function impulse_keep_last_words(?string $text): string {
        $text = (string) ($text ?? '');
        if ($text === '' || strpos($text, '<') !== false) return $text;
        $trimmed = rtrim($text);
        $pos = strrpos($trimmed, ' ');
        if ($pos === false) return $text;
        // \xc2\xa0 is the UTF-8 encoding of U+00A0 NO-BREAK SPACE.
        return substr($trimmed, 0, $pos) . "\xc2\xa0" . substr($trimmed, $pos + 1);
    }
}

/**
 * IntersectionObserver-backed autoplay for <video data-auto-inview="1">.
 * Plays when ≥30% visible, pauses below 10%. Prints once per page.
 */
if (!function_exists('impulse_render_video_autoplay_script')) {
    function impulse_render_video_autoplay_script(): string {
        static $printed = false;
        if ($printed) return '';
        $printed = true;
        $_vidJs = '(function(){if(!("IntersectionObserver" in window))return;var vids=document.querySelectorAll("video[data-auto-inview=\"1\"]");if(!vids.length)return;var io=new IntersectionObserver(function(es){es.forEach(function(e){var v=e.target;if(e.intersectionRatio>=0.3){if(v.paused)v.play().catch(function(){});}else if(e.intersectionRatio<0.1){if(!v.paused)v.pause();}});},{threshold:[0,0.1,0.3]});vids.forEach(function(v){io.observe(v);});})();';
        if (function_exists('wp_add_inline_script')) wp_add_inline_script('instinctor-frontend-js', $_vidJs, 'after');
        return '';
    }
}

/**
 * Cinematic legibility halo — soft layered text-shadow that picks up contrast
 * around glyphs without adding any container. Used by Text and Animator (and
 * any future text-bearing block) to keep type readable over busy 3D backgrounds.
 *
 * The halo colour is auto-selected from the text's own luminance: light text
 * gets a dark halo, dark text gets a light halo. Three blur stops layered so
 * the falloff reads as soft glow, not a hard outline.
 *
 * @param int    $level     0..100 — slider value (0 = no halo).
 * @param string $textColor Text colour in hex or rgba(...) so we can flip
 *                          the halo to the opposite luminance.
 * @return string CSS declaration block (`text-shadow:...;`) or '' when off.
 */
if (!function_exists('impulse_text_halo_css')) {
    function impulse_text_halo_css(int $level, string $textColor): string {
        if ($level <= 0) return '';
        $level = max(0, min(100, $level));
        $halo  = impulse_halo_color_for($textColor);
        // Dense COMPOUNDING halo. The tight core blur is stacked 3x so the layers
        // composite into a near-opaque dark pool hugging the glyphs — a real,
        // unmistakable halo that reads on dark colourful backgrounds (the standard
        // black-halo-on-busy-image technique) — then mid + wide layers fade it out.
        // 3x0.7 (and even 5x0.7) read as "no halo" in the editor; this does not.
        $b1 = round($level * 0.10, 1);
        $b2 = round($level * 0.22, 1);
        $b3 = round($level * 0.45, 1);
        return 'text-shadow:'
             . '0 0 ' . $b1 . 'px ' . $halo . ','
             . '0 0 ' . $b1 . 'px ' . $halo . ','
             . '0 0 ' . $b1 . 'px ' . $halo . ','
             . '0 0 ' . $b2 . 'px ' . $halo . ','
             . '0 0 ' . $b2 . 'px ' . $halo . ','
             . '0 0 ' . $b3 . 'px ' . $halo . ';';
    }
}

/**
 * Halo as a layered `filter:drop-shadow(...)` stack.
 *
 * `impulse_text_halo_css()` emits text-shadow, which only paints around
 * actual TEXT GLYPHS — perfect for the Text block and the visible label
 * of a Button. But icons (SVG paths) and canvas-rendered surfaces (the
 * Animator) need a different mechanism — text-shadow does nothing to
 * either. drop-shadow operates on the element's painted pixels so it
 * traces icon strokes and canvas content correctly. Slider semantics
 * are identical to the text variant (0..100) so a unified "Halo"
 * control on each block reads the same way regardless of which
 * mechanism is wired up underneath.
 *
 * @param int    $level     0..100 — slider value (0 = no halo).
 * @param string $textColor Foreground colour; halo flips to the
 *                          opposite luminance for legibility.
 * @return string CSS declaration block (`filter:drop-shadow…;`) or ''
 *                when the slider is at 0.
 */
if (!function_exists('impulse_drop_shadow_halo_css')) {
    function impulse_drop_shadow_halo_css(int $level, string $textColor): string {
        if ($level <= 0) return '';
        $level = max(0, min(100, $level));
        $halo  = impulse_halo_color_for($textColor);
        // Dense compounding stack to match impulse_text_halo_css (triple tight core).
        $b1 = round($level * 0.10, 1);
        $b2 = round($level * 0.22, 1);
        $b3 = round($level * 0.45, 1);
        return 'filter:'
             . 'drop-shadow(0 0 ' . $b1 . 'px ' . $halo . ') '
             . 'drop-shadow(0 0 ' . $b1 . 'px ' . $halo . ') '
             . 'drop-shadow(0 0 ' . $b1 . 'px ' . $halo . ') '
             . 'drop-shadow(0 0 ' . $b2 . 'px ' . $halo . ') '
             . 'drop-shadow(0 0 ' . $b2 . 'px ' . $halo . ') '
             . 'drop-shadow(0 0 ' . $b3 . 'px ' . $halo . ');';
    }
}

if (!function_exists('impulse_halo_color_for')) {
    function impulse_halo_color_for(string $color): string {
        $r = $g = $b = 128;
        $color = trim($color);
        if (preg_match('/^#([0-9a-fA-F]{6})$/', $color, $m)) {
            $r = hexdec(substr($m[1], 0, 2));
            $g = hexdec(substr($m[1], 2, 2));
            $b = hexdec(substr($m[1], 4, 2));
        } elseif (preg_match('/^#([0-9a-fA-F]{3})$/', $color, $m)) {
            $r = hexdec(str_repeat($m[1][0], 2));
            $g = hexdec(str_repeat($m[1][1], 2));
            $b = hexdec(str_repeat($m[1][2], 2));
        } elseif (preg_match('/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/', $color, $m)) {
            $r = (int) $m[1];
            $g = (int) $m[2];
            $b = (int) $m[3];
        }
        // Standard sRGB luminance approximation — good enough to choose
        // between black and white halo without paying for proper Y conversion.
        $lum = ($r * 0.299 + $g * 0.587 + $b * 0.114) / 255;
        // 0.85 (was 0.7): with the triple-stacked core the layers composite to a
        // near-solid pool, so the halo is unmistakable on busy dark backgrounds.
        return $lum > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.85)';
    }
}

