<?php
if ( ! defined( 'ABSPATH' ) ) { exit; }
ob_start();

// Set preview context flag — TierManager uses this to bypass license gates in the builder
if (!defined('INSTINCTOR_PREVIEW_CONTEXT')) {
    // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only preview-context detection from $_GET (isset only); no state change, so no nonce applies.
    define('INSTINCTOR_PREVIEW_CONTEXT', isset($_GET['impulse_preview']) || isset($_GET['impulse_builder']));
    // phpcs:enable WordPress.Security.NonceVerification.Recommended
}

// Built-in analytics. Fires once per genuine public page view; no-ops for
// editor previews, admin paths, bots, XHR. Server-side only, no JS, no
// cookies — see AnalyticsService::record() for the skip rules. The
// class_exists guard keeps this safe during migration / partial deploys.
$impulse_view_token = '';
if (class_exists('\\Instinctor\\Services\\AnalyticsService')) {
    // Resolve the REAL page id for analytics attribution. The old code passed
    // the WordPress-only $post->ID, which is null on standalone — so every view
    // recorded page_id=0 and per-page analytics (exit-by-screen etc.) had nothing
    // to scope. Mirror the render path's precedence below: WP queried id →
    // INSTINCTOR_RENDER_PAGE_ID → standalone slug / front-page config. record()
    // still skips previews/admin/bots/XHR and returns '' for those.
    $_trkPid = 0;
    if (function_exists('get_queried_object_id')) $_trkPid = (int) get_queried_object_id();
    if (!$_trkPid && defined('INSTINCTOR_RENDER_PAGE_ID')) $_trkPid = (int) INSTINCTOR_RENDER_PAGE_ID;
    if (!$_trkPid) {
        $_trkSlug = trim((string) wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '/')), PHP_URL_PATH), '/');
        if ($_trkSlug === '') {
            $_trkPid = (int) \Instinctor\Core\Config::get('instinctor_front_page_id', 0);
            if (!$_trkPid) $_trkPid = (int) \Instinctor\Core\Config::get('page_on_front', 0);
        } else {
            $_trkPg = \Instinctor\Services\PageService::getByPublicSlug($_trkSlug);
            if ($_trkPg) $_trkPid = (int) ($_trkPg['id'] ?? 0);
            elseif (ctype_digit($_trkSlug)) $_trkPid = (int) $_trkSlug;
        }
    }
    $impulse_view_token = \Instinctor\Services\AnalyticsService::record($_trkPid ?: null);
}

// Shared element renderers (renderer-unification refactor — see
// docs/renderer-architecture.md). Loaded once; both this file and the
// editor preview at admin/views/impulse-preview.php require the same
// registry, so any element type extracted into includes/element-renderers/
// renders identically in both contexts.
if (defined('INSTINCTOR_PLUGIN_DIR')) {
    require_once INSTINCTOR_PLUGIN_DIR . 'includes/element-renderers/_loader.php';
} else {
    require_once __DIR__ . '/../includes/element-renderers/_loader.php';
}

/**
 * Impulse Frontend Page Template
 * 
 * @package Instinctor
 * @subpackage Templates
 * @since 2.3.0
 * @version 1.0.1
 * 
 * OVERVIEW:
 * =========
 * This template renders Impulse pages on the frontend.
 * It's loaded when a WordPress page with '_impulse_enabled' meta is viewed.
 * 
 * DATA STRUCTURE:
 * - Pages are regular WordPress 'page' post type with '_impulse_enabled' meta
 * - Elements from post meta '_impulse_elements' (array)
 * - Page settings from post meta '_impulse_settings' (array)
 * - Elements have: id, parent_id, section_type, settings, sort_order
 * 
 * RENDERING FLOW:
 * 1. Load elements from post meta
 * 2. Build hierarchical tree by parent_id
 * 3. Sort children by sort_order
 * 4. Render recursively via impulse_render_frontend_element()
 * 
 * SUPPORTED ELEMENTS:
 * - Layout: section, container, row, column, grid, stack, inline
 * - Basic: heading, text, button, image, video, spacer, divider, html
 * - Products: products, product_single, product_images, product_title,
 *   product_price, product_description, product_meta, add_to_cart,
 *   quantity_selector, categories
 * - Cart/Checkout: cart, checkout, order_received, mini_cart,
 *   cart_totals, shipping_calculator, coupon_form, countdown
 * 
 * TEXT TRANSFORMS:
 * - Applied via PHP (mb_strtoupper, mb_strtolower, mb_convert_case)
 * - CSS text-transform not used (doesn't work on uppercase input)
 * 
 * URL STRUCTURE:
 * - Uses standard WordPress page URLs
 * - Integrates with WordPress menus and SEO plugins
 * 
 * CSS CLASSES:
 * - .impulse-page: Main page wrapper
 * - .impulse-section: Section container
 * - .impulse-container: Inner container
 * - .impulse-{type}: Element-specific classes
 */

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

// Animation rendering helpers (shared with preview)
require_once INSTINCTOR_PLUGIN_DIR . 'includes/impulse-animation-helpers.php';
require_once INSTINCTOR_PLUGIN_DIR . 'includes/impulse-hero-sandbox.php';
require_once INSTINCTOR_PLUGIN_DIR . 'includes/impulse-chat-pointer.php';

/**
 * Get social network SVG icon
 * 
 * @param string $network Social network name
 * @param string $color Fill/stroke color
 * @return string SVG markup
 */
// Cylinder deformation helper — 2D GD displacement map for true pixel warping
if (!function_exists('impulse_cyl_filter_output')) {
    function impulse_cyl_filter_output($filterId, $depth) {
        // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Renderer: emits HTML/CSS/SVG composed from sanitized settings, numeric/esc_*()/uid values and internal data structures; no raw request input reaches output (zero ValidatedSanitizedInput violations in the body).
        $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%)';
    // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped
    }
}

// Get a Lucide icon SVG
if (!function_exists('impulse_get_lucide_icon')) {
function impulse_get_lucide_icon($name, $size = 20, $color = 'currentColor') {
    $icons = [
        'check' => '<polyline points="20 6 9 17 4 12"/>',
        'check-circle' => '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
        'x' => '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
        'x-circle' => '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
        'arrow-right' => '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
        'arrow-left' => '<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>',
        'chevron-right' => '<polyline points="9 18 15 12 9 6"/>',
        'chevron-down' => '<polyline points="6 9 12 15 18 9"/>',
        'star' => '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
        'heart' => '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>',
        'circle' => '<circle cx="12" cy="12" r="10"/>',
        'square' => '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
        'minus' => '<line x1="5" y1="12" x2="19" y2="12"/>',
        'plus' => '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
        'plus-circle' => '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>',
        'plus-square' => '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>',
        'check-square' => '<rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 11 12 14 16 10"/>',
        'circle-dot' => '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/>',
        'zap' => '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
        'award' => '<circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/>',
        'target' => '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
        'shield' => '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
        'shield-check' => '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/>',
        'users' => '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
        'clock' => '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
        'calendar' => '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
        'gift' => '<polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/>',
        'truck' => '<rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
        'phone' => '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
        'mail' => '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>',
        'map-pin' => '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
        'globe' => '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
        'briefcase' => '<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
        'dollar-sign' => '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
        'percent' => '<line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/>',
        'thumbs-up' => '<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>',
        'sparkles' => '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/>',
        'rocket' => '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
        'lightbulb' => '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
        'settings' => '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>'
    ];
    
    $path = isset($icons[$name]) ? $icons[$name] : $icons['check'];
    return '<svg width="' . intval($size) . '" height="' . intval($size) . '" viewBox="0 0 24 24" fill="none" stroke="' . esc_attr($color) . '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' . $path . '</svg>';
}
}

/**
 * Render a product card using Designer settings
 * 
 * @param object $product Product object from instinctor_products table
 * @param array $design Designer settings from instinctor_card_design option
 */
if (!function_exists('impulse_render_product_card')) {
    function impulse_render_product_card($product, $design, $flipCards = false, $flipEffect = 'flip-h') {
        // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Renderer: emits HTML/CSS/SVG composed from sanitized settings, numeric/esc_*()/uid values and internal data structures; no raw request input reaches output (zero ValidatedSanitizedInput violations in the body).
        if ($flipCards) ob_start();
        $elements = $design['elements'] ?? ['image', 'title', 'price', 'add_to_cart'];
        $cardBg = $design['card']['background'] ?? '#ffffff';
        $cardRadius = $design['card']['border_radius'] ?? 0;
        $cardBorderWidth = $design['card']['border_width'] ?? 1;
        $cardBorderColor = $design['card']['border_color'] ?? '#e0e0e0';
        $hoverEffect = $design['card']['hover_effect'] ?? 'lift';
        
        $thumb = '';
        if (!empty($product->images)) {
            $images = json_decode($product->images, true);
            if (!empty($images[0]['url'])) $thumb = $images[0]['url'];
        }
        // Fallback: featured_image is a WP attachment ID — resolve to URL
        if (!$thumb && !empty($product->featured_image)) {
            $url = function_exists('wp_get_attachment_image_url')
                ? wp_get_attachment_image_url(intval($product->featured_image), 'large')
                : '';
            if ($url) $thumb = $url;
        }
        $hasSale = $product->sale_price && $product->sale_price < $product->price;
        $link = home_url('/product/' . $product->slug);
        
        // Card styles
        // For flip cards, box-shadow goes on outer wrapper (doesn't glitch during 3D transform)
        // For normal cards, shadow is handled by the grid-level CSS injection
        $cardStyle = 'background:' . esc_attr($cardBg) . ';';
        $cardStyle .= 'border-radius:' . intval($cardRadius) . 'px;';
        if (intval($cardBorderWidth) > 0) {
            $cardStyle .= 'border:' . intval($cardBorderWidth) . 'px solid ' . esc_attr($cardBorderColor) . ';';
        } else {
            $cardStyle .= 'border:none;';
        }
        // Shadow for non-flip cards
        $cardShadowVal = $design['card']['shadow'] ?? '';
        $cardShadowMap = [
            'small'  => '0 1px 4px rgba(0,0,0,0.10)',
            'medium' => '0 4px 12px rgba(0,0,0,0.14)',
            'large'  => '0 10px 28px rgba(0,0,0,0.18)'
        ];
        if ($cardShadowVal && isset($cardShadowMap[$cardShadowVal])) {
            $cardStyle .= 'box-shadow:' . $cardShadowMap[$cardShadowVal] . ';';
        }
        $cardStyle .= 'overflow:hidden;position:relative;transition:all 0.3s ease-in-out;';
        
        echo '<div class="instinctor-card-designed impulse-pcard" data-product-id="' . intval($product->id) . '" style="' . esc_attr($cardStyle) . '">';
        
        // Resolve badge design with defaults (handles old saved data missing sale_badge key)
        $saleBadgeDef = ['bg_color' => '#1d2327', 'text_color' => '#ffffff', 'text' => 'Sale', 'featured_bg_color' => '#1d2327', 'featured_text' => 'Featured'];
        $saleBadge = array_merge($saleBadgeDef, is_array($design['sale_badge'] ?? null) ? $design['sale_badge'] : []);
        $badgeEnabled = !isset($design['sale_badge']['enabled']) || !empty($design['sale_badge']['enabled']);

        // Image
        if (in_array('image', $elements)) {
            $imgRadius = $design['image']['border_radius'] ?? 0;
            $aspectRatio = $design['image']['aspect_ratio'] ?? 'square';
            $aspectMap = ['square' => '100%', 'portrait' => '133%', 'landscape' => '75%', 'wide' => '56.25%'];
            $paddingBottom = $aspectMap[$aspectRatio] ?? '100%';

            echo '<div class="instinctor-product-image" style="position:relative">';
            if ($thumb) {
                echo '<a href="' . esc_url($link) . '" style="display:block;padding-bottom:' . esc_attr($paddingBottom) . ';position:relative;background:#f8f8f8">';
                echo '<img src="' . esc_url($thumb) . '" alt="' . esc_attr((string)($product->name ?? '')) . '" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain">';
                echo '</a>';
            } else {
                // Placeholder — maintains aspect ratio so cards stay aligned
                echo '<a href="' . esc_url($link) . '" style="display:block;padding-bottom:' . esc_attr($paddingBottom) . ';position:relative;background:#f0f0f0">';
                echo '<div style="position:absolute;top:0;left:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center">';
                echo '<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="#ccc" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>';
                echo '</div>';
                echo '</a>';
            }

            // Sale badge (on image or placeholder)
            if ($hasSale && $badgeEnabled) {
                echo '<span style="position:absolute;top:8px;left:8px;background:' . esc_attr((string)($saleBadge['bg_color'] ?? '')) . ';color:' . esc_attr((string)($saleBadge['text_color'] ?? '')) . ';padding:4px 10px;border-radius:4px;font-size:12px;font-weight:600">' . esc_html((string)($saleBadge['text'] ?? '')) . '</span>';
            }
            echo '</div>';
        }
        
        // Content area
        $contentPadding = $design['card']['padding'] ?? 12;
        echo '<div style="padding:' . intval($contentPadding) . 'px">';
        
        // Title
        if (in_array('title', $elements)) {
            $titleSize = $design['title']['font_size'] ?? 16;
            $titleWeight = $design['title']['font_weight'] ?? '600';
            $titleColor = $design['title']['color'] ?? '#1d2327';
            // Title grows naturally — line-clamp caps at 3 lines, CSS grid row equalizes card heights
            $titleSlotH = intval(ceil($titleSize * 1.4 * 3));
            echo '<div style="overflow:hidden;flex-shrink:0;">';
            echo '<h3 class="instinctor-product-title" style="margin:0 0 8px;font-size:' . intval($titleSize) . 'px;font-weight:' . esc_attr($titleWeight) . ';color:' . esc_attr($titleColor) . ';line-height:1.4;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical">';
            echo '<a href="' . esc_url($link) . '" class="imp-link-reset">' . esc_html((string)($product->name ?? '')) . '</a>';
            echo '</h3>';
            echo '</div>';
        }
        
        // Rating
        if (in_array('rating', $elements)) {
            $starColor = $design['rating']['star_color'] ?? '#fbbf24';
            $ratingMargin = $design['rating']['margin_top'] ?? 8;
            // Get actual rating from reviews if available
            echo '<div class="instinctor-product-rating" style="margin-top:' . intval($ratingMargin) . 'px;color:' . esc_attr($starColor) . ';font-size:14px">★★★★☆</div>';
        }
        
        // Price
        if (in_array('price', $elements)) {
            $priceSize = $design['price']['font_size'] ?? 18;
            $priceWeight = $design['price']['font_weight'] ?? '700';
            $priceColor = $design['price']['color'] ?? '#1d2327';
            $saleColor = $design['price']['sale_color'] ?? '#dc2626';
            $priceMargin = $design['price']['margin_top'] ?? 8;

            // Subscription interval label
            $intervalLabel = '';
            if (($product->product_type ?? '') === 'subscription') {
                $meta = json_decode($product->meta ?? '{}', true);
                $interval = $meta['subscription_interval'] ?? 'monthly';
                $count    = (int)($meta['subscription_interval_count'] ?? 1);
                $singularMap = ['weekly' => 'week', 'monthly' => 'month', 'yearly' => 'year'];
                $pluralMap   = ['weekly' => 'weeks', 'monthly' => 'months', 'yearly' => 'years'];
                if ($count > 1) {
                    $unit = $pluralMap[$interval] ?? $interval;
                    $intervalLabel = ' / ' . $count . ' ' . $unit;
                } else {
                    $unit = $singularMap[$interval] ?? $interval;
                    $intervalLabel = ' / ' . $unit;
                }
            }

            echo '<div class=\"instinctor-product-price\" style=\"margin-top:' . intval($priceMargin) . 'px;padding-bottom:4px;font-size:' . intval($priceSize) . 'px\">';
            if ($hasSale) {
                echo '<span style=\"color:#999;text-decoration:line-through;margin-right:8px;font-size:' . (esc_attr($priceSize - 2)) . 'px\">$' . number_format((float)$product->price, 2) . '</span>';
                echo '<span style=\"font-weight:' . esc_attr($priceWeight) . ';color:' . esc_attr($saleColor) . '\">$' . number_format((float)$product->sale_price, 2) . esc_html($intervalLabel) . '</span>';
            } else {
                echo '<span style=\"font-weight:' . esc_attr($priceWeight) . ';color:' . esc_attr($priceColor) . '\">$' . number_format((float)$product->price, 2) . esc_html($intervalLabel) . '</span>';
            }
            // Trial badge
            if (($product->product_type ?? '') === 'subscription') {
                $meta = $meta ?? json_decode($product->meta ?? '{}', true);
                $trialDays = (int)($meta['trial_days'] ?? 0);
                if ($trialDays > 0) {
                    echo '<div style=\"font-size:11px;color:#888;margin-top:2px\">' . intval($trialDays) . '-day free trial</div>';
                }
            }
            echo '</div>';
        }
        
        // Spacer to push price to same position as ATC button on back face
        if ($flipCards) {
            echo '<div style="height:26px"></div>'; // matches back-face ATC button height
        }
        // Add to Cart button — hidden on front face when flip is active (button lives on back)
        if (in_array('add_to_cart', $elements) && !$flipCards) {
            $btnBg = $design['add_to_cart']['bg_color'] ?? '#1d2327';
            $btnColor = $design['add_to_cart']['text_color'] ?? '#ffffff';
            $btnRadius = $design['add_to_cart']['border_radius'] ?? 0;
            $btnText = $design['add_to_cart']['button_text'] ?? 'Add to Cart';
            $btnFullWidth = $design['add_to_cart']['full_width'] ?? true;
            $btnMargin = $design['add_to_cart']['margin_top'] ?? 12;

            $btnWidth = $btnFullWidth ? 'width:100%;' : '';

            echo '<button class="instinctor-add-to-cart-btn" data-product-id="' . intval($product->id) . '" style="' . esc_attr($btnWidth) . 'margin-top:' . intval($btnMargin) . 'px;padding:10px 16px;background:' . esc_attr($btnBg) . ';color:' . esc_attr($btnColor) . ';border:none;border-radius:' . intval($btnRadius) . 'px;cursor:pointer;font-weight:600;font-size:14px">' . esc_html($btnText) . '</button>';
        }
        
        echo '</div>'; // Close content area
        echo '</div>'; // Close card

        if ($flipCards) {
            $frontHtml = ob_get_clean();
            // Build back face
            $btn = $design['add_to_cart'] ?? [];
            $prc = $design['price'] ?? [];
            $cardBg  = esc_attr($design['card']['background'] ?? '#ffffff');
            $cardRad = intval($design['card']['border_radius'] ?? 8);
            $cardBW  = intval($design['card']['border_width'] ?? 1);
            $cardBC  = esc_attr($design['card']['border_color'] ?? '#e0e0e0');
            $tColor  = esc_attr($design['title']['color'] ?? '#1d2327');
            $pColor  = esc_attr($prc['color'] ?? '#1d2327');
            $btnBgF  = esc_attr($btn['bg_color'] ?? '#1d2327');
            $btnTxtF = esc_attr($btn['text_color'] ?? '#ffffff');
            $btnRadF = intval($btn['border_radius'] ?? 6);
            $btnLbl  = esc_html($btn['button_text'] ?? 'Add to Cart');
            $hasSaleF = $product->sale_price && $product->sale_price < $product->price;
            if ($hasSaleF) {
                $priceBackHtml = '<span style="color:' . esc_attr($prc['sale_color'] ?? '#dc2626') . ';font-weight:700">$' . number_format($product->sale_price, 2) . '</span><del style="color:#999;font-size:0.8em;margin-left:6px">$' . number_format($product->price, 2) . '</del>';
            } else {
                $eff = floatval($product->price) > 0 ? $product->price : $product->sale_price;
                $priceBackHtml = '<span style="font-weight:700">$' . number_format($eff, 2) . '</span>';
            }
            $descText = wp_strip_all_tags($product->short_description ?? $product->description ?? '');
            $_isBlankPlaceholder = (intval($product->id ?? 0) === 0 && trim((string) ($product->name ?? '')) === '');
            if (!$descText && !$_isBlankPlaceholder) $descText = 'No description available.';
            $backStyle = 'background:' . $cardBg . ';border-radius:' . $cardRad . 'px;border:' . $cardBW . 'px solid ' . $cardBC . ';display:flex;flex-direction:column;';
            $skuText = trim($product->sku ?? '');
            $back  = '<div class="impulse-flip-back" style="' . esc_attr($backStyle) . '">';
            $back .= '<div style="padding:16px 16px 6px;flex-shrink:0">';
            $back .= '<h3 style="margin:0 0 4px;font-size:14px;font-weight:600;color:' . $tColor . ';line-height:1.4">' . esc_html((string)($product->name ?? '')) . '</h3>';
            if ($skuText) $back .= '<div style="font-size:11px;color:#999">' . esc_html($skuText) . '</div>';
            $back .= '</div>';
            $back .= '<div style="flex:1;padding:0 16px 12px;font-size:12px;color:#666;line-height:1.6;overflow:auto">' . esc_html($descText) . '</div>';
            $back .= '<div style="padding:0 16px 16px;flex-shrink:0">';
            $back .= '<button class="instinctor-add-to-cart-btn impulse-pcard-btn" data-product-id="' . intval($product->id) . '" style="width:100%;padding:10px 16px;background:' . $btnBgF . ';color:' . $btnTxtF . ';border:none;border-radius:' . $btnRadF . 'px;cursor:pointer;font-weight:600;font-size:14px">' . $btnLbl . '</button>';
            $back .= '</div>';
            $back .= '</div>';
            // Shadow on outer wrapper — not on inner pcard (box-shadow glitches during 3D rotation)
            $shadows = ['none'=>'none','small'=>'0 1px 3px rgba(0,0,0,0.1)','medium'=>'0 4px 6px rgba(0,0,0,0.1)','large'=>'0 10px 25px rgba(0,0,0,0.15)'];
            $outerShadow = $shadows[$design['card']['shadow'] ?? 'small'] ?? 'none';
            $safeEff = str_replace('_','-',preg_replace('/[^a-z_-]/','', $flipEffect ?: 'flip-h'));
            echo '<div class="impulse-pcard impulse-flip-card pfc-' . esc_attr($safeEff) . '" data-flip="true" data-flip-effect="' . esc_attr($safeEff) . '" data-product-id="' . intval($product->id) . '" style="cursor:pointer;box-shadow:' . esc_attr($outerShadow) . ';border-radius:' . intval($design['card']['border_radius'] ?? 8) . 'px">'
               . '<div class="impulse-flip-inner">'
               . '<div class="impulse-flip-front">' . $frontHtml . '</div>'
               . $back
               . '</div></div>';
        }
    // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped
    }
}

// Helper function to extract product images (shared with preview)
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;
    }
}

// === Conditional function definitions (must be before rendering) ===
// PHP only hoists unconditional functions; these are inside if() blocks
// and must execute before impulse_render_frontend_element calls them.

if (!function_exists('impulse_resolve_depth_map')) {
    /**
     * Rescue for legacy pages where the plugin stored a LOCAL wp-content depth-map URL
     * instead of the platform URL. Platform's depth map is `<image>a.png` in the same
     * directory as the source image. If the saved depth map is a stale local path, derive
     * the correct platform URL from the source image URL.
     */
    function impulse_resolve_depth_map($depthMap, $imageUrl) {
        if (!$depthMap || !$imageUrl) return $depthMap;
        // Standalone installs store depth maps under /storage/uploads/ — those
        // paths are CANONICAL, not stale. Only legacy WP wp-content paths
        // need to be redirected to the Platform's `imagea.png` convention.
        // 2.1.867 — before this guard, every standalone depth_map URL got
        // mangled into a nonexistent Platform-style derived path, leaving
        // the depth canvas with a 404 texture (= black page).
        if (strpos($depthMap, '/storage/uploads/') === 0
            || strpos($depthMap, '/storage/uploads/') === 1) {
            return $depthMap;
        }
        $isStaleLocal = (strpos($depthMap, '/wp-content/uploads/instinctor-images/') !== false);
        if (!$isStaleLocal) return $depthMap;
        // Derive: image.jpg → imagea.png (same dir, `a.png` suffix before extension)
        $derived = preg_replace('/(\.[^\/\.?]+)(\?.*)?$/', 'a.png$2', $imageUrl);
        return $derived ?: $depthMap;
    }
}

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_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_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_render_bg_shapes')) {

    if (!function_exists('impulse_render_divider_line')) {
        function impulse_render_divider_line($divStyle, $t, $c, $settings) {
            // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Renderer: emits HTML/CSS/SVG composed from sanitized settings, numeric/esc_*()/uid values and internal data structures; no raw request input reaches output (zero ValidatedSanitizedInput violations in the body).
            $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.
                $h = max(8, $t * 3);
                $sw = max(1, $t);
                $amp = max(4, (int) round($t * 4));   // jag amplitude, viewBox-y units
                $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)})();';
                $_pgJs1 = ob_get_clean();
                wp_add_inline_script('instinctor-frontend-js', $_pgJs1, '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>';
            }
        // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }
    }



}

if (!function_exists('impulse_anchor_pos')) {
    function impulse_anchor_pos($anchor) {
        switch ($anchor) {
            case 'tl': return ['left' => '0',    'top' => '0'];
            case 'tr': return ['left' => '100%', 'top' => '0'];
            case 'bl': return ['left' => '0',    'top' => '100%'];
            case 'br': return ['left' => '100%', 'top' => '100%'];
            case 'cc':
            default:   return ['left' => '50%',  'top' => '50%'];
        }
    }
}
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 .= '--ox:' . floatval($ox) . 'cqw;--oy:' . floatval($oy) . 'cqw;';
            $style .= '--rot:' . intval($rotation) . 'deg;';
            $style .= '--flip:' . ($flipH ? '-1' : '1') . ';';
            $style .= 'transform:translate(-50%,-50%) 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="' . esc_attr($cc) . '"', $svg);
                    $svg = str_replace('data-s="' . $cIdx . '"', 'data-s="' . $cIdx . '" stroke="' . 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) {
            wp_add_inline_style('instinctor-page-inline', $responsiveCss);
        }
    }
}

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'];
        }
        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_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;
    }
}

// Generate responsive CSS overrides for tablet/mobile
// $settings: element settings array
// $selector: CSS selector (e.g. '#cbtn-123')
// $map: array of [setting_name => [css_property, unit]] or [setting_name => callback($val)]
// Returns CSS string with @media blocks (empty string if no overrides)
if (!function_exists('impulse_responsive_css')) {
    function impulse_responsive_css($settings, $selector, $map) {
        $tabletCss = '';
        $mobileCss = '';
        foreach ($map as $settingName => $rule) {
            if (is_callable($rule)) {
                // Custom callback: returns CSS declaration string or empty
                $tv = $settings[$settingName . '_tablet'] ?? null;
                $mv = $settings[$settingName . '_mobile'] ?? null;
                if ($tv !== null && $tv !== '') {
                    $decl = $rule($tv);
                    // Ensure !important on callback output
                    if ($decl && strpos($decl, '!important') === false) {
                        $decl = rtrim($decl, ';') . '!important;';
                    }
                    $tabletCss .= $decl;
                }
                if ($mv !== null && $mv !== '') {
                    $decl = $rule($mv);
                    if ($decl && strpos($decl, '!important') === false) {
                        $decl = rtrim($decl, ';') . '!important;';
                    }
                    $mobileCss .= $decl;
                }
            } else {
                $cssProp = $rule[0];
                $unit = $rule[1] ?? 'px';
                $important = true; // always !important to override inline styles
                $imp = '!important';
                $tv = $settings[$settingName . '_tablet'] ?? null;
                $mv = $settings[$settingName . '_mobile'] ?? null;
                if ($tv !== null && $tv !== '') $tabletCss .= $cssProp . ':' . intval($tv) . $unit . $imp . ';';
                if ($mv !== null && $mv !== '') $mobileCss .= $cssProp . ':' . intval($mv) . $unit . $imp . ';';
            }
        }
        $css = '';
        if ($tabletCss) $css .= '@media(max-width:1024px){' . $selector . '{' . $tabletCss . '}}';
        if ($mobileCss) $css .= '@media(max-width:767px){' . $selector . '{' . $mobileCss . '}}';
        return $css;
    }
}

// Smart responsive defaults for grid-based blocks
// Outputs tablet/mobile media queries with sensible column defaults
// User overrides via _tablet/_mobile settings take precedence
// $settings: element settings array
// $selector: CSS selector targeting the grid container
// $desktopCols: number of desktop columns
// $opts: array of options:
//   'tablet_max' => max columns on tablet (default: 2)
//   'mobile_max' => max columns on mobile (default: 1)
//   'col_key'    => settings key for user override (default: 'columns')
//   'masonry'    => if true, use column-count instead of grid-template-columns
// Returns CSS string (may be empty if user has full overrides)
if (!function_exists('impulse_grid_responsive_defaults')) {
    function impulse_grid_responsive_defaults($settings, $selector, $desktopCols, $opts = []) {
        $tabletMax = $opts['tablet_max'] ?? 2;
        $mobileMax = $opts['mobile_max'] ?? 1;
        $colKey = $opts['col_key'] ?? 'columns';
        $masonry = !empty($opts['masonry']);
        $css = '';
        
        $prop = $masonry ? 'column-count' : 'grid-template-columns';

        // Tablet: use explicit user value if set, else clamp desktop to tablet_max
        $userTablet = isset($settings[$colKey . '_tablet']) ? intval($settings[$colKey . '_tablet']) : null;
        $tabletCols = $userTablet !== null ? $userTablet : min($desktopCols, $tabletMax);
        if ($tabletCols !== $desktopCols) {
            $val = $masonry ? $tabletCols : 'repeat(' . $tabletCols . ',1fr)';
            $css .= '@media(max-width:1024px){' . $selector . '{' . $prop . ':' . $val . '!important}}';
        }

        // Mobile: use explicit user value if set, else clamp desktop to mobile_max
        $userMobile = isset($settings[$colKey . '_mobile']) ? intval($settings[$colKey . '_mobile']) : null;
        $mobileCols = $userMobile !== null ? $userMobile : min($desktopCols, $mobileMax);
        if ($mobileCols !== $desktopCols) {
            $val = $masonry ? $mobileCols : 'repeat(' . $mobileCols . ',1fr)';
            $css .= '@media(max-width:767px){' . $selector . '{' . $prop . ':' . $val . '!important}}';
        }

        return $css;
    }
}

// Blank template - no theme header/footer
// Get global header if defined
$_headerPage = \Instinctor\Services\PageService::getByType('global_header');
$hasGlobalHeader = !empty($_headerPage);
?>
<!DOCTYPE html>
<!-- impulse-page v<?php echo esc_html(INSTINCTOR_VERSION); ?> -->
<html <?php language_attributes(); ?>>
<head>
    <meta charset="<?php bloginfo('charset'); ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <?php
    // ── SEO Engine: title + meta + OG + Twitter + JSON-LD ──────────────────
    // NOTE: $post_id is resolved much later in this file (line ~1882) from the request
    // path/front-page config. We need it NOW in the head, so repeat the resolution here
    // in miniature. Without this, every root-URL render falls through to the site-name
    // blog info — every page's title becomes "Instinctor Store" regardless of content.
    // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only preview page-id from $_GET, intval'd; no state change.
    $isp_post_id = isset($_GET['impulse_preview'])
        ? intval($_GET['impulse_preview'])
    // phpcs:enable WordPress.Security.NonceVerification.Recommended
        : (isset($post_id) ? (int)$post_id : (function_exists('get_the_ID') ? (get_the_ID() ?: 0) : 0));
    if (!$isp_post_id && defined('INSTINCTOR_RENDER_PAGE_ID')) {
        $isp_post_id = (int) INSTINCTOR_RENDER_PAGE_ID;
    }
    if (!$isp_post_id && (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'standalone')) {
        $_ispSlug = trim(wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '/')), PHP_URL_PATH), '/');
        if (!$_ispSlug) {
            $isp_post_id = (int) \Instinctor\Core\Config::get('instinctor_front_page_id', 0);
            if (!$isp_post_id) $isp_post_id = (int) \Instinctor\Core\Config::get('page_on_front', 0);
        } else {
            $_ispPg = \Instinctor\Services\PageService::getBySlug($_ispSlug);
            if ($_ispPg) $isp_post_id = (int) $_ispPg['id'];
        }
    }
    $isp_seo_title = $isp_post_id
        ? \Instinctor\Services\SEOService::getPageTitle($isp_post_id)
        : (function_exists('get_bloginfo') ? get_bloginfo('name') : '');
    ?>
    <title><?php echo esc_html($isp_seo_title); ?></title>
    <?php
    // Favicon — uses Instinctor's actual brand mark (rotated circle + curve from
    // assets/images/logo.svg) on dark rounded square. Override per-site by setting
    // `instinctor_favicon_url` in Config.
    $_ispFavUrl = \Instinctor\Core\Config::get('instinctor_favicon_url', '');
    if ($_ispFavUrl) {
        // Cache-bust the favicon URL with the file's mtime. Browsers cache
        // favicons aggressively and ignore page-level cache headers — without
        // a versioned query string, a freshly-uploaded replacement at the same
        // path keeps showing the previous file indefinitely. Resolve the
        // local FS path via the storage URL→path mapping (storage lives
        // OUTSIDE public_html, so a naive ABSPATH+url join misses).
        $_favCacheBust = '';
        // Storage URL→path resolution: Config map first (works in both
        // modes), then INSTINCTOR_STORAGE_PATH, then WordPress's own
        // wp_upload_dir() helper. No ABSPATH path-guessing — if none match,
        // the cache-bust is simply skipped and the favicon still renders.
        $_storageUrl  = (string) \Instinctor\Core\Config::get('instinctor_storage_url', '');
        $_storagePath = (string) \Instinctor\Core\Config::get('instinctor_storage_path', '');
        if ($_storageUrl  === '') $_storageUrl  = '/storage/uploads';
        if ($_storagePath === '' && defined('INSTINCTOR_STORAGE_PATH')) $_storagePath = INSTINCTOR_STORAGE_PATH;
        $_storageUrl  = rtrim($_storageUrl, '/');
        $_storagePath = rtrim($_storagePath, '/\\');
        $_localPath = '';
        if ($_storagePath && $_storageUrl && strpos($_ispFavUrl, $_storageUrl) === 0) {
            $_localPath = $_storagePath . substr($_ispFavUrl, strlen($_storageUrl));
        } elseif (function_exists('wp_upload_dir')) {
            $_ud = wp_upload_dir();
            if (!empty($_ud['baseurl']) && !empty($_ud['basedir']) && strpos($_ispFavUrl, $_ud['baseurl']) === 0) {
                $_localPath = $_ud['basedir'] . substr($_ispFavUrl, strlen($_ud['baseurl']));
            }
        }
        if ($_localPath && file_exists($_localPath)) {
            $_mt = @filemtime($_localPath);
            if ($_mt) $_favCacheBust = '?v=' . $_mt;
        }
        echo '<link rel="icon" href="' . esc_url($_ispFavUrl . $_favCacheBust) . '">';
    } else {
        $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
             . '<rect width="32" height="32" rx="6" fill="#1d2327"/>'
             . '<g transform="translate(4 4) scale(1)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round">'
             .   '<g transform="rotate(90 12 12)">'
             .     '<circle cx="12" cy="12" r="10"/>'
             .     '<path d="M14.83 14.83a4 4 0 1 1 0-5.66"/>'
             .   '</g>'
             . '</g>'
             . '</svg>';
        echo '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,' . rawurlencode($svg) . '">';
    }
    if ($isp_post_id) {
        \Instinctor\Services\SEOService::outputPageHead($isp_post_id);
    }
    // Impulse pages always need the frontend stylesheet + the inline CSS
    // accumulator (per-block computed CSS routes through it via
    // wp_add_inline_style instead of inline <style> tags — WP.org Guideline 11).
    \Instinctor\Core\Assets::enqueue('instinctor-frontend');
    \Instinctor\Core\Assets::enqueue('instinctor-page-inline');
    // (The free-build env-canvas hide lives in the pre-paint critical block
    // above, where it flushes reliably; wp_add_inline_style here did not.)
    // Cart/add-to-cart JS (frontend.js) — standalone needs jQuery bundled first;
    // WP mode skips instinctor-jquery (WP ships its own) but frontend-js still loads.
    if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
        \Instinctor\Core\Assets::enqueue('instinctor-jquery');
    }
    \Instinctor\Core\Assets::enqueue('instinctor-frontend-js');

    // Emit configured analytics snippet (Plausible, GA4, or custom HTML).
// Reads three Config keys: instinctor_analytics_provider (plausible|ga|custom|none),
// instinctor_analytics_id (Plausible domain or GA measurement ID),
// instinctor_analytics_custom (raw HTML — used when provider=custom).
// Default 'none' means nothing is emitted.
if (!function_exists('impulse_emit_analytics')) {
    function impulse_emit_analytics(): void {
        // WP.org free build: built-in analytics (Plausible / GA / custom HTML)
        // is PRO-stripped. WP.org discourages shipping third-party trackers
        // (users add analytics via a dedicated plugin), and the custom provider
        // echoes freeform HTML (arbitrary code insertion). No-op on WP; fully
        // functional on standalone/Pro. WP.org round 4.
        
    }
}if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV !== 'standalone') {
        // WP mode: fire wp_head() but strip duplicate viewport meta
        add_action('wp_enqueue_scripts', function() {
            wp_dequeue_style('wp-block-library');
            wp_dequeue_style('wp-block-library-theme');
            wp_dequeue_style('classic-theme-styles');
            wp_dequeue_style('global-styles');
        }, 100);
        ob_start();
        wp_head();
        $impulse_wp_head = ob_get_clean();
        $impulse_wp_head = preg_replace('/<meta[^>]+name=["\']{1}viewport["\']{1}[^>]*>/i', '', $impulse_wp_head);
        // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
        echo $impulse_wp_head;
        // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
    } else {
        // Standalone: skip wp_head() entirely — no theme stack, no extra scripts
        $_assetUrl = defined('INSTINCTOR_PLUGIN_URL') ? INSTINCTOR_PLUGIN_URL : '/instinctor-assets/';
        $_ver      = defined('INSTINCTOR_VERSION') ? INSTINCTOR_VERSION : '1';
        $apiBase      = defined('INSTINCTOR_STANDALONE_URL') ? rtrim(INSTINCTOR_STANDALONE_URL, '/') . '/api/instinctor/v1' : '/api/instinctor/v1';
        $_ajaxUrl     = function_exists('admin_url') ? admin_url('admin-ajax.php') : '/admin/admin-ajax.php';
        $_feNonce     = class_exists('\Instinctor\Core\Session') ? \Instinctor\Core\Session::token('instinctor_frontend') : '';
        $_cartUrl     = class_exists('\Instinctor\Services\PageService') ? \Instinctor\Services\PageService::resolveUrl('cart')     : '/cart';
        $_checkoutUrl = class_exists('\Instinctor\Services\PageService') ? \Instinctor\Services\PageService::resolveUrl('checkout') : '/checkout';
        $_feConfigJs = 'var instinctorFrontend={ajaxUrl:' . wp_json_encode($_ajaxUrl) . ',apiUrl:' . wp_json_encode($apiBase . '/') . ',nonce:' . wp_json_encode($_feNonce) . ',currency:' . wp_json_encode(\Instinctor\Core\Config::get('instinctor_currency','USD')) . ',currencySymbol:"$",currencyPosition:"left",cartUrl:' . wp_json_encode($_cartUrl) . ',checkoutUrl:' . wp_json_encode($_checkoutUrl) . '};var impulseConfig={apiBase:instinctorFrontend.apiUrl};';
        if (function_exists('wp_add_inline_script') && wp_script_is('instinctor-frontend-js', 'registered')) {
            wp_add_inline_script('instinctor-frontend-js', $_feConfigJs, 'before');
        } else {
            // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript,WordPress.Security.EscapeOutput.OutputNotEscaped -- standalone fallback; config must precede main JS bundle; $_feConfigJs is wp_json_encode'd throughout
            echo '<script>' . $_feConfigJs . '</script>' . "\n";
        }
    }
    ?>
    <?php impulse_emit_analytics(); ?>
    <?php
    // ── Behavior tracker (engagement) — cookieless, first-party. Emitted only
    // when this view was actually recorded (token present); record() returns ''
    // for previews / admin / bots / XHR, so those pages ship no tracker.
    if (!empty($impulse_view_token) && defined('INSTINCTOR_PLUGIN_URL')) {
        $_trkBeacon = '/api/instinctor/v1/analytics/beacon';
        if (class_exists('\\Instinctor\\Core\\Router')) {
            $_b = \Instinctor\Core\Router::restBase();
            if (is_string($_b) && $_b !== '') { $_trkBeacon = rtrim($_b, '/') . '/analytics/beacon'; }
        }
        \Instinctor\Core\Assets::enqueue('instinctor-track');
        if (function_exists('wp_add_inline_script')) {
            wp_add_inline_script('instinctor-track', 'window.__impulseTrack={t:' . wp_json_encode($impulse_view_token) . ',u:' . wp_json_encode($_trkBeacon) . '};', 'before');
        }
    }
    ?>
<?php
    // animations.js loaded conditionally at page footer — only when animations are actually used
    $GLOBALS['impulse_needs_animations'] = false;
?>
<?php
    // ── Global snippets ─────────────────────────────────────────────────────
    // Snippet injection is disabled in WordPress context per WP.org guidelines.
    // Snippets are fully functional in standalone deployment.
    $_global_snips  = [];
    // WordPress: freeform global snippet injection is disabled per WP.org
    // guidelines (no arbitrary code insertion). The real injector + loader
    // below is PRO-stripped from the free WP zip; this no-op keeps the emit
    // calls harmless. Fully functional on standalone/Pro. WP.org round 4.
    $emitGlobalSnippets = function(string $location): void {};
    

    // Head injection: global snippets first, then CSS snippets, then per-page code blocks
    $emitGlobalSnippets('head');
    $emitGlobalSnippets('css');

    // Frontend: $post_id isn't resolved yet at this point — resolve slug here too so the
    // Google Fonts <link> scan below finds the page's elements. Without this, pages hit
    // via slug (not ?impulse_preview=N) had $headPostId=0 and the font scan was skipped,
    // so fonts like line_1_font=Bungee never got their Google Fonts stylesheet emitted.
    // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only preview page-id from $_GET, intval'd; no state change.
    // INSTINCTOR_RENDER_PAGE_ID is the authoritative id for the page being rendered
    // (demo route, front-page config): honor it so early settings (body_scroll, fonts)
    // come from the ACTUAL page, not a slug/front-page fallback. Without this, demo
    // pages inherited the cinematic front page's body_scroll and locked native scroll.
    $headPostId = isset($_GET['impulse_preview']) ? intval($_GET['impulse_preview'])
        : (defined('INSTINCTOR_RENDER_PAGE_ID') ? (int) INSTINCTOR_RENDER_PAGE_ID
        : (isset($post_id) ? (int) $post_id : 0));
    // phpcs:enable WordPress.Security.NonceVerification.Recommended
    if (!$headPostId && !empty($_SERVER['REQUEST_URI'])) {
        $_headSlug = trim(wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH), '/');
        if ($_headSlug && class_exists('\Instinctor\Services\PageService')) {
            $_headPg = \Instinctor\Services\PageService::getBySlug($_headSlug);
            if ($_headPg) $headPostId = (int) $_headPg['id'];
            elseif (ctype_digit($_headSlug)) $headPostId = (int) $_headSlug;
        }
        if (!$headPostId && class_exists('\Instinctor\Core\Config')) {
            $headPostId = (int) \Instinctor\Core\Config::get('instinctor_front_page_id', 0)
                       ?: (int) \Instinctor\Core\Config::get('page_on_front', 0);
        }
    }
    if ($headPostId) {
        $headElements = \Instinctor\Services\PageService::getElements($headPostId);
        if (is_array($headElements)) {
            foreach ($headElements as $hel) {
                $ht = $hel['type'] ?? '';
                $hs = $hel['settings'] ?? [];
                
            }
        }
    }
?>
<?php
// Inject background color before first paint — prevents white flash on dark screen sequences
if ($headPostId) {
    $earlySettings = \Instinctor\Services\PageService::getSettings($headPostId);
    if (true) { // every page is a screen sequence; the legacy non-screen page mode no longer exists
        $earlyCloudEff = $earlySettings['screen_cloud_effect'] ?? 'none';
        if ($earlyCloudEff !== 'none' && instinctor_env_locked($earlyCloudEff)) {
            $earlyCloudEff = 'none';
        }
        $earlyPaletteEffects = ['wave','clouds'];
        $earlyForceBlack = ['cosmos','caustics','glass','flow','topography','ripple','swirl','kaleidoscope','membrane'];
        $earlyEnvBg = in_array($earlyCloudEff, $earlyPaletteEffects) ? '#ffffff' : (in_array($earlyCloudEff, $earlyForceBlack) ? '#000000' : '#f5f5f5');

        // Screen mode: single env paints the whole viewport, env bg wins.
        $earlyBg = !empty($earlySettings['bg_color']) && $earlySettings['bg_color'] !== 'transparent'
            ? $earlySettings['bg_color']
            : $earlyEnvBg;
        // Paint the flash-prevent on HTML ONLY, not body. The env canvas
        // (#impulse-tunnel-canvas) is position:fixed with z-index:-1, which
        // stacks ABOVE html's background but BELOW body's in-flow box bg.
        // Painting body too made body's bg sit ON TOP of the canvas — every
        // transparent section past the hero showed solid #000000 instead of
        // the cosmos / caustics / glass / etc. animation. With html-only the
        // pre-init flash is still hidden (html paints before anything else)
        // and the canvas remains visible once it mounts. Locked 2026-05-27.
        // 2026-05-30: emit as a direct <style> in <head>, not via
        // wp_add_inline_style('instinctor-page-inline', ...). That handle is
        // footer-only, so the rule landed AFTER first paint — every load
        // flashed white before the dark page bg took effect. Bundling all
        // first-paint critical rules into one head-block here so they apply
        // before the browser ever paints a pixel.
        $_critical = 'html{background:' . esc_attr($earlyBg) . '!important}';
        // Cinematic "bare" mode (mode toggle): pin the hero image as a FIXED
        // full-viewport backdrop with a readability scrim; the transparent content
        // screens scroll over this static image. Force section text light so it
        // stays legible over the photo (mobile drops fixed-attach for perf/jank).
        if ((($earlySettings['body_scroll'] ?? 'snap') !== 'classic') && !empty($earlySettings['cinematic_bare'])) {
            $_cineBd = trim((string) ($earlySettings['cinematic_backdrop'] ?? ''));
            if ($_cineBd !== '') {
                $_critical .= "html{background:linear-gradient(rgba(10,11,20,0.48),rgba(10,11,20,0.62)),url('" . esc_url($_cineBd) . "') center center/cover fixed no-repeat #0a0b14!important}";
                $_critical .= '@media(max-width:768px){html{background-attachment:scroll!important}}';
            } else {
                $_critical .= 'html{background:#0a0b14!important}';
            }
            $_critical .= '.impulse-section *{color:#f4f2ee!important}';
            $_critical .= '.impulse-section *:not(a):not(button):not(input):not(select):not(textarea){background-color:transparent!important}';
            // Cinematic snap-screens drop the content max-width clamp (root screens
            // fill the viewport). Classic re-caps containers via body.impulse-scroll-classic,
            // so cinematic content (incl. the carousel) spans full width on wide monitors.
            // Re-apply the same content cap so content is column-width, exactly like classic.
            $_critical .= '.impulse-section>.impulse-container{max-width:var(--impulse-content-max-width,1440px)!important;margin-left:auto!important;margin-right:auto!important}';
            // Footer (studio name + copyright) is a block appended after the fixed
            // snap-screens, so it floats off in cinematic. Pin it to the bottom of the
            // viewport. pointer-events:none so it never blocks the overlay controls.
            $_critical .= '.impulse-footer{position:fixed!important;left:0;right:0;bottom:0;z-index:2147482000;background:transparent!important;color:#cdd2da!important;pointer-events:none}';
        }
        // Free build: 3D/depth/environments are an editor-only demo, so the
        // published page shows its own background. Hide every body-child env
        // canvas layer these engines mount (three, tunnel, motion-lava, wave,
        // fx, depth, cloud - some without stable ids) so nothing paints over the
        // page background. Content canvases (3D text, logo animator, mesh) are
        // nested in their elements, never body children, so they stay visible.
        // Lives in the pre-paint critical block so it applies before the engines
        // ever mount (wp_add_inline_style flushed too late / not at all here).
        if (function_exists('instinctor_is_free_build') && instinctor_is_free_build()) {
            $_critical .= 'body>canvas:not(#impulse-3d-text-canvas):not(#impulse-la-canvas):not(#impulse-la-3d-canvas):not(#impulse-mesh-canvas){display:none!important}';
            // ...and pin <body> to the page's OWN background. With the env layer
            // hidden, the WordPress theme's custom-background body color (e.g. a
            // grey set in the Customizer: body.custom-background{background-color})
            // would otherwise cover the page background. !important beats the
            // theme's non-important rule, so the user's page background shows.
            $_critical .= 'body{background:' . esc_attr($earlyBg) . '!important}';
        }
        // .impulse-animated default state: hide until IntersectionObserver
        // adds .impulse-visible. The same rule exists in frontend.css but
        // that file loads via <link>; emitting inline guarantees it is parsed
        // alongside the head-block and applies on first paint regardless of
        // network latency on the stylesheet.
        $_critical .= '.impulse-animated{opacity:0;animation-fill-mode:forwards}.impulse-animated.impulse-visible{animation-play-state:running!important}';
        // Soft fade-in for 3D-text titles. The impulse-3d-text engine starts
        // the wrapper at opacity:0 while it builds the WebGL scene, then
        // clears the inline style once ready — without a transition that's
        // a hard pop after the perceived "wait". With this transition the
        // moment the engine flips opacity, the title eases in over 700ms.
        // Reads as a cinematic fade-up against the cosmos. Sergei 2026-06-01.
        $_critical .= '.impulse-display-text{transition:opacity 700ms cubic-bezier(0.4, 0, 0.2, 1)}';
        // Chrome-hide for first paint. The detailed transitional rules live
        // in the page-inline block much further down, but those land in the
        // footer — so the header was visible (and reserving layout space) for
        // the first ~1 frame on every load, causing the cosmos-gap-at-top +
        // header-flash. Emit the minimum here: hide + remove from flow.
        $_earlyHideChrome = isset($earlySettings['hide_chrome_on_first_screens'])
            ? max(0, intval($earlySettings['hide_chrome_on_first_screens']))
            : (!empty($earlySettings['hide_chrome_on_first_screen']) ? 1 : 0);
        if ($_earlyHideChrome > 0) {
            // Walk the global header (if any) for header element ids so
            // the position:absolute layout-removal targets them at full
            // id-specificity, beating the header renderer's own
            // #impulse-header-N{position:relative !important} mobile rule.
            $_earlyHdrIds = [];
            if (!empty($_headerPage) && !empty($_headerPage['elements']) && is_array($_headerPage['elements'])) {
                foreach ($_headerPage['elements'] as $_ghEl) {
                    $_ghType = $_ghEl['type'] ?? $_ghEl['element_type'] ?? '';
                    if ($_ghType === 'header' && !empty($_ghEl['id'])) {
                        $_earlyHdrIds[] = (string) $_ghEl['id'];
                    }
                }
            }
            $_hideSel = [];
            $_posSel  = [];
            for ($_i = 0; $_i < $_earlyHideChrome; $_i++) {
                $_bp = 'body[data-impulse-screen="' . $_i . '"] ';
                $_hideSel[] = $_bp . '.impulse-header';
                $_hideSel[] = $_bp . '[id^="impulse-header-"]';
                foreach ($_earlyHdrIds as $_hid) {
                    $_posSel[] = $_bp . '#impulse-header-' . $_hid;
                }
            }
            if ($_hideSel) {
                $_critical .= implode(',', $_hideSel) . '{opacity:0!important;visibility:hidden!important;transform:translateY(-100%)!important;pointer-events:none!important}';
            }
            if ($_posSel) {
                $_critical .= implode(',', $_posSel) . '{position:absolute!important;top:0!important;left:0!important;right:0!important}';
            }
            // Smooth show/hide transition so the moment the body
            // data-impulse-screen attr flips past the hidden range, the
            // header doesn't pop into view in a single frame — that hard
            // snap reads as "the header is flashing on page load" because
            // the visitor's first scroll is what flips the attr. Visibility
            // is delayed when going hidden (so opacity can fade out), but
            // applied immediately when going visible (so the fade-in can
            // start from a rendered element). Transform + opacity ride a
            // 600ms ease-in-out curve. Sergei 2026-05-31.
            $_critical .= '.impulse-header,[id^="impulse-header-"]{transition:opacity 0.6s ease-in-out,transform 0.6s ease-in-out,visibility 0s linear 0s}';
            $_critical .= implode(',', $_hideSel) . '{transition:opacity 0.6s ease-in-out,transform 0.6s ease-in-out,visibility 0s linear 0.6s}';
        }
        // Mobile-vh fix: iOS Safari's 100dvh can overshoot the actual visible
        // viewport at first paint (URL-bar state hasn't settled), so a screen
        // sized at 100dvh ends up 40-60px taller than the user sees, the body
        // scrolls that overflow, and cosmos becomes visible above the screen
        // background. Bind a CSS var to window.innerHeight (the only number
        // that exactly matches the visible area) and use it for screen
        // heights; falls back to 100dvh if the script hasn't run yet.
        $_critical .= ':root{--imp-vh:100dvh}';
        // Lock body/html scroll on screen-mode pages from first paint. The
        // engine's reEnterScreenMode() applies these via JS, but until that
        // runs (after the engine script loads + executes) the body has
        // default native scroll. On Android Chrome the screen wrapper is
        // sized exactly to window.innerHeight by --imp-vh, BUT if any in-flow
        // element overshoots (heading, sandbox dock, anything position:fixed
        // anchored off-screen for entrance), the body rubber-bands and the
        // gap (cosmos) becomes visible above the screen.
        $_critical .= 'html,body[data-screen-mode="1"]{height:var(--imp-vh,100dvh);max-height:var(--imp-vh,100dvh);overflow:hidden;overscroll-behavior:none}';
        $_critical .= 'body[data-screen-mode="1"]{position:fixed;width:100%;left:0;top:0;margin:0}';
        // Header is hidden whenever any popup is open. Backdrop-filter on
        // the header otherwise appears to "blur" the popup top content
        // through stacking-context quirks (perspective on the screen
        // wrapper traps fixed positioning). JS adds .impulse-modal-open
        // on body in showPopup() / removes in hidePopup(). Sergei
        // 2026-06-01.
        $_critical .= 'body.impulse-modal-open .impulse-header,body.impulse-modal-open [id^="impulse-header-"]{opacity:0!important;visibility:hidden!important;pointer-events:none!important;transition:opacity 0.3s ease-in-out,visibility 0s linear 0.3s}';
        // Per-screen "hide background effect" — fades out whichever
        // background-effect canvas the page is rendering (3D-effect, lava,
        // wave, tunnel, fx) while a screen with data-hide-effect="1" is
        // active. Effect-agnostic: the page can be running any of the 30+
        // 3D effects (cosmos / tubes / aurora / glass / depth-image / etc.)
        // and this still hides whichever one is in play. Avoids the
        // per-screen bg_color-fades-with-opacity flash when two solid-bg
        // screens transition back-to-back. Smooth opacity ramp gives a
        // clean reveal as the visitor scrolls onto the first non-flagged
        // screen. :has() is widely supported and lets the rule live in
        // pure CSS with no JS wiring. Sergei 2026-05-31.
        $_critical .= '#impulse-3d-canvas,#impulse-tunnel-canvas,#impulse-motion-lava-canvas,#impulse-wave-canvas,#impulse-fx-canvas{transition:opacity 600ms ease-in-out}';
        $_critical .= 'body:has(.impulse-screen.impulse-screen-active[data-hide-effect="1"]) #impulse-3d-canvas,'
                   .  'body:has(.impulse-screen.impulse-screen-active[data-hide-effect="1"]) #impulse-tunnel-canvas,'
                   .  'body:has(.impulse-screen.impulse-screen-active[data-hide-effect="1"]) #impulse-motion-lava-canvas,'
                   .  'body:has(.impulse-screen.impulse-screen-active[data-hide-effect="1"]) #impulse-wave-canvas,'
                   .  'body:has(.impulse-screen.impulse-screen-active[data-hide-effect="1"]) #impulse-fx-canvas{opacity:0}';
        // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet,WordPress.Security.EscapeOutput.OutputNotEscaped -- critical paint CSS must be inline in wp_head; wp_add_inline_style outputs to footer (too late for layout)
        echo '<style id="impulse-critical-css">' . $_critical . '</style>' . "\n";
        // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- vh-fix must execute synchronously before first paint; wp_add_inline_script outputs to footer
        echo '<script id="impulse-vh-fix">(function(){function s(){document.documentElement.style.setProperty("--imp-vh",window.innerHeight+"px");}s();window.addEventListener("resize",s,{passive:true});window.addEventListener("orientationchange",function(){setTimeout(s,80);},{passive:true});})();</script>' . "\n";

        /* ─ Motion-hint microcopy (mobile, screen 1 only) ──────────────────
           On screen-mode pages the 3D engines respond to device tilt. On iOS
           the permission dialog fires on first touch; on Android gyro is
           auto-active. Either way the affordance is invisible without an
           invitation — visitors who never tilt their device never see the
           parallax. This pill appears bottom-centre on screen 1, fades out
           the moment we detect a tilt >3°, or when the visitor scrolls to
           screen 2, or after 8 s. Touch-only, mobile-width-only.
           Sergei 2026-05-31. */
        // Static motion-hint pill styling (identical on every render); attached
        // once to the enqueued page-inline handle instead of echoed raw.
        wp_add_inline_style('instinctor-page-inline', ''
           . '.impulse-motion-hint{position:fixed;left:50%;bottom:max(20px,env(safe-area-inset-bottom));transform:translate(-50%,8px);display:none;align-items:center;gap:8px;padding:9px 14px 9px 12px;background:rgba(255,255,255,0.07);backdrop-filter:blur(14px) saturate(140%);-webkit-backdrop-filter:blur(14px) saturate(140%);border:1px solid rgba(255,255,255,0.14);border-radius:999px;color:#fff;font:500 12px/1 system-ui,-apple-system,sans-serif;letter-spacing:0.02em;pointer-events:none;z-index:9999;opacity:0;transition:opacity 0.6s ease-in-out,transform 0.6s ease-in-out}'
           . 'body[data-impulse-screen="0"] .impulse-motion-hint.impulse-mh-ready{display:inline-flex;opacity:1;transform:translate(-50%,0)}'
           . '.impulse-motion-hint svg{width:14px;height:14px;animation:impulse-mh-tilt 2.4s ease-in-out infinite;transform-origin:50% 65%}'
           . '@keyframes impulse-mh-tilt{0%,100%{transform:rotate(-14deg)}50%{transform:rotate(14deg)}}'
           );
        echo '<div class="impulse-motion-hint" id="impulse-motion-hint" aria-hidden="true">'
           . '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>'
           . '<span>Tilt to look around</span>'
           . '</div>' . "\n";
        // Static motion-hint behavior (identical on every render); attached to
        // the enqueued frontend-js handle instead of echoed raw. It resolves its
        // element by id, so it does not need to sit adjacent to the markup.
        wp_add_inline_script('instinctor-frontend-js', '(function(){'
           . 'var touch=("ontouchstart" in window)||(navigator.maxTouchPoints>0);'
           . 'if(!touch||window.innerWidth>900)return;'
           . 'var el=document.getElementById("impulse-motion-hint");if(!el)return;'
           . 'var hidden=false;function hide(){if(hidden)return;hidden=true;el.classList.remove("impulse-mh-ready");setTimeout(function(){if(el&&el.parentNode)el.parentNode.removeChild(el);},800);}'
           . 'var baseB=null,baseG=null;function onTilt(e){var b=e.beta||0,g=e.gamma||0;if(baseB===null){baseB=b;baseG=g;return;}if(Math.abs(b-baseB)>3||Math.abs(g-baseG)>3)hide();}'
           . 'document.addEventListener("impulse-screen-changed",function(){var s=document.body.getAttribute("data-impulse-screen");if(s&&s!=="0")hide();});'
           . 'setTimeout(function(){if(hidden)return;el.classList.add("impulse-mh-ready");setTimeout(hide,8000);},1800);'
           . 'if(window._impulseGyro&&window._impulseGyro.register){window._impulseGyro.register(onTilt);}else if(typeof DeviceOrientationEvent!=="undefined"){window.addEventListener("deviceorientation",onTilt,{passive:true});}'
           . '})();', 'after');
    }
}
?>
</head>
<body <?php
    $bodyExtraClasses = 'impulse-frontend-page';
    $_bodyScroll = $earlySettings['body_scroll'] ?? 'snap';
    // Blog content is ALWAYS a flowing document, never a cinematic screen deck.
    // body_scroll='flow' is set programmatically (there is no builder UI control
    // for it), so a builder save of the blog index silently wipes it back to
    // empty/snap and the page regresses into a broken cinematic deck (Frank
    // reported this on mobile 2026-07-06). Force flow durably for posts
    // (type='post') and the blog index (slug='blog') so it can never regress,
    // regardless of the saved body_scroll value.
    $_pgTs      = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
        "SELECT type, slug FROM `" . \Instinctor\Core\DB::prefix() . "instinctor_pages` WHERE id = %d", $headPostId));
    $_forceFlow = (($_pgTs->type ?? 'page') === 'post') || (($_pgTs->slug ?? '') === 'blog');
    $_isFlow    = ($_bodyScroll === 'flow') || $_forceFlow;
    // Publish scroll mode early (before any element renders) so the section
    // renderer can give classic/flow top-level sections free content height.
    // A flow document sizes like classic (content height), not 100vh screens.
    $GLOBALS['impulse_body_scroll'] = ($_bodyScroll === 'classic' || $_isFlow) ? 'classic' : 'snap';
    // Zone merge (Sergei 2026-06-03): the environment is snap-only. Flag classic
    // native-scroll pages so the env canvases are hidden (CSS in
    // impulse-screens.js): a flowing document gets no animated backdrop.
    if ($_bodyScroll === 'classic') $bodyExtraClasses .= ' impulse-scroll-classic';
    // Content/flow mode (blog posts, articles): a plain scrolling document that
    // does NOT run through the cinematic screen engine at all. Emit
    // data-screen-mode="0" so (a) the fixed-viewport critical CSS never applies,
    // (b) the screen-hide CSS never applies, and (c) impulse-screens.js bails on
    // init.
    if ($_isFlow) $bodyExtraClasses .= ' impulse-scroll-classic impulse-flow-page';
    body_class($bodyExtraClasses);
?><?php echo ' data-screen-mode="' . ($_isFlow ? '0' : '1') . '" data-impulse-screen="0"'; ?>>
<?php
// Page-load fade-in: solid black overlay that fades out on first paint.
// Hides the initial layout flash while WebGL canvases warm up, fonts
// load, and the 3D-text engine samples the first frame. Pure CSS — no
// JS dependency. The overlay is `pointer-events:none` so it doesn't
// block clicks even before the animation completes.
if (true): // every page is a screen sequence
?>
<div class="impulse-page-fader"></div>
<style>
/* Boot cover: solid pre-paint colour that hides the raw stacked
   screens, font swaps, and the moment between server-render
   (children at opacity:1) and the screen engine applying its
   initial state (children at opacity:0 via decomposeEnter, ready
   to animate in). Time-based dissolves race the engine — on slow
   devices the cover starts thinning while content is still at
   opacity:1, the user sees the layout, then the engine snaps it
   to 0 and the animation plays. That's the "flash".

   Engine-driven removal: impulse-screens.js stamps body with
   .impulse-screens-booted once init() has set screen-0 children
   to their starting state. The cover fades only then.

   Safety auto-fade at 2.6s covers JS-disabled visitors, JS
   errors, and any path where screens.js never finishes booting.
   Above the boot-cover so the page is recoverable either way. */
.impulse-page-fader{position:fixed;inset:0;background:#000;z-index:9999;pointer-events:none;opacity:1;visibility:visible;will-change:opacity;animation:impulse-page-fader-safety 480ms ease-in-out 2200ms forwards}
body.impulse-screens-booted .impulse-page-fader{animation:impulse-page-fader-out 380ms ease-in-out forwards}
@keyframes impulse-page-fader-out{from{opacity:1}to{opacity:0;visibility:hidden}}
@keyframes impulse-page-fader-safety{from{opacity:1}to{opacity:0;visibility:hidden}}
</style>
<?php endif; ?>
<?php
// Preview payment bar removed — payment-first model.
$emitGlobalSnippets('body_start'); ?>

<div class="impulse-page-wrapper">

<?php
// ── Overlay reveal-after-mount gate ─────────────────────────────────────
// On page load the server-rendered overlay would otherwise paint at full
// visibility BEFORE the screens engine mounts and lifts screen 1 to z:2 —
// reading as a black flash that "settles" once screens take over. Hide
// every .impulse-gradient-overlay for the first 250ms via a 0-duration
// animation that flips visibility forward. Pure CSS so it doesn't fight
// the depth-parallax opacity pulse (which mutates inline opacity each
// frame — visibility is independent of opacity). Animation runs once per
// element creation; subsequent style mutations from the editor's live
// preview don't restart it. Emitted once per page via global flag.
if (empty($GLOBALS['impulse_overlay_anim_emitted'])) {
    wp_add_inline_style('instinctor-page-inline', '@keyframes impulse-overlay-show{to{visibility:visible}}.impulse-gradient-overlay{visibility:hidden;animation:impulse-overlay-show 0s 250ms forwards}');
    $GLOBALS['impulse_overlay_anim_emitted'] = true;
}

// ── Page-level Overlay (slider-driven scene-wide gradient) ──────────────
// Singleton, emitted at the page-wrapper level so it covers the entire
// viewport regardless of which screen is currently active. Was inside the
// per-section render before 2.0.911 — that nested it under a screen,
// causing it to pulse with screen transitions (hidden when the screen
// flips away). Out here it sits above the scene background and below all
// content, unaffected by screen visibility.
//
// v2.1.1513: this block reads $earlySettings, NOT $page_settings. This is
// TOP-LEVEL code (above the impulse_render_screen_page definition at ~2077);
// $page_settings is not assigned until the render dispatch (~2746), so it was
// empty here and the singleton NEVER emitted. The overlay then fell through
// to section.php's per-section fallback, which nests it INSIDE .impulse-screen
// — a transform-driven element that traps the position:fixed overlay in its
// own stacking context (clipped by overflow:hidden, buried under z:2 content,
// hidden/flashing on screen flips). $earlySettings (loaded ~1285) is the same
// full settings array and IS populated here, so the singleton fires at
// page-wrapper level (outside all screens) as intended.
if (true) { // every page is a screen sequence
    $_pwShape  = $earlySettings['screen_gradient_shape'] ?? '';
    $_pwD = isset($earlySettings['screen_gradient_diameter'])
        ? intval($earlySettings['screen_gradient_diameter'])
        : ($_pwShape === 'glow' ? 80 : ($_pwShape === 'linear' ? 200 : ($_pwShape === 'frame' ? 110 : 100)));
    $_pwR = isset($earlySettings['screen_gradient_roundness'])
        ? intval($earlySettings['screen_gradient_roundness'])
        : ($_pwShape === 'linear' ? 0 : ($_pwShape === 'frame' ? 80 : 100));
    $_pwAg = intval($earlySettings['screen_gradient_angle']    ?? 0);
    $_pwSf = isset($earlySettings['screen_gradient_softness'])
        ? intval($earlySettings['screen_gradient_softness'])
        : ($_pwShape === 'frame' ? 60 : 100);
    $_pwC1 = $earlySettings['screen_gradient_color1'] ?? 'rgba(0,0,0,0)';
    $_pwC2 = $earlySettings['screen_gradient_color2'] ?? 'rgba(0,0,0,0)';
    $_pwOff = function ($c) {
        if (!$c || $c === 'transparent' || $c === 'rgba(0,0,0,0)') return true;
        if (preg_match('/rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*([\d.]+))?\s*\)/i', (string) $c, $m)) {
            if (isset($m[1]) && $m[1] !== '') return ((float) $m[1]) <= 0.02;
        }
        return false;
    };
    if (!($_pwOff($_pwC1) && $_pwOff($_pwC2))) {
        $_pwD  = max(10, min(200, $_pwD ?: 100));
        $_pwR  = max(0,  min(100, $_pwR));
        $_pwAg = (($_pwAg % 360) + 360) % 360;
        $_pwSf = max(0,  min(100, $_pwSf));
        $_pwRX = $_pwD / 2;
        $_pwRY = max(0.5, $_pwRX * ($_pwR / 100));
        $_pwInner = max(0,   (100 - $_pwSf) / 2);
        $_pwOuter = min(100, 100 - $_pwInner);
        $_pwCss =
              'position:fixed;top:50%;left:50%;width:200%;height:200%;'
            // z-index:1 sits above the scene bg canvas (depth_image / cloud /
            // wave) but below the active screen (z:2) and its content. Was
            // z:5 in legacy per-section emission where the section's own
            // stacking context isolated it; at the page-wrapper level z:5
            // covers all content.
            . 'pointer-events:none;z-index:1;'
            . 'background:radial-gradient(ellipse '
            . number_format($_pwRX, 2, '.', '') . '% '
            . number_format($_pwRY, 2, '.', '') . '% at center,'
            . $_pwC1 . ' 0%,'
            . $_pwC1 . ' ' . number_format($_pwInner, 2, '.', '') . '%,'
            . $_pwC2 . ' ' . number_format($_pwOuter, 2, '.', '') . '%,'
            . $_pwC2 . ' 100%);'
            . 'transform:translate(-50%,-50%) rotate(' . $_pwAg . 'deg);';
        echo '<div class="impulse-gradient-overlay" style="' . esc_attr($_pwCss) . '"></div>';
        // Block per-section emission so the legacy block doesn't double-emit.
        $GLOBALS['impulse_grad_overlay_emitted'] = true;
    }
}
// Render global header if exists.
if ($hasGlobalHeader):
    $headerSettings = $_headerPage['settings'] ?? [];
    $headerElements = $_headerPage['elements'] ?? [];
    
    $bgColor = $headerSettings['bg_color'] ?? '#ffffff';
    $maxWidth = $headerSettings['max_width'] ?? 1200;
    $paddingTop = $headerSettings['padding_top'] ?? 16;
    $paddingBottom = $headerSettings['padding_bottom'] ?? 16;
    $sidePadding = $headerSettings['side_padding'] ?? 24;
    $marginBottom = $headerSettings['margin_bottom'] ?? 0;
?>
<header class="impulse-global-header" style="background:<?php echo esc_attr($bgColor); ?>;padding:<?php echo intval($paddingTop); ?>px <?php echo intval($sidePadding); ?>px <?php echo intval($paddingBottom); ?>px <?php echo intval($sidePadding); ?>px;border-bottom:1px solid #e0e0e0;<?php echo $marginBottom > 0 ? 'margin-bottom:' . intval($marginBottom) . 'px;' : ''; ?>">
    <div style="max-width:<?php echo intval($maxWidth); ?>px;margin:0 auto;">
        <?php 
        if (!empty($headerElements)) {
            // Build element tree for header
            $headerByParent = [];
            foreach ($headerElements as $el) {
                $parentId = $el['parent_id'] ?? 0;
                if (!isset($headerByParent[$parentId])) {
                    $headerByParent[$parentId] = [];
                }
                $headerByParent[$parentId][] = $el;
            }
            foreach ($headerByParent as &$children) {
                usort($children, function($a, $b) {
                    return ($a['sort_order'] ?? 0) - ($b['sort_order'] ?? 0);
                });
            }
            echo '<div style="display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap;">';
            if (isset($headerByParent[0])) {
                foreach ($headerByParent[0] as $element) {
                    impulse_render_header_footer_element($element, $headerByParent);
                }
            }
            echo '</div>';
        }
        ?>
    </div>
</header>
<?php endif; ?>

<?php
// Helper functions needed by impulse_render_frontend_element — must execute before rendering

if (!function_exists('impulse_resolve_3d_setting')) {
    /**
     * Resolve any 3D-family environment value (color1-4, speed, intensity,
     * preset) for the current effect from per-effect storage:
     * screen_{effect}_{suffix}. Every 3D-engine effect (cosmos, crystal,
     * plexus, dots, neon, corridor, etc.) keeps its own palette + params so
     * switching effects no longer bleeds the previous effect's values.
     */
    function impulse_resolve_3d_setting(array $settings, string $effect, string $suffix, string $default): string {
        $k = 'screen_' . $effect . '_' . $suffix;
        return !empty($settings[$k]) ? (string) $settings[$k] : $default;
    }
}

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);
        // 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')) {
    function impulse_get_responsive($settings, $name, $default, $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;
        }
        if (isset($settings[$name . '_tablet'])) return $settings[$name . '_tablet'];
        return $settings[$name] ?? $default;
    }
}
if (!function_exists('impulse_auto_mobile_defaults')) {
    /**
     * Fill missing _tablet/_mobile overrides with auto-scaled values for
     * heading and animator blocks. Only sets values the user hasn't explicitly
     * configured — explicit overrides always win.
     */
    function impulse_auto_mobile_defaults(array &$settings, string $type, string $device): void {
        if ($device === 'desktop') return;
        $isTablet = $device === 'tablet';
        $suffix   = '_' . $device;

        // Helper: scale a numeric key if no override exists yet
        $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));
        };

        if ($type === 'title') {
            $scale('line_1_size',    0.80, 0.62);
            // Letter spacing: only scale if set and positive
            $ls = intval($settings['letter_spacing'] ?? 0);
            if ($ls > 0) $scale('letter_spacing', 0.70, 0.50);
            // 3D depth: reduce significantly on smaller screens
            $scale('line_1_depth',   0.70, 0.35);
            // Mouse sensitivity: no hover on mobile
            $scale('mouse_sens',     0.60, 0.00);
            $scale('orbit_speed',    0.60, 0.00);
        }

        if ($type === 'title') {
            $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);
        }
    }
}
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;
        $r = max(0, hexdec(substr($hex,0,2)) - round(255*$percent/100));
        $g = max(0, hexdec(substr($hex,2,2)) - round(255*$percent/100));
        $b = 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);
    }
}

// Check for preview mode
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only preview page-id from $_GET, intval'd; no state change.
$preview_page_id = isset($_GET['impulse_preview']) ? intval($_GET['impulse_preview']) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended

// Build tree recursively - shared by both preview and normal paths
if (!function_exists('impulse_render_child_sequences')) {
    function impulse_render_child_sequences(int $parentPageId, bool $parentHasTunnel, string $parentBgColor): void
        // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Renderer: emits HTML/CSS/SVG composed from sanitized settings, numeric/esc_*()/uid values and internal data structures; no raw request input reaches output (zero ValidatedSanitizedInput violations in the body).
    {
        $allRows  = \Instinctor\Services\PageService::getAll();
        $children = [];
        foreach ($allRows as $row) {
            if (($row['status'] ?? '') === 'trash') continue;
            $s = is_array($row['settings']) ? $row['settings']
               : (is_string($row['settings']) ? (json_decode($row['settings'], true) ?: []) : []);
            if ((int)($s['parent_sequence_id'] ?? 0) !== $parentPageId) continue;
            $children[] = ['row' => $row, 'settings' => $s];
        }
        if (empty($children)) return;

        usort($children, function($a, $b) {
            return ($a['row']['sort_order'] ?? 0) - ($b['row']['sort_order'] ?? 0);
        });

        // Collect IDs that are parents of other sequences — exclude them (flat hierarchy only)
        $parentIds = [];
        foreach ($allRows as $_r) {
            $_s = is_array($_r['settings']) ? $_r['settings']
                : (is_string($_r['settings']) ? (json_decode($_r['settings'], true) ?: []) : []);
            $pid = (int)($_s['parent_sequence_id'] ?? 0);
            if ($pid > 0) $parentIds[$pid] = true;
        }
        $children = array_filter($children, function($c) use ($parentIds) {
            return empty($parentIds[(int)$c['row']['id']]);
        });
        $children = array_values($children);

        $paletteEffects    = ['wave','clouds'];
        // Keep in sync with admin _bgLockedEffects (instinctor-admin-core.js)
        // AND with the engines that hard-code dark output (Three.js
        // materialOpts.darkBg, FX-engine effects rendered on a forced-black
        // canvas, etc.). Effects here have their bg overridden to #000000
        // at render time regardless of any saved bg_color, so the admin
        // panel correctly dims the B swatch to match.
        $forceBlackEffects = ['cosmos','caustics','glass','flow','topography',
                              'ripple','swirl','kaleidoscope','membrane',
                              'neon','bubble','bubbles','aurora','aura','fractal',
                              'lava','noise','wormhole','fireflies','columns','nebula',
                              'lens','asteroid','attractor','particles'];
        // Effects that require Three.js / impulse-3d-effects.js
        $_threeEffects = ['cosmos','bubbles','particles','crystal','blob','wormhole','fireflies','plexus','wave','columns','asteroid','attractor'];
        // Effects that require impulse-fx-engine.js
        $_fxEffects    = ['fractal','caustics','glass','bubble','flow','aurora','clouds','aura',
                          'topography','neon','noise','dots','ripple',
                          'swirl','kaleidoscope','membrane','lava','nebula',
                          'agate','storm','lens'];

        foreach ($children as $child) {
            $childId       = (int)$child['row']['id'];
            $childSettings = $child['settings'];

            // Resolve child bg_color (mirrors main render path)
            $childBg   = $childSettings['bg_color'] ?? '';
            $cloudEff  = $childSettings['screen_cloud_effect'] ?? 'none';
            // Per-effect environment gate — free bundled effects always render,
            // others silently fall back to 'none' on free tier
            if ($cloudEff !== 'none' && instinctor_env_locked($cloudEff)) {
                $cloudEff = 'none';
            }
            if ($childBg === 'transparent' || $childBg === '') {
                if (in_array($cloudEff, $paletteEffects))         $childBg = '#ffffff';
                elseif (in_array($cloudEff, $forceBlackEffects))  $childBg = '#000000';
                else                                               $childBg = '#f5f5f5';
            }

            $childHasTunnel = !empty($childSettings['screen_tunnel'])
                || ($cloudEff !== 'none');
            // Flag script loading for child effects that the root scene may not have
            if ($childHasTunnel) $GLOBALS['impulse_needs_env_canvas'] = true;
            if (in_array($cloudEff, $_threeEffects)) $GLOBALS['impulse_needs_three'] = true;
            if (in_array($cloudEff, $_fxEffects))    $GLOBALS['impulse_needs_fx']    = true;
            if ($cloudEff === 'depth_image' && !empty($childSettings['screen_depth_enabled'])
                && !empty($childSettings['screen_depth_image']) && !empty($childSettings['screen_depth_map'])) {
                // Depth renders on every tier (watermarked in the free build).
                $GLOBALS['impulse_needs_depth_parallax'] = true;
            }
            $childTransition = $childSettings['screen_transition'] ?? '';
            if (in_array($childTransition, ['wipe','door','cube','swirl','glitch']))
                $GLOBALS['impulse_needs_gl_transitions'] = true;

            $childPadTop    = intval($childSettings['padding_top']    ?? 0);
            $childPadRight  = intval($childSettings['padding_right']  ?? 0);
            $childPadBottom = intval($childSettings['padding_bottom'] ?? 0);
            $childPadLeft   = intval($childSettings['padding_left']   ?? 0);
            $childMcw       = intval($childSettings['max_content_width'] ?? 1440);
            $childWStyle    = ($childMcw > 0 ? '--impulse-content-max-width:' . $childMcw . 'px;' : '--impulse-content-max-width:none;');

            // Build child element tree
            $childElements = \Instinctor\Services\PageService::getElements($childId);
            $childByParent = [];
            foreach ($childElements as $el) {
                $pid = $el['parent_id'] ?? 0;
                $childByParent[$pid][] = $el;
            }
            foreach ($childByParent as &$ch) {
                usort($ch, function($a, $b) { return ($a['sort_order'] ?? 0) - ($b['sort_order'] ?? 0); });
            }
            unset($ch);
            $childTree = impulse_build_tree(0, $childByParent);

            $_childScreenEmitted = false;
            $_scrnSeq = 0;
            foreach ($childTree as $element) {
                $type = $element['element_type'] ?? $element['section_type'] ?? '';
                if ($type === 'header' || $type === 'footer') continue;
                $elSettings = $element['settings'] ?? [];
                if (!impulse_check_visibility($elSettings)) continue;
                $_scrnSeq++;
                // Positional hash (s1, s2, ...) - element ids leaked inner
                // mechanics into URLs (#screen-40 on a 3-screen page).
                // css_id / screen_hash designer overrides still win. v2.1.1437.
                $hash    = sanitize_title($elSettings['css_id'] ?? ($elSettings['screen_hash'] ?? ('s' . $_scrnSeq)));
                $rawBg   = $elSettings['bg_color'] ?? 'transparent';
                // Cinematic "bare" mode: the snap-screen wrapper must be transparent
                // so the fixed hero backdrop shows through (this reads the section bg
                // directly, bypassing section.php's gate, so it needs its own check).
                $_cineBareScreen = ((($page_settings['body_scroll'] ?? '') !== 'classic') && !empty($page_settings['cinematic_bare']));
                $screenBg = ($childHasTunnel || $_cineBareScreen)
                    ? 'transparent'
                    : esc_attr(($rawBg !== '' && $rawBg !== 'transparent') ? $rawBg : $childBg);
                $entranceAttrs = '';
                $ePreset = $elSettings['screen_entrance'] ?? 'none';
                if ($ePreset && $ePreset !== 'none') {
                    $entranceAttrs  = ' data-entrance-preset="' . esc_attr($ePreset) . '"';
                    $entranceAttrs .= ' data-entrance-duration="' . intval($elSettings['screen_entrance_duration'] ?? 1200) . '"';
                    $entranceAttrs .= ' data-entrance-bg="' . esc_attr($rawBg ?: $childBg) . '"';
                }
                $isFirstScreen = !$_childScreenEmitted;
                $_childScreenEmitted = true;
                // First screen of child carries scene env data for JS background switching
                $sceneEnvAttr = '';
                if ($isFirstScreen) {
                    // Per-environment tier check — gate non-free environments on free tier
                    $cloudEffGated = $cloudEff;
                    if ($cloudEffGated !== 'none' && instinctor_env_locked($cloudEffGated)) {
                        $cloudEffGated = 'none';
                    }
                    // Env colours/params resolve from the section MERGED OVER the
                    // page-level palette: a per-screen save persists the effect name but
                    // the colours live page-level per effect (screen_<eff>_color*). Without
                    // this fall-back the scene-env rendered default greys and a recoloured
                    // per-screen effect "didn't survive" reload. Section keys still win.
                    $childEnv = array_merge((array) ($page_settings ?? []), (array) $childSettings);
                    $sceneEnvAttr = ' data-scene-env="' . esc_attr(wp_json_encode([
                        'cloudEffect'    => $cloudEffGated,
                        'bg'             => $childBg,
                        'transition'     => $childSettings['screen_transition'] ?? 'z_depth',
                        'duration'       => intval($childSettings['screen_duration'] ?? 700),
                        'tunnel'         => !empty($childSettings['screen_tunnel']) ? 1 : 0,
                        // Full dataset values so JS can patch wrapperEl.dataset before reinit
                        'lavaColor1'     => $childEnv['screen_lava_color1'] ?? '#333333',
                        'lavaColor2'     => $childEnv['screen_lava_color2'] ?? '#999999',
                        'lavaColor3'     => $childEnv['screen_lava_color3'] ?? '#666666',
                        'lavaColor4'     => $childEnv['screen_lava_color4'] ?? '#cccccc',
                        'lavaSpeed'      => intval($childEnv['screen_lava_speed'] ?? 10),
                        'lavaIntensity'  => intval($childEnv['screen_lava_intensity'] ?? 5),
                        'waveColor1'     => $childEnv['screen_wave_color1'] ?? '#333333',
                        'waveColor2'     => $childEnv['screen_wave_color2'] ?? '#999999',
                        'waveColor3'     => $childEnv['screen_wave_color3'] ?? '#666666',
                        'waveColor4'     => $childEnv['screen_wave_color4'] ?? '#cccccc',
                        'waveSpeed'      => intval($childEnv['screen_wave_speed'] ?? 10),
                        'wavePreset'     => $childEnv['screen_wave_preset'] ?? 'flow',
                        'waveIntensity'  => intval($childEnv['screen_wave_intensity'] ?? 10),
                        '3dColor1'       => impulse_resolve_3d_setting($childEnv, $cloudEff, 'color1', '#333333'),
                        '3dColor2'       => impulse_resolve_3d_setting($childEnv, $cloudEff, 'color2', '#999999'),
                        '3dColor3'       => impulse_resolve_3d_setting($childEnv, $cloudEff, 'color3', '#666666'),
                        '3dColor4'       => impulse_resolve_3d_setting($childEnv, $cloudEff, 'color4', '#cccccc'),
                        '3dSpeed'        => intval(impulse_resolve_3d_setting($childEnv, $cloudEff, 'speed', '10')),
                        '3dPreset'       => impulse_resolve_3d_setting($childEnv, $cloudEff, 'preset', ''),
                        '3dIntensity'    => intval(impulse_resolve_3d_setting($childEnv, $cloudEff, 'intensity', ($cloudEff === 'grid' ? '1' : '10'))),
                        '3dCount'        => intval(impulse_resolve_3d_setting($childEnv, $cloudEff, 'count', '7')),
                        '3dVariation'    => intval(impulse_resolve_3d_setting($childEnv, $cloudEff, 'variation', '3')),
                        '3dDensity'      => intval(impulse_resolve_3d_setting($childEnv, $cloudEff, 'density', '7')),
                        '3dHalo'         => intval(impulse_resolve_3d_setting($childEnv, $cloudEff, 'halo', '4')),
                        '3dBgColor'      => impulse_resolve_3d_setting($childEnv, $cloudEff, 'bg_color', ''),
                        'pageBgColor'    => $childBg,
                        'mouseSensLevel' => number_format((floatval($childSettings['screen_mouse_sens_level'] ?? 5) / 10), 2, '.', ''),
                        'neonHue'        => intval($childSettings['screen_effect_hue'] ?? -1),
                        'spaceVariant'   => intval($childSettings['screen_space_variant'] ?? 1),
                        'prismBg'        => $childSettings['screen_prism_bg'] ?? '',
                        'prismShape'     => intval($childSettings['screen_prism_shape'] ?? 0),
                        'bwElementSize'  => intval($childSettings['screen_bw_element_size'] ?? 50),
                        'bwElementCount' => intval($childSettings['screen_bw_element_count'] ?? 50),
                        'bwAccentColor'  => $childSettings['screen_bw_accent_color'] ?? '',
                        'tunnelSpeed'    => floatval($childSettings['screen_tunnel_speed'] ?? 1),
                        'depthEnabled'   => !empty($childSettings['screen_depth_enabled']) ? '1' : '0',
                        'depthImage'     => $childSettings['screen_depth_image'] ?? '',
                        'depthMap'       => impulse_resolve_depth_map($childSettings['screen_depth_map'] ?? '', $childSettings['screen_depth_image'] ?? ''),
                        'depthStrength'  => intval($childSettings['screen_depth_strength'] ?? 50)
                    ])) . '"';
                }
                /* Autoplay: when screen_autoplay_seconds > 0, the live
                   page schedules a next() advance N seconds after the
                   screen becomes active. Uses $elSettings (screen-scoped
                   in this iteration), NOT $childSettings — that latter
                   variable is bound to a different context further up
                   and was always empty here, which is why the attribute
                   never reached the rendered HTML. */
                $apSecs = floatval($elSettings['screen_autoplay_seconds'] ?? 0);
                $apAttr = ($apSecs > 0) ? ' data-autoplay-seconds="' . esc_attr($apSecs) . '"' : '';
                // Per-screen reveal (zone merge, Sergei 2026-06-03): each screen
                // owns its entrance. Absent/default → no attr → both engines fall
                // back to the page reveal, so existing pages are unchanged.
                $_rv = (string) ($elSettings['screen_transition'] ?? '');
                $revealAttr = ($_rv !== '' && $_rv !== 'default') ? ' data-reveal="' . esc_attr($_rv) . '"' : '';
                // Per-section intensity: when this screen has its own screen_intensity
                // setting, expose it as data-reveal-intensity so the runtime engine
                // applies it (otherwise it falls back to page/zone level). Sergei 2026-06-05.
                $revealIntAttr = isset($elSettings['screen_intensity']) && $elSettings['screen_intensity'] !== ''
                    ? ' data-reveal-intensity="' . esc_attr((string)(int)$elSettings['screen_intensity']) . '"'
                    : '';
                // Per-screen reveal speed (v2.1.1335): translate the panel's
                // 0-100% slider into a duration in ms using the same curve as
                // the page-level hero_speed (100% = 300ms fastest, 0% = 2000ms
                // slowest). Empty / unset = inherit zone duration. The runtime
                // (impulse-screens.js _zoneDuration) checks the destination
                // screen's data-reveal-speed-ms before falling back to the
                // wrapper's data-duration / data-hero-duration.
                $revealSpdAttr = '';
                if (isset($elSettings['screen_speed']) && $elSettings['screen_speed'] !== '') {
                    $_spdPct  = max(0, min(100, (int) $elSettings['screen_speed']));
                    $_spdMs   = max(300, min(2000, (int) round(2000 - ($_spdPct / 100) * 1700)));
                    $revealSpdAttr = ' data-reveal-speed-ms="' . $_spdMs . '"';
                }
                // Per-screen sensitivity (v2.1.1350): integer 1-4. Only emit
                // when explicitly >1; default 1 = today's single-tick advance.
                // Runtime accumulator in impulse-screens.js consumes this on
                // the DESTINATION screen — entering this screen requires N
                // wheel ticks / swipes in the same direction.
                $revealSensAttr = '';
                if (isset($elSettings['screen_sensitivity']) && $elSettings['screen_sensitivity'] !== '') {
                    $_sensN = max(1, min(4, (int) $elSettings['screen_sensitivity']));
                    if ($_sensN > 1) $revealSensAttr = ' data-reveal-sensitivity="' . $_sensN . '"';
                }
                // Per-screen Min height (vh) for native/classic scroll. The classic-bail
                // JS reads data-native-min-vh and applies it as min-height; ignored in snap.
                // Per-device: emit base + tablet + mobile so the classic-bail JS can
                // pick the value that matches the current viewport (mobile→tablet→base).
                $minVhAttr = '';
                if (isset($elSettings['screen_min_height_vh']) && (int) $elSettings['screen_min_height_vh'] > 0) {
                    $minVhAttr .= ' data-native-min-vh="' . max(0, min(200, (int) $elSettings['screen_min_height_vh'])) . '"';
                }
                if (isset($elSettings['screen_min_height_vh_tablet']) && $elSettings['screen_min_height_vh_tablet'] !== '') {
                    $minVhAttr .= ' data-native-min-vh-tablet="' . max(0, min(200, (int) $elSettings['screen_min_height_vh_tablet'])) . '"';
                }
                if (isset($elSettings['screen_min_height_vh_mobile']) && $elSettings['screen_min_height_vh_mobile'] !== '') {
                    $minVhAttr .= ' data-native-min-vh-mobile="' . max(0, min(200, (int) $elSettings['screen_min_height_vh_mobile'])) . '"';
                }
                // v2.1.1807: skippable screen flag. When screen_skip=1, the
                // engine's next/prev navigation bypasses this screen but
                // goTo(idx) still works — i.e. the screen is OFF the normal
                // scroll path until something programmatically reveals it.
                // Used by the homepage "See it in action" CTA which navigates
                // to a pizza-bridge screen on demand and re-skips on exit.
                $skipAttr = !empty($elSettings['screen_skip']) ? ' data-screen-skip="1"' : '';
                $skipClass = !empty($elSettings['screen_skip']) ? ' impulse-screen-skipped' : '';
                // data-id on the screen wrapper so the engine's openScreen()
                // resolver can find a screen by section id (button's
                // data-open-screen="<section_id>"). v2.1.1817.
                $_idAttr = !empty($element['id']) ? ' data-id="' . esc_attr((string) $element['id']) . '"' : '';
                echo '<div class="impulse-screen' . esc_attr($skipClass) . '" data-screen-hash="' . esc_attr($hash) . '" data-scene-id="' . esc_attr($childId) . '"' . $_idAttr . $sceneEnvAttr . $entranceAttrs . $apAttr . $revealAttr . $revealIntAttr . $revealSpdAttr . $revealSensAttr . $minVhAttr . $skipAttr . ' style="background:' . esc_attr($screenBg) . '">';
                echo '<div class="impulse-page" style="' . esc_attr($childWStyle) . '">';
                // Classic = sections have FREE height (Sergei 2026-07-02): a classic
                // section with no manually-set height is content-height, not a
                // centered 100dvh screen. Only cinematic/snap screens stay 100dvh.
                // Snap + any manual height (screen_min_height_vh / min_height / a
                // non-auto height_mode) keep the full-viewport centered behavior.
                $_classicFree = (($page_settings['body_scroll'] ?? '') === 'classic')
                    && (int) ($elSettings['screen_min_height_vh'] ?? 0) <= 0
                    && (int) ($elSettings['min_height'] ?? 0) <= 0
                    && in_array((string) ($elSettings['height_mode'] ?? 'auto'), ['', 'auto'], true);
                $_wrapMinH = $_classicFree ? 'auto' : 'calc(100dvh - var(--impulse-screen-bottom-inset, 0px))';
                $_wrapJC   = $_classicFree ? 'flex-start' : 'safe center';
                echo '<div class="impulse-content-wrapper" style="--impulse-wrapper-pad-y:' . (intval($childPadTop) + intval($childPadBottom)) . 'px;display:flex;flex-direction:column;justify-content:' . esc_attr($_wrapJC) . ';flex:1;min-height:' . esc_attr($_wrapMinH) . ';padding:' . esc_attr($childPadTop) . 'px ' . esc_attr($childPadRight) . 'px ' . esc_attr($childPadBottom) . 'px ' . esc_attr($childPadLeft) . 'px">';
                impulse_render_frontend_element($element);
                echo '</div></div></div>';
            }
        }
    // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped
    }
}



if (!function_exists('impulse_build_tree')) {
    function impulse_build_tree($parentId, &$byParent) {
        $result = [];
        if (isset($byParent[$parentId])) {
            foreach ($byParent[$parentId] as $el) {
                // Guard: skip self-referencing elements (parent_id === id)
                if (($el['id'] ?? 0) === $parentId) continue;
                $el['children'] = impulse_build_tree($el['id'], $byParent);
                $result[] = $el;
            }
        }
        return $result;
    }
}

/**
 * Render an Impulse page in screen mode.
 *
 * Every page is screen mode — each top-level content section becomes a
 * full-viewport screen. This is the single shared render body for both the
 * editor-preview path (?impulse_preview=ID) and the normal front-end path
 * (slug / front-page resolution). The two call sites differ only in how they
 * resolve $post_id / $elements / $page_settings; the render itself is here.
 *
 * @param int   $post_id       Resolved page ID.
 * @param array $elements      Decoded element tree from PageService::getElements().
 * @param array $page_settings Decoded page settings from PageService::getSettings().
 */
if (!function_exists('impulse_render_screen_page')) {
function impulse_render_screen_page($post_id, $elements, $page_settings) {
    // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Renderer: emits HTML/CSS/SVG composed from sanitized settings, numeric/esc_*()/uid values and internal data structures; no raw request input reaches output (zero ValidatedSanitizedInput violations in the body).
    // Make page settings reachable inside element-render functions (which
    // don't auto-import locals). The section render needs this to detect
    // depth_image + page-level gradient overlay (vignette etc.).
    $GLOBALS['impulse_page_settings'] = $page_settings;

    // Build element tree
    $tree = [];
    $byParent = [];
    foreach ($elements as $el) {
        $parentId = $el['parent_id'] ?? 0;
        if (!isset($byParent[$parentId])) {
            $byParent[$parentId] = [];
        }
        $byParent[$parentId][] = $el;
    }
    
    // Sort by sort_order
    foreach ($byParent as &$children) {
        usort($children, function($a, $b) {
            return ($a['sort_order'] ?? 0) - ($b['sort_order'] ?? 0);
        });
    }
    
    $tree = impulse_build_tree(0, $byParent);
    
    // Check if last element is footer
    $hasFooterLast = false;
    if (!empty($tree)) {
        $lastElement = end($tree);
        $lastType = $lastElement['element_type'] ?? $lastElement['section_type'] ?? '';
        if ($lastType === 'footer') {
            $hasFooterLast = true;
        }
    }
    
    // Page wrapper styles - use CSS variable for content max-width + padding
    $wrapperStyle = '';
    $maxContentWidth = intval($page_settings['max_content_width'] ?? 1440);
    $GLOBALS['impulse_page_max_content_width'] = $maxContentWidth;
    $paddingTop = $page_settings['padding_top'] ?? 0;
    $paddingRight = $page_settings['padding_right'] ?? 0;
    $paddingBottom = $page_settings['padding_bottom'] ?? 0;
    $paddingLeft = $page_settings['padding_left'] ?? 0;
    $bgColor = $page_settings['bg_color'] ?? '';
    if ($bgColor === 'transparent' || $bgColor === '') {
        $cloudEff = $page_settings['screen_cloud_effect'] ?? 'none';

        // Per-effect environment gate on public frontend
        if ($cloudEff !== 'none' && instinctor_env_locked($cloudEff)) {
            $cloudEff = 'none';
        }
        $paletteEffects = ['wave','clouds'];
        $forceBlackEffects = ['cosmos','caustics','glass','flow','topography','ripple','swirl','kaleidoscope','membrane','neon','bubble','bubbles','aurora','aura','fractal','lava','noise','wormhole','fireflies','columns','nebula','lens','asteroid','attractor','turing'];
        if (($page_settings['body_scroll'] ?? '') === 'classic') $bgColor = '#ffffff'; // classic = a normal website → white default (the env doesn't render here; manual bg_color still wins)
        elseif (in_array($cloudEff, $paletteEffects)) $bgColor = '#ffffff';
        elseif (in_array($cloudEff, $forceBlackEffects)) $bgColor = '#000000';
        else $bgColor = '#f5f5f5';
    }
    $GLOBALS['impulse_page_bg_color'] = $bgColor;
    $elementSpacing = $page_settings['element_spacing'] ?? 20;
    
    // Determine content max-width from page settings slider
    if ($maxContentWidth > 0) {
        $wrapperStyle .= '--impulse-content-max-width:' . $maxContentWidth . 'px;';
    } else {
        $wrapperStyle .= '--impulse-content-max-width:none;';
    }
    // CSS variables for padding
    $wrapperStyle .= '--impulse-padding-top:' . intval($paddingTop) . 'px;';
    $wrapperStyle .= '--impulse-padding-left:' . intval($paddingLeft) . 'px;';
    $wrapperStyle .= '--impulse-padding-right:' . intval($paddingRight) . 'px;';
    // Screen mode: each screen carries its own bg_color — the wrapper stays
    // transparent (no page-level background).

    // Separate elements into header, content, footer, and overlays.
    //
    // Overlay types (popup, modal, modal) are top-level elements
    // but they're page chrome, not snap-screens. Their renderers emit
    // their own fixed/absolute positioning and self-gate visibility on
    // a trigger. Throwing them into $contentElements made each one
    // become a 100dvh phantom screen that the snap engine would happily
    // navigate to — empty viewport, no way forward. (This was masked
    // for years by the footer-screen sitting after them; once the
    // footer stopped being a screen in v2.1.1405 the phantom became
    // the page's hard dead-end.) Overlays render OUTSIDE the screen
    // wrapper, alongside the site header — also chrome that escapes
    // the snap deck. v2.1.1407.
    $overlayTypes = ['modal'];

    $headerElements  = [];
    $contentElements = [];
    $footerElements  = [];
    $overlayElements = [];

    foreach ($tree as $element) {
        $type = $element['type'] ?? $element['element_type'] ?? $element['section_type'] ?? '';
        if ($type === 'header') {
            $headerElements[] = $element;
        } elseif ($type === 'footer') {
            $footerElements[] = $element;
        } elseif (in_array($type, $overlayTypes, true)) {
            $overlayElements[] = $element;
        } else {
            $contentElements[] = $element;
        }
    }
    
    // Output styles and render page
    impulse_output_frontend_styles();

    $screenTransition = $page_settings['screen_transition'] ?? 'z_depth';
    $screenCooldown = intval($page_settings['screen_cooldown'] ?? 150);
    $screenDuration = intval($page_settings['screen_duration'] ?? 700);
    $screenIntensity = intval($page_settings['screen_intensity'] ?? 50);
    // Hero zone: the first $heroScreenCount screens use their own hero_*
    // reveal; the rest use the screen_* (Body zone) values above.
    $heroScreenCount = intval($page_settings['hero_screen_count'] ?? 0);
    // Per-page body scroll mode: 'snap' (default, fixed cinematic deck the
    // whole way) or 'classic' (hero stays a snap deck, body releases to
    // native scroll with CSS scroll-snap). Runtime reads data-body-scroll.
    $bodyScroll      = (($page_settings['body_scroll'] ?? 'snap') === 'classic') ? 'classic' : 'snap';
    // Publish the scroll mode so the section renderer can give classic top-level
    // sections free (content) height instead of a centered 100dvh screen. 2026-07-02.
    $GLOBALS['impulse_body_scroll'] = $bodyScroll;
    $heroTransition  = $page_settings['hero_transition'] ?? $screenTransition;
    // Optional per-page asymmetric override: scrolling backward INTO
    // screen 0 uses this transition instead of $heroTransition. Empty
    // = symmetric behaviour (forward and back both use heroTransition).
    // instinctor.com sets this to 'fade' so the brand reveal isn't
    // re-played in reverse every time the visitor scrolls up.
    $heroBackFirstTrans = (string) ($page_settings['hero_back_first_transition'] ?? '');
    // Optional per-page chrome hiding: hide site header + chat widget
    // on the first N screens. Useful for marketing pages where the
    // opening screens are brand reveals or full-bleed compositions and
    // chrome (logo, nav, chat widget) would compete with them.
    //
    // Legacy key `hide_chrome_on_first_screen` (boolean) maps to N=1.
    // Key `hide_chrome_on_first_screens` (int) sets the desktop+ count.
    // Key `hide_chrome_on_first_screens_mobile` (int) extends the count
    //     on viewports ≤767px only — letting a page hide chrome on more
    //     screens for phones (small viewports where chrome competes
    //     harder with full-bleed content) while keeping the desktop
    //     header visible from a lower screen index.
    $hideChromeCount = isset($page_settings['hide_chrome_on_first_screens'])
        ? max(0, intval($page_settings['hide_chrome_on_first_screens']))
        : (!empty($page_settings['hide_chrome_on_first_screen']) ? 1 : 0);
    $hideChromeCountMobile = isset($page_settings['hide_chrome_on_first_screens_mobile'])
        ? max(0, intval($page_settings['hide_chrome_on_first_screens_mobile']))
        : $hideChromeCount;  // default: same as desktop
    $hideChromeFirstScreen = ($hideChromeCount > 0 || $hideChromeCountMobile > 0);
    $heroIntensity   = isset($page_settings['hero_intensity']) ? intval($page_settings['hero_intensity']) : $screenIntensity;
    $heroSpeedDisp   = isset($page_settings['hero_speed']) ? intval($page_settings['hero_speed']) : 76;
    $heroDuration    = max(300, min(2000, (int) round(2000 - ($heroSpeedDisp / 100) * 1700)));
    $screenNavDots = !empty($page_settings['nav_dots']);
    // Loop defaults to true (better UX — visitors hitting the last screen
    // shouldn't dead-end). Explicit false stays false. When this scene is
    // a member of a roll AND we're on the live frontend, the runtime
    // hands off to the next scene in the roll instead of looping inner
    // screens — so we disable the inner loop in that case so the handoff
    // RollService gone 2026-05-23 — loop is just the explicit setting now.
    // Backward compat: legacy DB rows store the value under `hero_loop`
    // (pre-rename key). Fall through to that before defaulting to true so
    // existing pages keep looping on the editor side too after the editor
    // PHP started reading the same fallback chain. Locked 2026-05-27.
    $loopExplicit = $page_settings['screen_infinite_loop'] ?? ($page_settings['hero_loop'] ?? null);
    $screenInfiniteLoop = ($loopExplicit === null) ? true : !empty($loopExplicit);
    $screenTunnel = !empty($page_settings['screen_tunnel']);
    $screenTunnelSpeed = isset($page_settings['screen_tunnel_speed']) ? floatval($page_settings['screen_tunnel_speed']) : 1;
    $screenCloudEffect = $page_settings['screen_cloud_effect'] ?? 'none';
    // Gate — free bundled effects render; others need platform account.
    // Admin/preview context always gets full access.
    if ($screenCloudEffect !== 'none' && instinctor_env_locked($screenCloudEffect)) {
        $screenCloudEffect = 'none';
    }
    $screenDepthEnabled  = !empty($page_settings['screen_depth_enabled']);
    $screenDepthImage    = $page_settings['screen_depth_image'] ?? '';
    $screenDepthMap      = impulse_resolve_depth_map($page_settings['screen_depth_map'] ?? '', $page_settings['screen_depth_image'] ?? '');
    $screenDepthStrength = intval($page_settings['screen_depth_strength'] ?? 50);
    // Glass composes over the 3D image — auto-enable depth when glass has
    // image+map, even if the user never explicitly flipped the depth flag.
    // Without this, switching effects strips the wrapper's data-depth-image
    // (see reinitTunnel) and Glass loses its bg layer permanently.
    if (!$screenDepthEnabled && $screenCloudEffect === 'glass' && $screenDepthImage && $screenDepthMap) {
        $screenDepthEnabled = true;
    }
    // 3D depth renders on every tier; the free build watermarks the output
    // (build-time engine flag), Pro renders it clean. No license gate.
    if ($screenDepthEnabled && $screenDepthImage && $screenDepthMap) {
        // Glass refracts what's behind it — when both Glass and 3D image are
        // configured, render both: parallax canvas (z:-1) under the Glass FX
        // canvas (z:0). All other env effects suppress so two backgrounds
        // don't fight.
        if ($screenCloudEffect !== 'glass') {
            $screenCloudEffect = 'none';
        }
    }

    // Free build: screen 3D/depth/tunnel is an editor-only demo. Suppress it on
    // the published page so the section keeps its OWN background. Gating the
    // tunnel is what actually matters here: while $screenTunnel is true,
    // impulse-screens.js calls initTunnel() (line 1582) even with cloud-effect
    // 'none', which OVERRIDES the page background with documentElement.style
    // 'background:#f5f5f5!important' (its pageBgColor read misses the wrapper) and
    // forces the section to transparent (line 2620). Setting it false stops
    // initTunnel, so the html{background} the user chose stands and the section
    // renders its real background. The editor preview (impulse-preview.php) is a
    // separate renderer and keeps the live demo.
    if (function_exists('instinctor_is_free_build') && instinctor_is_free_build()) {
        $screenDepthEnabled = false;
        $screenDepthImage   = '';
        $screenDepthMap     = '';
        $screenTunnel       = false;
    }

    
    // Global responsive max-content-width for containers
    $mcwTablet = isset($page_settings['max_content_width_tablet']) ? intval($page_settings['max_content_width_tablet']) : null;
    $mcwMobile = isset($page_settings['max_content_width_mobile']) ? intval($page_settings['max_content_width_mobile']) : null;
    if ($mcwTablet !== null || $mcwMobile !== null) {
        ob_start();
        if ($mcwTablet !== null) {
            echo '@media(max-width:1024px){.impulse-container,body.impulse-scroll-classic .impulse-section.impulse-stretch>.impulse-container-full{max-width:' . ($mcwTablet > 0 ? (int) $mcwTablet . 'px' : 'none') . '!important}}';
        }
        if ($mcwMobile !== null) {
            echo '@media(max-width:767px){.impulse-container,body.impulse-scroll-classic .impulse-section.impulse-stretch>.impulse-container-full{max-width:' . ($mcwMobile > 0 ? (int) $mcwMobile . 'px' : 'none') . '!important}}';
        }
        $_inst_css2 = ob_get_clean(); wp_add_inline_style("instinctor-page-inline", $_inst_css2);
    }

    // SCREEN MODE: each section is a full-viewport screen.
    // Roll-context handoff removed 2026-05-23.
        // Reveal-from-depth entry when arriving from a chained sequence
        $seqDuration = max(200, intval($page_settings['screen_duration'] ?? 700));
        // Entry cover for chained (same-site) navigation. This MUST stay inline
        // here (early in the body) and NOT go through wp_add_inline_*: on
        // standalone those flush at wp_footer, so the cover would be created
        // unstyled and late (a flash), and it hung on window.load with no
        // fallback -- a slow resource left the hero hidden up to 10s. The reveal
        // now fires on window.load OR a 1500ms fallback, whichever comes first.
        // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- page-builder inline style; unique per-page/per-element, generated at render time
        echo '<style>#impulse-seq-entry-cover{'
           . 'position:fixed;top:0;left:0;width:100%;height:100%;'
           . 'background:' . esc_attr($bgColor ?: '#000') . ';'
           . 'z-index:99998;pointer-events:none;'
           . 'opacity:1;transform:scale(1.45);filter:blur(18px);'
           . 'transition:opacity ' . (int) $seqDuration . 'ms cubic-bezier(0.4, 0, 0.2, 1),'
           . 'transform ' . (int) $seqDuration . 'ms cubic-bezier(0.4, 0, 0.2, 1),'
           . 'filter ' . (int) $seqDuration . 'ms cubic-bezier(0.4, 0, 0.2, 1)'
           . '}</style>';
        // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- pre-paint transition cover: runs right after the per-page cover CSS above (which is generated per page and cannot be enqueued) to paint the chained-navigation cover before first paint; deferring it to an enqueued footer script would run it after the page has already painted.
        echo '<script>(function(){'
           . 'var ref=document.referrer;'
           . 'var isChained=ref&&ref.indexOf(location.hostname)!==-1&&ref!==location.href;'
           . 'if(!isChained)return;'
           . 'var c=document.createElement("div");c.id="impulse-seq-entry-cover";'
           . 'document.documentElement.appendChild(c);'
           . 'var done=false;function rv(){if(done)return;done=true;'
           .   'c.style.opacity="0";c.style.transform="scale(1)";c.style.filter="blur(0px)";'
           .   'setTimeout(function(){c.remove();},650);}'
           . 'window.addEventListener("load",rv);setTimeout(rv,1500);'
           . '})();</script>';
    ?>
    <?php ob_start(); ?>.impulse-screen .impulse-content-wrapper::before,.impulse-screen .impulse-content-wrapper::after{content:'';flex:1 0 0px}
/* Hide all but the first screen until JS runs applyFocusState/applyEnterState.
   Without this, server-rendered HTML shows ALL screens stacked at z-index 0
   for a frame or two before JS marks the first active and hides the rest.
   JS-set inline opacity/visibility override these defaults during transitions. */
body[data-screen-mode="1"] .impulse-screen-wrapper > .impulse-screen{opacity:0;visibility:hidden}
body[data-screen-mode="1"] .impulse-screen-wrapper > .impulse-screen:first-of-type,
body[data-screen-mode="1"] .impulse-screen-wrapper > .impulse-screen.impulse-screen-active{opacity:1;visibility:visible}
<?php if ($hideChromeFirstScreen):
    /* Build selectors that match the first N screens (data-impulse-screen
       "0".."N-1"), where N = $hideChromeCount. Emitted in two halves:
       a comma-separated chain for the "chrome hidden" rules, and a
       chained :not() for the "chrome visible" reverse path.

       Specificity note: the live header has an inline-style rule keyed
       on its dynamic id (#impulse-header-NNN, specificity 0,1,0,0).
       Using only `.impulse-header` would lose for any property
       both touch. The id-rule only sets position/zindex (not opacity/
       visibility), so the !important properties below are uncontested.
       Attribute-selector fallback (`[id^="impulse-header-"]`) defends
       against future inline rules landing on the same id. */
    $_hideBodyPrefix = function ($el) use ($hideChromeCount) {
        $out = [];
        for ($i = 0; $i < $hideChromeCount; $i++) {
            $out[] = 'body[data-impulse-screen="' . $i . '"] ' . $el;
        }
        return implode(',', $out);
    };
    $_showBodyPrefix = function ($el) use ($hideChromeCount) {
        $notChain = '';
        for ($i = 0; $i < $hideChromeCount; $i++) {
            $notChain .= ':not([data-impulse-screen="' . $i . '"])';
        }
        return 'body' . $notChain . ' ' . $el;
    };
?>
/* HEADER — slides up out of view on the first <?php echo (int) $hideChromeCount; ?> screen(s),
   slides back down on every other screen. translateY(-100%) anchors to
   the header's own height so the slide distance is naturally correct
   regardless of header height. Combined opacity + transform transition
   (480ms ease-in-out) gives a smooth fade-and-slide. */
<?php echo $_hideBodyPrefix('.impulse-header') . ',' . $_hideBodyPrefix('[id^="impulse-header-"]'); ?> {
    opacity: 0 !important;
    visibility: hidden !important;
    pointer-events: none !important;
    transform: translateY(-100%) !important;
    transition: opacity 480ms ease,
                transform 480ms ease,
                visibility 0s linear 480ms !important;
}
/* CHAT — corner widget; opacity fade only. */
<?php echo $_hideBodyPrefix('#instinctor-chat') . ',' . $_hideBodyPrefix('#instinctor-chat-bar'); ?> {
    opacity: 0 !important;
    visibility: hidden !important;
    pointer-events: none !important;
    transition: opacity 480ms ease,
                visibility 0s linear 480ms !important;
}
/* The .impulse-content-wrapper inside each chrome-hidden screen
   carries an inline padding-top (default 26px to clear the absolute
   header). With the header hidden, that becomes a visible gap. Zero
   it while the chrome is hidden. */
<?php echo $_hideBodyPrefix('.impulse-content-wrapper'); ?> {
    padding-top: 0 !important;
}
<?php
    /* Header layout-removal on chrome-hidden screens. The header
       element renderer emits `#impulse-header-N{position:relative
       !important}` on mobile (≤768px) when sticky is off — this
       puts the header BACK into flow on mobile, taking ~64px of
       layout space at the top of its container even when our
       hide-rule sets visibility:hidden + translateY(-100%) (those
       move the element visually but don't release the layout box).
       Result: cosmos env shows above the screen content area by
       exactly the header height. We need to match the relative-
       rule's ID specificity to override it, so iterate every
       header element in the page tree and emit an id-prefixed
       override under the chrome-hide selectors. $headerElements
       was built above by the tree-pass that splits header/content/
       footer (line 1884+). Was originally trying $_headerPage
       (PageService::getByType global_header) but the header on
       this page is an inline element in the page's own tree, not a
       separate global_header page row. */
    $_hdrIds = [];
    if (!empty($headerElements)) {
        foreach ($headerElements as $_hElement) {
            if (!empty($_hElement['id'])) $_hdrIds[] = (string) $_hElement['id'];
        }
    }
    /* Global header (separate `global_header` page row): its header
       element lives in $_headerPage['elements'], not in $tree, so the
       loop above misses it. Without these IDs the layout-removal rule
       never targets the global header — `#impulse-header-N{position:
       relative !important}` from the header renderer keeps it in flow,
       cosmos shows above screen 1 by the header's height. Walk the
       global header's element tree, collect every header id.
       2026-05-30. */
    if (!empty($hasGlobalHeader) && !empty($_headerPage['elements']) && is_array($_headerPage['elements'])) {
        foreach ($_headerPage['elements'] as $_ghEl) {
            $_ghType = $_ghEl['type'] ?? $_ghEl['element_type'] ?? '';
            if ($_ghType === 'header' && !empty($_ghEl['id'])) {
                $_hdrIds[] = (string) $_ghEl['id'];
            }
        }
    }
    if (!empty($_hdrIds) && $hideChromeCount > 0):
        $_hdrChromeSelectors = [];
        foreach ($_hdrIds as $_hdrId) {
            $_hdrIdSel = '#impulse-header-' . $_hdrId;
            for ($_hi = 0; $_hi < $hideChromeCount; $_hi++) {
                $_hdrChromeSelectors[] = 'body[data-impulse-screen="' . $_hi . '"] ' . $_hdrIdSel;
            }
        }
?>
<?php echo implode(',', $_hdrChromeSelectors); ?> {
    position: absolute !important;
}
<?php endif; ?>
/* Reverse side: when off the chrome-hidden screens, ease-in-out the
   chrome back into visible state. visibility flips immediately so the
   slide actually plays (transform on a hidden element is invisible). */
<?php echo $_showBodyPrefix('.impulse-header') . ',' . $_showBodyPrefix('[id^="impulse-header-"]'); ?> {
    transform: translateY(0);
    transition: opacity 480ms ease,
                transform 480ms ease,
                visibility 0s linear 0s;
}
<?php echo $_showBodyPrefix('#instinctor-chat') . ',' . $_showBodyPrefix('#instinctor-chat-bar'); ?> {
    transition: opacity 480ms ease,
                visibility 0s linear 0s;
}
<?php
    /* Mobile extension: if hide_chrome_on_first_screens_mobile > base
       count, emit additional !important hide rules for the extra
       indices, wrapped in @media (max-width:767px). The base reverse
       "show" rule will match these screens too (since they're outside
       the base hidden range) — !important here wins on the mobile
       breakpoint so the chrome stays hidden until the visitor scrolls
       past the mobile-extra screens. */
    if ($hideChromeCountMobile > $hideChromeCount):
        $_mobileExtraHideEls = function ($el) use ($hideChromeCount, $hideChromeCountMobile) {
            $out = [];
            for ($i = $hideChromeCount; $i < $hideChromeCountMobile; $i++) {
                $out[] = 'body[data-impulse-screen="' . $i . '"] ' . $el;
            }
            return implode(',', $out);
        };
?>
@media (max-width:767px) {
<?php echo $_mobileExtraHideEls('.impulse-header') . ',' . $_mobileExtraHideEls('[id^="impulse-header-"]'); ?> {
    opacity: 0 !important;
    visibility: hidden !important;
    pointer-events: none !important;
    transform: translateY(-100%) !important;
    transition: opacity 480ms ease,
                transform 480ms ease,
                visibility 0s linear 480ms !important;
}
<?php echo $_mobileExtraHideEls('#instinctor-chat') . ',' . $_mobileExtraHideEls('#instinctor-chat-bar'); ?> {
    opacity: 0 !important;
    visibility: hidden !important;
    pointer-events: none !important;
    transition: opacity 480ms ease,
                visibility 0s linear 480ms !important;
}
<?php echo $_mobileExtraHideEls('.impulse-content-wrapper'); ?> {
    padding-top: 0 !important;
}
<?php
        /* Header layout-removal for mobile-extra hidden screens.
           Same id-specificity override as the base block above —
           emit for each header element on the page tree. */
        if (!empty($_hdrIds)) {
            $_hdrMobileSelectors = [];
            foreach ($_hdrIds as $_hdrId) {
                $_hdrIdSel = '#impulse-header-' . $_hdrId;
                for ($_hi = $hideChromeCount; $_hi < $hideChromeCountMobile; $_hi++) {
                    $_hdrMobileSelectors[] = 'body[data-impulse-screen="' . $_hi . '"] ' . $_hdrIdSel;
                }
            }
            if (!empty($_hdrMobileSelectors)):
?>
<?php echo implode(',', $_hdrMobileSelectors); ?> {
    position: absolute !important;
}
<?php       endif;
        }
?>
}
<?php endif; ?>
<?php endif; ?>
<?php
$_inst_lcss = ob_get_clean();
// Emit inline HERE — immediately before the .impulse-screen-wrapper below — NOT via the
// page-inline handle (which the standalone shim flushes at wp_footer, after the screens have
// already painted). This is critical pre-paint layout CSS: the "hide all but the first screen"
// rule MUST apply before the screens render, or non-active screens flash stacked for a frame on
// reload (the inner-screen flash). Critical CSS is the acknowledged inline exception. v2.1.1994.
// phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet,WordPress.Security.EscapeOutput.OutputNotEscaped -- server-generated critical CSS, no user input, must be inline pre-paint
echo '<style id="impulse-screen-critical">' . $_inst_lcss . '</style>';
?>
    <div class="impulse-screen-wrapper" style="position:fixed;top:0;left:0;width:100%;height:var(--imp-vh,100dvh);overflow:hidden;z-index:1"
         data-transition="<?php echo esc_attr($screenTransition); ?>"
         data-cooldown="<?php echo esc_attr($screenCooldown); ?>"
         data-duration="<?php echo esc_attr($screenDuration); ?>"
         data-intensity="<?php echo esc_attr($screenIntensity); ?>"
         data-hero-count="<?php echo ($bodyScroll === 'classic') ? esc_attr(max(1, $heroScreenCount)) : 0; ?>"<?php /* SNAP pages stay zone-free (0) — the 2026-06-16 hero-zone removal killed the per-block 3D decompose, and $heroScreenCount drives nothing else in the renderer. CLASSIC pages must emit the hero count so impulse-screens.js releaseToBody() can hand the body below the hero off to native scroll (a conventional vertical-scrolling website). Hardcoding 0 here had silently disabled classic/conventional layouts for every site. */ ?>
         data-body-scroll="<?php echo esc_attr($bodyScroll); ?>"
         data-hero-transition="<?php echo esc_attr($heroTransition); ?>"
         data-hero-back-first-transition="<?php echo esc_attr($heroBackFirstTrans); ?>"
         data-hero-intensity="<?php echo esc_attr($heroIntensity); ?>"
         data-hero-duration="<?php echo esc_attr($heroDuration); ?>"
         data-navdots="<?php echo $screenNavDots ? '1' : '0'; ?>"
         data-infinite="<?php echo $screenInfiniteLoop ? '1' : '0'; ?>"
         data-tunnel="<?php echo $screenTunnel ? '1' : '0'; ?>" data-tunnel-speed="<?php echo esc_attr($screenTunnelSpeed); ?>" data-cloud-effect="<?php echo esc_attr($screenCloudEffect); ?>" data-space-variant="<?php echo intval($page_settings['screen_space_variant'] ?? 1); ?>" data-mouse-sens-level="<?php echo number_format((floatval($page_settings["screen_mouse_sens_level"] ?? 5) / 10), 2, '.', ''); ?>" data-neon-sides="<?php echo isset($page_settings['screen_neon_sides']) ? intval($page_settings['screen_neon_sides']) : 4; ?>" data-neon-hue="<?php echo isset($page_settings['screen_effect_hue']) ? intval($page_settings['screen_effect_hue']) : esc_attr(-1); ?>" data-page-bg-color="<?php echo esc_attr($bgColor); ?>" data-bw-element-size="<?php echo isset($page_settings['screen_bw_element_size']) ? intval($page_settings['screen_bw_element_size']) : 50; ?>" data-bw-element-count="<?php echo isset($page_settings['screen_bw_element_count']) ? intval($page_settings['screen_bw_element_count']) : 50; ?>" data-bw-accent-color="<?php echo esc_attr($page_settings['screen_bw_accent_color'] ?? ''); ?>" data-lava-color1="<?php echo esc_attr($page_settings['screen_lava_color1'] ?? '#333333'); ?>" data-lava-color2="<?php echo esc_attr($page_settings['screen_lava_color2'] ?? '#999999'); ?>" data-lava-color3="<?php echo esc_attr($page_settings['screen_lava_color3'] ?? '#666666'); ?>" data-lava-color4="<?php echo esc_attr($page_settings['screen_lava_color4'] ?? '#cccccc'); ?>" data-magma-color1="<?php echo esc_attr($page_settings['screen_magma_color1'] ?? '#333333'); ?>" data-magma-color2="<?php echo esc_attr($page_settings['screen_magma_color2'] ?? '#999999'); ?>" data-magma-color3="<?php echo esc_attr($page_settings['screen_magma_color3'] ?? '#666666'); ?>" data-magma-color4="<?php echo esc_attr($page_settings['screen_magma_color4'] ?? '#cccccc'); ?>" data-wax-color1="<?php echo esc_attr($page_settings['screen_wax_color1'] ?? '#333333'); ?>" data-wax-color2="<?php echo esc_attr($page_settings['screen_wax_color2'] ?? '#999999'); ?>" data-wax-color3="<?php echo esc_attr($page_settings['screen_wax_color3'] ?? '#666666'); ?>" data-wax-color4="<?php echo esc_attr($page_settings['screen_wax_color4'] ?? '#cccccc'); ?>" data-lava-speed="<?php echo intval($page_settings['screen_lava_speed'] ?? 10); ?>" data-lava-intensity="<?php echo intval($page_settings['screen_lava_intensity'] ?? 5); ?>" data-wave-color1="<?php echo esc_attr($page_settings['screen_wave_color1'] ?? '#333333'); ?>" data-wave-color2="<?php echo esc_attr($page_settings['screen_wave_color2'] ?? '#999999'); ?>" data-wave-color3="<?php echo esc_attr($page_settings['screen_wave_color3'] ?? '#666666'); ?>" data-wave-color4="<?php echo esc_attr($page_settings['screen_wave_color4'] ?? '#cccccc'); ?>" data-wave-speed="<?php echo intval($page_settings['screen_wave_speed'] ?? 10); ?>" data-wave-preset="<?php echo esc_attr($page_settings['screen_wave_preset'] ?? 'flow'); ?>" data-wave-intensity="<?php echo intval($page_settings['screen_wave_intensity'] ?? 10); ?>" data-3d-color1="<?php echo esc_attr(impulse_resolve_3d_setting($page_settings, $screenCloudEffect, 'color1', '#333333')); ?>" data-3d-color2="<?php echo esc_attr(impulse_resolve_3d_setting($page_settings, $screenCloudEffect, 'color2', '#999999')); ?>" data-3d-color3="<?php echo esc_attr(impulse_resolve_3d_setting($page_settings, $screenCloudEffect, 'color3', '#666666')); ?>" data-3d-color4="<?php echo esc_attr(impulse_resolve_3d_setting($page_settings, $screenCloudEffect, 'color4', '#cccccc')); ?>" data-3d-speed="<?php echo intval(impulse_resolve_3d_setting($page_settings, $screenCloudEffect, 'speed', '10')); ?>" data-3d-preset="<?php echo esc_attr(impulse_resolve_3d_setting($page_settings, $screenCloudEffect, 'preset', '')); ?>" data-3d-intensity="<?php echo intval(impulse_resolve_3d_setting($page_settings, $screenCloudEffect, 'intensity', '10')); ?>" data-3d-bg-color="<?php echo esc_attr(impulse_resolve_3d_setting($page_settings, $screenCloudEffect, 'bg_color', '')); ?>" data-prism-bg="<?php echo esc_attr($page_settings['screen_prism_bg'] ?? ''); ?>" data-prism-shape="<?php echo intval($page_settings['screen_prism_shape'] ?? 0); ?>" data-outer-opacity="<?php echo esc_attr($page_settings['screen_outer_opacity'] ?? ''); ?>" data-depth-image="<?php echo esc_attr($screenDepthImage); ?>" data-depth-map="<?php echo esc_attr($screenDepthMap); ?>" data-depth-strength="<?php echo esc_attr($screenDepthStrength); ?>" data-depth-enabled="<?php echo $screenDepthEnabled ? '1' : '0'; ?>" data-depth-oscillation="<?php echo ($page_settings['screen_depth_oscillation'] ?? '1') !== '0' ? '1' : '0'; ?>" data-depth-chromatic="<?php echo ($page_settings['screen_depth_chromatic'] ?? '1') !== '0' ? '1' : '0'; ?>">
    <?php /* Always render canvas in screen mode — editor may switch effect to a non-none value */ ?>
    <canvas id="impulse-tunnel-canvas" style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;pointer-events:none<?php echo ($screenTunnel || $screenCloudEffect !== 'none') ? '' : ';display:none'; ?>"></canvas>
    <?php
    // Collect screen hashes for nav block
    $impulse_screen_hashes = [];
    foreach ($contentElements as $_se) {
        $_seSettings = $_se['settings'] ?? [];
        if (!impulse_check_visibility($_seSettings)) continue;
        $impulse_screen_hashes[] = sanitize_title($_seSettings['css_id'] ?? ($_seSettings['screen_hash'] ?? ('s' . (count($impulse_screen_hashes) + 1))));
    }
    $GLOBALS['impulse_screen_hashes'] = $impulse_screen_hashes;
    
    // Enforce min 26px top padding when a header block exists
    $snavPadTop = (!empty($headerElements) && intval($paddingTop) < 26) ? 26 : intval($paddingTop);

    // Last visible content screen id — the snap-mode footer pins to it.
    $_lastVisibleId = null;
    foreach ($contentElements as $_ce) {
        if (impulse_check_visibility($_ce['settings'] ?? [])) $_lastVisibleId = $_ce['id'] ?? null;
    }
    // Each content section = one screen
    $_scrnSeq2 = 0;
    foreach ($contentElements as $element) {
        $elSettings = $element['settings'] ?? [];
        // Skip screens hidden on all devices
        if (!impulse_check_visibility($elSettings)) continue;
        $_scrnSeq2++;
        $hash = sanitize_title($elSettings['css_id'] ?? ($elSettings['screen_hash'] ?? ('s' . $_scrnSeq2)));
        $rawBg = $elSettings['bg_color'] ?? 'transparent';
        // Cinematic "bare": snap-screen wrapper transparent so the fixed hero backdrop shows through.
        $_cineBareScreen2 = ((($page_settings['body_scroll'] ?? '') !== 'classic') && !empty($page_settings['cinematic_bare']));
        $_screenBg = ($screenTunnel || $screenCloudEffect !== 'none' || $_cineBareScreen2) ? 'transparent' : esc_attr($rawBg);
        $_entrancePreset = $elSettings['screen_entrance'] ?? 'none';
        $_entranceAttrs = '';
        if ($_entrancePreset && $_entrancePreset !== 'none') {
            $_entranceAttrs  = ' data-entrance-preset="' . esc_attr($_entrancePreset) . '"';
            $_entranceAttrs .= ' data-entrance-duration="' . intval($elSettings['screen_entrance_duration'] ?? 1200) . '"';
            $_entranceAttrs .= ' data-entrance-bg="' . esc_attr($rawBg ?: '#000000') . '"';
        }
        // Per-screen scroll mode (Stage 1 of scene/page unification): 'snap'
        // = pure 100vh panel, no internal scroll; 'free' = scrolls its own
        // overflow then snaps at the edge. Unset = today's auto behaviour.
        $_scrollMode = $elSettings['screen_scroll'] ?? '';
        $_scrollAttr = in_array($_scrollMode, ['snap', 'free'], true) ? ' data-scroll="' . $_scrollMode . '"' : '';
        // Autoplay (screen-level auto-advance) — see same logic in the
        // child-screen iteration block higher up.
        $_apSecs = floatval($elSettings['screen_autoplay_seconds'] ?? 0);
        $_apAttr = ($_apSecs > 0) ? ' data-autoplay-seconds="' . esc_attr($_apSecs) . '"' : '';
        // Scroll cue (per-screen): renders "SCROLL" + animated chevron at
        // bottom-center. Used as the cinematic hand-off marker — e.g. after
        // an auto-advanced intro, the next screen needs to tell the visitor
        // "you're driving now". The cue lives inside the screen wrapper so
        // it inherits the screen's visibility (auto-hides when the screen
        // is inactive). No JS required — pure CSS pulse + a one-time global
        // style block. Sergei 2026-05-31.
        $_showScrollCue = !empty($elSettings['screen_show_scroll']);
        // Hide whichever background effect the page is running (cosmos,
        // tubes, lava, wave, aurora, glass, depth-image — any of the 30+
        // effects) while this screen is active. Per-screen `bg_color`
        // fades with screen opacity during transitions, exposing the
        // effect canvas for ~100ms between two solid-bg screens (the
        // "content disappears, effect peeks, next bg overlays" flash).
        // Hiding the canvases themselves sidesteps the fade race entirely,
        // and a smooth CSS opacity transition handles the reveal when the
        // visitor scrolls into the first non-flagged screen.
        // Sergei 2026-05-31.
        $_hideEffect = !empty($elSettings['screen_hide_effect']);
        $_hideEffectAttr = $_hideEffect ? ' data-hide-effect="1"' : '';
        // Per-screen reveal (zone merge, Sergei 2026-06-03): see multi-screen path above.
        $_rv2 = (string) ($elSettings['screen_transition'] ?? '');
        $_revealAttr2 = ($_rv2 !== '' && $_rv2 !== 'default') ? ' data-reveal="' . esc_attr($_rv2) . '"' : '';
        // Per-section intensity, mirrors the multi-screen path above. Sergei 2026-06-05.
        $_revealIntAttr2 = isset($elSettings['screen_intensity']) && $elSettings['screen_intensity'] !== ''
            ? ' data-reveal-intensity="' . esc_attr((string)(int)$elSettings['screen_intensity']) . '"'
            : '';
        // Per-screen reveal speed — same curve / fallback as the multi-screen
        // path above. v2.1.1335.
        $_revealSpdAttr2 = '';
        if (isset($elSettings['screen_speed']) && $elSettings['screen_speed'] !== '') {
            $_spdPct2 = max(0, min(100, (int) $elSettings['screen_speed']));
            $_spdMs2  = max(300, min(2000, (int) round(2000 - ($_spdPct2 / 100) * 1700)));
            $_revealSpdAttr2 = ' data-reveal-speed-ms="' . $_spdMs2 . '"';
        }
        // Per-screen sensitivity — same emission rules as the multi-screen
        // path above. v2.1.1350.
        $_revealSensAttr2 = '';
        if (isset($elSettings['screen_sensitivity']) && $elSettings['screen_sensitivity'] !== '') {
            $_sensN2 = max(1, min(4, (int) $elSettings['screen_sensitivity']));
            if ($_sensN2 > 1) $_revealSensAttr2 = ' data-reveal-sensitivity="' . $_sensN2 . '"';
        }
        // Per-screen Min height (vh) for native/classic scroll — mirror of multi-screen path.
        $_minVhAttr2 = '';
        if (isset($elSettings['screen_min_height_vh']) && (int) $elSettings['screen_min_height_vh'] > 0) {
            $_minVhAttr2 .= ' data-native-min-vh="' . max(0, min(200, (int) $elSettings['screen_min_height_vh'])) . '"';
        }
        if (isset($elSettings['screen_min_height_vh_tablet']) && $elSettings['screen_min_height_vh_tablet'] !== '') {
            $_minVhAttr2 .= ' data-native-min-vh-tablet="' . max(0, min(200, (int) $elSettings['screen_min_height_vh_tablet'])) . '"';
        }
        if (isset($elSettings['screen_min_height_vh_mobile']) && $elSettings['screen_min_height_vh_mobile'] !== '') {
            $_minVhAttr2 .= ' data-native-min-vh-mobile="' . max(0, min(200, (int) $elSettings['screen_min_height_vh_mobile'])) . '"';
        }
        // v2.1.1807: skippable screen flag — mirror of multi-screen path
        // above. screen_skip=1 → emit data-screen-skip="1" + the matching
        // class so the engine's skip-aware next/prev bypasses this screen
        // by default.
        $_skipAttr2  = !empty($elSettings['screen_skip']) ? ' data-screen-skip="1"' : '';
        $_skipClass2 = !empty($elSettings['screen_skip']) ? ' impulse-screen-skipped' : '';
        // data-id on the screen wrapper — see line 2057 path. v2.1.1817.
        $_idAttr2 = !empty($element['id']) ? ' data-id="' . esc_attr((string) $element['id']) . '"' : '';
        echo '<div class="impulse-screen' . esc_attr($_skipClass2) . '" data-screen-hash="' . esc_attr($hash) . '" data-scene-id="' . esc_attr($post_id) . '"' . $_idAttr2 . $_scrollAttr . $_entranceAttrs . $_apAttr . $_hideEffectAttr . $_revealAttr2 . $_revealIntAttr2 . $_revealSpdAttr2 . $_revealSensAttr2 . $_minVhAttr2 . $_skipAttr2 . ' style="background:' . esc_attr($_screenBg) . '">';
        echo '<div class="impulse-page" style="' . esc_attr($wrapperStyle) . '">';
        // Classic = free (content) height: a classic section with no manual height is
        // content-height, top-aligned — not a centered 100dvh screen — so padding_top
        // controls the top offset. Mirrors the multi-screen path (~line 2054).
        $_classicFree2 = (($page_settings['body_scroll'] ?? '') === 'classic')
            && (int) ($elSettings['screen_min_height_vh'] ?? 0) <= 0
            && (int) ($elSettings['min_height'] ?? 0) <= 0
            && in_array((string) ($elSettings['height_mode'] ?? 'auto'), ['', 'auto'], true);
        $_wrapMinH2 = $_classicFree2 ? 'auto' : 'calc(100dvh - var(--impulse-screen-bottom-inset, 0px))';
        $_wrapJC2   = $_classicFree2 ? 'flex-start' : 'safe center';
        echo '<div class="impulse-content-wrapper" style="--impulse-wrapper-pad-y:' . (intval($snavPadTop) + intval($paddingBottom)) . 'px;display:flex;flex-direction:column;justify-content:' . esc_attr($_wrapJC2) . ';flex:1;min-height:' . esc_attr($_wrapMinH2) . ';padding:' . esc_attr($snavPadTop) . 'px ' . intval($paddingRight) . 'px ' . intval($paddingBottom) . 'px ' . intval($paddingLeft) . 'px">';
        impulse_render_frontend_element($element);
        echo '</div></div>';
        if ($_showScrollCue) {
            if (empty($GLOBALS['_impulse_scroll_cue_css'])) {
                $GLOBALS['_impulse_scroll_cue_css'] = true;
                // Static scroll-cue styling (identical on every render); attached
                // once to the enqueued page-inline handle instead of echoed raw.
                wp_add_inline_style('instinctor-page-inline', ''
                   . '.impulse-scroll-cue{position:absolute;left:50%;bottom:max(28px,env(safe-area-inset-bottom));transform:translateX(-50%);'
                   .   'display:flex;flex-direction:column;align-items:center;gap:6px;color:#ffffff;pointer-events:none;z-index:50;'
                   .   'font:600 10px/1 system-ui,-apple-system,sans-serif;letter-spacing:0.32em;text-transform:uppercase;'
                   .   'opacity:0;animation:impulse-scroll-cue-fadein 0.9s ease-in-out 1.2s both}'
                   . '.impulse-scroll-cue svg{width:18px;height:18px;animation:impulse-scroll-cue-pulse 1.6s ease-in-out infinite;opacity:0.85}'
                   . '@keyframes impulse-scroll-cue-fadein{from{opacity:0;transform:translate(-50%,8px)}to{opacity:0.78;transform:translate(-50%,0)}}'
                   . '@keyframes impulse-scroll-cue-pulse{0%,100%{transform:translateY(0);opacity:0.55}50%{transform:translateY(7px);opacity:1}}'
                   . '@media(max-width:767px){.impulse-scroll-cue{bottom:max(20px,env(safe-area-inset-bottom));font-size:9px}.impulse-scroll-cue svg{width:16px;height:16px}}'
                   );
            }
            echo '<div class="impulse-scroll-cue" aria-hidden="true">'
               . '<span>Scroll</span>'
               . '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>'
               . '</div>';
        }
        // Snap-mode footer (v2.1.1835): pin the footer to the bottom of the LAST
        // screen on cinematic pages. Sergei 2026-07-02: footers do NOT belong on
        // cinematic pages — they were never authored there (cloned client pages
        // carried a stray footer element). Gated to the main instinctor.com app
        // only, where a cinematic footer is intentional; every client install +
        // subdomain (isInstinctorApp() === false) renders NO footer in snap mode,
        // matching the pre-1835 architecture. Classic mode still renders it after
        // the wrapper (block below), unchanged.
        if ($bodyScroll !== 'classic' && !empty($footerElements) && !empty($element['id']) && $element['id'] === $_lastVisibleId
            && class_exists('\\Instinctor\\Core\\Plugin') && \Instinctor\Core\Plugin::isInstinctorApp()) {
            if (empty($GLOBALS['_impulse_snap_footer_css'])) {
                $GLOBALS['_impulse_snap_footer_css'] = true;
                wp_add_inline_style('instinctor-page-inline', '.impulse-screen:has(.impulse-snap-footer){position:relative}'
                   . '.impulse-snap-footer{position:absolute;left:0;right:0;bottom:0;z-index:6}'
                   // Mobile: hide the pinned footer entirely. On a phone the
                   // cinematic last screen has no room for it — it crowded the
                   // CTA content. (Option B pinned it flush at the bottom;
                   // Sergei 2026-06-24 chose to hide it on mobile instead.)
                   // Footer links/copyright still render on desktop + tablet.
                   . '@media(max-width:759px){.impulse-snap-footer{display:none!important}}');
            }
            echo '<div class="impulse-snap-footer">';
            foreach ($footerElements as $_fel) { impulse_render_frontend_element($_fel); }
            echo '</div>';
        }
        echo '</div>';
    }

    // Footer was rendered here as its own .impulse-screen until v2.1.1405.
    // A snap screen is a 100dvh fixed-position panel — a footer (~200px of
    // bottom-of-document chrome) does not fit that contract. Forcing it
    // into one produced an empty viewport with the footer stranded at the
    // top edge (#footer hash navigation, snap past the last content
    // screen, etc.). The footer now renders ONLY in classic-scroll mode,
    // outside the wrapper, where it naturally sits at the document bottom.
    // In snap mode there is no document bottom — designers compose
    // closing content as a dedicated screen instead. See block after the
    // wrapper closes.
    // Inline child sequences — appended before wrapper closes
    impulse_render_child_sequences($post_id, $screenTunnel || $screenCloudEffect !== 'none', $bgColor);
    // Try It Yourself sandbox — self-gates on hero_sandbox, idempotent.
    if (function_exists('impulse_render_hero_sandbox')) impulse_render_hero_sandbox($page_settings);
    // Chat-screen pointer — animated arrow toward the floating chat
    // trigger. Self-gates on chat being enabled + on the user having
    // tagged a section with chat_marker.
    if (function_exists('impulse_render_chat_pointer')) impulse_render_chat_pointer($page_settings);
    ?>
    </div>
    <?php
    // Persistent Instinctor brand lockup (top-center chrome). OPT-IN via the
    // page setting `screen_brand_logo` — never shown on pages that don't ask
    // for it. A single overlay fades in/out as screens change; sections
    // flagged `screen_brand_logo_hide` suppress it while active. v2.1.1845.
    if (!empty($page_settings['screen_brand_logo']) && $bodyScroll !== 'classic') {
        $_brandHideIds = [];
        foreach ($contentElements as $_be) {
            if (!empty(($_be['settings'] ?? [])['screen_brand_logo_hide']) && !empty($_be['id'])) {
                $_brandHideIds[] = $_be['id'];
            }
        }
        require_once INSTINCTOR_PLUGIN_DIR . 'includes/impulse-brand-logo.php';
        echo impulse_screen_brand_logo_html($_brandHideIds);
    }

    // Site header — rendered outside the screen wrapper as fixed overlay
    // chrome (not as a snap screen). header carries its own
    // sticky/absolute positioning CSS.
    foreach ($headerElements as $element) {
        impulse_render_inline_chrome_or_block($element);
    }

    // Overlay chrome (popup / modal / modal). Same rationale as
    // the header: fixed-position, trigger-gated visibility, not part
    // of the snap deck. Rendered after the wrapper so they layer
    // above page content via their own z-index. v2.1.1407.
    foreach ($overlayElements as $element) {
        impulse_render_frontend_element($element);
    }

    // Site footer — flowing document chrome. Rendered AFTER the screen
    // wrapper in normal block flow, gated on classic body-scroll. In snap
    // mode the wrapper covers the viewport with position:fixed and there
    // is no flowing bottom for the footer to attach to, so it is omitted
    // entirely (snap designers compose closing content as a regular
    // screen). In classic mode the wrapper becomes position:relative once
    // the runtime releases past the hero zone — and the footer here
    // appears naturally at the bottom of the document. v2.1.1405.
    if ($bodyScroll === 'classic') {
        foreach ($footerElements as $element) {
            impulse_render_frontend_element($element);
        }
    }
    ?>
    <?php
    \Instinctor\Core\Assets::enqueueMany([
        'instinctor-motion-lava',
        'instinctor-three',
        'instinctor-wave',
        'instinctor-3d-effects',
        'instinctor-timeline',
        'instinctor-screens',
        'instinctor-section-fx',
    ]);
    $GLOBALS['impulse_three_emitted'] = true;
    if (in_array($screenCloudEffect, ['fractal','caustics','glass','bubble','flow','aurora','clouds','aura','topography','neon','noise','dots','ripple','swirl','kaleidoscope','membrane','lava','nebula','agate','storm','lens'], true) || !empty($GLOBALS['impulse_needs_fx'])) {
        \Instinctor\Core\Assets::enqueue('instinctor-fx-engine');
    }
    $glTransitions = ['wipe','door','cube','swirl','glitch'];
    if (in_array($screenTransition, $glTransitions, true) || !empty($GLOBALS['impulse_needs_gl_transitions'])) {
        \Instinctor\Core\Assets::enqueue('instinctor-gl-transitions');
    }
    // Free build: 3D/depth is an editor-only demo, so the published page never
    // loads the depth engine (front-end renders the flat source image). The
    // editor preview (impulse-preview.php) enqueues it separately and is unaffected.
    if (!(function_exists('instinctor_is_free_build') && instinctor_is_free_build())
        && ((($screenDepthEnabled || ($page_settings['screen_cloud_effect'] ?? '') === 'depth_image') && $screenDepthImage && $screenDepthMap) || !empty($GLOBALS['impulse_needs_depth_parallax']))) {
        \Instinctor\Core\Assets::enqueue('instinctor-depth-parallax');
    }
    if (!empty($GLOBALS['impulse_needs_feature_card_parallax'])) \Instinctor\Core\Assets::enqueue('instinctor-feature-parallax');
    if (!empty($GLOBALS['impulse_needs_icon_list_hover']))       \Instinctor\Core\Assets::enqueue('instinctor-icon-list-hover');
    if (!empty($GLOBALS['impulse_needs_bg_parallax']))           \Instinctor\Core\Assets::enqueue('instinctor-bg-parallax');
    // Emit screen-mode engines immediately — body inline scripts further down
    // dispatch to Impulse3D / ImpulseFX globals at parse time.
    $_screen_handles = ['instinctor-three','instinctor-motion-lava','instinctor-wave','instinctor-3d-effects','instinctor-fx-engine','instinctor-gl-transitions','instinctor-timeline','instinctor-screens','instinctor-depth-parallax','instinctor-section-fx'];
    // Free build: 3D/depth publishes flat, so the depth engine is never loaded
    // on the published page (any stray data-depth-* becomes inert). The editor
    // preview loads it separately for the live demo.
    if (function_exists('instinctor_is_free_build') && instinctor_is_free_build()) {
        $_screen_handles = array_values(array_diff($_screen_handles, ['instinctor-depth-parallax']));
    }
    if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
        if (function_exists('_instinctor_emit_script')) {
            foreach ($_screen_handles as $_h) { _instinctor_emit_script($_h, false); _instinctor_emit_script($_h, true); }
        }
    } elseif (function_exists('wp_print_scripts')) {
        wp_print_scripts($_screen_handles);
    }
    ?>
    <?php
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
} // end if (!function_exists('impulse_render_screen_page'))

// ── Render dispatch ───────────────────────────────────────────────────────
// Two entry paths resolve $post_id / $elements / $page_settings differently,
// then delegate to the single shared screen renderer above.
if ($preview_page_id > 0) {
    // Editor-preview path — load a specific page by ID.
    // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- $post_id is local to this render-function-scoped template (PageService includes it within a method), not the WP global; all values are int-cast.
    $post_id = $preview_page_id;
    $GLOBALS['impulse_current_page'] = \Instinctor\Services\PageService::getById($post_id);
    $elements      = \Instinctor\Services\PageService::getElements($post_id);
    $page_settings = \Instinctor\Services\PageService::getSettings($post_id);
    impulse_render_screen_page($post_id, $elements, $page_settings);
} else {
    // Normal page view
    if (function_exists('have_posts') && have_posts()) {
        the_post();
        $post_id = get_the_ID();
    } elseif (defined('INSTINCTOR_RENDER_PAGE_ID')) {
        // Override: render a specific page by ID. Used by gig route (/d/:slug/:gig)
        // which resolves the gig metadata to a page_id outside the URL slug system.
        $post_id = (int) INSTINCTOR_RENDER_PAGE_ID;
    } else {
        // Standalone: resolve page ID from slug or front page config
        $slug    = trim(wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '/')), PHP_URL_PATH), '/');
        if (!$slug) {
            // Root URL — serve configured front page
            $post_id = (int) \Instinctor\Core\Config::get('instinctor_front_page_id', 0);
            if (!$post_id) {
                $post_id = (int) \Instinctor\Core\Config::get('page_on_front', 0);
            }
        } else {
            // Public top-level slug → use getByPublicSlug so designer-owned
            // pages (slug='profile' etc.) don't leak into the global URL
            // namespace. Designer pages reach this template via the /d/ route
            // which sets INSTINCTOR_RENDER_PAGE_ID and skips this branch.
            $pg      = \Instinctor\Services\PageService::getRoutableBySlug($slug);
            $post_id = $pg ? (int)$pg['id'] : 0;
            // Fallback: try numeric ID
            if (!$post_id && ctype_digit($slug)) {
                $post_id = (int)$slug;
    // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited
            }
        }
    }

    // Stash the page row so any block can read its owner_id without re-querying.
    if ($post_id) {
        $GLOBALS['impulse_current_page'] = \Instinctor\Services\PageService::getById($post_id);
    }
    $elements      = $post_id ? \Instinctor\Services\PageService::getElements($post_id) : [];
    $page_settings = $post_id ? \Instinctor\Services\PageService::getSettings($post_id) : [];
    // Live frontend path: expose page settings to element-render functions
    // (impulse_render_frontend_element does not auto-import locals).
    $GLOBALS['impulse_page_settings'] = $page_settings;

    impulse_render_screen_page($post_id, $elements, $page_settings);
}

/**
 * Render child sequence screens inline after parent screens.
 * Each child page preserves its own visual environment via per-screen bg_color.
 */
/**
 * Output frontend styles once
 */
function impulse_output_frontend_styles() {
    // CSS moved to assets/css/frontend.css — loaded via wp_enqueue_style
    static $output = false;
    if ($output) return;
    $output = true;
}

?>

</div><!-- .impulse-page-wrapper -->

<?php
/**
 * Render header/footer elements (simplified renderer for theme elements)
 */
function impulse_render_header_footer_element($element, &$byParent) {
    // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Renderer: emits HTML/CSS/SVG composed from sanitized settings, numeric/esc_*()/uid values and internal data structures; no raw request input reaches output (zero ValidatedSanitizedInput violations in the body).
    // Accept all three key conventions: `type` is what the modern editor saves
    // (and what PageService::getElements returns from the elements JSON column);
    // `element_type` and `section_type` are legacy keys from the early WP-meta
    // schema. Without `type` here, every block on a global_header / global_footer
    // page silently falls through the switch and the wrapper renders empty.
    $type = $element['type'] ?? $element['element_type'] ?? $element['section_type'] ?? '';
    $settings = $element['settings'] ?? [];
    $elementId = $element['id'] ?? 0;
    
    switch ($type) {
        case 'site_logo':
            $logoId = $settings['logo_id'] ?? 0;
            $maxWidth = $settings['max_width'] ?? 180;
            $maxHeight = $settings['max_height'] ?? 60;
            $linkHome = $settings['link_home'] ?? true;
            
            $logoUrl = '';
            if ($logoId && function_exists('wp_get_attachment_image_url')) {
                $logoUrl = wp_get_attachment_image_url($logoId, 'full');
            }
            if (!$logoUrl && function_exists('get_theme_mod') && function_exists('wp_get_attachment_image_url')) {
                $customLogo = (function_exists('get_theme_mod') ? get_theme_mod('custom_logo') : null);
                if ($customLogo) {
                    $logoUrl = wp_get_attachment_image_url($customLogo, 'full');
                }
            }
            
            if ($logoUrl) {
                $img = '<img src="' . esc_url($logoUrl) . '" alt="' . esc_attr((function_exists('get_bloginfo') ? get_bloginfo('name') : '')) . '" style="max-width:' . intval($maxWidth) . 'px;max-height:' . intval($maxHeight) . 'px;width:auto;height:auto;display:block">';
                if ($linkHome) {
                    echo '<a href="' . esc_url(home_url('/')) . '">' . $img . '</a>';
                } else {
                    echo $img;
                }
            }
            break;
            
        case 'site_title':
            $useSiteTitle = $settings['use_site_title'] ?? true;
            $customTitle = $settings['custom_title'] ?? '';
            $tag = $settings['tag'] ?? 'div';
            $size = $settings['size'] ?? 24;
            $color = $settings['color'] ?? '#1d2327';
            $linkHome = $settings['link_home'] ?? true;
            
            $title = $useSiteTitle ? (function_exists('get_bloginfo') ? get_bloginfo('name') : '') : $customTitle;
            $style = 'font-size:' . intval($size) . 'px;color:' . esc_attr($color) . ';font-weight:700;margin:0';
            
            echo '<' . esc_attr($tag) . ' class="impulse-site-title" style="' . esc_attr($style) . '">';
            if ($linkHome) {
                echo '<a href="' . esc_url(home_url('/')) . '" class="imp-link-reset">' . esc_html($title) . '</a>';
            } else {
                echo esc_html($title);
            }
            echo '</' . esc_attr($tag) . '>';
            break;
        
        case 'header':
            // Delegated to the shared element renderer
            // (includes/element-renderers/header.php). Was inline ~333 lines
            // here that diverged from the editor and the shared file.
            echo impulse_element_render_header($element, $settings, 'frontend');
            break;
        
        case 'footer':
            $layout = $settings['layout'] ?? '4-col';
            $bgColor = $settings['bg_color'] ?? '#1d2327';
            $textColor = $settings['text_color'] ?? '#999999';
            $headingColor = $settings['heading_color'] ?? '#ffffff';
            // Auto-derived
            $linkColor = $textColor;
            $linkHoverColor = $headingColor;
            $borderColor = impulse_derive_border($bgColor);
            // Neutralise the derived divider to greyscale (a tinted near-black
            // bg derives a faintly coloured line), then let the footer "Line"
            // swatch override it. Mirrors footer.php + impulse-preview.php. 2026-07-03.
            if (preg_match('/^#([0-9a-fA-F]{6})$/', $borderColor, $_bm)) { $_g = (int) round(0.299 * hexdec(substr($_bm[1], 0, 2)) + 0.587 * hexdec(substr($_bm[1], 2, 2)) + 0.114 * hexdec(substr($_bm[1], 4, 2))); $borderColor = sprintf('#%02x%02x%02x', $_g, $_g, $_g); }
            if (!empty($settings['line_color'])) $borderColor = $settings['line_color'];
            // Typography (Sergei 2026-06-24): per-footer title/link size + line
            // height. Empty/unset falls back to the previous hardcoded values
            // (title 14px, link 14px, line height 130% ~= 1.3).
            $fTitleSize = (isset($settings['footer_title_size']) && $settings['footer_title_size'] !== '') ? max(8, min(48, (int) $settings['footer_title_size'])) : 14;
            $fLinkSize  = (isset($settings['footer_link_size'])  && $settings['footer_link_size']  !== '') ? max(8, min(40, (int) $settings['footer_link_size']))  : 14;
            $fLineH     = (isset($settings['footer_line_height']) && $settings['footer_line_height'] !== '') ? max(80, min(260, (int) $settings['footer_line_height'])) : 130;

            // Logo — from header element on this page
            $showLogo = !empty($settings['show_logo']);
            $logoImg = '';
            $logoHeight = 32;
            if ($showLogo) {
                foreach ($elements as $_el) {
                    if (($_el['type'] ?? '') === 'header') {
                        $logoImg = $_el['settings']['logo'] ?? '';
                        break;
                    }
                }
            }
            
            // Columns
            $col1Title = $settings['col1_title'] ?? 'Company';
            $col2Title = $settings['col2_title'] ?? 'Support';
            $col3Title = $settings['col3_title'] ?? 'Legal';
            
            // Social — from SEO Config
            $showSocial = $settings['show_social'] !== false;
            $socialLinks = [];
            if ($showSocial) {
                $socialLinks = impulse_get_social_links();
            }

            // Store info — from Config. The footer ONLY uses the short tagline
            // for the "© {year} {name} — {tagline}." line. store_description
            // is a paragraph-length field consumed by Instagent + SEO meta
            // descriptions, and dumping it into the footer was bloating the
            // copyright line to four lines of marketing prose. Falls back to
            // an empty string so the footer reads "© {year} {name}. All
            // rights reserved." with no em-dash clause when no tagline is set.
            // Footer block's own site_name overrides the store name for the
            // copyright (sub-brand footer, e.g. Instinctor, while store name /
            // emails stay the demo studio name). Client footers leave it unset.
            $_footerSiteName = (string) ($settings['site_name'] ?? '');
            $siteName    = $_footerSiteName !== '' ? $_footerSiteName : \Instinctor\Core\Config::get('instinctor_store_name', (function_exists('get_bloginfo') ? get_bloginfo('name') : ''));
            $siteTagline = \Instinctor\Core\Config::get('instinctor_store_tagline', '');
            $siteDesc    = $siteTagline;

            // Copyright — auto-generated
            $showCopyright = ($settings['show_copyright'] ?? true) !== false;
            $copyrightText = '© ' . gmdate('Y') . ' ' . $siteName . '. All rights reserved.';
            
            // Payment icons
            $showPayments = !empty($settings['show_payments']);
            
            $footerId = 'impulse-footer-' . $element['id'];
            
            // CSS
            ob_start();
            echo '#' . esc_attr($footerId) . ' a { color: ' . esc_attr($linkColor) . '; text-decoration: none; transition: color 0.2s; }';
            echo '#' . esc_attr($footerId) . ' a:hover { color: ' . esc_attr($linkHoverColor) . '; }';
            echo '#' . esc_attr($footerId) . ' .footer-column ul { list-style: none; margin: 0; padding: 0; }';
            // Denser line spacing (Sergei 2026-06-23): tighter rows read as a
            // compact footer, not a spacer band. Line height is now per-footer
            // configurable (footer_line_height, percent).
            echo '#' . esc_attr($footerId) . ' .footer-column li { margin-bottom: 6px; line-height: ' . esc_attr($fLineH) . '%; break-inside: avoid; }';
            echo '#' . esc_attr($footerId) . ' .footer-column li a { font-size: ' . esc_attr($fLinkSize) . 'px; line-height: ' . esc_attr($fLineH) . '%; }';
            echo '#' . esc_attr($footerId) . ' .footer-column img { height: ' . esc_attr($logoHeight) . 'px; }';
            // Distribute evenly: column 1 anchored left (default), last column
            // anchored right so its content (heading + links) aligns with the
            // right edge of the divider line below. Avoids the visual cluster
            // where left-aligned columns leave the right side of the footer
            // empty when content is short.
            echo '@media (min-width: 769px) {';
            echo '#' . esc_attr($footerId) . ' .footer-columns > .footer-column:last-child { text-align: right; }';
            // Multi-column link lists read cleaner left-aligned, even at the
            // right edge — override the last-child right alignment for them.
            echo '#' . esc_attr($footerId) . ' .footer-columns > .footer-column.footer-col-multi:last-child { text-align: left; }';
            echo '}';
            echo '@media (max-width: 768px) {';
            echo '#' . esc_attr($footerId) . ' .footer-columns { grid-template-columns: 1fr !important; text-align: center; }';
            echo '#' . esc_attr($footerId) . ' .footer-column ul { display: inline-block; text-align: center; }';
            // Never split a link list into sub-columns on phones — too narrow.
            echo '#' . esc_attr($footerId) . ' .footer-column ul { column-count: 1 !important; }';
            // Single-row (slim legal) footer stays visible on mobile and
            // stacks: copyright over the links, centered. (It used to be
            // hidden on phones, but the legal links should stay reachable.)
            if ($layout === 'single_row') {
                echo '#' . esc_attr($footerId) . ' > div { flex-direction: column; text-align: center; gap: 10px; }';
                echo '#' . esc_attr($footerId) . ' > div > div:last-child { justify-content: center; }';
            }
            echo '}';
            echo '@media (max-width: 1024px) and (min-width: 769px) {';
            echo '#' . esc_attr($footerId) . ' .footer-columns { grid-template-columns: 1fr 1fr !important; }';
            echo '}';
            $_inst_css4 = ob_get_clean(); wp_add_inline_style("instinctor-page-inline", $_inst_css4);
            
            $gridCols = $layout === '3-col' ? 3 : 4;
            if ($layout === 'centered' || $layout === 'minimal') $gridCols = 1;

            // Spacing — symmetric. For single_row layout we force tight
            // padding regardless of any saved value, because the standard
            // 60px top is calibrated for 3/4-column footers with stacked
            // headings and link lists. With one line of inline text the
            // same 60px reads as "spacer band, not footer." Multi-column
            // layouts honour the saved/default values.
            if ($layout === 'single_row') {
                $fPad = 14;
                $fPadBottom = 14;
                $fSidePad = 32;
            } else {
                $fPad = intval($settings['padding_top'] ?? 60);
                $fPadBottom = max(intval($fPad * 0.5), 20);
                $fSidePad = 40;
            }

            $footerStyle = 'background:' . esc_attr($bgColor) . ';color:' . esc_attr($textColor) . ';padding:' . $fPad . 'px ' . $fSidePad . 'px ' . $fPadBottom . 'px ' . $fSidePad . 'px';
            
            echo '<footer id="' . esc_attr($footerId) . '" class="impulse-footer" style="' . esc_attr($footerStyle) . '">';
            
            if ($layout === 'single_row') {
                // Single-line footer: copyright on the left, inline links centre/right,
                // social icons on the far right. Wraps gracefully on narrow viewports.
                $allLinks = array_merge(
                    $settings['col1_links'] ?? [],
                    $settings['col2_links'] ?? [],
                    $settings['col3_links'] ?? []
                );
                echo '<div style="max-width:var(--impulse-content-max-width, 1280px);margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap">';
                // Left: copyright. Format: "© 2026 Instinctor — The Web Production Platform. All rights reserved."
                echo '<div style="font-size:13px;color:' . esc_attr($textColor) . '">';
                if ($showCopyright) {
                    $_year = gmdate('Y');
                    // Slim legal line: brand + rights only (no tagline).
                    $_line = '© ' . $_year . ' ' . $siteName . '. All rights reserved.';
                    echo esc_html(html_entity_decode((string) $_line, ENT_QUOTES, 'UTF-8'));
                }
                echo '</div>';
                // Right: inline links + social
                echo '<div style="display:flex;align-items:center;gap:24px;font-size:14px">';
                foreach ($allLinks as $link) {
                    $lbl = trim((string) ($link['label'] ?? ''));
                    $url = trim((string) ($link['url']   ?? ''));
                    if ($lbl === '' || $url === '' || $url === '#') continue;
                    echo '<a href="' . esc_url($url) . '" style="color:' . esc_attr($textColor) . '">' . esc_html(html_entity_decode((string) $lbl, ENT_QUOTES, 'UTF-8')) . '</a>';
                }
                if ($showSocial && !empty($socialLinks)) {
                    echo '<div style="display:flex;gap:14px;align-items:center">';
                    foreach ($socialLinks as $link) {
                        $platform = $link['platform'] ?? 'facebook';
                        $url      = $link['url']      ?? '';
                        if ($url && $url !== '#') {
                            echo '<a href="' . esc_url($url) . '" target="_blank" rel="noopener">' . impulse_get_social_svg($platform, $linkColor) . '</a>';
                        }
                    }
                    echo '</div>';
                }
                echo '</div>';
                echo '</div>';
            } elseif ($layout === 'minimal') {
                echo '<div style="max-width:var(--impulse-content-max-width, 1200px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:16px">';
                if ($showCopyright) {
                    echo '<div style="font-size:13px">' . esc_html(html_entity_decode((string) $copyrightText, ENT_QUOTES, 'UTF-8')) . '</div>';
                }
                if ($showSocial && !empty($socialLinks)) {
                    echo '<div style="display:flex;gap:16px">';
                    foreach ($socialLinks as $link) {
                        $platform = $link['platform'] ?? 'facebook';
                        $url = $link['url'] ?? '';
                        if ($url && $url !== '#') {
                            echo '<a href="' . esc_url($url) . '" target="_blank">' . impulse_get_social_svg($platform, $linkColor) . '</a>';
                        }
                    }
                    echo '</div>';
                }
                echo '</div>';
            } elseif ($layout === 'centered') {
                echo '<div style="max-width:600px;margin:0 auto;text-align:center">';
                if ($showLogo && $logoImg) {
                    echo '<img src="' . esc_url($logoImg) . '" alt="Logo" style="height:' . esc_attr($logoHeight) . 'px;margin-bottom:24px">';
                } elseif ($showLogo) {
                    echo '<div style="font-size:24px;font-weight:700;color:' . esc_attr($headingColor) . ';margin-bottom:24px">' . esc_html(html_entity_decode((string) $siteName, ENT_QUOTES, 'UTF-8')) . '</div>';
                }
                if ($showSocial && !empty($socialLinks)) {
                    echo '<div style="display:flex;gap:16px;justify-content:center;margin-bottom:30px">';
                    foreach ($socialLinks as $link) {
                        $platform = $link['platform'] ?? 'facebook';
                        $url = $link['url'] ?? '';
                        if ($url && $url !== '#') {
                            echo '<a href="' . esc_url($url) . '" target="_blank">' . impulse_get_social_svg($platform, $linkColor) . '</a>';
                        }
                    }
                    echo '</div>';
                }
                if ($showCopyright) {
                    echo '<div style="font-size:13px">' . esc_html(html_entity_decode((string) $copyrightText, ENT_QUOTES, 'UTF-8')) . '</div>';
                }
                echo '</div>';
            } else {
                // Multi-column layout. 3-col / 4-col semantics:
                //   4-col → auto store-info column + col1 + col2 + col3
                //   3-col → col1 + col2 + col3 (no auto store-info; user
                //           controls all 3 columns)
                // Empty columns (no title AND no links) are skipped entirely
                // so a settings cleanup that empties col1+col2 doesn't render
                // ghost columns with placeholder "Link 1, Link 2" text.
                echo '<div class="footer-columns" style="display:grid;grid-template-columns:repeat(' . esc_attr($gridCols) . ',1fr);gap:40px;max-width:var(--impulse-content-max-width, 1200px);margin:0 auto">';

                if ($layout === '4-col') {
                    // First column - store info (4-col only)
                    echo '<div class="footer-column">';
                    if ($showLogo && $logoImg) {
                        echo '<img src="' . esc_url($logoImg) . '" alt="Logo" style="height:' . esc_attr($logoHeight) . 'px;margin-bottom:20px">';
                    }
                    echo '<div style="font-size:18px;font-weight:600;color:' . esc_attr($headingColor) . ';margin-bottom:12px">' . esc_html(html_entity_decode((string) $siteName, ENT_QUOTES, 'UTF-8')) . '</div>';
                    if (!empty($siteDesc)) {
                        echo '<p style="font-size:14px;line-height:1.6;margin:0">' . esc_html(html_entity_decode((string) $siteDesc, ENT_QUOTES, 'UTF-8')) . '</p>';
                    }
                    echo '</div>';
                }

                // Render column helper - uses manual links. Skip rendering
                // entirely when both title and links are empty (no ghost
                // headings, no "Link 1, Link 2" placeholders).
                $renderFooterColumn = function($title, $links) use ($headingColor, $linkColor, $fTitleSize) {
                    $hasLinks = !empty($links) && is_array($links);
                    if ($title === '' && !$hasLinks) return;
                    // No column shows more than 3 items: split the link list into
                    // ceil(n/3) sub-columns (e.g. 5 links → 2 cols, 6 → 2 cols).
                    // Sergei 2026-06-23.
                    $valid = 0;
                    if ($hasLinks) { foreach ($links as $l) { if (!empty($l['label'])) $valid++; } }
                    $ulCols = max(1, (int) ceil($valid / 3));
                    echo '<div class="footer-column' . ($ulCols > 1 ? ' footer-col-multi' : '') . '">';
                    if ($title !== '') {
                        echo '<div style="font-size:' . esc_attr($fTitleSize) . 'px;font-weight:600;color:' . esc_attr($headingColor) . ';margin-bottom:16px">' . esc_html(html_entity_decode((string) $title, ENT_QUOTES, 'UTF-8')) . '</div>';
                    }
                    if ($hasLinks) {
                        $ulStyle = $ulCols > 1 ? ' style="column-count:' . $ulCols . ';column-gap:36px"' : '';
                        echo '<ul' . $ulStyle . '>';
                        foreach ($links as $link) {
                            if (!empty($link['label'])) {
                                echo '<li><a href="' . esc_url($link['url'] ?? '#') . '">' . esc_html(html_entity_decode((string)($link['label'] ?? ''), ENT_QUOTES, 'UTF-8')) . '</a></li>';
                            }
                        }
                        echo '</ul>';
                    }
                    echo '</div>';
                };

                // Read links — default to empty arrays so an unconfigured
                // footer renders cleanly empty (the column-skip above handles
                // that case) instead of injecting placeholder About/Careers
                // links that confuse new installs.
                $col1Links = $settings['col1_links'] ?? [];
                $col2Links = $settings['col2_links'] ?? [];
                $col3Links = $settings['col3_links'] ?? [];

                $renderFooterColumn($col1Title, $col1Links);
                $renderFooterColumn($col2Title, $col2Links);
                $renderFooterColumn($col3Title, $col3Links);

                echo '</div>';
                
                // Bottom bar — only if content exists
                if ($showCopyright || ($showSocial && !empty($socialLinks))) {
                echo '<div style="max-width:var(--impulse-content-max-width, 1200px);margin:40px auto 0;padding-top:30px;border-top:1px solid ' . esc_attr($borderColor) . ';display:flex;flex-direction:column;align-items:center;gap:16px">';
                if ($showCopyright) {
                    echo '<div style="font-size:13px">' . esc_html(html_entity_decode((string) $copyrightText, ENT_QUOTES, 'UTF-8')) . '</div>';
                }
                
                if ($showSocial && !empty($socialLinks)) {
                    echo '<div style="display:flex;gap:16px">';
                    foreach ($socialLinks as $link) {
                        $platform = $link['platform'] ?? 'facebook';
                        $url = $link['url'] ?? '';
                        if ($url && $url !== '#') {
                            echo '<a href="' . esc_url($url) . '" target="_blank">' . impulse_get_social_svg($platform, $linkColor) . '</a>';
                        }
                    }
                    echo '</div>';
                }
                
                if ($showPayments) {
                    echo '<div style="display:flex;gap:8px">';
                    if ($settings['pay_visa'] !== false) echo '<div style="background:#fff;padding:4px 8px;border-radius:4px"><svg width="32" height="20" viewBox="0 0 32 20"><rect fill="#1A1F71" width="32" height="20" rx="2"/><text x="16" y="13" fill="#fff" font-size="6" text-anchor="middle">VISA</text></svg></div>';
                    if ($settings['pay_mastercard'] !== false) echo '<div style="background:#fff;padding:4px 8px;border-radius:4px"><svg width="32" height="20" viewBox="0 0 32 20"><circle cx="12" cy="10" r="7" fill="#EB001B"/><circle cx="20" cy="10" r="7" fill="#F79E1B"/></svg></div>';
                    if ($settings['pay_amex'] !== false) echo '<div style="background:#006FCF;padding:4px 8px;border-radius:4px"><svg width="32" height="20"><text x="16" y="13" fill="#fff" font-size="6" font-weight="bold" text-anchor="middle">AMEX</text></svg></div>';
                    if ($settings['pay_paypal'] !== false) echo '<div style="background:#fff;padding:4px 8px;border-radius:4px"><svg width="32" height="20"><text x="16" y="13" fill="#003087" font-size="6" font-weight="bold" text-anchor="middle">PayPal</text></svg></div>';
                    echo '</div>';
                }
                echo '</div>';
                } // end bottom bar conditional
            }
            
            echo '</footer>';
            break;
            
        case 'nav_menu':
            // Check visibility condition
            if (!impulse_check_visibility($settings)) break;
            
            $menuId = $settings['menu_id'] ?? '';
            $layout = $settings['layout'] ?? 'horizontal';
            $gap = $settings['gap'] ?? 24;
            $fontSize = $settings['font_size'] ?? 15;
            $textColor = $settings['text_color'] ?? '#1d2327';
            $hoverColor = $settings['hover_color'] ?? '#666666';
            
            // Skip rendering entirely if no visible content
            if (empty(trim(wp_strip_all_tags($displayContent)))) break;

            // Entrance animation
            $animAttrs = impulse_get_animation_attrs($settings);
            
            $menuStyle = 'list-style:none;margin:0;padding:0;display:flex;';
            $menuStyle .= $layout === 'vertical' ? 'flex-direction:column;' : 'flex-direction:row;';
            $menuStyle .= 'gap:' . intval($gap) . 'px;';
            
            $navClass = 'impulse-nav-menu';
            if ($animAttrs['class']) $navClass .= ' ' . $animAttrs['class'];
            $navStyle = '';
            if ($animAttrs['style']) $navStyle = ' style="' . $animAttrs['style'] . '"';
            
            echo '<nav class="' . esc_attr($navClass) . '"' . $navStyle . '>';
            wp_add_inline_style('instinctor-page-inline', '.impulse-nav-menu a{font-size:' . intval($fontSize) . 'px;color:' . esc_attr($textColor) . ';text-decoration:none}.impulse-nav-menu a:hover{color:' . esc_attr($hoverColor) . '}.impulse-nav-menu li{list-style:none}');
            
            if ($menuId) {
                wp_nav_menu(['menu' => $menuId, 'container' => false, 'items_wrap' => '<ul style="' . esc_attr($menuStyle) . '">%3$s</ul>']);
            } else {
                echo '<ul style="' . esc_attr($menuStyle) . '"><li><a href="' . esc_url(home_url('/')) . '">Home</a></li></ul>';
            }
            echo '</nav>';
            break;
            
        case 'social':
            $iconSize = $settings['icon_size'] ?? 20;
            $gap = $settings['gap'] ?? 12;
            $iconColor = $settings['icon_color'] ?? '#666666';
            $hoverColor = $settings['hover_color'] ?? '#1d2327';
            $bgShape = $settings['bg_shape'] ?? 'none';
            $bgSize = $settings['bg_size'] ?? 40;
            $bgColor = $settings['bg_color'] ?? '';
            $align = $settings['align'] ?? 'center';

            // Build links from global social profiles
            $links = impulse_get_social_links();
            // Background shape styles
            $bgRadius = '0';
            if ($bgShape === 'circle') $bgRadius = '50%';
            elseif ($bgShape === 'rounded') $bgRadius = '8px';
            
            $justify = 'center';
            if ($align === 'left') $justify = 'flex-start';
            elseif ($align === 'right') $justify = 'flex-end';
            
            // Container with CSS variables for gap
            echo '<div class="impulse-social-icons" style="display:flex;gap:var(--impulse-gap,' . intval($gap) . 'px);align-items:center;justify-content:' . esc_attr($justify) . '">';
            wp_add_inline_style('instinctor-page-inline', '.impulse-social-icons a{color:var(--impulse-icon-color,' . esc_attr($iconColor) . ');transition:color 0.2s}.impulse-social-icons a:hover{color:' . esc_attr($hoverColor) . '}.impulse-social-icons svg{width:var(--impulse-icon-size,' . intval($iconSize) . 'px);height:var(--impulse-icon-size,' . intval($iconSize) . 'px)}');
            

            if (empty($links)) {
                // Placeholder icons when no social profiles are configured in SEO > Site
                $placeholderSvgs = [
                    'instagram' => '<path fill="currentColor" d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 3.678c-3.405 0-6.162 2.76-6.162 6.162 0 3.405 2.76 6.162 6.162 6.162 3.405 0 6.162-2.76 6.162-6.162 0-3.405-2.76-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405c0 .795-.646 1.44-1.44 1.44-.795 0-1.44-.646-1.44-1.44 0-.794.646-1.439 1.44-1.439.793-.001 1.44.645 1.44 1.439z"/>',
                    'facebook'  => '<path fill="currentColor" d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>',
                    'youtube'   => '<path fill="currentColor" d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>'
                ];
                foreach ($placeholderSvgs as $svg) {
                    $iconStyle = 'display:flex;align-items:center;justify-content:center;';
                    if ($bgShape !== 'none') {
                        $effectiveBg = $bgColor ?: '#f0f0f0';
                        $iconStyle .= 'width:' . intval($bgSize) . 'px;height:' . intval($bgSize) . 'px;background:' . esc_attr($effectiveBg) . ';border-radius:' . $bgRadius . ';';
                    }
                    echo '<span style="' . esc_attr($iconStyle) . 'color:' . esc_attr($iconColor) . ';opacity:0.4">';
                    echo '<svg width="' . intval($iconSize) . '" height="' . intval($iconSize) . '" viewBox="0 0 24 24">' . $svg . '</svg>';
                    echo '</span>';
                }
                break;
            }

            // Render social links
            if (!empty($links)) {
                foreach ($links as $link) {
                    $platform = $link['platform'] ?? '';
                    $url = $link['url'] ?? '';
                    if ($platform && $url && isset($socials[$platform])) {
                        $iconStyle = 'display:flex;align-items:center;justify-content:center;';
                        if ($bgShape !== 'none') {
                            $effectiveBg = $bgColor ?: '#f0f0f0';
                            $iconStyle .= 'width:var(--impulse-bg-size,' . intval($bgSize) . 'px);height:var(--impulse-bg-size,' . intval($bgSize) . 'px);background:var(--impulse-bg-color,' . esc_attr($effectiveBg) . ');border-radius:' . $bgRadius . ';';
                        }
                        echo '<a href="' . esc_url($url) . '" target="_blank" rel="noopener" style="' . esc_attr($iconStyle) . '">';
                        echo '<svg viewBox="0 0 24 24">' . $socials[$platform] . '</svg>';
                        echo '</a>';
                    }
                }
            }
            echo '</div>';
            break;
            
        case 'copyright':
            $text = $settings['text'] ?? '© {year} {site_name}. All rights reserved.';
            $fontSize = $settings['font_size'] ?? 14;
            $color = $settings['color'] ?? '#666666';
            
            $text = str_replace('{year}', gmdate('Y'), $text);
            $text = str_replace('{site_name}', (function_exists('get_bloginfo') ? get_bloginfo('name') : ''), $text);
            
            echo '<div class="impulse-copyright" style="font-size:' . intval($fontSize) . 'px;color:' . esc_attr($color) . '">' . esc_html($text) . '</div>';
            break;
    } // end switch
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped
} // end impulse_render_header_footer_element

/**
 * Convert any colour-string the panel emits (hex, rgb(), rgba(), or
 * empty) into an rgba() with the requested alpha. Used by the Frosted
 * Glass branch on buttons so the user's chosen bg_color is rendered
 * as a low-alpha tint that lets backdrop-filter actually show through
 * to whatever sits behind. An opaque bg covers the blur entirely and
 * the effect reads as invisible — the hero-reference buttons pin
 * their bg at rgba(255,255,255,0.04) for the same reason.
 *
 * @since 2.1.116
 */
function impulse_color_to_rgba(string $color, float $alpha = 0.10): string {
    $color = trim($color);
    if ($color === '' || $color === 'transparent') return 'rgba(255,255,255,' . $alpha . ')';
    // Already rgba — splice the alpha; if rgb() — append alpha.
    if (preg_match('/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/i', $color, $m)) {
        return 'rgba(' . (int) $m[1] . ',' . (int) $m[2] . ',' . (int) $m[3] . ',' . $alpha . ')';
    }
    // Hex — #rgb / #rrggbb / #rrggbbaa (drop incoming alpha)
    if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $color, $m)) {
        $h = $m[1];
        if (strlen($h) === 3) {
            $r = hexdec($h[0] . $h[0]); $g = hexdec($h[1] . $h[1]); $b = hexdec($h[2] . $h[2]);
        } else {
            $r = hexdec(substr($h, 0, 2)); $g = hexdec(substr($h, 2, 2)); $b = hexdec(substr($h, 4, 2));
        }
        return 'rgba(' . $r . ',' . $g . ',' . $b . ',' . $alpha . ')';
    }
    // Unknown form (CSS named colour, currentColor, var()…) — fall back to
    // neutral white tint; the user can always edit the swatch directly to
    // an rgba value if they want a different hue under the blur.
    return 'rgba(255,255,255,' . $alpha . ')';
}

/**
 * Map a legacy btn_size preset (sm/md/lg/xl) to an anchor height in px.
 * Used as a fallback when btn_height isn't set (pre-2.1.652 buttons).
 */
function impulse_btn_size_to_height(string $size): int {
    $map = ['sm' => 36, 'md' => 44, 'lg' => 52, 'xl' => 64];
    return $map[$size] ?? 44;
}

/**
 * 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.
 */
if (!function_exists('impulse_btn_metrics_from_height')) {
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];
}
}

/**
 * Wrap a text block's HTML content for content-level reveal animations.
 *
 * Container-level entrance animations (impulse_get_animation_attrs) animate
 * the wrapper div. Long text bodies need the text itself to come alive —
 * words pulse in, lines slide up, a sweep band reveals across, etc. This
 * helper splits the rendered HTML into spans that the IntersectionObserver
 * trigger animates with staggered delays.
 *
 * Splitting is HTML-aware: tags pass through untouched, only text-node
 * content is tokenized. Preserves bold/italic/link markup inside the text.
 *
 *   $mode  one of words | lines | chars | sweep
 *   $html  the rendered + sanitized HTML (already through nl2br)
 *
 * Returns the wrapped HTML; the caller adds .impulse-text-reveal-<mode>
 * class to the wrapper and .ttr-stagger / .ttr-duration CSS vars.
 */
/**
 * Split text-block HTML into N column buckets at paragraph boundaries.
 *
 * Used for the multi-column-text decompose effect: if the content has at least
 * N <p> blocks, returns an array of N HTML strings. Each chunk includes any
 * inline content (headings, lists) that sits between the previous paragraph and
 * the next, so order is preserved and nothing is dropped. Tail content after
 * the last paragraph is appended to the final column.
 *
 * Returns false when the content can't be split cleanly (fewer than N
 * paragraphs, or no <p> tags at all). Caller falls back to CSS column-count.
 *
 *   $html  rendered + sanitized HTML (post nl2br, post markdown)
 *   $n     target column count (2..4)
 */
function impulse_split_text_columns(string $html, int $n) {
    if ($n < 2) return false;
    if (!preg_match_all('/<p\b[^>]*>.*?<\/p>/is', $html, $m, PREG_OFFSET_CAPTURE)) return false;
    $matches = $m[0];
    if (count($matches) < $n) return false;

    // Build chunks: each "chunk" runs from the end of the previous paragraph
    // (or start of $html) up to and including the next paragraph close. Keeps
    // interleaved headings/lists attached to the following paragraph so column
    // boundaries don't strand them.
    $chunks = [];
    $cursor = 0;
    foreach ($matches as $match) {
        $start = $match[1];
        $end = $start + strlen($match[0]);
        $chunks[] = substr($html, $cursor, $end - $cursor);
        $cursor = $end;
    }
    if ($cursor < strlen($html)) {
        $chunks[count($chunks) - 1] .= substr($html, $cursor);
    }
    if (count($chunks) < $n) return false;

    // Distribute chunks evenly. Equal-count split (first ⌈k/N⌉ → col 0, etc.)
    // is good enough; balancing by character length adds complexity without
    // visible gain at the typical 2-4 column counts.
    $cols = array_fill(0, $n, '');
    $perCol = (int) ceil(count($chunks) / $n);
    foreach ($chunks as $i => $c) {
        $colIdx = min($n - 1, intdiv($i, $perCol));
        $cols[$colIdx] .= $c;
    }
    return $cols;
}

function impulse_split_text_for_reveal(string $html, string $mode): string {
    if ($mode === 'sweep') {
        // Sweep is container-level (CSS clip-path / mask sweeps across)
        // — no token wrapping needed.
        return $html;
    }
    if ($mode === 'lines') {
        // Split on <br>, <br/>, <br />, or hard newlines. Preserve other
        // inline HTML inside each line.
        $parts = preg_split('/<br\s*\/?>|\r?\n/i', $html);
        $idx = 0;
        $out = '';
        foreach ($parts as $p) {
            $p = trim($p);
            if ($p === '') continue;
            $out .= '<span class="impulse-tr-line" style="--i:' . $idx . '">' . $p . '</span>';
            $idx++;
        }
        return $out;
    }
    // words / chars: split into alternating text + tag chunks so HTML
    // attributes and tag bodies stay intact.
    $parts = preg_split('/(<[^>]+>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
    $idx = 0;
    $out = '';
    foreach ($parts as $p) {
        if ($p === '') continue;
        if (strlen($p) > 0 && $p[0] === '<') {
            $out .= $p;  // HTML tag, untouched
            continue;
        }
        if ($mode === 'words') {
            $out .= preg_replace_callback('/(\S+)/u', function ($m) use (&$idx) {
                return '<span class="impulse-tr-word" style="--i:' . ($idx++) . '">' . $m[1] . '</span>';
            }, $p);
        } elseif ($mode === 'chars') {
            $chars = preg_split('//u', $p, -1, PREG_SPLIT_NO_EMPTY);
            foreach ($chars as $c) {
                if (preg_match('/\s/u', $c)) { $out .= $c; continue; }
                $out .= '<span class="impulse-tr-char" style="--i:' . ($idx++) . '">' . $c . '</span>';
            }
        } else {
            $out .= $p;
        }
    }
    return $out;
}

/**
 * Generate entrance animation class and style for an element
 */
function impulse_get_animation_attrs($settings) {
    $animation = $settings['entrance_animation'] ?? 'none';
    $depth = intval($settings['depth'] ?? 0);

    $class = '';
    $style = '';

    if ($animation && $animation !== 'none') {
        // animation_duration / animation_delay are stored in SECONDS (was ms
        // pre-2.1.640). Reads use floatval; CSS emits with the `s` unit.
        $duration = floatval($settings['animation_duration'] ?? 0.6);
        $delay    = floatval($settings['animation_delay']    ?? 0);
        $intensity = intval($settings['entrance_intensity'] ?? 50);
        $t = $intensity / 100;
        
        $class = 'impulse-animated';
        
        // Compute CSS custom properties from intensity
        if ($animation === 'emerge') {
            $scale = 1 - ($t * 0.2);
            $blur = $t * 8;
            $style .= '--ea-scale:' . number_format($scale, 3) . ';';
            $style .= '--ea-blur:' . number_format($blur, 1) . 'px;';
            $animName = 'impulse-emerge';
        } elseif ($animation === 'surface') {
            $depthVal = -($t * 120);
            $style .= '--ea-depth:' . intval($depthVal) . 'px;';
            $animName = 'impulse-surface';
        } elseif ($animation === 'unveil') {
            $dir = $settings['unveil_direction'] ?? 'up';
            $animName = 'impulse-unveil-' . esc_attr($dir);
        } elseif ($animation === 'resolve') {
            $blur = $t * 20;
            $style .= '--ea-blur:' . number_format($blur, 1) . 'px;';
            $animName = 'impulse-resolve';
        } else {
            // Legacy animation names (`fade`, `rise`, `fall`, `dissolve`,
            // `zoom`) saved in old element data — the panel was simplified
            // to four presets (emerge/surface/unveil/resolve) but DB still
            // carries the legacy values. The keyframes for these names
            // were dropped, so the animation rendered as
            // `animation-name:impulse-fade` with no matching keyframes,
            // leaving the element stuck at the `.impulse-animated{opacity:0}`
            // initial state. Map them to the closest surviving keyframe so
            // legacy elements actually appear instead of staying invisible.
            $legacyMap = [
                'fade'     => 'fade-in',
                'rise'     => 'surface',  // upward depth-rise → use depth-surface
                'fall'     => 'surface',
                'dissolve' => 'fade-in',
                'zoom'     => 'emerge',
            ];
            $eaName = $legacyMap[$animation] ?? $animation;
            $animName = 'impulse-' . esc_attr($eaName);
        }
        
        $style .= 'animation-name:' . $animName . ';';
        $style .= 'animation-duration:' . $duration . 's;';
        $style .= 'animation-timing-function:cubic-bezier(0.4, 0, 0.2, 1);';
        if ($delay > 0) {
            $style .= 'animation-delay:' . $delay . 's;';
        }
        $style .= 'animation-play-state:paused;';
    }
    
    if ($depth !== 0) {
        $style .= '--parallax-depth:' . $depth . ';';
    }
    
    return ['class' => $class, 'style' => $style];
}

/**
 * Check if element should be visible based on visibility condition
 * Returns true if element should be rendered, false if it should be skipped
 * Uses desktop (base) visibility for logged_in/logged_out check (server-side)
 * If ALL devices are 'off', skip entirely as optimization
 */
function impulse_check_visibility($settings) {
    $vis_desktop = $settings['visibility'] ?? 'all';
    $vis_tablet = $settings['visibility_tablet'] ?? $vis_desktop;
    $vis_mobile = $settings['visibility_mobile'] ?? $vis_tablet;
    
    // If all devices are off, don't render at all
    if ($vis_desktop === 'off' && $vis_tablet === 'off' && $vis_mobile === 'off') return false;
    
    // Check logged_in/logged_out using desktop value (server-side decision)
    if ($vis_desktop === 'all' || $vis_desktop === 'off') return true;
    
    $is_logged_in = is_user_logged_in();
    
    if ($vis_desktop === 'logged_in' && !$is_logged_in) return false;
    if ($vis_desktop === 'logged_out' && $is_logged_in) return false;
    
    return true;
}

/**
 * Get visibility classes based on per-device visibility settings
 */
function impulse_get_visibility_classes($settings) {
    $classes = [];
    $vis_desktop = $settings['visibility'] ?? 'all';
    $vis_tablet = $settings['visibility_tablet'] ?? $vis_desktop;
    $vis_mobile = $settings['visibility_mobile'] ?? $vis_tablet;
    
    if ($vis_desktop === 'off') {
        $classes[] = 'impulse-hide-desktop';
    }
    if ($vis_tablet === 'off') {
        $classes[] = 'impulse-hide-tablet';
    }
    if ($vis_mobile === 'off') {
        $classes[] = 'impulse-hide-mobile';
    }
    return implode(' ', $classes);
}

/**
 * Get hover effect class and transition style
 * Returns ['class' => 'impulse-hover-X', 'style' => 'transition:...']
 */
/**
 * Get hover effect class and transition style
 */
function impulse_get_hover_effect_attrs($settings) {
    $effect = $settings['hover_effect'] ?? 'none';
    $transition = $settings['hover_transition'] ?? 300;
    
    if ($effect === 'none' || empty($effect)) {
        return ['class' => '', 'style' => ''];
    }
    
    $class = 'impulse-hover-' . esc_attr($effect);
    $style = 'transition:transform ' . intval($transition) . 'ms ease-in-out, filter ' . intval($transition) . 'ms ease-in-out;';
    
    return ['class' => $class, 'style' => $style];
}

/**
 * Get CSS ID and custom classes from element settings
 */
function impulse_get_custom_id_classes($settings) {

    $result = ['id' => '', 'classes' => ''];

    // Anchor id source: the editor "Anchor" field (structure panel) writes
    // css_id; programmatically-built pages may set anchor_id. Accept either
    // so section anchors resolve regardless of which key set them. css_id
    // wins when both are present.
    if (!empty($settings['css_id'])) {
        $result['id'] = sanitize_html_class($settings['css_id']);
    } elseif (!empty($settings['anchor_id'])) {
        $result['id'] = sanitize_html_class($settings['anchor_id']);
    }

    if (!empty($settings['css_classes'])) {
        $customClasses = array_map('sanitize_html_class', explode(' ', $settings['css_classes']));
        $customClasses = array_filter($customClasses);
        if ($customClasses) {
            $result['classes'] = implode(' ', $customClasses);
        }
    }
    
    return $result;
}

/**
 * Render a single element on the frontend
 */
/**
 * Dispatch a single $headerElements / $footerElements entry to the right renderer.
 *
 * header / footer block types live in `impulse_render_header_footer_element`
 * — a separate switch from `impulse_render_frontend_element` (which has no case for
 * them). Without this dispatch an inline header element is silently dropped
 * because the frontend switch has no matching case.
 */
function impulse_render_inline_chrome_or_block($element) {
    $type = $element['type'] ?? ($element['element_type'] ?? '');
    if ($type === 'header' || $type === 'footer') {
        $byParent = [];
        foreach (($element['children'] ?? []) as $child) {
            $byParent[$child['parent_id'] ?? 0][] = $child;
        }
        impulse_render_header_footer_element($element, $byParent);
        return;
    }
    impulse_render_frontend_element($element);
}

// Per-block reveal wrapper. When a block carries a reveal override
// (reveal_order / reveal_delay / reveal_off), capture the block's rendered
// HTML and inject the data-reveal-* attrs into its ROOT tag, then emit. The
// frontend has no shared .impulse-el wrapper (each renderer emits its own
// root), so this common entry point is where per-block attrs attach without
// touching ~50 renderers. Both reveal engines read these attrs off reveal
// units, walking up to the nearest ancestor that has them, so an override on
// a container (grid/rows) cascades to its decomposed cells. Attr build is
// inlined to match the editor mirror in admin/views/impulse-preview.php and to
// dodge the co-deploy-a-new-helper 500 trap. Byte-identical output + zero
// ob/regex overhead when no override is set. A regex miss falls back to the
// un-injected HTML (override silently skipped, never malformed). v2.1.2146.
function impulse_render_frontend_element($el) {
    $_rs = is_array($el) ? ($el['settings'] ?? []) : [];
    $_ra = '';
    if (isset($_rs['reveal_order']) && $_rs['reveal_order'] !== '' && $_rs['reveal_order'] !== null) {
        $_ra .= ' data-reveal-order="' . esc_attr((string) (int) $_rs['reveal_order']) . '"';
    }
    if (!empty($_rs['reveal_delay'])) {
        $_ra .= ' data-reveal-delay="' . esc_attr((string) max(0, min(5000, (int) $_rs['reveal_delay']))) . '"';
    }
    if (!empty($_rs['reveal_off'])) {
        $_ra .= ' data-reveal-off="1"';
    }
    if (!empty($_rs['push_bottom'])) {
        $_ra .= ' data-push-bottom="1"';   // per-block: margin-top:auto in the section's flex column -> this block + those after it drop to the bottom
    }
    if ($_ra === '') { impulse_render_frontend_element_inner($el); return; }
    ob_start();
    impulse_render_frontend_element_inner($el);
    $_html = ob_get_clean();
    $_inj  = str_replace(['\\', '$'], ['\\\\', '\\$'], $_ra); // protect preg replacement
    $_n = 0;
    $_out = preg_replace(
        '/<(?!\/|!|style[\s>]|script[\s>]|link[\s>]|meta[\s>]|template[\s>])([a-zA-Z][\w-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*?)(\s*\/?)>/',
        '<$1$2' . $_inj . '$3>',
        $_html,
        1,
        $_n
    );
    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $_out is a sub-renderer return escaped at construction; $_html is the composed element markup, both already escaped.
    echo ($_n > 0 && $_out !== null) ? $_out : $_html;
}
function impulse_render_frontend_element_inner($el) {
    // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Renderer: emits HTML/CSS/SVG composed from sanitized settings, numeric/esc_*()/uid values and internal data structures; no raw request input reaches output (zero ValidatedSanitizedInput violations in the body).
    $type = $el['type'] ?? '';
    $settings = $el['settings'] ?? [];
    $children = $el['children'] ?? [];
    $parentId = intval($el['parent_id'] ?? 0);

    // ── T9 Freemium gate ─────────────────────────────────────────────────────
    // Blocks are never tier-gated — refusing to render a block would break a
    // live page. Free differentiation is via watermark / caps / stripped
    // catalog, never by withholding a whole block.

    // Cached tier capability checks (computed once per request)
    static $impulseCanAnimations = null;
    static $impulseCanHoverPremium = null;
    static $impulseCanImageDepth = null;
    if ($impulseCanAnimations === null) {
        $impulseCanAnimations   = true; // per-effect check via canEnvironment()
        $impulseCanHoverPremium = true; // hover effects free since 2.0
        // 3D depth renders on every tier (free build watermarks the output).
        $impulseCanImageDepth   = true;
    }
    
    // Section Generator is an editor-only placeholder — it's replaced with
    // the AI-generated element tree before save. If any ever survives to the
    // frontend (unsaved draft, aborted generation), it renders nothing so it
    // doesn't appear on the public page.
    if ($type === 'instagent') {
        return;
    }

    // Route consolidated block modes to their renderers
    if ($type === 'cart') {
        $cartMode = $settings['cart_mode'] ?? 'full';
        if ($cartMode === 'mini') $type = 'mini_cart';
    }

    // Layout blocks with fill_container ("Fit parent") wrap in a flex:1 div so
    // they stretch to fill their parent's height (the section is a flex column,
    // so this works in classic scroll too, not only screen mode).
    $fillContainerTypes = ['slider', 'grid', 'rows', 'columns'];
    $useFillWrapper = !empty($settings['fill_container'])
        && $parentId > 0
        && in_array($type, $fillContainerTypes, true);
    if ($useFillWrapper) {
        echo '<div data-fill="1" style="flex:1;min-height:0;height:100%;display:flex;flex-direction:column">';
    }

    // Timeline keyframes wrapper for non-section elements. Sections emit
    // data-keyframes on their own <section> tag below; child elements
    // (text/image/button/etc) have no native wrapper we can attach it to,
    // so we fence them in a transparent <div class="impulse-kf-wrap"> that
    // the runtime (assets/js/impulse-timeline.js) can find via
    // querySelectorAll('[data-keyframes]') and interpolate into.
    $kfWrapOpen = false;
    if (!empty($settings['keyframes']) && is_array($settings['keyframes']) && $type !== 'section') {
        echo '<div class="impulse-kf-wrap" data-keyframes=\'' . esc_attr(wp_json_encode($settings['keyframes'])) . '\'>';
        $kfWrapOpen = true;
    }

    try {
    switch ($type) {
        case 'section':
            // Shared renderer (see includes/element-renderers/section.php).
            // Phase 1 step 10 of the renderer-unification refactor.
            // (The big one: 688 frontend + 448 editor lines collapsed
            // into the shared file. See the docblock there for the full
            // per-context decision log.)
            echo impulse_element_render_section($el, $settings, $children, 'frontend', 'impulse_render_frontend_element');
            break;

        case 'grid':
            // Shared renderer (see includes/element-renderers/grid.php).
            // Phase 1 step 9 of the renderer-unification refactor.
            echo impulse_element_render_grid($el, $settings, $children, 'frontend', 'impulse_render_frontend_element');
            break;

        case 'rows':
            // Shared renderer (see includes/element-renderers/rows.php).
            // Phase 1 step 7 of the renderer-unification refactor.
            echo impulse_element_render_rows($el, $settings, $children, 'frontend', 'impulse_render_frontend_element');
            break;

        case 'columns':
            // Shared renderer (see includes/element-renderers/columns.php).
            // Phase 1 step 8 of the renderer-unification refactor.
            echo impulse_element_render_columns($el, $settings, $children, 'frontend', 'impulse_render_frontend_element');
            break;

        case 'demo':
            // Marketing-page block; excluded from WP build. If the file
            // wasn't shipped (WP install), render nothing.
            if (function_exists('impulse_element_render_demo')) {
                echo impulse_element_render_demo($el, $settings, 'frontend');
            }
            break;

        case 'title':
            // Shared renderer (see includes/element-renderers/title.php). The
            // animated branch now calls the shared animated-title renderer
            // directly (renderer-unification phase 2) instead of goto-ing the
            // legacy inline block below — goto is disallowed by Plugin Check.
            if (impulse_title_should_use_animated($settings)) {
                impulse_title_remap_to_animated($settings);
                echo impulse_element_render_title_animated($el, $settings, 'frontend');
                break;
            }
            echo impulse_element_render_title($el, $settings, 'frontend');
            break;
            
        case 'divider':
            // Shared renderer (see includes/element-renderers/divider.php).
            // Phase 1 of the renderer-unification refactor; the previous
            // inline implementation lived here and was 130 lines long —
            // recover from `git show main:templates/impulse-page.php` if
            // ever needed for reference.
            echo impulse_element_render_divider($el, $settings, 'frontend');
            break;
        case 'text':
            // Shared renderer (see includes/element-renderers/text.php).
            // Phase 1 step 2 of renderer-unification refactor. Previous
            // inline implementation was ~320 lines; recover from
            // `git show main~1:templates/impulse-page.php` if needed.
            echo impulse_element_render_text($el, $settings, 'frontend');
            break;
            
        case 'image':
            // Shared renderer (see includes/element-renderers/image.php).
            // Phase 1 step 3 of renderer-unification refactor.
            echo impulse_element_render_image($el, $settings, 'frontend');
            break;

        // image_popup case removed 2.1.420 — merged into 'image' as a
        // lightbox_enabled toggle (see includes/element-renderers/image.php).

        case 'receipt':
            // Shared renderer (see includes/element-renderers/receipt.php).
            echo impulse_element_render_receipt($el, $settings, 'frontend');
            break;

        case 'account':
            // Shared renderer (see includes/element-renderers/account.php).
            echo impulse_element_render_account($el, $settings, 'frontend');
            break;

        case 'site_logo':
            $logoId = $settings['logo_id'] ?? 0;
            $maxWidth = $settings['max_width'] ?? 180;
            $maxHeight = $settings['max_height'] ?? 60;
            $linkHome = $settings['link_home'] ?? true;
            
            $style = 'max-width:' . intval($maxWidth) . 'px;max-height:' . intval($maxHeight) . 'px;width:auto;height:auto;';
            
            echo '<div class="impulse-site-logo">';
            if ($linkHome) echo '<a href="' . esc_url(home_url('/')) . '">';
            
            if ($logoId && function_exists('wp_get_attachment_image_src')) {
                $imgSrc = wp_get_attachment_image_src($logoId, 'full');
                if ($imgSrc) {
                    echo '<img src="' . esc_url((string)($imgSrc[0] ?? '')) . '" alt="Site Logo" style="' . esc_attr($style) . '">';
                }
            } else {
                $customLogo = function_exists('get_theme_mod') ? get_theme_mod('custom_logo') : 0;
                if ($customLogo && function_exists('wp_get_attachment_image_src')) {
                    $imgSrc = wp_get_attachment_image_src($customLogo, 'full');
                    if ($imgSrc) {
                        echo '<img src="' . esc_url((string)($imgSrc[0] ?? '')) . '" alt="Site Logo" style="' . esc_attr($style) . '">';
                    }
                } else {
                    echo '<span style="font-size:24px;font-weight:700">' . esc_html((function_exists('get_bloginfo') ? get_bloginfo('name') : '')) . '</span>';
                }
            }
            
            if ($linkHome) echo '</a>';
            echo '</div>';
            break;
            
        case 'site_title':
            $useSiteTitle = $settings['use_site_title'] ?? true;
            $customTitle = $settings['custom_title'] ?? '';
            $tag = $settings['tag'] ?? 'div';
            $size = $settings['size'] ?? 24;
            $color = $settings['color'] ?? '#1d2327';
            $linkHome = $settings['link_home'] ?? true;
            
            $title = $useSiteTitle ? (function_exists('get_bloginfo') ? get_bloginfo('name') : '') : $customTitle;
            $style = 'font-size:' . intval($size) . 'px;color:' . esc_attr($color) . ';font-weight:700;margin:0;';
            
            echo '<' . esc_attr($tag) . ' class="impulse-site-title" style="' . esc_attr($style) . '">';
            if ($linkHome) echo '<a href="' . esc_url(home_url('/')) . '" class="imp-link-reset">';
            echo esc_html($title);
            if ($linkHome) echo '</a>';
            echo '</' . esc_attr($tag) . '>';
            break;
            
        case 'nav_menu':
            // Check visibility condition
            if (!impulse_check_visibility($settings)) break;
            
            $menuId = $settings['menu_id'] ?? '';
            $layout = $settings['layout'] ?? 'horizontal';
            $gap = $settings['gap'] ?? 24;
            $fontSize = $settings['font_size'] ?? 15;
            $textColor = $settings['text_color'] ?? '#1d2327';
            $hoverColor = $settings['hover_color'] ?? '#666666';
            $visClass = impulse_get_visibility_classes($settings);
            
            // Entrance animation
            $animAttrs = impulse_get_animation_attrs($settings);
            
            $menuStyle = 'list-style:none;margin:0;padding:0;display:flex;';
            $menuStyle .= $layout === 'vertical' ? 'flex-direction:column;' : 'flex-direction:row;';
            $menuStyle .= 'gap:' . intval($gap) . 'px;';
            
            $navClass = 'impulse-nav-menu';
            if ($visClass) $navClass .= ' ' . $visClass;
            if ($animAttrs['class']) $navClass .= ' ' . $animAttrs['class'];
            $navStyle = '';
            if ($animAttrs['style']) $navStyle = ' style="' . $animAttrs['style'] . '"';
            
            echo '<nav class="' . esc_attr($navClass) . '"' . $navStyle . '>';
            wp_add_inline_style('instinctor-page-inline', '.impulse-nav-menu a{font-size:' . intval($fontSize) . 'px;color:' . esc_attr($textColor) . ';text-decoration:none}.impulse-nav-menu a:hover{color:' . esc_attr($hoverColor) . '}');
            
            if ($menuId) {
                wp_nav_menu(['menu' => $menuId, 'container' => false, 'items_wrap' => '<ul style="' . esc_attr($menuStyle) . '">%3$s</ul>']);
            } else {
                echo '<ul style="' . esc_attr($menuStyle) . '"><li><a href="' . esc_url(home_url('/')) . '">Home</a></li></ul>';
            }
            echo '</nav>';
            break;
        case 'social':
            $iconSize = $settings['icon_size'] ?? 20;
            $gap = $settings['gap'] ?? 12;
            $iconColor = $settings['icon_color'] ?? '#666666';
            $hoverColor = $settings['hover_color'] ?? '#1d2327';
            $bgShape = $settings['bg_shape'] ?? 'none';
            $bgSize = $settings['bg_size'] ?? 40;
            $bgColor = $settings['bg_color'] ?? '';
            $align = $settings['align'] ?? 'center';
            $brandColors = !empty($settings['brand_colors']);
            $hoverEffect = $settings['hover_effect'] ?? 'none';
            $visClass = impulse_get_visibility_classes($settings);
            
            $brandColorMap = [
                'facebook' => '#1877F2', 'x' => '#000000', 'twitter' => '#000000',
                'instagram' => '#E4405F', 'linkedin' => '#0A66C2', 'youtube' => '#FF0000',
                'tiktok' => '#000000', 'pinterest' => '#BD081C', 'github' => '#181717',
                'dribbble' => '#EA4C89', 'behance' => '#1769FF'
            ];
            
            $socials = [
                'facebook' => '<path fill="currentColor" d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>',
                'x' => '<path fill="currentColor" d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>',
                'twitter' => '<path fill="currentColor" d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>',
                'instagram' => '<path fill="currentColor" d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 3.678c-3.405 0-6.162 2.76-6.162 6.162 0 3.405 2.76 6.162 6.162 6.162 3.405 0 6.162-2.76 6.162-6.162 0-3.405-2.76-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405c0 .795-.646 1.44-1.44 1.44-.795 0-1.44-.646-1.44-1.44 0-.794.646-1.439 1.44-1.439.793-.001 1.44.645 1.44 1.439z"/>',
                'linkedin' => '<path fill="currentColor" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>',
                'youtube' => '<path fill="currentColor" d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>',
                'tiktok' => '<path fill="currentColor" d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/>',
                'pinterest' => '<path fill="currentColor" d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z"/>',
                'github' => '<path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>',
                'dribbble' => '<path fill="currentColor" d="M12 24C5.385 24 0 18.615 0 12S5.385 0 12 0s12 5.385 12 12-5.385 12-12 12zm10.12-10.358c-.35-.11-3.17-.953-6.384-.438 1.34 3.684 1.887 6.684 1.992 7.308 2.3-1.555 3.936-4.02 4.395-6.87zm-6.115 7.808c-.153-.9-.75-4.032-2.19-7.77l-.066.02c-5.79 2.015-7.86 6.025-8.04 6.4 1.73 1.358 3.92 2.166 6.29 2.166 1.42 0 2.77-.29 4-.81zm-11.62-2.58c.232-.4 3.045-5.055 8.332-6.765.135-.045.27-.084.405-.12-.26-.585-.54-1.167-.832-1.74C7.17 11.775 2.206 11.71 1.756 11.7l-.004.312c0 2.633.998 5.037 2.634 6.855zm-2.42-8.955c.46.008 4.683.026 9.477-1.248-1.698-3.018-3.53-5.558-3.8-5.928-2.868 1.35-5.01 3.99-5.676 7.17zM9.6 2.052c.282.38 2.145 2.914 3.822 6 3.645-1.365 5.19-3.44 5.373-3.702C16.86 2.61 14.545 1.62 12 1.62c-.8 0-1.64.116-2.4.432zm10.335 3.483c-.218.29-1.89 2.478-5.64 4.023.24.49.47.985.68 1.48.08.18.15.36.22.53 3.41-.43 6.8.26 7.14.33-.02-2.42-.88-4.64-2.4-6.36z"/>',
                'behance' => '<path fill="currentColor" d="M6.938 4.503c.702 0 1.34.06 1.92.188.577.13 1.07.33 1.485.61.41.28.733.65.96 1.12.225.47.34 1.05.34 1.73 0 .74-.17 1.36-.507 1.86-.338.5-.837.9-1.502 1.22.906.26 1.576.72 2.022 1.37.448.66.665 1.45.665 2.36 0 .75-.13 1.39-.41 1.93-.28.55-.67 1-1.16 1.35-.48.348-1.05.6-1.67.767-.63.16-1.3.24-2.004.24H0v-13.2h6.938zm-.36 5.59c.56 0 1.03-.14 1.38-.42.35-.28.52-.7.52-1.27 0-.32-.06-.58-.19-.78-.13-.21-.3-.37-.52-.48-.22-.12-.47-.2-.75-.24-.28-.05-.57-.07-.86-.07H3.41v3.27h3.17zm.19 5.84c.32 0 .63-.03.92-.1.29-.06.55-.18.77-.35.22-.16.39-.38.52-.67.13-.28.19-.64.19-1.07 0-.84-.24-1.46-.72-1.84-.48-.38-1.1-.57-1.88-.57H3.41v4.6h3.36zM15.94 4.503h6.96v1.76h-6.96V4.503zM22.74 12.78c0-.66-.12-1.26-.36-1.79-.24-.53-.57-.99-.98-1.38-.41-.38-.9-.68-1.46-.9-.56-.22-1.17-.33-1.82-.33-.65 0-1.25.11-1.81.33-.56.22-1.04.52-1.45.9-.41.39-.73.85-.96 1.38-.24.53-.35 1.13-.35 1.79h9.17zm-9.17 1.66c.08.65.24 1.24.5 1.77.26.53.6.98 1.02 1.36.42.38.93.67 1.51.88.58.2 1.24.31 1.96.31.95 0 1.74-.2 2.37-.58.63-.39 1.13-.93 1.52-1.61l2.24 1.2c-.66 1.1-1.55 1.93-2.66 2.48-1.11.55-2.33.82-3.65.82-1.05 0-2.02-.17-2.91-.52-.89-.35-1.66-.84-2.31-1.49-.65-.64-1.16-1.42-1.52-2.34-.37-.91-.55-1.93-.55-3.06 0-1.08.18-2.07.54-2.97.36-.89.85-1.67 1.48-2.32.63-.65 1.37-1.16 2.24-1.52.87-.37 1.81-.55 2.83-.55 1.1 0 2.06.2 2.88.6.82.4 1.5.94 2.05 1.61.55.68.96 1.45 1.23 2.34.27.89.4 1.82.4 2.8v.73h-9.17z"/>'
            ];
            
            // Check for new links array format
            $links = [];
            if (!empty($settings['links'])) {
                $linksData = $settings['links'];
                if (is_string($linksData)) {
                    $linksData = json_decode($linksData, true);
                }
                if (is_array($linksData)) {
                    $links = $linksData;
                }
            }
            // Fallback: read from global social profiles
            if (empty($links)) {
                $links = impulse_get_social_links();
            }
            
            // Background shape styles
            $bgRadius = '0';
            if ($bgShape === 'circle') $bgRadius = '50%';
            elseif ($bgShape === 'rounded') $bgRadius = '8px';
            
            $justify = 'center';
            if ($align === 'left') $justify = 'flex-start';
            elseif ($align === 'right') $justify = 'flex-end';
            
            // Entrance animation
            $animAttrs = impulse_get_animation_attrs($settings);
            
            $socClass = 'impulse-social-icons';
            if ($hoverEffect && $hoverEffect !== 'none') $socClass .= ' impulse-social-hover-' . $hoverEffect;
            if ($visClass) $socClass .= ' ' . $visClass;
            if ($animAttrs['class']) $socClass .= ' ' . $animAttrs['class'];
            
            $socId = 'soc-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
            $socStyle = 'display:flex;gap:' . intval($gap) . 'px;align-items:center;justify-content:' . $justify . ';';
            if ($animAttrs['style']) $socStyle .= $animAttrs['style'];
            
            echo '<div id="' . esc_attr($socId) . '" class="' . esc_attr($socClass) . '" style="' . esc_attr($socStyle) . '">';
            $_socCss = '#' . $socId . ' a{color:' . esc_attr($iconColor) . ';transition:color 0.3s,transform 0.7s}';
            if ($brandColors) {
                foreach ($links as $li => $link) {
                    $p = $link['platform'] ?? '';
                    if (isset($brandColorMap[$p])) {
                        $bc = $brandColorMap[$p];
                        $_socCss .= '#' . $socId . ' a:nth-of-type(' . ($li + 1) . '){color:' . $bc . '}';
                        $_socCss .= '#' . $socId . ' a:nth-of-type(' . ($li + 1) . '):hover{color:' . $bc . ';filter:brightness(1.2)}';
                    }
                }
            } else {
                $_socCss .= '#' . $socId . ' a:hover{color:' . esc_attr($hoverColor) . '}';
            }
            wp_add_inline_style('instinctor-page-inline', $_socCss);
            
            // Render social links — fallback to placeholder icons if none configured in SEO > Site
            if (!empty($links)) {
                foreach ($links as $link) {
                    $platform = $link['platform'] ?? '';
                    $url = $link['url'] ?? '';
                    if ($platform && $url && isset($socials[$platform])) {
                        $iconStyle = 'display:flex;align-items:center;justify-content:center;';
                        if ($bgShape !== 'none') {
                            $effectiveBg = $bgColor ?: '#f0f0f0';
                            $iconStyle .= 'width:' . intval($bgSize) . 'px;height:' . intval($bgSize) . 'px;background:' . esc_attr($effectiveBg) . ';border-radius:' . $bgRadius . ';';
                        }
                        echo '<a href="' . esc_url($url) . '" target="_blank" rel="noopener" style="' . esc_attr($iconStyle) . '">';
                        echo '<svg width="' . intval($iconSize) . '" height="' . intval($iconSize) . '" viewBox="0 0 24 24">' . $socials[$platform] . '</svg>';
                        echo '</a>';
                    }
                }
            } else {
                $placeholderSvgs = [
                    $socials['instagram'],
                    $socials['facebook'],
                    $socials['youtube']
                ];
                foreach ($placeholderSvgs as $svg) {
                    $iconStyle = 'display:flex;align-items:center;justify-content:center;';
                    if ($bgShape !== 'none') {
                        $effectiveBg = $bgColor ?: '#f0f0f0';
                        $iconStyle .= 'width:' . intval($bgSize) . 'px;height:' . intval($bgSize) . 'px;background:' . esc_attr($effectiveBg) . ';border-radius:' . $bgRadius . ';';
                    }
                    echo '<span style="' . esc_attr($iconStyle) . 'color:' . esc_attr($iconColor) . ';opacity:0.35">';
                    echo '<svg width="' . intval($iconSize) . '" height="' . intval($iconSize) . '" viewBox="0 0 24 24">' . $svg . '</svg>';
                    echo '</span>';
                }
            }
            
            echo '</div>';
            // Responsive: gap + icon size
            $socResp = impulse_responsive_css($settings, '#' . $socId, ['gap' => ['gap', 'px']]);
            foreach (['tablet' => '1024', 'mobile' => '767'] as $dv => $bp) {
                $sz = $settings['icon_size_' . $dv] ?? null;
                if ($sz !== null) {
                    $socResp .= '@media(max-width:' . $bp . 'px){#' . $socId . ' svg{width:' . intval($sz) . 'px!important;height:' . intval($sz) . 'px!important}}';
                }
            }
            if ($socResp) wp_add_inline_style('instinctor-page-inline', $socResp);
            break;
            
        case 'copyright':
            $text = $settings['text'] ?? '© {year} {site_name}. All rights reserved.';
            $fontSize = $settings['font_size'] ?? 14;
            $textColor = $settings['text_color'] ?? '#666666';
            $align = $settings['align'] ?? 'center';
            
            $text = str_replace('{year}', gmdate('Y'), $text);
            $text = str_replace('{site_name}', (function_exists('get_bloginfo') ? get_bloginfo('name') : ''), $text);
            
            $style = 'font-size:' . intval($fontSize) . 'px;color:' . esc_attr($textColor) . ';text-align:' . esc_attr($align) . ';';
            
            echo '<div class="impulse-copyright" style="' . esc_attr($style) . '">' . esc_html($text) . '</div>';
            break;
            
        case 'shape':
            // Removed - shapes managed via section bg_shapes
            break;

        case 'map':
            $address = $settings['address'] ?? '1600 Amphitheatre Parkway, Mountain View, CA';
            $latitude = $settings['latitude'] ?? '';
            $longitude = $settings['longitude'] ?? '';
            $zoom = intval($settings['zoom'] ?? 14);
            $mapType = $settings['map_type'] ?? 'roadmap';
            $mapShape = $settings['map_shape'] ?? 'none';
            $mapStyle = $settings['map_style'] ?? 'normal';
            $width = intval($settings['width'] ?? 100);
            $visClass = impulse_get_visibility_classes($settings);
            
            if ($latitude && $longitude) {
                $query = $latitude . ',' . $longitude;
            } else {
                $query = $address;
            }
            
            $typeMap = ['roadmap' => 'm', 'satellite' => 'k', 'hybrid' => 'h', 'terrain' => 'p'];
            $mapTypeParam = $typeMap[$mapType] ?? 'm';
            $embedUrl = 'https://maps.google.com/maps?q=' . urlencode($query) . '&z=' . $zoom . '&t=' . $mapTypeParam . '&output=embed';
            
            $mapClass = 'impulse-google-map impulse-map-reveal';
            $GLOBALS['impulse_needs_map_reveal'] = true;
            if ($mapShape === 'circle') $mapClass .= ' impulse-map-circle';
            if ($visClass) $mapClass .= ' ' . $visClass;
            
            // Container: width-based, shape determines aspect ratio
            $containerStyle = 'position:relative;overflow:hidden;width:var(--impulse-width,' . $width . '%);' . (empty($parentId) ? 'margin:0 auto;' : '');
            if ($mapShape === 'circle') {
                $containerStyle .= 'aspect-ratio:1/1;border-radius:50%;transition:border-radius 0.5s cubic-bezier(0.4,0,0.2,1);';
            } elseif ($mapShape === 'soft') {
                $containerStyle .= 'aspect-ratio:16/9;border-radius:24px;';
            } else {
                $containerStyle .= 'aspect-ratio:16/9;';
            }
            
            $iframeFilter = '';
            if ($mapStyle === 'grey') {
                $iframeFilter = 'filter:grayscale(100%);';
            }
            
            $mapElId = 'impulse-map-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? wp_rand(1000,9999)));
            echo '<div id="' . esc_attr($mapElId) . '" class="' . esc_attr($mapClass) . '" style="' . esc_attr($containerStyle) . '">';
            echo '<iframe src="' . esc_url($embedUrl) . '" width="100%" height="100%" style="border:0;position:absolute;inset:0;' . esc_attr($iframeFilter) . '" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>';



            // Shine overlay
            echo '<div class="impulse-map-shine" style="position:absolute;inset:0;pointer-events:none;z-index:1;overflow:hidden"></div>';

            echo '</div>';
            // Responsive: width — id-based selector since the public frontend
            // doesn't emit the .impulse-el wrapper that this rule used to chain off.
            $mapEl = '#' . $mapElId;
            $mapResp = '';
            foreach (['tablet' => '1024', 'mobile' => '767'] as $dv => $bp) {
                $w = $settings['width_' . $dv] ?? null;
                if ($w !== null) $mapResp .= '@media(max-width:' . $bp . 'px){' . $mapEl . '{width:' . intval($w) . '%!important}}';
            }
            if ($mapResp) wp_add_inline_style('instinctor-page-inline', $mapResp);
            break;
        
        case 'title':
            title_animated_render:
            impulse_auto_mobile_defaults($settings, 'title', 'tablet');
            impulse_auto_mobile_defaults($settings, 'title', 'mobile');
            $laContentType = $settings['la_content_type'] ?? 'text';
            // Frontend: empty fallback (placeholder 'Your text' was leaking to published pages).
            $laText        = isset($settings['la_text']) ? (string) $settings['la_text'] : '';
            $laFontSize    = intval($settings['la_font_size'] ?? 64);
            $laColor       = $settings['la_color'] ?? '#1d2327';
            $laWeight      = $settings['la_font_weight'] ?? '700';
            $laImageSrc    = $settings['la_image_src'] ?? '';
            $laImageWidth  = intval($settings['la_image_width'] ?? 280);
            $laBg          = $settings['la_bg'] ?? 'transparent';
            $laPreset      = $settings['la_preset'] ?? 'shatter_in';
            $laSpring      = intval($settings['la_spring'] ?? 5);
            if (strpos($laPreset, '3d_') === 0) $GLOBALS['impulse_needs_three'] = true;
            $laDurRaw = floatval($settings['la_duration'] ?? 1.2);
            $laDuration = $laDurRaw > 10 ? intval($laDurRaw) : intval(round($laDurRaw * 1000));
            $laDelayRaw = floatval($settings['la_delay'] ?? 0);
            $laDelay = $laDelayRaw > 10 ? intval($laDelayRaw) : intval(round($laDelayRaw * 1000));
            $laEasing      = $settings['la_easing'] ?? 'ease-in-out';
            // Sensitivity source. The Title panel's "Sensitivity" slider writes
            // `mouse_sens` (0..50); `la_tilt` is unset on every modern save. The
            // editor preview (impulse-preview.php) and heading renderer
            // (heading.php) already read mouse_sens, but this frontend animator
            // path used to read only la_tilt — so the slider had NO effect on the
            // live site, and at Sensitivity 0 a stale/legacy la_tilt could keep
            // the cursor tilt + gradient-spin alive. Prefer mouse_sens, fall back
            // to la_tilt for legacy. At 0 both heading-tilt and the gradient
            // onMove gate disable fully — no mouse effect of any kind.
            $_laTiltKey    = isset($settings['mouse_sens']) ? 'mouse_sens' : 'la_tilt';
            $laTilt        = intval($settings[$_laTiltKey] ?? 0);
            // Animator block uses the universal heading-tilt script for cursor-
            // driven rotation. Flag the asset so it gets enqueued — without this
            // animators rendered without a matching display-text block on the
            // page have data-tilt but no JS reading it.
            if ($laTilt > 0) { $GLOBALS['impulse_needs_heading_tilt'] = true; }
            $laDepth       = intval($settings['la_depth'] ?? 50);
            // la_ring_arc retired 2026-05-23 — slider + engine removed.
            $laGapBelow    = intval($settings['la_gap_below'] ?? 0);
            $laElId        = 'la-' . $el['id'];
            $laFontFamily  = $settings['font_family'] ?? '';
            
            $laInner = '';
            if ($laContentType === 'image' && $laImageSrc) {
                $laSvgColors = $settings['la_svg_colors'] ?? '';
                $laSvgAttr   = ($laSvgColors && str_ends_with(strtolower($laImageSrc), '.svg'))
                    ? ' data-svg-colors="' . esc_attr($laSvgColors) . '"' : '';
                $laInner = '<img src="' . esc_attr($laImageSrc) . '" class="impulse-la-asset"' . $laSvgAttr . ' style="width:' . $laImageWidth . 'px;max-width:100%;display:block;opacity:0" alt="">';
            } else {
                // Main colour swatch (la_color) IS the gradient start. la_gradient_from is a
                // legacy mirror that can drift out of sync on old saves — ignore it.
                $_laGradFromF = $laColor;
                $_laGradToF   = $settings['la_gradient_to']    ?? '';
                $_laGradAngF  = intval($settings['la_gradient_angle'] ?? 135);
                $_laLetterSpacing = isset($settings['la_letter_spacing'])
                    ? intval($settings['la_letter_spacing']) . 'px'
                    : '-0.01em';
                /* Wrap inside narrow containers (grid cells, columns). The animator's
                   container is `display:flex`, and flex items default to `min-width:auto`
                   (= content's intrinsic size), which can override max-width and prevent
                   wrapping when an outer ancestor — like a section with bg animation —
                   imposes a different containment. min-width:0 forces the flex item to
                   honour its max-width so word-break/overflow-wrap can do their job. */
                $laFontStyle = 'font-size:' . $laFontSize . 'px;font-weight:' . esc_attr($laWeight) . ';line-height:1.15;text-align:center;letter-spacing:' . $_laLetterSpacing . ';opacity:0;max-width:100%;min-width:0;word-break:break-word;overflow-wrap:break-word';
                // Gradient fill via background-clip:text when both colors set — visible if
                // the animator is ever shown as static (no preset, no-preset-finish) so it
                // doesn't drop back to plain main color on frontend.
                if (!empty($_laGradToF) && !empty($_laGradFromF)) {
                    // CSS linear-gradient uses 0deg=up convention; canvas gradient uses 0deg=right
                    // convention (matches the panel's angle slider). CSS angle = canvas angle + 90.
                    $_laCssAngF = ($_laGradAngF + 90) % 360;
                    $laFontStyle .= ';background-image:linear-gradient(' . $_laCssAngF . 'deg,' . esc_attr($_laGradFromF) . ',' . esc_attr($_laGradToF) . ');-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent';
                } else {
                    $laFontStyle .= ';color:' . esc_attr($laColor);
                }
                if ($laFontFamily) {
                    // RAW: $laFontStyle is esc_attr()'d at echo below. Inner
                    // esc_attr here double-escaped quotes in the stack
                    // ("Palatino" → &amp;quot;Palatino&amp;quot;) → browser
                    // ignored the rule. 2026-05-29.
                    $laFontStyle .= ';font-family:' . $laFontFamily;
                }
                $laInner = '<div class="impulse-la-asset" style="' . esc_attr($laFontStyle) . '">' . impulse_text_safe_breaks($laText) . '</div>';
            }
            
            // Loop mode removed — entrance always plays on scroll-trigger
            // (or immediately when force-immediate is set for above-fold).
            $laScrollTrig  = !empty($_hdrForceImmediate) ? 0 : 1;

            // Helper: emit per-device data attributes for JS-driven settings
            $_laDA = function($key, $attr) use ($settings) {
                $o = '';
                foreach (['tablet', 'mobile'] as $d) {
                    $dk = $key . '_' . $d;
                    if (isset($settings[$dk]) && $settings[$dk] !== '') $o .= ' data-' . $attr . '-' . $d . '="' . intval($settings[$dk]) . '"';
                }
                return $o;
            };
            // Helper: same but with seconds→ms conversion for timing attrs
            $_laDMs = function($key, $attr) use ($settings) {
                $o = '';
                foreach (['tablet', 'mobile'] as $d) {
                    $dk = $key . '_' . $d;
                    if (isset($settings[$dk]) && $settings[$dk] !== '') {
                        $r = floatval($settings[$dk]);
                        $o .= ' data-' . $attr . '-' . $d . '="' . ($r > 10 ? intval($r) : intval(round($r * 1000))) . '"';
                    }
                }
                return $o;
            };

            // align within the full-width animator container — matches Heading's align setting.
            $laAlign = $settings['align'] ?? 'center';
            $laJustify = $laAlign === 'left' ? 'flex-start' : ($laAlign === 'right' ? 'flex-end' : 'center');
            // Halo — soft glow around the animator's rendered text or
            // image. Animator renders to <canvas> at runtime so a CSS
            // text-shadow on the wrapper would do nothing; filter:
            // drop-shadow on the wrapper traces every child element's
            // painted pixels including the canvas. Halo colour flips
            // to the opposite luminance of la_color for legibility on
            // busy backgrounds.
            $_laHalo    = max(0, min(100, intval($settings['text_halo'] ?? 0)));
            $_laHaloCss = $_laHalo > 0 ? impulse_drop_shadow_halo_css($_laHalo, $laColor) : '';
            $_laWrapStyle = 'display:flex;align-items:center;justify-content:' . $laJustify
                . ';background:' . esc_attr($laBg)
                . ($laGapBelow > 0 ? ';margin-bottom:' . $laGapBelow . 'px' : '')
                . ';overflow:visible;position:relative;width:100%'
                . ';' . $_laHaloCss;
            echo '<div id="' . esc_attr($laElId) . '" class="impulse-logo-animator"'
                . ' data-preset="' . esc_attr($laPreset) . '"'
                . ' data-spring="' . esc_attr($laSpring) . '"' . $_laDA('la_spring', 'spring')
                . ' data-duration="' . esc_attr($laDuration) . '"' . $_laDMs('la_duration', 'duration')
                . ' data-delay="' . esc_attr($laDelay) . '"' . $_laDMs('la_delay', 'delay')
                . ' data-easing="' . esc_attr($laEasing) . '"'
                . ' data-scroll-trigger="' . esc_attr($laScrollTrig) . '"'
                . ' data-tilt="' . esc_attr($laTilt) . '"' . $_laDA($_laTiltKey, 'tilt')
                . ' data-depth="' . esc_attr($laDepth) . '"' . $_laDA('la_depth', 'depth')
                . ' data-color="' . esc_attr($settings['la_color'] ?? '#1d2327') . '"'
                . ' data-gradient-from="' . esc_attr($settings['la_color'] ?? '#1d2327') . '"'
                . ' data-gradient-to="' . esc_attr($settings['la_gradient_to'] ?? '') . '"'
                . ' data-gradient-angle="' . intval($settings['la_gradient_angle'] ?? 135) . '"' . $_laDA('la_gradient_angle', 'gradient-angle')
                . ' style="' . esc_attr($_laWrapStyle) . '">'
                . $laInner
                . '</div>';
            // Per-device align overrides (frontend). The wrapper's justify-content
            // is inline = desktop align; without these the tablet/mobile align is
            // ignored and the title stays centered on mobile even when align_mobile
            // is set. Emit @media rules with !important (mirrors animator.php). The
            // canvas reads the wrapper's COMPUTED justify-content when it rasterises,
            // so the @media override flows through to the canvas text alignment too.
            // Sergei 2026-05-28.
            $_laAlignTabRaw = (string) ($settings['align_tablet'] ?? '');
            $_laAlignMobRaw = (string) ($settings['align_mobile'] ?? '');
            if ($_laAlignTabRaw !== '' || $_laAlignMobRaw !== '') {
                $_laJcFor = function ($a) { return $a === 'left' ? 'flex-start' : ($a === 'right' ? 'flex-end' : 'center'); };
                if ($_laAlignTabRaw !== '' && $_laAlignTabRaw !== $laAlign) {
                    wp_add_inline_style('instinctor-page-inline', '@media(max-width:1024px){#' . esc_attr($laElId) . '{justify-content:' . $_laJcFor($_laAlignTabRaw) . ' !important}}');
                }
                if ($_laAlignMobRaw !== '' && $_laAlignMobRaw !== $laAlign) {
                    wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . esc_attr($laElId) . '{justify-content:' . $_laJcFor($_laAlignMobRaw) . ' !important}}');
                }
            }
            // Responsive CSS overrides (font-size, image-width on asset; gap-below on container)
            $laResp = impulse_responsive_css($settings, '#' . esc_attr($laElId) . ' .impulse-la-asset', [
                'la_font_size'   => ['font-size', 'px'],
                'la_image_width' => ['width', 'px'],
            ]);
            $laRespGap = impulse_responsive_css($settings, '#' . esc_attr($laElId), [
                'la_gap_below' => ['margin-bottom', 'px'],
            ]);
            if ($laResp || $laRespGap) wp_add_inline_style('instinctor-page-inline', $laResp . $laRespGap);
            
            if (!isset($GLOBALS['impulse_animator_js_emitted'])) {
                $GLOBALS['impulse_animator_js_emitted'] = true;
                // Canonical animator engine — assets/js/impulse-animator.js.
                // The frontend previously inlined a ~2,890-line fork of this
                // engine here. The fork had drifted from the canonical file:
                // it lacked the canvas height-growth pre-measure (headings
                // clipped), the device-pixel playOnce fix (text squeezed on
                // HiDPI), and the eager font preload. Fork removed.
                //
                // CRITICAL: emit a SYNCHRONOUS <script> (no defer) at the same
                // document position the inline fork occupied. The editor
                // (admin/views/impulse-preview.php) also loads this file
                // synchronously. A deferred script runs at end-of-parse, which
                // changes when the screen-integration globals (impulsePreWarm-
                // Screen / impulseOnScreenFocus) become defined relative to the
                // screen engine and sandbox wrappers — desyncing frontend from
                // editor. Synchronous load keeps the timing identical to both
                // the editor and the original inline fork.
                $_laBase = defined('INSTINCTOR_PLUGIN_URL') ? INSTINCTOR_PLUGIN_URL : '/instinctor-plugin/';
                $_laVer  = defined('INSTINCTOR_VERSION')    ? INSTINCTOR_VERSION    : '0';
                // Engine-required opacity rules (hide source asset once the
                // canvas overlay exists) — inlined so they apply immediately,
                // exactly as the removed fork did.
                // The canvas hide rule excludes `.impulse-screen-entering`
                // (stamped on the destination screen by impulse-screens.js for
                // the duration of a transition) so the incoming title canvas
                // stays visible while the screen slides in — letting the
                // pre-fired animation play during the transition rather than
                // unseen behind opacity:0.
                wp_add_inline_style('instinctor-page-inline', '.impulse-logo-animator .impulse-la-asset{opacity:0}'
                   . '.impulse-screen:not(.impulse-screen-active):not(.impulse-screen-entering) .impulse-la-canvas,'
                   . '.impulse-screen:not(.impulse-screen-active):not(.impulse-screen-entering) .impulse-la-3d-canvas{opacity:0!important;}'
                   . '.impulse-logo-animator:has(> .impulse-la-canvas) > .impulse-la-asset,'
                   . '.impulse-logo-animator:has(> .impulse-la-3d-canvas) > .impulse-la-asset{opacity:0!important}');
                \Instinctor\Core\Assets::enqueue('instinctor-animator-js');
                if (function_exists('wp_add_inline_script')) {
                    wp_add_inline_script('instinctor-animator-js', 'window._instinctorPluginUrl=' . wp_json_encode($_laBase) . ';', 'before');
                }
            }
            break;

        case 'kinetic':
            /* Composition engine (v3 — 2026-05-29). Old preset/phrase
               settings are translated into the new text+composition model
               so existing pages don't break on first load. */
            $kComp        = $settings['composition'] ?? 'block-slam';
            /* Free-tier gate: only the default `block-slam` composition is
               unlocked on free WP installs, and custom per-line mode is Pro.
               Any saved Pro setting silently downgrades on output so the page
               still renders (just plainly). Standalone + Pro-licensed WP both
               return true from isPro() so this is a no-op there. */
            $_kIsFree = !instinctor_has_pro();
            if ($_kIsFree) {
                if ($kComp !== 'block-slam') $kComp = 'block-slam';
                if (!empty($settings['custom_mode'])) $settings['custom_mode'] = '0';
            }
            $kCustomMode  = !empty($settings['custom_mode']) ? '1' : '0';
            $kTier        = $settings['tier'] ?? 'hero';
            $kLineGap     = floatval($settings['line_gap'] ?? 0);
            $kCylDiameter = floatval($settings['cyl_diameter'] ?? 70);
            $kAccent      = $settings['accent'] ?? 'none';
            $kAccentText  = $settings['accent_text'] ?? '';
            $kCustomLines = $settings['custom_lines'] ?? '[]';
            if (is_array($kCustomLines)) {
                $kCustomLines = wp_json_encode($kCustomLines, JSON_UNESCAPED_UNICODE);
            }
            $kColor  = $settings['color']  ?? '#ffffff';
            $kLoop   = !empty($settings['loop']) ? '1' : '0';
            $kSpeed     = floatval($settings['speed'] ?? 1.0);
            $kIntensity = floatval($settings['intensity'] ?? 1.0);
            if ($kIntensity <= 0) $kIntensity = 1.0;
            $kHeight = intval($settings['height']  ?? 400);
            $kText   = $settings['text']   ?? '';

            /* Migration from v2 (preset + phrases JSON) to v3 (text + composition). */
            if ($kText === '') {
                if (!empty($settings['raw_text'])) {
                    $kText = (string) $settings['raw_text'];
                } elseif (!empty($settings['phrases'])) {
                    $decoded = json_decode($settings['phrases'], true);
                    if (is_array($decoded)) {
                        $lines = [];
                        foreach ($decoded as $p) {
                            if (is_array($p) && isset($p['text'])) $lines[] = trim($p['text']);
                        }
                        $kText = implode("\n", array_filter($lines));
                    }
                }
            }

            /* Background is transparent — user controls colour contrast
               via their containing section / cell. Black-on-text isn't
               always what's wanted (cell with bg image, light-theme,
               coloured section).
               When fit_container is true, the block fills its parent's
               height (good for grid cells with their own dimensions);
               otherwise the height slider drives an explicit px height. */
            /* Height mode (consolidated): auto | fixed | fit. One setting replaces
               the old height_auto + fit_container keys. Falls back to those legacy
               keys for blocks saved before the merge. Auto = hug text (default);
               fixed = the Height slider px; fit = fill the parent. Cylinder can't
               hug, so 'auto' collapses to 'fixed'. */
            $kHeightMode = $settings['height_mode'] ?? '';
            if ($kHeightMode !== 'auto' && $kHeightMode !== 'fixed' && $kHeightMode !== 'fit') {
                $kHeightMode = !empty($settings['fit_container']) ? 'fit'
                             : (((string)($settings['height_auto'] ?? '1') === '0') ? 'fixed' : 'auto');
            }
            if ($kComp === 'cylinder' && $kHeightMode === 'auto') $kHeightMode = 'fixed';
            $kFitContainer = ($kHeightMode === 'fit');
            $kAutoHeight   = ($kHeightMode === 'auto');
            $kHeightCss    = $kAutoHeight ? 'auto' : ($kFitContainer ? '100%' : ($kHeight . 'px'));
            /* Semantic tag (SEO): None → decorative <div>; H1/H2/H3 → real
               heading so Kinetic can stand in for the Title block without
               losing document structure. margin:0 neutralises the browser's
               default heading margins (the inner letter spans drive size). */
            /* Horizontal alignment: desktop value rides data-align (the runtime
               sets the stage's align-items/text-align from it); tablet/mobile
               overrides ride @media + !important below, mirroring the title
               block's align mechanism. */
            $kAlign = strtolower(trim((string)($settings['align'] ?? 'center')));
            if (!in_array($kAlign, ['left', 'right'], true)) $kAlign = 'center';
            $kElId  = 'kin-' . $el['id'];
            $kTagRaw  = strtolower(trim((string)($settings['tag'] ?? '')));
            $kTagName = in_array($kTagRaw, ['h1', 'h2', 'h3'], true) ? $kTagRaw : 'div';
            /* Non-cylinder kinetic HUGS its text (Sergei: never crop AND never
               leave a huge uncontrollable gap). The block is content-sized:
               grows down for more lines (never clipped) and shrinks to the text
               (no min-height floor, so a short line in a tall block can't strand
               empty space). The Height value no longer forces a box for
               non-cylinder. Cylinder keeps its fixed drum box; Fit fills parent. */
            $kGrows      = (!$kAutoHeight && !$kFitContainer && $kComp !== 'cylinder');
            $kEngineAuto = $kAutoHeight || $kGrows; /* stage laid in-flow + height-shrink skipped */
            $kStyle = $kEngineAuto
                ? ('width:100%;height:auto;min-height:0;position:relative;overflow:visible;margin:0;'
                   . 'display:flex;flex-direction:column;align-items:center;justify-content:center;')
                : ('width:100%;height:' . $kHeightCss . ';position:relative;overflow:hidden;margin:0;'
                   . ($kFitContainer ? 'min-height:80px;' : '')
                   . 'display:flex;align-items:center;justify-content:center;');
            // SEO / no-JS fallback: emit the plain heading text INSIDE the tag,
            // visually hidden. Crawlers read a real h1/h2/h3; the engine paints
            // the animation over it (it builds from data-* attrs, not this text
            // node). Matches the visible animated words, so it is accessible
            // text, not cloaking.
            $_kSeo = '';
            $_kDec = json_decode($kCustomLines, true);
            if (is_array($_kDec)) { $_kt = []; foreach ($_kDec as $_kl) { if (!empty($_kl['text'])) $_kt[] = $_kl['text']; } $_kSeo = implode(' ', $_kt); }
            if ($_kSeo === '') $_kSeo = (string) $kText;
            $_kSeo = trim(wp_strip_all_tags(str_replace(['<br>', '<br/>', '<br />'], ' ', $_kSeo)));
            $_kSeoSpan = ($_kSeo !== '') ? '<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap">' . esc_html($_kSeo) . '</span>' : '';
            echo '<' . tag_escape($kTagName) . ' id="' . esc_attr($kElId) . '" class="impulse-kinetic-block"'
                . ' style="' . esc_attr($kStyle) . '"'
                . ' data-align="' . esc_attr($kAlign) . '"'
                . ' data-composition="' . esc_attr($kComp) . '"'
                . ' data-text="' . esc_attr(rawurlencode($kText)) . '"'
                . ' data-color="' . esc_attr($kColor) . '"'
                . ' data-fill="' . esc_attr(strtolower((string)($settings['fill'] ?? 'solid'))) . '"'
                . ' data-grad-mode="' . esc_attr(strtolower((string)($settings['grad_mode'] ?? 'travel'))) . '"'
                . ' data-grad-scope="' . esc_attr(strtolower((string)($settings['grad_scope'] ?? 'line'))) . '"'
                . ' data-grad-c1="' . esc_attr((string)($settings['grad_c1'] ?? '#ff2d55')) . '"'
                . ' data-grad-c2="' . esc_attr((string)($settings['grad_c2'] ?? '#5e5cff')) . '"'
                . ' data-grad-speed="' . esc_attr((string)($settings['grad_speed'] ?? '1')) . '"'
                . ' data-loop="' . esc_attr($kLoop) . '"'
                . ' data-cycle="' . (((string)($settings['cycle'] ?? '') === '1') ? '1' : '0') . '"'
                . ' data-speed="' . esc_attr($kSpeed) . '"'
                . ' data-intensity="' . esc_attr($kIntensity) . '"'
                . ' data-custom-mode="' . esc_attr($kCustomMode) . '"'
                . ' data-tier="' . esc_attr($kTier) . '"'
                . ' data-line-gap="' . esc_attr($kLineGap) . '"'
                . ' data-max-width="' . esc_attr((string)(floatval($settings['max_width'] ?? 0) ?: 0)) . '"'
                /* Per-device caps: emit tablet/mobile only when overridden; the
                   engine picks by viewport (desktop value is the fallback). */
                . ((isset($settings['max_width_tablet']) && $settings['max_width_tablet'] !== '') ? ' data-max-width-tablet="' . esc_attr((string)floatval($settings['max_width_tablet'])) . '"' : '')
                . ((isset($settings['max_width_mobile']) && $settings['max_width_mobile'] !== '') ? ' data-max-width-mobile="' . esc_attr((string)floatval($settings['max_width_mobile'])) . '"' : '')
                . ' data-cyl-diameter="' . esc_attr($kCylDiameter) . '"'
                . ' data-accent="' . esc_attr($kAccent) . '"'
                . ' data-accent-text="' . esc_attr(rawurlencode($kAccentText)) . '"'
                . ' data-custom-lines="' . esc_attr(rawurlencode($kCustomLines)) . '"'
                . ' data-scroll-exit="' . esc_attr(strtolower((string)($settings['scroll_exit'] ?? 'off'))) . '"'
                . ' data-lead-screen="' . esc_attr((string)($settings['lead_screen'] ?? '') === '0' ? '0' : '1') . '"'
                . ' data-height-auto="' . ($kEngineAuto ? '1' : '0') . '"'
                . ' data-tag="' . esc_attr($kTagName) . '"'
                . '>' . $_kSeoSpan . '</' . tag_escape($kTagName) . '>';
            $_kAlignCss = function ($a) {
                if ($a === 'left')  return 'align-items:flex-start !important;text-align:left !important';
                if ($a === 'right') return 'align-items:flex-end !important;text-align:right !important';
                return 'align-items:center !important;text-align:center !important';
            };
            $_kAlignTab = strtolower(trim((string)($settings['align_tablet'] ?? '')));
            $_kAlignMob = strtolower(trim((string)($settings['align_mobile'] ?? '')));
            if ($_kAlignTab !== '' && $_kAlignTab !== $kAlign) {
                wp_add_inline_style('instinctor-page-inline', '@media(max-width:1024px){#' . esc_attr($kElId) . ' .impulse-kinetic-stage{' . $_kAlignCss($_kAlignTab) . '}}');
            }
            if ($_kAlignMob !== '' && $_kAlignMob !== $kAlign) {
                wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . esc_attr($kElId) . ' .impulse-kinetic-stage{' . $_kAlignCss($_kAlignMob) . '}}');
            }
            /* Inline init script ensures editor refreshes pick up new
               settings: the script tag is re-executed when the element is
               replaced, so the block always (re)attaches a fresh instance. */
            // DO NOT strip the <script> wrapper. It was removed by 1bc1563
            // (WP.org compliance pass 2026-06-28), which turned this init JS
            // into visible page text and broke kinetic bootstrap on every
            // block site-wide. The script MUST sit adjacent to its block
            // (document.currentScript + previousElementSibling); enqueuing to
            // wp_footer breaks the attach.
            // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- intentional inline bootstrap, must be adjacent to the block (see note above).
            echo '<script>(function(){var s=document.currentScript;var el=s&&s.previousElementSibling;'
               . 'if(!el||!el.classList||!el.classList.contains("impulse-kinetic-block"))return;'
               . 'function go(){if(window.ImpulseKinetic){if(!el._kineticInst)window.ImpulseKinetic.initEl(el);}else{setTimeout(go,40);}}go();'
               . '})();</script>';
            if (!isset($GLOBALS['impulse_kinetic_js_emitted'])) {
                $GLOBALS['impulse_kinetic_js_emitted'] = true;
                \Instinctor\Core\Assets::enqueue('instinctor-kinetic');
            }
            break;

        case 'flip_box':
            $frontTitle = $settings['front_title'] ?? '';
            $frontContent = $settings['front_content'] ?? '';
            $frontBg = $settings['front_bg'] ?? '#f5f5f5';
            $frontColor = $settings['front_color'] ?? '#1d2327';
            $frontTitleSize = intval(impulse_get_responsive($settings, 'front_title_size', 24));
            $frontContentSize = intval(impulse_get_responsive($settings, 'front_content_size', 14));
            $backTitle = $settings['back_title'] ?? '';
            $backContent = $settings['back_content'] ?? '';
            $backButtonText = $settings['back_button_text'] ?? '';
            $backButtonLink = $settings['back_button_link'] ?? '#';
            $backBg = $settings['back_bg'] ?? '#e0e0e0';
            $backColor = $settings['back_color'] ?? '#1d2327';
            
            // Fallback: empty/transparent → parent section bg → page bg
            $_secBg = $GLOBALS['impulse_current_section_bg'] ?? '';
            $_pageBg = $GLOBALS['impulse_page_bg_color'] ?? '#ffffff';
            $_bgFallback = (!empty($_secBg) && $_secBg !== 'transparent') ? $_secBg : $_pageBg;
            if (empty($frontBg) || $frontBg === 'transparent') $frontBg = $_bgFallback;
            if (empty($backBg) || $backBg === 'transparent') $backBg = $_bgFallback;
            $backTitleSize = intval($settings['back_title_size'] ?? $frontTitleSize);
            $backContentSize = intval($settings['back_content_size'] ?? $frontContentSize);
            $animationType = $settings['animation_type'] ?? 'flip';
            // Default 900ms reads as premium-cinematic; 600 felt rushed.
            $animSpeed = intval($settings['animation_speed'] ?? 900);
            $animMap = ['fade' => 'fade', 'blur' => 'blur', 'glitch' => 'glitch', 'door' => 'door', 'swing' => 'swing'];
            if (isset($animMap[$animationType])) $animationType = $animMap[$animationType];
            // Removed animation types — fall through to spring so existing
            // flipboxes saved with these values keep working.
            if (in_array($animationType, ['orbital','tilt','z-push'], true)) $animationType = 'spring';
            $flipDirection = impulse_get_responsive($settings, 'flip_direction', 'horizontal');
            $slideDirection = impulse_get_responsive($settings, 'slide_direction', 'left');
            $height = intval($settings['height'] ?? 250);
            $borderRadius = intval($settings['border_radius'] ?? 8);
            // Padding — uniform slider takes priority over the 4-side
            // values when set, matching the admin panel UI (single
            // "Padding" slider). Earlier this renderer ignored the
            // uniform key and tiles rendered with the 24px fallback
            // until the user clicked the flipbox and the live-CSS
            // injection in admin-core.js applied the real value.
            if (isset($settings['padding'])) {
                $padding = intval($settings['padding']) . 'px';
            } else {
                $paddingTop    = intval($settings['padding_top']    ?? 24);
                $paddingRight  = intval($settings['padding_right']  ?? 24);
                $paddingBottom = intval($settings['padding_bottom'] ?? 24);
                $paddingLeft   = intval($settings['padding_left']   ?? 24);
                $padding = $paddingTop . 'px ' . $paddingRight . 'px ' . $paddingBottom . 'px ' . $paddingLeft . 'px';
            }
            $visClass = impulse_get_visibility_classes($settings);
            
            // Border per-side
            $fbBorderWidth = intval($settings['border_width'] ?? 0);
            $fbBorderColor = esc_attr($settings['border_color'] ?? '#e0e0e0');
            $fbBorderStyle = '';
            if ($fbBorderWidth > 0) {
                $fbBorderTop = !isset($settings['border_top']) || $settings['border_top'] !== '0';
                $fbBorderRight = !isset($settings['border_right']) || $settings['border_right'] !== '0';
                $fbBorderBottom = !isset($settings['border_bottom']) || $settings['border_bottom'] !== '0';
                $fbBorderLeft = !isset($settings['border_left']) || $settings['border_left'] !== '0';
                $bw = $fbBorderWidth . 'px';
                $fbBorderStyle = 'border-top:' . ($fbBorderTop ? $bw . ' solid ' . $fbBorderColor : 'none') . ';'
                    . 'border-right:' . ($fbBorderRight ? $bw . ' solid ' . $fbBorderColor : 'none') . ';'
                    . 'border-bottom:' . ($fbBorderBottom ? $bw . ' solid ' . $fbBorderColor : 'none') . ';'
                    . 'border-left:' . ($fbBorderLeft ? $bw . ' solid ' . $fbBorderColor : 'none') . ';';
            }
            
            // Vertical alignment — top / center / bottom. Default top
            // reads more like a premium spec card (consistent baseline
            // across cards regardless of text length). Use center only
            // when the card is mostly single-line.
            $_vAlignMap = ['top' => 'flex-start', 'center' => 'center', 'bottom' => 'flex-end'];
            $_fbVAlign  = $_vAlignMap[(string)($settings['vertical_align'] ?? 'top')] ?? 'flex-start';
            // Horizontal content alignment (v2.1.1830) — mirror of the shared
            // flip_box.php renderer. Default 'center' = legacy.
            $_fbHAlignMap = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'];
            $_fbHAlign     = (string)($settings['content_align'] ?? 'center');
            $_fbAlignItems = $_fbHAlignMap[$_fbHAlign] ?? 'center';
            $_fbTextAlign  = in_array($_fbHAlign, ['left', 'center', 'right'], true) ? $_fbHAlign : 'center';
            // transform-style:preserve-3d on the faces lets descendants
            // (title, detail span) live at different translateZ values so
            // the depth engine's existing section-level transforms create
            // natural parallax between the lines on scroll/flip — no
            // mouse-driven hack required.
            $baseSideStyle = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:' . $_fbAlignItems . ';justify-content:' . $_fbVAlign . ';padding:' . $padding . ';text-align:' . $_fbTextAlign . ';border-radius:' . $borderRadius . 'px;transition:all 0.6s ease-in-out;transform-style:preserve-3d;' . $fbBorderStyle;
            $flipMaxWidth = intval($settings['section_max_width'] ?? 600);
            // Frosted-glass is rendered as a "fake glass" recipe baked
            // INTO each face (heavier translucent gradient + 1px inset
            // highlight + 1px inset stroke) rather than a separate
            // frosted wrapper around the flip-box. The wrapper-based
            // approach created a stationary base layer that the inner
            // rotated over — visually "two layers". Faces with the
            // fake glass recipe rotate together with the inner, so the
            // whole card flips as a single object.
            //
            // backdrop-filter is intentionally NOT applied: it doesn't
            // survive Chromium's preserve-3d ancestor compositing, and
            // the cosmos canvas at z:-1 wouldn't be sampleable anyway.
            $frostedGlass = !empty($settings['frosted_glass']);
            if ($frostedGlass) {
                $glassRecipe = ';background:linear-gradient(155deg,rgba(255,255,255,0.12),rgba(255,255,255,0.04));'
                             . 'box-shadow:inset 0 1px 0 rgba(255,255,255,0.22),inset 0 0 0 1px rgba(255,255,255,0.08),0 12px 32px rgba(0,0,0,0.32);';
            }
            $containerStyle = 'width:100%;'
                . ($flipMaxWidth > 0 ? 'max-width:' . $flipMaxWidth . 'px;' : '')
                . 'height:' . $height . 'px;position:relative;'
                . ($animationType === 'spring' ? '' : 'overflow:hidden;')
                . 'isolation:isolate;';
            $innerStyle = 'position:relative;width:100%;height:100%;';
            $frontStyle = $baseSideStyle . 'background:' . esc_attr($frontBg) . ';color:' . esc_attr($frontColor) . ';';
            $backStyle = $baseSideStyle . 'background:' . esc_attr($backBg) . ';color:' . esc_attr($backColor) . ';';
            
            $dataAttrs = 'data-animation="' . esc_attr($animationType) . '" data-speed="' . $animSpeed . '"';
            
            switch ($animationType) {
                case 'fade':
                    // No flip: the front fades out to reveal the back (which sits
                    // underneath at full opacity). Demo team cards put the SAME portrait
                    // under a dark scrim on the back, so the bio appears on a dark overlay
                    // without the card flipping. Inner stays flat (no preserve-3d) so
                    // z-index controls stacking. Sergei 2026-07-13.
                    $frontStyle .= 'z-index:2;';
                    $backStyle  .= 'z-index:1;';
                    break;
                case 'zoom':
                    $frontStyle .= 'z-index:2;';
                    $backStyle .= 'z-index:1;transform:scale(0.82);opacity:0;';
                    break;
                case 'slide':
                    $containerStyle .= 'overflow:hidden;';
                    $frontStyle .= 'z-index:3;';
                    $backStyle .= 'z-index:1;opacity:0;';
                    break;
                case 'push':
                    $containerStyle .= 'overflow:hidden;';
                    $innerStyle .= 'transition:transform 0.6s ease-in-out;';
                    $pushOffsets = ['left'=>'transform:translateX(100%);','right'=>'transform:translateX(-100%);','up'=>'transform:translateY(100%);','down'=>'transform:translateY(-100%);'];
                    $backStyle .= $pushOffsets[$slideDirection] ?? $pushOffsets['left'];
                    $dataAttrs .= ' data-slide-direction="' . esc_attr($slideDirection) . '" data-push="1"';
                    break;
                case 'morph':
                    // Back stays at full opacity behind the front; the front's
                    // clip-path shrinks to reveal the back's color beneath.
                    // 150% radius (relative to box diagonal) overshoots so
                    // the corners stay fully clipped — earlier 100% left a
                    // hairline near the corners on tall/short ratios.
                    $frontStyle .= 'z-index:2;clip-path:circle(150% at 50% 50%);transition:clip-path 0.55s ease-in-out;';
                    $backStyle .= 'z-index:1;';
                    break;
                case 'glitch':
                    $frontStyle .= 'z-index:2;transition:none;';
                    $backStyle .= 'z-index:1;opacity:0;transition:opacity 0.3s ease-in-out;';
                    break;

                case 'fold': case 'flip': case 'swing':

                case 'stack':
                    $frontStyle .= 'z-index:2;';
                    $backStyle .= 'z-index:1;opacity:0;transform:scale(0.92);';
                    break;
                case 'cube':
                    $containerStyle .= 'perspective:1000px;overflow:hidden;';
                    $innerStyle .= 'transform-style:preserve-3d;transition:transform 0.6s ease-in-out;';
                    $cubeTranslate = $height / 2;
                    $frontStyle .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;transform:translateZ(' . $cubeTranslate . 'px);';
                    $backStyle .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;transform:rotateX(-90deg) translateZ(' . $cubeTranslate . 'px);';
                    $dataAttrs .= ' data-cube-translate="' . $cubeTranslate . '"';
                    break;
                case 'depth-rise':
                    $containerStyle .= 'perspective:600px;overflow:hidden;';
                    $frontStyle .= 'z-index:2;';
                    $backStyle .= 'z-index:1;opacity:0;';
                    break;
                case 'void':
                    $frontStyle .= 'z-index:2;';
                    $backStyle .= 'z-index:1;transform:scale(0);opacity:0;';
                    break;
                default:
                    $containerStyle .= 'perspective:1200px;';
                    $innerStyle .= 'transform-style:preserve-3d;';
                    $flipTransform = $flipDirection==='vertical'?'rotateX(180deg)':'rotateY(180deg)';
                    $frontStyle .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;';
                    $backStyle .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;transform:' . $flipTransform . ';';
                    $dataAttrs .= ' data-flip-direction="' . esc_attr($flipDirection) . '"';
                    break;
                case 'spring':
                    $containerStyle .= 'perspective:1200px;';
                    $innerStyle .= 'transform-style:preserve-3d;';
                    $flipTransform = $flipDirection==='vertical'?'rotateX(180deg)':'rotateY(180deg)';
                    $frontStyle .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;';
                    $backStyle .= 'backface-visibility:hidden;-webkit-backface-visibility:hidden;transform:' . $flipTransform . ';';
                    $dataAttrs .= ' data-flip-direction="' . esc_attr($flipDirection) . '"';
                    break;
            }

            $flipClass = 'impulse-flip-box';
            if ($visClass) $flipClass .= ' ' . $visClass;

            // When frosted_glass is enabled bake the glass recipe into
            // each face so they flip as one with the card. Done AFTER
            // the per-animation switch so it always wins over the
            // animation's own face styles.
            if ($frostedGlass) {
                $frontStyle .= $glassRecipe;
                $backStyle  .= $glassRecipe;
            }

            // Outer wrapper. isolation:isolate keeps the inner stacking
            // context contained. Single-card flip — no separate frost
            // wrapper; the glass effect (when frosted_glass=true) is
            // baked into each face's bg and shadow so the whole card
            // rotates as one object.
            //
            // Unique id on the flip-box container so per-device CSS
            // (height / padding overrides) can target it. The admin
            // Height + Padding sliders save _tablet / _mobile keys —
            // wire those into the rendered output via @media so the
            // frontend honours them. Without this the user could
            // drag the Mobile-tab Height slider all day and see no
            // effect (it saved the value but the renderer read only
            // the desktop key). Locked 2026-05-26.
            $_fbId      = 'impulse-fb-' . intval($el['id'] ?? 0);
            $_fbHeightT = intval($settings['height_tablet'] ?? 0);
            $_fbHeightM = intval($settings['height_mobile'] ?? 0);
            $_fbPadT    = isset($settings['padding_tablet']) ? intval($settings['padding_tablet']) : -1;
            $_fbPadM    = isset($settings['padding_mobile']) ? intval($settings['padding_mobile']) : -1;
            $_fbResp    = '';
            if ($_fbHeightT > 0 || $_fbPadT >= 0) {
                $_fbResp .= '@media(max-width:1024px){';
                if ($_fbHeightT > 0) $_fbResp .= '#' . $_fbId . '.impulse-flip-box{height:' . $_fbHeightT . 'px!important}';
                if ($_fbPadT   >= 0) $_fbResp .= '#' . $_fbId . ' .impulse-flip-front,#' . $_fbId . ' .impulse-flip-back{padding:' . $_fbPadT . 'px!important}';
                $_fbResp .= '}';
            }
            if ($_fbHeightM > 0 || $_fbPadM >= 0) {
                $_fbResp .= '@media(max-width:767px){';
                if ($_fbHeightM > 0) $_fbResp .= '#' . $_fbId . '.impulse-flip-box{height:' . $_fbHeightM . 'px!important}';
                if ($_fbPadM   >= 0) $_fbResp .= '#' . $_fbId . ' .impulse-flip-front,#' . $_fbId . ' .impulse-flip-back{padding:' . $_fbPadM . 'px!important}';
                $_fbResp .= '}';
            }
            if ($_fbResp !== '' && function_exists('wp_add_inline_style')) {
                wp_add_inline_style('instinctor-page-inline', $_fbResp);
            }
            echo '<div style="display:flex;justify-content:center;width:100%;margin:0 auto;align-self:center;isolation:isolate">';
            echo '<div id="' . esc_attr($_fbId) . '" class="' . esc_attr($flipClass) . '" style="' . esc_attr($containerStyle) . '" ' . $dataAttrs . '>';
            echo '<div class="impulse-flip-inner" style="' . esc_attr($innerStyle) . '">';
            // text-wrap:pretty avoids the "last word alone on its line"
            // widow. Browser CSS shifts the break point so phrases stay
            // grouped — e.g. "Every form is editable" wraps as
            // "Every form / is editable" instead of "Every form is /
            // editable". Chrome/Edge 117+, Firefox 121+, Safari 17.4+;
            // unsupported browsers silently ignore.
            // impulse_keep_last_words() injects a non-breaking space before
            // the last word of each line so a one-word orphan can never
            // form. text-wrap:pretty is kept as a hint for any browsers
            // that DO honor it on 2-line cases.
            echo '<div class="impulse-flip-front" style="' . esc_attr($frontStyle) . '">';
            // Lucide icon (v2.1.1830) + icon_position top|left (v2.1.1831) —
            // mirror of the shared flip_box.php renderer.
            $_fbFrontIcon = (string) ($settings['front_icon'] ?? '');
            $_fbIconPos   = (string) ($settings['icon_position'] ?? 'top');
            $_fbIcoSvg = '';
            if ($_fbFrontIcon !== '' && function_exists('impulse_lucide_svg')) {
                $_fbIcoSize = intval($settings['front_icon_size'] ?? 24);
                $_fbIcoSvg  = impulse_lucide_svg($_fbFrontIcon, $_fbIcoSize, 'currentColor');
            }
            $_fbTitleHtml = '<div class="impulse-flip-title" style="font-size:' . $frontTitleSize . 'px;font-weight:600;line-height:1.2;text-wrap:pretty;' . ($frontContent ? 'margin-bottom:4px;' : '') . '">' . impulse_kses_richtext(impulse_keep_last_words($frontTitle)) . '</div>';
            $_fbContentHtml = '<div style="font-size:' . $frontContentSize . 'px;line-height:1.3;text-wrap:pretty;opacity:0.7">' . impulse_kses_richtext(impulse_keep_last_words($frontContent)) . '</div>';
            if ($_fbIconPos === 'left' && $_fbIcoSvg !== '') {
                echo '<div class="impulse-flip-iconrow" style="display:flex;flex-direction:row;align-items:center;gap:12px;width:100%">';
                echo   '<div class="impulse-flip-icon" style="flex:0 0 auto;line-height:0;opacity:0.9">' . $_fbIcoSvg . '</div>';
                echo   '<div style="display:flex;flex-direction:column;min-width:0">' . $_fbTitleHtml . $_fbContentHtml . '</div>';
                echo '</div>';
            } else {
                if ($_fbIcoSvg !== '') {
                    echo '<div class="impulse-flip-icon" style="line-height:0;margin-bottom:10px;opacity:0.9">' . $_fbIcoSvg . '</div>';
                }
                echo $_fbTitleHtml . $_fbContentHtml;
            }
            echo '</div>';
            echo '<div class="impulse-flip-back" style="' . esc_attr($backStyle) . '">';
            echo '<div class="impulse-flip-title" style="font-size:' . esc_attr($backTitleSize) . 'px;font-weight:600;text-wrap:pretty;' . ($backContent || $backButtonText ? 'margin-bottom:12px;' : '') . '">' . impulse_kses_richtext(impulse_keep_last_words($backTitle)) . '</div>';
            echo '<div style="font-size:' . esc_attr($backContentSize) . 'px;text-wrap:pretty;">' . impulse_kses_richtext(impulse_keep_last_words($backContent)) . '</div>';
            if ($backButtonText) {
                echo '<a href="' . esc_url($backButtonLink) . '" style="display:inline-block;margin-top:16px;padding:10px 24px;background:rgba(255,255,255,0.2);color:inherit;border-radius:6px;text-decoration:none;">' . esc_html($backButtonText) . '</a>';
            }
            echo '</div></div></div>';
            echo '</div>'; // centering wrapper

            // Glitch keyframes + flip box JS
            // Static CSS moved to frontend.css
            // Premium enhancement: lighting shimmer, shard shatter, swipe-to-flip, gyroscope, proximity tease
            // One-time global CSS for flipboxes — emit once per page.
            // .impulse-flip-detail puts the smaller second line on a
            // distinct z-plane behind the title so the depth engine's
            // existing scroll-time transforms produce natural parallax
            // between the lines (no mouse trick required).
            if (empty($GLOBALS['impulse_flipbox_css_emitted'])) {
                $GLOBALS['impulse_flipbox_css_emitted'] = true;
                wp_add_inline_style('instinctor-page-inline', ''
                   . '.impulse-flip-title{transform-style:preserve-3d}'
                   . '.impulse-flip-detail{display:inline-block;font-weight:400;font-size:13px;opacity:0.85;transform:translateZ(-14px)}'
                   . '');
            }
            /* Mobile: icon_position=left renders a horizontal [icon | text]
               row that's cramped on a phone. On <=767px stack it to a
               centered column (icon above the text), everything centered.
               Shared guard so it emits once whether the page renders flip
               boxes via this inline path or the flip_box.php element renderer
               (Sergei 2026-06-24). */
            if (empty($GLOBALS['impulse_flip_iconrow_css'])) {
                $GLOBALS['impulse_flip_iconrow_css'] = true;
                wp_add_inline_style('instinctor-page-inline', '@media (max-width:767px){'
                   . '.impulse-flip-iconrow{flex-direction:column!important;align-items:center!important;gap:10px!important}'
                   . '.impulse-flip-iconrow>div{align-items:center!important;text-align:center!important}'
                   . '}');
            }
            ob_start();
            echo '(function(){
if(!window._impulseFlipBoxRegistry)window._impulseFlipBoxRegistry=[];
function _enh(box){
    // Idempotent: these scripts are emitted once per flip-box and each run
    // re-scans every box rendered so far, so without this guard box 0 gets
    // enhanced ~N times, box N-1 once. Tap-to-flip is wired ONCE in the main
    // animation script below; a duplicate tap handler here double-toggled
    // _touchFlipped and cancelled the flip on whichever cards happened to
    // accumulate an even handler count (position-dependent — left vs right
    // column on a 2-col mobile grid). Fixed 2026-05-28.
    if(box._fbEnhA)return;box._fbEnhA=1;
    var inner=box.querySelector(".impulse-flip-inner"),front=box.querySelector(".impulse-flip-front"),back=box.querySelector(".impulse-flip-back");
    if(!inner||!front||!back)return;
    window._impulseFlipBoxRegistry.push(box);
}
document.querySelectorAll(".impulse-flip-box").forEach(_enh);
// Global gyroscope
if(!window._impulseGyroInit&&("ontouchstart" in window||navigator.maxTouchPoints>0)){
    window._impulseGyroInit=true;
    function _applyGyro(e){
        var ry=(Math.max(-28,Math.min(28,e.gamma||0))/28)*18,rx=-(Math.max(-20,Math.min(20,(e.beta||45)-45))/20)*12;
        (window._impulseFlipBoxRegistry||[]).forEach(function(fb){
            if(fb._touchFlipped||fb._tiltActive)return;
            var rect=fb.getBoundingClientRect();
            if(rect.bottom<0||rect.top>window.innerHeight)return;
            var fi=fb.querySelector(".impulse-flip-inner");if(!fi)return;
            fi.style.transition="transform 0.15s ease-in-out";fi.style.transform="rotateY("+ry+"deg) rotateX("+rx+"deg)";
        });
    }
    function _startG(){if(typeof DeviceOrientationEvent!=="undefined"){if(typeof DeviceOrientationEvent.requestPermission==="function"){DeviceOrientationEvent.requestPermission().then(function(s){if(s==="granted")window.addEventListener("deviceorientation",_applyGyro)}).catch(function(){})}else{window.addEventListener("deviceorientation",_applyGyro)}}}
    document.addEventListener("touchstart",function _gr(){_startG();document.removeEventListener("touchstart",_gr)},{once:true,passive:true});
}
// Proximity tease (mobile only)
if(!document.getElementById("impulse-tease-css")){var _ts=document.createElement("style");_ts.id="impulse-tease-css";_ts.textContent="@media (pointer:coarse){@keyframes _ift{0%,100%{transform:perspective(600px) rotateY(0deg)}30%{transform:perspective(600px) rotateY(18deg)}60%{transform:perspective(600px) rotateY(-5deg)}}.impulse-flip-box.impulse-tease .impulse-flip-inner{animation:_ift 0.9s cubic-bezier(0.4, 0, 0.2, 1) 1 forwards}}";document.head.appendChild(_ts)}
setTimeout(function(){if(!("ontouchstart" in window)&&navigator.maxTouchPoints<=0)return;document.querySelectorAll(".impulse-flip-box").forEach(function(fb){if(fb._touchFlipped)return;fb.classList.add("impulse-tease");setTimeout(function(){fb.classList.remove("impulse-tease")},1000)})},1200);
})();';
            $_pgJs2 = ob_get_clean();
            wp_add_inline_script('instinctor-frontend-js', $_pgJs2, 'after');
            ob_start();
            echo '(function(){
var boxes=document.querySelectorAll(".impulse-flip-box");
if(!boxes.length)return;
function _rb(back){back.style.transition="none";back.style.transitionDelay="0s";back.style.opacity="0";back.style.zIndex="1";setTimeout(function(){back.style.transition="";back.style.transitionDelay=""},20)}
boxes.forEach(function(b){
if(b._fbEnhB)return;b._fbEnhB=1;
var inner=b.querySelector(".impulse-flip-inner"),front=b.querySelector(".impulse-flip-front"),back=b.querySelector(".impulse-flip-back");
if(!inner||!front||!back)return;
var a=b.dataset.animation||"spring",fd=b.dataset.flipDirection||"horizontal",sd=b.dataset.slideDirection||"left",spd=parseInt(b.dataset.speed)||600,ct=parseFloat(b.dataset.cubeTranslate)||125;

// Shimmer
var shim=null;
// shimmer removed
var shim=null;
function _sh(ms){}

// Shatter, 12 shard tiles cloned from front bg. Resolve shard color
// through the bg-fallback chain (front → back → page #fff) so a
// transparent computedStyle never produces invisible shards.

// Reveal doors
if(a==="reveal"){var _mkD=function(isL){var d=document.createElement("div");d.style.cssText="position:absolute;top:0;bottom:0;overflow:hidden;z-index:4;pointer-events:none;backface-visibility:hidden;width:50%;"+(isL?"left:0;transform-origin:left center;":"left:50%;transform-origin:right center;");var cl=front.cloneNode(true);cl.style.cssText="position:absolute;top:0;bottom:0;width:200%;transform:none!important;opacity:1!important;transition:none!important;clip-path:none!important;z-index:auto;pointer-events:none;inset:auto;height:100%;"+(isL?"left:0":"left:-100%");d.appendChild(cl);inner.appendChild(d);return d};b._revL=_mkD(true);b._revR=_mkD(false);front.style.opacity="0"}

// Cascade
if(a==="cascade"){var _N=7,_cbg=window.getComputedStyle(front).backgroundColor||"#f5f5f5";b._cas=[];var cw=document.createElement("div");cw.style.cssText="position:absolute;inset:0;z-index:4;pointer-events:none;";for(var ci=0;ci<_N;ci++){var cs=document.createElement("div");cs.style.cssText="position:absolute;top:0;bottom:0;overflow:hidden;backface-visibility:hidden;left:"+(ci/_N*100)+"%;width:"+(100/_N)+"%;transform-origin:left center;";var ccl=front.cloneNode(true);ccl.style.cssText="position:absolute;top:0;bottom:0;height:100%;width:"+(_N*100)+"%;left:"+(-ci*100)+"%;transform:none!important;opacity:1!important;transition:none!important;clip-path:none!important;z-index:auto;pointer-events:none;inset:auto;";cs.appendChild(ccl);cw.appendChild(cs);b._cas.push(cs)}inner.appendChild(cw);front.style.opacity="0"}

// Slide strips
if(a==="slide"){var nst=8,isH=(sd==="left"||sd==="right"),spt=100/nst,fbg=window.getComputedStyle(front).backgroundColor||"#f5f5f5";for(var sti=0;sti<nst;sti++){var st=document.createElement("div");st.className="impulse-shutter-strip";var dl=(sd==="right"||sd==="down")?(nst-1-sti)*0.04:sti*0.04;st.style.cssText=isH?"position:absolute;top:0;bottom:0;left:"+(sti*spt)+"%;width:"+spt+"%;background:"+fbg+";z-index:2;transform:scaleX(1);transition:transform 0.4s ease-in-out "+dl+"s,opacity 0.4s ease-in-out "+dl+"s;transform-origin:center center":"position:absolute;left:0;right:0;top:"+(sti*spt)+"%;height:"+spt+"%;background:"+fbg+";z-index:2;transform:scaleY(1);transition:transform 0.4s ease-in-out "+dl+"s,opacity 0.4s ease-in-out "+dl+"s;transform-origin:center center";inner.appendChild(st)}}

// Cursor tracking
var _vx=50,_vy=50;
if(a==="void")b.addEventListener("mousemove",function(e){var r=b.getBoundingClientRect();_vx=((e.clientX-r.left)/r.width*100).toFixed(1);_vy=((e.clientY-r.top)/r.height*100).toFixed(1)});
// Spring, smooth 3D flip on hover. The earlier overshoot curve
// (cubic-bezier(0.4, 0, 0.2, 1) — Y > 1 = bouncy overshoot past
// 180°) read as toy-ish. Replaced with a Material-standard smooth
// ease-in-out on both directions for a premium feel.
//
// 2026-05-30: do NOT return here. The tap-to-flip touchstart and
// touchend handlers are attached at the end of this forEach body
// (line below mouseenter/mouseleave). An early return stranded
// spring boxes without tap handlers; net effect on mobile: spring
// cards silently ignored every tap. Other animations all wire
// their visual reactions in the generic mouseenter/mouseleave
// dispatch below via else-if(a===...) chains with no spring
// branch, so falling through here is a visual no-op for spring.
if(a==="spring"){
  b.addEventListener("mouseenter",function(){
    inner.style.transition="transform "+(spd/1000)+"s cubic-bezier(0.4,0,0.2,1)";
    inner.style.transform=fd==="vertical"?"rotateX(180deg)":"rotateY(180deg)";
    _sh(spd)
  });
  b.addEventListener("mouseleave",function(){
    inner.style.transition="transform "+(spd/1000)+"s cubic-bezier(0.4,0,0.2,1)";
    inner.style.transform=""
  });
}

// mouseenter
b.addEventListener("mouseenter",function(){
if(a==="cube"){inner.style.transform="rotateX(90deg)";_sh(spd)}
else if(a==="fade"){front.style.transition="opacity "+(spd*0.55/1000)+"s ease-in-out";front.style.opacity="0"}
else if(a==="push"){var pm={left:"translateX(-100%)",right:"translateX(100%)",up:"translateY(-100%)",down:"translateY(100%)"};inner.style.transform=pm[sd]||pm.left}
else if(a==="zoom"){front.style.transition="transform "+(spd*0.45/1000)+"s ease-in-out,opacity "+(spd*0.4/1000)+"s ease-in-out";front.style.transform="scale(0)";front.style.opacity="0";back.style.transition="transform "+(spd*0.5/1000)+"s cubic-bezier(0.4, 0, 0.2, 1) "+(spd*0.18/1000)+"s,opacity "+(spd*0.4/1000)+"s ease-in-out "+(spd*0.18/1000)+"s";back.style.transform="scale(1)";back.style.opacity="1";front.style.zIndex="1";back.style.zIndex="2"}
else if(a==="slide"){front.style.opacity="0";front.style.transition="opacity 0.3s ease-in-out";var _ih=(sd==="left"||sd==="right");inner.querySelectorAll(".impulse-shutter-strip").forEach(function(s){s.style.transform=_ih?"scaleX(0)":"scaleY(0)";s.style.opacity="0"});setTimeout(function(){back.style.opacity="1"},spd*0.4)}
else if(a==="morph"){void front.offsetHeight;front.style.clipPath="circle(0% at 50% 50%)"}
else if(a==="glitch"){front.classList.add("impulse-flip-glitching");b._gt=setTimeout(function(){front.style.opacity="0";back.style.opacity="1";front.style.zIndex="1";back.style.zIndex="2"},500)}
else if(a==="peel"){front.style.transform="rotateX(50deg) rotateY(-15deg) scale(0.85)";front.style.opacity="0";setTimeout(function(){back.style.transition="opacity 0.35s ease-in-out";back.style.opacity="1"},spd*0.3)}
else if(a==="stack"){front.style.transition="transform "+(spd*0.38/1000)+"s ease-in-out,opacity "+(spd*0.3/1000)+"s ease-in-out";front.style.transform="translateX(55%) rotate(4deg) scale(0.9)";front.style.opacity="0";back.style.transition="transform "+(spd*0.35/1000)+"s cubic-bezier(0.4, 0, 0.2, 1) "+(spd*0.22/1000)+"s,opacity "+(spd*0.3/1000)+"s ease-in-out "+(spd*0.22/1000)+"s";back.style.opacity="1";back.style.transform="scale(1)";front.style.zIndex="1";back.style.zIndex="2"}
else if(a==="depth-rise"){front.style.transition="transform "+(spd*0.38/1000)+"s cubic-bezier(0.4,0,1,1),opacity "+(spd*0.32/1000)+"s ease-in-out "+(spd*0.08/1000)+"s";front.style.transform="translateZ(420px)";front.style.opacity="0";front.style.zIndex="1";back.style.zIndex="2";setTimeout(function(){back.style.transition="opacity 0.38s ease-in-out";back.style.opacity="1"},spd*0.28)}
else if(a==="void"){var vo=_vx+"% "+_vy+"%";front.style.transformOrigin=vo;back.style.transformOrigin=vo;front.style.transition="transform "+(spd*0.42/1000)+"s cubic-bezier(0.4,0,1,1),opacity "+(spd*0.38/1000)+"s ease-in-out";front.style.transform="scale(0)";front.style.opacity="0";back.style.transition="transform "+(spd*0.5/1000)+"s cubic-bezier(0.4, 0, 0.2, 1) "+(spd*0.25/1000)+"s,opacity "+(spd*0.4/1000)+"s ease-in-out "+(spd*0.25/1000)+"s";back.style.transform="scale(1)";back.style.opacity="1";front.style.zIndex="1";back.style.zIndex="2"}
});

// mouseleave
b.addEventListener("mouseleave",function(){
if(a==="cube"||a==="push"){inner.style.transition="transform 0.5s ease-in-out";inner.style.transform=""}
else if(a==="fade"){front.style.opacity=""}
else if(a==="zoom"){_rb(back);back.style.transform="scale(0.82)";front.style.transition="";front.style.transform="";front.style.opacity="";front.style.zIndex="2"}
else if(a==="slide"){_rb(back);front.style.opacity="";front.style.transition="";var _ih2=(sd==="left"||sd==="right");inner.querySelectorAll(".impulse-shutter-strip").forEach(function(s){s.style.transform=_ih2?"scaleX(1)":"scaleY(1)";s.style.opacity="1"})}
else if(a==="morph"){front.style.transition="none";front.style.clipPath="circle(150% at 50% 50%)";requestAnimationFrame(function(){front.style.transition="clip-path 0.55s ease-in-out"})}
else if(a==="glitch"){if(b._gt)clearTimeout(b._gt);front.classList.remove("impulse-flip-glitching");front.style.opacity="";front.style.zIndex="2";_rb(back)}
else if(a==="peel"){_rb(back);front.style.transform="";front.style.opacity=""}

else if(a==="stack"){_rb(back);back.style.transform="scale(0.92)";front.style.transition="";front.style.transform="";front.style.opacity="";front.style.zIndex="2"}
else if(a==="depth-rise"){_rb(back);front.style.transition="none";front.style.transform="";front.style.opacity="";front.style.zIndex="2"}
else if(a==="void"){_rb(back);back.style.transform="scale(0)";front.style.transformOrigin="";front.style.transition="";front.style.transform="";front.style.opacity="";front.style.zIndex="2"}
});

// Tap-to-flip (see note on the box-level handler above)
(function(){var ts=0,tx=0,ty=0;b.addEventListener("touchstart",function(e){ts=Date.now();tx=e.touches[0].clientX;ty=e.touches[0].clientY},{passive:true});b.addEventListener("touchend",function(e){var dt=Date.now()-ts,dx=e.changedTouches[0].clientX-tx,dy=e.changedTouches[0].clientY-ty,dist=Math.sqrt(dx*dx+dy*dy);if(dist>28||dt>650)return;if(!b._touchFlipped){b._touchFlipped=true;b.dispatchEvent(new MouseEvent("mouseenter",{bubbles:true,cancelable:true}))}else{b._touchFlipped=false;b.dispatchEvent(new MouseEvent("mouseleave",{bubbles:true,cancelable:true}))}})})();
});

// Gyroscope
if(!window._impulseGyroInit&&("ontouchstart" in window||navigator.maxTouchPoints>0)){window._impulseGyroInit=true;function _applyGyro(e){var gx=Math.max(-18,Math.min(18,(e.gamma||0)*0.5)),gy=Math.max(-12,Math.min(12,((e.beta||45)-45)*-0.35));(window._impulseFlipBoxRegistry||[]).forEach(function(fb){if(fb._touchFlipped||fb._tiltActive||fb._flipping)return;var r=fb.getBoundingClientRect();if(r.bottom<0||r.top>window.innerHeight)return;var fi=fb.querySelector(".impulse-flip-inner");if(!fi)return;fi.style.transition="transform 0.15s ease-in-out";fi.style.transform="rotateY("+gx+"deg) rotateX("+gy+"deg)"})}function _startGyro(){if(typeof DeviceOrientationEvent!=="undefined"){if(typeof DeviceOrientationEvent.requestPermission==="function"){DeviceOrientationEvent.requestPermission().then(function(s){if(s==="granted")window.addEventListener("deviceorientation",_applyGyro)}).catch(function(){})}else{window.addEventListener("deviceorientation",_applyGyro)}}}document.addEventListener("touchstart",function _gr(){_startGyro();document.removeEventListener("touchstart",_gr)},{once:true,passive:true})}

// Register for gyro
if(!window._impulseFlipBoxRegistry)window._impulseFlipBoxRegistry=[];
document.querySelectorAll(".impulse-flip-box").forEach(function(b){if(b._fbReg)return;b._fbReg=1;window._impulseFlipBoxRegistry.push(b)});

// Mobile tease
if(!document.getElementById("impulse-flip-tease-css")){var _tc=document.createElement("style");_tc.id="impulse-flip-tease-css";_tc.textContent="@media (pointer:coarse){@keyframes _ift{0%,100%{transform:perspective(600px) rotateY(0deg)}30%{transform:perspective(600px) rotateY(18deg)}60%{transform:perspective(600px) rotateY(-5deg)}}.impulse-flip-box.impulse-tease .impulse-flip-inner{animation:_ift 0.9s cubic-bezier(0.4, 0, 0.2, 1) 1 forwards}}";document.head.appendChild(_tc)}
setTimeout(function(){if(!("ontouchstart" in window)&&navigator.maxTouchPoints<=0)return;document.querySelectorAll(".impulse-flip-box").forEach(function(fb){if(fb._touchFlipped)return;fb.classList.add("impulse-tease");setTimeout(function(){fb.classList.remove("impulse-tease")},1000)})},1200);
})();';
            $_pgJs3 = ob_get_clean();
            wp_add_inline_script('instinctor-frontend-js', $_pgJs3, 'after');

            break;

        case 'product_carousel':
            $GLOBALS['impulse_needs_carousel'] = true; $GLOBALS['impulse_has_carousel'] = true;
            $settings['display_mode'] = 'carousel';
            // Fall through to products
        case 'configurator':
            // Standalone build-a-pizza configurator block. Self-contained: reads
            // its own crust/ingredients/sizes/scene from settings, posts a
            // synthetic cart line (no linked product). See configurator.php.
            echo impulse_element_render_configurator($el, $settings, 'frontend');
            break;

        case 'products':
            global $wpdb;
            $source     = $settings['source'] ?? 'query';
            $queryType  = $settings['query_type'] ?? 'recent';
            $count      = intval($settings['count'] ?? 8);
            $columns    = intval($settings['columns'] ?? 4);
            $gap        = intval($settings['gap'] ?? 10);
            $sectionBg        = $settings['section_bg'] ?? '';
            $sectionPaddingV  = intval($settings['section_padding_v'] ?? 0);
            $sectionPaddingH  = intval($settings['section_padding_h'] ?? 0);
            $sectionMaxWidth  = intval($settings['section_max_width'] ?? 1200);
            $visClass = impulse_get_visibility_classes($settings);
            $displayMode = $settings['display_mode'] ?? 'grid';

            // Menu mode (restaurant-style list) lives only in the shared renderer —
            // this inline path knows grid/carousel/single, so delegate. Without this
            // it falls through to a single card.
            if ($displayMode === 'menu') {
                echo impulse_element_render_products($el, $settings, 'frontend');
                break;
            }

            // Limit when inside slider
            if (isset($settings['slide_index']) || !empty($GLOBALS['impulse_inside_slider'])) {
                $count = min($count, 4);
            }

            // Load card design
            $design = !empty($settings['card_design']) ? $settings['card_design'] : get_option('instinctor_card_design', []);

            // Query products
            $products_table   = $wpdb->prefix . 'instinctor_products';
            $categories_table = $wpdb->prefix . 'instinctor_product_categories';
            $where    = "status = 'published'";
            $orderBy  = "id DESC";
            $prepArgs = array(); // values bound to the %d placeholders in $where / $orderBy

            if ($source === 'manual' && !empty($settings['product_ids'])) {
                $ids   = array_values(array_filter(array_map('intval', explode(',', $settings['product_ids']))));
                $count = count($ids);
                if (!empty($ids)) {
                    $ph       = implode(',', array_fill(0, count($ids), '%d'));
                    $where   .= " AND id IN ($ph)";
                    $orderBy  = "FIELD(id, $ph)";
                    $prepArgs = array_merge($prepArgs, $ids, $ids); // IN(...) then FIELD(...)
                }
            } else {
                switch ($queryType) {
                    case 'newest':     break; // default id DESC — no extra clause
                    case 'featured':   $where .= " AND is_featured = 1"; break;
                    case 'sale':       $where .= " AND sale_price IS NOT NULL AND sale_price > 0 AND sale_price < price"; break;
                    case 'category':
                        if (!empty($settings['category_id'])) {
                            $where     .= " AND id IN (SELECT product_id FROM $categories_table WHERE category_id = %d)";
                            $prepArgs[] = intval($settings['category_id']);
                        }
                        break;
                }
            }

            $prepArgs[] = $count; // LIMIT %d
            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery -- $products_table is built from DB::prefix() (internal table name, not user input); direct query against the plugin's own custom table, request-scoped; LIMIT bound via %d.
            $products = $wpdb->get_results(
                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $products_table is from DB::prefix(); $where/$orderBy are built from whitelisted columns + int-cast ids; the LIMIT value is bound via %d.
                $wpdb->prepare("SELECT * FROM $products_table WHERE $where ORDER BY $orderBy LIMIT %d", ...$prepArgs)
                // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            );

            if (empty($products)) break;

            // ── GRID MODE ───────────────────────────────────────────────────────────
            if ($displayMode === 'grid') {
                $elId      = 'impulse-pgrid-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
                $flipCards = (!empty($settings['flip_card_effect']) && $settings['flip_card_effect'] !== 'none');
                $flipEffect = $settings['flip_card_effect'] ?? 'flip-h';

                if ($flipCards) {
                    // Static CSS moved to frontend.css
                }

                $productCount = count($products);

                // ── Layout: always show full cards, never partial ─────────────
                // Card width is percentage-based so it always fits the container.
                // Max-width slider controls the wrapper. Cards centered via flexbox.

                $maxCardWidth = 380;
                $effectiveCols = $columns;

                // Row logic: no partial second row
                if ($productCount >= $effectiveCols * 2) {
                    $rows = 2;
                    $products = array_slice($products, 0, $effectiveCols * 2);
                } else {
                    $rows = 1;
                    // Show what we have (up to effectiveCols), no trimming for single row
                    if ($productCount > $effectiveCols) {
                        $products = array_slice($products, 0, $effectiveCols);
                    }
                }

                // Two-layer centering. Outer is a stretched flex row that
                // explicitly justifies its inner child to centre — this is
                // immune to the `.impulse-container > *{align-self:stretch}`
                // cascade override (the outer fully stretches by design;
                // its inner child is centred by flex justify-content, not
                // by margin or align-self). The previous one-layer
                // align-self:center + margin:0 auto approach was visibly
                // ignored by Chrome on production; this nested-flex pattern
                // is the bulletproof fallback. Sergei 2026-05-30.
                echo '<div style="display:flex;justify-content:center;width:100%;'
                    . ($sectionBg && $sectionBg !== 'transparent' ? 'background:' . esc_attr($sectionBg) . ';' : '')
                    . '">';
                echo '<div style="width:100%;max-width:' . esc_attr($sectionMaxWidth) . 'px;box-sizing:border-box;'
                    . 'padding:' . esc_attr(max(20, $sectionPaddingV)) . 'px ' . esc_attr(max(20, $sectionPaddingH)) . 'px;">';

                // Grid: flexbox, always centered, always wrap
                $gridStyle = 'display:flex;flex-wrap:wrap;justify-content:center;gap:' . $gap . 'px;';

                // Card width: percentage of container
                $cardW = 'calc((100% - ' . (($effectiveCols - 1) * $gap) . 'px) / ' . $effectiveCols . ')';
                $css  = '#' . $elId . ' > *{flex:0 0 ' . $cardW . ';max-width:' . $cardW . ';box-sizing:border-box;}';
                // Responsive
                $tCols = isset($settings['columns_tablet']) ? max(1, intval($settings['columns_tablet'])) : min($effectiveCols, 2);
                $mCols = isset($settings['columns_mobile']) ? max(1, intval($settings['columns_mobile'])) : 1;
                $tW = 'calc((100% - ' . (($tCols - 1) * $gap) . 'px) / ' . $tCols . ')';
                $mW = 'calc((100% - ' . (($mCols - 1) * $gap) . 'px) / ' . $mCols . ')';
                $css .= '@media(max-width:1024px){#' . $elId . ' > *{flex-basis:' . $tW . '!important;max-width:' . $tW . '!important}}';
                $css .= '@media(max-width:768px){#' . $elId . ' > *{flex-basis:' . $mW . '!important;max-width:' . $mW . '!important}}';
                wp_add_inline_style('instinctor-page-inline', $css);

                $gridClass = 'impulse-product-grid';
                if ($visClass) $gridClass .= ' ' . $visClass;
                if (isset($settings['slide_index']) || !empty($GLOBALS['impulse_inside_slider'])) $gridClass .= ' impulse-grid-in-slider';

                $md = $settings['modal_design'] ?? [];
                $atc = $design['add_to_cart'] ?? [];
                $qvData = wp_json_encode([
                    'show_desc'      => isset($md['show_desc'])      ? (bool)$md['show_desc']      : true,
                    'show_desc_type' => $md['show_desc_type'] ?? 'short',
                    'show_price'     => isset($md['show_price'])      ? (bool)$md['show_price']      : true,
                    'show_qty'       => isset($md['show_qty'])        ? (bool)$md['show_qty']        : true,
                    'max_width'      => intval($md['max_width'] ?? 900),
                    'height'         => intval($md['height'] ?? 90),
                    'btn_bg'         => $atc['bg_color'] ?? '#1d2327',
                    'btn_text'       => $atc['text_color'] ?? '#ffffff',
                    'btn_radius'     => intval($atc['border_radius'] ?? 0),
                    'enable_modal'   => !isset($settings['enable_modal']) || $settings['enable_modal'] === true || $settings['enable_modal'] === '1' || $settings['enable_modal'] === 1,
                    'addons'         => is_array($settings['addons'] ?? null) ? $settings['addons'] : []
                ]);
                $mw = intval($md['max_width'] ?? 900);
                $mh = intval($md['height'] ?? 90);
                $cardShadow = $design['card']['shadow'] ?? 'small';
                $modalShadows = [
                    'none'   => 'none',
                    'small'  => '0 8px 30px rgba(0,0,0,0.15)',
                    'medium' => '0 25px 50px rgba(0,0,0,0.25)',
                    'large'  => '0 40px 80px rgba(0,0,0,0.40)'
                ];
                $modalShadow = $modalShadows[$cardShadow] ?? $modalShadows['small'];
                $cardBg = $design['card']['background'] ?? '#ffffff';
                wp_add_inline_style('instinctor-page-inline', '.impulse-quickview-modal{max-width:' . $mw . 'px!important;max-height:' . $mh . 'vh!important;box-shadow:' . $modalShadow . '!important;}'
                   . '.impulse-quickview-gallery{background:' . esc_attr($cardBg) . '!important;}'
                   . '.impulse-quickview-details{background:' . esc_attr($cardBg) . '!important;}');

                $mobileCols = $mCols;
                echo '<div id="' . esc_attr($elId) . '" class="' . esc_attr($gridClass) . '" style="' . esc_attr($gridStyle) . '" data-cols-mobile="' . esc_attr($mobileCols) . '" data-qv="' . esc_attr($qvData) . '">';
                foreach ($products as $product) {
                    impulse_render_product_card($product, $design, $flipCards, $flipEffect);
                }
                echo '</div>';
                echo '</div>'; // inner wrapper (max-width)
                echo '</div>'; // outer flex centering wrapper

                // 2-row: scale to fit viewport
                if ($rows >= 2) {
                    wp_add_inline_script('instinctor-frontend-js', '(function(){var g=document.getElementById(' . wp_json_encode($elId) . ');if(!g)return;function fit(){g.style.transform="";g.style.marginBottom="";var h=g.scrollHeight,max=window.innerHeight-40;if(h>max){var s=max/h;g.style.transformOrigin="top center";g.style.transform="scale("+s.toFixed(4)+")";g.style.marginBottom="-"+(h*(1-s)).toFixed(0)+"px"}}fit();window.addEventListener("resize",fit)})();', 'after');
                }

            // ── CAROUSEL MODE ────────────────────────────────────────────────────────
            } elseif ($displayMode === 'carousel') {
                $carouselMode       = $settings['carousel_mode'] ?? 'snap';
                $visibleCount       = intval($settings['carousel_cards'] ?? $settings['columns'] ?? 4);
                $visibleCountTablet = intval($settings['columns_tablet'] ?? 2);
                $visibleCountMobile = intval($settings['columns_mobile'] ?? 1);
                $gapTablet          = intval($settings['gap_tablet'] ?? $gap);
                $gapMobile          = intval($settings['gap_mobile'] ?? $gap);
                $transitionSpeed    = intval($settings['transition_speed'] ?? 400);
                $scrollSpeed        = intval($settings['scroll_speed'] ?? 40);
                $autoplay           = !empty($settings['autoplay']);
                $autoplaySpeed      = intval($settings['autoplay_speed'] ?? 3000);
                if ($autoplaySpeed <= 100) $autoplaySpeed *= 1000; // stored as seconds, convert to ms
                $pauseHover         = !empty($settings['pause_hover']);
                $flipCards          = ($carouselMode !== 'scroll') && (!empty($settings['flip_card_effect']) && $settings['flip_card_effect'] !== 'none');
                $flipEffect         = $settings['flip_card_effect'] ?? 'flip-h';

                $elId        = 'impulse-pcarousel-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
                $slideWidth  = 'calc(' . (100 / $visibleCount) . '% - ' . ($gap * ($visibleCount - 1) / $visibleCount) . 'px)';
                $trackOverflow  = $carouselMode === 'scroll' ? 'hidden' : 'visible';
                $trackTransition = $carouselMode === 'scroll' ? 'none' : 'transform ' . $transitionSpeed . 'ms ease-in-out';

                $containerStyle = 'position:relative;overflow:hidden;padding-top:40px;padding-bottom:40px;margin-top:-40px;margin-bottom:-40px;';
                if ($sectionBg && $sectionBg !== 'transparent') $containerStyle .= 'background:' . esc_attr($sectionBg) . ';';
                if ($sectionPaddingV > 0 || $sectionPaddingH > 0) $containerStyle .= 'padding:' . $sectionPaddingV . 'px ' . $sectionPaddingH . 'px;';
                if ($sectionMaxWidth > 0) {
                    $containerStyle .= 'max-width:' . $sectionMaxWidth . 'px;';
                    $containerStyle .= 'margin-left:auto;margin-right:auto;';
                }

                if ($flipCards) {
                    // Static CSS moved to frontend.css
                }

                $carouselClass = 'impulse-carousel impulse-product-carousel';
                if ($visClass) $carouselClass .= ' ' . $visClass;

                // Responsive slide widths
                $twTablet = 'calc(' . (100 / $visibleCountTablet) . '% - ' . ($gapTablet * ($visibleCountTablet - 1) / $visibleCountTablet) . 'px)';
                $twMobile = 'calc(' . (100 / $visibleCountMobile) . '% - ' . ($gapMobile * ($visibleCountMobile - 1) / $visibleCountMobile) . 'px)';
                wp_add_inline_style('instinctor-page-inline', '@media(max-width:1024px){#' . $elId . ' .impulse-carousel-slides{gap:' . $gapTablet . 'px!important}#' . $elId . ' .impulse-carousel-slide{flex:0 0 ' . $twTablet . '!important}}'
                    . '@media(max-width:768px){#' . $elId . ' .impulse-carousel-slides{gap:' . $gapMobile . 'px!important}#' . $elId . ' .impulse-carousel-slide{flex:0 0 ' . $twMobile . '!important}}');

                $md2 = $settings['modal_design'] ?? [];
                $qvData2 = wp_json_encode([
                    'show_desc'      => isset($md2['show_desc'])      ? (bool)$md2['show_desc']      : true,
                    'show_desc_type' => $md2['show_desc_type'] ?? 'short',
                    'show_sku'       => isset($md2['show_sku'])        ? (bool)$md2['show_sku']        : true,
                    'show_price'     => isset($md2['show_price'])      ? (bool)$md2['show_price']      : true,
                    'show_qty'       => isset($md2['show_qty'])        ? (bool)$md2['show_qty']        : true,
                    'enable_modal'   => !isset($settings['enable_modal']) || $settings['enable_modal'] === true || $settings['enable_modal'] === '1' || $settings['enable_modal'] === 1,
                    'addons'         => is_array($settings['addons'] ?? null) ? $settings['addons'] : []
                ]);
                echo '<div class="impulse-pblock-outer" style="width:100%">';
                echo '<div id="' . esc_attr($elId) . '" class="' . esc_attr($carouselClass) . '"'
                    . ' data-qv="' . esc_attr($qvData2) . '"'
                    . ' data-autoplay="' . ($autoplay ? 'true' : 'false') . '"'
                    . ' data-speed="' . esc_attr($autoplaySpeed) . '"'
                    . ' data-pause="' . ($pauseHover ? 'true' : 'false') . '"'
                    . ' data-slides-per-view="' . esc_attr($visibleCount) . '"'
                    . ' data-slides-per-view-tablet="' . esc_attr($visibleCountTablet) . '"'
                    . ' data-slides-per-view-mobile="' . esc_attr($visibleCountMobile) . '"'
                    . ' data-gap="' . esc_attr($gap) . '"'
                    . ' data-transition="' . esc_attr($transitionSpeed) . '"'
                    . ' data-mode="' . esc_attr($carouselMode) . '"'
                    . ' data-scroll-speed="' . esc_attr($scrollSpeed) . '"'
                    . ' style="' . esc_attr($containerStyle) . '">';
                echo '<div class="impulse-carousel-track" style="overflow:' . esc_attr($trackOverflow) . '">';
                echo '<div class="impulse-carousel-slides" style="display:flex;gap:' . esc_attr($gap) . 'px;transition:' . esc_attr($trackTransition) . '">';

                if ($carouselMode !== 'scroll') {
                    foreach ($products as $product) {
                        echo '<div class="impulse-carousel-slide" style="flex:0 0 ' . esc_attr($slideWidth) . ';min-width:0">';
                        impulse_render_product_card($product, $design, $flipCards, $flipEffect);
                        echo '</div>';
                    }
                } else {
                    // Scroll: initial pass + clone pass for infinite marquee
                    foreach ($products as $product) {
                        echo '<div class="impulse-carousel-slide" style="flex:0 0 ' . esc_attr($slideWidth) . ';min-width:0">';
                        impulse_render_product_card($product, $design, false);
                        echo '</div>';
                    }
                    foreach ($products as $product) {
                        echo '<div class="impulse-carousel-slide impulse-clone" style="flex:0 0 ' . esc_attr($slideWidth) . ';min-width:0">';
                        impulse_render_product_card($product, $design, false);
                        echo '</div>';
                    }
                }

                echo '</div></div></div>';
                echo '</div>'; // .impulse-pblock-outer

            // ── SINGLE MODE ──────────────────────────────────────────────────────────
            } else {
                // Single mode: one centered card, 250px wide (same as grid default card width)
                $cardWidth = max(250, min(500, intval($settings['card_max_width'] ?? 250)));
                $wrapStyle = 'display:flex;justify-content:center;';
                if ($sectionBg && $sectionBg !== 'transparent') $wrapStyle .= 'background:' . esc_attr($sectionBg) . ';';
                if ($sectionPaddingV > 0 || $sectionPaddingH > 0) $wrapStyle .= 'padding:' . $sectionPaddingV . 'px ' . $sectionPaddingH . 'px;';
                if ($visClass) $wrapStyle .= ''; // applied to wrapper div
                if ($sectionMaxWidth > 0) $wrapStyle .= 'max-width:' . $sectionMaxWidth . 'px;margin-left:auto;margin-right:auto;';

                $elId = 'impulse-psingle-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
                echo '<div class="impulse-pblock-outer" style="width:100%">';
                echo '<div id="' . esc_attr($elId) . '" style="' . esc_attr($wrapStyle) . '">';
                echo '<div style="width:' . esc_attr($cardWidth) . 'px;max-width:100%">';
                impulse_render_product_card($products[0], $design, false);
                echo '</div></div>';
                echo '</div>'; // .impulse-pblock-outer
            }

            break;

        case 'bundle':
            $bundleId     = intval($settings['bundle_id'] ?? 0);
            $layout       = 'stack';
            $showSavings  = !isset($settings['show_savings']) || filter_var($settings['show_savings'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
            $showImages   = !isset($settings['show_images'])  || filter_var($settings['show_images'],  FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
            $showPrices   = !isset($settings['show_prices'])  || filter_var($settings['show_prices'],  FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
            $btnLabel     = $settings['btn_label'] ?? (function_exists('instinctor__') ? instinctor__('Add Bundle to Cart') : 'Add Bundle to Cart');
            $accentColor  = esc_attr($settings['accent_color'] ?? '#1d2327');
            $buttonColor  = esc_attr($settings['button_color'] ?? '#1d2327');
            $savingsColor = esc_attr($settings['savings_color'] ?? '#999999');
            $fieldColor   = esc_attr($settings['field_color'] ?? '#f5f5f5');
            $showTitle    = !isset($settings['show_title']) || filter_var($settings['show_title'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
            $fieldText    = impulse_contrast_color($fieldColor);
            $fieldTextSub = ($fieldText === '#000000') ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.5)';
            $bundleWidthPx = intval($settings['bundle_width'] ?? 400);
            if ($bundleWidthPx < 340) $bundleWidthPx = 340;
            if ($bundleWidthPx > 600) $bundleWidthPx = 600;
            $buttonWidth  = intval($settings['button_width'] ?? 0);
            $fieldRadius  = intval($settings['field_radius'] ?? 6);
            $buttonRadius = intval($settings['button_radius'] ?? 6);
            $elId = 'pb-' . esc_attr($el['id'] ?? uniqid());

            // Load bundle data
            $bundle   = $bundleId ? \Instinctor\Services\ProductBundleService::getBundle($bundleId) : null;
            $products = [];
            if ($bundle && !empty($bundle['products'])) {
                $pt = \Instinctor\Core\DB::prefix() . 'instinctor_products';
                foreach (array_map('intval', $bundle['products']) as $pid) {
                    $p = \Instinctor\Core\DB::get_row(
                        \Instinctor\Core\DB::prepare("SELECT id, name, price, sale_price, images, gallery_images, featured_image FROM {$pt} WHERE id = %d", $pid),
                        ARRAY_A
                    );
                    if ($p) {
                        $imgs = impulse_get_product_images((object)$p);
                        $p['image_url'] = $imgs[0] ?? '';
                        $reg  = floatval($p['price']);
                        $sale = floatval($p['sale_price']);
                        $p['effective_price'] = $reg > 0 ? $reg : ($sale > 0 ? $sale : 0);
                        $products[] = $p;
                    }
                }
            }
            $pricing = $bundleId ? \Instinctor\Services\ProductBundleService::calculateBundlePrice($bundleId) : ['original' => 0, 'discounted' => 0, 'savings' => 0];
            $isBuildOwn = $bundle && $bundle['bundle_type'] === 'build_your_own';

            // Layout CSS
            $wrapStyle = $layout === 'grid'
                ? 'display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px;'
                : 'display:flex;flex-direction:column;gap:8px;';
            $bundleWidthStyle = 'max-width:' . $bundleWidthPx . 'px;width:100%;margin:0 auto;';

            echo '<div id="' . esc_attr($elId) . '" class="impulse-product-bundle" style="display:block;width:100%;box-sizing:border-box;font-family:inherit;padding:12px">';

            if (!$bundle || empty($products)) {
                echo '<div style="padding:24px;text-align:center;color:#999;font-size:13px">' . esc_html((function_exists('instinctor__') ? instinctor__('No bundle selected.') : 'No bundle selected.')) . '</div>';
                echo '</div>';
                break;
            }

            // Inner constrained container
            echo '<div style="' . esc_attr($bundleWidthStyle) . '">';

            // Bundle name + savings badge
            echo '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap">';
            if ($showTitle) {
                echo '<div class="impulse-bundle-title" style="font-size:17px;font-weight:600;color:inherit">' . esc_html((string)($bundle['name'] ?? '')) . '</div>';
            }
            if ($showSavings && $pricing['savings'] > 0) {
                $savingsLabel = $bundle['discount_type'] === 'percent'
                    ? sprintf((function_exists('instinctor__') ? instinctor__('Save %s%%') : 'Save %s%%'), floatval($bundle['discount_value']))
                    : sprintf((function_exists('instinctor__') ? instinctor__('Save %s') : 'Save %s'), \Instinctor\Services\CurrencyService::formatPrice($pricing['savings']));
                echo '<span class="impulse-bundle-savings-badge" style="background:' . esc_attr($savingsColor) . ';color:#fff;font-size:11px;font-weight:600;padding:3px 8px;border-radius:20px;white-space:nowrap">' . esc_html($savingsLabel) . '</span>';
            }
            echo '</div>';

            // Products grid
            echo '<div class="impulse-bundle-products" style="' . esc_attr($wrapStyle) . 'margin-bottom:16px">';
            foreach ($products as $p) {
                $frStyle  = 'border-radius:' . $fieldRadius . 'px;';
                $itemStyle = $layout === 'stack'
                    ? 'display:flex;align-items:center;gap:10px;padding:8px;background:' . $fieldColor . ';color:' . $fieldText . ';' . $frStyle
                    : 'background:' . $fieldColor . ';color:' . $fieldText . ';overflow:hidden;' . $frStyle;
                $checked = !$isBuildOwn ? '' : ' checked';
                echo '<div class="impulse-bundle-item' . ($isBuildOwn ? ' impulse-bundle-selectable' : '') . '" data-id="' . intval($p['id']) . '" style="' . esc_attr($itemStyle) . '">';

                if ($isBuildOwn) {
                    echo '<input type="checkbox" class="impulse-bundle-check" data-id="' . intval($p['id']) . '"' . esc_attr($checked) . ' style="display:none">';
                }

                if ($layout === 'stack') {
                    // Stack: text left, image right
                    echo '<div style="flex:1;min-width:0">';
                    echo '<div style="font-size:13px;font-weight:500;color:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' . esc_html((string)($p['name'] ?? '')) . '</div>';
                    if ($showPrices && $p['effective_price'] > 0) {
                        echo '<div style="font-size:12px;color:' . esc_attr($fieldTextSub) . ';margin-top:2px">' . esc_html(\Instinctor\Services\CurrencyService::formatPrice((float)($p['effective_price'] ?? 0))) . '</div>';
                    }
                    echo '</div>';
                    if ($showImages && $p['image_url']) {
                        echo '<img src="' . esc_url((string)($p['image_url'] ?? '')) . '" alt="' . esc_attr((string)($p['name'] ?? '')) . '" loading="lazy" style="width:52px;height:52px;object-fit:cover;border-radius:4px;flex-shrink:0">';
                    }
                    if ($isBuildOwn) {
                        echo '<div class="impulse-bundle-check-icon" style="width:20px;height:20px;border-radius:50%;border:2px solid #ccc;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background 0.15s,border-color 0.15s">';
                        echo '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>';
                        echo '</div>';
                    }
                } else {
                    // Grid: image top, text below
                    if ($showImages && $p['image_url']) {
                        echo '<img src="' . esc_url((string)($p['image_url'] ?? '')) . '" alt="' . esc_attr((string)($p['name'] ?? '')) . '" loading="lazy" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:4px">';
                    }
                    echo '<div style="padding:8px">';
                    echo '<div style="font-size:13px;font-weight:500;color:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' . esc_html((string)($p['name'] ?? '')) . '</div>';
                    if ($showPrices && $p['effective_price'] > 0) {
                        echo '<div style="font-size:12px;color:' . esc_attr($fieldTextSub) . ';margin-top:2px">' . esc_html(\Instinctor\Services\CurrencyService::formatPrice((float)($p['effective_price'] ?? 0))) . '</div>';
                    }
                    echo '</div>';
                    if ($isBuildOwn) {
                        echo '<div class="impulse-bundle-check-icon" style="width:20px;height:20px;border-radius:50%;border:2px solid #ccc;display:flex;align-items:center;justify-content:center;margin:8px;transition:background 0.15s,border-color 0.15s">';
                        echo '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>';
                        echo '</div>';
                    }
                }

                echo '</div>';
            }
            echo '</div>';

            // Price + CTA in one row
            $minSel = intval($bundle['min_products'] ?? 0);
            $accentText = impulse_contrast_color($buttonColor);
            echo '<div class="impulse-bundle-footer" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">';
            if ($pricing['original'] > 0) {
                echo '<div style="display:flex;align-items:baseline;gap:8px;flex:1;min-width:0">';
                if ($pricing['savings'] > 0) {
                    echo '<span class="impulse-bundle-price" style="font-size:22px;font-weight:700;color:' . esc_attr($accentColor) . '">' . esc_html(\Instinctor\Services\CurrencyService::formatPrice((float)($pricing['discounted'] ?? 0))) . '</span>';
                    echo '<span class="impulse-bundle-original" style="font-size:14px;opacity:0.45;text-decoration:line-through">' . esc_html(\Instinctor\Services\CurrencyService::formatPrice((float)($pricing['original'] ?? 0))) . '</span>';
                } else {
                    echo '<span class="impulse-bundle-price" style="font-size:22px;font-weight:700;color:' . esc_attr($accentColor) . '">' . esc_html(\Instinctor\Services\CurrencyService::formatPrice((float)($pricing['original'] ?? 0))) . '</span>';
                }
                echo '</div>';
            } else {
                echo '<div style="flex:1"></div>';
            }
            echo '<button type="button" class="impulse-bundle-cta impulse-bundle-add-btn" data-bundle-id="' . intval($bundleId) . '" data-build-own="' . ($isBuildOwn ? '1' : '0') . '" data-min="' . esc_attr($minSel) . '" style="display:inline-flex;align-items:center;justify-content:center;padding:0 20px;height:40px;background:' . esc_attr($buttonColor) . ';color:' . esc_attr($accentText) . ';border:none;border-radius:' . esc_attr($buttonRadius) . 'px;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:opacity 0.15s">';
            echo esc_html($btnLabel);
            echo '</button>';
            echo '</div>';

            // Inline JS for this bundle element
            $frontendNonce = wp_create_nonce('instinctor_frontend');
            $ajaxUrl = esc_js(admin_url('admin-ajax.php'));
            $cartUrl = esc_js(\Instinctor\Services\PageService::resolveUrl('cart'));
            echo '<div class="impulse-bundle-msg" style="display:none;margin-top:10px;font-size:12px;text-align:center"></div>';
            echo '</div>'; // inner constrained container

            // Output bundle UI script inline — works in both WP and standalone mode
            // (wp_add_inline_script requires wp_footer() which standalone does not call)
            static $bundleUiPrinted = false;
            if (!$bundleUiPrinted) {
                $bundleUiPrinted = true;
                ob_start();
                echo '(function(){';
                echo 'function initBundleUI(){';
                // Select/deselect for build-your-own
                echo 'document.addEventListener("click",function(e){';
                echo '  var item=e.target.closest(".impulse-bundle-selectable");';
                echo '  if(!item)return;';
                echo '  var cb=item.querySelector(".impulse-bundle-check");';
                echo '  if(!cb)return;';
                echo '  cb.checked=!cb.checked;';
                echo '  var icon=item.querySelector(".impulse-bundle-check-icon");';
                echo '  if(icon){';
                echo '    icon.style.background=cb.checked?"' . esc_js($accentColor) . '":"";';
                echo '    icon.style.borderColor=cb.checked?"' . esc_js($accentColor) . '":"#ccc";';
                echo '  }';
                echo '  item.style.outline=cb.checked?"2px solid ' . esc_js($accentColor) . '":"";';
                echo '});';
                // Add bundle to cart
                echo 'document.addEventListener("click",function(e){';
                echo '  var btn=e.target.closest(".impulse-bundle-cta");';
                echo '  if(!btn)return;';
                echo '  var bundleId=btn.dataset.bundleId;';
                echo '  var isBuildOwn=btn.dataset.buildOwn==="1";';
                echo '  var minSel=parseInt(btn.dataset.min)||0;';
                echo '  var wrap=btn.closest(".impulse-product-bundle");';
                echo '  var msg=wrap?wrap.querySelector(".impulse-bundle-msg"):null;';
                echo '  var svgIcon=btn.querySelector("svg")?btn.querySelector("svg").outerHTML:"";';
                echo '  var origText=btn.textContent.trim();';
                echo '  var selectedProducts=[];';
                echo '  if(isBuildOwn){';
                echo '    wrap.querySelectorAll(".impulse-bundle-check:checked").forEach(function(cb){selectedProducts.push(parseInt(cb.dataset.id));});';
                echo '    if(minSel>0&&selectedProducts.length<minSel){';
                echo '      if(msg){msg.textContent="Please select at least "+minSel+" product"+(minSel!==1?"s":"")+".";msg.style.display="block";msg.style.color="#c00";}';
                echo '      return;';
                echo '    }';
                echo '  }';
                echo '  btn.disabled=true;btn.style.opacity="0.6";';
                echo '  btn.style.width=btn.offsetWidth+"px";';
                echo '  var fd=new FormData();';
                echo '  fd.append("action","instinctor_add_bundle_to_cart");';
                echo '  fd.append("nonce","' . esc_js($frontendNonce) . '");';
                echo '  fd.append("bundle_id",bundleId);';
                echo '  selectedProducts.forEach(function(id){fd.append("selected_products[]",id);});';
                echo '  fetch("' . esc_js($ajaxUrl) . '",{method:"POST",body:fd})';
                echo '    .then(function(r){return r.json();})';
                echo '    .then(function(d){';
                echo '      if(d.success){';
                echo '        btn.textContent="Added!";';
                echo '        if(msg){msg.textContent=(d.data&&d.data.message)||"Bundle added to cart!";msg.style.display="block";msg.style.color="' . esc_js($savingsColor) . '";}';
                echo '        var badge=document.querySelector(".impulse-cart-count,.instinctor-cart-count");';
                echo '        if(badge&&d.data.cart_count!==undefined)badge.textContent=d.data.cart_count;';
                echo '        setTimeout(function(){btn.disabled=false;btn.style.opacity="";btn.style.width="";btn.innerHTML=svgIcon+" "+origText;},2000);';
                echo '      }else{';
                echo '        btn.disabled=false;btn.style.opacity="";btn.style.width="";';
                echo '        if(msg){msg.textContent=(d.data&&d.data.message)||"Failed to add bundle.";msg.style.display="block";msg.style.color="#c00";}';
                echo '      }';
                echo '    })';
                echo '    .catch(function(){';
                echo '      btn.disabled=false;btn.style.opacity="";btn.style.width="";';
                echo '      if(msg){msg.textContent="Network error. Please try again.";msg.style.display="block";msg.style.color="#c00";}';
                echo '    });';
                echo '});';
                echo '}';
                echo 'if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",initBundleUI);}else{initBundleUI();}';
                echo '})();';
                $_pgJs4 = ob_get_clean();
                wp_add_inline_script('instinctor-frontend-js', $_pgJs4, 'after');
            }

            echo '</div>'; // .impulse-product-bundle
            break;


        case 'plans':
            $plans = $settings['plans'] ?? [];
            if (is_string($plans)) $plans = json_decode($plans, true) ?: [];
            if (empty($plans)) {
                $plans = [
                    ['title' => 'Basic', 'subtitle' => 'For individuals', 'price' => '19', 'currency' => '$', 'period' => '/month', 'features' => [['text'=>'5 Projects','status'=>'included'],['text'=>'10GB Storage','status'=>'included'],['text'=>'Email Support','status'=>'included'],['text'=>'Priority Support','status'=>'excluded']], 'button_text' => 'Get Started', 'button_link' => '#', 'ribbon' => 'none'],
                    ['title' => 'Pro', 'subtitle' => 'For small teams', 'price' => '49', 'currency' => '$', 'period' => '/month', 'features' => [['text'=>'Unlimited Projects','status'=>'included'],['text'=>'100GB Storage','status'=>'included'],['text'=>'Priority Support','status'=>'included'],['text'=>'API Access','status'=>'included']], 'button_text' => 'Get Started', 'button_link' => '#', 'ribbon' => 'popular', 'highlighted' => true],
                    ['title' => 'Enterprise', 'subtitle' => 'For large teams', 'price' => '99', 'currency' => '$', 'period' => '/month', 'features' => [['text'=>'Everything in Pro','status'=>'included'],['text'=>'Unlimited Storage','status'=>'included'],['text'=>'Dedicated Support','status'=>'included'],['text'=>'Custom Integrations','status'=>'included']], 'button_text' => 'Contact Sales', 'button_link' => '#', 'ribbon' => 'none']
                ];
            }
            
            $showToggle = !empty($settings['show_toggle']);
            $yearlyLabel = $settings['yearly_label'] ?? 'Save 20%';
            $ribbonStyle = $settings['ribbon_style'] ?? 'banner';
            $hoverEffect = $settings['hover_effect'] ?? 'none';
            // 3 explicit card colors
            $bgColor = $settings['bg_color'] ?? '#ffffff';
            $textColor = $settings['text_color'] ?? '#666666';
            $buttonBg = $settings['button_bg'] ?? '#1d2327';
            // Always derived
            $borderColor = impulse_derive_border($bgColor);
            $titleColor = impulse_contrast_color($bgColor);
            $toggleLabelColor = $titleColor;
            $buttonColor = impulse_contrast_color($buttonBg);
            $checkColor = $textColor;
            $crossColor = $textColor;
            $checkIcon = 'check-square';
            $crossIcon = 'x';
            $ribbonBg = $buttonBg;
            // 3 explicit highlighted card colors
            $highlightBg = $settings['highlight_bg'] ?? '#1d2327';
            $highlightColor = $settings['highlight_color'] ?? '#ffffff';
            $highlightButtonBg = $settings['highlight_button_bg'] ?? '#ffffff';
            $highlightBorder = $highlightBg;
            $highlightButtonColor = impulse_contrast_color($highlightButtonBg);
            $highlightCheckColor = $highlightColor;
            $highlightCrossColor = $highlightColor;
            $highlightRibbonBg = $highlightButtonBg;
            $priceColor = $titleColor;
            $ribbonColor = impulse_contrast_color($ribbonBg);
            $highlightRibbonColor = impulse_contrast_color($highlightRibbonBg);
            $borderRadius = intval(impulse_get_responsive($settings, 'border_radius', 4));
            $priceSize = intval($settings['price_size'] ?? 38);
            $btnFontSize = intval($settings['btn_font_size'] ?? 14);
            $btnPaddingV = intval($settings['btn_padding_v'] ?? 14);
            $sectionMaxWidth = intval($settings['section_max_width'] ?? 1200);
            $visClass = impulse_get_visibility_classes($settings);
            
            $uniqueId = 'pricing-' . $el['id'];
            
            // Hover CSS - 4 effects matching preview
            if ($hoverEffect !== 'none') {
                $uid = $uniqueId;
                ob_start();
                echo '#' . esc_attr($uid) . ' .impulse-pricing-card { transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out; position: relative; }';
                if ($hoverEffect === 'lift') {
                    echo '#' . esc_attr($uid) . ' .impulse-pricing-card:hover { transform: translateY(-6px); box-shadow: 0 12px 32px rgba(0,0,0,0.12); }';
                } elseif ($hoverEffect === 'tilt') {
                    echo '#' . esc_attr($uid) . ' .impulse-pricing-card { transition: transform 0.4s ease-in-out, box-shadow 0.4s ease-in-out; transform-style: preserve-3d; }';
                    echo '#' . esc_attr($uid) . ' .impulse-pricing-card:hover { transform: perspective(800px) rotateX(-4deg) rotateY(2deg) translateY(-4px); box-shadow: 8px 16px 32px rgba(0,0,0,0.12); }';
                } elseif ($hoverEffect === 'glow') {
                    $glowColor = $buttonBg;
                    echo '#' . esc_attr($uid) . ' .impulse-pricing-card:hover { box-shadow: 0 8px 40px ' . esc_attr($glowColor) . '40, 0 0 80px ' . esc_attr($glowColor) . '1a; }';
                } elseif ($hoverEffect === 'float') {
                    echo '@keyframes impulseFloat' . esc_attr($uid) . ' { 0%,100% { transform: translateY(-4px); } 50% { transform: translateY(-10px); } }';
                    echo '#' . esc_attr($uid) . ' .impulse-pricing-card:hover { animation: impulseFloat' . esc_attr($uid) . ' 2s ease-in-out infinite; box-shadow: 0 16px 40px rgba(0,0,0,0.1); }';
                }
                echo '#' . esc_attr($uid) . ' .impulse-pricing-button { transition: filter 0.2s ease-in-out; }';
                echo '#' . esc_attr($uid) . ' .impulse-pricing-button:hover { filter: brightness(0.9); }';
                $_inst_css8 = ob_get_clean(); wp_add_inline_style("instinctor-page-inline", $_inst_css8);
            }
            
            // Plans block always centres horizontally within its parent — the grid of
            // cards is a hero visual and looks broken when left-aligned inside a section
            // that has extra horizontal room (e.g. a screen-mode root at max-width 1200
            // inside a viewport wider than 1200). Previously this only fired for top-level
            // plans (parentId empty), leaving nested plans left-aligned.
            $wrapperStyle = 'width:100%;margin-left:auto;margin-right:auto;';
            if ($sectionMaxWidth > 0) $wrapperStyle .= 'max-width:' . $sectionMaxWidth . 'px;';
            
            // Entrance animation
            $animAttrs = impulse_get_animation_attrs($settings);
            
            $wrapClass = 'impulse-pricing-wrapper';
            if ($visClass) $wrapClass .= ' ' . $visClass;
            if ($animAttrs['class']) $wrapClass .= ' ' . $animAttrs['class'];
            if ($animAttrs['style']) $wrapperStyle .= $animAttrs['style'];
            // Arc on mobile is OFF by default — rotated cards crash into the
            // phone's side rail and fight the 2-column mobile layout. Opt back
            // in with arc_mobile=true; the @media block below keys off this attr.
            $arcMobile = !empty($settings['arc_mobile']);
            $mobileArcAttr = $arcMobile ? ' data-mobile-arc="1"' : '';
            echo '<div class="' . esc_attr($wrapClass) . '" id="' . esc_attr($uniqueId) . '"' . $mobileArcAttr . ' style="' . esc_attr($wrapperStyle) . '">';
            
            // Monthly/Yearly Toggle
            if ($showToggle) {
                echo '<div class="impulse-pricing-toggle" style="display:flex;justify-content:center;gap:8px;margin-bottom:32px;align-items:center">';
                echo '<span class="impulse-toggle-label impulse-toggle-monthly" style="font-size:14px;font-weight:500;color:' . esc_attr($toggleLabelColor) . '">Monthly</span>';
                echo '<label style="position:relative;display:inline-block;width:50px;height:26px;cursor:pointer">';
                echo '<input type="checkbox" class="impulse-billing-toggle" style="opacity:0;width:0;height:0">';
                echo '<span style="position:absolute;inset:0;background:#ddd;border-radius:26px;transition:0.3s"></span>';
                echo '<span class="impulse-toggle-knob" style="position:absolute;left:3px;top:3px;width:20px;height:20px;background:#fff;border-radius:50%;transition:0.3s;box-shadow:0 1px 3px rgba(0,0,0,0.2)"></span>';
                echo '</label>';
                echo '<span class="impulse-toggle-label impulse-toggle-yearly" style="font-size:14px;font-weight:500;color:' . esc_attr($toggleLabelColor) . ';opacity:0.5">Yearly</span>';
                if ($yearlyLabel) echo '<span style="background:' . esc_attr($ribbonBg) . ';color:' . esc_attr($ribbonColor) . ';font-size:11px;padding:4px 8px;border-radius:4px;font-weight:500">' . esc_html($yearlyLabel) . '</span>';
                echo '</div>';
            }
            
            // Fixed column count so max-width controls card size, not wrapping
            $planCount = count($plans);
            $planCols = max(1, min($planCount, 4));
            // Arc tilt — 0..100 maps to 0..25° max rotateY on the outermost card.
            // Each card's angle is scaled by its offset from centre so the fan is symmetric.
            $arcIntensity = max(0, min(100, intval($settings['arc_intensity'] ?? 0)));
            $arcActive = $arcIntensity > 0 && $planCount > 1;
            $arcMaxAngle = 25.0;
            $arcAngleScale = $arcIntensity / 100.0;
            $arcCenterIdx = ($planCount - 1) / 2.0;
            $gridClass = 'impulse-pricing-grid' . ($arcActive ? ' impulse-pricing-arc' : '');
            $gridStyle = 'display:grid;grid-template-columns:repeat(' . $planCols . ',1fr);gap:24px;align-items:stretch;';
            if ($arcActive) {
                // `perspective-origin:center top` places the vanishing point on the grid's
                // top edge so rotated card tops project at Y=0 (aligned) regardless of Z.
                $gridStyle .= 'perspective:1200px;perspective-origin:center top;transform-style:preserve-3d;';
            }
            // Arc CSS — rules scoped by `.impulse-pricing-arc` class (applied only when
            // arc_intensity > 0). The filter (blur) is driven inline by the JS so the
            // centre card (arc-base 0) never gets blurred; CSS only sets up transition.
            // Mobile: vertical column with an arc that tilts around the X axis
            // instead of Y. Full-width cards, typography intact, icons intact.
            // Gyro drives the parallax on phones (DeviceOrientation), cursor on desktop.
            $_uid = esc_attr($uniqueId);
            wp_add_inline_style('instinctor-page-inline', '#' . $_uid . ' .impulse-pricing-arc .impulse-pricing-card{backface-visibility:hidden;transition:transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),filter 0.3s ease-in-out;will-change:transform,filter}'
                . '#' . $_uid . ' .impulse-pricing-arc .impulse-pricing-card{transform-origin:center top}'
                // ── Mobile (≤768px): clean vertical card stack ───────────────
                // Each card stays a vertical flex column (same shape as the
                // desktop card): ribbon band, then title/subtitle, price,
                // features, and a full-width button. Cards hug their content
                // height; the Plans screen scrolls internally (section
                // screen_scroll:free) so all three tiers are reachable on a
                // phone. Arc is OFF by default (rotated cards crash into the
                // device rail); opt back in via arc_mobile=true. This inline
                // renderer is the one the screen-mode frontend actually uses;
                // the element-renderer copy in includes/element-renderers/
                // plans.php is NOT on this path. Rebuilt 2026-05-28 (was a
                // cramped 2-column row layout).
                . '@media(max-width:768px){'
                    . '#' . $_uid . '{max-width:none !important}'
                    . '#' . $_uid . ' .impulse-pricing-grid{grid-template-columns:1fr !important;gap:16px !important}'
                    // Arc: off by default (strip perspective + baked transforms),
                    // restored only when the designer set data-mobile-arc="1".
                    . ($arcMobile
                        ? '#' . $_uid . ' .impulse-pricing-arc{perspective:1400px;perspective-origin:center center}'
                          . '#' . $_uid . ' .impulse-pricing-arc .impulse-pricing-card{transform-origin:center center}'
                        : '#' . $_uid . ' .impulse-pricing-arc{perspective:none !important}'
                          . '#' . $_uid . ' .impulse-pricing-arc .impulse-pricing-card{transform:none !important;filter:none !important}')
                    // Card = vertical flex stack (reset any grid reshaping).
                    . '#' . $_uid . ' .impulse-pricing-card{display:flex !important;flex-direction:column !important}'
                    // Collapse the reserved ribbon band on cards with no ribbon
                    // (Free / Studio) so the title starts flush with the top.
                    . '#' . $_uid . ' .impulse-pricing-ribbon-slot:empty{height:0 !important;min-height:0 !important}'
                    . '#' . $_uid . ' .impulse-pricing-header{padding:20px 22px 0 !important;text-align:center !important;min-height:0 !important}'
                    . '#' . $_uid . ' .impulse-pricing-title{font-size:19px !important;letter-spacing:0 !important}'
                    . '#' . $_uid . ' .impulse-pricing-subtitle{font-size:12px !important;min-height:0 !important;margin-top:3px !important}'
                    . '#' . $_uid . ' .impulse-pricing-price{padding:10px 22px !important;text-align:center !important}'
                    // Keep currency + amount + period on one line.
                    . '#' . $_uid . ' .impulse-price-display,#' . $_uid . ' .impulse-price-monthly,#' . $_uid . ' .impulse-price-yearly{white-space:nowrap}'
                    . '#' . $_uid . ' .impulse-price-currency{font-size:16px !important}'
                    . '#' . $_uid . ' .impulse-price-amount{font-size:34px !important}'
                    . '#' . $_uid . ' .impulse-price-period,#' . $_uid . ' .impulse-pricing-period{font-size:13px !important}'
                    . '#' . $_uid . ' .impulse-pricing-features{padding:6px 26px 4px !important;flex:0 1 auto !important}'
                    . '#' . $_uid . ' .impulse-pricing-feature{margin-bottom:9px !important;gap:10px !important}'
                    . '#' . $_uid . ' .impulse-pricing-feature svg{width:15px !important;height:15px !important;flex-shrink:0}'
                    . '#' . $_uid . ' .impulse-pricing-feature > span{font-size:13px !important;line-height:1.3}'
                    . '#' . $_uid . ' .impulse-pricing-card > div:last-child{padding:14px 22px 20px !important}'
                    . '#' . $_uid . ' .impulse-pricing-button{display:block !important;width:100% !important;font-size:15px !important;padding:13px !important;letter-spacing:0 !important;border-radius:8px !important}'
                . '}');
            echo '<div class="' . esc_attr($gridClass) . '" style="' . esc_attr($gridStyle) . '">';

            $planIdx = -1;
            foreach ($plans as $plan) {
                $planIdx++;
                $isHighlighted = !empty($plan['highlighted']);
                $planTitle = $plan['title'] ?? 'Plan';
                $planSubtitle = $plan['subtitle'] ?? '';
                $planPrice = $plan['price'] ?? '0';
                $planYearlyPrice = $plan['yearly_price'] ?? $planPrice;
                $planCurrency = $plan['currency'] ?? '$';
                $planPeriod = $plan['period'] ?? '/month';
                $planYearlyPeriod = $plan['yearly_period'] ?? '/year';
                $planOriginalPrice = $plan['original_price'] ?? '';
                $planFeatures = $plan['features'] ?? [];
                if (is_string($planFeatures)) $planFeatures = json_decode($planFeatures, true) ?: [];
                $planButtonText = $plan['button_text'] ?? 'Get Started';
                $planButtonLink = $plan['button_link'] ?? '#';
                $planButtonType = $plan['button_type'] ?? 'custom';
                $planProductId = $plan['product_id'] ?? '';
                $planPageId = $plan['page_id'] ?? '';
                $planRibbon = $plan['ribbon'] ?? 'none';
                $planRibbonText = $plan['ribbon_text'] ?? '';
                
                $buttonHref = $planButtonLink;
                if ($planButtonType === 'product' && $planProductId) {
                    $buttonHref = '#'; // handled by instinctor-add-to-cart-btn JS
                } elseif ($planButtonType === 'page' && $planPageId) {
                    $buttonHref = \Instinctor\Services\PageService::getPageUrl((int)$planPageId) ?: '#';
                }
                
                $cardBg = $isHighlighted ? $highlightBg : $bgColor;
                $cardTitleColor = $isHighlighted ? $highlightColor : $titleColor;
                $cardPriceColor = $isHighlighted ? $highlightColor : $priceColor;
                $cardTextColor = $isHighlighted ? 'rgba(255,255,255,0.8)' : $textColor;
                $cardBtnBg = $isHighlighted ? $highlightButtonBg : $buttonBg;
                $cardBtnColor = $isHighlighted ? $highlightButtonColor : $buttonColor;
                $cardBorder = $isHighlighted ? $highlightBorder : $borderColor;
                
                // preserve-3d on the card makes interior translateZ layers orbit
                // with the card's arc rotation — free parallax at no extra JS cost.
                $cardStyle = 'background:' . esc_attr($cardBg) . ';border-radius:' . $borderRadius . 'px;position:relative;display:flex;flex-direction:column;overflow:hidden;transform-style:preserve-3d;';
                $cardArcBase = 0;
                if ($arcActive && $arcCenterIdx > 0) {
                    $_relPos    = ($arcCenterIdx - $planIdx) / $arcCenterIdx;     // +1 leftmost → -1 rightmost
                    $cardArcBase = round($_relPos * $arcMaxAngle * $arcAngleScale, 2);
                    $cardStyle .= 'transform:rotateY(' . $cardArcBase . 'deg);';
                }

                // Z ladder for interior layers. Highlighted cards get a small
                // extra boost so the hero plan reads as closer to the camera.
                $zBoost     = $isHighlighted ? 15 : 0;
                $zRibbon    = 50 + $zBoost;
                $zHeader    = 15 + $zBoost;
                $zPrice     = 30 + $zBoost;
                $zFeatures  = 8  + $zBoost;
                $zButton    = 40 + $zBoost;

                $_arcBaseAttr = $arcActive ? ' data-arc-base="' . esc_attr((string)$cardArcBase) . '"' : '';
                echo '<div class="impulse-pricing-card' . ($isHighlighted ? ' highlighted' : '') . '"' . $_arcBaseAttr . ' style="' . esc_attr($cardStyle) . '">';
                
                $cardRibbonBg = $isHighlighted ? $highlightRibbonBg : $ribbonBg;
                $cardRibbonColor = $isHighlighted ? $highlightRibbonColor : $ribbonColor;
                $hasRibbon = $planRibbon !== 'none' && $ribbonStyle !== 'none';
                $ribbonContainerHeight = ($ribbonStyle === 'banner') ? '36px' : '0';
                echo '<div class="impulse-pricing-ribbon-slot" style="height:' . esc_attr($ribbonContainerHeight) . ';position:relative;flex-shrink:0;overflow:hidden;border-radius:' . esc_attr($borderRadius) . 'px ' . esc_attr($borderRadius) . 'px 0 0;transform-style:preserve-3d">';
                if ($hasRibbon && $ribbonStyle === 'banner') {
                    $ribbonText = $planRibbon === 'custom' ? $planRibbonText : ucfirst($planRibbon);
                    echo '<div class="impulse-pricing-ribbon-banner" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:' . esc_attr($cardRibbonBg) . ';color:' . esc_attr($cardRibbonColor) . ';font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:1px;transform:translateZ(' . esc_attr($zRibbon) . 'px)">' . esc_html($ribbonText) . '</div>';
                }
                echo '</div>';

                if ($hasRibbon && $ribbonStyle === 'corner') {
                    $ribbonText = $planRibbon === 'custom' ? $planRibbonText : ucfirst($planRibbon);
                    echo '<div style="position:absolute;top:0;right:0;width:80px;height:80px;overflow:hidden;z-index:2;transform:translateZ(' . esc_attr($zRibbon) . 'px);transform-style:preserve-3d">';
                    echo '<div style="position:absolute;top:16px;right:-24px;background:' . esc_attr($cardRibbonBg) . ';color:' . esc_attr($cardRibbonColor) . ';padding:4px 32px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;transform:rotate(45deg)">' . esc_html($ribbonText) . '</div>';
                    echo '</div>';
                }

                echo '<div class="impulse-pricing-header" style="padding:18px 20px 0;text-align:center;min-height:58px;display:flex;flex-direction:column;justify-content:center;transform:translateZ(' . esc_attr($zHeader) . 'px)">';
                echo '<div class="impulse-pricing-title" style="font-size:18px;font-weight:600;color:' . esc_attr($cardTitleColor) . ';letter-spacing:-0.01em">' . esc_html($planTitle) . '</div>';
                echo '<div class="impulse-pricing-subtitle" style="font-size:14px;color:' . esc_attr($cardTextColor) . ';margin-top:4px;min-height:20px">' . ($planSubtitle ? esc_html($planSubtitle) : '&nbsp;') . '</div>';
                echo '</div>';

                echo '<div class="impulse-pricing-price" style="padding:14px 20px;text-align:center;transform:translateZ(' . esc_attr($zPrice) . 'px)">';
                if ($planOriginalPrice) echo '<div style="text-decoration:line-through;color:' . esc_attr($cardTextColor) . ';font-size:14px;margin-bottom:4px">' . esc_html($planCurrency . $planOriginalPrice) . '</div>';
                echo '<div class="impulse-price-display">';
                echo '<span class="impulse-price-monthly">';
                echo '<span class="impulse-price-currency" style="font-size:20px;vertical-align:top;color:' . esc_attr($cardPriceColor) . ';font-weight:500">' . esc_html($planCurrency) . '</span>';
                echo '<span class="impulse-price-amount" style="font-size:' . esc_attr($priceSize) . 'px;font-weight:600;color:' . esc_attr($cardPriceColor) . ';line-height:1;letter-spacing:-0.02em">' . esc_html($planPrice) . '</span>';
                echo '<span class="impulse-price-period" style="font-size:14px;color:' . esc_attr($cardTextColor) . ';font-weight:400">' . esc_html($planPeriod) . '</span>';
                echo '</span>';
                if ($showToggle) {
                    echo '<span class="impulse-price-yearly" style="display:none">';
                    echo '<span class="impulse-price-currency" style="font-size:20px;vertical-align:top;color:' . esc_attr($cardPriceColor) . ';font-weight:500">' . esc_html($planCurrency) . '</span>';
                    echo '<span class="impulse-price-amount" style="font-size:' . esc_attr($priceSize) . 'px;font-weight:600;color:' . esc_attr($cardPriceColor) . ';line-height:1;letter-spacing:-0.02em">' . esc_html($planYearlyPrice) . '</span>';
                    echo '<span class="impulse-price-period" style="font-size:14px;color:' . esc_attr($cardTextColor) . ';font-weight:400">' . esc_html($planYearlyPeriod) . '</span>';
                    echo '</span>';
                }
                echo '</div></div>';
                
                echo '<div class="impulse-pricing-features" style="padding:0 20px 20px;flex:1;transform:translateZ(' . esc_attr($zFeatures) . 'px)">';
                foreach ($planFeatures as $feature) {
                    $featureText = is_string($feature) ? $feature : ($feature['text'] ?? '');
                    $featureStatus = is_string($feature) ? 'included' : ($feature['status'] ?? 'included');
                    if ($featureStatus === 'included') {
                        $iconColor = $isHighlighted ? $highlightCheckColor : $checkColor;
                        $iconSvg = impulse_get_feature_icon($checkIcon, $iconColor, 'impulse-feature-check');
                    } elseif ($featureStatus === 'excluded') {
                        $iconColor = $isHighlighted ? $highlightCrossColor : $crossColor;
                        $iconSvg = impulse_get_feature_icon($crossIcon, $iconColor, 'impulse-feature-cross');
                    } else {
                        $iconColor = $isHighlighted ? $highlightColor : $textColor;
                        $iconSvg = impulse_get_feature_icon('minus-square', $iconColor, 'impulse-feature-neutral');
                    }
                    echo '<div class="impulse-pricing-feature" style="display:flex;align-items:flex-start;gap:12px;margin-bottom:14px;">';
                    echo '<span style="flex-shrink:0;display:flex;align-items:center;height:21px">' . $iconSvg . '</span>';
                    echo '<span style="color:' . esc_attr($isHighlighted ? $highlightColor : $textColor) . ';font-size:14px;line-height:21px">' . esc_html($featureText) . '</span>';
                    echo '</div>';
                }
                echo '</div>';
                
                $btnStyle = 'display:block;text-align:center;padding:' . $btnPaddingV . 'px 24px;border-radius:' . $borderRadius . 'px;text-decoration:none;font-weight:500;font-size:' . $btnFontSize . 'px;transition:all 0.2s;';
                $btnStyle .= 'background:' . esc_attr($cardBtnBg) . ';color:' . esc_attr($cardBtnColor) . ';border:none;';
                echo '<div style="padding:0 20px 20px;transform:translateZ(' . esc_attr($zButton) . 'px)">';
                $_atcAttr  = ($planButtonType === 'product' && $planProductId) ? ' data-product-id="' . intval($planProductId) . '" data-buy-now="1"' : '';
                $_atcClass = ($planButtonType === 'product' && $planProductId) ? ' instinctor-add-to-cart-btn' : '';
                echo '<a href="' . esc_url($buttonHref) . '" class="impulse-pricing-button' . esc_attr($_atcClass) . '" style="' . esc_attr($btnStyle) . '"' . $_atcAttr . '>' . esc_html($planButtonText) . '</a>';
                echo '</div>';
                
                echo '</div>';
            }
            
            echo '</div></div>';

            // Arc parallax + blur — emitted AFTER the grid/wrapper close so the DOM is
            // fully parsed before the script runs. Checks `.impulse-pricing-arc` on each
            // event so toggling arc via live-preview takes effect immediately. Per-card
            // filter/transform computed inline so the centre card (base angle 0) never
            // gets blurred, and outer-card blur scales with tilt angle.
            ob_start();
            echo '(function(){'
                . 'var grid=document.getElementById(' . wp_json_encode($uniqueId) . ');'
                . 'if(!grid)return;var row=grid.querySelector(".impulse-pricing-grid");'
                . 'if(!row)return;'
                . 'var cards=row.querySelectorAll(":scope > .impulse-pricing-card");'
                . 'if(cards.length<2)return;'
                . 'function readBase(){return Array.prototype.map.call(cards,function(c){return parseFloat(c.getAttribute("data-arc-base"))||0;});}'
                . 'var base=readBase();var MAX=3,dx=0,dy=0,tdx=0,tdy=0,easing=false,hov=new Array(cards.length).fill(false);'
                // Arc is OFF on mobile (≤768px) by default — treat it as
                // inactive there so apply() clears every transform/filter
                // (genuine arc=0, no residual gyro tilt), not merely
                // CSS-overridden. The designer can opt back in by setting
                // arc_mobile=true, which stamps data-mobile-arc on the
                // wrapper (grid). 2026-05-28.
                . 'function isArc(){return row.classList.contains("impulse-pricing-arc") && (window.innerWidth>768 || grid.hasAttribute("data-mobile-arc"));}'
                // Runtime axis switch: horizontal fan (rotateY, top-pivot) on desktop;
                // vertical fan (rotateX, centre-pivot) on mobile stacks. Queried every
                // apply() so resize/orientation change flips cleanly without reload.
                . 'function isVertical(){return window.innerWidth<=768;}'
                . 'function arcScaleY(i){var w=cards[i].offsetWidth||0;if(!w)return 1;var s=Math.abs(Math.sin(base[i]*Math.PI/180));return 1+(w*s)/2400;}'
                . 'function arcScaleX(i){var h=cards[i].offsetHeight||0;if(!h)return 1;var s=Math.abs(Math.sin(base[i]*Math.PI/180));return 1+(h*s)/2400;}'
                . 'function apply(){var on=isArc();var vert=isVertical();cards.forEach(function(c,i){if(!on){c.style.transform="";c.style.filter="";c.removeAttribute("data-arc-hover");return;}var absBase=Math.abs(base[i]);if(hov[i]){c.style.transform=vert?"rotateX(0deg) rotateY(0deg) scaleX(1)":"rotateY(0deg) rotateX(0deg) scaleY(1)";c.style.filter="none";c.setAttribute("data-arc-hover","1");}else{if(vert){var sx=arcScaleX(i);c.style.transform="rotateX("+(base[i]-dy).toFixed(2)+"deg) rotateY("+dx.toFixed(2)+"deg) scaleX("+sx.toFixed(4)+")";}else{var sy=arcScaleY(i);c.style.transform="rotateY("+(base[i]+dx).toFixed(2)+"deg) rotateX("+(-dy).toFixed(2)+"deg) scaleY("+sy.toFixed(4)+")";}var b=absBase<0.5?0:Math.min(2.5,0.4+absBase/14);c.style.filter=b>0?"blur("+b.toFixed(2)+"px)":"none";c.removeAttribute("data-arc-hover");}});}'
                . 'function ease(){dx+=(tdx-dx)*0.06;dy+=(tdy-dy)*0.06;apply();if(Math.abs(tdx-dx)>0.01||Math.abs(tdy-dy)>0.01){requestAnimationFrame(ease);}else{easing=false;}}'
                . 'function kickEase(){if(!easing){easing=true;requestAnimationFrame(ease);}}'
                . 'row.addEventListener("mousemove",function(e){if(!isArc())return;var r=row.getBoundingClientRect();tdx=((e.clientX-r.left)/r.width-0.5)*(MAX*2);tdy=((e.clientY-r.top)/r.height-0.5)*(MAX*2);kickEase();});'
                . 'row.addEventListener("mouseleave",function(){tdx=0;tdy=0;kickEase();});'
                . 'cards.forEach(function(c,i){c.addEventListener("mouseenter",function(){hov[i]=true;apply();});c.addEventListener("mouseleave",function(){hov[i]=false;apply();});});'
                // Touch: tap a card to focus it (deblur + flatten) since mobile has no
                // hover. Tap outside any card clears focus. Links inside the card still
                // navigate on second tap because we use passive touchstart that doesn\'t
                // call preventDefault.
                . 'if("ontouchstart" in window){cards.forEach(function(c,i){c.addEventListener("touchstart",function(){for(var k=0;k<hov.length;k++)hov[k]=false;hov[i]=true;apply();},{passive:true});});row.addEventListener("touchstart",function(e){if(!e.target.closest(".impulse-pricing-card")){for(var k=0;k<hov.length;k++)hov[k]=false;apply();}},{passive:true});}'
                // Gyroscope input on touch devices. Maps gamma (left-right tilt) and
                // beta (front-back tilt) to the same tdx/tdy target the cursor path
                // writes to. iOS 13+ needs explicit permission after a user gesture.
                . 'function bindGyro(){window.addEventListener("deviceorientation",function(e){if(!isArc())return;var g=e.gamma||0,b=e.beta||0;tdx=Math.max(-MAX,Math.min(MAX,g/15));tdy=Math.max(-MAX,Math.min(MAX,(b-30)/15));kickEase();});}'
                . 'if(typeof DeviceOrientationEvent!=="undefined"&&typeof DeviceOrientationEvent.requestPermission==="function"){var onFirstTouch=function(){DeviceOrientationEvent.requestPermission().then(function(r){if(r==="granted")bindGyro();}).catch(function(){});};document.addEventListener("touchstart",onFirstTouch,{once:true});}else if("ontouchstart" in window){bindGyro();}'
                . 'window.addEventListener("resize",function(){apply();});'
                . 'row._impulseArcRefresh=function(){base=readBase();apply();};'
                . 'document.addEventListener("impulse-screen-changed",function(){setTimeout(function(){var host=row.closest(\'.impulse-screen,.impulse-el[data-type="section"]\');var anyActive=document.querySelector(\'.impulse-screen-active\');if(host&&anyActive&&!host.classList.contains("impulse-screen-active"))return;apply();},50);});'
                . 'apply();'
                . '})();';
            $_pgJs5 = ob_get_clean();
            wp_add_inline_script('instinctor-frontend-js', $_pgJs5, 'after');

            // Responsive CSS for min_card_width, border_radius, price_size
            $pResp = impulse_responsive_css($settings, '#' . $uniqueId . ' .impulse-pricing-card', [
                'border_radius' => ['border-radius', 'px']
            ]);
            $pResp .= impulse_responsive_css($settings, '#' . $uniqueId . ' .impulse-price-amount', [
                'price_size' => ['font-size', 'px']
            ]);
            $pResp .= impulse_responsive_css($settings, '#' . $uniqueId . ' .impulse-pricing-button', [
                'btn_font_size' => ['font-size', 'px']
            ]);
            if ($pResp) wp_add_inline_style('instinctor-page-inline', $pResp);
            
            // Pricing toggle JS
            if ($showToggle) {
                wp_add_inline_script('instinctor-frontend-js', '(function(){var w=document.getElementById("' . esc_js($uniqueId) . '");if(!w)return;var t=w.querySelector(".impulse-billing-toggle");if(!t)return;t.addEventListener("change",function(){var yearly=this.checked;w.querySelectorAll(".impulse-price-monthly").forEach(function(el){el.style.display=yearly?"none":""});w.querySelectorAll(".impulse-price-yearly").forEach(function(el){el.style.display=yearly?"":"none"});var ml=w.querySelector(".impulse-toggle-monthly"),yl=w.querySelector(".impulse-toggle-yearly"),knob=w.querySelector(".impulse-toggle-knob"),bg=knob?knob.parentElement.previousElementSibling:null;if(ml)ml.style.opacity=yearly?"0.5":"1";if(yl)yl.style.opacity=yearly?"1":"0.5";if(knob)knob.style.left=yearly?"27px":"3px";if(bg)bg.style.background=yearly?"#1d2327":"#ddd"})})();', 'after');
            }
            break;

        // ================================================================
        // BUTTON
        // ================================================================
        case 'button':
            // Shared renderer (see includes/element-renderers/button.php).
            // Phase 1 step 4 of renderer-unification refactor.
            echo impulse_element_render_button($el, $settings, 'frontend');
            break;

        // ================================================================
        // VIDEO
        // ================================================================
        case 'video':
            $videoUrl = $settings['video_url'] ?? $settings['video_file_url'] ?? '';
            $autoplay = !empty($settings['autoplay']);
            $loop = !empty($settings['loop']) || $autoplay;
            $muted = !empty($settings['muted']) || $autoplay;
            $controls = $settings['controls'] ?? true;
            $aspectRatio = $settings['aspect_ratio'] ?? '16:9';
            $borderRadius = intval($settings['border_radius'] ?? 0);
            $maxWidth = intval($settings['max_width'] ?? 0);
            $width = intval($settings['width'] ?? 100);
            $align = $settings['align'] ?? 'center';
            $posterUrl = $settings['poster_url'] ?? '';
            $maskUrl = $settings['mask_url'] ?? '';
            $maskViewbox = $settings['mask_viewbox'] ?? '';

            $ratioMap = ['16:9'=>[16,9],'4:3'=>[4,3],'21:9'=>[21,9],'1:1'=>[1,1],'9:16'=>[9,16]];
            [$rw, $rh] = $ratioMap[$aspectRatio] ?? [16,9];
            // Mask overrides aspect via its viewBox
            if ($maskUrl && $maskViewbox) {
                $vbParts = preg_split('/[\s]+/', trim($maskViewbox));
                if (count($vbParts) >= 4 && floatval($vbParts[2]) > 0 && floatval($vbParts[3]) > 0) {
                    $rw = floatval($vbParts[2]); $rh = floatval($vbParts[3]);
                }
            }

            $justifyMap = ['left'=>'flex-start','center'=>'center','right'=>'flex-end'];
            $outerStyle = 'display:flex;align-items:center;justify-content:'.($justifyMap[$align] ?? 'center').';width:100%;';
            // Simple sizing: width % of parent, aspect-ratio drives height, max-height caps to viewport so
            // even in tight screen-mode sections the video can't exceed the viewport.
            $wrapStyle = 'position:relative;width:'.$width.'%;aspect-ratio:'.$rw.'/'.$rh.';max-height:100dvh;max-width:100%;overflow:hidden;border-radius:'.$borderRadius.'px;margin:0 auto;';
            if ($maxWidth > 0) $wrapStyle .= 'max-width:min(100%,'.$maxWidth.'px);';
            if ($maskUrl) {
                $maskCss = str_starts_with($maskUrl, 'data:') ? 'url('.$maskUrl.')' : 'url('.esc_url($maskUrl).')';
                $wrapStyle .= '-webkit-mask-image:'.$maskCss.';mask-image:'.$maskCss.';';
                $wrapStyle .= '-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center;';
            }

            $embedUrl = '';
            if (preg_match('/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]+)/', $videoUrl, $m)) {
                $embedUrl = 'https://www.youtube.com/embed/' . $m[1] . '?rel=0';
                if ($autoplay) $embedUrl .= '&autoplay=1&mute=1';
                if ($loop) $embedUrl .= '&loop=1&playlist=' . $m[1];
                if ($controls === false || $controls === '0') $embedUrl .= '&controls=0';
            } elseif (preg_match('/vimeo\.com\/(\d+)/', $videoUrl, $m)) {
                $embedUrl = 'https://player.vimeo.com/video/' . $m[1] . '?dnt=1';
                if ($autoplay) $embedUrl .= '&autoplay=1&muted=1';
                if ($loop) $embedUrl .= '&loop=1';
            }

            echo '<div class="impulse-video-outer" style="' . esc_attr($outerStyle).'">';
            echo '<div class="impulse-video" style="' . esc_attr($wrapStyle).'">';
            if ($embedUrl) {
                echo '<iframe src="'.esc_url($embedUrl).'" style="position:absolute;top:0;left:0;width:100%;height:100%;border:none" allow="autoplay;fullscreen;picture-in-picture" allowfullscreen loading="lazy"></iframe>';
            } elseif ($videoUrl) {
                $vidAttrs = 'class="impulse-video-media" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover" playsinline';
                // Autoplay gated by IntersectionObserver via data attribute — actual autoplay attr only added when user didn't opt into lazy behaviour.
                if ($autoplay) $vidAttrs .= ' muted loop data-auto-inview="1"';
                elseif ($muted) $vidAttrs .= ' muted';
                if ($loop && !$autoplay) $vidAttrs .= ' loop';
                if ($controls !== false && $controls !== '0') $vidAttrs .= ' controls';
                if ($posterUrl) $vidAttrs .= ' poster="'.esc_url($posterUrl).'"';
                $vidAttrs .= ' preload="metadata"';
                echo '<video '.$vidAttrs.'><source src="'.esc_url($videoUrl).'" type="video/mp4"></video>';
            } else {
                // Empty-state placeholder (no URL yet) — gentle play glyph, centred.
                echo '<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#f5f5f5"><div style="width:64px;height:64px;border-radius:50%;border:3px solid #fff;display:flex;align-items:center;justify-content:center"><svg width="28" height="28" viewBox="0 0 24 24" fill="#fff"><polygon points="9,6 18,12 9,18"/></svg></div></div>';
            }
            echo '</div></div>';
            if ($autoplay && !$embedUrl) echo impulse_render_video_autoplay_script();
            break;

        // ================================================================
        // ICON (Icon Box)
        // ================================================================
        case 'icon_card':
            // Shared renderer (see includes/element-renderers/icon_card.php).
            // Phase 1 step 5 of the renderer-unification refactor.
            // Now identical to editor: gains container box, SVG-URL
            // inline reading, halo, data-view, continuous-effect class.
            echo impulse_element_render_icon_card($el, $settings, 'frontend');
            break;

        // ================================================================
        // REVIEWS (Google-style testimonials)
        // ================================================================
        case 'reviews':
            // Shared renderer (see includes/element-renderers/reviews.php).
            // Pure server-rendered card grid — identical in editor + frontend.
            echo impulse_element_render_reviews($el, $settings, 'frontend');
            break;

        // ================================================================
        // NETWORK (animated capability grid)
        // ================================================================
        case 'network':
            // Shared renderer (see includes/element-renderers/network.php).
            echo impulse_element_render_network($el, $settings, 'frontend');
            break;

        // ================================================================
        // MARQUEE (continuous card strips)
        // ================================================================
        case 'marquee':
            // Shared renderer (see includes/element-renderers/marquee.php).
            echo impulse_element_render_marquee($el, $settings, 'frontend');
            break;

        // ================================================================
        // ICON LIST
        // ================================================================
        case 'table':
            // Shared renderer (includes/element-renderers/table.php) - editor
            // and frontend now run the SAME implementation. The legacy inline
            // copy that lived here predated v2.1.1374: no mobile stacking CSS
            // (rows squeezed into slivers on phones), still had the
            // word-break:break-word per-character wrap, and esc_html()'d
            // cells so typed <br> rendered as literal text. v2.1.1422.
            echo impulse_element_render_icon_card_list($el, $settings, 'frontend');
            break;

        // ================================================================
        // ACCORDION (Unfold)
        // ================================================================
        // Shared renderer (see includes/element-renderers/unfold.php).
        // Carries section_id resolution + 5 anim modes (cinematic /
        // spotlight / rolodex / tiles / focus) + classic fallback.
        case 'unfold':
            $GLOBALS['impulse_needs_unfold'] = true;
            echo impulse_element_render_unfold($el, $settings, $children ?? [], 'frontend', 'impulse_render_frontend_element');
            break;

        // ================================================================
        // CODE
        // ================================================================
        case 'code': /* code block disabled in WordPress.org build */ break;

        case 'chart':
            $chEntries = $settings['chart_entries'] ?? [];
            if (is_string($chEntries)) $chEntries = json_decode($chEntries, true) ?: [];
            if (!is_array($chEntries)) $chEntries = [];
            // Normalise rows — drop blanks, coerce value to float.
            $chRows = [];
            foreach ($chEntries as $r) {
                if (!is_array($r)) continue;
                $lbl = trim((string)($r['label'] ?? ''));
                $val = floatval($r['value'] ?? 0);
                if ($lbl === '' && $val == 0) continue;
                $chRows[] = [
                    'label' => $lbl !== '' ? $lbl : 'Untitled',
                    'value' => max(0, $val),
                    'color' => trim((string)($r['color'] ?? '')),
                ];
            }
            if (empty($chRows)) break;
            $chType        = $settings['chart_type']        ?? 'pie';     // pie | bar | line | radial
            $chStyle       = $settings['chart_style']       ?? 'flat';    // pie: flat | exploded | tilted_ring | half  |  bar: plain | rounded  |  line: plain | filled
            $chOrientation = $settings['chart_orientation'] ?? 'vertical';// bar only: vertical | horizontal
            $chInnerRadius = max(0, min(80, intval($settings['chart_inner_radius'] ?? 0))); // 0 = pie, >0 = donut
            $chLegendPos   = $settings['chart_legend_pos']  ?? 'right';   // right | bottom | off
            $chLabelFormat = $settings['chart_label_format']?? 'none';    // none | percent | value | label_percent
            $chTiltSens    = max(0, min(10, intval($settings['chart_tilt_sens'] ?? 0))); // tilted-ring only, default 0 (off)
            $chAnimateEnt  = !isset($settings['chart_animate']) || !empty($settings['chart_animate']);
            $chVolume      = !empty($settings['chart_volume']);                  // extruded 3D depth
            // Self-healing migration: any chart that isn't pie+3D gets animation
            // forced ON, overriding stored '0' values. This fixes charts whose
            // chart_animate was incorrectly saved as false by an earlier bug
            // that hid the Animate checkbox when 3D was toggled on bar/line/radial.
            // Users can still turn animation off — the checkbox is visible now,
            // and once they explicitly uncheck + save, the new value sticks.
            // Pie+3D stays force-off (its rendering breaks all animation approaches).
            if ($chType === 'pie' && $chVolume) {
                $chAnimateEnt = false;
            } else {
                $chAnimateEnt = true;
            }
            // NOTE: the "Group <2% as Other" auto-aggregation setting was removed —
            // manual data entry makes it pointless (users wouldn't add rows just to
            // have them merged away). If that kind of long-tail rollup is needed
            // later for imported data, it comes back as its own opt-in pass.
            // Helper: shift a hex colour toward black by $factor (0..1). Used for
            // the extruded "side wall" shade on volumetric slices/bars/rings.
            if (!function_exists('impulse_chart_shade')) {
                function impulse_chart_shade($hex, $factor = 0.7) {
                    $hex = ltrim((string)$hex, '#');
                    if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
                    if (strlen($hex) !== 6) return '#555555';
                    $r = hexdec(substr($hex, 0, 2));
                    $g = hexdec(substr($hex, 2, 2));
                    $b = hexdec(substr($hex, 4, 2));
                    $r = max(0, min(255, intval($r * $factor)));
                    $g = max(0, min(255, intval($g * $factor)));
                    $b = max(0, min(255, intval($b * $factor)));
                    return sprintf('#%02X%02X%02X', $r, $g, $b);
                }
            }
            // Palette fallback — any row without a color gets a default pastel shade.
            $chFallbackPalette = ['#9CC3E3','#B8D9B0','#F3B48A','#CDB3D4','#2E5C8A','#6FB38F','#E87E77','#D9C38F'];
            foreach ($chRows as $i => &$r) {
                if ($r['color'] === '') $r['color'] = $chFallbackPalette[$i % count($chFallbackPalette)];
            }
            unset($r);
            $chId = 'chart-' . ($element['id'] ?? wp_rand(1000, 9999));
            $chLegendAlign = $chLegendPos === 'bottom' ? 'column' : 'row';
            // Half-pie: force the flex container's height to match the half-pie's
            // SVG height, then align-items:flex-end puts the legend's bottom
            // exactly at the pie's flat baseline. Without this, the flex
            // container can grow from entrance/min-height wrappers higher up
            // the tree, and flex-end aligns to THAT (lower) bottom, leaving
            // the legend floating above the pie baseline.
            $_chHalf = ($chType === 'pie' && $chStyle === 'half');
            $_chAlignItems = $_chHalf ? 'flex-end' : 'center';
            $_chWrapH = $_chHalf ? (';height:' . intval(360 / 2) . 'px') : '';
            $chWrapStyle = 'width:100%;position:relative;display:flex;gap:32px;align-items:' . $_chAlignItems . ';justify-content:center;flex-direction:' . $chLegendAlign . $_chWrapH;
            // Bars mode is a flat HTML list — drop the flex-row gap/align meant for SVG + legend pairing.
            if ($chType === 'bars') {
                $chWrapStyle = 'width:100%;position:relative;display:block';
            }
            echo '<div id="' . esc_attr($chId) . '" class="impulse-chart impulse-chart-' . esc_attr($chType) . ' impulse-chart-' . esc_attr($chStyle) . '" style="' . esc_attr($chWrapStyle) . '" data-chart-type="' . esc_attr($chType) . '" data-chart-style="' . esc_attr($chStyle) . '" data-tilt-sens="' . esc_attr($chTiltSens) . '" data-animate="' . ($chAnimateEnt ? '1' : '0') . '">';
            // ── CHART SVG ───────────────────────────────────────────────────────
            if ($chType === 'pie') {
                $chIsHalf  = ($chStyle === 'half');
                $chSize    = 360;
                // Half-pie: SVG viewBox height = exactly half, so the bottom of the
                // SVG coincides with the pie's flat baseline. The wrapper's flex
                // align-items:flex-end then places the legend's bottom flush with
                // the flat baseline as the user expects.
                $chSvgH    = $chIsHalf ? intval($chSize / 2) : $chSize;
                $chCx      = $chSize / 2;
                $chCy      = $chIsHalf ? ($chSize / 2) : ($chSize / 2);
                $chExplode = ($chStyle === 'exploded') ? 10 : 0;
                // Shrink radius to accommodate the explode offset so slices stay
                // inside the SVG viewBox (and therefore inside the CSS mask
                // wrapper, which clips everything outside its box).
                $chR       = $chSize / 2 - 4 - $chExplode;
                $chIR      = ($chR * $chInnerRadius) / 100;
                $chTotal   = 0; foreach ($chRows as $r) $chTotal += $r['value'];
                if ($chTotal <= 0) { echo '</div>'; break; }
                // Half-pie sweeps 180° from -180° (left edge) clockwise to 0° (right edge).
                // Full pie sweeps 360° starting at -90° (top).
                $chSpan    = $chIsHalf ? 180 : 360;
                $chStart   = $chIsHalf ? -180 : -90;
                // Tilted ring wraps SVG in a perspective container; ring flattens rotate math.
                // Volume mode wraps the pie in a tilted perspective so the extruded
                // stack reads as depth rather than a stack of flat duplicates.
                $chVolumeTilt = $chVolume && $chStyle !== 'tilted_ring';
                // CSS conic-gradient mask for clockwise radial sweep. Uses @property
                // so the --impulse-sweep-{id} angle animates smoothly. Applied on a
                // wrapper OUTSIDE the 3D tilt container, so the mask operates in
                // 2D compositor space and works reliably in both 2D and 3D modes.
                if ($chAnimateEnt) {
                    wp_add_inline_style('instinctor-page-inline', '@property --impulse-sweep-' . esc_attr($chId) . '{syntax:"<angle>";initial-value:0deg;inherits:false}'
                       . '@keyframes impulse-pie-sweep-' . esc_attr($chId) . '{to{--impulse-sweep-' . esc_attr($chId) . ':360deg}}'
                       . '@keyframes impulse-pie-stroke-' . esc_attr($chId) . '{to{stroke-opacity:1}}');
                    // from 0deg = start at 12 o'clock and sweep clockwise — CSS
                    // conic-gradient's default origin is already at the top.
                    $_maskGrad = 'conic-gradient(from 0deg at center,#000 0deg,#000 var(--impulse-sweep-' . esc_attr($chId) . '),transparent var(--impulse-sweep-' . esc_attr($chId) . '),transparent 360deg)';
                    echo '<div class="impulse-pie-sweep-wrap" style="display:inline-block;-webkit-mask:' . esc_attr($_maskGrad) . ';mask:' . esc_attr($_maskGrad) . ';animation:impulse-pie-sweep-' . esc_attr($chId) . ' 1.4s cubic-bezier(0.65,0,0.35,1) forwards">';
                }
                if ($chStyle === 'tilted_ring' || $chVolumeTilt) {
                    $_tiltAngle = $chVolumeTilt ? -55 : -38;
                    echo '<div class="impulse-chart-stage" style="perspective:1400px;perspective-origin:center 30%;display:flex;align-items:center;justify-content:center;width:' . esc_attr($chSize) . 'px;height:' . intval($chSize * 0.8) . 'px">';
                    if ($chVolumeTilt) {
                        echo '<div class="impulse-chart-tilt" style="transform:rotateX(' . esc_attr($_tiltAngle) . 'deg);will-change:transform;transition:transform 0.25s ease-in-out">';
                    } else {
                        echo '<div class="impulse-chart-tilt" style="transform-style:preserve-3d;transform:rotateX(' . esc_attr($_tiltAngle) . 'deg) rotateY(0deg);will-change:transform;transition:transform 0.25s ease-in-out">';
                    }
                }
                echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . esc_attr($chSize) . ' ' . esc_attr($chSvgH) . '" width="' . esc_attr($chSize) . '" height="' . esc_attr($chSvgH) . '" style="overflow:visible;display:block">';
                // Pie reveal sequence:
                //   Phase 1 (0 → 1.4s):   clock-hand sweep reveals slice fills
                //                         clockwise via an SVG <mask>.
                //   Phase 2 (1.4 → 1.9s): white divider strokes fade in.
                //   Phase 3 (1.9 → 2.5s): 3D extrusion fades in beneath the slices.
                // No mask — too many rendering traps in 3D context. Per-slice
                // stagger below gives a clockwise reveal without masks.
                // Extrusion pass — emit N darker layers below the main slices to
                // build visible side-wall depth. Wrapped in a <g> that fades in
                // at Phase 3 so the stack doesn't appear before the slices.
                if ($chVolume && $chAnimateEnt) {
                    // Extrusion via SMIL — fades in after sweep + strokes (~1.9s).
                    echo '<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="1900ms" dur="500ms" fill="freeze"/>';
                } elseif ($chVolume) {
                    echo '<g>';
                }
                if ($chVolume) {
                    $chDepth = 18;
                    for ($layer = $chDepth; $layer >= 1; $layer--) {
                        $shadeFactor = 0.45 + (0.35 * ($chDepth - $layer) / $chDepth); // 0.45..0.80
                        $chAngleL = $chStart;
                        foreach ($chRows as $_extI => $rr) {
                            if ($rr['value'] <= 0) continue;
                            $sweepL = $chSpan * $rr['value'] / $chTotal;
                            $a0L = deg2rad($chAngleL);
                            $a1L = deg2rad($chAngleL + $sweepL);
                            $aMidL = deg2rad($chAngleL + $sweepL / 2);
                            $oxL = $chExplode * cos($aMidL);
                            $oyL = $chExplode * sin($aMidL);
                            $cxL = $chCx + $oxL;
                            $cyL = $chCy + $oyL + $layer; // push down by $layer px
                            $x1L = $cxL + $chR * cos($a0L); $y1L = $cyL + $chR * sin($a0L);
                            $x2L = $cxL + $chR * cos($a1L); $y2L = $cyL + $chR * sin($a1L);
                            $largeL = $sweepL > 180 ? 1 : 0;
                            if ($chIR > 0) {
                                $ix1L = $cxL + $chIR * cos($a1L); $iy1L = $cyL + $chIR * sin($a1L);
                                $ix2L = $cxL + $chIR * cos($a0L); $iy2L = $cyL + $chIR * sin($a0L);
                                $pathL = 'M ' . $x1L . ' ' . $y1L
                                       . ' A ' . $chR . ' ' . $chR . ' 0 ' . $largeL . ' 1 ' . $x2L . ' ' . $y2L
                                       . ' L ' . $ix1L . ' ' . $iy1L
                                       . ' A ' . $chIR . ' ' . $chIR . ' 0 ' . $largeL . ' 0 ' . $ix2L . ' ' . $iy2L . ' Z';
                            } else {
                                $pathL = 'M ' . $cxL . ' ' . $cyL
                                       . ' L ' . $x1L . ' ' . $y1L
                                       . ' A ' . $chR . ' ' . $chR . ' 0 ' . $largeL . ' 1 ' . $x2L . ' ' . $y2L . ' Z';
                            }
                            // No individual animation on extrusion paths — the wrapping
                            // <g> fades the entire stack in together at Phase 3.
                            echo '<path class="impulse-chart-extrusion" data-slice-index="' . esc_attr($_extI) . '" d="' . esc_attr($pathL) . '" fill="' . esc_attr(impulse_chart_shade($rr['color'], $shadeFactor)) . '" stroke="none" style="pointer-events:none"></path>';
                            $chAngleL += $sweepL;
                        }
                    }
                    echo '</g>'; // close extrusion group
                }
                echo '<g>';
                $chAngle = $chStart;
                foreach ($chRows as $i => $r) {
                    if ($r['value'] <= 0) continue;
                    $sweep = $chSpan * $r['value'] / $chTotal;
                    $a0 = deg2rad($chAngle);
                    $a1 = deg2rad($chAngle + $sweep);
                    $aMid = deg2rad($chAngle + $sweep / 2);
                    $ox = $chExplode * cos($aMid);
                    $oy = $chExplode * sin($aMid);
                    $cx = $chCx + $ox;
                    $cy = $chCy + $oy;
                    $x1 = $cx + $chR * cos($a0); $y1 = $cy + $chR * sin($a0);
                    $x2 = $cx + $chR * cos($a1); $y2 = $cy + $chR * sin($a1);
                    $large = $sweep > 180 ? 1 : 0;
                    if ($chIR > 0) {
                        $ix1 = $cx + $chIR * cos($a1); $iy1 = $cy + $chIR * sin($a1);
                        $ix2 = $cx + $chIR * cos($a0); $iy2 = $cy + $chIR * sin($a0);
                        $path = 'M ' . $x1 . ' ' . $y1
                              . ' A ' . $chR . ' ' . $chR . ' 0 ' . $large . ' 1 ' . $x2 . ' ' . $y2
                              . ' L ' . $ix1 . ' ' . $iy1
                              . ' A ' . $chIR . ' ' . $chIR . ' 0 ' . $large . ' 0 ' . $ix2 . ' ' . $iy2
                              . ' Z';
                    } else {
                        $path = 'M ' . $cx . ' ' . $cy
                              . ' L ' . $x1 . ' ' . $y1
                              . ' A ' . $chR . ' ' . $chR . ' 0 ' . $large . ' 1 ' . $x2 . ' ' . $y2
                              . ' Z';
                    }
                    $pct = $chTotal > 0 ? ($r['value'] / $chTotal * 100) : 0;
                    // Wrap each slice in its own <g> — SMIL opacity animation on
                    // <g> works reliably, on <path> it was silently failing in
                    // the editor iframe context (verified via DIAG).
                    $_angleFromStart = $chAngle - $chStart;
                    $_sliceDelay    = round(($_angleFromStart / $chSpan) * 900);
                    $_sliceDur      = max(120, round(($sweep / $chSpan) * 900));
                    if ($chAnimateEnt) {
                        // CSS conic-gradient mask on wrapper handles the clockwise
                        // reveal of fills. Slice stroke fades in at 1.4s (after sweep).
                        echo '<path class="impulse-chart-slice" d="' . esc_attr($path) . '" fill="' . esc_attr($r['color']) . '" stroke="#fff" stroke-width="2" stroke-opacity="0" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" data-pct="' . number_format($pct, 1) . '" style="transition:transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),filter 0.25s ease-in-out;transform-origin:' . esc_attr($chCx) . 'px ' . esc_attr($chCy) . 'px;cursor:default">'
                           . '<animate attributeName="stroke-opacity" from="0" to="1" begin="1400ms" dur="500ms" fill="freeze"/>'
                           . '</path>';
                    } else {
                        echo '<path class="impulse-chart-slice" d="' . esc_attr($path) . '" fill="' . esc_attr($r['color']) . '" stroke="#fff" stroke-width="2" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" data-pct="' . number_format($pct, 1) . '" style="transition:transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),filter 0.25s ease-in-out;transform-origin:' . esc_attr($chCx) . 'px ' . esc_attr($chCy) . 'px;cursor:default"></path>';
                    }
                    if ($chLabelFormat !== 'none' && $sweep >= 18) {
                        $lR = $chIR > 0 ? ($chR + $chIR) / 2 : $chR * 0.62;
                        $lx = $cx + $lR * cos($aMid);
                        $ly = $cy + $lR * sin($aMid);
                        $labelText = '';
                        if ($chLabelFormat === 'percent')           $labelText = number_format($pct, 0) . '%';
                        elseif ($chLabelFormat === 'value')         $labelText = (string)$r['value'];
                        elseif ($chLabelFormat === 'label_percent') $labelText = $r['label'] . ' ' . number_format($pct, 0) . '%';
                        if ($labelText !== '') {
                            echo '<text x="' . esc_attr($lx) . '" y="' . esc_attr($ly) . '" text-anchor="middle" dominant-baseline="middle" fill="#fff" font-size="12" font-weight="600" style="pointer-events:none;paint-order:stroke;stroke:rgba(0,0,0,0.35);stroke-width:2">' . esc_html($labelText) . '</text>';
                        }
                    }
                    $chAngle += $sweep;
                }
                echo '</g>'; // close slices group
                echo '</svg>';
                if ($chStyle === 'tilted_ring' || ($chVolume && $chStyle !== 'tilted_ring')) {
                    echo '</div></div>';
                }
                if ($chAnimateEnt) { echo '</div>'; } // close .impulse-pie-sweep-wrap
            } elseif ($chType === 'bar') {
                $chIsHoriz   = ($chOrientation === 'horizontal');
                $chMax = 0; foreach ($chRows as $r) if ($r['value'] > $chMax) $chMax = $r['value'];
                if ($chMax <= 0) { echo '</div>'; break; }
                $chN = count($chRows);
                $chGap = 8;
                if (!$chIsHoriz) {
                    // Vertical bars. Axis labels sit below the baseline with a clear
                    // gap so the label row reads as its own band, not glued to the bars.
                    $chW = 480; $chPadX = 24; $chPadY = 24; $chLabelH = 18; $chLabelGap = 16;
                    $chBarTop = $chPadY;
                    $chBarMaxH = 200;
                    $chBarBottom = $chBarTop + $chBarMaxH;
                    $chH = $chBarBottom + $chLabelGap + $chLabelH;
                    $chBarW = ($chW - $chPadX * 2 - $chGap * max(0, $chN - 1)) / max(1, $chN);
                    $chRound = ($chStyle === 'rounded') ? min(6, $chBarW / 4) : 0;
                    echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . esc_attr($chW) . ' ' . esc_attr($chH) . '" width="' . esc_attr($chW) . '" height="' . esc_attr($chH) . '" style="overflow:visible;display:block">';
                    // Volume offsets for the 3D box look — isometric projection.
                    $chVd = $chVolume ? 8 : 0;  // depth offset in px (right + up)
                    foreach ($chRows as $i => $r) {
                        $h = $chBarMaxH * ($r['value'] / $chMax);
                        $x = $chPadX + $i * ($chBarW + $chGap);
                        $y = $chBarBottom - $h;
                        if ($chVolume) {
                            $sideColor = impulse_chart_shade($r['color'], 0.55);
                            $topColor  = impulse_chart_shade($r['color'], 1.20); // brighter
                            // Side face: parallelogram from top-right of front face to +depth offset.
                            $sideX1 = $x + $chBarW;        $sideY1 = $y;
                            $sideX2 = $sideX1 + $chVd;     $sideY2 = $y - $chVd;
                            $sideX3 = $sideX2;             $sideY3 = $y - $chVd + $h;
                            $sideX4 = $sideX1;             $sideY4 = $y + $h;
                            echo '<polygon points="' . esc_attr($sideX1) . ',' . esc_attr($sideY1) . ' ' . esc_attr($sideX2) . ',' . esc_attr($sideY2) . ' ' . esc_attr($sideX3) . ',' . esc_attr($sideY3) . ' ' . esc_attr($sideX4) . ',' . esc_attr($sideY4) . '" fill="' . esc_attr($sideColor) . '" stroke="none"></polygon>';
                            // Top face: parallelogram from top-left-front to top-right-back.
                            $topX1 = $x;                   $topY1 = $y;
                            $topX2 = $x + $chVd;           $topY2 = $y - $chVd;
                            $topX3 = $x + $chBarW + $chVd; $topY3 = $y - $chVd;
                            $topX4 = $x + $chBarW;         $topY4 = $y;
                            echo '<polygon points="' . esc_attr($topX1) . ',' . esc_attr($topY1) . ' ' . esc_attr($topX2) . ',' . esc_attr($topY2) . ' ' . esc_attr($topX3) . ',' . esc_attr($topY3) . ' ' . esc_attr($topX4) . ',' . esc_attr($topY4) . '" fill="' . esc_attr($topColor) . '" stroke="none"></polygon>';
                        }
                        // Bar entry: fade-only when Volume 3D is on (rect would
                        // otherwise translate through the side/top extrusion polygons).
                        $_barKf = $chVolume ? 'impulse-chart-fadein' : 'impulse-chart-rise';
                        $_barAnim = $chAnimateEnt ? 'animation:' . $_barKf . ' 800ms cubic-bezier(0.4, 0, 0.2, 1) ' . ($i * 120) . 'ms both;' : '';
                        echo '<rect class="impulse-chart-slice" x="' . esc_attr($x) . '" y="' . esc_attr($y) . '" width="' . esc_attr($chBarW) . '" height="' . esc_attr($h) . '" rx="' . esc_attr($chRound) . '" ry="' . esc_attr($chRound) . '" fill="' . esc_attr($r['color']) . '" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" style="' . esc_attr($_barAnim) . 'transition:filter 0.2s ease-in-out;cursor:default"></rect>';
                        if ($chLabelFormat !== 'none') {
                            $chLblTxt = '';
                            if ($chLabelFormat === 'percent' || $chLabelFormat === 'label_percent') {
                                $pctSum = 0; foreach ($chRows as $_r2) $pctSum += $_r2['value'];
                                $chLblTxt = $pctSum > 0 ? number_format($r['value'] / $pctSum * 100, 0) . '%' : '';
                            } elseif ($chLabelFormat === 'value') {
                                $chLblTxt = (string)$r['value'];
                            }
                            if ($chLabelFormat === 'label_percent' && $chLblTxt !== '') $chLblTxt = $r['label'] . ' ' . $chLblTxt;
                            if ($chLblTxt !== '') {
                                echo '<text x="' . (esc_attr($x + $chBarW / 2)) . '" y="' . (esc_attr($y - 6 - $chVd)) . '" text-anchor="middle" fill="#555" font-size="11" font-weight="600">' . esc_html($chLblTxt) . '</text>';
                            }
                        }
                        echo '<text x="' . (esc_attr($x + $chBarW / 2)) . '" y="' . (esc_attr($chBarBottom + $chLabelGap + 12)) . '" text-anchor="middle" fill="#888" font-size="11">' . esc_html(mb_strimwidth($r['label'], 0, 12, '…')) . '</text>';
                    }
                    echo '</svg>';
                } else {
                    // Horizontal bars — each row a bar lying on its side, labels on left.
                    $chW = 480; $chLabelW = 90; $chPadX = 16; $chPadY = 16;
                    $chBarH = (280 - $chPadY * 2 - $chGap * max(0, $chN - 1)) / max(1, $chN);
                    $chBarH = max(16, $chBarH);
                    $chH = intval($chPadY * 2 + $chBarH * $chN + $chGap * max(0, $chN - 1));
                    $chBarLeft = $chPadX + $chLabelW + 8;
                    $chBarRight = $chW - $chPadX;
                    $chBarMaxW = $chBarRight - $chBarLeft;
                    $chRound = ($chStyle === 'rounded') ? min(6, $chBarH / 4) : 0;
                    echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . esc_attr($chW) . ' ' . esc_attr($chH) . '" width="' . esc_attr($chW) . '" height="' . esc_attr($chH) . '" style="overflow:visible;display:block">';
                    $chVd = $chVolume ? 6 : 0;
                    foreach ($chRows as $i => $r) {
                        $w = $chBarMaxW * ($r['value'] / $chMax);
                        $y = $chPadY + $i * ($chBarH + $chGap);
                        echo '<text x="' . (esc_attr($chPadX + $chLabelW)) . '" y="' . (esc_attr($y + $chBarH / 2 + 4)) . '" text-anchor="end" fill="#888" font-size="11">' . esc_html(mb_strimwidth($r['label'], 0, 18, '…')) . '</text>';
                        if ($chVolume) {
                            $sideColor = impulse_chart_shade($r['color'], 0.55);
                            $topColor  = impulse_chart_shade($r['color'], 1.20);
                            // Right side face (end-cap)
                            $hx = $chBarLeft + $w;
                            echo '<polygon points="' . esc_attr($hx) . ',' . esc_attr($y) . ' ' . (esc_attr($hx + $chVd)) . ',' . (esc_attr($y - $chVd)) . ' ' . (esc_attr($hx + $chVd)) . ',' . (esc_attr($y + $chBarH - $chVd)) . ' ' . esc_attr($hx) . ',' . (esc_attr($y + $chBarH)) . '" fill="' . esc_attr($sideColor) . '" stroke="none"></polygon>';
                            // Top face
                            echo '<polygon points="' . esc_attr($chBarLeft) . ',' . esc_attr($y) . ' ' . (esc_attr($chBarLeft + $chVd)) . ',' . (esc_attr($y - $chVd)) . ' ' . (esc_attr($hx + $chVd)) . ',' . (esc_attr($y - $chVd)) . ' ' . esc_attr($hx) . ',' . esc_attr($y) . '" fill="' . esc_attr($topColor) . '" stroke="none"></polygon>';
                        }
                        $_barKfH = $chVolume ? 'impulse-chart-fadein' : 'impulse-chart-rise';
                        $_barAnimH = $chAnimateEnt ? 'animation:' . $_barKfH . ' 800ms cubic-bezier(0.4, 0, 0.2, 1) ' . ($i * 120) . 'ms both;' : '';
                        echo '<rect class="impulse-chart-slice" x="' . esc_attr($chBarLeft) . '" y="' . esc_attr($y) . '" width="' . esc_attr($w) . '" height="' . esc_attr($chBarH) . '" rx="' . esc_attr($chRound) . '" ry="' . esc_attr($chRound) . '" fill="' . esc_attr($r['color']) . '" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" style="' . esc_attr($_barAnimH) . 'transition:filter 0.2s ease-in-out;cursor:default"></rect>';
                        if ($chLabelFormat !== 'none') {
                            $chLblTxt = '';
                            if ($chLabelFormat === 'percent' || $chLabelFormat === 'label_percent') {
                                $pctSum = 0; foreach ($chRows as $_r2) $pctSum += $_r2['value'];
                                $chLblTxt = $pctSum > 0 ? number_format($r['value'] / $pctSum * 100, 0) . '%' : '';
                            } elseif ($chLabelFormat === 'value') {
                                $chLblTxt = (string)$r['value'];
                            }
                            if ($chLblTxt !== '') {
                                echo '<text x="' . (esc_attr($chBarLeft + $w + 6)) . '" y="' . (esc_attr($y + $chBarH / 2 + 4)) . '" fill="#555" font-size="11" font-weight="600">' . esc_html($chLblTxt) . '</text>';
                            }
                        }
                    }
                    echo '</svg>';
                }
            } elseif ($chType === 'line') {
                // Line / area chart — points positioned by row order; y driven by value.
                // Style: plain (stroke only) | filled (area under line).
                $chW = 480; $chH = 260; $chPadX = 28; $chPadY = 24; $chLabelH = 18;
                $chMax = 0; foreach ($chRows as $r) if ($r['value'] > $chMax) $chMax = $r['value'];
                if ($chMax <= 0) { echo '</div>'; break; }
                $chN = count($chRows);
                $chLineTop    = $chPadY;
                $chLineBottom = $chH - $chPadY - $chLabelH;
                $chLineHeight = $chLineBottom - $chLineTop;
                $chLineLeft   = $chPadX;
                $chLineRight  = $chW - $chPadX;
                $chLineWidth  = $chLineRight - $chLineLeft;
                $chLineColor  = $chRows[0]['color'] ?: '#9CC3E3';
                $points = [];
                foreach ($chRows as $i => $r) {
                    $x = $chLineLeft + ($chN > 1 ? ($chLineWidth * $i / ($chN - 1)) : $chLineWidth / 2);
                    $y = $chLineBottom - $chLineHeight * ($r['value'] / $chMax);
                    $points[] = ['x' => $x, 'y' => $y, 'row' => $r, 'i' => $i];
                }
                echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . esc_attr($chW) . ' ' . esc_attr($chH) . '" width="' . esc_attr($chW) . '" height="' . esc_attr($chH) . '" style="overflow:visible;display:block">';
                // Volume mode — define a vertical gradient (brighter top → darker bottom)
                // for the line stroke, plus a soft drop-shadow filter. Gives a ribbon feel.
                if ($chVolume) {
                    $_darkLine  = impulse_chart_shade($chLineColor, 0.55);
                    $_lightLine = impulse_chart_shade($chLineColor, 1.15);
                    $_gradId = 'impulse-chart-line-grad-' . esc_attr($chId);
                    $_shadowId = 'impulse-chart-line-shadow-' . esc_attr($chId);
                    echo '<defs>';
                    echo '<linearGradient id="' . esc_attr($_gradId) . '" x1="0" x2="0" y1="0" y2="1">';
                    echo '<stop offset="0%" stop-color="' . esc_attr($_lightLine) . '"/>';
                    echo '<stop offset="100%" stop-color="' . esc_attr($_darkLine) . '"/>';
                    echo '</linearGradient>';
                    echo '<filter id="' . esc_attr($_shadowId) . '" x="-20%" y="-20%" width="140%" height="140%">';
                    echo '<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>';
                    echo '<feOffset dx="0" dy="4" result="off"/>';
                    echo '<feComponentTransfer result="shadow"><feFuncA type="linear" slope="0.35"/></feComponentTransfer>';
                    echo '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
                    echo '</filter>';
                    echo '</defs>';
                }
                // Build polyline path
                $linePath = '';
                foreach ($points as $k => $p) {
                    $linePath .= ($k === 0 ? 'M ' : ' L ') . round($p['x'], 1) . ' ' . round($p['y'], 1);
                }
                // Line entry animations — path draws itself, area fades in after
                // draw completes, dots pop one at a time after that.
                $_lineDrawAnim = $chAnimateEnt ? 'animation:impulse-chart-line-draw 3500ms cubic-bezier(0.4, 0, 0.2, 1) both;' : '';
                $_lineAreaAnim = $chAnimateEnt ? 'animation:impulse-chart-fadein 900ms ease-in-out 3000ms both;' : '';
                if ($chStyle === 'filled') {
                    $areaPath = $linePath
                              . ' L ' . round(end($points)['x'], 1) . ' ' . $chLineBottom
                              . ' L ' . round($points[0]['x'], 1) . ' ' . $chLineBottom
                              . ' Z';
                    if ($chVolume) {
                        // Area uses its own vertical gradient fading to transparent.
                        $_areaGradId = 'impulse-chart-area-grad-' . esc_attr($chId);
                        echo '<defs><linearGradient id="' . esc_attr($_areaGradId) . '" x1="0" x2="0" y1="0" y2="1">'
                           . '<stop offset="0%" stop-color="' . esc_attr($chLineColor) . '" stop-opacity="0.55"/>'
                           . '<stop offset="100%" stop-color="' . esc_attr($chLineColor) . '" stop-opacity="0"/>'
                           . '</linearGradient></defs>';
                        echo '<path class="impulse-chart-line-area" d="' . esc_attr($areaPath) . '" fill="url(#' . esc_attr($_areaGradId) . ')" style="' . esc_attr($_lineAreaAnim) . '"></path>';
                    } else {
                        echo '<path class="impulse-chart-line-area" d="' . esc_attr($areaPath) . '" fill="' . esc_attr($chLineColor) . '" fill-opacity="0.18" style="' . esc_attr($_lineAreaAnim) . '"></path>';
                    }
                }
                if ($chVolume) {
                    // Thicker ribbon stroke, gradient fill, drop shadow.
                    echo '<path class="impulse-chart-line-path" d="' . esc_attr($linePath) . '" fill="none" stroke="url(#' . esc_attr($_gradId) . ')" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" filter="url(#' . esc_attr($_shadowId) . ')" style="' . esc_attr($_lineDrawAnim) . '"></path>';
                } else {
                    echo '<path class="impulse-chart-line-path" d="' . esc_attr($linePath) . '" fill="none" stroke="' . esc_attr($chLineColor) . '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="' . esc_attr($_lineDrawAnim) . '"></path>';
                }
                // Point markers + axis labels
                foreach ($points as $p) {
                    $r = $p['row'];
                    $pct = 0; $pctSum = 0; foreach ($chRows as $_r2) $pctSum += $_r2['value']; if ($pctSum > 0) $pct = $r['value'] / $pctSum * 100;
                    // Dot pops in after the line finishes drawing (1100ms baseline +
                    // 90ms per dot). Overshoot easing gives a bouncy feel.
                    $_dotAnim = $chAnimateEnt ? 'animation:impulse-chart-pop 380ms cubic-bezier(0.4, 0, 0.2, 1) ' . (3200 + $p['i'] * 180) . 'ms both;transform-origin:' . round($p['x'], 1) . 'px ' . round($p['y'], 1) . 'px;' : '';
                    echo '<circle class="impulse-chart-slice" cx="' . esc_attr(round($p['x'], 1)) . '" cy="' . esc_attr(round($p['y'], 1)) . '" r="5" fill="' . esc_attr($r['color']) . '" stroke="#fff" stroke-width="2" data-slice-index="' . esc_attr($p['i']) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" data-pct="' . number_format($pct, 1) . '" style="' . esc_attr($_dotAnim) . 'transition:r 0.2s ease-in-out, filter 0.2s ease-in-out;cursor:default"></circle>';
                    if ($chLabelFormat === 'value') {
                        echo '<text x="' . esc_attr(round($p['x'], 1)) . '" y="' . esc_attr(round($p['y'] - 10, 1)) . '" text-anchor="middle" fill="#555" font-size="11" font-weight="600">' . esc_html((string)$r['value']) . '</text>';
                    } elseif ($chLabelFormat === 'percent' || $chLabelFormat === 'label_percent') {
                        echo '<text x="' . esc_attr(round($p['x'], 1)) . '" y="' . esc_attr(round($p['y'] - 10, 1)) . '" text-anchor="middle" fill="#555" font-size="11" font-weight="600">' . number_format($pct, 0) . '%</text>';
                    }
                    echo '<text x="' . esc_attr(round($p['x'], 1)) . '" y="' . (esc_attr($chH - $chPadY / 2)) . '" text-anchor="middle" fill="#888" font-size="11">' . esc_html(mb_strimwidth($r['label'], 0, 12, '…')) . '</text>';
                }
                echo '</svg>';
            } elseif ($chType === 'bars') {
                // Compact horizontal bar list. Each row: label (left) → faded track
                // with coloured fill → optional value (right). Values are taken as
                // percentages (0-100) directly — no normalisation against the row
                // max — so the bar widths read as literal "this skill at 80%".
                echo '<div class="impulse-chart-bars-list" style="width:100%;display:flex;flex-direction:column;gap:14px;padding:4px 0">';
                foreach ($chRows as $i => $r) {
                    $pct     = max(0, min(100, $r['value']));
                    $valTxt  = '';
                    if ($chLabelFormat === 'percent' || $chLabelFormat === 'label_percent') {
                        $valTxt = number_format($pct, 0) . '%';
                    } elseif ($chLabelFormat === 'value') {
                        $valTxt = (string)$r['value'];
                    }
                    $rowDelay = $chAnimateEnt ? ($i * 120) : 0;
                    $fillAnim = $chAnimateEnt
                        ? 'animation:impulse-chart-bars-grow 800ms cubic-bezier(0.4, 0, 0.2, 1) ' . $rowDelay . 'ms both;transform-origin:left center;'
                        : '';
                    echo '<div class="impulse-chart-bars-row" style="display:flex;align-items:center;gap:14px">';
                    echo '<div class="impulse-chart-bars-label" style="flex:0 0 110px;font-size:14px;font-weight:600;color:inherit;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' . esc_html($r['label']) . '</div>';
                    echo '<div class="impulse-chart-bars-track" style="flex:1 1 auto;position:relative;height:10px;background:rgba(0,0,0,0.1);overflow:hidden">';
                    echo '<div class="impulse-chart-slice" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" style="position:absolute;left:0;top:0;bottom:0;width:' . esc_attr($pct) . '%;background:' . esc_attr($r['color']) . ';' . esc_attr($fillAnim) . '"></div>';
                    echo '</div>';
                    if ($valTxt !== '') {
                        echo '<div class="impulse-chart-bars-value" style="flex:0 0 auto;font-size:12px;color:inherit;opacity:0.6;font-weight:600;min-width:34px;text-align:right">' . esc_html($valTxt) . '</div>';
                    }
                    echo '</div>';
                }
                echo '</div>';
            } elseif ($chType === 'radial') {
                // Radial progress — single value (first entry), drawn as a filled arc
                // over a muted background ring. Value treated as percent (0-100).
                // User-configurable diameter + stroke thickness so dashboards and
                // hero KPIs can share one block type with different visual weights.
                $chSize = 280;
                $chCx = $chSize / 2; $chCy = $chSize / 2;
                $chRingDiameter = max(0, min(90, intval($settings['chart_ring_diameter'] ?? 70)));
                $chStrokeW = max(4, min(40, intval($settings['chart_ring_thickness'] ?? 18)));
                // Ring radius grows with diameter %: 90% = nearly touches the SVG edge.
                $chR = ($chSize / 2 - $chStrokeW / 2 - 6) * ($chRingDiameter / 90);
                if ($chR < $chStrokeW) $chR = $chStrokeW; // sanity clamp
                $first = $chRows[0];
                $val = max(0, min(100, floatval($first['value'])));
                // Full circle circumference; use stroke-dasharray to show progress.
                $circ = 2 * M_PI * $chR;
                $filled = $circ * ($val / 100);
                $gap = $circ - $filled;
                echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . esc_attr($chSize) . ' ' . esc_attr($chSize) . '" width="' . esc_attr($chSize) . '" height="' . esc_attr($chSize) . '" style="overflow:visible;display:block">';
                // Volume mode: tube appearance — radial gradient across the ring
                // band (inner edge brighter, outer edge darker) via SVG pattern,
                // plus a soft drop shadow giving the ring lift off the background.
                if ($chVolume) {
                    $_ringDark  = impulse_chart_shade($first['color'], 0.55);
                    $_ringLight = impulse_chart_shade($first['color'], 1.18);
                    $_rGrad = 'impulse-chart-radial-grad-' . esc_attr($chId);
                    $_rShadow = 'impulse-chart-radial-shadow-' . esc_attr($chId);
                    // Linear gradient works as a band-cross because we rotate the
                    // stroke path — use one aligned to the ring radius via rotateY.
                    echo '<defs>';
                    echo '<radialGradient id="' . esc_attr($_rGrad) . '" cx="0.5" cy="0.5" r="0.5">'
                       . '<stop offset="' . esc_attr(round(max(0, ($chR - $chStrokeW / 2) / $chR) * 100)) . '%" stop-color="' . esc_attr($_ringLight) . '"/>'
                       . '<stop offset="' . esc_attr(round(min(100, ($chR + $chStrokeW / 2) / $chR) * 100)) . '%" stop-color="' . esc_attr($_ringDark) . '"/>'
                       . '</radialGradient>';
                    echo '<filter id="' . esc_attr($_rShadow) . '" x="-20%" y="-20%" width="140%" height="140%">';
                    echo '<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>';
                    echo '<feOffset dx="0" dy="6" result="off"/>';
                    echo '<feComponentTransfer result="shadow"><feFuncA type="linear" slope="0.28"/></feComponentTransfer>';
                    echo '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
                    echo '</filter>';
                    echo '</defs>';
                }
                // Background ring
                echo '<circle cx="' . esc_attr($chCx) . '" cy="' . esc_attr($chCy) . '" r="' . esc_attr($chR) . '" fill="none" stroke="rgba(0,0,0,0.08)" stroke-width="' . esc_attr($chStrokeW) . '"></circle>';
                // Progress ring — rotated -90° so it starts at the top.
                // When entry animation is on, the ring is rendered empty (0 filled)
                // and the inline JS below sweeps it: 0 → full circle → target value.
                // data-circ / data-filled / data-gap carry the target values for the
                // JS to animate to. The CSS transition on stroke-dasharray (1.2s for
                // the sweep, 0.5s for the settle) is set in JS so non-animated charts
                // don't animate on later hover updates.
                $_dasharrayInit = $chAnimateEnt
                    ? '0 ' . round($circ, 2)
                    : round($filled, 2) . ' ' . round($gap, 2);
                // WAAPI animation attached below in the chart's <script> block
                // drives the dasharray growth. Ring stays at 12 o'clock via
                // the static SVG transform attr.
                $_ringCommonAttrs = 'cx="' . $chCx . '" cy="' . $chCy . '" r="' . $chR . '" fill="none" stroke-width="' . $chStrokeW . '" stroke-linecap="round" stroke-dasharray="' . $_dasharrayInit . '" transform="rotate(-90 ' . $chCx . ' ' . $chCy . ')"'
                    . ' data-slice-index="0" data-label="' . esc_attr($first['label']) . '" data-value="' . esc_attr((string)$first['value']) . '" data-pct="' . number_format($val, 1) . '"'
                    . ' data-circ="' . round($circ, 2) . '" data-filled="' . round($filled, 2) . '" data-gap="' . round($gap, 2) . '"';
                if ($chVolume) {
                    echo '<circle class="impulse-chart-slice" ' . $_ringCommonAttrs . ' stroke="url(#' . esc_attr($_rGrad) . ')" filter="url(#' . esc_attr($_rShadow) . ')" style="cursor:default"></circle>';
                } else {
                    echo '<circle class="impulse-chart-slice" ' . $_ringCommonAttrs . ' stroke="' . esc_attr($first['color']) . '" style="cursor:default"></circle>';
                }
                // Centre text — big value + small label beneath.
                echo '<text x="' . esc_attr($chCx) . '" y="' . esc_attr($chCy) . '" text-anchor="middle" dominant-baseline="middle" fill="#1d2327" font-size="52" font-weight="700" style="pointer-events:none">' . number_format($val, 0) . '%</text>';
                if (!empty($first['label'])) {
                    echo '<text x="' . esc_attr($chCx) . '" y="' . (esc_attr($chCy + 36)) . '" text-anchor="middle" fill="#888" font-size="13" style="pointer-events:none">' . esc_html($first['label']) . '</text>';
                }
                echo '</svg>';
            }
            // ── LEGEND ──────────────────────────────────────────────────────────
            // Bar charts always label every bar on its axis, so a legend is a second
            // copy of the same information — skip it.
            // Radial charts show the category name under the big centre percentage,
            // so a legend would show "Category A / Category A" (centre + legend) —
            // skip it too. Legend is only useful for pie and line.
            if ($chLegendPos !== 'off' && $chType !== 'bar' && $chType !== 'radial' && $chType !== 'bars') {
                $legendRows = $chRows;
                $legendStyle = 'display:flex;flex-direction:column;gap:8px;min-width:0;max-width:260px';
                echo '<div class="impulse-chart-legend" style="' . esc_attr($legendStyle) . '">';
                foreach ($legendRows as $i => $r) {
                    echo '<div class="impulse-chart-legend-item" style="display:flex;align-items:center;gap:8px;font-size:12px;color:#1d2327" data-slice-index="' . esc_attr($i) . '">';
                    echo '<span style="width:16px;height:16px;border-radius:3px;background:' . esc_attr($r['color']) . ';flex-shrink:0;display:inline-block"></span>';
                    echo '<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' . esc_html($r['label']) . '</span>';
                    echo '</div>';
                }
                echo '</div>';
            }
            // Mobile stacking + subtle "alive" animations (gated on reduced-motion).
            // Sway is skipped when tilt_sens > 0 — the JS tilt writes transform each
            // frame and the CSS animation would fight it, making the slider feel dead.
            $chSwayRule = ($chTiltSens > 0)
                ? ''
                : '#' . esc_attr($chId) . '.impulse-chart-tilted_ring .impulse-chart-tilt{animation:impulse-chart-sway 7s ease-in-out infinite}';
            // Breathe animation (translateY up/down) is fine for pie/radial where the
            // shape is symmetrical, but on bar charts it visually moves the baseline
            // up and down — reads as a bouncing horizontal line under the bars. Skip
            // breathe for bar type so the baseline stays rock-steady.
            $chBreatheRule = ($chType === 'bar')
                ? ''
                : '#' . esc_attr($chId) . ' .impulse-chart-svg{animation:impulse-chart-breathe 6s ease-in-out infinite}';
            // Entry animation keyframes — pure CSS, apply via inline `animation`
            // property on each element at render time. No JS / WAAPI /
            // IntersectionObserver race; the animation runs as soon as the
            // browser parses the element. animation-fill-mode:both (on the
            // inline `animation:` shorthand) keeps the final keyframe state
            // after the animation completes.
            $chAnimCss = '@media(max-width:640px){#' . esc_attr($chId) . '{flex-direction:column}#' . esc_attr($chId) . ' .impulse-chart-legend{max-width:100%}}'
                       . '@keyframes impulse-chart-breathe{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}'
                       . '@keyframes impulse-chart-sway{0%,100%{transform:rotateX(-38deg) rotateY(-1.2deg)}50%{transform:rotateX(-38deg) rotateY(1.2deg)}}'
                       . '@keyframes impulse-radial-pulse{0%,100%{transform:rotate(-90deg) scale(1)}50%{transform:rotate(-90deg) scale(1.015)}}'
                       . '@keyframes impulse-chart-fadein{from{opacity:0}to{opacity:1}}'
                       . '@keyframes impulse-chart-rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}'
                       . '@keyframes impulse-chart-pop{from{opacity:0;transform:scale(0.3)}to{opacity:1;transform:scale(1)}}'
                       . '@keyframes impulse-chart-stroke-fade{to{stroke-opacity:1}}'
                       . '@keyframes impulse-chart-bars-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}';
            // (Mask-based sweep keyframe removed — per-slice stagger in PHP handles
            // the reveal without needing a mask.)
            // Radial sweep keyframes — unique per chart since filled/gap values differ.
            if ($chType === 'radial' && $chAnimateEnt && !empty($chRows)) {
                $_r0 = $chRows[0];
                $_v = max(0, min(100, floatval($_r0['value'])));
                $_r = ($chSize = 280) / 2 - (max(4, min(40, intval($settings['chart_ring_thickness'] ?? 18)))) / 2 - 6;
                $_rr = $_r * (max(0, min(90, intval($settings['chart_ring_diameter'] ?? 70))) / 90);
                $_circ = 2 * M_PI * $_rr;
                $_filled = $_circ * ($_v / 100);
                $_gap = $_circ - $_filled;
                // Radial animation is emitted inline via SMIL on the <circle>
                // (see the pie/radial render block above) — no CSS keyframe
                // needed here. Left this block empty so the surrounding @media
                // and breathe/sway rules keep appending cleanly to $chAnimCss.
            }
            // Line path draw-in — uses a large enough dasharray that the line
            // sweep works without knowing the path length server-side.
            if ($chType === 'line' && $chAnimateEnt) {
                $chAnimCss .= '@keyframes impulse-chart-line-draw{from{stroke-dashoffset:3000}to{stroke-dashoffset:0}}'
                            . '#' . esc_attr($chId) . ' .impulse-chart-line-path{stroke-dasharray:3000}';
            }
            $chAnimCss .= '@media(prefers-reduced-motion:no-preference){'
                       .   $chBreatheRule
                       .   $chSwayRule
                       . '}';
            wp_add_inline_style('instinctor-page-inline', $chAnimCss);
            echo '</div>'; // .impulse-chart

            // Chart interactions — hover highlight, tooltip, tilt for tilted_ring.
            // Entry animations are CSS/SMIL for most chart types.
            // Radial is the exception: WAAPI el.animate() drives its stroke-dasharray
            // — proven working approach from 2.1.135, restored after several
            // attempts at CSS/SMIL variants that failed to render in the editor iframe.
            ob_start();
            echo '(function(){var root=document.getElementById(' . wp_json_encode($chId) . ');if(!root)return;';
            echo 'if(root.dataset.animate==="1"&&root.dataset.chartType==="radial"&&typeof Element.prototype.animate==="function"&&!matchMedia("(prefers-reduced-motion:reduce)").matches){';
            echo '  var _radEl=root.querySelector("circle.impulse-chart-slice");';
            echo '  if(_radEl){';
            echo '    var _c=parseFloat(_radEl.getAttribute("data-circ"))||0;';
            echo '    var _f=parseFloat(_radEl.getAttribute("data-filled"))||0;';
            echo '    var _g=parseFloat(_radEl.getAttribute("data-gap"))||0;';
            echo '    if(_c>0){';
            // Arc grows directly from empty to its target value — no full-circle
            // overshoot, no bounce. Clean ease-in-out from 0 to filled.
            echo '      _radEl.animate([';
            echo '        {strokeDasharray:"0 "+_c},';
            echo '        {strokeDasharray:_f+" "+_g}';
            echo '      ],{duration:1200,easing:"cubic-bezier(0.4, 0, 0.2, 1)",fill:"both"});';
            echo '    }';
            echo '  }';
            echo '}';
            // Tooltip
            echo 'var tip=document.createElement("div");tip.style.cssText="position:fixed;pointer-events:none;background:rgba(29,35,39,0.92);color:#fff;padding:6px 10px;border-radius:4px;font-size:12px;font-weight:500;z-index:10000;opacity:0;transition:opacity 0.15s;white-space:nowrap;box-shadow:0 4px 16px rgba(0,0,0,0.25)";document.body.appendChild(tip);';
            echo 'function showTip(e,label,value,pct){tip.innerHTML=label+"<span style=\"opacity:0.7;margin-left:6px\">"+(pct?(pct+"%"):value)+"</span>";tip.style.left=(e.clientX+12)+"px";tip.style.top=(e.clientY-24)+"px";tip.style.opacity="1";}';
            echo 'function hideTip(){tip.style.opacity="0";}';
            echo 'root.querySelectorAll(".impulse-chart-slice").forEach(function(s){';
            echo '  s.addEventListener("mousemove",function(e){showTip(e,s.getAttribute("data-label"),s.getAttribute("data-value"),s.getAttribute("data-pct"));});';
            echo '  s.addEventListener("mouseleave",hideTip);';
            // Pie hover lift — slice scales up slightly
            echo '  s.addEventListener("mouseenter",function(){s.style.filter="brightness(1.1) drop-shadow(0 2px 8px rgba(0,0,0,0.15))";if(root.dataset.chartType==="pie")s.style.transform="scale(1.03)";});';
            echo '  s.addEventListener("mouseleave",function(){s.style.filter="";s.style.transform="";});';
            echo '});';
            // Legend highlight parity — hovering a legend item highlights matching slice
            echo 'root.querySelectorAll(".impulse-chart-legend-item").forEach(function(li){';
            echo '  li.addEventListener("mouseenter",function(){var i=li.getAttribute("data-slice-index");var sl=root.querySelector(".impulse-chart-slice[data-slice-index=\""+i+"\"]");if(sl){sl.style.filter="brightness(1.1) drop-shadow(0 2px 8px rgba(0,0,0,0.15))";if(root.dataset.chartType==="pie")sl.style.transform="scale(1.03)";}});';
            echo '  li.addEventListener("mouseleave",function(){var i=li.getAttribute("data-slice-index");var sl=root.querySelector(".impulse-chart-slice[data-slice-index=\""+i+"\"]");if(sl){sl.style.filter="";sl.style.transform="";}});';
            echo '});';
            // Tilt bias — only for tilted_ring style with tilt_sens > 0.
            echo 'var sens=parseInt(root.dataset.tiltSens||"0",10)/10;';
            echo 'if(sens>0&&root.dataset.chartStyle==="tilted_ring"){';
            echo '  var tilt=root.querySelector(".impulse-chart-tilt");';
            echo '  if(tilt){var baseRx=-38,mx=0,my=0,gx=0,gy=0,rx=baseRx,ry=0,rxT=baseRx,ryT=0,ease=null;';
            echo '    function setTarget(){rxT=baseRx+(my+gy)*sens*10;ryT=(mx+gx)*sens*20;if(!ease)ease=requestAnimationFrame(frame);}';
            echo '    function frame(){rx+=(rxT-rx)*0.1;ry+=(ryT-ry)*0.1;tilt.style.transform="rotateX("+rx.toFixed(2)+"deg) rotateY("+ry.toFixed(2)+"deg)";if(Math.abs(rxT-rx)>0.05||Math.abs(ryT-ry)>0.05){ease=requestAnimationFrame(frame);}else{ease=null;}}';
            echo '    root.addEventListener("mousemove",function(e){var r=root.getBoundingClientRect();mx=((e.clientX-r.left)/r.width)*2-1;my=((e.clientY-r.top)/r.height)*2-1;setTarget();});';
            echo '    root.addEventListener("mouseleave",function(){mx=0;my=0;setTarget();});';
            echo '    var bindGyro=function(){window.addEventListener("deviceorientation",function(e){var g=(e.gamma||0)/45,b=((e.beta||0)-30)/45;if(g>1)g=1;else if(g<-1)g=-1;if(b>1)b=1;else if(b<-1)b=-1;gx=g;gy=b;setTarget();});};';
            echo '    if(typeof DeviceOrientationEvent!=="undefined"&&typeof DeviceOrientationEvent.requestPermission==="function"){document.addEventListener("touchstart",function(){DeviceOrientationEvent.requestPermission().then(function(r){if(r==="granted")bindGyro();}).catch(function(){});},{once:true});}else if("ontouchstart" in window){bindGyro();}';
            echo '  }';
            echo '}';
            // Radial mouse tilt — rotates the ring back and forth with mouse X.
            // Uses the SVG transform attribute directly (WAAPI animates only
            // stroke-dasharray on the same element, so transform is free).
            echo 'if(sens>0&&root.dataset.chartType==="radial"){';
            echo '  var _rEl=root.querySelector("circle.impulse-chart-slice");';
            echo '  if(_rEl){';
            echo '    var _rCx=parseFloat(_rEl.getAttribute("cx"))||0;';
            echo '    var _rCy=parseFloat(_rEl.getAttribute("cy"))||0;';
            echo '    var _rBase=-90, _rMx=0, _rMxT=0, _rRaf=null;';
            echo '    function _rFrame(){_rMx+=(_rMxT-_rMx)*0.12;var _rot=_rBase+(_rMx*sens*20);_rEl.setAttribute("transform","rotate("+_rot.toFixed(2)+" "+_rCx+" "+_rCy+")");if(Math.abs(_rMxT-_rMx)>0.02){_rRaf=requestAnimationFrame(_rFrame);}else{_rRaf=null;}}';
            echo '    root.addEventListener("mousemove",function(e){var r=root.getBoundingClientRect();_rMxT=((e.clientX-r.left)/r.width)*2-1;if(!_rRaf)_rRaf=requestAnimationFrame(_rFrame);});';
            echo '    root.addEventListener("mouseleave",function(){_rMxT=0;if(!_rRaf)_rRaf=requestAnimationFrame(_rFrame);});';
            echo '  }';
            echo '}';
            echo '})();';
            $_pgJs6 = ob_get_clean();
            wp_add_inline_script('instinctor-frontend-js', $_pgJs6, 'after');
            break;

        // ================================================================
        // PRICE LIST  — delegates to the canonical renderer in
        // includes/element-renderers/price_list.php (loaded via _loader).
        // ================================================================
        // v2.1.1337: the old inline body of this case had been a divergent
        // legacy renderer that only knew 3 of the block's 9 separator
        // styles (`dots` / `line` / `dashes`) and read `description`
        // instead of `desc` for the item subtitle. Pages that picked any
        // of the other 6 styles in the panel (fade / wave / zigzag /
        // pulse / shimmer / solid / none / dashed in the new lexicon)
        // landed in the bare-spacer `else` branch and rendered an empty
        // gap between title and price — which is exactly what Sergei
        // reported on infinite2delivery.com/admin?p=2 screen 3. Routing
        // through the shared `impulse_element_render_price_list()` keeps
        // every screen consistent and prevents this drift from
        // happening again.
        case 'price_list':
            echo impulse_element_render_price_list($el, $settings, 'frontend');
            break;

        case 'posts':
            // Blog index — auto-lists published type='post' pages.
            echo impulse_element_render_posts($el, $settings, 'frontend');
            break;

        // ================================================================
        // CONTACT FORM
        // ================================================================
        case 'contact':
            // Shared renderer (see includes/element-renderers/contact.php).
            // The frontend MUST delegate to the same function the editor uses
            // (impulse-preview.php case 'contact'), or the two diverge. The old
            // inline copy that lived here was the pre-unification frontend
            // prototype and had silently rotted: it emitted an INVALID
            // <input type="select"> (so every dropdown rendered as a plain text
            // box on the live site) and a native <input type="time"> (24h or
            // locale-dependent, value always 24h). The shared renderer handles
            // select / checkbox / date + AM/PM time-slot dropdowns and keeps the
            // frontend nonce + honeypot. WYSIWYG parity restored. v2.1.1747.
            echo impulse_element_render_contact($el, $settings, 'frontend');
            break;

        // ================================================================
        // LOGIN FORM
        // ================================================================
        case 'login':
            // Shared renderer (see includes/element-renderers/login.php).
            echo impulse_element_render_login($el, $settings, 'frontend');
            break;

        case 'register':
            // Shared renderer (see includes/element-renderers/register.php).
            echo impulse_element_render_register($el, $settings, 'frontend');
            break;

        case 'cart':
            $GLOBALS['instinctor_cart_settings'] = $settings;
            echo do_shortcode('[instinctor_cart]');
            unset($GLOBALS['instinctor_cart_settings']);
            break;

        // ================================================================
        // MINI CART
        // ================================================================
        case 'mini_cart':
            // Mini cart renders via cart shortcode with mini mode
            $settings['cart_mode'] = 'mini';
            $GLOBALS['instinctor_cart_settings'] = $settings;
            echo do_shortcode('[instinctor_cart]');
            unset($GLOBALS['instinctor_cart_settings']);
            break;

        // ================================================================
        // CHECKOUT
        // ================================================================
        case 'checkout':
            $GLOBALS['impulse_checkout_settings'] = $settings;
            echo do_shortcode('[instinctor_checkout]');
            unset($GLOBALS['impulse_checkout_settings']);
            break;

        // ================================================================
        // POPUP
        // ================================================================
        case 'modal':
            // Shared renderer (see includes/element-renderers/modal.php).
            echo impulse_element_render_modal($el, $settings, $children, 'frontend', 'impulse_render_frontend_element');
            break;

        case 'booking':
            // Shared renderer (see includes/element-renderers/booking.php):
            // carries the deposit / no-show payment flow. The frontend was a
            // stale inline copy (same two-renderers fix as the contact block).
            echo impulse_element_render_booking($el, $settings, 'frontend');
            break;

        case 'schedule':
            // Member-facing class timetable (Instructor / Fitness vertical).
            echo impulse_element_render_schedule($el, $settings, 'frontend');
            break;

        case 'carousel':
            $GLOBALS['impulse_needs_carousel'] = true; $GLOBALS['impulse_has_carousel'] = true;
            $height = intval($settings['height'] ?? 400);
            $borderRadius = intval($settings['border_radius'] ?? 0);
            $itemsToShow = intval($settings['items_to_show'] ?? 1);
            if ($itemsToShow < 1) $itemsToShow = 1;
            $gap = intval($settings['gap'] ?? 0);
            $objectFit = $settings['object_fit'] ?? 'cover';
            $animMode = $settings['animation_mode'] ?? 'scroll';
            $direction = $settings['direction'] ?? 'left';
            $speed = max(3, round(60 / max(1, intval($settings['scroll_speed'] ?? 3))));
            $autoplay = ($settings['autoplay'] ?? true) !== false;
            $autoplaySpeed = intval($settings['autoplay_speed'] ?? 4);
            if ($autoplaySpeed <= 100) $autoplaySpeed *= 1000;
            $pauseHover = ($settings['pause_hover'] ?? true) !== false;
            // `slider_width` is the panel's canonical key (renamed from `max_width`); fall
            // back to `max_width` for legacy saves. Without this parity the frontend
            // always defaulted to 100% and the Max Width slider looked broken on the
            // public page even though the editor applied it correctly.
            $maxWidth = intval($settings['slider_width'] ?? $settings['max_width'] ?? 100);
            if ($maxWidth > 100) $maxWidth = 100;
            if ($maxWidth < 1) $maxWidth = 100;
            // Arc tilt for snap/fade/flyin modes (slides sit still at rest between
            // transitions). Other modes are animation-driven so the arc would fight them.
            $arcIntensity     = max(0, min(100, intval($settings['arc_intensity'] ?? 0)));
            $arcModeSupported = false;
            $arcActive        = $arcIntensity > 0 && $arcModeSupported && $itemsToShow > 1;
            $arcMaxAngle      = 25.0;
            $arcAngleScale    = $arcIntensity / 100.0;
            $arcCenterIdx     = ($itemsToShow - 1) / 2.0;
            $arcCompute = function ($slideIdx) use ($arcActive, $itemsToShow, $arcCenterIdx, $arcMaxAngle, $arcAngleScale) {
                if (!$arcActive || $arcCenterIdx <= 0) return 0;
                $rel = ($arcCenterIdx - ($slideIdx % $itemsToShow)) / $arcCenterIdx;
                return round($rel * $arcMaxAngle * $arcAngleScale, 2);
            };
            $images = $settings['images'] ?? [];
            if (is_string($images)) $images = json_decode($images, true) ?: [];
            $slideCount = count($images);
            if ($slideCount < 1) break;
            $carouselId = 'carousel-' . ($el['id'] ?? wp_rand(1000,9999));
            $slideWidth = $itemsToShow > 1
                ? 'calc(' . (100 / $itemsToShow) . '% - ' . ($gap * ($itemsToShow - 1) / $itemsToShow) . 'px)'
                : '100%';

            // Modes where slides extend into 3D space (rotate, lean, move in Z) need
            // overflow:visible so tilted/receding slides aren't guillotined at the
            // wrapper/track edges. Marquee-style modes keep overflow:hidden because
            // they rely on it to hide off-frame horizontal content.
            $_3dTiltModes = ['coverflow','cube','conveyor','depth_rush','portal','table','orbit'];
            $_overflowWrap  = in_array($animMode, $_3dTiltModes) ? 'visible' : 'hidden';
            $_overflowTrack = in_array($animMode, $_3dTiltModes) ? 'visible' : 'hidden';
            // Tilt/mouse sensitivity for 3D rotational modes (orbit/table/cube).
            // Defaults to 5 so the feature is discoverable on first drop.
            $_imgTiltSensAttr = '';
            if (in_array($animMode, ['orbit', 'table', 'cube'], true)) {
                $_imgTiltSensVal = isset($settings['tilt_sens']) ? max(0, min(10, intval($settings['tilt_sens']))) : 5;
                $_imgTiltSensAttr = ' data-tilt-sens="' . $_imgTiltSensVal . '"';
            }
            echo '<div id="' . esc_attr($carouselId) . '" class="impulse-carousel impulse-image-carousel"'
                . ' data-mode="' . esc_attr($animMode) . '"'
                . ' data-direction="' . esc_attr($direction) . '"'
                . ' data-speed="' . esc_attr($speed) . '"'
                . ' data-autoplay="' . ($autoplay ? 'true' : 'false') . '"'
                . ' data-interval="' . esc_attr($autoplaySpeed) . '"'
                . ' data-pause="' . ($pauseHover ? 'true' : 'false') . '"'
                . ' data-items="' . esc_attr($itemsToShow) . '"'
                . ' data-gap="' . esc_attr($gap) . '"'
                . ' data-slide-count="' . esc_attr($slideCount) . '"'
                . $_imgTiltSensAttr
                . ' style="position:relative;border-radius:' . esc_attr($borderRadius) . 'px;overflow:' . esc_attr($_overflowWrap) . ';width:' . esc_attr($maxWidth) . '%;margin:0 auto">';
            // Arc perspective on the track when active — same model as Plans arc:
            // `center top` vanishing point so rotated slide tops stay on the same screen Y.
            $trackExtra = $arcActive ? ';perspective:1200px;perspective-origin:center top;transform-style:preserve-3d' : '';
            echo '<div class="impulse-carousel-track" style="overflow:' . esc_attr($_overflowTrack) . ';height:' . esc_attr($height) . 'px' . esc_attr($trackExtra) . '">';

            // Helper to get image URL
            $getImgUrl = function($img) {
                return $img['full_url'] ?? ($img['url'] ?? '');
            };

            if ($animMode === 'scroll') {
                // Always emit enough repetitions so the marquee never reveals empty space.
                // The scroll animation wraps every `setWidth` pixels (= one original-slide
                // set). The visible track needs to contain at least two full sets + the
                // viewport window at every moment. Min 5 sets, max 12 DOM copies.
                $totalSets = $slideCount > 0 ? min(12, max(5, ceil(($itemsToShow * 6) / $slideCount))) : 5;
                echo '<div class="impulse-carousel-slides" style="display:flex;gap:' . esc_attr($gap) . 'px;height:100%">';
                for ($s = 0; $s < $totalSets; $s++) {
                    foreach ($images as $img) {
                        $url = $getImgUrl($img); $link = $img['link'] ?? '';
                        echo '<div class="impulse-carousel-slide" style="flex:0 0 ' . esc_attr($slideWidth) . ';min-width:0;height:100%">';
                        if ($url) {
                            if ($link) echo '<a href="' . esc_url($link) . '" class="imp-fill">';
                            echo '<img src="' . esc_url($url) . '" style="width:100%;height:100%;object-fit:' . esc_attr($objectFit) . ';display:block" loading="lazy">';
                            if ($link) echo '</a>';
                        }
                        echo '</div>';
                    }
                }
                echo '</div>';
            } elseif ($animMode === 'slots' || $animMode === 'jackpot' || $animMode === 'reels') {
                $isJackpot = ($animMode === 'jackpot'); $isReels = ($animMode === 'reels');
                $repeats = $isJackpot ? ($slideCount * 4 + 1) : ($isReels ? ($slideCount * 3 + 1) : ($slideCount + 1));
                echo '<div class="impulse-carousel-slides impulse-slots-container" data-items="' . esc_attr($itemsToShow) . '" style="display:flex;gap:' . esc_attr($gap) . 'px;width:100%;height:100%">';
                for ($reel = 0; $reel < $itemsToShow; $reel++) {
                    echo '<div class="impulse-slot-reel" data-reel="' . esc_attr($reel) . '" data-count="' . esc_attr($slideCount) . '" style="flex:0 0 ' . esc_attr($slideWidth) . ';height:100%;overflow:hidden;position:relative">';
                    echo '<div class="impulse-slot-strip" style="position:absolute;top:0;left:0;width:100%">';
                    for ($r = 0; $r < $repeats; $r++) {
                        $img = $images[($reel + $r) % $slideCount]; $url = $getImgUrl($img);
                        echo '<div class="impulse-slot-item" style="width:100%;height:' . esc_attr($height) . 'px">';
                        if ($url) echo '<img src="' . esc_url($url) . '" style="width:100%;height:100%;object-fit:' . esc_attr($objectFit) . ';display:block" loading="lazy">';
                        echo '</div>';
                    }
                    echo '</div></div>';
                }
                echo '</div>';
            } elseif (in_array($animMode, ['depth_rush','card_flip','pixel_decode','coverflow','cube','conveyor','portal'])) {
                // Shared render for modes where JS positions absolute-stacked slides:
                // classic transitional modes + the new 3D family (coverflow, cube, helix,
                // conveyor, depth_stack, magazine, portal). JS handles perspective +
                // transforms; PHP just ensures every slide exists in the DOM.
                $perspective = $animMode === 'depth_rush' ? 'perspective:1200px;' : ($animMode === 'card_flip' ? 'perspective:1000px;' : '');
                echo '<div class="impulse-carousel-slides" style="position:relative;width:100%;height:100%;' . esc_attr($perspective) . '">';
                foreach ($images as $idx => $img) {
                    $url = $getImgUrl($img); $link = $img['link'] ?? '';
                    $active = ($idx === 0) ? 'opacity:1;z-index:2' : 'opacity:0;z-index:0';
                    echo '<div class="impulse-carousel-slide" data-index="' . esc_attr($idx) . '" style="position:absolute;inset:0;' . esc_attr($active) . ';will-change:transform,opacity">';
                    if ($url) {
                        if ($link) echo '<a href="' . esc_url($link) . '" class="imp-fill">';
                        echo '<img src="' . esc_url($url) . '" style="width:100%;height:100%;object-fit:' . esc_attr($objectFit) . ';display:block;border-radius:' . esc_attr($borderRadius) . 'px" loading="lazy">';
                        if ($link) echo '</a>';
                    }
                    echo '</div>';
                }
                echo '</div>';
            } else {
                // Turntable/Orbit/Ring
                $minItems = 8; $totalItems = $slideCount;
                while ($totalItems < $minItems) $totalItems += $slideCount;
                echo '<div class="impulse-carousel-slides impulse-turntable" data-variant="' . esc_attr($animMode) . '" data-total="' . esc_attr($totalItems) . '" data-real="' . esc_attr($slideCount) . '" style="position:relative;width:100%;height:100%;perspective:1000px">';
                for ($i = 0; $i < $totalItems; $i++) {
                    $img = $images[$i % $slideCount]; $url = $getImgUrl($img); $link = $img['link'] ?? '';
                    echo '<div class="impulse-turntable-item" data-index="' . esc_attr($i) . '" style="position:absolute;width:30%;height:80%;top:10%;left:50%;transform:translateX(-50%);will-change:transform,filter,opacity">';
                    if ($url) {
                        if ($link) echo '<a href="' . esc_url($link) . '" class="imp-fill">';
                        echo '<img src="' . esc_url($url) . '" style="width:100%;height:100%;object-fit:' . esc_attr($objectFit) . ';display:block;border-radius:' . esc_attr($borderRadius) . 'px" loading="lazy">';
                        if ($link) echo '</a>';
                    }
                    echo '</div>';
                }
                echo '</div>';
            }
            echo '</div></div>';
            // Responsive: adjust items_to_show, gap, height AND max width per device
            $itemsTablet = intval($settings['items_to_show_tablet'] ?? min($itemsToShow, 2));
            $itemsMobile = intval($settings['items_to_show_mobile'] ?? 1);
            $gapTablet = intval($settings['gap_tablet'] ?? $gap);
            $gapMobile = intval($settings['gap_mobile'] ?? $gap);
            $heightTablet = intval($settings['height_tablet'] ?? 0);
            $heightMobile = intval($settings['height_mobile'] ?? 0);
            // Max width per device — support both `slider_width` (canonical) and legacy
            // `max_width` keys. Default mobile to 100% when no override so the block
            // fills the viewport on phones even if desktop is capped lower.
            $mwTabletRaw = $settings['slider_width_tablet'] ?? $settings['max_width_tablet'] ?? null;
            $mwMobileRaw = $settings['slider_width_mobile'] ?? $settings['max_width_mobile'] ?? null;
            $mwTablet    = $mwTabletRaw !== null ? max(1, min(100, intval($mwTabletRaw))) : $maxWidth;
            $mwMobile    = $mwMobileRaw !== null ? max(1, min(100, intval($mwMobileRaw))) : 100;
            $rCss = '';
            if ($itemsTablet !== $itemsToShow || $gapTablet !== $gap || $heightTablet > 0 || $mwTablet !== $maxWidth) {
                $rCss .= '@media(max-width:1024px){';
                if ($itemsTablet !== $itemsToShow || $gapTablet !== $gap) {
                    $twT = $itemsTablet > 1 ? 'calc(' . (100/$itemsTablet) . '% - ' . ($gapTablet*($itemsTablet-1)/$itemsTablet) . 'px)' : '100%';
                    $rCss .= '#'.$carouselId.' .impulse-carousel-slide{flex:0 0 '.$twT.'!important}';
                    $rCss .= '#'.$carouselId.' .impulse-carousel-slides{gap:'.$gapTablet.'px!important}';
                }
                if ($heightTablet > 0) $rCss .= '#'.$carouselId.' .impulse-carousel-track{height:'.$heightTablet.'px!important}';
                if ($mwTablet !== $maxWidth) $rCss .= '#'.$carouselId.'{width:'.$mwTablet.'%!important}';
                $rCss .= '}';
            }
            if ($itemsMobile !== $itemsToShow || $gapMobile !== $gap || $heightMobile > 0 || $mwMobile !== $maxWidth) {
                $rCss .= '@media(max-width:767px){';
                if ($itemsMobile !== $itemsToShow || $gapMobile !== $gap) {
                    $twM = $itemsMobile > 1 ? 'calc(' . (100/$itemsMobile) . '% - ' . ($gapMobile*($itemsMobile-1)/$itemsMobile) . 'px)' : '100%';
                    $rCss .= '#'.$carouselId.' .impulse-carousel-slide{flex:0 0 '.$twM.'!important}';
                    $rCss .= '#'.$carouselId.' .impulse-carousel-slides{gap:'.$gapMobile.'px!important}';
                }
                if ($heightMobile > 0) $rCss .= '#'.$carouselId.' .impulse-carousel-track{height:'.$heightMobile.'px!important}';
                if ($mwMobile !== $maxWidth) $rCss .= '#'.$carouselId.'{width:'.$mwMobile.'%!important}';
                $rCss .= '}';
            }
            if ($rCss) wp_add_inline_style('instinctor-page-inline', $rCss);
            // Arc CSS + parallax/hover/blur script — only emitted for supported modes.
            // Same mental model as Plans arc: top-centre pivot, aligned top edges,
            // scaleY compensation for equal inner-side heights, blur on idle cards,
            // hover flattens the hovered slide.
            if ($arcModeSupported) {
                wp_add_inline_style('instinctor-page-inline', '#' . $carouselId . ' .impulse-carousel-arc .impulse-carousel-slide{transform-origin:center top;backface-visibility:hidden;transition:transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),filter 0.3s ease-in-out;will-change:transform,filter}'
                    . '@media(max-width:768px){#' . $carouselId . ' .impulse-carousel-arc .impulse-carousel-slide{transform:none !important;filter:none !important}}');
                ob_start();
                echo '(function(){'
                    . 'var grid=document.getElementById(' . wp_json_encode($carouselId) . ');'
                    . 'if(!grid)return;var row=grid.querySelector(".impulse-carousel-slides");'
                    . 'if(!row)return;'
                    . 'var cards=row.querySelectorAll(":scope > .impulse-carousel-slide");'
                    . 'if(cards.length<2)return;'
                    . 'function readBase(){return Array.prototype.map.call(cards,function(c){return parseFloat(c.getAttribute("data-arc-base"))||0;});}'
                    . 'var base=readBase();var MAX=4,dx=0,dy=0,hov=new Array(cards.length).fill(false);'
                    . 'function isArc(){return row.classList.contains("impulse-carousel-arc");}'
                    . 'function arcScaleY(i){var w=cards[i].offsetWidth||0;if(!w)return 1;var s=Math.abs(Math.sin(base[i]*Math.PI/180));return 1+(w*s)/2400;}'
                    . 'function apply(){var on=isArc();cards.forEach(function(c,i){if(!on){c.style.transform="";c.style.filter="";c.removeAttribute("data-arc-hover");return;}var absBase=Math.abs(base[i]);if(hov[i]){c.style.transform="rotateY(0deg) rotateX(0deg) scaleY(1)";c.style.filter="none";c.setAttribute("data-arc-hover","1");}else{var sy=arcScaleY(i);c.style.transform="rotateY("+(base[i]+dx).toFixed(2)+"deg) rotateX("+(-dy).toFixed(2)+"deg) scaleY("+sy.toFixed(4)+")";var b=absBase<0.5?0:Math.min(2.5,0.4+absBase/14);c.style.filter=b>0?"blur("+b.toFixed(2)+"px)":"none";c.removeAttribute("data-arc-hover");}});}'
                    . 'row.addEventListener("mousemove",function(e){if(!isArc())return;var r=row.getBoundingClientRect();dx=((e.clientX-r.left)/r.width-0.5)*(MAX*2);dy=((e.clientY-r.top)/r.height-0.5)*(MAX*2);apply();});'
                    . 'row.addEventListener("mouseleave",function(){dx=0;dy=0;apply();});'
                    . 'cards.forEach(function(c,i){c.addEventListener("mouseenter",function(){hov[i]=true;apply();});c.addEventListener("mouseleave",function(){hov[i]=false;apply();});});'
                    . 'row._impulseArcRefresh=function(){base=readBase();apply();};'
                    . 'document.addEventListener("impulse-screen-changed",function(){setTimeout(function(){var host=row.closest(\'.impulse-screen,.impulse-el[data-type="section"]\');var anyActive=document.querySelector(\'.impulse-screen-active\');if(host&&anyActive&&!host.classList.contains("impulse-screen-active"))return;apply();},50);});'
                    . 'apply();'
                    . '})();';
                $_pgJs8 = ob_get_clean();
                wp_add_inline_script('instinctor-frontend-js', $_pgJs8, 'after');
            }
            $GLOBALS['impulse_has_carousel'] = true;
            break;
        // ================================================================
        case 'slider':
            $GLOBALS['impulse_needs_carousel'] = true; $GLOBALS['impulse_has_carousel'] = true;
            $height = intval($settings['height'] ?? 500);
            $heightMode = $settings['height_mode'] ?? 'fixed';
            $autoplay = !empty($settings['autoplay']);
            $autoplaySpeed = intval($settings['autoplay_speed'] ?? 5);
            if ($autoplaySpeed <= 100) $autoplaySpeed *= 1000;
            $direction = $settings['direction'] ?? 'right';
            $transitionSpeed = intval($settings['transition_speed'] ?? 400);
            $slides = $settings['slides'] ?? [];
            if (is_string($slides)) $slides = json_decode($slides, true) ?: [];
            if (empty($slides)) $slides = [['id'=>'slide-1','background'=>'#f5f5f5','valign'=>'center']];
            $savedCount = isset($settings['slide_count']) ? intval($settings['slide_count']) : 0;
            if ($savedCount >= 1 && $savedCount <= 20) $slides = array_slice($slides, 0, $savedCount);
            $slideCount = count($slides);
            $carouselId = 'new-slider-' . ($el['id'] ?? wp_rand(1000,9999));
            $isVertical = ($direction === 'up' || $direction === 'down');
            $sliderEffect = $settings['slider_effect'] ?? 'slide';
            // Grid-mode effects run through the image-carousel JS init which positions
            // absolute-stacked slides in 3D. Pair-wise modes (depth_rush, card_flip)
            // and plain slide stay on the new-slider init.
            $_sliderGridModes = ['coverflow','cube','conveyor','portal'];
            $isGridMode = in_array($sliderEffect, $_sliderGridModes, true);
            // Scroll is a marquee — same image-carousel init but slides flow in flex,
            // not absolute-stacked. Duplicated for seamless loop.
            $isScrollMode = ($sliderEffect === 'scroll');
            $isImgCarouselMode = $isGridMode || $isScrollMode;
            $isStackedEffect = ($sliderEffect !== 'slide' && $sliderEffect !== 'scroll');
            $slidesHgt = $heightMode === 'auto' ? 'min-height:100%' : 'height:100%';
            $flexDir = $isVertical ? 'flex-direction:column;' : '';
            $slidesStyle = $isStackedEffect
                ? 'position:relative;width:100%;' . $slidesHgt
                : 'display:flex;' . $flexDir . 'transition:transform ' . $transitionSpeed . 'ms ease-in-out;' . $slidesHgt;
            $sliderWidth = intval($settings['slider_width'] ?? 0);
            $wrapOverflow = $isImgCarouselMode ? 'visible' : 'hidden';
            $sliderWrapStyle = 'position:relative;overflow:' . $wrapOverflow . ';';
            if ($sliderWidth > 0) $sliderWrapStyle .= 'max-width:' . $sliderWidth . 'px;margin-left:auto;margin-right:auto;';

            $mobileStack = !empty($settings['mobile_stack']);
            $mobileStackAttr = $mobileStack ? ' data-mobile-stack="1"' : '';
            $wrapperClasses = $isImgCarouselMode
                ? 'impulse-carousel impulse-new-slider impulse-image-carousel'
                : 'impulse-carousel impulse-new-slider';
            // Scroll uses the JS speed data (rAF marquee); grid modes use interval for autoplay.
            $imgCarouselAttrs = '';
            if ($isImgCarouselMode) {
                $imgCarouselAttrs = ' data-mode="' . esc_attr($sliderEffect) . '"'
                    . ' data-pause="true" data-items="1" data-gap="0"'
                    . ' data-slide-count="' . $slideCount . '"';
                if ($isScrollMode) {
                    $scrollSpd = max(3, round(60 / max(1, intval($settings['scroll_speed'] ?? 3))));
                    $imgCarouselAttrs .= ' data-speed="' . $scrollSpd . '"';
                } else {
                    $imgCarouselAttrs .= ' data-interval="' . $autoplaySpeed . '"';
                }
                // Tilt/mouse sensitivity for 3D rotational effects (orbit, table, cube).
                // Scale 0-10, 0 = off. JS normalises to 0-1 multiplier.
                if (in_array($sliderEffect, ['orbit', 'table', 'cube'], true)) {
                    $tiltSens = isset($settings['tilt_sens']) ? intval($settings['tilt_sens']) : 5;
                    $tiltSens = max(0, min(10, $tiltSens));
                    $imgCarouselAttrs .= ' data-tilt-sens="' . $tiltSens . '"';
                }
            }
            echo '<div id="' . esc_attr($carouselId) . '" class="' . esc_attr($wrapperClasses) . '" data-autoplay="' . ($autoplay ? 'true' : 'false') . '" data-direction="' . esc_attr($direction) . '" data-transition="' . esc_attr($transitionSpeed) . '" data-effect="' . esc_attr($sliderEffect) . '" data-height-mode="' . esc_attr($heightMode) . '"' . $mobileStackAttr . $imgCarouselAttrs . ' style="' . esc_attr($sliderWrapStyle) . '">';
            impulse_render_bg_shapes($settings, 'desktop', false);
            $trackHgt = $heightMode === 'auto' ? 'min-height:' . $height . 'px' : 'height:' . $height . 'px';
            $trackOverflow = $isImgCarouselMode ? 'visible' : 'hidden';
            echo '<div class="impulse-carousel-track" style="overflow:' . esc_attr($trackOverflow) . ';' . esc_attr($trackHgt) . '">';
            echo '<div class="impulse-carousel-slides" style="' . esc_attr($slidesStyle) . '">';

            // Scroll mode emits multiple copies of the slide set so the marquee loop
            // never reveals empty track — same pattern the Images scroll uses.
            $_scrollSets = $isScrollMode && $slideCount > 0 ? max(3, ceil(6 / $slideCount)) : 1;
            for ($_set = 0; $_set < $_scrollSets; $_set++):
            foreach ($slides as $idx => $slide) {
                $bgColor = $slide['background'] ?? '';
                $slideValign = $slide['valign'] ?? 'center';
                // Filter children for this slide
                $slideChildren = [];
                foreach ($children as $child) {
                    $cs = $child['settings'] ?? [];
                    if (isset($cs['slide_index']) && intval($cs['slide_index']) === $idx) {
                        $slideChildren[] = $child;
                    }
                }
                $realBg = ($bgColor !== '' && $bgColor !== '#f5f5f5') ? $bgColor : '';
                if (empty($slideChildren) && $realBg === '') continue; // skip empty slides on frontend
                $effectiveBg = $realBg !== '' ? $realBg : '';
                $slideBgStyle = $effectiveBg !== '' ? 'background:' . esc_attr($effectiveBg) . ';' : '';
                $justifyMap2 = ['top'=>'flex-start','center'=>'center','bottom'=>'flex-end','between'=>'space-between','around'=>'space-around','evenly'=>'space-evenly'];
                $justifyContent = $justifyMap2[$slideValign] ?? 'center';
                $slideHgtRule = $heightMode === 'auto' ? 'min-height:' . $height . 'px' : 'height:100%';
                $slideSize = $isStackedEffect
                    ? 'position:absolute;inset:0;' . ($idx === 0 ? 'opacity:1;z-index:2' : 'opacity:0;z-index:0')
                    : ($isVertical ? 'flex:0 0 100%;min-height:100%' : 'flex:0 0 100%;min-width:100%') . ';' . $slideHgtRule . ';position:relative';
                // Grid-mode JS rewrites slide cssText, so bg/valign must live on the
                // inner wrapper instead of the slide outer.
                $outerBgStyle = $isGridMode ? '' : $slideBgStyle;
                $innerBgStyle = $isGridMode ? $slideBgStyle : '';
                echo '<div class="impulse-carousel-slide" data-index="' . esc_attr($idx) . '" style="' . esc_attr($slideSize) . ';' . esc_attr($outerBgStyle) . '">';
                echo '<div style="position:relative;width:100%;' . ($heightMode === 'auto' ? 'min-height:' . esc_attr($height) . 'px' : 'height:100%') . ';display:flex;flex-direction:column;align-items:stretch;justify-content:' . esc_attr($justifyContent) . ';box-sizing:border-box;z-index:2;' . esc_attr($innerBgStyle) . '">';
                foreach ($slideChildren as $sc) {
                    impulse_render_frontend_element($sc);
                }
                echo '</div></div>';
            }
            endfor; // _scrollSets
            echo '</div></div></div>';
            // Responsive height
            $sliderHeightTablet = intval($settings['height_tablet'] ?? 0);
            $sliderHeightMobile = intval($settings['height_mobile'] ?? 0);
            $sRcss = '';
            if ($sliderHeightTablet > 0) $sRcss .= '@media(max-width:1024px){#'.$carouselId.' .impulse-carousel-track{'.($heightMode==='auto'?'min-':'').'height:'.$sliderHeightTablet.'px!important}}';
            if ($sliderHeightMobile > 0) $sRcss .= '@media(max-width:767px){#'.$carouselId.' .impulse-carousel-track{'.($heightMode==='auto'?'min-':'').'height:'.$sliderHeightMobile.'px!important}}';
            if ($sRcss) wp_add_inline_style('instinctor-page-inline', $sRcss);
            $GLOBALS['impulse_has_carousel'] = true;
            break;

        case 'shipping':
            // Shared renderer (see includes/element-renderers/shipping.php).
            echo impulse_element_render_shipping($el, $settings, 'frontend');
            break;

        case 'footer':
            // Per-page footer block. Delegates to the header/footer helper
            // with an empty $byParent (per-page footers are self-contained,
            // no header/footer sub-elements).
            $_pageFooterByParent = [];
            impulse_render_header_footer_element($el, $_pageFooterByParent);
            break;
    }
    } catch (\Throwable $e) {
        // Isolate block errors — one broken block must never kill the entire page.
        // Log the exception so we can find what's swallowing renders silently.
        // phpcs:disable WordPress.PHP.DevelopmentFunctions -- error_log() for server-side error capture, not debug output.
        error_log(sprintf(
            '[Instinctor block error] type=%s id=%s: %s in %s:%d',
            $type ?? '?',
            $el['id'] ?? '?',
            $e->getMessage(),
            $e->getFile(),
            $e->getLine()
        ));
        // phpcs:enable WordPress.PHP.DevelopmentFunctions
        echo '<!-- impulse: block error -->';
    }
    if ($kfWrapOpen) {
        echo '</div>';
    }
    if ($useFillWrapper) {
        echo '</div>';
    }
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.Security.EscapeOutput.HeredocOutputNotEscaped,WordPress.Security.EscapeOutput.ExceptionNotEscaped
}

// End template — enqueue any feature-flagged scripts based on block flags
// collected during render, then let wp_footer() emit them.
if (!empty($GLOBALS['impulse_has_carousel']))         \Instinctor\Core\Assets::enqueue('instinctor-carousel-init');
if (!empty($GLOBALS['impulse_needs_configurator']))   \Instinctor\Core\Assets::enqueue('instinctor-configurator');
if (!empty($GLOBALS['impulse_needs_frameloop']))      \Instinctor\Core\Assets::enqueue('instinctor-frameloop');
if (!empty($GLOBALS['impulse_needs_animations']))     \Instinctor\Core\Assets::enqueue('instinctor-animations');
if (!empty($GLOBALS['impulse_is_demo']))              \Instinctor\Core\Assets::enqueue('instinctor-demo');
if (!empty($GLOBALS['impulse_needs_3d_text'])) {
    if (!empty($GLOBALS['impulse_needs_three']) && empty($GLOBALS['impulse_three_emitted'])) {
        \Instinctor\Core\Assets::enqueue('instinctor-three');
        $GLOBALS['impulse_three_emitted'] = true;
    }
    \Instinctor\Core\Assets::enqueue('instinctor-3d-text');
}
if (!empty($GLOBALS['impulse_needs_section_fx']))     \Instinctor\Core\Assets::enqueue('instinctor-section-fx');
// Section-level WebGL motion bg (lava on home hero etc). Page-mode pages
// don't ship motion-lava by default — scene mode does the global enqueue.
// Conditional on per-section motion_bg setting flagging this global.
if (!empty($GLOBALS['impulse_needs_motion_lava']))     \Instinctor\Core\Assets::enqueue('instinctor-motion-lava');
// Scroll-triggered 3D section reveals. Conditional on a per-section ovfx
// setting flagging this global during render.
if (!empty($GLOBALS['impulse_needs_overlay_fx'])) {
    \Instinctor\Core\Assets::enqueue('instinctor-overlay-fx');
}
// Text-wall background: enqueued only when a root/classic section uses it.
if (!empty($GLOBALS['impulse_needs_textwall'])) {
    \Instinctor\Core\Assets::enqueue('instinctor-textwall');
}
// Icon-card hover parallax — was only enqueued in screen mode, so on a
// regular page-mode page (e.g. /home-v2) the effect never ran on the
// frontend. Wire it here so any page that contains an icon block with the
// parallax hover effect gets the script.
if (!empty($GLOBALS['impulse_needs_feature_card_parallax'])) \Instinctor\Core\Assets::enqueue('instinctor-feature-parallax');
// Section bg-gradient mouse rotation — small inline script (no separate
// asset). Reads window._impulseTilt (published by impulse-section-fx, which
// is force-loaded below when this flag is on) and writes per-section
// `--imp-bg-grad-angle`. Tagged sections opt in via data-bg-grad-mouse.
// Gradient-border ring CSS — emitted directly because add_action is a no-op
// on standalone (wp-shims.php). Both standalone and WP get the same inline
// <style>; deferring through wp_print_footer_scripts silently dropped the
// rule on standalone, leaving cells without any visible gradient border.
if (!empty($GLOBALS['impulse_needs_border_grad_mouse']) || !empty($GLOBALS['impulse_needs_border_gradient'])) { ?>
<style>
/* Gradient border ring — mask-based so border-radius is honored.
 *
 * The old `border-image: linear-gradient(...) 1` recipe ignored
 * border-radius entirely (CSS spec: when border-image renders, radius
 * is dropped), so cells with rounded corners snapped to square
 * rectangles whenever the gradient (or its spin) was active.
 *
 * The two-mask technique below paints a gradient on a ::before pseudo
 * element, then masks out the interior so only a uniform-width "ring"
 * remains. border-radius:inherit makes the ring follow the cell's
 * rounded corners — including during the angle-spin animation (the
 * mask is on the pseudo, the gradient angle is just a CSS var the
 * cell's tick loop overwrites).
 *
 * Sized by --imp-bgrad-w (defaults to 1px when not emitted). The
 * cell's actual `border-width:1px` declaration kept for layout
 * compatibility (the renderer still emits transparent borders for
 * the cell box model), but the visible ring is now the pseudo. */
.impulse-cell-grad-border {
    position: relative;
    background-clip: padding-box;
    box-sizing: border-box;
}
.impulse-cell-grad-border::before {
    content: '';
    position: absolute;
    inset: 0;
    /* Without an explicit z-index the pseudo paints BELOW the cell's
       flex children (flipbox, image, text, etc.) — when content fills
       the cell to its edges (padding 0), the ring is hidden behind it.
       z-index:1 lifts the ring above the content; pointer-events:none
       keeps clicks landing on the real content beneath. */
    z-index: 1;
    pointer-events: none;
    padding: var(--imp-bgrad-w, 1px);
    background: linear-gradient(
        var(--imp-bgrad-angle, 135deg),
        var(--imp-bgrad-c1, #e0e0e0),
        var(--imp-bgrad-c2, #e0e0e0)
    );
    border-radius: inherit;
    -webkit-mask:
        linear-gradient(#fff 0 0) content-box,
        linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
            mask:
        linear-gradient(#fff 0 0) content-box,
        linear-gradient(#fff 0 0);
            mask-composite: exclude;
}
</style>
<?php }
// Button gradient ring rule (separate var namespace from grid so live
// preview can target either independently). The button render writes
// var(--impulse-border-gradient-angle, FALLBACK) etc inline; this rule
// is a no-op on its own, but the @keyframes registration below + the
// tick loop's mouse-driven angle write both target this var.
?>
<?php
if (!empty($GLOBALS['impulse_needs_bg_grad_mouse']) || !empty($GLOBALS['impulse_needs_border_grad_mouse']) || !empty($GLOBALS['impulse_needs_text_grad_mouse'])) {
    // Force section-fx so the tilt bus is published even when no other
    // tilt/ambient sections exist on the page.
    \Instinctor\Core\Assets::enqueue('instinctor-section-fx'); ?>
<script>
/**
 * Per-element angle smoothing with circular shortest-arc lerp.
 *
 * The naive approach, damp local _cx/_cy positions and pass them to atan2 -
 * does NOT smooth the rendered angle. Both axes damp at the same rate, so
 * their ratio is preserved and atan2 snaps to the target direction within
 * one frame regardless of damping factor. We learned that the hard way.
 *
 * Correct model:
 *   1. One global mousemove listener tracks cursor position.
 *   2. Per element, per frame: compute INSTANT target angle from raw cursor
 *      offset via atan2. When cursor is outside, hold the last angle so we
 *      don't slew through an arbitrary path on exit.
 *   3. Damp the rendered angle toward the target via shortest-arc circular
 *      lerp at 8%/frame (~ 350ms to 99% at 60fps). Real angle smoothing.
 *   4. Damp the cursor MAGNITUDE separately (also 8%/frame) so the gradient
 *      pull fades in/out as the cursor enters/leaves, no hard cutoff.
 *   5. Final angle = base + spin·now + (smoothedAng × smoothedMag × sens/100).
 *      %360 keeps the value bounded against floating-point drift.
 *
 * Single tick loop drives bg-gradient (sections) and border-gradient
 * (buttons + grid cells), same rubber-smooth feel everywhere.
 */
(function(){
    // Direct cursor read + per-element 0.12 lerp. The earlier two-stage
    // smoothing made the gradient feel detached from the actual cursor.
    // Coordination with the cell's parallax/3D tilt comes from sharing the
    // SAME instant cursor source, the cell tilts with cursor, the gradient
    // rotates with cursor, both updated every frame from the same globalMx/My.
    var globalMx = 0, globalMy = 0;
    document.addEventListener('mousemove', function(e) {
        globalMx = e.clientX;
        globalMy = e.clientY;
    }, { passive: true });

    // Shortest-arc circular lerp. Handles wraparound at ±180° correctly:
    // lerping from 170° to -170° goes the short way (20°), not the long way (340°).
    function lerpAngle(a, b, t) {
        var diff = ((b - a + 540) % 360) - 180;
        return a + diff * t;
    }

    function smoothCursor(el) {
        var r = el.getBoundingClientRect();
        var inside = r.width > 0 && r.height > 0
                  && globalMx >= r.left && globalMx <= r.right
                  && globalMy >= r.top  && globalMy <= r.bottom;
        var tx = inside ? ((globalMx - r.left) / r.width  - 0.5) : 0;
        var ty = inside ? ((globalMy - r.top)  / r.height - 0.5) : 0;
        var tMag = Math.sqrt(tx*tx + ty*ty) * 2;
        var tAng;
        if (tx*tx + ty*ty > 0.0001) {
            tAng = Math.atan2(ty, tx) * 180 / Math.PI;
        } else {
            tAng = el._curAng !== undefined ? el._curAng : 0;
        }
        if (el._curAng === undefined) el._curAng = tAng;
        if (el._curMag === undefined) el._curMag = 0;
        // 12%/frame, snappy enough to keep up with cursor across small cells.
        el._curAng = lerpAngle(el._curAng, tAng, 0.12);
        el._curMag += (tMag - el._curMag) * 0.12;
    }

    function tick() {
        var nowSec = performance.now() / 1000;

        // Bg-gradient (sections + grid cells with data-bg-grad-mouse).
        // Cursor pull only, no spin (containers spinning their bg gradient
        // is jarring; the text-grad loop below handles spin for the place
        // where it actually makes sense).
        var bgNodes = document.querySelectorAll('[data-bg-grad-mouse]');
        for (var i = 0; i < bgNodes.length; i++) {
            var el = bgNodes[i];
            smoothCursor(el);
            var sensB = parseFloat(el.dataset.bgGradMouse) || 0;
            var baseB = parseFloat(el.dataset.bgGradBase)  || 135;
            var cAngB = el._curAng * Math.min(1, el._curMag);
            var angB = (((baseB + cAngB * (sensB / 100)) % 360) + 360) % 360;
            el.style.setProperty('--imp-bg-grad-angle', angB.toFixed(1) + 'deg');
        }

        // Text-fill gradient (Text block), drives --imp-text-grad-angle on
        // the text element. Independent from bg-grad: the gradient paints
        // the GLYPHS via background-clip:text, so the angle vector rotates
        // through the letters themselves. Both spin and cursor pull are
        // supported; spin is integrated over dt so live slider drags stay
        // smooth without jumps.
        var tfNodes = document.querySelectorAll('[data-text-grad-mouse]');
        for (var ti = 0; ti < tfNodes.length; ti++) {
            var elT = tfNodes[ti];
            smoothCursor(elT);
            var sensT = parseFloat(elT.dataset.textGradMouse) || 0;
            var baseT = parseFloat(elT.dataset.textGradBase)  || 135;
            var spinT = parseFloat(elT.dataset.textGradSpin)  || 0;
            var dtT = (elT._lastTfT === undefined) ? 0 : Math.max(0, nowSec - elT._lastTfT);
            elT._lastTfT = nowSec;
            elT._spinAccumTf = (elT._spinAccumTf || 0) + dtT * spinT;
            var cAngT = elT._curAng * Math.min(1, elT._curMag);
            var angT = (((baseT + elT._spinAccumTf + cAngT * (sensT / 100)) % 360) + 360) % 360;
            elT.style.setProperty('--imp-text-grad-angle', angT.toFixed(1) + 'deg');
        }

        // Border-gradient (buttons + grid cells).
        //
        // Single coupling formula: pull = cAng × (sens/100). Range ±180°.
        // The mouse_sens slider IS the cap, at sens=0 no pull, at sens=100
        // full ±180°. User dials it down for subtle, up for dramatic.
        // Cap-on-spin logic was tried (limit pull to ±20° when spin>0) but
        // made the slider invisible on spinning buttons since ±20° bias is
        // lost inside a 180°/sec spin.
        // Spin integration: accumulate dt*spin per frame so changing the spin
        // value mid-rotation continues smoothly instead of jumping. See the
        // editor preview tick loop for the full rationale.
        var brNodes = document.querySelectorAll('[data-border-grad-mouse]');
        for (var j = 0; j < brNodes.length; j++) {
            var el2 = brNodes[j];
            smoothCursor(el2);
            if (el2._phase === undefined) el2._phase = Math.random() * 6.283;
            var cs2 = getComputedStyle(el2);
            var sensV = parseFloat(cs2.getPropertyValue('--impulse-border-mouse-sens'));
            var sens2 = !isNaN(sensV) ? sensV : (parseFloat(el2.dataset.borderGradMouse) || 0);
            var baseV = parseFloat(cs2.getPropertyValue('--impulse-border-gradient-angle-base'));
            var base2 = !isNaN(baseV) ? baseV : (parseFloat(el2.dataset.borderGradBase) || 135);
            var spinV = parseFloat(cs2.getPropertyValue('--impulse-border-spin-speed'));
            var spin  = !isNaN(spinV) ? spinV : (parseFloat(el2.dataset.borderGradSpin) || 0);
            var dt = (el2._lastT === undefined) ? 0 : Math.max(0, nowSec - el2._lastT);
            el2._lastT = nowSec;
            el2._spinAccum = (el2._spinAccum || 0) + dt * spin;
            var cAng = el2._curAng * Math.min(1, el2._curMag);
            var breath = Math.sin(nowSec * 0.7 + el2._phase) * 4;
            var pull = cAng * (sens2 / 100);
            var ang = (((base2 + el2._spinAccum + breath + pull) % 360) + 360) % 360;
            var angStr = ang.toFixed(1) + 'deg';
            el2.style.setProperty('--imp-bgrad-angle', angStr);                  // legacy (grid cells)
            el2.style.setProperty('--impulse-border-gradient-angle', angStr);    // new (buttons)
        }
        requestAnimationFrame(tick);
    }
    requestAnimationFrame(tick);
})();
</script>
<?php }
if (!empty($GLOBALS['impulse_needs_cell_depth']))     \Instinctor\Core\Assets::enqueue('instinctor-cell-depth');
if (!empty($GLOBALS['impulse_needs_heading_tilt']))   \Instinctor\Core\Assets::enqueue('instinctor-heading-tilt');
\Instinctor\Core\Assets::enqueue('instinctor-interactions');

// ImpulseConfig data: bound to interactions handle so it prints right before it.
$_imp_cfg = 'window.ImpulseConfig=window.ImpulseConfig||{};'
          . 'ImpulseConfig.nonce=' . wp_json_encode(function_exists('wp_create_nonce') ? wp_create_nonce('instinctor_frontend') : '') . ';'
          . 'ImpulseConfig.ajaxUrl=' . wp_json_encode(function_exists('admin_url') ? admin_url('admin-ajax.php') : '/wp-admin/admin-ajax.php') . ';';
wp_add_inline_script('instinctor-interactions', $_imp_cfg, 'before');

if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV !== 'standalone') { wp_footer(); }
else { wp_footer(); /* shim emits enqueued scripts */ }
?>
<?php
// Footer injection: output code blocks with placement=footer
if (isset($headPostId) && $headPostId) {
    $footerElements = \Instinctor\Services\PageService::getElements($headPostId);
    if (is_array($footerElements)) {
        foreach ($footerElements as $fel) {
            $ft = $fel['type'] ?? '';
            $fs = $fel['settings'] ?? [];
            
        }
    }
}
// Global snippets: body_end fires last, after all per-page code
$emitGlobalSnippets('body_end');

// ── Thumbnail capture handler — editor/preview-iframe only.
// This script only responds to a postMessage the admin editor sends; it has
// zero purpose on a public frontend page, so don't ship 130 lines of JS to
// every visitor. Gate by INSTINCTOR_PREVIEW_CONTEXT (set at the top of this
// template when impulse_preview / impulse_builder is on the query string).
if (defined('INSTINCTOR_PREVIEW_CONTEXT') && INSTINCTOR_PREVIEW_CONTEXT):
?>
<script>
window.addEventListener('message', function(e) {
    if (!e.data || e.data.type !== 'impulse-capture-thumbnail') return;
    var pageId = new URLSearchParams(location.search).get('impulse_preview') || '0';

    // ── Step 1: capture live environment canvas ──
    function _doCapture() {

    var _envDataUrl = null;
    (function() {
        var allCanvases = document.querySelectorAll('canvas');
        var _dbg = 'total='+allCanvases.length+' ';
        var bg = document.body.style.background || getComputedStyle(document.body).backgroundColor || '#000000';
        if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') bg = '#000000';
        for (var _ci = 0; _ci < allCanvases.length; _ci++) {
            var _c = allCanvases[_ci];
            _dbg += '['+_ci+':id='+(_c.id||'?')+' w='+_c.width+' h='+_c.height+'] ';
            if (!_c || _c.width < 100 || _c.height < 100) continue;
            try {
                var _snap = document.createElement('canvas');
                _snap.width = Math.min(_c.width, window.innerWidth || 1200);
                _snap.height = Math.min(_c.height, window.innerHeight || 800);
                var _sc = _snap.getContext('2d');
                _sc.fillStyle = bg;
                _sc.fillRect(0, 0, _snap.width, _snap.height);
                _sc.drawImage(_c, 0, 0, _snap.width, _snap.height);
                var _url = _snap.toDataURL('image/jpeg', 0.85);
                _dbg += 'url.len='+(_url?_url.length:0)+' ';
                if (_url && _url.length > 500 && _url !== 'data:,') {
                    _envDataUrl = _url;
                    break;
                }
            } catch(_ex) { _dbg += 'ERR:'+_ex.message+' '; }
        }
        window.parent && window.parent.postMessage({type:'impulse-env-debug', msg:_dbg, got:!!_envDataUrl}, '*');
    })();

    // ── Step 2: reset body/html styles added by screen mode so html2canvas works ──
    document.body.classList.remove('impulse-screen-mode-active');
    document.body.style.overflowX = '';
    document.body.style.maxWidth = '';
    document.documentElement.style.overflowX = '';
    document.documentElement.style.overscrollBehavior = '';

    // Disable screen mode so sections become flow-positioned
    var sw = document.querySelector('.impulse-screen-wrapper');
    if (sw) {
        sw.style.cssText += ';position:relative!important;overflow:visible!important;height:auto!important';
        var screens = sw.querySelectorAll('.impulse-screen');
        screens.forEach(function(s, i) {
            if (i === 0) {
                s.style.cssText += ';position:relative!important;width:100%!important;display:block!important;visibility:visible!important;opacity:1!important;min-height:600px!important;height:auto!important';
            } else {
                s.style.display = 'none';
            }
        });
    }
    // Hide effects & nav
    document.querySelectorAll('canvas, svg[id*="impulse-cloud"], svg[id*="impulse-tunnel"], #impulse-screen-strip, .impulse-screen-nav').forEach(function(el) {
        el.style.display = 'none';
    });
    document.body.style.overflow = 'visible';
    window.scrollTo(0, 0);
    
    // Wait for layout reflow after style changes
    setTimeout(function() {
        // Background
        var bodyBg = getComputedStyle(document.body).backgroundColor;
        var useBg = '#000000';
        if (bodyBg && bodyBg !== 'rgba(0, 0, 0, 0)' && bodyBg !== 'transparent') {
            useBg = bodyBg;
        } else {
            var wrapper = document.querySelector('[data-page-bg-color]');
            if (wrapper) { var dbg = wrapper.dataset.pageBgColor; if (dbg && dbg.charAt(0) === '#') useBg = dbg; }
        }
        
        function doCapture() {
            var w = document.documentElement.scrollWidth || 1200;
            var h = Math.max(document.body.scrollHeight || 750, 600);
            h = Math.min(h, 1500);
            html2canvas(document.body, {
                width: w, height: h, windowWidth: w, windowHeight: h,
                scale: 0.4, useCORS: true, allowTaint: false, logging: false,
                backgroundColor: _envDataUrl ? null : useBg, imageTimeout: 3000
            }).then(function(canvas) {
                var thumb = document.createElement('canvas');
                thumb.width = 480; thumb.height = 300;
                var tctx = thumb.getContext('2d');
                // Composite: environment animation behind, html2canvas content on top
                if (_envDataUrl) {
                    var envImg = new Image();
                    envImg.onload = function() {
                        tctx.drawImage(envImg, 0, 0, 480, 300);
                        tctx.drawImage(canvas, 0, 0, 480, 300);
                        var dataUrl; try { dataUrl = thumb.toDataURL('image/jpeg', 0.8); } catch(ex) { dataUrl = null; }
                        window.parent.postMessage({ type: 'impulse-thumbnail-captured', pageId: pageId, dataUrl: dataUrl }, '*');
                    };
                    envImg.onerror = function() {
                        tctx.drawImage(canvas, 0, 0, 480, 300);
                        var dataUrl; try { dataUrl = thumb.toDataURL('image/jpeg', 0.7); } catch(ex) { dataUrl = null; }
                        window.parent.postMessage({ type: 'impulse-thumbnail-captured', pageId: pageId, dataUrl: dataUrl }, '*');
                    };
                    envImg.src = _envDataUrl;
                } else {
                    tctx.drawImage(canvas, 0, 0, 480, 300);
                    var dataUrl; try { dataUrl = thumb.toDataURL('image/jpeg', 0.7); } catch(ex) { dataUrl = null; }
                    window.parent.postMessage({ type: 'impulse-thumbnail-captured', pageId: pageId, dataUrl: dataUrl }, '*');
                }
            }).catch(function() {
                window.parent.postMessage({ type: 'impulse-thumbnail-captured', pageId: pageId, dataUrl: null }, '*');
            });
        }
        
        if (typeof html2canvas === 'function') { setTimeout(doCapture, 100); }
        else {
            var sc = document.createElement('script');
            sc.src = '<?php echo esc_url(INSTINCTOR_PLUGIN_URL . "assets/js/vendor/html2canvas.min.js?v=" . INSTINCTOR_VERSION); ?>';
            sc.onload = function() { setTimeout(doCapture, 100); };
            sc.onerror = function() { window.parent.postMessage({ type: 'impulse-thumbnail-captured', pageId: pageId, dataUrl: null }, '*'); };
            document.head.appendChild(sc);
        }
    }, 200);

    } // end _doCapture

    // Wait for next paint cycle, ensures animation engines have rendered at least one frame
    // Use rAF chain: rAF fires before paint, second rAF fires after, ensuring canvas has content
    requestAnimationFrame(function() {
        requestAnimationFrame(function() {
            _doCapture();
        });
    });
});
</script>
<?php endif; // INSTINCTOR_PREVIEW_CONTEXT — thumbnail capture handler ?>

<?php // INSTINCTOR_GIG_FOOTER_HTML block removed 2.1.413 — gig render path gone. ?>

<?php // Marketing footer — auto-injected on top-level Impulse marketing pages
      // (/, /pricing, /agencies, etc.) so all chrome stays in one place.
      // Plugin.php sets the constant when the resolved page is non-scene mode
      // and no footer block is already authored on the page.
      if (defined('INSTINCTOR_MARKETING_FOOTER_HTML') && INSTINCTOR_MARKETING_FOOTER_HTML !== ''): ?>
<?php // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- INSTINCTOR_MARKETING_FOOTER_HTML is a plugin constant (curated marketing footer).
echo INSTINCTOR_MARKETING_FOOTER_HTML;
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<?php endif; ?>

<?php
// ── "Made with Instinctor" credit ─────────────────────────────────────────
// Always-on credit line on every Instinctor-published site, linking back to
// our pricing page. Cheapest possible distribution channel — every site
// built on the platform advertises the platform. Replaces the marketplace-
// era "Site built by <designer> on Instinctor" footer (renamed + reworked
// 2.1.413 during the marketplace teardown).
//
// Skipped only when the site is instinctor.com itself (same-domain
// self-reference would be redundant).
$_imp_credit_host = strtolower((string) wp_parse_url((string) (function_exists('home_url') ? home_url() : ''), PHP_URL_HOST));
// Never emit attribution on WordPress installs: WordPress.org guidelines
// prohibit "Powered by" / credit links on user-facing output without the
// site admin's explicit opt-in. The credit remains only on Instinctor-managed
// standalone client sites (INSTINCTOR_ENV !== 'wordpress'), which the operator
// controls directly. v2.1.1959.
// WP is the default env when INSTINCTOR_ENV is undefined (every other WP-detect
// in the codebase uses !defined() || ==='wordpress'). The previous
// `defined() && ==='wordpress'` was FALSE on a real WP install (where the
// constant is never defined), so the "Made with" credit was rendering on
// WordPress frontends — a WP.org attribution violation. Fixed 2026-06-27.
$_imp_is_wp_env = (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress');
if (!$_imp_is_wp_env && $_imp_credit_host !== 'instinctor.com' && $_imp_credit_host !== 'www.instinctor.com') {
    echo '<div class="instinctor-built-credit" style="position:relative;background:#f5f5f5;text-align:center;padding:14px 20px;font-size:12px;color:#999;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',sans-serif;line-height:1.5;border-top:1px solid #e0e0e0">'
        . 'Made with <a href="https://instinctor.com/pricing" rel="noopener" style="color:#1d2327;text-decoration:none;border-bottom:1px solid #999">Instinctor</a>'
        . '</div>';
}

// ── Top CTA bar — instinctor.com only ─────────────────────────────────────
// Full-width black semi-transparent overlay bar fixed at the top of the
// viewport. Instinctor wordmark on the left, primary CTA + secondary
// WordPress download on the right. Visible at all times — including
// during the hero scene, where it replaces the marketing nav. Hidden
// only on /start (the funnel itself) and admin paths.
// Replaces the bottom-right floating button — Sergei wanted a header
// presence so the decision path is visible from the first frame.
if ($_imp_credit_host === 'instinctor.com' || $_imp_credit_host === 'www.instinctor.com') {
    // WP.org B2 — sanitize $_SERVER read before use, even though we only
    // pattern-match against a path allow-list below.
    $_imp_sticky_path = strtok(sanitize_text_field(wp_unslash((string) ($_SERVER['REQUEST_URI'] ?? '/'))), '?');
    $_imp_sticky_skip_paths = ['/start', '/admin', '/admin/'];
    // Top CTA bar retired — instinctor.com now uses a real Impulse nav block
    // placed directly on the page. Emitting this bar as well stacked two
    // navs (one from the page, one from the embedded hero scene). Gated
    // off rather than deleted so the slide-in/reveal logic stays on record.
    if (false && !in_array($_imp_sticky_path, $_imp_sticky_skip_paths, true) && strpos($_imp_sticky_path, '/admin/') !== 0) {
        // Resolve site logo — same source MarketingShell::nav() uses. Falls
        // back to the INSTINCTOR wordmark if no logo is uploaded (so the
        // bar still renders on a fresh install).
        $_imp_bar_logo = '';
        if (class_exists('\\Instinctor\\Core\\Config')) {
            $_imp_bar_logo = (string) \Instinctor\Core\Config::get('instinctor_site_logo_url', '');
        }
        // Brand mark: graphic logo + "INSTINCTOR" wordmark side-by-side.
        // Sergei: "Need textual logo too, as they still do not mentally
        // wire the graphic logo with anything." So we ship both — the
        // image gives recognition, the wordmark gives identification.
        $_imp_bar_brand_inner = '';
        if ($_imp_bar_logo !== '') {
            $_imp_bar_brand_inner .= '<img src="' . htmlspecialchars($_imp_bar_logo, ENT_QUOTES) . '" alt="" style="height:24px;width:auto;display:block">';
        }
        $_imp_bar_brand_inner .= '<span class="inst-brand-wordmark">INSTINCTOR</span>';
        $_imp_bar_brand = '<a href="/" class="inst-brand" aria-label="Instinctor home">' . $_imp_bar_brand_inner . '</a>';
        // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- page-builder inline style; unique per-page/per-element, generated at render time
        echo '<style>'
           // Default state: bar is OFF-SCREEN on pages that have a hero
           // (scene-mode). Slides in when the user advances
           // past screen 1 of the hero — Sergei: don't show a CTA the
           // moment they land, give them the first impression first.
           // On non-hero pages the bar is visible immediately.
           // Default: bar visible. Slide-up-hide ONLY when body explicitly
           // has .impulse-top-bar-hide (set by JS while user is on hero
           // screen 0). Any other state — past hero, on screen 2+, no
           // hero at all — leaves the bar visible. Inverted from the
           // previous "hide by default + reveal by class" model so the
           // bar can't get stuck off-screen if a reveal event is missed.
           . '.instinctor-top-bar{position:fixed!important;top:0;left:0;right:0;z-index:99999;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 24px;background:rgba(0,0,0,0.55);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:0 1px 0 rgba(255,255,255,0.06);pointer-events:auto;transform:translateY(0);opacity:1;transition:transform 0.55s cubic-bezier(0.4, 0, 0.2, 1),opacity 0.55s cubic-bezier(0.4, 0, 0.2, 1)}'
           . 'body.impulse-top-bar-hide .instinctor-top-bar{transform:translateY(-110%);opacity:0;pointer-events:none}'
           . '.instinctor-top-bar .inst-brand{display:inline-flex;align-items:center;gap:10px;color:#fff;text-decoration:none}'
           . '.instinctor-top-bar .inst-brand-wordmark{font-size:13px;font-weight:700;letter-spacing:3px}'
           . '.instinctor-top-bar .inst-actions{display:inline-flex;align-items:center;gap:10px}'
           . '.instinctor-top-bar a.inst-cta-primary{display:inline-flex;align-items:center;gap:8px;padding:9px 18px;background:#fff;color:#1d2327;text-decoration:none;font-size:13px;font-weight:600;border-radius:6px;letter-spacing:0.2px;transition:background 0.18s ease-in-out,color 0.18s ease-in-out}'
           . '.instinctor-top-bar a.inst-cta-primary:hover{background:#e0e0e0}'
           . '.instinctor-top-bar a.inst-cta-primary svg{width:14px;height:14px;display:block}'
           . '@media (max-width:600px){.instinctor-top-bar{padding:8px 14px;gap:10px}.instinctor-top-bar .inst-brand img{height:20px}.instinctor-top-bar .inst-brand-wordmark{font-size:11px;letter-spacing:2px}.instinctor-top-bar a.inst-cta-primary{padding:8px 14px;font-size:12px}}'
           . '</style>'
           . '<div class="instinctor-top-bar" role="banner">'
           // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
           .   $_imp_bar_brand
           // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
           .   '<div class="inst-actions">'
           .     '<a href="/wordpress" class="inst-cta-primary" aria-label="Download for WordPress">'
           .       '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>'
           .       'Download for WordPress'
           .     '</a>'
           .   '</div>'
           . '</div>'
           // Self-relocate to <body>, plus reveal logic. Reveal kicks in
           // when EITHER: (a) the active hero screen index is >= 1, OR
           // (b) the user has scrolled past the hero entirely (body has
           // .impulse-past-hero or .impulse-hero-exiting). The MutationObserver
           // on body classes covers the past-hero transition; the
           // screen-changed event covers in-hero navigation.
           // Reveal model (inverted): bar is visible by default. We HIDE
           // it only while body has neither past-hero/exiting marks AND
           // the currently-active hero screen is index 0. Everything else
           // shows the bar. Failure modes (event missed, MO didn't fire)
           // leave the bar visible — never invisible.
           . '<script>(function(){var b=document.querySelector(".instinctor-top-bar");if(b&&b.parentNode!==document.body){document.body.appendChild(b);}'
           . 'function _activeHeroIdx(){var a=document.querySelector(".impulse-screen.impulse-screen-active");if(!a)return -1;var i=parseInt(a.dataset.screenIndex||"0",10);return isNaN(i)?0:i;}'
           . 'var _scheduled=false;function _scheduleUpdate(){if(_scheduled)return;_scheduled=true;requestAnimationFrame(function(){_scheduled=false;_runUpdate();});}'
           . 'function _runUpdate(){var cls=document.body.classList;var pastHero=cls.contains("impulse-past-hero")||cls.contains("impulse-hero-exiting");var idx=_activeHeroIdx();var onScreenZero=(idx===0)&&!pastHero;var has=cls.contains("impulse-top-bar-hide");if(onScreenZero&&!has){cls.add("impulse-top-bar-hide");}else if(!onScreenZero&&has){cls.remove("impulse-top-bar-hide");}}'
           . 'document.addEventListener("impulse-screen-changed",_scheduleUpdate);'
           . 'window.addEventListener("scroll",_scheduleUpdate,{passive:true});'
           . 'if(window.MutationObserver){new MutationObserver(_scheduleUpdate).observe(document.body,{attributes:true,attributeFilter:["class"]});}'
           . '_runUpdate();'
           . '})();';
    }
}
?>

</body>
</html>
<?php
$impulse_html = ob_get_clean();

// Full-screen license-lock overlay for visitors (final dunning stage).
// Renders nothing unless the platform has flagged the site locked.
if (class_exists('\\Instinctor\\Core\\PlatformService')) {
    try { \Instinctor\Core\PlatformService::renderLockOverlay(); } catch (\Throwable $e) {}
}

// Inject chat widget on standalone.
// On WordPress, ChatService hooks wp_footer → outputs widget automatically.
// On standalone, wp_footer() is a no-op shim, so we inject directly here.
if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
    if (class_exists('\\Instinctor\\Services\\ChatService')) {
        try {
            ob_start();
            \Instinctor\Services\ChatService::instance()->renderWidget();
            $_chatHtml = ob_get_clean();
            // renderWidget() enqueues chat-widget.css, but wp_footer() already
            // ran above — the style queue was processed and won't pick up
            // late additions. Force-emit the handle so the <link> tag reaches
            // the page (standalone-only; WP runs ChatService on wp_footer
            // hook, before the print pass, so it doesn't need this).
            if (function_exists('_instinctor_emit_style')) {
                ob_start();
                _instinctor_emit_style('instinctor-chat-widget');
                $_chatCss = ob_get_clean();
                if (!empty($_chatCss)) {
                    $_chatHtml = $_chatCss . $_chatHtml;
                }
            }
            // renderWidget() also enqueues the chat SCRIPT handle
            // (assets/js/instinctor-chat.js + its before/after inline blocks),
            // but wp_footer() already ran above so the script queue was processed
            // and won't pick up this late addition. Force-emit the handle so the
            // <script> + config + wiring reach the page (standalone-only; on WP
            // the wp_footer print pass handles it). APPENDED after the widget
            // markup — the script must run after the DOM it wires exists. (CSS is
            // prepended; JS is appended.)
            if (function_exists('_instinctor_emit_script')) {
                ob_start();
                _instinctor_emit_script('instinctor-chat', true);
                $_chatJs = ob_get_clean();
                if (!empty($_chatJs)) {
                    $_chatHtml = $_chatHtml . $_chatJs;
                }
            }
        } catch (\Throwable $_chatErr) {
            $_chatHtml = '';
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- server-side error capture in catch block, not debug output
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- error_log() for server-side error capture, not debug output.
            error_log('[Instinctor] Chat widget render error: ' . $_chatErr->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }
        if (!empty($_chatHtml)) {
            $impulse_html = str_replace('</body>', $_chatHtml . "\n</body>", $impulse_html);
        }
        unset($_chatHtml, $_chatErr, $_chatCss, $_chatJs);
    }
}

// Forced attribution (footer credit + corner badge): stripped from the WP.org
// build, where forced/re-added credits are prohibited. Kept on the standalone
// SaaS and the Pro build, where attribution is part of the service terms
// (instinctor.com/terms). build.py removes everything between the PRO markers.


// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
echo $impulse_html;
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
