<?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((string) 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) . '">';
    }
    // Resolve the tenant/owner context BEFORE the SEO head runs. Tenant::currentOwnerId()
    // reads $GLOBALS['impulse_current_page']['owner_id']; without stashing the page row
    // here the head runs owner-less, which suppresses the per-tenant BeautySalon schema,
    // leaks the vendor SoftwareApplication/Organization schema onto the storefront, and
    // puts the vendor name in <title>/og:site_name. The render path re-stashes the same
    // row later (idempotent). Sergei 2026-07-14 (audit bug: head-before-owner ordering).
    if ($isp_post_id && empty($GLOBALS['impulse_current_page'])) {
        $GLOBALS['impulse_current_page'] = \Instinctor\Services\PageService::getById($isp_post_id);
    }
    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.
        
    }
}

/**
 * The single Google tag (gtag.js) plus the conversion runtime.
 *
 * ONE loader, configured for GA4 and/or Google Ads. Config keys:
 *   instinctor_analytics_provider  'ga' + instinctor_analytics_id  -> G-XXXXXXXXXX
 *   instinctor_ads_conversion_id                                   -> AW-XXXXXXXXX
 *   instinctor_ads_conversion_label                                -> the label alone
 *
 * The conversion NEVER fires on page load by itself. It fires only when a
 * pending token exists, and that token is written by the form's success handler
 * only after the server returned success with a submission id. So a visit, a
 * click, a validation failure, a server error and a direct visit to the
 * thank-you page all fire nothing.
 *
 * Duplicate protection is two-layer: the pending token lives in sessionStorage
 * and is consumed on read (so a refresh finds nothing), and every fired
 * submission id is remembered in localStorage (so a replayed token is ignored).
 * The Ads event also carries transaction_id, which is Google's own dedup key.
 *
 * No personally identifying data is sent. The payload is the form name, the lead
 * type and the submission id.
 */
if (!function_exists('impulse_emit_google_tag')) {
    function impulse_emit_google_tag(): void {
        
    }
}

/**
 * Campaign click tracking for the WordPress plugin funnel.
 *
 * Fires a GA4 `wordpress_install_click` event when a visitor clicks ANY link to
 * the wordpress.org plugin page. /wordpress carries three such links, one of them
 * an icon link with no text; the handler is delegated on the document and matched
 * by destination URL, so all three are covered and none needs a class or id.
 *
 * First-touch UTMs and the entry referrer are captured into sessionStorage on the
 * first page of the session, so they travel with the click even when the visitor
 * browsed in from another page. Reddit arrives through the referrer rather than a
 * utm_source parameter, so the referrer is captured and sent too (page_referrer).
 *
 * The event uses GA4 `beacon` transport, so it is delivered even as the click
 * navigates away; nothing fires on page load. Emitted only when a real GA4
 * measurement id is configured, so it is inert until analytics is switched on.
 */
