<?php
declare(strict_types=1);

if (!defined('ABSPATH')) { exit; }

if (!function_exists('impulse_svg_allowed')) {
    /**
     * Allowlist for wp_kses() when echoing an inline-SVG / chrome markup STRING
     * whose tag structure is dynamic (a shape's stored SVG, a curated icon).
     * Call it inline: `echo wp_kses($svg, impulse_svg_allowed());` -- wp_kses is
     * the escaping function phpcs (and the reviewer's Plugin Check) recognizes,
     * so the allowlist must be exposed this way rather than wrapped in a custom
     * helper (a custom wrapper is not recognized and would still be flagged).
     *
     * For literal `<svg ...>` tags with interpolated values, esc_attr() the
     * values inline instead -- cheaper, and avoids routing trusted literals
     * through kses.
     *
     * wp_kses lowercases element/attribute names; for INLINE svg the browser's
     * HTML parser re-corrects the casing (viewbox->viewBox, lineargradient->
     * linearGradient -- verified empirically), so this is render-neutral. CSS
     * custom properties (--ox, --rot) are stripped by safecss_filter_attr, so
     * callers must NOT pass custom-property `style` attributes through kses --
     * those stay as esc_attr()'d literals.
     */
    function impulse_svg_allowed() {
        static $allowed = null;
        if ($allowed === null) {
            $a = array_fill_keys([
                'id','class','style','fill','stroke','stroke-width','stroke-linecap',
                'stroke-linejoin','stroke-dasharray','stroke-dashoffset','stroke-opacity',
                'stroke-miterlimit','fill-opacity','fill-rule','clip-rule','opacity',
                'transform','transform-origin','d','points','cx','cy','r','rx','ry',
                'x','y','x1','y1','x2','y2','dx','dy','width','height','viewbox',
                'preserveaspectratio','xmlns','xmlns:xlink','gradientunits',
                'gradienttransform','spreadmethod','offset','stop-color','stop-opacity',
                'vector-effect','data-c','data-s','data-depth','filter','mask','clip-path',
                'marker-end','marker-start','patternunits','patterncontentunits',
                'href','xlink:href','result','in','in2','mode','type','values','stddeviation',
                'basefrequency','numoctaves','scale','seed','rotate','text-anchor',
                'font-size','font-family','font-weight','letter-spacing','baseline-shift',
                'paint-order','color-interpolation-filters','flood-color','flood-opacity',
            ], true);
            $tags = ['svg','g','path','circle','ellipse','rect','line','polyline','polygon',
                'defs','lineargradient','radialgradient','stop','use','symbol','clippath',
                'mask','pattern','filter','fegaussianblur','feoffset','femerge','femergenode',
                'feturbulence','fedisplacementmap','fecolormatrix','feblend','fecomposite',
                'feflood','femorphology','fedropshadow','text','tspan','title','desc',
                'div','span','a','img'];
            $allowed = [];
            foreach ($tags as $t) { $allowed[$t] = $a; }
            $allowed['a']['href'] = true; $allowed['a']['target'] = true; $allowed['a']['rel'] = true;
            $allowed['img']['src'] = true; $allowed['img']['alt'] = true; $allowed['img']['loading'] = true;
            $allowed['div']['data-shape-idx'] = true;
        }
        return $allowed;
    }
}

// is a pure renderer that assembles inline CSS, SVG geometry, and per-element
// markup from block settings that are sanitized/cast at the settings layer
// (numeric sizes/coords, generated element IDs, validated color values). The
// output is composed CSS/SVG, not HTML attributes carrying raw user text.

/**
 * Shared helpers used by element renderers.
 *
 * Functions here previously lived inline inside `admin/views/impulse-preview.php`
 * (gated on the impulse_preview query string) or inside the body of
 * `templates/impulse-page.php`. Moving them into a top-level shared file
 * lets the extracted per-element renderers in this directory call them
 * regardless of which renderer (frontend or editor) loaded the loader.
 *
 * Each function is wrapped in a `function_exists` guard so it's safe to
 * load alongside the existing inline copies during the staged extraction.
 */

if (!function_exists('impulse_resolve_image')) {
    /**
     * Resolve an image id to URL + dimensions.
     *
     * Tries the Instinctor standalone DB first (derivative-then-original),
     * falls back to WordPress's wp_get_attachment_image_src if available.
     *
     * @param int    $id   instinctor_images.id (Instinctor standalone) or
     *                     attachment ID (WordPress).
     * @param string $size 'large' | 'medium' | 'thumbnail' | 'medium_large' | 'full'.
     *
     * @return array|false [url, width, height, is_intermediate] or false on miss.
     */
    function impulse_resolve_image(int $id, string $size = 'large'): array|false {
        if ($id <= 0) return false;
        if (class_exists('\\Instinctor\\Core\\DB') && class_exists('\\Instinctor\\Core\\Storage')) {
            $table = \Instinctor\Core\DB::prefix() . 'instinctor_images';
            // Map WP-style size name → Instinctor derivative role.
            $roleMap = ['large' => 'd', 'medium' => 't', 'thumbnail' => 'm', 'medium_large' => 't', 'full' => 'd'];
            $role    = $roleMap[$size] ?? 'd';
            // Try derivative (WebP) first.
            $deriv = \Instinctor\Core\DB::get_row(
                \Instinctor\Core\DB::prepare(
                    "SELECT rel_path, width, height FROM {$table} WHERE parent_id = %d AND derivative_role = %s LIMIT 1",
                    $id, $role
                ),
                'ARRAY_A'
            );
            if ($deriv && !empty($deriv['rel_path'])) {
                $url = \Instinctor\Core\Storage::url($deriv['rel_path']);
                if ($url) return [$url, (int) $deriv['width'], (int) $deriv['height'], false];
            }
            // Fall back to original.
            $row = \Instinctor\Core\DB::get_row(
                \Instinctor\Core\DB::prepare(
                    "SELECT rel_path, width, height FROM {$table} WHERE id = %d AND parent_id IS NULL LIMIT 1",
                    $id
                ),
                'ARRAY_A'
            );
            if ($row && !empty($row['rel_path'])) {
                $url = \Instinctor\Core\Storage::url($row['rel_path']);
                if ($url) return [$url, (int) $row['width'], (int) $row['height'], false];
            }
        }
        if (function_exists('wp_get_attachment_image_src')) {
            return wp_get_attachment_image_src($id, $size) ?: false;
        }
        return false;
    }
}