if (!function_exists('impulse_emit_wp_click_tracking')) {
    function impulse_emit_wp_click_tracking(): void {
        
    }
}
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);
        // Snapshot what the page-inline sheet already carried BEFORE the head
        // prints it. Every element renderer adds its computed CSS through
        // wp_add_inline_style('instinctor-page-inline') while the BODY renders,
        // which is after this point — WordPress has already emitted (and marked
        // done) the handle by then, so all of it was silently dropped: per-device
        // bg_image / bg_position and the grid's per-device padding + alignment
        // never reached a WP page at all. Standalone was unaffected because it
        // skips wp_head() and prints its own head later. Diffing against this
        // baseline lets the block below emit exactly the late additions, with no
        // risk of duplicating anything the head already wrote. Sergei 2026-07-31.
        $impulse_inline_baseline = [];
        if (function_exists('wp_styles')) {
            $_b = wp_styles()->get_data('instinctor-page-inline', 'after');
            if (is_array($_b)) $impulse_inline_baseline = $_b;
        }
        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 impulse_emit_google_tag(); ?>
    <?php impulse_emit_wp_click_tracking(); ?>
    <?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((string) 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.
        // Classic pages read as documents: with no explicit page bg they paint
        // WHITE, not the env-derived dark backdrop (which belongs to cinematic
        // screen decks). Sergei 2026-07-24, WP-free audit: "background remains
        // black in both modes".
        $_earlyClassic = (($earlySettings['body_scroll'] ?? 'snap') === 'classic');
        $earlyBg = !empty($earlySettings['bg_color']) && $earlySettings['bg_color'] !== 'transparent'
            ? $earlySettings['bg_color']
            : ($_earlyClassic ? '#ffffff' : $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.
        // Classic/native-scroll pages (body.impulse-scroll-classic) carry
        // data-screen-mode="1" so the reveal engine still runs, but they must NOT be
        // locked to a fixed 100dvh viewport — that gave a first-paint cinematic flash
        // and, on mobile, a dark bar above the hero that jumped once the URL bar
        // settled and JS un-fixed the body. Only emit the viewport lock for true snap
        // (cinematic) pages. Sergei 2026-07-14.
        // Only lock the viewport for TRUE snap (cinematic) pages. Classic/native-scroll
        // pages (body.impulse-scroll-classic, body_scroll='classic') must scroll the
        // document natively — a fixed body there makes the whole page below the hero
        // unreachable (footer + its reveal never fire) and, on mobile, floats a dark bar
        // above the hero until the URL bar settles. Sergei 2026-07-14 (re-applied after a
        // wrong revert: headless test proved the fixed body caps document scroll at 0).
        if ((($earlySettings['body_scroll'] ?? 'snap') !== 'classic')) {
            $_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)
        // ── Reveal-from-black on a fresh build ──────────────────────────
        // The generation montage redirects to /demo/...?built=1: cover the
        // first paint in black and fade in after load, so the new site
        // ARRIVES instead of popping mid-render (Sergei 2026-07-25). Demo
        // route only; the cover rides the critical CSS so no frame ever
        // paints before it. The kinetic hero holds its entrance until the
        // fade completes (window.__impHoldKinetic / impulse:revealed).
        // A recording run sets the impap cookie, so the scrollbar can be hidden
        // in the CRITICAL css - before first paint. The autopilot's own JS
        // injection lands after parse, which flashed a scrollbar on every new
        // page of the run, most visibly just before the demo reveal.
        if (!empty($_COOKIE['impap'])) {
            $_critical .= 'html,body{scrollbar-width:none;-ms-overflow-style:none;overflow-anchor:none}';
            $_critical .= '::-webkit-scrollbar{width:0!important;height:0!important;display:none!important}';
        }
        $_builtReveal = !empty($GLOBALS['impulse_is_demo']) && isset($_GET['built']);
        if ($_builtReveal) {
            $_critical .= 'html::after{content:"";position:fixed;inset:0;background:#0e0f12;z-index:2147483200;pointer-events:none;opacity:1;transition:opacity 1100ms ease-in-out}';
            $_critical .= 'html.imp-built-revealed::after{opacity:0;visibility:hidden;transition:opacity 1100ms ease-in-out,visibility 0s 1100ms}';
        }
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSS text, not markup: composed here from hardcoded declarations plus sanitised colour/size settings. No CSS-context escaper applies without corrupting the rules.
        echo '<style id="impulse-critical-css">' . $_critical . '</style>' . "\n";
        if ($_builtReveal) {
            echo '<script>window.__impHoldKinetic=true;'
               . 'window.addEventListener("load",function(){setTimeout(function(){'
               . 'document.documentElement.classList.add("imp-built-revealed");'
               . 'try{history.replaceState(null,"",location.pathname);}catch(e){}'
               . 'setTimeout(function(){window.__impHoldKinetic=false;'
               . 'try{window.dispatchEvent(new Event("impulse:revealed"));}catch(e){}'
               . '},1150);},350);});</script>' . "\n";
        }
        // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- vh-fix must execute synchronously before first paint; wp_add_inline_script outputs to footer
        // Guard against innerHeight===0 (first paint before layout, a stalled
        // WebGL init, or a background tab): writing --imp-vh:0px there overrides
        // the :root{--imp-vh:100dvh} default and collapses every screen-vh
        // section to 0, leaving an empty band above the hero. Only write a real
        // value; when it is 0 the 100dvh default stands. Sergei 2026-07-23.
        echo '<script id="impulse-vh-fix">(function(){function s(){var h=window.innerHeight;if(h>0)document.documentElement.style.setProperty("--imp-vh",h+"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. */
        // hide_motion_hint: per-page opt-out, for screen recordings and for
        // owners who do not want the invitation. Absent = shown, so existing
        // pages are unaffected.
        if (($earlySettings['body_scroll'] ?? 'snap') !== 'classic'
            && empty($earlySettings['hide_motion_hint'])) {
        // 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');
        } // end if not classic
    }
}
?>
</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_env_needs_three')) {
    /**
     * Does this environment effect run on Three.js / impulse-3d-effects.js?
     *
     * The list mirrors the EFFECTS registry in impulse-3d-effects.js, which is
     * what actually decides whether the module can render the effect. Kept in
     * one place because two callers need it: the child-sequence loop in
     * impulse_render_child_sequences(), and the page's OWN screen_cloud_effect.
     * The page-level check was missing entirely, so a page whose environment is
     * a 3D effect (home page 868 on fireflies) never enqueued three.min.js or
     * impulse-3d-effects.js and rendered a flat background on every device.
     */
    function impulse_env_needs_three(string $effect): bool {
        return in_array($effect, [
            'cosmos', 'wormhole', 'bubbles', 'particles', 'crystal', 'blob',
            'fireflies', 'plexus', 'columns', 'asteroid', 'grid',
        ], true);
    }
}

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 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 (impulse_env_needs_three($cloudEff)) $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']) . '"' : '';
                // Paint-time per-device min-heights, mirroring _nativeMinVhFor in
                // impulse-screens.js. Until the classic-bail JS ran, CSS laid the
                // page out with min-height 0 / desktop values and JS re-applied the
                // device value on arrival: a whole-document reflow at script time,
                // 18-25s on throttled mobile, which re-rated LCP to 21-27s across
                // the SEO estate (2026-08-02). The same values as CSS vars let the
                // classic stylesheet resolve them per breakpoint at FIRST PAINT;
                // the JS pass then writes identical numbers and nothing moves.
                $minVhVars = '';
                if (isset($elSettings['screen_min_height_vh']) && (int) $elSettings['screen_min_height_vh'] > 0) {
                    $minVhVars .= '--imp-nvh:' . max(0, min(200, (int) $elSettings['screen_min_height_vh'])) . 'vh;';
                }
                if (isset($elSettings['screen_min_height_vh_tablet']) && $elSettings['screen_min_height_vh_tablet'] !== '') {
                    $minVhVars .= '--imp-nvh-t:' . max(0, min(200, (int) $elSettings['screen_min_height_vh_tablet'])) . 'vh;';
                }
                if (isset($elSettings['screen_min_height_vh_mobile']) && $elSettings['screen_min_height_vh_mobile'] !== '') {
                    $minVhVars .= '--imp-nvh-m:' . max(0, min(200, (int) $elSettings['screen_min_height_vh_mobile'])) . 'vh;';
                }
                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="' . esc_attr($minVhVars) . '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);
                // Classic + explicit per-section vh: the wrapper floor is THAT vh,
                // not full viewport - mirrors the section renderer's classic-vh
                // branch. Without this, a 38vh classic section sat inside a 100dvh
                // wrapper and the page showed viewport-tall bands of empty space
                // (Sergei 2026-07-22).
                $_classicVh = (($page_settings['body_scroll'] ?? '') === 'classic')
                    && (int) ($elSettings['screen_min_height_vh'] ?? 0) > 0;
                if ($_classicVh) {
                    $_wrapMinH = 'calc(var(--imp-vh, 100dvh) * ' . (max(1, min(200, (int) $elSettings['screen_min_height_vh'])) / 100) . ')';
                } else {
                    $_wrapMinH = $_classicFree ? 'auto' : 'var(--imp-vh, 100dvh)'   /* dock inset moved to the section's padding: subtracting it here shrank the screen and left a dark bar under the hero. Sergei 2026-07-16 */;
                }
                $_wrapJC   = ($_classicFree || $_classicVh) ? 'flex-start' : 'safe center';
                // Classic: page-level VERTICAL padding stays off the wrapper. In snap
                // it sits invisibly inside a full-bleed screen; in classic it painted a
                // page-bg band above the hero and between sections ("white gap on top",
                // Sergei 2026-07-22). Sections own their vertical rhythm; the horizontal
                // gutters keep working.
                $_isClassicPad = (($page_settings['body_scroll'] ?? '') === 'classic');
                $_vPadT = $_isClassicPad ? 0 : intval($childPadTop);
                $_vPadB = $_isClassicPad ? 0 : intval($childPadBottom);
                echo '<div class="impulse-content-wrapper" style="--impulse-wrapper-pad-y:' . ($_vPadT + $_vPadB) . 'px;display:flex;flex-direction:column;justify-content:' . esc_attr($_wrapJC) . ';flex:1;min-height:' . esc_attr($_wrapMinH) . ';padding:' . $_vPadT . 'px ' . esc_attr($childPadRight) . 'px ' . $_vPadB . '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;
    // Every render path passes through here, so the chat widget's per-page
    // persona lookup can rely on this id regardless of controller.
    $GLOBALS['impulse_current_page_id'] = (int) $post_id;

    // Single-source business data: on a PUBLISHED owner page (not a demo, not the
    // editor/preview), fill the address, hours and map from the owner's dashboard
    // BusinessProfile so the live page always mirrors Settings. Address is edited in
    // the dashboard only — this is what removes on-page address editing. Demos keep
    // their Places-driven, inline-editable content; the editor shows the raw tree.
    if (empty($GLOBALS['impulse_is_demo'])
        && (!defined('INSTINCTOR_PREVIEW_CONTEXT') || !INSTINCTOR_PREVIEW_CONTEXT)
        && class_exists('\\Instinctor\\Core\\Tenant')
        && class_exists('\\Instinctor\\Services\\DemoService')) {
        $_imp_owner = \Instinctor\Core\Tenant::currentOwnerId();
        if ($_imp_owner) {
            $elements = \Instinctor\Services\DemoService::applyOwnerBusinessData($elements, (int) $_imp_owner);
        }
    }

    // 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-mode="<?php echo (($page_settings['screen_depth_mode'] ?? 'parallax') === 'relief') ? 'relief' : 'parallax'; ?>" data-depth-light="<?php echo max(0, min(70, intval($page_settings['screen_depth_light'] ?? 50))); ?>" data-depth-dolly="<?php echo max(0, min(10, intval($page_settings['screen_depth_dolly'] ?? 7))); ?>" data-depth-pivot="<?php echo max(0, min(100, intval($page_settings['screen_depth_pivot'] ?? 50))); ?>" data-depth-zoom="<?php echo max(100, min(140, intval($page_settings['screen_depth_zoom'] ?? 100))); ?>" 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']) . '"' : '';
        // Paint-time per-device min-heights, same mirror as the multi-screen emit
        // above: without them this render path (single-screen flow pages, the whole
        // SEO estate) kept resolving its minimums in JS at DOMContentLoaded, and the
        // late reflow re-rated LCP at 21-27s on throttled mobile (2026-08-02).
        $_minVhVars2 = '';
        if (isset($elSettings['screen_min_height_vh']) && (int) $elSettings['screen_min_height_vh'] > 0) {
            $_minVhVars2 .= '--imp-nvh:' . max(0, min(200, (int) $elSettings['screen_min_height_vh'])) . 'vh;';
        }
        if (isset($elSettings['screen_min_height_vh_tablet']) && $elSettings['screen_min_height_vh_tablet'] !== '') {
            $_minVhVars2 .= '--imp-nvh-t:' . max(0, min(200, (int) $elSettings['screen_min_height_vh_tablet'])) . 'vh;';
        }
        if (isset($elSettings['screen_min_height_vh_mobile']) && $elSettings['screen_min_height_vh_mobile'] !== '') {
            $_minVhVars2 .= '--imp-nvh-m:' . max(0, min(200, (int) $elSettings['screen_min_height_vh_mobile'])) . 'vh;';
        }
        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="' . esc_attr($_minVhVars2) . '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);
        // Classic-vh mirror - see multi-screen path above.
        $_classicVh2 = (($page_settings['body_scroll'] ?? '') === 'classic')
            && (int) ($elSettings['screen_min_height_vh'] ?? 0) > 0;
        if ($_classicVh2) {
            $_wrapMinH2 = 'calc(var(--imp-vh, 100dvh) * ' . (max(1, min(200, (int) $elSettings['screen_min_height_vh'])) / 100) . ')';
        } else {
            $_wrapMinH2 = $_classicFree2 ? 'auto' : 'var(--imp-vh, 100dvh)'   /* dock inset moved to the section's padding: subtracting it here shrank the screen and left a dark bar under the hero. Sergei 2026-07-16 */;
        }
        $_wrapJC2   = ($_classicFree2 || $_classicVh2) ? 'flex-start' : 'safe center';
        // Classic vertical-padding drop - see multi-screen path above.
        $_isClassicPad2 = (($page_settings['body_scroll'] ?? '') === 'classic');
        $_vPadT2 = $_isClassicPad2 ? 0 : intval($snavPadTop);
        $_vPadB2 = $_isClassicPad2 ? 0 : intval($paddingBottom);
        echo '<div class="impulse-content-wrapper" style="--impulse-wrapper-pad-y:' . ($_vPadT2 + $_vPadB2) . 'px;display:flex;flex-direction:column;justify-content:' . esc_attr($_wrapJC2) . ';flex:1;min-height:' . esc_attr($_wrapMinH2) . ';padding:' . $_vPadT2 . 'px ' . intval($paddingRight) . 'px ' . $_vPadB2 . '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);
    }

    // Full-screen intro video, page-scoped: only the page carrying intro_video
    // shows one. Emitted as a SIBLING of the content so the page still paints
    // and a crawler reads it normally. v2.1.2840.
    if (!empty($page_settings['intro_video'])) {
        require_once INSTINCTOR_PLUGIN_DIR . 'includes/impulse-intro-video.php';
        // phpcs:disable WordPress.Security.EscapeOutput -- shared sub-renderer return, escaped at construction
        $_introPid = defined('INSTINCTOR_RENDER_PAGE_ID') ? (int) INSTINCTOR_RENDER_PAGE_ID : 0;
        echo impulse_intro_video_html($page_settings, $_introPid, false);
        // phpcs:enable WordPress.Security.EscapeOutput
    }

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

    // Classic / SEO pages that carry NO header element get a small Instinctor
    // wordmark fixed in the top-left as light branding. Fixed overlay, so it
    // never affects layout / the hero height. Cinematic pages use their own
    // centred screen_brand_logo instead.
    //
    // THE HEADER CHECK IS LOAD-BEARING (Sergei 2026-08-04, seen on
    // /designed-for-you). This block was written when classic pages genuinely
    // had no header, and said so; that premise expired once SEO pages got a
    // real header element, which is rendered ten lines above and carries its
    // own logo. The floating mark is position:fixed at top:22px/left:30px and
    // the header logo sits at essentially the same spot, so the two stacked
    // into one smudged double wordmark on all 331 vendor classic pages: the
    // whole SEO network, /compare, /how-to, /alternatives, /terms and the
    // celaglow set included. A page with a header is already branded.
    //
    // OURS ONLY (Sergei 2026-07-26). This was written for instinctor.com's own
    // ~281 classic marketing pages, but the gate was the scroll mode alone, so
    // every classic page inherited it: demos and free tenant sites got our
    // wordmark over THEIR hero, linking their visitor away to us from the
    // strongest position on the page. Attribution is already handled by the
    // locked #instinctor-built-credit footer; a second mark makes a tenant's
    // site look like our brochure and buys nothing.
    //
    // Pro standalones looked exempt only by accident: the fleet is frozen at
    // 2.1.2396 and this block landed after that, so the FIRST fleet deploy past
    // 2396 would have stamped our logo onto paying clients' pages.
    //
    // Same classification TierManager::canFeature() uses, so there is one rule
    // for "whose page is this", not two: apex host, not a demo, no tenant owner.
    // Both render-dispatch paths stash impulse_current_page before calling this
    // renderer (see the dispatch below), so the tenant lookup is populated here.
    // OWNERSHIP IS THE RULE, read straight off the page rather than inferred from
    // request context. Tenant::isTenant() resolves through impulse_current_page,
    // and on any dispatch path that has not populated it the check returns FALSE
    // and this gate fails OPEN: our wordmark lands on somebody else's live site,
    // top-left over their hero, linking their visitor away to us. A page with an
    // owner is theirs, and that fact does not depend on which route served it
    // (Sergei 2026-07-29, seen on a published demo site).
    $_pgOwnerId = (int) ($GLOBALS['impulse_current_page']['owner_id'] ?? 0);
    // A MASTER TEMPLATE IS NOT A MARKETING PAGE. The registry templates are
    // vendor-owned, so ownership alone still counted them as ours, but every one
    // of them exists to show what a TENANT's site looks like: our wordmark over
    // that hero is both wrong in the preview and wrong in anything recorded from
    // it (Sergei 2026-07-29, seen on ?impulse_preview=867).
    $_pgId = (int) ($GLOBALS['impulse_current_page']['id'] ?? 0);
    $_isMasterTpl = $_pgId > 0 && class_exists('\Instinctor\Core\Verticals')
        && in_array($_pgId, \Instinctor\Core\Verticals::templateIds(), true);
    $_seoBrandOurs = class_exists('\Instinctor\Core\Plugin')
        && \Instinctor\Core\Plugin::isInstinctorApp()
        && empty($GLOBALS['impulse_is_demo'])
        && $_pgOwnerId <= 0
        && !$_isMasterTpl
        && !(class_exists('\Instinctor\Core\Tenant') && \Instinctor\Core\Tenant::isTenant());
    if ($bodyScroll === 'classic' && $_seoBrandOurs && empty($headerElements)) {
        $_seoLogoHome = function_exists('home_url') ? home_url('/') : '/';
        echo '<a class="impulse-seo-brand" href="' . esc_url($_seoLogoHome) . '" aria-label="Instinctor home">'
           . '<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><g transform="scale(2.288)" fill="currentColor" stroke="none" fill-rule="evenodd">'
           . '<path d="M3.02 0.02l2.23 0c1.44,0 2.75,0.59 3.7,1.54 0.95,0.95 1.54,2.26 1.54,3.7 0,1.44 -0.59,2.75 -1.54,3.7 -0.95,0.95 -2.26,1.54 -3.7,1.54 -1.44,0 -2.75,-0.59 -3.7,-1.54 -0.95,-0.95 -1.54,-2.26 -1.54,-3.7l0 -2.23 2.31 0 0 2.23c0,0.8 0.33,1.53 0.86,2.06 0.53,0.53 1.26,0.86 2.06,0.86 0.8,0 1.53,-0.33 2.06,-0.86 0.53,-0.53 0.86,-1.26 0.86,-2.06 0,-0.8 -0.33,-1.53 -0.86,-2.06 -0.53,-0.53 -1.26,-0.86 -2.06,-0.86l-2.23 0 0 -2.31z"/>'
           . '<rect x="0.02" y="0.02" width="2.31" height="2.31"/></g></svg>'
           . '<span>Instinctor</span></a>';
        $_seoBrandCss = '.impulse-seo-brand{position:fixed;top:22px;left:30px;z-index:1100;display:inline-flex;align-items:center;gap:9px;'
           . 'color:#fff;text-decoration:none;pointer-events:auto;filter:drop-shadow(0 2px 9px rgba(0,0,0,0.5))}'
           . '.impulse-seo-brand svg{display:block}'
           . '.impulse-seo-brand span{font-size:15px;font-weight:600;letter-spacing:0.02em;line-height:1}'
           . '@media(max-width:767px){.impulse-seo-brand{top:14px;left:16px}.impulse-seo-brand svg{width:20px;height:20px}.impulse-seo-brand span{font-size:13px}}';
        if (function_exists('wp_add_inline_style')) wp_add_inline_style('instinctor-page-inline', $_seoBrandCss);
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSS text, not markup: composed here from hardcoded declarations plus sanitised colour/size settings. No CSS-context escaper applies without corrupting the rules.
        else echo '<style>' . $_seoBrandCss . '</style>';
    }

    // 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
    // three.min.js is 714KB and impulse-3d-effects.js another 138KB. They were
    // emitted on every page, including plain text-and-image pages that never
    // touch WebGL, and setting impulse_three_emitted unconditionally turned the
    // correct impulse_needs_three check further down into a no-op. Emit them
    // early only when the page actually runs a 3D effect; the late check
    // re-emits for any flag raised after this point.
    // impulse-screens.js stays unconditional: classic pages need releaseToBody().
    $_emitted = [
        'instinctor-motion-lava',
        'instinctor-wave',
        'instinctor-timeline',
        'instinctor-screens',
        'instinctor-section-fx',
    ];
    // The page's OWN environment effect counts too. Only the child-sequence
    // loop raised this flag, so a page running a 3D environment directly (no
    // child sequences) shipped three.min.js via some other dependency but
    // never impulse-3d-effects.js, leaving the environment unrendered.
    if (impulse_env_needs_three($screenCloudEffect)) {
        $GLOBALS['impulse_needs_three'] = true;
    }
    if (!empty($GLOBALS['impulse_needs_three'])) {
        $_emitted[] = 'instinctor-three';
        $_emitted[] = 'instinctor-3d-effects';
        $GLOBALS['impulse_three_emitted'] = true;
    }
    \Instinctor\Core\Assets::enqueueMany($_emitted);
    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');
        $_emitted[] = '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');
        $_emitted[] = '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');
        \Instinctor\Core\Assets::enqueue('instinctor-depth-mesh'); // 3D-relief depth mode
        $_emitted[] = 'instinctor-depth-parallax';
        $_emitted[] = 'instinctor-depth-mesh';
        // depth engines declare instinctor-three as a dependency, so the
        // dependency resolver pulls it in even when it was not emitted early.
        if (!in_array('instinctor-three', $_emitted, true)) {
            $_emitted[] = 'instinctor-three';
            $GLOBALS['impulse_three_emitted'] = true;
        }
    }
    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.
    // Print exactly what was enqueued above. The old hardcoded list printed
    // fx-engine / gl-transitions / depth-* even when their conditionals had
    // declined them, which is how a plain page ended up shipping 1.7MB of JS.
    $_screen_handles = array_values(array_unique($_emitted));
    // 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','instinctor-depth-mesh']));
    }
    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((string) 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;
    // The page id, for footer-time consumers: the chat widget passes it back so
    // the server can resolve a per-page chat persona from THIS page's settings.
    $GLOBALS['impulse_current_page_id'] = (int) $post_id;

    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 && function_exists('wp_nav_menu')) {
                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' || $mode === 'unroll') {
        // Sweep and Unroll are container-level (CSS mask / clip + 3D curl
        // on the whole block) — 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') {
            // Entity-aware tokenizing: stored copy is HTML-escaped, and a
            // per-character split dismembered "&#039;" into six one-char
            // spans the browser can never reassemble - the raw entity text
            // rendered on screen (Sergei 2026-07-25, chars-reveal paragraph).
            // An entity is ONE character.
            // Chars are inline-blocks, and line breaks are legal between ANY
            // two atomic inlines - a chars paragraph wrapped mid-word
            // ("yo|u'll"). Group each word's chars in a nowrap wrapper so
            // breaks only happen at spaces, like real text.
            $words = preg_split('/(\s+)/u', $p, -1, PREG_SPLIT_DELIM_CAPTURE);
            foreach ($words as $w) {
                if ($w === '') continue;
                if (preg_match('/^\s+$/u', $w)) { $out .= $w; continue; }
                $out .= '<span class="impulse-tr-w" style="display:inline-block;white-space:nowrap">';
                preg_match_all('/&#?[a-zA-Z0-9]+;|./us', $w, $mTok);
                foreach ($mTok[0] as $c) {
                    $out .= '<span class="impulse-tr-char" style="--i:' . ($idx++) . '">' . $c . '</span>';
                }
                $out .= '</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) {
    // Content dependency. A band that exists only to frame owner-supplied content
    // must not survive as a bare heading when that content is absent: a trade who
    // has uploaded no job photos would otherwise publish a RECENT JOBS screen with
    // nothing under it, which reads as a broken site rather than an empty one
    // (Sergei 2026-07-28). Frontend only: section.php skips this whole function in
    // the editor, so the owner still sees the band and its placeholders while
    // building, which is where they discover the feature.
    $requires = (string) ($settings['requires'] ?? '');
    if ($requires === 'service_photos') {
        // A DEMO IS NOT A PUBLISHED SITE. Services do not exist until publish, so
        // this gate is always false on a demo and took the whole RECENT JOBS band
        // with it: heading, strip and all, even though the element carries nine
        // seeded photographs and marquee.php is ready to render them (its "state 2"
        // branch). The band only ever appeared after publishing, which is why the
        // work strip was missing from the demo walk (Sergei asked where Recent Jobs
        // went, 2026-07-30). The seeded photos are stripped at publish by
        // bakePublishedElements, so a real owner with no uploads still gets the
        // honest empty-state behaviour this gate was written for.
        if (!empty($GLOBALS['impulse_is_demo'])) return true;
        if (!function_exists('impulse_marquee_service_photos')) return false;
        if (!impulse_marquee_service_photos()) return false;
    }

    $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_top'])) {
        $_ra .= ' data-push-top="1"';      // per-block: margin-bottom:auto in the section's flex column -> this block + those before it pin to the top
    }
    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
    }
    // PER-DEVICE push. Desktop above is the baseline; a tablet/mobile key OVERRIDES
    // it inside that breakpoint. Three states, because two are not enough: absent
    // means "inherit desktop", '1' means pin, and '0' means explicitly do not pin
    // (needed when desktop pushes and the narrower device must not). Emitting
    // nothing when the key is absent is what keeps every existing page byte
    // identical. Sergei 2026-08-07: buttons bottom on desktop, top on mobile.
    foreach (['tablet', 'mobile'] as $_pDev) {
        foreach (['top', 'bottom'] as $_pEdge) {
            $_pKey = 'push_' . $_pEdge . '_' . $_pDev;
            if (!array_key_exists($_pKey, $_rs) || $_rs[$_pKey] === '' || $_rs[$_pKey] === null) continue;
            $_pVal = (string) (int) !empty($_rs[$_pKey]);
            $_ra .= ' data-push-' . $_pEdge . '-' . $_pDev . '="' . esc_attr($_pVal) . '"';
        }
    }
    // Nudge (flow_offset_x / _y): a LOCAL move - relative translate on this
    // block only, siblings keep their positions. The Image block has carried
    // these keys for a while; this lifts them to EVERY block so the context
    // menu's "Nudge position" is honest everywhere (Sergei 2026-07-28). Image
    // keeps its own richer per-device handling: skip here so it is not doubled.
    $_elType = is_array($el) ? ($el['type'] ?? '') : '';
    if ($_elType !== 'image') {
        $_nx = isset($_rs['flow_offset_x']) && $_rs['flow_offset_x'] !== '' ? (int) $_rs['flow_offset_x'] : 0;
        $_ny = isset($_rs['flow_offset_y']) && $_rs['flow_offset_y'] !== '' ? (int) $_rs['flow_offset_y'] : 0;
        if ($_nx !== 0 || $_ny !== 0) {
            $_nx = max(-600, min(600, $_nx));
            $_ny = max(-600, min(600, $_ny));
            $_ra .= ' style="position:relative;transform:translate(' . $_nx . 'px,' . $_ny . 'px)"';
        }
    }
    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');
            // Stream this section's accumulated responsive CSS right behind its
            // markup instead of parking it in the footer (see the helper's
            // docblock in wp-shims.php: footer CSS on a slow stream = a page
            // restyle ~15s after first paint, re-rating LCP).
            if (function_exists('instinctor_flush_inline_styles')) instinctor_flush_inline_styles();
            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 && function_exists('wp_nav_menu')) {
                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);
            // Mobile: line the address/hours block up with the map's left edge
            // (once per page). See impulse_map_row_align_css() in map.php.
            if (empty($GLOBALS['impulse_map_row_align_done']) && function_exists('impulse_map_row_align_css')) {
                $GLOBALS['impulse_map_row_align_done'] = true;
                echo impulse_map_row_align_css();
            }
            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);
            $kCubeStyle   = ($settings['cube_style'] ?? 'faces') === 'tiles' ? 'tiles' : 'faces';
            $kCubeSize    = floatval($settings['cube_size'] ?? 55);
            $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 (!in_array($kHeightMode, ['auto', 'fixed', 'vh', 'fit'], true)) {
                $kHeightMode = !empty($settings['fit_container']) ? 'fit'
                             : (((string)($settings['height_auto'] ?? '1') === '0') ? 'fixed' : 'auto');
            }
            if (($kComp === 'cylinder' || $kComp === 'cube') && $kHeightMode === 'auto') $kHeightMode = 'fixed';
            /* A TEXT composition has its type in flow, so the box hugs it and the space
               around it belongs to the SECTION, which has its own height and padding.
               Only the scene compositions - which paint an absolute stage and add no
               flow height - keep a height of their own. Forced, not merely hidden: a
               hidden control over a saved 400px would leave the block stuck at a size
               nothing can change (Sergei 2026-08-08). */
            $kSceneHeights = ['cylinder','sweep','brutal','cube','glitchloop','echo','helix','curtain','typepulse','tide','discs','vortex','wiper'];
            if (!in_array((string) $kComp, $kSceneHeights, true)) $kHeightMode = 'auto';
            $kFitContainer = ($kHeightMode === 'fit');
            $kAutoHeight   = ($kHeightMode === 'auto');
            /* 'vh' is its own mode with its own key: a shared number would
               reread a saved 400px block as 400vh the moment the unit flipped. */
            $kHeightVh     = max(10, min(100, (int) ($settings['height_vh'] ?? 100)));
            $kHeightCss    = $kAutoHeight ? 'auto'
                           : ($kFitContainer ? '100%'
                           : (($kHeightMode === 'vh') ? ($kHeightVh . 'vh') : ($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';
            /* Per-device knobs (Sergei 2026-07-30: Kinetic must save ALL settings
               separately per device). Emit -tablet / -mobile ONLY when the key is
               actually overridden, so an untouched block emits exactly what it did
               before and the runtime falls back to the desktop value. The editor
               preview resolves per device server-side and emits one value, which
               falls straight through. */
            $kDevAttr = function (string $key, string $attr) use ($settings): string {
                $out = '';
                foreach (['tablet', 'mobile'] as $dev) {
                    $k = $key . '_' . $dev;
                    if (isset($settings[$k]) && $settings[$k] !== '') {
                        $out .= ' ' . $attr . '-' . $dev . '="' . esc_attr((string) $settings[$k]) . '"';
                    }
                }
                return $out;
            };
            $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' && $kComp !== 'cube');
            $kEngineAuto = ($kAutoHeight || $kGrows) && empty($settings['as_background']); /* stage laid in-flow + height-shrink skipped */
            /* Non-cylinder kinetic never CROPS: the box is a floor, not a cage.
               auto hugs the text (no floor at all); px and vh set min-height to
               the value so "100vh" really is a full viewport, and the block still
               grows past it rather than clipping a long line. Before 2026-08-05
               every non-auto mode fell through to min-height:0, so the Height
               slider did nothing whatsoever for these compositions.
               Cylinder and Cube keep their hard box; Fit fills the parent. */
            $kAsBg = !empty($settings['as_background']) && (string) $settings['as_background'] !== '0';
            /* Background mode: the block fills its SECTION (padding included) and
               takes no flow space, so a headline and buttons sit on top of it
               instead of being pushed below. inset:0 resolves against the
               section because .impulse-container is not positioned.
               z-index:-1 is what puts it under the in-flow siblings: a
               positioned box at z-index 0 or auto still paints ABOVE
               non-positioned block content. The matching
               `.impulse-section:has(.impulse-kinetic-bg){isolation:isolate}`
               rule keeps that -1 inside the section, otherwise it escapes
               behind the section's own background and vanishes. */
            $kStyle = $kAsBg
                ? ('position:absolute;inset:0;margin:0;z-index:-1;overflow:hidden;'
                   . 'display:flex;align-items:center;justify-content:center;')
                : ($kEngineAuto
                ? ('width:100%;height:auto;min-height:' . ($kAutoHeight ? '0' : $kHeightCss) . ';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>' : '';
            // Free WP.org build: kinetic is an EDITOR demo, same model as 3D and
            // the motion wall. This file is the FRONT-END renderer only (the
            // builder uses impulse-preview.php), so the gate needs no editor flag.
            //
            // The heading still ships, with its text VISIBLE instead of the
            // clip-hidden SEO span: the engine never arrives to paint over it, and
            // a headline hidden with nothing drawn on top reads as a broken plugin
            // rather than as a feature the free tier does not include. It keeps the
            // block's own colour and size, so it lands as deliberate static type.
            // Sergei 2026-08-07.
            if (function_exists('instinctor_motion_demo_locked') && instinctor_motion_demo_locked(false)) {
                echo '<' . tag_escape($kTagName) . ' id="' . esc_attr($kElId) . '"'
                   . ' class="impulse-kinetic-block impulse-kinetic-static"'
                   . ' style="' . esc_attr($kStyle) . '">'
                   . esc_html($_kSeo)
                   . '</' . tag_escape($kTagName) . '>';
                break;
            }
            echo '<' . tag_escape($kTagName) . ' id="' . esc_attr($kElId) . '" class="impulse-kinetic-block' . ($kAsBg ? ' impulse-kinetic-bg' : '') . '"'
                . ' 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-disc-clip="' . ((isset($settings['disc_clip']) && !$settings['disc_clip']) ? '0' : '1') . '"'
                . ' 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'])) . '"' : '')
                . $kDevAttr('speed', 'data-speed')
                . $kDevAttr('intensity', 'data-intensity')
                . $kDevAttr('line_gap', 'data-line-gap')
                . $kDevAttr('comp_scale', 'data-comp-scale')
                . $kDevAttr('comp_size', 'data-comp-size')
                . $kDevAttr('disc_font', 'data-disc-font')
                . $kDevAttr('cyl_diameter', 'data-cyl-diameter')
                . $kDevAttr('cube_size', 'data-cube-size')
                . $kDevAttr('grad_speed', 'data-grad-speed')
                . ' data-cyl-diameter="' . esc_attr($kCylDiameter) . '"'
                . ' data-cube-style="' . esc_attr($kCubeStyle) . '"'
                . ' data-cube-mono="' . (!empty($settings['cube_mono']) ? '1' : '0') . '"'
                . ' data-cube-size="' . esc_attr($kCubeSize) . '"'
                . ' data-tide-bubbles="' . ((!empty($settings['tide_bubbles']) && (string) $settings['tide_bubbles'] !== '0') ? '1' : '0') . '"'
                // 'desktop', not $device: this is impulse_render_frontend_element_inner($el),
                // which has no $device in scope, so the call emitted a PHP warning into the
                // page on every kinetic block. The frontend deliberately emits the DESKTOP
                // value here and lets the data-comp-font-tablet / -mobile attributes above
                // (from $kDevAttr) drive the per-device override at runtime.
                . ' data-comp-font="' . esc_attr((string) max(0, min(400, (float) impulse_device_val($settings, 'comp_font', 'desktop', 0)))) . '"'
                . ' data-disc-font="' . esc_attr((string) max(0, min(400, (float) ($settings['disc_font'] ?? 0)))) . '"'
                . ' data-kinetic-weight="' . esc_attr((string) ($settings['kinetic_weight'] ?? '')) . '"'
                . ' data-disc-ink="' . esc_attr((string) ($settings['disc_ink'] ?? '')) . '"'
                . ' data-disc-glow="' . ((!empty($settings['disc_glow']) && (string) $settings['disc_glow'] !== '0') ? '1' : '0') . '"'
                . ' data-disc-buttons="' . ((isset($settings['disc_buttons'])
                        ? !empty($settings['disc_buttons'])
                        /* Absent: infer from the URLs, so a block that carries links
                           never renders them silently disabled. */
                        : (trim((string) ($settings['disc_link_0'] ?? '') . ($settings['disc_link_1'] ?? '')
                                . ($settings['disc_link_2'] ?? '') . ($settings['disc_link_3'] ?? '')) !== '')
                    ) ? '1' : '0') . '"'
                . ' data-disc-subs="' . esc_attr(rawurlencode((string) wp_json_encode([
                    (string) ($settings['disc_sub_0'] ?? ''),
                    (string) ($settings['disc_sub_1'] ?? ''),
                    (string) ($settings['disc_sub_2'] ?? ''),
                    (string) ($settings['disc_sub_3'] ?? ''),
                ]))) . '"'
                . ' data-disc-links="' . esc_attr(rawurlencode((string) wp_json_encode([
                    (string) ($settings['disc_link_0'] ?? ''),
                    (string) ($settings['disc_link_1'] ?? ''),
                    (string) ($settings['disc_link_2'] ?? ''),
                    (string) ($settings['disc_link_3'] ?? ''),
                ]))) . '"'
                . ' data-disc-colors="' . esc_attr(rawurlencode((string) wp_json_encode([
                    (string) ($settings['disc_color_0'] ?? ''),
                    (string) ($settings['disc_color_1'] ?? ''),
                    (string) ($settings['disc_color_2'] ?? ''),
                    (string) ($settings['disc_color_3'] ?? ''),
                ]))) . '"'
                . ' data-echo-count="' . esc_attr((string) max(2, min(8, (int) ($settings['echo_count'] ?? 5)))) . '"'
                . ' 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-comp-scale="' . esc_attr((string) (floatval($settings['comp_scale'] ?? 1) ?: 1)) . '"'
                . ' data-comp-size="' . esc_attr((string) max(0, min(10, (float) ($settings['comp_size'] ?? 0)))) . '"'
                . ' data-font="' . esc_attr((string) ($settings['kinetic_font'] ?? 'system')) . '"'
                . ' data-cursor-magnetic="'  . (filter_var($settings['cursor_magnetic']  ?? false, FILTER_VALIDATE_BOOLEAN) ? '1' : '0') . '"'
                . ' data-cursor-spotlight="' . (filter_var($settings['cursor_spotlight'] ?? false, FILTER_VALIDATE_BOOLEAN) ? '1' : '0') . '"'
                . ' data-cursor-gradient="'  . (filter_var($settings['cursor_gradient']  ?? false, FILTER_VALIDATE_BOOLEAN) ? '1' : '0') . '"'
                . ' data-cursor-tilt="'      . (filter_var($settings['cursor_tilt']      ?? false, FILTER_VALIDATE_BOOLEAN) ? '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) . '}}');
            }
            /* Height + height mode per device. These are pure CSS on the block, so
               they ride @media rather than the engine (which never sees a resize
               mid-animation). Mode wins over the px value: fit = fill the parent,
               auto = hug the text, fixed = the device's Height slider. */
            $_kHCss = function ($mode, $px) {
                if ($mode === 'fit')   return 'height:100% !important';
                if ($mode === 'auto')  return 'height:auto !important';
                if ($mode === 'fixed') return 'height:' . intval($px) . 'px !important';
                return $px !== null && $px !== '' ? 'height:' . intval($px) . 'px !important' : '';
            };
            foreach ([['tablet', '(max-width:1024px)'], ['mobile', '(max-width:767px)']] as $_kd) {
                [$_kdev, $_kq] = $_kd;
                $_kmode = strtolower(trim((string) ($settings['height_mode_' . $_kdev] ?? '')));
                $_kpx   = $settings['height_' . $_kdev] ?? '';
                if ($_kmode === '' && ($_kpx === '' || $_kpx === null)) continue;
                if ($_kmode === '') $_kmode = 'fixed';
                if ($_kmode === 'fixed' && ($_kpx === '' || $_kpx === null)) $_kpx = $kHeight;
                $_kcss = $_kHCss($_kmode, $_kpx);
                if ($_kcss === '') continue;
                wp_add_inline_style('instinctor-page-inline', '@media' . $_kq . '{#' . esc_attr($kElId) . '{' . $_kcss . '}}');
            }
            /* 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'] ?? 'center')] ?? 'center';
            $_fbVAlignBack = $_vAlignMap[(string)($settings['vertical_align_back'] ?? 'center')] ?? 'center';
            // 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;
            $baseSideStyleBack = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:' . $_fbAlignItems . ';justify-content:' . $_fbVAlignBack . ';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) . ';';
            $frontImage = trim((string) ($settings['front_image'] ?? ''));
            if ($frontImage !== '') {
                // Image face: cover, with a bottom scrim so a bottom-aligned title stays
                // legible over any photo. front_bg remains the load-time fallback color.
                $frontStyle .= 'background-image:linear-gradient(to top, rgba(18,14,10,0.78) 0%, rgba(18,14,10,0.18) 55%, rgba(18,14,10,0) 100%), url(' . esc_url($frontImage) . ');background-size:cover;background-position:center;';
            }

            $backStyle = $baseSideStyleBack . '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 'slideup':
                    // No flip: a (usually translucent, slightly dark) overlay slides UP
                    // from the bottom over the front, carrying the back content. The front
                    // — a portrait on team cards — stays put and shows through. Inner stays
                    // flat so z-index stacks the back above the front. Sergei 2026-07-14.
                    $frontStyle .= 'z-index:1;';
                    $backStyle  .= 'z-index:2;transform:translateY(100%);';
                    break;
                case 'zoom':
                    $frontStyle .= 'z-index:2;';
                    $backStyle .= 'z-index:1;transform:scale(0.82);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 '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':
                    $frontStyle .= 'z-index:2;';
                    $backStyle .= 'z-index:1;opacity:0;transform:scale(0.92);';
                    break;
                case 'cube':
                    $containerStyle .= 'perspective:1000px;overflow:hidden;';
                    $cubeTranslate = $height / 2;
                    $innerStyle .= 'transform-style:preserve-3d;transition:transform 0.6s ease-in-out;transform:translateZ(-' . $cubeTranslate . 'px);';
                    $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;
                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 .= '}';
            }
            // Mobile metric clamp (mirror of flip_box.php, Sergei 2026-07-24):
            // desktop title/content/padding cap DOWNWARD on <=767px unless the
            // designer saved explicit mobile overrides.
            // Per-key guard: a *_mobile value EQUAL to desktop is materialized
            // junk (clamp anyway); only a DIFFERENT mobile value is intent.
            $_fbRealMob = static function (string $key, $desktop) use ($settings): bool {
                $m = $settings[$key . '_mobile'] ?? null;
                return $m !== null && $m !== '' && intval($m) !== intval($desktop);
            };
            $_dTF = intval($settings['front_title_size'] ?? 24);
            $_dTB = intval($settings['back_title_size'] ?? $_dTF);
            $_dCF = intval($settings['front_content_size'] ?? 14);
            $_dCB = intval($settings['back_content_size'] ?? $_dCF);
            $_cl = '';
            if (!$_fbRealMob('front_title_size', $_dTF) && $_dTF > 20) $_cl .= '#' . $_fbId . ' .impulse-flip-front .impulse-flip-title{font-size:20px!important}';
            if (!$_fbRealMob('back_title_size', $_dTB) && $_dTB > 20)  $_cl .= '#' . $_fbId . ' .impulse-flip-back .impulse-flip-title{font-size:20px!important}';
            if (!$_fbRealMob('front_content_size', $_dCF) && $_dCF > 15) $_cl .= '#' . $_fbId . ' .impulse-flip-front .impulse-flip-content{font-size:15px!important}';
            if (!$_fbRealMob('back_content_size', $_dCB) && $_dCB > 15)  $_cl .= '#' . $_fbId . ' .impulse-flip-back .impulse-flip-content{font-size:15px!important}';
            $_dPU = isset($settings['padding']) ? intval($settings['padding']) : null;
            if ($_dPU !== null) {
                if (!$_fbRealMob('padding', $_dPU) && $_dPU > 18) $_cl .= '#' . $_fbId . ' .impulse-flip-front,#' . $_fbId . ' .impulse-flip-back{padding:18px!important}';
            } else {
                $_pt = intval($settings['padding_top'] ?? 24); $_pr = intval($settings['padding_right'] ?? 24);
                $_pb = intval($settings['padding_bottom'] ?? 24); $_pl = intval($settings['padding_left'] ?? 24);
                if (!$_fbRealMob('padding_top', $_pt) && max($_pt, $_pr, $_pb, $_pl) > 18) {
                    $_cl .= '#' . $_fbId . ' .impulse-flip-front,#' . $_fbId . ' .impulse-flip-back{padding:' . min($_pt,18) . 'px ' . min($_pr,18) . 'px ' . min($_pb,18) . 'px ' . min($_pl,18) . 'px!important}';
                }
            }
            if ($_cl !== '') $_fbResp .= '@media(max-width:767px){' . $_cl . '}';
            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){}


// Slide strips

// Cursor tracking
// 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="translateZ(-"+ct+"px) 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==="slideup"){back.style.transition="transform "+(spd*0.62/1000)+"s cubic-bezier(0.22,1,0.36,1)";back.style.transform="translateY(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"){if(b._zt)clearTimeout(b._zt);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==="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==="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)}
});

// mouseleave
b.addEventListener("mouseleave",function(){
if(a==="cube"){inner.style.transition="transform 0.5s ease-in-out";inner.style.transform="translateZ(-"+ct+"px)"}else if(a==="push"){inner.style.transition="transform 0.5s ease-in-out";inner.style.transform=""}
else if(a==="fade"){front.style.opacity=""}
else if(a==="slideup"){back.style.transform="translateY(100%)"}
else if(a==="zoom"){if(b._zt)clearTimeout(b._zt);back.style.transition="transform "+(spd*0.45/1000)+"s ease-in-out,opacity "+(spd*0.4/1000)+"s ease-in-out";back.style.transform="scale(0.82)";back.style.opacity="0";front.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";front.style.transform="scale(1)";front.style.opacity="1";b._zt=setTimeout(function(){front.style.zIndex="2";back.style.zIndex="1";front.style.transition="";front.style.transform="";front.style.opacity=""},spd*0.7)}
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==="depth-rise"){_rb(back);front.style.transition="none";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;var _tc=e.changedTouches[0];if(!b._touchFlipped){b._touchFlipped=true;b.dispatchEvent(new MouseEvent("mouseenter",{bubbles:true,cancelable:true,clientX:_tc.clientX,clientY:_tc.clientY}))}else{b._touchFlipped=false;b.dispatchEvent(new MouseEvent("mouseleave",{bubbles:true,cancelable:true}))}});b.addEventListener("touchmove",function(e){if(!b._touchFlipped)return;var _tm=e.touches[0];b.dispatchEvent(new MouseEvent("mousemove",{bubbles:true,cancelable:true,clientX:_tm.clientX,clientY:_tm.clientY}))},{passive:true});if(!window._impulseFlipOutside){window._impulseFlipOutside=true;document.addEventListener("touchend",function(e){(window._impulseFlipBoxRegistry||[]).forEach(function(fb){if(fb._touchFlipped&&!fb.contains(e.target)){fb._touchFlipped=false;fb.dispatchEvent(new MouseEvent("mouseleave",{bubbles:true,cancelable:true}))}})},{passive: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.
            // Auto margins on a flex item WIN over the outer flex's justify-content,
            // so an unconditional `margin:0 auto` centered the video even when align
            // was left/right (align was silently dead whenever max_width applied).
            // Only auto-center when align actually is 'center'.
            $wrapStyle = 'position:relative;width:'.$width.'%;aspect-ratio:'.$rw.'/'.$rh.';max-height:100dvh;max-width:100%;overflow:hidden;border-radius:'.$borderRadius.'px;' . ($align === 'center' ? 'margin:0 auto;' : 'margin:0;');
            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 = '';
            $vidYtId  = '';
            if (preg_match('/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]+)/', $videoUrl, $m)) {
                $vidYtId  = $m[1];
                $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 && !$autoplay && $vidYtId !== '') {
                // Click-to-play facade: the YouTube embed is ~1MB of player JS and
                // Chromium's slow-connection lazy threshold loads even below-fold
                // iframes, costing ~6 mobile PSI points per page across the 280
                // tutorial embeds (2026-08-01). One thumbnail instead; the iframe is
                // born on click, with autoplay so the click means play. This is the
                // FRONTEND twin of the shared renderer's facade: both video paths
                // must carry it or the fix silently misses this one (Video x2 trap).
                $vfThumb = $posterUrl !== '' ? $posterUrl : 'https://i.ytimg.com/vi/' . $vidYtId . '/hqdefault.jpg';
                echo '<div class="impulse-video-facade" role="button" tabindex="0" aria-label="Play video"'
                   . ' data-embed="' . esc_url($embedUrl . '&autoplay=1') . '"'
                   . ' style="position:absolute;inset:0;cursor:pointer;background:#000">'
                   . '<img src="' . esc_url($vfThumb) . '" alt="" loading="lazy"'
                   . ' style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0.85">'
                   . '<span style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:64px;height:64px;border-radius:50%;border:3px solid #fff;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;transition:transform 0.2s ease-in-out">'
                   . '<svg width="28" height="28" viewBox="0 0 24 24" fill="#fff" stroke="none" aria-hidden="true"><polygon points="9,6 18,12 9,18"/></svg>'
                   . '</span></div>';
                if (empty($GLOBALS['impulse_video_facade_js'])) {
                    $GLOBALS['impulse_video_facade_js'] = 1;
                    echo '<script>(function(){function play(f){var i=document.createElement("iframe");i.src=f.getAttribute("data-embed");i.setAttribute("allow","accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture");i.setAttribute("allowfullscreen","");i.style.cssText="position:absolute;top:0;left:0;width:100%;height:100%;border:none";f.parentNode.replaceChild(i,f);}document.addEventListener("click",function(e){var f=e.target.closest?e.target.closest(".impulse-video-facade"):null;if(f){e.preventDefault();play(f);}},true);document.addEventListener("keydown",function(e){if(e.key!=="Enter"&&e.key!==" ")return;var f=e.target.closest?e.target.closest(".impulse-video-facade"):null;if(f){e.preventDefault();play(f);}},true);})();</script>';
                }
            } elseif ($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;

        case 'hours':
            // Shared renderer (see includes/element-renderers/hours.php). Renders the
            // owner's week straight from their profile, so the site cannot drift from
            // their booking slots or their Google listing.
            echo impulse_element_render_hours($el, $settings, 'frontend');
            break;

        case 'team':
            // Shared renderer (see includes/element-renderers/team.php). Renders the
            // owner's people straight from the staff table, so the cards ARE the team:
            // no per-card content to drift out of step with the booking picker.
            echo impulse_element_render_team($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;

        // ================================================================
        // COUNTDOWN (flip / odometer / rings / drops)
        // ================================================================
        case 'countdown':
            // Shared renderer (see includes/element-renderers/countdown.php).
            echo impulse_element_render_countdown($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 'form':
            // Unified lead-capture block: routes to contact / booking / estimate
            // by its form_type. The old three types below still render so pages
            // authored before the merge need no migration.
            echo impulse_element_render_form($el, $settings, 'frontend');
            break;

        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 '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:' . intval($settings['margin_top'] ?? 0) . 'px auto 0">';
            // 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>';

            // Mobile fallback for 3D grid modes. Coverflow / cube / conveyor / portal
            // etc. rely on width, perspective and a hover/drag affordance that collapse
            // into cropped, blurry portrait cards on a phone. For <=767px we hide the 3D
            // carousel and render a clean native scroll-snap slider (one landscape photo,
            // neighbours peeking) in its place. Desktop keeps the 3D effect untouched.
            $mobileFallbackCss = '';
            if (in_array($animMode, $_3dTiltModes, true)) {
                $mid = $carouselId . '-m';
                echo '<div id="' . esc_attr($mid) . '" class="impulse-carousel-mobile" aria-label="Gallery">';
                foreach ($images as $img) {
                    $u = $getImgUrl($img);
                    if (!$u) continue;
                    $lnk = $img['link'] ?? '';
                    echo '<div>';
                    if ($lnk) echo '<a href="' . esc_url($lnk) . '" style="display:block;width:100%;height:100%">';
                    echo '<img src="' . esc_url($u) . '" loading="lazy" alt="">';
                    if ($lnk) echo '</a>';
                    echo '</div>';
                }
                echo '</div>';
                $br = intval($borderRadius);
                $mobileFallbackCss =
                    '#' . $mid . '{display:none}'
                  . '@media(max-width:767px){'
                  .   '#' . $carouselId . '{display:none!important}'
                  .   '#' . $mid . '{display:flex;gap:10px;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;width:100%;margin:0 auto;padding:0 4px}'
                  .   '#' . $mid . '::-webkit-scrollbar{display:none}'
                  .   '#' . $mid . '>div{flex:0 0 88%;scroll-snap-align:center;aspect-ratio:3/2;border-radius:' . $br . 'px;overflow:hidden}'
                  .   '#' . $mid . ' img{width:100%;height:100%;object-fit:cover;display:block}'
                  . '}';
            }
            // 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 .= '}';
            }
            $rCss .= $mobileFallbackCss;
            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. depth_rush + card_flip JOINED the list
            // 2026-07-20: the old comment claimed the new-slider init handled them,
            // but that engine has no effect branches at all - it just translated the
            // track, so both modes "flew off to the left". The image-carousel engine
            // implements both. Keep in lockstep with slider.php.
            $_sliderGridModes = ['coverflow','cube','conveyor','portal','depth_rush','card_flip'];
            $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');
}
// Countdown block engine: enqueued only when a page renders one.
if (!empty($GLOBALS['impulse_needs_countdown'])) {
    \Instinctor\Core\Assets::enqueue('instinctor-countdown');
}
// 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');

/* WP mode: emit the per-block CSS the element renderers added AFTER wp_head()
   had already printed (and closed) the instinctor-page-inline sheet. Without
   this every wp_add_inline_style() call made during the body render is thrown
   away by WordPress, which is why a per-device background image, a per-device
   focal point, or a grid's per-device padding never applied on a WP page while
   the identical code worked standalone. Diffed against the pre-head baseline so
   nothing the head already wrote is repeated. Standalone prints its own head
   after the render and is deliberately left alone. Sergei 2026-07-31. */
if ((!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV !== 'standalone') && function_exists('wp_styles')) {
    $_late = wp_styles()->get_data('instinctor-page-inline', 'after');
    if (is_array($_late) && $_late) {
        $_baseline = isset($impulse_inline_baseline) && is_array($impulse_inline_baseline) ? $impulse_inline_baseline : [];
        $_new = array_slice($_late, count($_baseline));
        $_css = trim(implode('', array_map('strval', $_new)));
        if ($_css !== '') {
            // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
            echo '<style id="instinctor-page-inline-late">' . $_css . '</style>';
            // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
        }
    }
}

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
// ── Call bar ──────────────────────────────────────────────────────────────
// A tap-to-call line at the foot of every page, above the credit.
//
// The site carried NO phone number anywhere: not in a band, not in the footer,
// not as a tel: link, while the Settings screen's phone field had promised
// "Shown on your site so customers can call you" the whole time (Sergei
// 2026-07-31). For a trade that is the primary path: somebody with water coming
// through a ceiling calls, they do not fill in a quote form.
//
// NOT the notify number. That one is the owner's private alert line and the
// publish card promises in writing that it is never shown; this reads the
// separate public `phone` field, gated on the `phone_public` toggle.
//
// Deliberately outside the PRO markers: this is the owner's own content, not
// our attribution, so it belongs in the WordPress build too.
if (class_exists('\Instinctor\Services\BusinessProfile')) {
    $_imp_pub_phone = trim((string) \Instinctor\Services\BusinessProfile::get('phone'));
    $_imp_pub_on    = (string) \Instinctor\Services\BusinessProfile::get('phone_public');
    // Unset means ON, matching the field's default: the publish number is the
    // business number, and an owner who does not want it shown switches it off in
    // Settings. Only an explicit '0' hides the bar.
    $_imp_pub_show  = ($_imp_pub_on !== '0');
    $_imp_pub_tel   = preg_replace('/[^0-9+]/', '', $_imp_pub_phone);
    // (555) 555-5555, never 5555555555. The publish card stores bare digits under
    // instinctor_store_phone, so formatting at STORAGE would only fix new owners:
    // done here so every existing site reads properly too (Sergei 2026-08-01).
    $_imp_pub_label = function_exists('impulse_hours_phone_pretty')
        ? impulse_hours_phone_pretty($_imp_pub_phone) : $_imp_pub_phone;
    if ($_imp_pub_phone !== '' && $_imp_pub_show && strlen(preg_replace('/\D/', '', $_imp_pub_tel)) >= 7) {
        // Black, not white. A white band under a finished page read as an unstyled
        // strip stuck to the bottom rather than part of the site.
        echo '<div class="instinctor-call-bar" style="background:#1d2327;text-align:center;padding:14px 20px;'
            . 'font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',sans-serif">'
            . '<a href="tel:' . esc_attr($_imp_pub_tel) . '" style="display:inline-flex;align-items:center;gap:9px;'
            . 'color:#fff;text-decoration:none;font-size:15px;font-weight:600;letter-spacing:0.2px">'
            . '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" '
            . 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
            . '<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.7'
            . 'A2 2 0 0 1 22 16.92z"/></svg>'
            . esc_html($_imp_pub_label)
            . '</a></div>';
    }
}