if (!function_exists('impulse_resolve_image_url')) {
    /**
     * Convenience: resolve image id to URL only, '' on miss.
     */
    function impulse_resolve_image_url(int $id, string $size = 'large'): string {
        $r = impulse_resolve_image($id, $size);
        return $r ? (string) $r[0] : '';
    }
}

// ─────────────────────────────────────────────────────────────────
// Consolidated from templates/impulse-page.php + admin/views/impulse-preview.php
// (Phase 1 cleanup, v2.1.264). Both parent files used to declare
// these inline under function_exists guards — kept in sync by hand,
// drifted over time. Single canonical lives here; the inline
// duplicates have been removed.
// ─────────────────────────────────────────────────────────────────

if (!function_exists('impulse_anchor_pos')) {
    function impulse_anchor_pos($anchor) {
        switch ($anchor) {
            case 'tl': return ['left' => '0',    'top' => '0',    'tx' => '0',    'ty' => '0'];
            case 'tr': return ['left' => '100%', 'top' => '0',    'tx' => '-100%', 'ty' => '0'];
            case 'bl': return ['left' => '0',    'top' => '100%', 'tx' => '0',    'ty' => '-100%'];
            case 'br': return ['left' => '100%', 'top' => '100%', 'tx' => '-100%', 'ty' => '-100%'];
            case 'cc':
            default:   return ['left' => '50%',  'top' => '50%',  'tx' => '-50%', 'ty' => '-50%'];
        }
    }
}


if (!function_exists('impulse_contrast_color')) {
    function impulse_contrast_color($hexColor) {
        $hex = ltrim($hexColor, '#');
        if (strlen($hex) === 3) {
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        }
        $r = hexdec(substr($hex, 0, 2));
        $g = hexdec(substr($hex, 2, 2));
        $b = hexdec(substr($hex, 4, 2));
        $luminance = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255;
        return $luminance > 0.5 ? '#111827' : '#ffffff';
    }
}


if (!function_exists('impulse_cyl_filter_output')) {
    function impulse_cyl_filter_output($filterId, $depth) {
        $absD = abs($depth);
        $scale = round($absD * 2.5);
        if (function_exists('imagecreatetruecolor')) {
            $W = 64; $H = 64;
            $img = imagecreatetruecolor($W, $H);
            for ($y = 0; $y < $H; $y++) {
                for ($x = 0; $x < $W; $x++) {
                    $nx = ($W > 1) ? $x / ($W - 1) : 0.5;
                    $ny = ($H > 1) ? $y / ($H - 1) : 0.5;
                    $sinX  = sin($nx * M_PI);
                    $distY = 0.5 - $ny;
                    $factor = $absD / 50.0;
                    $g = $depth > 0
                        ? 128 + $sinX * $distY * $factor * 118
                        : 128 - $sinX * $distY * $factor * 118;
                    $gv = max(0, min(255, (int)round($g)));
                    imagesetpixel($img, $x, $y, imagecolorallocate($img, 128, $gv, 0));
                }
            }
            ob_start(); imagepng($img); $pngData = ob_get_clean(); imagedestroy($img);
            $mapUrl = 'data:image/png;base64,' . base64_encode($pngData);
        } else {
            $gC = $depth > 0 ? 220 : 36;
            $gE = $depth > 0 ? 36 : 220;
            $mapSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">' .
                '<defs><radialGradient id="rg" cx="50%" cy="0%" r="100%">' .
                '<stop offset="0%" stop-color="rgb(128,' . $gC . ',0)"/>' .
                '<stop offset="100%" stop-color="rgb(128,' . $gE . ',0)"/>' .
                '</radialGradient></defs><rect width="64" height="64" fill="url(%23rg)"/></svg>';
            $mapUrl = 'data:image/svg+xml;base64,' . base64_encode($mapSvg);
        }
        echo '<svg id="' . esc_attr($filterId) . '-host" style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">' .
            '<defs><filter id="' . esc_attr($filterId) . '" color-interpolation-filters="sRGB">' .
            '<feImage href="' . esc_attr($mapUrl) . '" result="m" preserveAspectRatio="none" x="0" y="0" width="100%" height="100%"/>' .
            '<feDisplacementMap in="SourceGraphic" in2="m" scale="' . esc_attr($scale) . '" xChannelSelector="R" yChannelSelector="G"/>' .
            '</filter></defs></svg>';
        $alpha = number_format(min(0.45, $absD / 50 * 0.45), 2);
        return $depth > 0
            ? 'linear-gradient(to right, rgba(0,0,0,' . $alpha . ') 0%, transparent 28%, transparent 72%, rgba(0,0,0,' . $alpha . ') 100%)'
            : 'radial-gradient(ellipse at center, rgba(0,0,0,' . $alpha . ') 0%, transparent 65%)';
    }
}


if (!function_exists('impulse_darken_color')) {
    function impulse_darken_color($hexColor, $percent = 15) {
        $hex = ltrim($hexColor, '#');
        if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
        if (strlen($hex) !== 6) return $hexColor;
        // (int) casts: round() returns float, so the subtractions below are
        // float-typed. The original inline copies lived in files without
        // declare(strict_types=1); helpers.php declares strict_types, so
        // dechex() would refuse the float without an explicit cast.
        $r = (int) max(0, hexdec(substr($hex,0,2)) - round(255*$percent/100));
        $g = (int) max(0, hexdec(substr($hex,2,2)) - round(255*$percent/100));
        $b = (int) max(0, hexdec(substr($hex,4,2)) - round(255*$percent/100));
        return '#'.str_pad(dechex($r),2,'0',STR_PAD_LEFT).str_pad(dechex($g),2,'0',STR_PAD_LEFT).str_pad(dechex($b),2,'0',STR_PAD_LEFT);
    }
}


if (!function_exists('impulse_derive_border')) {
    function impulse_derive_border($hexColor) {
        $hex = ltrim($hexColor, '#');
        if (strlen($hex) === 3) {
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        }
        $r = hexdec(substr($hex, 0, 2));
        $g = hexdec(substr($hex, 2, 2));
        $b = hexdec(substr($hex, 4, 2));
        $lum = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255;
        if ($lum > 0.5) {
            $r = max(0, (int)($r * 0.85)); $g = max(0, (int)($g * 0.85)); $b = max(0, (int)($b * 0.85));
        } else {
            $r = min(255, (int)($r + (255 - $r) * 0.15)); $g = min(255, (int)($g + (255 - $g) * 0.15)); $b = min(255, (int)($b + (255 - $b) * 0.15));
        }
        return '#' . str_pad(dechex($r), 2, '0', STR_PAD_LEFT) . str_pad(dechex($g), 2, '0', STR_PAD_LEFT) . str_pad(dechex($b), 2, '0', STR_PAD_LEFT);
    }
}