// ── "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');
// UNIVERSAL on standalone (Sergei 2026-07-18): every site, EVERY page — client
// domains, instinctor.com tenant pages AND instinctor.com's own marketing pages
// (the earlier self-reference skip is gone; no exceptions). Only the WP env is
// excluded: WP.org guidelines prohibit forced frontend credits. Two layers:
// the PRO markers strip this block from the .org zip entirely (added
// 2026-07-18 — the block previously sat OUTSIDE the markers and relied on the
// runtime guard alone), and the runtime guard below keeps FREE WordPress
// installs credit-free. The MutationObserver re-adds the bar if anything
// removes it from the DOM.
//
// WP RULE REFINED (Sergei 2026-08-13): the credit now shows on PRO WordPress
// installs too. The .org guideline governs what we publish THERE, and the Pro
// WP build is not distributed through wp.org — the PRO markers already strip
// this block from the .org zip, so a free .org install never even ships the
// code. What remains is our own Pro build, delivered directly, where the
// credit is part of the deal. Free WP (and anything non-Pro) stays clean.


// ── 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();'
           . '})();';
    }
}
?>

<?php /* The recording rig is a STANDALONE-only asset: /instinctor-assets/ does not exist on a WordPress install, so this tag 404'd there and tripped the enqueue sniff. Gated rather than enqueued, because WP never needs it. Sergei 2026-08-07. */ ?>
<?php if (\Instinctor\Core\Config::get('instinctor_autopilot_enabled', '0') === '1' && defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') : ?>
<?php // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- standalone-only recording rig, gated to INSTINCTOR_ENV === 'standalone' on the line above so it is never emitted on a WordPress install; /instinctor-assets/ does not exist there. ?>
<script src="/instinctor-assets/js/impulse-autopilot.js?ver=<?php echo esc_attr(INSTINCTOR_VERSION); ?>" defer></script>
<?php // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript ?>
<?php endif; // Recording rig only: publicly armable via URL params, so it ships solely while instinctor_autopilot_enabled=1 (Sergei 2026-08-03) ?>
</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
        }
        // ── Chat hint bubble ──────────────────────────────────────────────────
        // /make-a-site has carried one since it shipped (.demo-chat-hint in
        // demo-entry.php) and it works: a small bubble beside the chat bar so the
        // visitor knows a person is reachable. That template is routed, so Impulse
        // pages never had it, and /pizza ended up with a builder people enjoy and
        // no way at all to make contact (Sergei 2026-07-31). Driven by a page
        // setting so any page can carry its own wording; absent by default.
        $_hint = trim((string) ($GLOBALS['impulse_page_settings']['chat_hint'] ?? ''));
        if (!empty($_chatHtml) && $_hint !== '') {
            $_h = esc_html($_hint);
            $_chatHtml .= '<div class="imp-chat-hint" id="impChatHint">' . $_h . '</div>'
                . '<style>'
                . '.imp-chat-hint{position:fixed;right:90px;bottom:24px;z-index:2147482000;max-width:230px;'
                . 'background:#fff;color:#1d2327;border:1px solid #e0e0e0;border-radius:12px;padding:11px 14px;'
                . 'font:500 13.5px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;'
                . 'box-shadow:0 6px 24px rgba(0,0,0,0.10);opacity:0;transform:translateY(6px);cursor:pointer;'
                . 'pointer-events:none;transition:opacity 320ms ease-in-out,transform 320ms ease-in-out}'
                . '.imp-chat-hint.show{opacity:1;transform:none;pointer-events:auto}'
                . '.imp-chat-hint::after{content:"";position:absolute;right:-6px;bottom:14px;width:12px;height:12px;'
                . 'background:#fff;border-right:1px solid #e0e0e0;border-bottom:1px solid #e0e0e0;transform:rotate(-45deg)}'
                . '@media (max-width:600px){.imp-chat-hint{right:76px;bottom:18px;max-width:200px;font-size:12.5px}}'
                . '</style>'
                . '<script>(function(){var h=document.getElementById("impChatHint");'
                . 'var b=document.getElementById("instinctor-chat-bar");'
                . 'if(!h)return; if(!b){h.parentNode.removeChild(h);return;}'   // no chat bar -> no dangling bubble
                . 'setTimeout(function(){h.classList.add("show");},2600);'
                . 'if(window.matchMedia&&window.matchMedia("(max-width:600px)").matches){'
                . 'setTimeout(function(){h.classList.remove("show");},7100);}'
                . 'h.addEventListener("click",function(){b.click();h.classList.remove("show");});'
                . 'document.addEventListener("click",function(e){'
                . 'if(e.target.closest&&e.target.closest("#instinctor-chat-bar,#instinctor-chat"))h.classList.remove("show");});'
                . '})();</script>';
        }
        if (!empty($_chatHtml)) {
            $impulse_html = str_replace('</body>', $_chatHtml . "\n</body>", $impulse_html);
        }
        unset($_chatHtml, $_chatErr, $_chatCss, $_chatJs, $_hint, $_h);
    }
}

// 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