if (!function_exists('impulse_device_val')) {
    function impulse_device_val($shape, $key, $device, $fallback) {
        if ($device !== 'desktop') {
            $dk = $key . '_' . $device;
            if (isset($shape[$dk]) && $shape[$dk] !== null && $shape[$dk] !== '') {
                return $shape[$dk];
            }
        }
        return isset($shape[$key]) ? $shape[$key] : $fallback;
    }
}


if (!function_exists('impulse_get_feature_icon')) {
    function impulse_get_feature_icon($iconType, $color, $class = '') {
        $icons = [
            'plus-square' => '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>',
            'check-square' => '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><polyline points="9 11 12 14 16 10"></polyline>',
            'check-circle' => '<circle cx="12" cy="12" r="10"></circle><polyline points="9 12 12 15 16 9"></polyline>',
            'check' => '<polyline points="20 6 9 17 4 12"></polyline>',
            'plus-circle' => '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>',
            'circle-dot' => '<circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="3" fill="' . esc_attr($color) . '"></circle>',
            'x' => '<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>',
            'x-square' => '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="9" x2="15" y2="15"></line><line x1="15" y1="9" x2="9" y2="15"></line>',
            'x-circle' => '<circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line>',
            'minus-square' => '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="8" y1="12" x2="16" y2="12"></line>',
            'minus-circle' => '<circle cx="12" cy="12" r="10"></circle><line x1="8" y1="12" x2="16" y2="12"></line>',
            'square' => '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>'
        ];
        $path = $icons[$iconType] ?? $icons['plus-square'];
        $classAttr = $class ? ' class="' . esc_attr($class) . '"' : '';
        return '<svg' . $classAttr . ' viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="' . esc_attr($color) . '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' . $path . '</svg>';
    }
}


if (!function_exists('impulse_get_product_images')) {
    function impulse_get_product_images($product) {
        $images = [];
        
        // Primary: images JSON field (URL-based, set by product editor)
        if (!empty($product->images)) {
            $imagesData = json_decode($product->images, true);
            if (is_array($imagesData)) {
                foreach ($imagesData as $img) {
                    if (!empty($img['url'])) $images[] = $img['url'];
                }
            }
        }
        
        // Fallback: featured_image (attachment ID)
        if (empty($images) && !empty($product->featured_image)) {
            $url = function_exists('wp_get_attachment_image_url')
                ? wp_get_attachment_image_url(intval($product->featured_image), 'large')
                : '';
            if ($url) $images[] = $url;
        }
        
        // Always return at least 3 slots for backward compat
        while (count($images) < 3) $images[] = '';
        return $images;
    }
}


if (!function_exists('impulse_render_divider_line')) {
        function impulse_render_divider_line($divStyle, $t, $c, $settings) {
            $c2 = esc_attr($settings['color2'] ?? '#00ffff');
            $uid = 'idiv-' . uniqid();
            
            if ($divStyle === 'electric') {
                // Electric: a live-wire jag. Rendered as SMIL-animated <path>s (discrete
                // d-swap between a few seeded zigzag variants) plus a CSS drop-shadow glow,
                // NOT an SVG turbulence/displacement filter: Chrome truncates SVG filters
                // when an ancestor has a CSS transform (the editor's .impulse-canvas zoom),
                // which clipped the old filtered line ~35% short in the PREVIEW. Path
                // geometry + CSS drop-shadow both render correctly under transforms, so this
                // is WYSIWYG in the editor AND on the frontend. v2.1.2151.
                $sw  = max(1, $t);
                $amp = max(4, (int) round($t * 4));   // jag amplitude, viewBox-y units
                // The box must CONTAIN the jag. It used to be t*3 tall while the jag
                // swings +/- t*4 around the middle, so the line was drawn well outside
                // its own element and only survived because every ancestor happened to
                // let it overflow. Turning on Fade edges broke that: the mask is sized
                // to the element box, so everything painted beyond it - which is most
                // of the waveform - was masked away, and the divider showed as a row of
                // disconnected diagonal dashes with the peaks and troughs sheared off.
                // mask-clip does not help (the content is outside the box, not merely
                // clipped by it), so size the box to the geometry instead. That also
                // makes the block immune to any other clipping ancestor - a section
                // with overflow:hidden, a grid cell, `contain` - and stops the line
                // overlapping whatever sits above and below it. Sergei 2026-08-14.
                $h   = max(8, 2 * $amp + 2 * $sw);
                $g   = $t * 4 + 6;
                $mid = $h / 2;
                $steps = 44;
                // Deterministic non-crypto PRNG (inline LCG) seeded from the element uid, so the
                // decorative SVG electric-divider jag renders identically each request. Uses only
                // self-contained arithmetic: the core RNG (wp_rand) cannot be seeded, and this is
                // purely visual jitter.
                $rngState = crc32($uid) & 0x7fffffff;
                $vs = array();
                for ($v = 0; $v < 4; $v++) {
                    $dp = 'M0 ' . $mid;
                    for ($sx = 1; $sx <= $steps; $sx++) {
                        $x = round($sx / $steps * 1200, 1);
                        $rngState = ($rngState * 1103515245 + 12345) & 0x7fffffff;
                        $y = ($sx === $steps) ? $mid : round($mid + ((($rngState % 2001) - 1000) / 1000) * $amp, 2);
                        $dp .= ' L' . $x . ' ' . $y;
                    }
                $vs[$v] = $dp;
                }
                $vals = $vs[0] . ';' . $vs[1] . ';' . $vs[2] . ';' . $vs[3] . ';' . $vs[0];
                $d0 = $vs[0];
                echo '<svg class="impulse-divider-line" viewBox="0 0 1200 ' . esc_attr($h) . '" preserveAspectRatio="none" style="display:block;width:100%;height:' . esc_attr($h) . 'px;overflow:visible">';
                echo '<path d="' . esc_attr($d0) . '" fill="none" stroke="' . esc_attr($c) . '" stroke-width="' . esc_attr($sw * 3) . '" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" opacity="0.4" style="filter:drop-shadow(0 0 ' . esc_attr($g) . 'px ' . esc_attr($c) . ')"><animate attributeName="d" values="' . esc_attr($vals) . '" dur="0.12s" calcMode="discrete" repeatCount="indefinite"/></path>';
                echo '<path d="' . esc_attr($d0) . '" fill="none" stroke="' . esc_attr($c) . '" stroke-width="' . esc_attr($sw * 2) . '" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" style="filter:drop-shadow(0 0 ' . intval($g * 0.5) . 'px ' . esc_attr($c) . ')"><animate attributeName="d" values="' . esc_attr($vals) . '" dur="0.12s" calcMode="discrete" repeatCount="indefinite"/></path>';
                echo '<path d="' . esc_attr($d0) . '" fill="none" stroke="rgba(255,255,255,0.85)" stroke-width="' . esc_attr($sw) . '" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"><animate attributeName="d" values="' . esc_attr($vals) . '" dur="0.12s" calcMode="discrete" repeatCount="indefinite"/></path>';
                echo '</svg>';
            } elseif ($divStyle === 'glow_scan') {
                // Scan: glowing line with bright flickering orb clamped inside
                $g = $t * 3 + 4;
                $orbSize = max(12, $t * 5 + 10);
                $orbGlow = $t * 6 + 12;
                wp_add_inline_style('instinctor-page-inline', ''
                  . '@keyframes ' . $uid . 's{0%{left:0}100%{left:100%}}'
                  . '@keyframes ' . $uid . 'f{0%{opacity:1;transform:translate(-50%,-50%) scale(1)}10%{opacity:0.6;transform:translate(-50%,-50%) scale(0.85)}20%{opacity:1;transform:translate(-50%,-50%) scale(1.1)}30%{opacity:0.8;transform:translate(-50%,-50%) scale(0.95)}40%{opacity:1;transform:translate(-50%,-50%) scale(1)}60%{opacity:0.7;transform:translate(-50%,-50%) scale(0.9)}65%{opacity:1;transform:translate(-50%,-50%) scale(1.05)}100%{opacity:1;transform:translate(-50%,-50%) scale(1)}}'
                  . '');
                echo '<span class="impulse-divider-line" style="display:block;position:relative;height:' . esc_attr(max(2, $t)) . 'px;background:' . esc_attr($c) . ';border-radius:' . esc_attr($t) . 'px;box-shadow:0 0 ' . esc_attr($g) . 'px ' . esc_attr($c) . ';overflow:hidden">';
                echo '<span style="position:absolute;top:50%;left:0;transform:translate(-50%,-50%);width:' . esc_attr($orbSize) . 'px;height:' . esc_attr($orbSize) . 'px;border-radius:50%;background:radial-gradient(circle,#fff 0%,rgba(255,255,255,0.9) 20%,' . esc_attr($c) . ' 50%,transparent 70%);box-shadow:0 0 ' . esc_attr($orbGlow) . 'px ' . esc_attr($c) . ',0 0 ' . esc_attr($orbGlow * 2) . 'px ' . esc_attr($c) . ',0 0 ' . esc_attr($orbGlow * 3) . 'px color-mix(in srgb,' . esc_attr($c) . ',transparent 50%);pointer-events:none;animation:' . esc_attr($uid) . 's 3s cubic-bezier(0.45,0.05,0.55,0.95) infinite alternate,' . esc_attr($uid) . 'f 0.8s ease-in-out infinite"></span>';
                echo '</span>';
            
            } elseif ($divStyle === 'neon') {
                // Neon: layered CSS blur + hotspots + flicker + warm-up ignition
                // 2026-05-24: tubeH slashed from 80+ → ~12px to match the
                // other divider footprints. The glow blur (up to 30px on L1)
                // escapes the box via overflow:visible and the divider's
                // .impulse-el wrapper, so the visual reach is unchanged —
                // only the layout-occupied height shrinks. Adjacent content
                // no longer gets pushed 80px apart.
                $tubeH = max(10, $t * 3 + 6);
                $coreW = 2;
                $neonId = $uid . '-gl';
                $tubeId = $uid . '-tu';
                // Seed hotspot positions from uid
                $seed = crc32($uid);
                $spots = [];
                for ($si = 0; $si < 5; $si++) {
                    $spots[] = (($seed >> ($si * 3)) & 0xFF) / 255.0 * 80 + 5; // 5%-85% positions
                }
                sort($spots);
                // Build uneven gradient for glow layers — brighter at hotspot positions
                $glowGrad = 'linear-gradient(90deg,' . $c . ' 0%';
                foreach ($spots as $sp) {
                    $pct = number_format($sp, 1);
                    $before = number_format(max(0, $sp - 6), 1);
                    $after = number_format(min(100, $sp + 6), 1);
                    $glowGrad .= ',' . $c . ' ' . $before . '%,color-mix(in srgb,' . $c . ',white 30%) ' . $pct . '%,' . $c . ' ' . $after . '%';
                }
                $glowGrad .= ',' . $c . ' 100%)';
                // Core gradient — brighter spots = whiter
                $coreGrad = 'linear-gradient(90deg,color-mix(in srgb,' . $c . ' 20%,white) 0%';
                foreach ($spots as $sp) {
                    $pct = number_format($sp, 1);
                    $before = number_format(max(0, $sp - 4), 1);
                    $after = number_format(min(100, $sp + 4), 1);
                    $coreGrad .= ',color-mix(in srgb,' . $c . ' 20%,white) ' . $before . '%,white ' . $pct . '%,color-mix(in srgb,' . $c . ' 20%,white) ' . $after . '%';
                }
                $coreGrad .= ',color-mix(in srgb,' . $c . ' 20%,white) 100%)';
                // Specular gradient — uneven light reflection on glass surface
                $specGrad = 'linear-gradient(90deg,transparent 0%';
                foreach ($spots as $si => $sp) {
                    $bright = ($si % 2 === 0) ? '0.22' : '0.08';
                    $pct = number_format($sp, 1);
                    $before = number_format(max(0, $sp - 3), 1);
                    $after = number_format(min(100, $sp + 3), 1);
                    $specGrad .= ',transparent ' . $before . '%,rgba(255,255,255,' . $bright . ') ' . $pct . '%,transparent ' . $after . '%';
                }
                $specGrad .= ',transparent 100%)';
                // Keyframes
                ob_start();
                echo '@keyframes ' . esc_attr($uid) . 'b1{0%{opacity:0.5}12%{opacity:0.38}25%{opacity:0.5}40%{opacity:0.42}55%{opacity:0.48}70%{opacity:0.36}85%{opacity:0.5}100%{opacity:0.5}}';
                echo '@keyframes ' . esc_attr($uid) . 'b2{0%{opacity:0.65}15%{opacity:0.5}30%{opacity:0.6}45%{opacity:0.48}60%{opacity:0.65}75%{opacity:0.52}90%{opacity:0.6}100%{opacity:0.65}}';
                echo '@keyframes ' . esc_attr($uid) . 'fl{';
                echo '0%{opacity:0.9}4%{opacity:0.55}6%{opacity:0.85}';
                echo '20%{opacity:0.9}22%{opacity:0.6}24%{opacity:0.82}26%{opacity:0.9}';
                echo '50%{opacity:0.88}52%{opacity:0.5}53%{opacity:0.75}55%{opacity:0.88}';
                echo '72%{opacity:0.9}74%{opacity:0.65}76%{opacity:0.9}';
                echo '90%{opacity:0.85}92%{opacity:0.55}94%{opacity:0.8}100%{opacity:0.9}}';
                echo '@keyframes ' . esc_attr($uid) . 'dr{0%{background-position:0% 0}50%{background-position:30% 0}100%{background-position:0% 0}}';
                echo '@keyframes ' . esc_attr($uid) . 'd1{0%{opacity:0}8%{opacity:0.45}10%{opacity:0}100%{opacity:0}}';
                echo '@keyframes ' . esc_attr($uid) . 'd2{0%{opacity:0}35%{opacity:0}37%{opacity:0.5}39%{opacity:0.15}40%{opacity:0.4}42%{opacity:0}100%{opacity:0}}';
                echo '@keyframes ' . esc_attr($uid) . 'd3{0%{opacity:0}65%{opacity:0}66%{opacity:0.35}68%{opacity:0}72%{opacity:0.3}73%{opacity:0}100%{opacity:0}}';
                $_inst_css1 = ob_get_clean(); wp_add_inline_style('instinctor-page-inline', $_inst_css1);
                echo '<div class="impulse-divider-line" style="position:relative;height:' . esc_attr($tubeH) . 'px;overflow:visible">';
                // 2026-05-24: L-shaped end brackets removed (left/right bends
                // that made the tube look like it dipped into the wall). The
                // tube now runs edge-to-edge. Variables retained but the bend
                // DIVs and their glowing counterparts (L7/L8) are gone, along
                // with the JS that toggled their opacity on ignition.
                $tw = $coreW * 2;
                echo '<div id="' . esc_attr($tubeId) . '" style="position:absolute;top:50%;left:0;right:0;height:' . esc_attr($tw) . 'px;margin-top:-' . esc_attr($coreW) . 'px;background:#555;z-index:2"></div>';
                // Glow assembly — starts hidden
                echo '<div id="' . esc_attr($neonId) . '" style="position:absolute;inset:0;opacity:0;z-index:3">';
                // L1: wide bloom — uneven gradient
                echo '<div style="position:absolute;top:50%;left:-20px;right:-20px;height:6px;margin-top:-3px;background:' . esc_attr($glowGrad) . ';background-size:200% 100%;border-radius:3px;filter:blur(30px);animation:' . esc_attr($uid) . 'b1 7s ease-in-out infinite,' . esc_attr($uid) . 'dr 12s ease-in-out infinite"></div>';
                // L2: medium glow — uneven
                echo '<div style="position:absolute;top:50%;left:-10px;right:-10px;height:4px;margin-top:-2px;background:' . esc_attr($glowGrad) . ';background-size:200% 100%;border-radius:2px;filter:blur(14px);animation:' . esc_attr($uid) . 'b2 5s ease-in-out infinite,' . esc_attr($uid) . 'dr 15s ease-in-out infinite"></div>';
                // L3: tight glow
                echo '<div style="position:absolute;top:50%;left:0;right:0;height:4px;margin-top:-2px;background:' . esc_attr($c) . ';border-radius:2px;filter:blur(6px);opacity:0.7"></div>';
                // L4: bright core — uneven, flickering
                echo '<div style="position:absolute;top:50%;left:0;right:0;height:' . esc_attr($coreW * 2) . 'px;margin-top:-' . esc_attr($coreW) . 'px;background:' . esc_attr($coreGrad) . ';background-size:200% 100%;border-radius:' . esc_attr($coreW) . 'px;filter:blur(0.8px);animation:' . esc_attr($uid) . 'fl 4s ease-in-out infinite,' . esc_attr($uid) . 'dr 10s ease-in-out infinite"></div>';
                // L5: hotspot blooms — extra bright spots
                foreach ($spots as $si => $sp) {
                    $sz = 8 + ($si % 3) * 4; // 8-16px
                    $blur = 10 + ($si % 2) * 8; // 10-18px
                    echo '<div style="position:absolute;top:50%;left:' . number_format($sp, 1) . '%;width:' . esc_attr($sz) . 'px;height:' . esc_attr($sz) . 'px;margin-top:-' . intval($sz / 2) . 'px;margin-left:-' . intval($sz / 2) . 'px;background:white;border-radius:50%;filter:blur(' . esc_attr($blur) . 'px);opacity:0.25"></div>';
                }
                // L5b: localized dimmers
                echo '<div style="position:absolute;top:50%;left:12%;width:18%;height:20px;margin-top:-10px;background:radial-gradient(ellipse,rgba(0,0,0,0.7) 0%,transparent 70%);opacity:0;animation:' . esc_attr($uid) . 'd1 3.5s ease-in-out infinite;pointer-events:none"></div>';
                echo '<div style="position:absolute;top:50%;left:45%;width:14%;height:20px;margin-top:-10px;background:radial-gradient(ellipse,rgba(0,0,0,0.7) 0%,transparent 70%);opacity:0;animation:' . esc_attr($uid) . 'd2 5s ease-in-out infinite;pointer-events:none"></div>';
                echo '<div style="position:absolute;top:50%;left:72%;width:16%;height:20px;margin-top:-10px;background:radial-gradient(ellipse,rgba(0,0,0,0.7) 0%,transparent 70%);opacity:0;animation:' . esc_attr($uid) . 'd3 4.2s ease-in-out infinite;pointer-events:none"></div>';
                // L6: glass specular — uneven light reflection (edge-to-edge now that the bends are gone).
                echo '<div style="position:absolute;top:50%;left:0;right:0;height:1px;margin-top:-' . esc_attr($coreW + 2) . 'px;background:' . esc_attr($specGrad) . ';border-radius:1px"></div>';
                // L7/L8 glowing end-bends retired 2026-05-24.
                echo '</div>';
                echo '</div>';
                // Warm-up ignition JS with IntersectionObserver
                ob_start();
                echo '(function(){';
                echo 'var g=document.getElementById("' . esc_js($neonId) . '");';
                echo 'var t=document.getElementById("' . esc_js($tubeId) . '");';
                echo 'if(!g||!t)return;';
                // [opacity, hold_ms] — faint ionization → dark pauses → brighter → catches → steady
                echo 'var seq=[';
                echo '[.06,80],[0,450],[.12,100],[.04,60],[0,300],';
                echo '[.2,120],[.06,70],[0,180],[.3,140],[.12,50],[.28,100],[.08,40],[0,120],';
                echo '[.5,180],[.3,50],[.65,200],[.45,40],[.8,220],[.6,60],[.92,200],[1,0]';
                echo '];';
                echo 'function fire(){var i=0;function step(){';
                echo 'if(i>=seq.length){g.style.opacity=1;t.style.opacity=0;return}';
                echo 'var s=seq[i];g.style.opacity=s[0];';
                echo 'if(s[0]>.3){var f=Math.max(0,1-s[0]*1.3).toFixed(2);t.style.opacity=f}';
                echo 'i++;setTimeout(step,s[1]+Math.random()*50)}';
                echo 'step()}if("IntersectionObserver"in window){var ob=new IntersectionObserver(function(e){if(e[0].isIntersecting){ob.disconnect();setTimeout(fire,100)}},{threshold:.1});ob.observe(g)}else setTimeout(fire,300)})();';
                $_inlineJs = ob_get_clean();
                wp_add_inline_script('instinctor-frontend-js', $_inlineJs, 'after');

            } elseif ($divStyle === 'aurora') {
                // Aurora: wide blurred multi-color ribbon. Gradient is periodic (starts and ends on
                // the same colour) so when background-position shifts by one full period the next
                // tile is identical — no sharp edge at the loop point.
                $h = max(4, $t * 3 + 2);
                $grad = 'linear-gradient(90deg,' . $c . ' 0%,' . $c2 . ' 25%,#ff00ff 50%,#ffaa00 75%,' . $c . ' 100%)';
                echo '<span class="impulse-divider-line" style="display:block;height:' . esc_attr($h) . 'px;border-radius:' . esc_attr($h) . 'px;background:' . esc_attr($grad) . ';background-size:200% 100%;background-repeat:repeat-x;animation:impulseDivAurora 6s linear infinite;filter:blur(' . esc_attr(max(2, $t)) . 'px) brightness(1.4)"></span>';
            
            } elseif ($divStyle === 'prism') {
                // Prism: rainbow spectrum drifting slowly — no scan sweep, the spectrum motion is the effect.
                $h = max(3, $t + 1);
                $g = $t * 2 + 4;
                wp_add_inline_style('instinctor-page-inline', '@keyframes ' . $uid . 'pd{0%{background-position:0% 0}100%{background-position:200% 0}}');
                echo '<span class="impulse-divider-line" style="display:block;position:relative;height:' . esc_attr($h) . 'px;border-radius:' . esc_attr($h) . 'px;overflow:hidden;background:linear-gradient(90deg,#ff0040,#ff6000,#ffd000,#00e060,#00b0ff,#8040ff,#ff0040,#ff6000,#ffd000,#00e060,#00b0ff,#8040ff);background-size:200% 100%;animation:' . esc_attr($uid) . 'pd 4s linear infinite;box-shadow:0 0 ' . esc_attr($g) . 'px rgba(255,200,100,0.5),0 0 ' . esc_attr($g*2) . 'px rgba(100,100,255,0.25)"></span>';
            
            } elseif ($divStyle === 'stripes') {
                $h = max(3, $t + 1);
                // Gap colour = color2 when set, transparent otherwise. Empty
                // gaps show through to whatever's behind the divider; coloured
                // gaps give a two-tone striped band. (Stripes joined the
                // dual-color set in 2.0.914.)
                $_stripeGap = $settings['color2'] ?? '';
                if (!$_stripeGap || $_stripeGap === 'transparent' || $_stripeGap === 'rgba(0,0,0,0)') {
                    $_stripeGap = 'transparent';
                } else {
                    $_stripeGap = esc_attr($_stripeGap);
                }
                wp_add_inline_style('instinctor-page-inline', '@keyframes ' . $uid . 'sp{0%{background-position:0 0}100%{background-position:56px 0}}');
                echo '<span class="impulse-divider-line" style="display:block;height:' . esc_attr($h) . 'px;border-radius:' . esc_attr($h) . 'px;overflow:hidden;background:repeating-linear-gradient(135deg,' . esc_attr($c) . ' 0px,' . esc_attr($c) . ' 20px,' . esc_attr($_stripeGap) . ' 20px,' . esc_attr($_stripeGap) . ' 40px);animation:' . esc_attr($uid) . 'sp 1.2s linear infinite"></span>';

            } else {
                // Fallback
                echo '<span class="impulse-divider-line" style="display:block;height:0;border-bottom:' . esc_attr($t) . 'px solid ' . esc_attr($c) . '"></span>';
            }
        }
    }

if (!function_exists('impulse_btn_metrics_from_height')) {
    /**
     * Derive (font_size, padding_y, padding_x) from a button height (px).
     * Linear interpolation between two anchor points chosen so the original
     * S/M/L/XL preset values land cleanly on the curve at H=36/44/52/64.
     *   H=28  -> font 13, py 10, px 18
     *   H=80  -> font 22, py 24, px 52
     * Both endpoints clamp.
     */
    function impulse_btn_metrics_from_height(int $height): array {
        $h = max(28, min(80, $height));
        $t = ($h - 28) / 52.0;  // 0..1
        $font  = (int) round(13 + $t * 9);   // 13 -> 22
        $padY  = (int) round(10 + $t * 14);  // 10 -> 24
        $padX  = (int) round(18 + $t * 34);  // 18 -> 52
        return [$font, $padY, $padX];
    }
}

// ─────────────────────────────────────────────────────────────────
// Divergent-helper consolidation (Phase 1 cleanup, v2.1.265).
// These three helpers had real divergence between FE/ED inline copies
// (not just whitespace). Resolutions:
//   - impulse_text_safe_breaks: FE canonical (identical logic, more comments)
//   - impulse_render_bg_shapes: FE canonical (correct $isEditor=false
//     default; gates device_val on $isEditor; emits responsive @media)
//   - impulse_get_responsive: unified to 4-arg signature with optional
//     $device. Callers passing 3 args get the GLOBALS-based device read
//     (editor's preview-toolbar device); 4-arg callers get their explicit
//     value (frontend renderers in @media-breakpoint loops). The 4th
//     dupe — impulse_get_lucide_icon — was dead code (no callers in
//     production; the real Lucide renderer is impulse_lucide_svg in
//     includes/lucide-icons.php) and was simply deleted.
// ─────────────────────────────────────────────────────────────────

if (!function_exists('impulse_text_safe_breaks')) {
    /**
     * HTML-escape user-entered text but preserve a small whitelist of inline
     * formatting that writers can type directly: `&nbsp;`, `<br>`, plus
     * `<b>`, `<i>`, `<u>`, `<strong>`, `<em>`, `<sup>`, `<sub>`, `<small>`,
     * `<mark>` (and their closing tags). Used for Animator / Display
     * headings where `esc_html()` would otherwise show the raw markup as
     * literal text. Canvas-rendered animator presets ignore the inline
     * formatting (text is sampled flat) but still respect line breaks.
     */
    function impulse_text_safe_breaks($text) {
        $escaped = esc_html((string)$text);
        // Restore the safe entities. esc_html turned `&` → `&amp;` and
        // `<>` → `&lt;&gt;`; map both case variants for robustness.
        static $map = null;
        if ($map === null) {
            $tags = ['br','b','i','u','strong','em','sup','sub','small','mark'];
            $map  = ['&amp;nbsp;' => '&nbsp;'];
            foreach ($tags as $t) {
                // Open tags: <t>, <t/>, <t />
                $map['&lt;' . $t . '&gt;']     = '<' . $t . '>';
                $map['&lt;' . $t . '/&gt;']    = '<' . $t . '>';
                $map['&lt;' . $t . ' /&gt;']   = '<' . $t . '>';
                $map['&lt;' . strtoupper($t) . '&gt;']  = '<' . $t . '>';
                $map['&lt;' . strtoupper($t) . '/&gt;'] = '<' . $t . '>';
                // Close tags (self-closing tags like br don't need this but it's harmless)
                $map['&lt;/' . $t . '&gt;'] = '</' . $t . '>';
                $map['&lt;/' . strtoupper($t) . '&gt;'] = '</' . $t . '>';
            }
        }
        $out = strtr($escaped, $map);
        // Restore ANY HTML entity the writer typed verbatim:
        //   &amp;NAMED;  → &NAMED;   (e.g. &amp;, &copy;, &trade;, &mdash;,
        //                             &hellip;, &deg;, &times;, &laquo;…)
        //   &amp;#NN;    → &#NN;     (decimal numeric)
        //   &amp;#xNN;   → &#xNN;    (hex numeric)
        // esc_html replaced `&` with `&amp;`, leaving the original entity
        // intact one level deeper. This regex peels that single &amp; off.
        // XSS-safe: `<` and `>` remain escaped, so no tag can be injected
        // via this path. Locked 2026-06-08.
        $out = preg_replace(
            '/&amp;(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]{1,31});/',
            '&$1;',
            $out
        );
        // Native line breaks — promote \r\n / \n to <br>. Animator's panel
        // stores Enter as \n (textarea), and the canvas sampling path also
        // splits on \n, so the DOM and canvas stay in sync. 2.0.854.
        $out = str_replace(["\r\n", "\n"], '<br>', $out);
        return $out;
    }
}


if (!function_exists('impulse_get_responsive')) {
    /**
     * Read a per-device setting from a $settings array with cascade fallback.
     *
     * 4-arg form passes $device explicitly (used by frontend renderers that
     * loop over breakpoints to emit @media rules). 3-arg form (without
     * $device) reads `$GLOBALS['impulse_preview_device']` — the editor sets
     * that to the current preview-toolbar device; on the live frontend it's
     * absent, which falls through to 'desktop'.
     *
     * Cascade:
     *   - mobile  → $key_mobile → $key_tablet → $key → $default
     *   - tablet  → $key_tablet → $key → $default
     *   - desktop → $key → $default
     */
    function impulse_get_responsive($settings, $name, $default = null, $device = null) {
        if ($device === null) {
            $device = $GLOBALS['impulse_preview_device'] ?? 'desktop';
        }
        if ($device === 'desktop') {
            return $settings[$name] ?? $default;
        }
        if ($device === 'mobile') {
            if (isset($settings[$name . '_mobile'])) return $settings[$name . '_mobile'];
            if (isset($settings[$name . '_tablet'])) return $settings[$name . '_tablet'];
            return $settings[$name] ?? $default;
        }
        // tablet
        if (isset($settings[$name . '_tablet'])) return $settings[$name . '_tablet'];
        return $settings[$name] ?? $default;
    }
}


if (!function_exists('impulse_bg_shape_device_css')) {
    function impulse_bg_shape_device_css($shape, $device, $uid) {
        $props = [];
        $suffix = '_' . $device;

        if (isset($shape['anchor' . $suffix]) && $shape['anchor' . $suffix] !== '') {
            $ap = impulse_anchor_pos($shape['anchor' . $suffix]);
            $props[] = 'left:' . $ap['left'];
            $props[] = 'top:' . $ap['top'];
            $props[] = '--tx:' . $ap['tx'];
            $props[] = '--ty:' . $ap['ty'];
        }
        if (isset($shape['ox' . $suffix]) && $shape['ox' . $suffix] !== '') {
            $props[] = '--ox:' . floatval($shape['ox' . $suffix]) . 'cqw';
        }
        if (isset($shape['oy' . $suffix]) && $shape['oy' . $suffix] !== '') {
            $props[] = '--oy:' . floatval($shape['oy' . $suffix]) . 'cqw';
        }
        if (isset($shape['size_pct' . $suffix]) && $shape['size_pct' . $suffix] !== '') {
            $props[] = 'width:' . intval($shape['size_pct' . $suffix]) . '%';
        }
        if (isset($shape['opacity' . $suffix]) && $shape['opacity' . $suffix] !== '') {
            $props[] = 'opacity:' . (floatval($shape['opacity' . $suffix]) / 100);
        }
        if (isset($shape['rotation' . $suffix]) && $shape['rotation' . $suffix] !== '') {
            $props[] = '--rot:' . intval($shape['rotation' . $suffix]) . 'deg';
        }
        if (isset($shape['flip_h' . $suffix])) {
            $props[] = '--flip:' . ($shape['flip_h' . $suffix] ? '-1' : '1');
        }
        if (isset($shape['visible' . $suffix]) && $shape['visible' . $suffix] === false) {
            $props[] = 'display:none';
        }

        if (empty($props)) return '';
        return '.impulse-bgs-' . esc_attr($uid) . '{' . implode('!important;', $props) . '!important}';
    }
}


if (!function_exists('impulse_render_bg_shapes')) {
    function impulse_render_bg_shapes($settings, $device = 'desktop', $isEditor = false) {
        $shapesJson = $settings['bg_shapes'] ?? '';
        if (empty($shapesJson)) return;

        $shapes = json_decode($shapesJson, true);
        if (!is_array($shapes) || empty($shapes)) return;

        echo '<div class="impulse-bg-shape-layer" style="position:absolute;inset:0;pointer-events:none;z-index:3;overflow:hidden;container-type:inline-size">';
        $responsiveCss = '';

        foreach ($shapes as $idx => $shape) {
            $svg = $shape['svg'] ?? '';
            $viewbox = $shape['viewbox'] ?? '0 0 24 24';
            $color = $shape['color'] ?? '#999999';
            $uid = $shape['uid'] ?? 'shp_' . $idx;

            $anchor   = $shape['anchor']   ?? 'cc';
            $ox       = $shape['ox']       ?? 0;
            $oy       = $shape['oy']       ?? 0;
            $sizePct  = $shape['size_pct'] ?? 30;
            $opacity  = $shape['opacity']  ?? 100;
            $rotation = $shape['rotation'] ?? 0;
            $visible  = $shape['visible']  ?? true;
            $flipH    = $shape['flip_h']   ?? false;

            if ($isEditor) {
                $anchor   = impulse_device_val($shape, 'anchor',   $device, $anchor);
                $ox       = impulse_device_val($shape, 'ox',       $device, $ox);
                $oy       = impulse_device_val($shape, 'oy',       $device, $oy);
                $sizePct  = impulse_device_val($shape, 'size_pct', $device, $sizePct);
                $opacity  = impulse_device_val($shape, 'opacity',  $device, $opacity);
                $rotation = impulse_device_val($shape, 'rotation', $device, $rotation);
                $visible  = impulse_device_val($shape, 'visible',  $device, $visible);
                $flipH    = impulse_device_val($shape, 'flip_h',   $device, $flipH);
            }

            if (!$visible && !$isEditor) continue;

            $ap = impulse_anchor_pos($anchor);

            $style  = 'position:absolute;';
            $style .= 'left:' . $ap['left'] . ';top:' . $ap['top'] . ';';
            $style .= 'width:' . intval($sizePct) . '%;aspect-ratio:1;';
            $style .= '--tx:' . $ap['tx'] . ';--ty:' . $ap['ty'] . ';';
            $style .= '--ox:' . floatval($ox) . 'cqw;--oy:' . floatval($oy) . 'cqw;';
            $style .= '--rot:' . intval($rotation) . 'deg;';
            $style .= '--flip:' . ($flipH ? '-1' : '1') . ';';
            $style .= 'transform:translate(var(--tx),var(--ty)) translate(var(--ox),var(--oy)) rotate(var(--rot)) scaleX(var(--flip));';
            $style .= 'opacity:' . (floatval($opacity) / 100) . ';';
            if ($isEditor) {
                $style .= 'pointer-events:auto;cursor:move;';
            }

            $elClass = 'impulse-bg-shape-el impulse-bgs-' . $uid;

            // Depth for parallax
            $shapeDepth = intval($shape['depth'] ?? 0);

            // N-color support via CSS custom properties
            $colors = isset($shape['colors']) && is_array($shape['colors']) ? $shape['colors'] : [];
            if (empty($colors)) {
                $colors = [$color];
                if (!empty($shape['color2'])) $colors[] = $shape['color2'];
            }
            $hasColorGroups = strpos($svg, 'data-c=') !== false || strpos($svg, 'data-s=') !== false;
            if ($hasColorGroups && count($colors) >= 1) {
                // Inject inline fill/stroke directly (SVG style blocks leak across shapes)
                foreach ($colors as $ci => $cc) {
                    $cIdx = $ci + 1;
                    $svg = str_replace('data-c="' . $cIdx . '"', 'data-c="' . $cIdx . '" fill="var(--c' . $cIdx . ',' . esc_attr($cc) . ')"', $svg);
                    $svg = str_replace('data-s="' . $cIdx . '"', 'data-s="' . $cIdx . '" stroke="var(--c' . $cIdx . ',' . esc_attr($cc) . ')"', $svg);
                }
            }
            
            echo '<div class="' . esc_attr($elClass) . '"';
            if ($isEditor) echo ' data-shape-idx="' . intval($idx) . '"';
            if ($shapeDepth !== 0) echo ' data-depth="' . intval($shapeDepth) . '"';
            echo ' style="' . esc_attr($style) . '">';

            // Image graphic type
            if (!empty($shape['type']) && $shape['type'] === 'image' && !empty($shape['image_url'])) {
                echo '<img src="' . esc_url((string)($shape['image_url'] ?? '')) . '" alt="' . esc_attr($shape['name'] ?? 'Graphic') . '" style="width:100%;height:100%;object-fit:contain;display:block" loading="lazy">';
            } elseif ($hasColorGroups) {
                echo '<svg viewBox="' . esc_attr($viewbox) . '" width="100%" height="100%" style="display:block">' . wp_kses($svg, impulse_svg_allowed()) . '</svg>';
            } else {
                echo '<svg viewBox="' . esc_attr($viewbox) . '" width="100%" height="100%" fill="' . esc_attr($colors[0] ?? $color) . '" stroke="none" style="display:block">' . wp_kses($svg, impulse_svg_allowed()) . '</svg>';
            }
            echo '</div>';
            
            // Generate responsive CSS for frontend (not editor)
            if (!$isEditor) {
                // Tablet overrides
                $tabletCss = impulse_bg_shape_device_css($shape, 'tablet', $uid);
                if ($tabletCss) $responsiveCss .= '@media(max-width:1024px){' . $tabletCss . '}';
                
                // Mobile overrides
                $mobileCss = impulse_bg_shape_device_css($shape, 'mobile', $uid);
                if ($mobileCss) $responsiveCss .= '@media(max-width:767px){' . $mobileCss . '}';
            }
        }
        
        echo '</div>';

        if ($responsiveCss) {
            // Per-element responsive overrides ride the registered inline-CSS
            // handle (same pattern as the other renderers) instead of a raw
            // <style> tag, satisfying WP.org's enqueue rule.
            wp_add_inline_style('instinctor-page-inline', $responsiveCss);
        }
    }
}

