<?php
// phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- this template is included within a shortcode/render function (or WP template loader); the flagged assignments are page/render-local variables, not WordPress core globals.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- queries use the Instinctor DB abstraction layer with its own parameter binding; input sanitization handled by the Instinctor sanitization layer on save
// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- Instinctor uses its own token-based authentication via platform.instinctor.com; WP nonces are not applicable to this request lifecycle
if ( ! defined( 'ABSPATH' ) ) { exit; }

// Shared element renderers (renderer-unification refactor — see
// docs/renderer-architecture.md). Both this file and the frontend
// template 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 Page Builder Preview
 * 
 * @package Instinctor
 * @subpackage Admin/Views
 * @since 2.3.0
 * @version 2.9.0
 * 
 * PREVIEW ARCHITECTURE:
 * ====================
 * 
 * This file renders the live preview iframe for the Impulse page builder.
 * It displays elements from the database and provides interactive editing.
 * 
 * DATA STORAGE (v2.3+):
 * - Pages are regular WordPress 'page' post type with '_impulse_enabled' meta
 * - Elements stored in post meta '_impulse_elements' as array
 * - Settings stored in post meta '_impulse_settings'
 * 
 * HOW IT WORKS:
 * 1. Loaded via ?impulse_preview={page_id} query parameter
 * 2. Fetches page as CPT post, elements from post meta
 * 3. Renders elements with editing controls (drag handle, duplicate, delete)
 * 4. Communicates with parent frame via postMessage
 * 
 * ELEMENT RENDERING:
 * - Each element wrapped in .impulse-el with data-id and data-type
 * - Toolbar appears on hover with drag/duplicate/delete buttons
 * - Containers (section, container, row) have .impulse-dropzone children
 * - Text transforms applied via PHP (CSS transform doesn't work on uppercase)
 * 
 * DRAG AND DROP:
 * - External drops from left panel: parent sends impulse-drag-start
 * - iframe's drop handler detects drop location and sends impulse-drop back
 * - Internal reordering: drag handles within iframe
 * - Drop zones: top/bottom edges for reorder, center for move-into
 * 
 * INLINE EDITING:
 * - Heading elements have contenteditable on the heading tag
 * - Changes saved via impulse-content-changed message to parent
 * 
 * POSTMESSAGE EVENTS SENT TO PARENT:
 * - impulse-select: {id, elementType} - Element clicked
 * - impulse-deselect: {} - Empty area clicked
 * - impulse-drop: {element, parentId, position} - New element dropped
 * - impulse-delete: {id} - Delete button clicked
 * - impulse-duplicate: {id} - Duplicate button clicked
 * - impulse-reorder: {sourceId, targetId, position} - Element repositioned
 * - impulse-move-into: {sourceId, targetId} - Element moved into container
 * - impulse-content-changed: {id, field, value} - Inline edit completed
 * - impulse-drag-position: {x, y} - Mouse position during drag
 * 
 * POSTMESSAGE EVENTS RECEIVED FROM PARENT:
 * - impulse-drag-start: {element, template} - Begin drag, highlight dropzones
 * - impulse-drag-end: {} - End drag, remove highlights
 * - impulse-deselect: {} - Clear selection
 * 
 * CSS CLASSES:
 * - .impulse-el: Element wrapper
 * - .impulse-el-toolbar: Hover toolbar
 * - .impulse-dropzone: Drop target in containers
 * - body.drag-active: External drag in progress
 * - .selected: Currently selected element
 * - .drop-target: Container being hovered during drag
 * 
 * IMPORTANT NOTES:
 * - Do not use CSS text-transform, use PHP transforms instead
 * - pendingElement stores the element type during external drag
 * - Elements are rendered recursively via impulse_render_element()
 */

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

// Impulse Page Builder preview — admin context only.
// Guard prevents ?impulse_preview=N from firing on public-facing pages.
// In standalone mode INSTINCTOR_ADMIN_CONTEXT is set by AdminRouter before this file is included.
// Note: current_user_can() cannot be called here — this file is loaded during
// wp-settings.php bootstrap before wp_get_current_user() exists.
// For WP frontend editor requests (?impulse_preview=N&edit=1), we set a flag here
// and perform the capability check inside the parse_request callback below.
$GLOBALS['_impulse_wp_editor_request'] = !empty($_GET['impulse_preview']) && !empty($_GET['edit'])
    && !(function_exists('is_admin') && is_admin()); // only when not already in admin context

$_impulse_is_admin = (function_exists('is_admin') && is_admin())
    || (defined('INSTINCTOR_ADMIN_CONTEXT') && INSTINCTOR_ADMIN_CONTEXT)
    || (defined('INSTINCTOR_PREVIEW_CONTEXT') && INSTINCTOR_PREVIEW_CONTEXT)
    || ($GLOBALS['_impulse_wp_editor_request'] ?? false); // capability verified in parse_request callback

if (isset($_GET['impulse_preview']) && $_impulse_is_admin && (!defined('DOING_AJAX') || defined('INSTINCTOR_RENDER_INCLUDE')) && !defined('REST_REQUEST')) {
    $GLOBALS['impulse_preview_page_id'] = intval($_GET['impulse_preview']);
    $GLOBALS['impulse_preview_device'] = isset($_GET['device']) ? sanitize_text_field(wp_unslash($_GET['device'])) : 'desktop';
    // Edit mode only when the editor iframe explicitly requests it (&edit=1).
    // Admin opening the Preview tab (no edit=1) gets a clean runtime view —
    // buttons clickable, modals triggerable — so they can test without publishing.
    $_impulse_edit_requested = !empty($_GET['edit']);
    $GLOBALS['impulse_is_edit_mode'] = $_impulse_edit_requested;

    // Define editor mode constant to disable clickable links in preview (edit mode only)
    if ($_impulse_edit_requested && !defined('INSTINCTOR_EDITOR_MODE')) {
        define('INSTINCTOR_EDITOR_MODE', true);
    }
    
    // Animation rendering helpers (shared with frontend)
    require_once INSTINCTOR_PLUGIN_DIR . 'includes/impulse-animation-helpers.php';
    // Hero sandbox controls (shared with frontend)
    require_once INSTINCTOR_PLUGIN_DIR . 'includes/impulse-hero-sandbox.php';
    // Chat-screen pointer overlay (shared with frontend)
    require_once INSTINCTOR_PLUGIN_DIR . 'includes/impulse-chat-pointer.php';

    // 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) {
            $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);   // 0 at edges → 1 at center
                        $distY = 0.5 - $ny;          // +0.5 top → 0 mid → -0.5 bottom
                        $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 {
                // Fallback: radial SVG gradient approximation
                $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="' . (float)$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%)';
        }
    }

    // Image resolver — checks instinctor_images table first, falls back to WP media
    if (!function_exists('impulse_resolve_image')) {
        function impulse_resolve_image(int $id, string $size = 'large'): array|false {
            if ($id <= 0) return false;
            if (class_exists('\\Instinctor\\Core\\DB') && class_exists('\\Instinctor\\Core\\Storage')) {
                $table = \Instinctor\Core\DB::prefix() . 'instinctor_images';
                // Map size name → derivative role
                $roleMap = ['large' => 'd', 'medium' => 't', 'thumbnail' => 'm', 'medium_large' => 't', 'full' => 'd'];
                $role    = $roleMap[$size] ?? 'd';
                // Try derivative (WebP) first
                $deriv = \Instinctor\Core\DB::get_row(
                    \Instinctor\Core\DB::prepare(
                        "SELECT rel_path, width, height FROM {$table} WHERE parent_id = %d AND derivative_role = %s LIMIT 1",
                        $id, $role
                    ),
                    'ARRAY_A'
                );
                if ($deriv && !empty($deriv['rel_path'])) {
                    $url = \Instinctor\Core\Storage::url($deriv['rel_path']);
                    if ($url) return [$url, (int)$deriv['width'], (int)$deriv['height'], false];
                }
                // Fall back to original
                $row = \Instinctor\Core\DB::get_row(
                    \Instinctor\Core\DB::prepare(
                        "SELECT rel_path, width, height FROM {$table} WHERE id = %d AND parent_id IS NULL LIMIT 1",
                        $id
                    ),
                    'ARRAY_A'
                );
                if ($row && !empty($row['rel_path'])) {
                    $url = \Instinctor\Core\Storage::url($row['rel_path']);
                    if ($url) return [$url, (int)$row['width'], (int)$row['height'], false];
                }
            }
            if (function_exists('wp_get_attachment_image_src')) {
                return wp_get_attachment_image_src($id, $size) ?: false;
            }
            return false;
        }
    }

    if (!function_exists('impulse_resolve_image_url')) {
        function impulse_resolve_image_url(int $id, string $size = 'large'): string {
            $r = impulse_resolve_image($id, $size);
            return $r ? (string)$r[0] : '';
        }
    }

    // HTML-escape user-entered text but preserve &nbsp; and <br>. Mirrors the
    // helper in templates/impulse-page.php; whichever file loads first defines
    // it (function_exists guard).
    if (!function_exists('impulse_text_safe_breaks')) {
        function impulse_text_safe_breaks($text) {
            $escaped = esc_html((string)$text);
            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) {
                    $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 . '>';
                    $map['&lt;/' . $t . '&gt;'] = '</' . $t . '>';
                    $map['&lt;/' . strtoupper($t) . '&gt;'] = '</' . $t . '>';
                }
            }
            $out = strtr($escaped, $map);
            // Restore ANY HTML entity the writer typed verbatim. esc_html
            // turned `&` → `&amp;`, leaving the original entity one level
            // deeper. Pull it back. XSS-safe: `<` `>` stay escaped. See
            // includes/element-renderers/helpers.php for full notes.
            $out = preg_replace(
                '/&amp;(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]{1,31});/',
                '&$1;',
                $out
            );
            // Native line breaks — promote \r\n / \n to <br>. Animator panel
            // stores Enter as \n via textarea; canvas sampling also splits on
            // \n. 2.0.854.
            $out = str_replace(["\r\n", "\n"], '<br>', $out);
            return $out;
        }
    }

    // Helper function to get responsive value for current device
    // Fallback chain: device-specific → tablet (for mobile) → base → default
    if (!function_exists('impulse_get_responsive')) {
        function impulse_get_responsive($settings, $key, $default = null) {
            $device = $GLOBALS['impulse_preview_device'] ?? 'desktop';
            
            if ($device === 'desktop') {
                return $settings[$key] ?? $default;
            }
            
            // Mobile: check mobile → tablet → base
            if ($device === 'mobile') {
                if (isset($settings[$key . '_mobile'])) {
                    return $settings[$key . '_mobile'];
                }
                if (isset($settings[$key . '_tablet'])) {
                    return $settings[$key . '_tablet'];
                }
                return $settings[$key] ?? $default;
            }
            
            // Tablet: check tablet → base
            if (isset($settings[$key . '_tablet'])) {
                return $settings[$key . '_tablet'];
            }
            
            return $settings[$key] ?? $default;
        }
    }
    
    /**
     * Pre-process settings to resolve all responsive values for current device
     * This allows block rendering code to use $settings['size'] and get device-specific values
     */
    if (!function_exists('impulse_resolve_responsive_settings')) {
        function impulse_resolve_responsive_settings($settings) {
            $device = $GLOBALS['impulse_preview_device'] ?? 'desktop';
            if ($device === 'desktop') {
                return $settings;
            }
            
            // List of responsive setting keys to resolve
            $responsiveKeys = [
                // Dimensions
                'width', 'height', 'max_width', 'min_width', 'max_height', 'min_height',
                'form_width', 'button_width', 'image_width', 'image_height', 'icon_size', 'section_max_width',
                'logo_height', 'avatar_size', 'card_width', 'card_height', 'card_max_width', 'min_card_width',
                // Padding
                'padding', 'padding_top', 'padding_right', 'padding_bottom', 'padding_left',
                'padding_x', 'padding_y', 'inner_padding', 'padding_sides',
                'box_padding', 'content_padding', 'oc_padding', 'oc_width',
                'popup_padding', 'popup_radius', 'popup_width', 'popup_height',
                'side_padding',
                // Margin
                'margin', 'margin_top', 'margin_right', 'margin_bottom', 'margin_left',
                // Typography
                'size', 'font_size', 'line_height', 'letter_spacing',
                'title_size', 'subtitle_size', 'description_size', 'price_size',
                'heading_size', 'text_size', 'label_size', 'button_font_size',
                'content_size', 'number_size', 'menu_font_size',
                'front_title_size', 'front_content_size', 'back_title_size', 'back_content_size',
                'line_1_size',
                'pl_title_size', 'pl_desc_size', 'pl_price_size', 'pl_spacing', 'pl_icon_size',
                // Grid/Layout
                'columns', 'column_count', 'grid_columns', 'items_per_row', 'products_per_row',
                'gap', 'row_gap', 'column_gap', 'col_gap', 'spacing', 'item_gap', 'cell_gap',
                'gap_horizontal', 'gap_vertical', 'item_space', 'menu_gap',
                'icon_gap', 'icon_spacing', 'text_spacing',
                // Border/Radius
                'border_radius', 'radius', 'cell_radius', 'card_radius',
                'button_radius', 'field_radius',
                'border_width', 'field_border_width', 'field_gap', 'icon_border_width',
                'checkout_design_field_radius', 'checkout_design_button_radius', 'checkout_design_field_border_width',
                // Cells
                'cell_padding_h', 'cell_padding_v',
                'cell_border_width', 'cell_padding_top', 'cell_padding_bottom', 'cell_padding_sides',
                // Section
                'section_padding', 'content_width', 'container_width',
                'min_height_value', 'row_height',
                // Slider/Carousel
                'slides_to_show', 'slide_gap', 'items_to_show', 'slider_width',
                'autoplay_speed', 'transition_speed', 'scroll_speed',
                // Kinetic: every motion/layout knob is per device (Sergei
                // 2026-07-30). Without these the canvas kept showing the desktop
                // value on the tablet/mobile tabs even though the panel saved the
                // override, so the block "did not respond" to per-device edits.
                'speed', 'intensity', 'line_gap', 'comp_scale', 'comp_size', 'disc_font', 'cyl_diameter',
                'cube_size', 'grad_speed', 'height_mode',
                // Animation
                'animation_delay', 'animation_duration', 'ah_duration', 'ah_pause',
                'hover_transition', 'duration',
                // Effects
                'overlay_opacity', 'zoom', 'zoom_scale', 'rotation',
                'shadow_h', 'shadow_v', 'shadow_blur', 'hover_shadow_size',
                'offset_x', 'offset_y', 'focal_point',
                // Button
                'button_padding', 'button_size', 'btn_height',
                // Other
                'thickness', 'element_icon_size', 'element_text_size', 'element_spacing',
                'icon_padding', 'icon_radius', 'image_size',
                'trigger_delay', 'trigger_scroll', 'show_after', 'scroll_percent',
                'sticky_offset', 'sticky_z_index', 'value',
                // Alignment (can differ per device). `valign` carries the
                // section/scene-root vertical placement including the Spread
                // toggle (between/around/evenly) — same shape as content_align,
                // same per-device override expectation.
                'align', 'alignment', 'text_align', 'content_align', 'items_align',
                'valign', 'justify', 'justify_content', 'vertical_align', 'direction', 'flex_direction',
                // Divider specific (CSS-level — orientation stays global)
                'height_px', 'position_align', 'tilt',
                // Visibility (per-device show/hide)
                'visibility', 'sticky',
                'fit_min', 'fit_max', 'z_index',
                // Depth/3D effects
                'depth', 'dof_intensity', 'perspective',
                // Video
                'aspect_ratio'
            ];
            
            foreach ($responsiveKeys as $key) {
                if (isset($settings[$key]) || isset($settings[$key . '_tablet']) || isset($settings[$key . '_mobile'])) {
                    $settings[$key] = impulse_get_responsive($settings, $key, $settings[$key] ?? null);
                }
            }
            
            return $settings;
        }
    }
    
    // Helper function to calculate contrasting text color (black or white)
    if (!function_exists('impulse_contrast_color')) {
        function impulse_contrast_color($hexColor) {
            // Remove # if present
            $hex = ltrim($hexColor, '#');
            
            // Handle shorthand hex
            if (strlen($hex) === 3) {
                $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
            }
            
            // Convert to RGB
            $r = hexdec(substr($hex, 0, 2));
            $g = hexdec(substr($hex, 2, 2));
            $b = hexdec(substr($hex, 4, 2));
            
            // Calculate luminance (perceived brightness)
            $luminance = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255;
            
            // Return black for light backgrounds, white for dark backgrounds
            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_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);
        }
    }
    
    // Helper function to get Feather icon SVG
    if (!function_exists('impulse_get_feather_icon')) {
        function impulse_get_feather_icon($icon, $size = 24, $color = 'currentColor') {
            $icons = [
            '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"/>',
            '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"/>',
            'plus' => '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
            'minus' => '<line x1="5" y1="12" x2="19" y2="12"/>',
            '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"/>',
            'arrow-up' => '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
            'arrow-down' => '<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>',
            'chevron-right' => '<polyline points="9 18 15 12 9 6"/>',
            'chevron-left' => '<polyline points="15 18 9 12 15 6"/>',
            'chevron-up' => '<polyline points="18 15 12 9 6 15"/>',
            'chevron-down' => '<polyline points="6 9 12 15 18 9"/>',
            'menu' => '<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>',
            'search' => '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
            'user' => '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
            '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"/>',
            '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"/>',
            '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"/>',
            '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"/>',
            '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"/><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"/>',
            'shopping-cart' => '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>',
            'shopping-bag' => '<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/>',
            '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"/>',
            'credit-card' => '<rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/>',
            'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
            'unlock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>',
            'eye' => '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
            'eye-off' => '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
            'edit' => '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
            'trash' => '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
            'download' => '<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"/>',
            'upload' => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
            'share' => '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
            'link' => '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
            'external-link' => '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
            'info' => '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
            'alert-circle' => '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>',
            'alert-triangle' => '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
            'home' => '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
            '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"/>',
            'zap' => '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
            '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"/>',
            'award' => '<circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/>',
            '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"/>',
            'thumbs-down' => '<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/>',
            'sun' => '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>',
            'moon' => '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
            'coffee' => '<path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/>',
            'music' => '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
            'video' => '<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>',
            'camera' => '<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>',
            'image' => '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
            'file' => '<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>',
            'folder' => '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
            'bookmark' => '<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
            'tag' => '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
            'bell' => '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
            'send' => '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
            'message-circle' => '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>',
            'message-square' => '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
            'facebook' => '<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>',
            'twitter' => '<path d="M23 3a10.9 10.9 0 0 1-3.14 1.53 4.48 4.48 0 0 0-7.86 3v1A10.66 10.66 0 0 1 3 4s-4 9 5 13a11.64 11.64 0 0 1-7 2c9 5 20 0 20-11.5a4.5 4.5 0 0 0-.08-.83A7.72 7.72 0 0 0 23 3z"/>',
            'instagram' => '<rect x="2" y="2" width="20" height="20" rx="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"/>',
            'linkedin' => '<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/>',
            'youtube' => '<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z"/><polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02"/>',
            'refresh-cw' => '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
            'play' => '<polygon points="5 3 19 12 5 21 5 3"/>',
            'pause' => '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>',
            'circle' => '<circle cx="12" cy="12" r="10"/>',
            'square' => '<rect x="3" y="3" width="18" height="18" rx="2"/>',
            'activity' => '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
            '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"/>',
            '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"/>',
            '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"/>',
            'circle-dot' => '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/>'];
        
        $path = $icons[$icon] ?? $icons['star'];
        return '<svg viewBox="0 0 24 24" width="' . intval($size) . '" height="' . intval($size) . '" fill="none" stroke="' . esc_attr($color) . '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' . $path . '</svg>';
        }
    }
    
    // Alias for Lucide icons (same as Feather)
    if (!function_exists('impulse_get_lucide_icon')) {
        function impulse_get_lucide_icon($icon, $size = 24, $color = 'currentColor') {
            return impulse_get_feather_icon($icon, $size, $color);
        }
    }
    
    // Helper function for feature icons in pricing tables
    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>';
        }
    }
    
    // Helper function to render visibility badge in preview
    if (!function_exists('impulse_visibility_badge')) {
        function impulse_visibility_badge($settings) {
            $visibility = $settings['visibility'] ?? 'all';
            // Only two non-default values show a badge. Treat empty/unknown as 'all' so
            // new sections (where visibility is unset) don't render a stray "Logged Out Only"
            // badge just because the ternary fell through.
            if ($visibility !== 'logged_in' && $visibility !== 'logged_out') return '';

            $label = $visibility === 'logged_in' ? 'Logged In Only' : 'Logged Out Only';
            $icon = $visibility === 'logged_in' 
                ? '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
                : '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>';
            
            return '<div style="position:absolute;top:4px;right:4px;z-index:100;background:rgba(29,35,39,0.85);color:#fff;font-size:9px;padding:3px 6px;border-radius:3px;display:flex;align-items:center;gap:4px;pointer-events:none">' . $icon . $label . '</div>';
        }
    }
    
    // Render divider line HTML for a given style
    if (!function_exists('impulse_render_divider_line')) {
        function impulse_render_divider_line($divStyle, $t, $c, $settings) {
            $c2 = esc_attr($settings['color2'] ?? '#00ffff');
            $uid = 'idiv-' . uniqid();
            
            if ($divStyle === 'electric') {
                // Electric: a live-wire jag. Rendered as SMIL-animated <path>s (discrete
                // d-swap between a few seeded zigzag variants) plus a CSS drop-shadow glow,
                // NOT an SVG turbulence/displacement filter: Chrome truncates SVG filters
                // when an ancestor has a CSS transform (the editor's .impulse-canvas zoom),
                // which clipped the old filtered line ~35% short in the PREVIEW. Path
                // geometry + CSS drop-shadow both render correctly under transforms, so this
                // is WYSIWYG in the editor AND on the frontend. v2.1.2151.
                $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 ' . (float)$h . '" preserveAspectRatio="none" style="display:block;width:100%;height:' . (float)$h . 'px;overflow:visible">';
                echo '<path d="' . esc_attr($d0) . '" fill="none" stroke="' . esc_attr($c) . '" stroke-width="' . (float)($sw * 3) . '" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" opacity="0.4" style="filter:drop-shadow(0 0 ' . (float)$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="' . (float)($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="' . (float)$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.5;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:' . (float) max(2, $t) . 'px;background:' . esc_attr($c) . ';border-radius:' . (float)$t . 'px;box-shadow:0 0 ' . (float)$g . 'px ' . esc_attr($c) . ';overflow:hidden">';
                echo '<span style="position:absolute;top:50%;left:0;transform:translate(-50%,-50%);width:' . (float)$orbSize . 'px;height:' . (float)$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 ' . (float)$orbGlow . 'px ' . esc_attr($c) . ',0 0 ' . (float)($orbGlow * 2) . 'px ' . esc_attr($c) . ',0 0 ' . (float)($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
                $tubeH = max(80, $t * 14 + 50);
                $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'; // alternating bright/dim
                    $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.55}';
                echo '20%{opacity:0.9}22%{opacity:0.6}24%{opacity:0.52}26%{opacity:0.9}';
                echo '50%{opacity:0.58}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.55}92%{opacity:0.55}94%{opacity:0.8}100%{opacity:0.9}}';
                // Slow drift — hotspots creep along tube
                echo '@keyframes ' . esc_attr($uid) . 'dr{0%{background-position:0% 0}50%{background-position:30% 0}100%{background-position:0% 0}}';
                // Localized dimmers — dark patches that independently sputter sections
                echo '@keyframes ' . esc_attr($uid) . 'd1{0%{opacity:0.5%{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_pcss1 = ob_get_clean(); wp_add_inline_style('instinctor-page-inline', $_inst_pcss1);
                echo '<div class="impulse-divider-line" style="position:relative;height:' . (float)$tubeH . 'px;overflow:visible">';
                $bendH = 14; // how far down the tube bends
                $bendW = 8; // curve width
                $tw = $coreW * 2; // tube thickness
                // Grey inert tube — flat ends (bends provide termination)
                echo '<div id="' . esc_attr($tubeId) . '" style="position:absolute;top:50%;left:' . (int)$tw . 'px;right:' . (int)$tw . 'px;height:' . (int)$tw . 'px;margin-top:-' . (int)$coreW . 'px;background:#555;z-index:2"></div>';
                // Grey left bend
                echo '<div style="position:absolute;top:50%;left:0;width:' . (int)$bendW . 'px;height:' . (int)$bendH . 'px;margin-top:-' . (int)$coreW . 'px;border-left:' . (int)$tw . 'px solid #555;border-bottom:' . (int)$tw . 'px solid #555;border-bottom-left-radius:' . (int)($bendW + 2) . 'px;box-sizing:border-box;z-index:1" id="' . esc_attr($uid) . '-bl"></div>';
                // Grey right bend
                echo '<div style="position:absolute;top:50%;right:0;width:' . (int)$bendW . 'px;height:' . (int)$bendH . 'px;margin-top:-' . (int)$coreW . 'px;border-right:' . (int)$tw . 'px solid #555;border-bottom:' . (int)$tw . 'px solid #555;border-bottom-right-radius:' . (int)($bendW + 2) . 'px;box-sizing:border-box;z-index:1" id="' . esc_attr($uid) . '-br"></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:' . (int)$tw . 'px;right:' . (int)$tw . 'px;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:' . (int)$tw . 'px;right:' . (int)$tw . 'px;height:' . (int)($coreW * 2) . 'px;margin-top:-' . (int)$coreW . 'px;background:' . esc_attr($coreGrad) . ';background-size:200% 100%;border-radius:' . (int)$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:' . esc_attr(number_format($sp, 1)) . '%;width:' . (int)$sz . 'px;height:' . (int)$sz . 'px;margin-top:-' . intval($sz / 2) . 'px;margin-left:-' . intval($sz / 2) . 'px;background:white;border-radius:50%;filter:blur(' . (int)$blur . 'px);opacity:0.25"></div>';
                }
                // L5b: localized dimmers — dark patches that sputter independently
                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
                echo '<div style="position:absolute;top:50%;left:' . (int)$bendW . 'px;right:' . (int)$bendW . 'px;height:1px;margin-top:-' . (int)($coreW + 2) . 'px;background:' . esc_attr($specGrad) . ';border-radius:1px"></div>';
                // L7: glowing left bend
                echo '<div style="position:absolute;top:50%;left:0;width:' . (int)$bendW . 'px;height:' . (int)$bendH . 'px;margin-top:-' . (int)$coreW . 'px;border-left:' . (int)$tw . 'px solid ' . esc_attr($c) . ';border-bottom:' . (int)$tw . 'px solid ' . esc_attr($c) . ';border-bottom-left-radius:' . (int)($bendW + 2) . 'px;box-sizing:border-box;filter:drop-shadow(0 0 8px ' . esc_attr($c) . ')"></div>';
                // L8: glowing right bend
                echo '<div style="position:absolute;top:50%;right:0;width:' . (int)$bendW . 'px;height:' . (int)$bendH . 'px;margin-top:-' . (int)$coreW . 'px;border-right:' . (int)$tw . 'px solid ' . esc_attr($c) . ';border-bottom:' . (int)$tw . 'px solid ' . esc_attr($c) . ';border-bottom-right-radius:' . (int)($bendW + 2) . 'px;box-sizing:border-box;filter:drop-shadow(0 0 8px ' . esc_attr($c) . ')"></div>';
                echo '</div>';
                echo '</div>';
                // Warm-up ignition JS
                ob_start();
                echo '(function(){';
                echo 'var g=document.getElementById("' . esc_js($neonId) . '");';
                echo 'var t=document.getElementById("' . esc_js($tubeId) . '");';
                echo 'var bl=document.getElementById("' . esc_js($uid) . '-bl");';
                echo 'var br=document.getElementById("' . esc_js($uid) . '-br");';
                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 'var i=0;function step(){';
                echo 'if(i>=seq.length){g.style.opacity=1;t.style.opacity=0;if(bl)bl.style.opacity=0;if(br)br.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 'setTimeout(step,400)})();';
                $_pgJs1 = ob_get_clean();
                wp_add_inline_script('instinctor-frontend-js', $_pgJs1, 'after');

            } elseif ($divStyle === 'aurora') {
                // Aurora: wide blurred multi-color ribbon
                $h = (float) max(4, $t * 3 + 2);
                $blur = (float) max(2, $t);
                $cSafe = esc_attr($c);
                $grad = 'linear-gradient(90deg,' . $cSafe . ' 0%,' . $c2 . ' 25%,#ff00ff 50%,#ffaa00 75%,' . $cSafe . ' 100%)';
                $auroraStyle = 'display:block;height:' . $h . 'px;border-radius:' . $h . 'px;background:' . $grad . ';background-size:200% 100%;background-repeat:repeat-x;animation:impulseDivAurora 6s linear infinite;filter:blur(' . $blur . 'px) brightness(1.4)';
                echo '<span class="impulse-divider-line" style="' . esc_attr($auroraStyle) . '"></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:' . (float)$h . 'px;border-radius:' . (float)$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 ' . (float)$g . 'px rgba(255,200,100,0.5),0 0 ' . (float)($g*2) . 'px rgba(100,100,255,0.25)"></span>';
            
            } elseif ($divStyle === 'stripes') {
                // Stripes: diagonal barber-pole pattern. Gap colour = color2
                // when set, transparent otherwise — see frontend renderer for
                // matching logic.
                $h = max(3, $t + 1);
                $_stripeGap = $settings['color2'] ?? '';
                if (!$_stripeGap || $_stripeGap === 'transparent' || $_stripeGap === 'rgba(0,0,0,0)') {
                    $_stripeGap = 'transparent';
                }
                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:' . (float)$h . 'px;border-radius:' . (float)$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:' . (float)$t . 'px solid ' . esc_attr($c) . '"></span>';
            }
        }
    }


    
    // Render background shapes layer for containers.
    // Position model: shape is anchored to one of 5 reference points
    // (TL/TR/BL/BR/Center) and offset by (ox, oy) in cqw units (= % of
    // section WIDTH). Vertical offsets in width-units stay stable when the
    // section's height changes due to content reflow. Resize from any size
    // never moves the shape because translate(-50%,-50%) keeps the shape
    // centered on its anchor coordinate.
    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 = true) {
            $shapesJson = $settings['bg_shapes'] ?? '';
            if (empty($shapesJson)) return '';

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

            // container-type:inline-size makes 1cqw = 1% of this layer's width,
            // which equals the section's content width. Any cqw-based offset
            // is therefore stable across viewport resizes.
            $html = '<div class="impulse-bg-shape-layer" style="position:absolute;inset:0;pointer-events:none;z-index:3;overflow:hidden;container-type:inline-size">';

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

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

                $ap = impulse_anchor_pos($anchor);

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

                $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:' . ($visible ? (floatval($opacity) / 100) : '0') . ';';
                $style .= 'color:' . esc_attr($color) . ';';
                if ($isEditor) {
                    $style .= 'pointer-events:auto;cursor:move;';
                }

                // 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) {
                    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);
                    }
                }
                // Sanitise user-supplied SVG markup before embedding. Allowlist
                // covers the standard SVG drawing primitives — everything else
                // (script, foreignObject, animate, etc.) gets stripped. Defined
                // once per function call; wp_kses dedupes at the engine level.
                static $bg_svg_allowed = null;
                if ($bg_svg_allowed === null) {
                    $bg_svg_allowed = [
                        'svg'      => ['viewbox' => true, 'xmlns' => true, 'fill' => true, 'width' => true, 'height' => true, 'preserveaspectratio' => true, 'style' => true],
                        'g'        => ['fill' => true, 'stroke' => true, 'transform' => true, 'opacity' => true, 'data-c' => true, 'data-s' => true],
                        'path'     => ['d' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'fill-rule' => true, 'clip-rule' => true, 'transform' => true, 'opacity' => true, 'data-c' => true, 'data-s' => true],
                        'circle'   => ['cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'data-c' => true, 'data-s' => true],
                        'rect'     => ['x' => true, 'y' => true, 'width' => true, 'height' => true, 'fill' => true, 'stroke' => true, 'rx' => true, 'ry' => true, 'data-c' => true, 'data-s' => true],
                        'ellipse'  => ['cx' => true, 'cy' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'data-c' => true, 'data-s' => true],
                        'polygon'  => ['points' => true, 'fill' => true, 'stroke' => true, 'data-c' => true, 'data-s' => true],
                        'polyline' => ['points' => true, 'fill' => true, 'stroke' => true, 'data-c' => true, 'data-s' => true],
                        'line'     => ['x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true, 'data-c' => true, 'data-s' => true],
                        'defs'     => [],
                        'use'      => ['href' => true, 'xlink:href' => true, 'x' => true, 'y' => true],
                    ];
                }
                $svg = wp_kses($svg, $bg_svg_allowed);

                $html .= '<div class="impulse-bg-shape-el" data-shape-idx="' . $idx . '" data-anchor="' . esc_attr($anchor) . '" style="' . esc_attr($style) . '">';

                if (!empty($shape['type']) && $shape['type'] === 'image' && !empty($shape['image_url'])) {
                    $html .= '<img src="' . esc_url((string)($shape['image_url'] ?? '')) . '" alt="' . esc_attr($shape['name'] ?? 'Graphic') . '" style="width:100%;height:100%;object-fit:contain;display:block">';
                } elseif ($hasColorGroups) {
                    $html .= '<svg viewBox="' . esc_attr($viewbox) . '" width="100%" height="100%" style="display:block">' . $svg . '</svg>';
                } else {
                    $html .= '<svg viewBox="' . esc_attr($viewbox) . '" width="100%" height="100%" fill="' . esc_attr($colors[0] ?? $color) . '" stroke="none" style="display:block">' . $svg . '</svg>';
                }
                $html .= '</div>';
            }

            $html .= '</div>';
            return $html;
        }
    }
    
    // Helper: get device-specific value with fallback
    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;
        }
    }
    
    // Helper function to extract product images
    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 = 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;
        }
    }
    
    // Helper function to render a product card (matches card-designer.php exactly)
    if (!function_exists('impulse_render_product_card')) {
        function impulse_render_product_card($design, $elements, $cardStyle, $imgPadding, $name, $thumb, $price, $salePrice, $hasSale, $sku = '', $stockQty = null, $thumb2 = '', $productId = 0, $description = '', $thumb3 = '', $extraThumbs = [], $isFeatured = false, $imgHeightPx = 0, $flipCards = false, $flipEffect = 'flip-h') {
        $ttl = $design['title'];
        $prc = $design['price'];
        $btn = $design['add_to_cart'];
        $badge = $design['sale_badge'];
        $desc = $design['description'] ?? [];
        $skuSettings = $design['sku'] ?? [];
        $stockSettings = $design['stock'] ?? [];
        
        // Collect all valid images for slideshow
        $allThumbs = array_values(array_filter(array_merge([$thumb, $thumb2, $thumb3], $extraThumbs)));
        $hasSlideshow = count($allThumbs) > 1;
        
        $badgeBgVar = $badge['bg_color'] ?? '#1d2327';
        $badgeFeaturedBgVar = $badge['featured_bg_color'] ?? '#1d2327';
        $badgeTxtVar = $badge['text_color'] ?? '#fff';
        $html = '<div class="impulse-pcard" style="' . esc_attr($cardStyle) . 'position:relative;' . ($imgHeightPx > 0 ? 'height:auto!important;' : '') . '--badge-bg:' . esc_attr($badgeBgVar) . ';--featured-badge-bg:' . esc_attr($badgeFeaturedBgVar) . ';--badge-txt:' . esc_attr($badgeTxtVar) . ';" data-product-id="' . intval($productId) . '">';
        $buttonHtml = '';
        $imageHtml = '';
        // Featured badge always renders regardless of whether sale_badge is in elements list
        $featuredText = esc_html($badge['featured_text'] ?? 'Featured');
        $badgeHtml = $isFeatured
            ? '<span class="impulse-pcard-badge impulse-pcard-badge-featured" style="position:absolute;top:12px;left:12px;z-index:10;background:var(--featured-badge-bg,' . esc_attr($badge['featured_bg_color'] ?? '#1d2327') . ');color:var(--badge-txt,' . esc_attr($badge['text_color'] ?? '#fff') . ');font-size:11px;font-weight:600;padding:4px 10px;border-radius:4px;pointer-events:none;">' . $featuredText . '</span>'
            : '';
        $titleHtml = '';
        $bottomContentHtml = ''; // price and other bottom elements
        
        foreach ($elements as $el) {
            if ($el === 'image') {
                $imgStyle = $imgHeightPx > 0 ? 'height:' . $imgHeightPx . 'px;' : '';
                $imageHtml .= '<div class="impulse-pcard-image" style="overflow:hidden;position:relative;flex-shrink:0;' . $imgStyle . '" data-quickview="' . intval($productId) . '">' ;
                $innerStyle = $imgHeightPx > 0 ? 'height:' . $imgHeightPx . 'px;position:relative;overflow:hidden;background:#f5f5f5;' : 'padding-bottom:' . $imgPadding . ';position:relative;background:#f5f5f5;';
                $imageHtml .= '<div class="impulse-pcard-image-inner" style="' . esc_attr($innerStyle) . '">';
                if ($thumb) {
                    $imageHtml .= '<img src="' . esc_url($thumb) . '" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain;">';
                } else {
                    $logoUrl = INSTINCTOR_PLUGIN_URL . 'assets/images/placeholder-logo.svg';
                    $imageHtml .= '<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#f5f5f5;">';
                    $imageHtml .= '<img src="' . esc_url($logoUrl) . '" class="impulse-placeholder-img" style="width:48px;height:auto;">';
                    $imageHtml .= '</div>';
                }
                $imageHtml .= '</div></div>';
            }
            elseif ($el === 'sale_badge' && ($badge['enabled'] ?? true)) {
                // Only show sale badge if BOTH prices exist and sale < regular
                $regularPrice = floatval($price);
                $currentSalePrice = floatval($salePrice);
                $isActuallySale = $currentSalePrice > 0 && $regularPrice > 0 && $currentSalePrice < $regularPrice;
                // Featured badge already set above; just append sale badge here
                if ($isActuallySale) {
                    $saleTop = $isFeatured ? '40px' : '12px';
                    $badgeHtml .= '<span class="impulse-pcard-badge" style="position:absolute;top:' . $saleTop . ';left:12px;z-index:10;background:var(--badge-bg,' . esc_attr($badge['bg_color'] ?? '#1d2327') . ');color:var(--badge-txt,' . esc_attr($badge['text_color'] ?? '#fff') . ');font-size:11px;font-weight:600;padding:4px 10px;border-radius:4px;pointer-events:none;">' . esc_html($badge['text'] ?? 'Sale') . '</span>';
                }
            }
            elseif ($el === 'title') {
                $titleLines = intval($ttl['lines'] ?? 2);
                $titleFontSize = intval($ttl['font_size'] ?? 16);
                $lineClamp = $titleLines > 1 ? 'display:-webkit-box;-webkit-line-clamp:' . $titleLines . ';-webkit-box-orient:vertical;overflow:hidden;' : 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
                $titleHtml = '<h3 class="impulse-pcard-title" style="margin:0;padding:12px 16px 0;font-size:' . $titleFontSize . 'px;font-weight:' . esc_attr($ttl['font_weight'] ?? '600') . ';color:' . esc_attr($ttl['color'] ?? '#1d2327') . ';line-height:1.4;' . $lineClamp . '">' . esc_html($name) . '</h3>';
            }
            elseif ($el === 'price') {
                $priceInnerHtml = '';
                // Determine the effective display price
                // If only sale_price is set (no regular price), treat sale_price as the regular price
                $regularPrice = floatval($price);
                $currentSalePrice = floatval($salePrice);
                $effectivePrice = $regularPrice > 0 ? $regularPrice : $currentSalePrice;
                
                // Only show sale formatting if BOTH prices exist and sale < regular
                $showAsSale = $hasSale && $currentSalePrice > 0 && $regularPrice > 0 && $currentSalePrice < $regularPrice;
                
                if ($showAsSale) {
                    $priceInnerHtml = '<span class="sale-price" style="color:' . esc_attr($prc['sale_color'] ?? '#dc2626') . ';font-weight:' . esc_attr($prc['font_weight'] ?? '700') . '">$' . number_format($currentSalePrice, 2) . '</span>';
                    $priceInnerHtml .= '<del style="text-decoration:line-through;color:#999;font-size:0.75em;margin-left:8px;">$' . number_format($regularPrice, 2) . '</del>';
                } else {
                    // Show the effective price (regular if exists, otherwise sale_price)
                    $displayPrice = $effectivePrice > 0 ? $effectivePrice : 0;
                    $priceInnerHtml = '<span style="font-weight:' . esc_attr($prc['font_weight'] ?? '700') . '">$' . number_format($displayPrice, 2) . '</span>';
                }
                $bottomContentHtml .= '<div class="impulse-pcard-price" style="padding:0 16px;font-size:' . intval($prc['font_size'] ?? 18) . 'px;color:' . esc_attr($prc['color'] ?? '#1d2327') . ';">' . $priceInnerHtml . '</div>';
            }
            elseif ($el === 'add_to_cart') {
                if (!$flipCards) { // button lives on back face when flip is active
                    $fullWidth = ($btn['full_width'] ?? true) ? 'width:100%;' : '';
                    $btnStyle = $fullWidth . 'padding:10px 16px;background:' . esc_attr($btn['bg_color'] ?? '#1d2327') . ';color:' . esc_attr($btn['text_color'] ?? '#fff') . ';border:none;border-radius:' . intval($btn['border_radius'] ?? 6) . 'px;font-size:14px;font-weight:500;cursor:pointer;';
                    $buttonHtml = '<div class="impulse-pcard-btn-wrap" style="padding:12px 16px 16px;"><button class="impulse-pcard-btn" style="' . esc_attr($btnStyle) . '">' . esc_html($btn['button_text'] ?? 'Add to Cart') . '</button></div>';
                }
            }
        }
        
        // Build card structure: image first, then badge directly in .impulse-pcard (z-index above image)
        $html .= $imageHtml;
        if ($badgeHtml) {
            $html .= $badgeHtml; // badges are position:absolute relative to .impulse-pcard (position:relative)
        }
        
        // Content wrapper — flex column
        $_btnSize = in_array('add_to_cart', $elements) ? (intval($btn['font_size'] ?? 14) + 32) : 0;
        if ($imgHeightPx > 0) {
            // Effect mode (Single): title grows naturally, line-clamp caps at 3 lines, grid row equalizes height
            $html .= '<div class="impulse-pcard-content" style="display:flex;flex-direction:column;flex:none;overflow:hidden;">';
            $html .= '<div style="overflow:hidden;flex-shrink:0;">' . $titleHtml . '</div>';
            // Price immediately below title slot
            if ($bottomContentHtml) {
                $html .= '<div class="impulse-pcard-bottom" style="padding-top:4px;">' . $bottomContentHtml . '</div>';
            }
            // Button immediately below price
            if ($buttonHtml) {
                $html .= '<div class="impulse-pcard-btn-inner" style="flex:none;padding:8px 16px 12px;">';
                $html .= '<button class="impulse-pcard-btn" style="' . (($btn['full_width'] ?? true) ? 'width:100%;' : '') . 'padding:10px 16px;background:' . esc_attr($btn['bg_color'] ?? '#1d2327') . ';color:' . esc_attr($btn['text_color'] ?? '#fff') . ';border:none;border-radius:' . intval($btn['border_radius'] ?? 6) . 'px;font-size:14px;font-weight:500;cursor:pointer;">' . esc_html($btn['button_text'] ?? 'Add to Cart') . '</button>';
                $html .= '</div>';
            }
            $html .= '</div>';
        } else {
            // Normal mode (grid / snap / scroll carousel)
            // Title grows naturally, line-clamp caps at 3 lines, CSS grid row equalizes card heights
            $html .= '<div class="impulse-pcard-content" style="display:flex;flex-direction:column;flex:1;">';
            $html .= '<div style="overflow:hidden;flex-shrink:0;">' . $titleHtml . '</div>';
            $html .= '<div style="flex:1;min-height:0;"></div>'; // spacer: pushes price+button to bottom
            if ($bottomContentHtml) {
                $html .= '<div class="impulse-pcard-bottom" style="padding-top:4px;">' . $bottomContentHtml . '</div>';
            }
            if ($flipCards) {
                // Spacer matches ATC button height so price sits at same vertical position as back face button
                $html .= '<div style="height:26px"></div>';
            } else {
                $html .= $buttonHtml; // button immediately after price — no gap
            }
            $html .= '</div>';
        }
        $html .= '</div>';

        // Flip card wrapping — front = original card, back = mini info card
        if ($flipCards) {
            $btn    = $design['add_to_cart'] ?? [];
            $prc    = $design['price'] ?? [];
            $cardBg = esc_attr($design['card']['background'] ?? '#ffffff');
            $cardRad = intval($design['card']['border_radius'] ?? 8);
            $cardBorderW = intval($design['card']['border_width'] ?? 1);
            $cardBorderC = esc_attr($design['card']['border_color'] ?? '#e0e0e0');
            $titleColor  = esc_attr($design['title']['color'] ?? '#1d2327');
            $priceColor  = esc_attr($prc['color'] ?? '#1d2327');
            $btnBg       = esc_attr($btn['bg_color'] ?? '#1d2327');
            $btnTxt      = esc_attr($btn['text_color'] ?? '#ffffff');
            $btnRad      = intval($btn['border_radius'] ?? 6);
            $btnLabel    = esc_html($btn['button_text'] ?? 'Add to Cart');

            // Price display for back face
            $regP = floatval($price); $saleP = floatval($salePrice);
            $showAsSaleBack = $hasSale && $saleP > 0 && $regP > 0 && $saleP < $regP;
            if ($showAsSaleBack) {
                $priceBackHtml = '<span style="color:' . esc_attr($prc['sale_color'] ?? '#dc2626') . ';font-weight:700">$' . number_format($saleP, 2) . '</span>'
                               . '<del style="color:#999;font-size:0.8em;margin-left:6px">$' . number_format($regP, 2) . '</del>';
            } else {
                $eff = $regP > 0 ? $regP : $saleP;
                $priceBackHtml = '<span style="font-weight:700">$' . number_format($eff, 2) . '</span>';
            }

            // Description — truncate. Hide entirely (no "No description
            // available" fallback) for blank placeholder cards (productId=0
            // AND empty name). Falling back to the placeholder string on
            // a card that's already empty everywhere else just clutters
            // the canvas with four identical "No description available"
            // rows. Real products with no short_description still get
            // the fallback so the back face never has a bare price.
            $descText = wp_strip_all_tags($description ?? '');
            $_isBlankPlaceholder = (intval($productId) === 0 && trim((string) $name) === '');
            if (!$descText && !$_isBlankPlaceholder) $descText = 'No description available.';

            $backStyle = 'background:' . $cardBg . ';border-radius:' . $cardRad . 'px;border:' . $cardBorderW . 'px solid ' . $cardBorderC . ';display:flex;flex-direction:column;';

            $skuBack = isset($sku) ? trim($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:' . $titleColor . ';line-height:1.4">' . esc_html($name) . '</h3>';
            if ($skuBack) $back .= '<div style="font-size:11px;color:#999">' . esc_html($skuBack) . '</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" data-product-id="' . intval($productId) . '" style="width:100%;padding:10px 16px;background:' . $btnBg . ';color:' . $btnTxt . ';border:none;border-radius:' . $btnRad . 'px;cursor:pointer;font-weight:600;font-size:14px">' . $btnLabel . '</button>';
            $back .= '</div>';
            $back .= '</div>';

            // Shadow on outer wrapper — box-shadow on flip-inner glitches during 3D rotation
            $shadowsMap = ['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 = $shadowsMap[$design['card']['shadow'] ?? 'small'] ?? 'none';
            $safeEffect = str_replace('_','-',preg_replace('/[^a-z_-]/','', $flipEffect ?: 'flip-h'));
            $html = '<div class="impulse-pcard impulse-flip-card pfc-' . $safeEffect . '" data-flip="true" data-flip-effect="' . $safeEffect . '" data-product-id="' . intval($productId) . '" style="cursor:pointer;box-shadow:' . $outerShadow . ';border-radius:' . $cardRad . 'px">'
                  . '<div class="impulse-flip-inner">'
                  . '<div class="impulse-flip-front">' . $html . '</div>'
                  . $back
                  . '</div></div>';
        }

        return $html;
        }
    }
    
    // Use parse_request for earlier interception - before WordPress processes the query

// ── Button height → (font, padding-y, padding-x) interpolation ──
// Mirrors the helper in impulse-page.php so the editor preview produces
// the same button proportions as the live site. Anchors chosen so the
// legacy S/M/L/XL preset values land cleanly at H=36/44/52/64.
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];
    }
}

// ── impulse_render_element — defined at file scope so AJAX handler can use it ──
if (!function_exists('impulse_render_element')) {
    function impulse_render_element($el) {
    // Active device tab. 14 call sites below hand this to
    // impulse_device_val(); undefined, they each raised a PHP warning and
    // silently resolved to the desktop value. Same source the rest of the
    // editor uses.
    $device = $GLOBALS['impulse_preview_device'] ?? 'desktop';
    // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct reads use the Instinctor standalone DB abstraction with its own parameter binding. Every echoed dynamic value is escaped inline; the only unescaped output is `echo impulse_element_render_*()` / `impulse_render_product_card()` callback returns, which are composed+escaped by the shared renderers in includes/element-renderers/ (out of scope for this file).
    $type = $el->section_type ?? 'section';
    $settings = $el->settings ?? [];
    $id = $el->id;
    $parentId = $el->parent_id ?? null;
    
    // Pre-process settings to resolve responsive values for current device
    $settings = impulse_resolve_responsive_settings($settings);
    
    // Build wrapper styles for 4-way margin (applies to most elements)
    $wrapperStyle = '';
    $marginTop = $settings['margin_top'] ?? 0;
    $marginRight = $settings['margin_right'] ?? 0;
    $marginBottom = $settings['margin_bottom'] ?? 0;
    $marginLeft = $settings['margin_left'] ?? 0;
    
    // Default padding: 20px for root-level blocks, 0 inside parents
    // Blocks that read padding_* internally skip wrapper padding (avoids doubling)
    // `heading` joins this list (added 2026-05-08) so its padding settings
    // apply on the .impulse-display-text wrap (mirroring `text` block which
    // applies padding on .impulse-text). Without this, heading wrapper padding
    // landed on the outer .impulse-el while text wrapper stayed at 0 — so
    // identical "padding_left: 32" panel values produced DIFFERENT layout
    // results in a flex-grid cell: heading text shifted right by 32px while
    // text stayed at cell-left. See the heading render below for the matching
    // change that emits padding on the inner wrap.
    // button: pads itself via Pad X/Y on the <a>; the frontend wrapper ignores
    // element-level padding_* for it, so the editor must too - stray saved
    // padding_left values shifted buttons right ONLY in the canvas
    // (cinematica360 hero, 2026-07-24).
    $selfPaddedBlocks = ['section', 'text', 'title', 'icon_card', 'code', 'flip_box', 'header', 'footer', 'slider', 'rows', 'columns', 'cart', 'button'];
    $isSelfPadded = in_array($type, $selfPaddedBlocks);
    $rootPadDefault = empty($parentId) ? 20 : 0;
    if ($isSelfPadded) {
        // These blocks apply padding internally — wrapper stays at 0
        // They use $rootPadDefault for their own default (20 at root, 0 inside parent)
        $paddingTop = $paddingRight = $paddingBottom = $paddingLeft = 0;
    } else {
        // Only products keeps default padding; all others start at 0
        $wrapPadDefault = 0;
        $paddingTop = intval($settings['padding_top'] ?? $wrapPadDefault);
        $paddingRight = intval($settings['padding_right'] ?? $wrapPadDefault);
        $paddingBottom = intval($settings['padding_bottom'] ?? $wrapPadDefault);
        $paddingLeft = intval($settings['padding_left'] ?? $wrapPadDefault);
    }
    
    // For images with fill_container, make wrapper fill parent
    if ($type === 'image' && !empty($settings['fill_container'])) {
        $wrapperStyle .= 'position:absolute;top:0;left:0;width:100%;height:100%;';
    } else {
        if ($marginTop > 0) $wrapperStyle .= 'margin-top:' . intval($marginTop) . 'px;';
        if ($marginRight > 0) $wrapperStyle .= 'margin-right:' . intval($marginRight) . 'px;';
        if ($marginBottom > 0) $wrapperStyle .= 'margin-bottom:' . intval($marginBottom) . 'px;';
        if ($marginLeft > 0) $wrapperStyle .= 'margin-left:' . intval($marginLeft) . 'px;';
        // Apply wrapper padding (20px default for root blocks, 0 inside parents)
        if ($paddingTop > 0 || $paddingRight > 0 || $paddingBottom > 0 || $paddingLeft > 0) {
            $wrapperStyle .= 'padding:' . $paddingTop . 'px ' . $paddingRight . 'px ' . $paddingBottom . 'px ' . $paddingLeft . 'px;';
        }
    }
    
    // Apply page width to loose blocks (root-level elements that are not sections/popups)
    if (empty($parentId) && $type !== 'section' && $type !== 'modal') {
        $pageContentWidth = $GLOBALS['impulse_page_content_width'] ?? 100;
        if ($pageContentWidth < 100) {
            $wrapperStyle .= 'max-width:' . intval($pageContentWidth) . '%;margin-left:auto;margin-right:auto;';
        }
    }
    
    // Apply section_max_width to any block that has it set (universal)
    // Nested blocks (inside grid/cell-grid/columns cells) skip margin:auto — let the cell control positioning
    // Self-centering blocks (forms, confirmations) always get margin:auto
    // Video is exempt: its own Width % slider is supposed to go up to the full container width,
    // including past the typical 1200px content cap (hero videos need to fill the screen).
    $sectionMaxWidth = intval(impulse_get_responsive($settings, 'section_max_width', 0));
    if ($sectionMaxWidth > 0 && $type !== 'video') {
        $wrapperStyle .= 'max-width:' . $sectionMaxWidth . 'px;';
        $selfCenterTypes = ['contact', 'login', 'register', 'receipt', 'account', 'checkout', 'cart', 'table', 'price_list', 'flip_box', 'text', 'plans', 'carousel', 'product_carousel', 'products', 'grid', 'columns', 'rows'];
        if (empty($parentId) || in_array($type, $selfCenterTypes)) {
            $wrapperStyle .= 'margin-left:auto;margin-right:auto;';
        }
    }
    
    // Popup: center using popup_width
    if ($type === 'modal') {
        $popupW = intval(impulse_get_responsive($settings, 'popup_width', 500));
        if ($popupW > 0) {
            $wrapperStyle .= 'max-width:' . $popupW . 'px;margin-left:auto;margin-right:auto;';
        }
    }
    
    // Apply width% to text elements. Default 100% → wrapper stretches to fill
    // the parent (align-self:stretch beats parent's align-items:center).
    // No max-width cap on the wrapper — headings don't have one either, and
    // the previous 1200px cap left a visible offset in cells wider than 1200
    // (the wrapper got capped and auto-margined, so left-aligned text glyphs
    // landed inset from the cell edge, looking "pushed right" relative to the
    // full-bleed headings above).
    // Below 100% → narrow wrapper auto-margined for centring.
    if ($type === 'text') {
        $textWidth = intval(impulse_get_responsive($settings, 'width', 100));
        if ($textWidth > 0 && $textWidth < 100) {
            $wrapperStyle .= 'width:' . $textWidth . '%;margin-left:auto;margin-right:auto;';
        } else {
            $wrapperStyle .= 'width:100%;align-self:stretch;';
        }
        // Vertical spacing — panel exposes margin_top/_bottom but the text
        // renderer wasn't reading them.
        $tMt = intval(impulse_get_responsive($settings, 'margin_top', 0));
        $tMb = intval(impulse_get_responsive($settings, 'margin_bottom', 0));
        if ($tMt) $wrapperStyle .= 'margin-top:' . $tMt . 'px;';
        if ($tMb) $wrapperStyle .= 'margin-bottom:' . $tMb . 'px;';
    }

    // Image hugs its visible footprint (v2.1.1511): the width that used
    // to live on the inner .impulse-image-wrapper is lifted up to the
    // .impulse-el so the selection box, hover toolbar and hit area match
    // the image instead of spanning the full row (this is the per-
    // renderer refactor the global v2.1.1318-1320 attempt was parked
    // for). The inner wrapper renders width:100% (image.php editor
    // branch). align-self mirrors the frontend contract: --impulse-ca
    // set by the parent section, center default. Skipped for
    // fill-container (absolute full-bleed) and pinned (position:fixed)
    // modes; grid cells neutralise it with width:auto!important so the
    // cell regime keeps sizing the img. Lightbox thumbs are fixed-px,
    // fit-content hugs them without the %-circularity trap.
    if ($type === 'image' && empty($settings['fill_container']) && empty($settings['image_pin_to_screen'])) {
        if (!empty($settings['lightbox_enabled'])) {
            $wrapperStyle .= 'width:fit-content;max-width:100%;align-self:var(--impulse-ca,center);';
        } else {
            $imgHugW = intval(impulse_get_responsive($settings, 'width', 50));
            $wrapperStyle .= 'width:var(--impulse-width,' . $imgHugW . '%);max-width:100%;align-self:var(--impulse-ca,center);';
        }
    }

    // Text width sizes the .impulse-el container (Sergei 2026-06-23) — same lift
    // as the image block above, so the selection box + hover toolbar + hit area
    // match the text box instead of spanning the full row. The inner .impulse-text
    // renders width:100% (text.php editor branch). Hard cap 1200px; centered.
    if ($type === 'text') {
        $txtHugW = max(10, min(100, intval(impulse_get_responsive($settings, 'width', 100))));
        // Mirror the frontend (text.php): a narrow box positions per the
        // block's own align, not a hardcoded margin:auto center — otherwise a
        // left/right aligned narrow text gets centered in the cell while its
        // glyphs sit left, reading as "not aligned". Default 'center' keeps
        // the historical centering (no regression). Full-width (100%) is
        // unaffected. Sergei 2026-06-25.
        $txtAlign = impulse_get_responsive($settings, 'align', 'center');
        // box_align decouples box POSITION from text alignment; falls back to
        // the text align when unset (back-compat). 2026-07-01.
        $txtBoxAlign = impulse_get_responsive($settings, 'box_align', '');
        if ($txtBoxAlign === '') $txtBoxAlign = $txtAlign;
        // box_align governs box POSITION at ANY width, including full width
        // (100%). Previously gated on <100, so a left/right box_align was
        // silently centered at full width and would not line up with a
        // left-anchored sibling (button/section). Mirrors text.php. 2026-07-03.
        $txtBoxMargin = ($txtBoxAlign === 'left')  ? 'margin-left:0;margin-right:auto;'
                     : (($txtBoxAlign === 'right') ? 'margin-left:auto;margin-right:0;'
                     : 'margin-left:auto;margin-right:auto;');
        $wrapperStyle .= 'width:var(--impulse-width,' . $txtHugW . '%);max-width:1200px;align-self:var(--impulse-ca,center);' . $txtBoxMargin;
    }

    // Kinetic fills its cell (Sergei 2026-06-25). The kinetic stage is
    // position:absolute, so the H1 has ZERO intrinsic width. The frontend
    // has no .impulse-el wrapper — the block is a direct flex child of the
    // grid-cell, so its width:100% resolves against the 710px cell. In the
    // editor the extra .impulse-el wrapper sits between them with
    // align-self:auto (parent's align-items isn't stretch in a 9-dot aligned
    // cell), so the wrapper shrinks to content = 0, the block renders 0-wide,
    // and the entrance never fires (the engine's visibility gate needs
    // width>1 → invisible kinetic in the editor only). Stretch the wrapper to
    // restore the frontend contract.
    if ($type === 'kinetic') {
        $wrapperStyle .= 'width:100%;align-self:stretch;';
    }

    // Grid fills its row in the editor (Sergei 2026-07-03). On the frontend a
    // grid is a direct flex child of the section and stretches to full width;
    // its own grid_halign (margin:auto / align-self) then centers or edge-pins
    // the grid inside that full width. In the editor the extra .impulse-el
    // wrapper had no width, so it shrank to content and a section set to
    // content_align:left pinned the whole grid to the left edge, defeating
    // grid_halign:center. Stretch the wrapper to restore the frontend contract;
    // grid.php still controls the grid's horizontal placement within it.
    if ($type === 'grid') {
        $wrapperStyle .= 'width:100%;align-self:stretch;';
    }

    // (v2.1.1320 video+image outer-width lift reverted in v2.1.1321 with
    // the rest of the shrink-to-fit change.)
    
    // Build visibility data attributes
    $visAttrs = '';
    $visDesktop = $settings['visibility'] ?? 'all';
    $visTablet = $settings['visibility_tablet'] ?? $visDesktop;
    $visMobile = $settings['visibility_mobile'] ?? $visTablet;
    if ($visDesktop === 'off') $visAttrs .= ' data-hide-desktop="1"';
    if ($visTablet === 'off') $visAttrs .= ' data-hide-tablet="1"';
    if ($visMobile === 'off') $visAttrs .= ' data-hide-mobile="1"';
    
    // Build CSS ID and custom classes
    $cssId = !empty($settings['css_id']) ? ' id="' . esc_attr((string)($settings['css_id'] ?? '')) . '"' : '';
    $cssClasses = 'impulse-el';
    if (!empty($settings['css_classes'])) {
        // Sanitize each class
        $customClasses = array_map('sanitize_html_class', explode(' ', $settings['css_classes']));
        $customClasses = array_filter($customClasses); // Remove empty
        if ($customClasses) {
            $cssClasses .= ' ' . implode(' ', $customClasses);
        }
    }
    
    // Add empty class for sections with no children (editor visibility)
    $hasContent = !empty($el->children);
    $hasBgColor = !empty($settings['bg_color']) && $settings['bg_color'] !== 'transparent';
    $hasBgImage = !empty($settings['bg_image']);
    $hasAnimation = !empty(impulse_device_val($settings, 'bg_animation_enabled', $GLOBALS['impulse_preview_device'] ?? 'desktop', false));
    $isTrulyEmpty = ($type === 'section' && !$hasContent && !$hasBgColor && !$hasBgImage && !$hasAnimation);
    if ($type === 'section' && empty($el->children)) {
        $cssClasses .= ' impulse-section-empty';
    }
    
    // Add sticky/auto-hide class for headers
    if ($type === 'header') {
        $stickyMode = $settings['sticky_mode'] ?? (!empty($settings['sticky']) ? 'sticky' : 'off');
        if ($stickyMode === 'sticky') {
            $cssClasses .= ' sticky-header';
        } elseif ($stickyMode === 'auto-hide') {
            $cssClasses .= ' sticky-header auto-hide-header';
        }
    }

    // Shrink-to-content wrappers (v2.1.1318-1320) reverted in v2.1.1321.
    // Idea was right (toolbox at actual content edge) but the
    // implementation broke percentage-width inner wraps — video block
    // with aspect-ratio + width:30% on the inner can't resolve against
    // a width:auto outer, collapsing to ~60px. Proper fix is per-
    // renderer (lift width to outer + restructure each block's inner
    // wrap), not a global CSS class. Parked until I have time to
    // refactor each renderer individually.
    
    // Depth for parallax
    $elDepth = intval($settings['depth'] ?? 0);
    $depthAttr = $elDepth !== 0 ? ' data-depth="' . $elDepth . '"' : '';
    
    // fill_container ("Fit parent"): layout blocks that stretch to fill their parent's height
    $fillAttr = '';
    $fillContainerTypes = ['slider', 'grid', 'rows', 'columns'];
    if (!empty($settings['fill_container']) && in_array($type, $fillContainerTypes, true)) {
        $fillAttr = ' data-fill="1"';
    }
    // Per-block "Push to bottom": margin-top:auto in the section's flex column so
    // this block and any after it group at the bottom edge (CSS below). v2.1.2164.
    // Bottom anchor, ONE per container: a second auto margin in the same flex
    // column splits the free space instead of grouping the content. That rule is
    // enforced in CSS (sibling rules neutralise every bottom anchor after the
    // first, in BOTH stylesheets), which is where it belongs: sibling order
    // survives the editor re-rendering one block in isolation, and no
    // server-side bookkeeping can see that.
    //
    // 2.1.2749 added a call to impulse_claim_bottom_anchor() here that was never
    // defined anywhere, so EVERY editor preview of a page with a push_bottom
    // block died with a fatal: 751's hero kinetic sets it, so /admin?p=751
    // returned a 500 the moment the canvas loaded (Sergei 2026-07-30). Emitting
    // the attribute unconditionally matches templates/impulse-page.php exactly,
    // which is what the comment claimed and the CSS already relies on.
    $pushBottomAttr = !empty($settings['push_bottom']) ? ' data-push-bottom="1"' : '';
    // Per-block "Push to top": margin-bottom:auto so this block and any before it
    // pin to the top edge. Mutually exclusive with push_bottom (CSS below).
    $pushTopAttr = !empty($settings['push_top']) ? ' data-push-top="1"' : '';
    // Per-device overrides, mirroring the frontend emit in impulse-page.php so the
    // canvas shows what publishes. Absent = inherit desktop, 1 = pin, 0 = do not.
    foreach (['tablet', 'mobile'] as $_pDev) {
        foreach (['top', 'bottom'] as $_pEdge) {
            $_pKey = 'push_' . $_pEdge . '_' . $_pDev;
            if (!array_key_exists($_pKey, $settings) || $settings[$_pKey] === '' || $settings[$_pKey] === null) continue;
            $pushTopAttr .= ' data-push-' . $_pEdge . '-' . $_pDev . '="' . esc_attr((string) (int) !empty($settings[$_pKey])) . '"';
        }
    }
    // Nudge (flow_offset_x / _y): local relative move, siblings unaffected.
    // Mirrors the frontend lift in templates/impulse-page.php so the canvas
    // shows exactly what publishes. Image keeps its own richer handling.
    $nudgeStyle = '';
    if ($type !== 'image') {
        $_nx = isset($settings['flow_offset_x']) && $settings['flow_offset_x'] !== '' ? (int) $settings['flow_offset_x'] : 0;
        $_ny = isset($settings['flow_offset_y']) && $settings['flow_offset_y'] !== '' ? (int) $settings['flow_offset_y'] : 0;
        if ($_nx !== 0 || $_ny !== 0) {
            $nudgeStyle = 'position:relative;transform:translate(' . max(-600, min(600, $_nx)) . 'px,' . max(-600, min(600, $_ny)) . 'px);';
        }
    }

    // Cloud effect (screen mode only)
    $cloudAttr = '';
    if ($type === 'section' && !empty($settings['cloud_effect']) && $settings['cloud_effect'] !== 'none') {
        $cloudAttr = ' data-cloud-effect="' . esc_attr((string)($settings['cloud_effect'] ?? '')) . '"';
    }

    $sceneRootAttr = '';
    $entranceAttr = '';
    if ($type === 'section' && !empty($settings['screen_entrance']) && $settings['screen_entrance'] !== 'none') {
        $entranceAttr  = ' data-entrance-preset="' . esc_attr((string)($settings['screen_entrance'] ?? '')) . '"';
        $entranceAttr .= ' data-entrance-duration="' . intval($settings['screen_entrance_duration'] ?? 1200) . '"';
        $entranceAttr .= ' data-entrance-bg="' . esc_attr($settings['bg_color'] ?? '#000000') . '"';
    }

    // Timeline keyframes (Phase 2) — consumed by assets/js/impulse-timeline.js
    // and the admin timeline editor (instinctor-admin-timeline.js).
    $kfAttr = '';
    if (!empty($settings['keyframes']) && is_array($settings['keyframes'])) {
        $kfAttr = ' data-keyframes=\'' . esc_attr(wp_json_encode($settings['keyframes'])) . '\'';
    }

    // Per-screen Reveal: editor-side mirror of templates/impulse-page.php
    // emission. Root sections in screen mode advertise their own preset +
    // intensity + speed so the editor's _zoneReveal / _zoneDuration honor
    // them. Empty settings emit nothing, falling through to body-level. The
    // _zoneDuration override (data-reveal-speed-ms) was added in v2.1.1335
    // alongside the zone-merge follow-up; previously speed was page-level
    // only. Intensity emission gates the same way the frontend does (empty
    // string => inherit page-level).
    $revealAttrs = '';
    $_isRootScrnEd = ($type === 'section' && empty($parentId));
    if ($_isRootScrnEd) {
        $_rvEd = (string) ($settings['screen_transition'] ?? '');
        if ($_rvEd !== '' && $_rvEd !== 'default') {
            $revealAttrs .= ' data-reveal="' . esc_attr($_rvEd) . '"';
        }
        if (isset($settings['screen_intensity']) && $settings['screen_intensity'] !== '') {
            $revealAttrs .= ' data-reveal-intensity="' . esc_attr((string)(int) $settings['screen_intensity']) . '"';
        }
        if (isset($settings['screen_speed']) && $settings['screen_speed'] !== '') {
            $_spdEdPct = max(0, min(100, (int) $settings['screen_speed']));
            $_spdEdMs  = max(300, min(2000, (int) round(2000 - ($_spdEdPct / 100) * 1700)));
            $revealAttrs .= ' data-reveal-speed-ms="' . $_spdEdMs . '"';
        }
        // Per-screen sensitivity (v2.1.1350) — editor mirror of the front
        // emission. Same 1-4 range, same skip-when-1 rule.
        if (isset($settings['screen_sensitivity']) && $settings['screen_sensitivity'] !== '') {
            $_sensEdN = max(1, min(4, (int) $settings['screen_sensitivity']));
            if ($_sensEdN > 1) $revealAttrs .= ' data-reveal-sensitivity="' . $_sensEdN . '"';
        }
        // Per-screen Min height (vh) for native/classic scroll — editor mirror.
        // Device-resolve so the active device tab (mobile/tablet) honors its own
        // screen_min_height_vh override. The editor re-renders per device, so a
        // single resolved attribute is correct here (mobile→tablet→base chain).
        $_smhEd = (int) impulse_get_responsive($settings, 'screen_min_height_vh', 0);
        if ($_smhEd > 0) {
            $revealAttrs .= ' data-native-min-vh="' . max(0, min(200, $_smhEd)) . '"';
        }
    }

    // Skippable screen (editor mirror of templates/impulse-page.php). When
    // screen_skip=1 on a root section, the editor's wheel-gesture engine
    // walks past it the same way the frontend does. The bridge is still
    // reachable via direct goToScreen / dot nav so the designer can edit it.
    $skipAttrEd = '';
    if ($type === 'section' && empty($parentId) && !empty($settings['screen_skip'])) {
        $skipAttrEd = ' data-screen-skip="1"';
        $cssClasses .= ' impulse-screen-skipped';
    }
    // Per-block reveal override (order / delay / opt-out). Read by BOTH reveal
    // engines — classic overlay-fx (impulse-overlay-fx.js) + cinematic
    // screen-decompose (impulse-screens.js and its editor mirror below). Applies
    // to any block; unset emits nothing so existing reveals are byte-identical.
    // The engines resolve a unit's override by walking up to the nearest
    // ancestor carrying these attrs, so an override on a container (grid/rows)
    // cascades to its decomposed cells. Kept inline (not a shared helper) to
    // dodge the co-deploy-a-new-helper 500 trap. v2.1.2146.
    $revealBlockAttrsEd = '';
    if (isset($settings['reveal_order']) && $settings['reveal_order'] !== '' && $settings['reveal_order'] !== null) {
        $revealBlockAttrsEd .= ' data-reveal-order="' . esc_attr((string) (int) $settings['reveal_order']) . '"';
    }
    if (!empty($settings['reveal_delay'])) {
        $revealBlockAttrsEd .= ' data-reveal-delay="' . esc_attr((string) max(0, min(5000, (int) $settings['reveal_delay']))) . '"';
    }
    if (!empty($settings['reveal_off'])) {
        $revealBlockAttrsEd .= ' data-reveal-off="1"';
    }
    echo '<div' . wp_kses_post($cssId) . ' class="' . esc_attr($cssClasses) . '" data-id="' . esc_attr($id) . '" data-type="' . esc_attr($type) . '" draggable="true"' . (($wrapperStyle || $nudgeStyle) ? ' style="' . esc_attr($nudgeStyle . $wrapperStyle) . '"' : '') . wp_kses_post($visAttrs . $depthAttr . $fillAttr . $pushBottomAttr . $pushTopAttr . $cloudAttr . $sceneRootAttr . $entranceAttr . $kfAttr . $revealAttrs . $revealBlockAttrsEd . $skipAttrEd) . '>';
    // No hover toolbar on Header or Screen (root section in screen mode):
    // both span the full canvas, so the top-right toolbar lands above the
    // page boundary where it reads as floating debris. Their operations
    // live in the Screens sidebar / Structure panel. Sergei 2026-06-10.
    $_noToolbar = ($type === 'header') || $_isRootScrnEd;
    if (!$_noToolbar) {
    echo '<div class="impulse-el-toolbar">';
    echo '<button type="button" class="impulse-el-drag" data-tip="Drag"><svg viewBox="0 0 24 24"><circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/></svg></button>';
    // Select-parent button — quick navigation up the tree without opening
    // the Structure panel. Visible only when the element has a parent (root
    // sections / nav / footer have no parent to step into).
    if (!empty($parentId)) {
        echo '<button type="button" class="impulse-el-parent" data-parent-id="' . intval($parentId) . '" data-tip="Select Parent"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m16 14-4-4-4 4"/></svg></button>';
    }
    // data-el-id is an EXPLICIT belt-and-braces id binding — the delegated
    // click handler in admin-core uses it directly rather than calling
    // closest('.impulse-el'), so even if the toolbar gets re-parented or
    // a CSS cascade quirk lets the user click a different toolbar than
    // they think, duplicate still operates on the exact element that
    // owns this toolbar.
    echo '<button type="button" class="impulse-el-duplicate" data-el-id="' . intval($id) . '" onclick="impulseDuplicate(' . intval($id) . ')" data-tip="Duplicate"><svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button>';
    // Wrap-in-container button — shown only for non-layout, non-overlay
    // blocks. One click opens a popover with Row/Columns/Grid options.
    // Source for the icon: a 2-cell horizontal split graphic to read as
    // "put this in a row container". Click handler lives in admin core JS.
    // Wrap-in-container moved to the right-click context menu (Sergei
    // 2026-07-19): the toolbox button was irrelevant for most selections
    // and ate hit-target room. The wrap-* actions live on in the shell's
    // context-menu dispatcher, gated by the same non-container type rule.
    // Delete-button skip rule:
    //   • Scene mode (screen_mode=1): every root section IS a screen —
    //     delete is managed via the screen-row's "Remove screen" button in
    //     the left panel, so the toolbar delete is suppressed.
    //   • Page mode (no screen_mode): never suppress.
    $_isPureSceneRoot = ($type === 'section' && empty($parentId));
    if (!$_isPureSceneRoot) {
        echo '<button type="button" class="impulse-el-delete" data-el-id="' . intval($id) . '" onclick="impulseDelete(' . intval($id) . ')" data-tip="Delete"><svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>';
    }
    echo '</div>';
    } // end if (!$_noToolbar)
    
    // Route consolidated block modes to their renderers
    if ($type === 'products' && ($settings['display_mode'] ?? 'grid') === 'carousel') {
        $type = 'product_carousel';
    } elseif ($type === 'carousel' && ($settings['content_type'] ?? 'images') === 'data') {
        $type = 'carousel_data'; // Handled as slider-mode render inside the switch
    } elseif ($type === 'cart') {
        $cartMode = $settings['cart_mode'] ?? 'full';
        if ($cartMode === 'mini') $type = 'mini_cart';
    }
    
    switch ($type) {
        case 'section':
            // Shared renderer (see includes/element-renderers/section.php).
            // Phase 1 step 10 of the renderer-unification refactor.
            $_secChildren = [];
            if (!empty($el->children)) {
                foreach ($el->children as $_secChild) { $_secChildren[] = $_secChild; }
            }
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_section($el, $settings, $_secChildren, 'editor', 'impulse_render_element');
            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);
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                echo impulse_element_render_title_animated($el, $settings, 'editor');
                break;
            }
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_title($el, $settings, 'editor');
            break;
            
        case 'text':
            // Shared renderer (see includes/element-renderers/text.php).
            // Phase 1 step 2 of the renderer-unification refactor.
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_text($el, $settings, 'editor');
            break;
            
        case 'image':
            // Shared renderer (see includes/element-renderers/image.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_image($el, $settings, 'editor');
            break;

        // image_popup case removed 2.1.420 — merged into 'image' as a
        // lightbox_enabled toggle.

            
        case 'button':
            // Shared renderer (see includes/element-renderers/button.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_button($el, $settings, 'editor');
            break;

        case 'icon_card':
            // Shared renderer (see includes/element-renderers/icon_card.php).
            // Phase 1 step 5 of the renderer-unification refactor.
            // Frontend now renders identically - gains container box,
            // SVG-URL inline reading, halo, data-view, and the
            // continuous-effect class that previously only worked here.
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_icon_card($el, $settings, 'editor');
            break;

        case 'reviews':
            // Shared renderer (see includes/element-renderers/reviews.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_reviews($el, $settings, 'editor');
            break;

        case 'hours':
            // Shared renderer (see includes/element-renderers/hours.php). 'editor' shows
            // where to set them, since this block has no content of its own to click.
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_hours($el, $settings, 'editor');
            break;

        case 'team':
            // Shared renderer (see includes/element-renderers/team.php). Same output as
            // the frontend; 'editor' only swaps the nobody-added-yet hint in, so an empty
            // team reads as empty rather than as a broken block.
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_team($el, $settings, 'editor');
            break;

        

        case 'network':
            // Shared renderer (see includes/element-renderers/network.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_network($el, $settings, 'editor');
            break;

        case 'marquee':
            // Shared renderer (see includes/element-renderers/marquee.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_marquee($el, $settings, 'editor');
            break;

        case 'countdown':
            // Shared renderer (see includes/element-renderers/countdown.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction
            echo impulse_element_render_countdown($el, $settings, 'editor');
            break;

        

        case 'divider':
            // Shared renderer (see includes/element-renderers/divider.php).
            // Phase 1 of the renderer-unification refactor.
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_divider($el, $settings, 'editor');
            break;
            
        case 'code':
            // Shared renderer (see includes/element-renderers/code.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_code($el, $settings, 'editor');
            break;

        case 'unfold':
            // Shared renderer (see includes/element-renderers/unfold.php).
            // Carries section_id resolution + 5 anim modes + classic fallback.
            $GLOBALS['impulse_needs_unfold'] = true;
            $unfoldChildren = is_object($el) ? ($el->children ?? []) : ($el['children'] ?? []);
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_unfold($el, $settings, $unfoldChildren, 'editor', 'impulse_render_element');
            break;
            

        // (dead duplicate cases: icon, divider, code, unfold - removed)

        case 'table':
            // Shared renderer (see includes/element-renderers/table.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_icon_card_list($el, $settings, 'editor');
            break;

        

        case 'modal':
            // Shared renderer (see includes/element-renderers/modal.php).
            $_pChildren = [];
            if (!empty($el->children)) {
                foreach ($el->children as $_pChild) { $_pChildren[] = $_pChild; }
            }
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_modal($el, $settings, $_pChildren, 'editor', 'impulse_render_element');
            break;

        case 'plans':
            // Shared renderer (see includes/element-renderers/plans.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_plans($el, $settings, 'editor');
            break;

        case 'title':
            title_animated_render_preview:
            // Materialise tablet/mobile defaults from desktop on the editor
            // preview path. The shared animator.php already does this in
            // frontend + standalone editor, but this INLINE renderer was
            // bypassing it — so switching to the Mobile/Tablet device tab
            // rendered the title at the raw desktop font size (huge) until
            // the user touched any slider, which then injected the
            // JS-side-scaled value via livePreviewSettings and snapped it
            // back to the expected size. Sergei: "Title is losing layout/
            // size once device tabs switched. Can become unpredictably huge
            // on mobile, then upon smallest change in settings - reset to
            // normal." Locked 2026-05-26.
            if (function_exists('impulse_auto_mobile_defaults')) {
                $_animDev = $GLOBALS['impulse_preview_device'] ?? 'desktop';
                impulse_auto_mobile_defaults($settings, 'title', $_animDev);
            }
            $laContentType = $settings['la_content_type'] ?? 'text';
            // No literal placeholder fallback — the frontend canonical
            // renderer uses an empty string when la_text is unset
            // (see animator.php / impulse-page.php). The editor used to
            // emit 'Logo or Heading' here, which leaked as a visible
            // default whenever the heading-to-animator dispatch fired
            // before line_1 had been remapped (e.g. when la_preset was
            // just clicked and the cached settings still had a stale
            // empty la_text). Match the frontend — emit empty so the
            // user never sees a foreign default in the preview.
            $laText        = $settings['la_text'] ?? '';
            $laFontSize    = intval(impulse_device_val($settings, 'la_font_size', ($GLOBALS['impulse_preview_device'] ?? 'desktop'), 64));
            $laColor       = $settings['la_color'] ?? '#1d2327';
            $laWeight      = $settings['la_font_weight'] ?? '700';
            $laImageSrc    = $settings['la_image_src'] ?? '';
            $__dev = $GLOBALS['impulse_preview_device'] ?? 'desktop';
            $laImageWidth  = intval(impulse_device_val($settings, 'la_image_width', $__dev, 280));
            $laBg          = $settings['la_bg'] ?? 'transparent';
            $laPreset      = $settings['la_preset'] ?? '';
            $laDuration    = intval(impulse_device_val($settings, 'la_duration', $__dev, 1200));
            $laDelay       = intval(impulse_device_val($settings, 'la_delay', $__dev, 0));
            $laFlash       = !empty($settings['la_flash']) ? 1 : 0;
            // Loop mode removed — entrance always plays on scroll-into-view
            // (or immediately when force-immediate is set for above-fold).
            $laScrollTrig  = !empty($_hdrForceImmediate) ? 0 : 1;
            // 2026-05-23: prefer mouse_sens (Title panel's Sensitivity
            // slider, 0..50) over la_tilt. The heading-dispatch path
            // (impulse_title_remap_to_animated) copies mouse_sens →
            // la_tilt only when the element type IS 'heading';
            // elements typed 'animator' jump straight here. Bypass
            // impulse_device_val for the raw read (the earlier wrap
            // returned 0 because the synthetic mini-settings array
            // confused the responsive resolver). Responsive overrides
            // for `mouse_sens` aren't a user-exposed feature anyway —
            // a single 0..50 scalar across devices is fine.
            $_laTiltRaw = $settings['mouse_sens'] ?? $settings['la_tilt'] ?? 0;
            $laTilt    = max(0, min(50, intval($_laTiltRaw)));
            $laDepth       = intval(impulse_device_val($settings, 'la_depth', $__dev, 50));
            // la_ring_arc retired 2026-05-23 — slider + engine removed.
            $laGapBelow    = intval(impulse_device_val($settings, 'la_gap_below', $__dev, 0));
            // 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.
            $laGradFrom    = $laColor;
            $laGradTo      = $settings['la_gradient_to'] ?? '';
            $laGradAngle   = intval($settings['la_gradient_angle'] ?? 135);
            $laElId        = 'la-' . $id;
            $laFontFamily  = $settings['font_family'] ?? '';
            
            if (!($laContentType === 'image' && $laImageSrc)) {
                // Letter spacing — heading's letter_spacing slider routed via la_letter_spacing.
                // Default animator tightening is -0.01em when the heading didn't pass anything.
                $_laLetterSpacing = isset($settings['la_letter_spacing'])
                    ? intval($settings['la_letter_spacing']) . 'px'
                    : '-0.01em';
                /* Wrap inside narrow containers. min-width:0 is required because the
                   animator container is `display:flex` and a flex item's default
                   `min-width:auto` would otherwise let the asset push past max-width
                   (visible when the parent section also has its own animation/overlay
                   wrappers tightening its containment). */
                $laFontStyle = 'font-size:' . $laFontSize . 'px;font-weight:' . esc_attr($laWeight) . ';line-height:1.15;text-align:center;letter-spacing:' . $_laLetterSpacing . ';max-width:100%;min-width:0;word-break:break-word;overflow-wrap:break-word';
                // Gradient fill via background-clip:text when both colors set; otherwise plain color.
                // Gradient must render independent of Ring Arc or any other setting.
                if (!empty($laGradTo) && !empty($laGradFrom)) {
                    // CSS convention (0deg=up) vs panel/canvas convention (0deg=right) — add 90°.
                    $laCssAngle = ($laGradAngle + 90) % 360;
                    $laFontStyle .= ';background-image:linear-gradient(' . $laCssAngle . 'deg,' . esc_attr($laGradFrom) . ',' . esc_attr($laGradTo) . ');-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent';
                } else {
                    $laFontStyle .= ';color:' . esc_attr($laColor);
                }
                if ($laFontFamily) {
                    // font_family stores a full system-font stack — emit raw.
                    $laFontStyle .= ';font-family:' . esc_attr($laFontFamily);
                }
            }
            
            // 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 — filter:drop-shadow traces canvas + asset pixels both;
            // see frontend renderer comment.
            $_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%;min-height:80px'
                . ';' . $_laHaloCss;
            echo '<div id="' . esc_attr($laElId) . '" class="impulse-logo-animator"'
                . ' data-preset="' . esc_attr($laPreset) . '"'
                . ' data-duration="' . (int)$laDuration . '"'
                . ' data-delay="' . (int)$laDelay . '"'
                . ' data-flash="' . (int)$laFlash . '"'
                . ' data-scroll-trigger="' . (int)$laScrollTrig . '"'
                . ' data-tilt="' . (int)$laTilt . '"'
                . ' data-depth="' . (int)$laDepth . '"'
                . ' data-gradient-from="' . esc_attr($laGradFrom) . '"'
                . ' data-gradient-to="' . esc_attr($laGradTo) . '"'
                . ' data-gradient-angle="' . (int)$laGradAngle . '"'
                . ' style="' . esc_attr($_laWrapStyle) . '">';
            if ($laContentType === 'image' && $laImageSrc) {
                echo '<img src="' . esc_attr($laImageSrc) . '" class="impulse-la-asset" style="width:' . (int)$laImageWidth . 'px;max-width:100%;display:block" alt="">';
            } else {
                echo '<div class="impulse-la-asset" style="' . esc_attr($laFontStyle) . '">' . wp_kses_post(impulse_text_safe_breaks($laText)) . '</div>';
            }
            echo '</div>';
            if (!isset($GLOBALS['impulse_animator_js_emitted'])) {
                $GLOBALS['impulse_animator_js_emitted'] = true;
                $_laBase = defined('INSTINCTOR_PLUGIN_URL') ? INSTINCTOR_PLUGIN_URL : '/instinctor-plugin/';
                $_laVer  = defined('INSTINCTOR_VERSION')    ? INSTINCTOR_VERSION    : '0';
                $_laBootJson = function_exists('wp_json_encode') ? wp_json_encode($_laBase) : json_encode($_laBase);
                \Instinctor\Core\Assets::enqueue('instinctor-animator-js');
                if (function_exists('wp_add_inline_script')) {
                    wp_add_inline_script('instinctor-animator-js', 'window._instinctorPluginUrl=' . $_laBootJson . ';', 'before');
                }
            }

            break;
        
        case 'kinetic':
            /* Composition engine v3 (2026-05-29) — text + composition model.
               Legacy preset/phrases settings migrate to text for the new engine. */
            $kComp        = $settings['composition'] ?? 'block-slam';
            /* Free-tier coercion — keep editor canvas in sync with frontend
               renderer above. Pro composition or custom-mode saved on a free
               WP install renders as block-slam in the preview too, so the user
               sees exactly what their visitors will. */
            if (!instinctor_has_pro()) {
                if ($kComp !== 'block-slam') $kComp = 'block-slam';
                if (!empty($settings['custom_mode'])) { $settings['custom_mode'] = '0'; $kCustomMode = '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(impulse_device_val($settings, 'cube_size', $device, 55));
            $kAccent      = $settings['accent'] ?? 'none';
            $kAccentText  = $settings['accent_text'] ?? '';
            $kCustomLines = $settings['custom_lines'] ?? '[]';
            if (is_array($kCustomLines)) {
                $kCustomLines = 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']   ?? '';
            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));
                    }
                }
            }
            /* Height mode (consolidated): auto | fixed | fit. One setting replaces
               the old height_auto + fit_container keys (legacy fallback kept).
               Cylinder can't hug, so 'auto' collapses to 'fixed'. Mirrors
               templates/impulse-page.php. */
            $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 can hug it exactly
               and any space around it belongs to the SECTION, which has its own
               height and padding. Two controls competing for the same vertical space
               is how blocks ended up 400px tall around 84px of text (Sergei
               2026-08-08). Only the scene compositions - which paint an absolute
               stage and contribute no flow height - keep a height of their own, and
               the panel hides the control everywhere else. Forced rather than merely
               hidden: a hidden control over a saved 400px would leave the block stuck
               at a size nothing can change. */
            $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 → <div>; H1/H2/H3 → real heading.
               Mirrors templates/impulse-page.php. margin:0 kills default
               heading margins (inner letter spans drive the size). */
            /* Horizontal alignment: desktop value rides data-align (the runtime
               sets the stage's align-items/text-align from it); tablet/mobile
               overrides ride @media + !important below, mirroring the title
               block's align mechanism. */
            $kAlign = strtolower(trim((string)($settings['align'] ?? 'center')));
            if (!in_array($kAlign, ['left', 'right'], true)) $kAlign = 'center';
            $kElId  = 'kin-' . $id;
            $kTagRaw  = strtolower(trim((string)($settings['tag'] ?? '')));
            $kTagName = in_array($kTagRaw, ['h1', 'h2', 'h3'], true) ? $kTagRaw : 'div';
            /* Non-cylinder kinetic HUGS its text — never crop AND never strand a
               huge gap. Content-sized: grows for more lines, shrinks to the text
               (no min-height floor). Height stops forcing a box for non-cylinder.
               Cylinder keeps its drum box; Fit fills parent. Mirrors
               templates/impulse-page.php. */
            $kGrows      = (!$kAutoHeight && !$kFitContainer && $kComp !== 'cylinder' && $kComp !== 'cube');
            $kEngineAuto = ($kAutoHeight || $kGrows) && empty($settings['as_background']);
            /* 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: plain heading text inside the tag, visually
            // hidden (crawlable h1/h2/h3). Engine paints animation over it.
            // Mirror of templates/impulse-page.php.
            $_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)));
            echo '<' . esc_html($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="' . (float)$kSpeed . '"'
                . ' data-intensity="' . (float)$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) . '"'
                /* Editor: emit the value already resolved for the active device
                   tab (desktop/tablet/mobile), so the per-device cap previews
                   correctly. No -tablet/-mobile attrs needed here — the engine
                   uses this single resolved value. */
                . ' data-max-width="' . esc_attr((string)(floatval(impulse_device_val($settings, 'max_width', $device, 0)) ?: 0)) . '"'
                . ' 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') . '"'
                . ' data-comp-font="' . esc_attr((string) max(0, min(400, (float) impulse_device_val($settings, 'comp_font', $device, 0)))) . '"'
                . ' data-disc-font="' . esc_attr((string) max(0, min(400, (float) impulse_device_val($settings, 'disc_font', $device, 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(impulse_device_val($settings, 'comp_scale', $device, 1)) ?: 1)) . '"'
                . ' data-comp-size="' . esc_attr((string) max(0, min(10, (float) impulse_device_val($settings, 'comp_size', $device, 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) . '"'
                . '>' . ($_kSeo !== '' ? '<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap">' . esc_html($_kSeo) . '</span>' : '') . '</' . esc_html($kTagName) . '>';
            $_kAlignCss = function ($a) {
                if ($a === 'left')  return 'align-items:flex-start !important;text-align:left !important';
                if ($a === 'right') return 'align-items:flex-end !important;text-align:right !important';
                return 'align-items:center !important;text-align:center !important';
            };
            $_kAlignTab = strtolower(trim((string)($settings['align_tablet'] ?? '')));
            $_kAlignMob = strtolower(trim((string)($settings['align_mobile'] ?? '')));
            if ($_kAlignTab !== '' && $_kAlignTab !== $kAlign) {
                wp_add_inline_style('instinctor-page-inline', '@media(max-width:1024px){#' . esc_attr($kElId) . ' .impulse-kinetic-stage{' . $_kAlignCss($_kAlignTab) . '}}');
            }
            if ($_kAlignMob !== '' && $_kAlignMob !== $kAlign) {
                wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . esc_attr($kElId) . ' .impulse-kinetic-stage{' . $_kAlignCss($_kAlignMob) . '}}');
            }
            /* Inline init script: re-executed on editor refresh so the
               block always (re)attaches a fresh instance with new settings.
               DO NOT strip the <script> wrapper. Without it the init JS
               renders as VISIBLE TEXT in the editor canvas (the editor-side
               twin of the 1bc1563 frontend regression). The script must sit
               adjacent to its block (document.currentScript +
               previousElementSibling). Mirror of impulse-page.php case 'kinetic'. */
            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':
            // Shared renderer (see includes/element-renderers/flip_box.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_flip_box($el, $settings, 'editor');
            break;

        case 'social':
            // Shared renderer (see includes/element-renderers/social.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_social($el, $settings, 'editor');
            break;

        case 'header':
            // Delegated to the shared element renderer
            // (includes/element-renderers/header.php). Was inline ~272 lines
            // here that diverged from the frontend and the shared file.
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_header($el, $settings, 'editor');
            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 colors
            $linkColor = $textColor;
            $linkHoverColor = $headingColor;
            // Per-footer typography (editor parity, Sergei 2026-06-24).
            $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;
            $borderColor = impulse_derive_border($bgColor);
            // Neutralise the derived divider to greyscale, then let the footer
            // "Line" swatch override it. Mirrors footer.php + impulse-page.php.
            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'];
            
            // Logo — read from header settings (site-wide)
            $showLogo = !empty($settings['show_logo']);
            $logoImg = '';
            if ($showLogo) {
                // Try to find header element's logo on this page
                foreach ($elementTree as $_rootEl) {
                    if (($_rootEl->section_type ?? '') === 'header') {
                        $logoImg = $_rootEl->settings['logo'] ?? '';
                        break;
                    }
                }
            }
            $fDevice = $GLOBALS['impulse_preview_device'] ?? 'desktop';
            $logoHeight = 32;
            
            // Store name from Config
            $footerSiteName = \Instinctor\Core\Config::get('instinctor_store_name', get_bloginfo('name'));
            
            // Columns
            $col1Title = $settings['col1_title'] ?? 'Company';
            $col1Menu = $settings['col1_menu'] ?? '';
            $col2Title = $settings['col2_title'] ?? 'Support';
            $col2Menu = $settings['col2_menu'] ?? '';
            $col3Title = $settings['col3_title'] ?? 'Legal';
            $col3Menu = $settings['col3_menu'] ?? '';
            
            // Social — from global social profiles
            $showSocial = $settings['show_social'] !== false;
            $socialLinks = [];
            if ($showSocial) {
                $socialLinks = impulse_get_social_links();
            }
            
            // Copyright
            $showCopyright = $settings['show_copyright'] ?? true;
            if ($showCopyright === '0' || $showCopyright === 0 || $showCopyright === false) $showCopyright = false;
            
            // Social icons SVG paths
            $socialIcons = [
                'facebook' => '<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>',
                'x' => '<path 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' => '<rect x="2" y="2" width="20" height="20" rx="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"/>',
                'linkedin' => '<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/>',
                'youtube' => '<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z"/><polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02"/>',
                'tiktok' => '<path d="M9 12a4 4 0 1 0 4 4V4a5 5 0 0 0 5 5"/>',
                'pinterest' => '<path d="M12 2C6.477 2 2 6.477 2 12c0 4.236 2.636 7.855 6.356 9.312-.088-.791-.167-2.005.035-2.868.182-.78 1.172-4.97 1.172-4.97s-.299-.598-.299-1.482c0-1.388.806-2.425 1.81-2.425.853 0 1.265.641 1.265 1.408 0 .858-.545 2.14-.828 3.33-.236.995.5 1.807 1.48 1.807 1.778 0 3.144-1.874 3.144-4.58 0-2.393-1.72-4.068-4.177-4.068-2.845 0-4.515 2.135-4.515 4.34 0 .859.331 1.781.745 2.281a.3.3 0 0 1 .069.288l-.278 1.133c-.044.183-.145.223-.335.134-1.249-.581-2.03-2.407-2.03-3.874 0-3.154 2.292-6.052 6.608-6.052 3.469 0 6.165 2.473 6.165 5.776 0 3.447-2.173 6.22-5.19 6.22-1.013 0-1.965-.527-2.292-1.148l-.623 2.378c-.226.869-.835 1.958-1.244 2.621.937.29 1.931.446 2.962.446 5.523 0 10-4.477 10-10S17.523 2 12 2z"/>'];
            
            // Helper to render social icons
            $renderSocialIcons = function() use ($socialLinks, $socialIcons, $linkColor) {
                foreach ($socialLinks as $link) {
                    $platform = $link['platform'] ?? 'facebook';
                    $url = $link['url'] ?? '#';
                    if (empty($url) || $url === '#') continue;
                    $iconPath = $socialIcons[$platform] ?? $socialIcons['facebook'];
                    echo '<a href="' . esc_url($url) . '" target="_blank" rel="noopener"><svg width="20" height="20" viewBox="0 0 24 24" ' . ($platform === 'instagram' ? 'fill="none" stroke="' . esc_attr($linkColor) . '" stroke-width="2"' : 'fill="' . esc_attr($linkColor) . '"') . '>' . wp_kses($iconPath, impulse_svg_allowed()) . '</svg></a>';
                }
            };
            
            // Copyright — auto-generated from Config. Footer uses the SHORT
            // tagline (instinctor_store_tagline), not the long description —
            // the description field feeds Instagent / SEO meta and is a
            // paragraph; dumping it into the footer reads as marketing prose
            // and breaks every line break. Matches the live impulse-page.php
            // renderer for parity between editor preview and frontend.
            $copyrightText = '© ' . gmdate('Y') . ' ' . $footerSiteName . '. All rights reserved.';
            $siteDesc = \Instinctor\Core\Config::get('instinctor_store_tagline', '');
            
            // Payment icons
            $showPayments = !empty($settings['show_payments']);
            
            // Determine grid columns based on layout
            $gridCols = 4;
            if ($layout === '3-col') $gridCols = 3;
            if ($layout === 'centered' || $layout === 'minimal') $gridCols = 1;
            
            // Spacing — single padding value, symmetric
            $fPad = intval(impulse_device_val($settings, 'padding_top', $fDevice, 60));
            // Single-row (slim legal) footer uses tight fixed padding, mirroring
            // impulse-page.php / footer.php so the editor preview matches live.
            if ($layout === 'single_row') $fPad = 14;

            // Footer wrapper
            $footerStyle = 'background:' . esc_attr($bgColor) . ';color:' . esc_attr($textColor) . ';padding:' . $fPad . 'px 40px ' . max(intval($fPad * 0.5), 20) . 'px 40px;';
            
            echo '<footer class="impulse-footer" style="' . esc_attr($footerStyle) . '">';
            
            if ($layout === 'single_row') {
                // Slim legal line: copyright left, inline links (Terms/Privacy) right.
                // Mirrors impulse-page.php + footer.php for editor/live parity.
                $allLinks = array_merge($settings['col1_links'] ?? [], $settings['col2_links'] ?? [], $settings['col3_links'] ?? []);
                echo '<div style="max-width:1280px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap">';
                echo '<div style="font-size:13px;color:' . esc_attr($textColor) . '">';
                if ($showCopyright) { echo esc_html(html_entity_decode((string) $copyrightText, ENT_QUOTES, 'UTF-8')); }
                echo '</div>';
                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">'; $renderSocialIcons(); echo '</div>'; }
                echo '</div></div>';
            } elseif ($layout === 'minimal') {
                // Minimal: just copyright and social
                echo '<div style="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">';
                    $renderSocialIcons();
                    echo '</div>';
                }
                echo '</div>';
            } elseif ($layout === 'centered') {
                // Centered layout
                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:' . (int)$logoHeight . 'px;margin-bottom:16px">';
                }
                echo '<div style="font-size:24px;font-weight:700;color:' . esc_attr($headingColor) . ';margin-bottom:24px">' . esc_html(html_entity_decode((string) $footerSiteName, ENT_QUOTES, 'UTF-8')) . '</div>';
                if ($showSocial && !empty($socialLinks)) {
                    echo '<div style="display:flex;gap:16px;justify-content:center;margin-bottom:30px">';
                    $renderSocialIcons();
                    echo '</div>';
                }
                if ($showCopyright) {
                    echo '<div style="font-size:13px;color:' . esc_attr($textColor) . '">' . esc_html(html_entity_decode((string) $copyrightText, ENT_QUOTES, 'UTF-8')) . '</div>';
                }
                echo '</div>';
            } else {
                // Multi-column layout (3-col or 4-col)
                echo '<div style="display:grid;grid-template-columns:repeat(' . (int)$gridCols . ',1fr);gap:40px;max-width:1200px;margin:0 auto">';
                
                // 4-col → auto store-info column first; 3-col → user controls
                // all three columns (col1+col2+col3). Matches the frontend
                // renderer (templates/impulse-page.php) for WYSIWYG parity —
                // the old code forced store-info even in 3-col, which pushed
                // col3 out of the grid and hid it in the editor.
                if ($layout === '4-col') {
                    echo '<div>';
                    if ($showLogo && $logoImg) {
                        echo '<img src="' . esc_url($logoImg) . '" alt="Logo" style="height:' . (int)$logoHeight . 'px;margin-bottom:16px;display:block">';
                    }
                    echo '<div style="font-size:18px;font-weight:600;color:' . esc_attr($headingColor) . ';margin-bottom:12px">' . esc_html(html_entity_decode((string) $footerSiteName, 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>';
                }

                // Get column links
                $col1Links = $settings['col1_links'] ?? [['label'=>'About','url'=>'#'],['label'=>'Careers','url'=>'#']];
                $col2Links = $settings['col2_links'] ?? [['label'=>'Help','url'=>'#'],['label'=>'Contact','url'=>'#']];
                $col3Links = $settings['col3_links'] ?? [['label'=>'Privacy','url'=>'#'],['label'=>'Terms','url'=>'#']];
                if (is_string($col1Links)) $col1Links = json_decode($col1Links, true) ?: [];
                if (is_string($col2Links)) $col2Links = json_decode($col2Links, true) ?: [];
                if (is_string($col3Links)) $col3Links = json_decode($col3Links, true) ?: [];
                
                // Helper function to render column. No column shows more than
                // 3 items: the link list splits into ceil(n/3) sub-columns
                // (5 links → 2, 6 → 2) with denser line spacing. Mirrors the
                // frontend renderer (templates/impulse-page.php). Sergei 2026-06-23.
                $renderColumn = function($title, $links) use ($headingColor, $linkColor, $fTitleSize, $fLinkSize, $fLineH) {
                    $valid = 0;
                    if (!empty($links) && is_array($links)) { foreach ($links as $l) { if (!empty($l['label'])) $valid++; } }
                    $cols = max(1, (int) ceil($valid / 3));
                    echo '<div>';
                    if ($title !== '' && $title !== null) {
                        echo '<div style="font-size:' . (int)$fTitleSize . 'px;font-weight:600;color:' . esc_attr($headingColor) . ';margin-bottom:16px">' . esc_html(html_entity_decode((string) $title, ENT_QUOTES, 'UTF-8')) . '</div>';
                    }
                    $listStyle = $cols > 1 ? 'column-count:' . $cols . ';column-gap:36px;' : '';
                    echo '<div style="' . esc_attr($listStyle) . '">';
                    if (!empty($links)) {
                        foreach ($links as $link) {
                            $label = $link['label'] ?? '';
                            $url = $link['url'] ?? '#';
                            if ($label) {
                                echo '<a href="' . esc_url($url) . '" style="display:block;margin-bottom:6px;line-height:' . (int)$fLineH . '%;color:' . esc_attr($linkColor) . ';text-decoration:none;font-size:' . (int)$fLinkSize . 'px;break-inside:avoid">' . esc_html(html_entity_decode((string) $label, ENT_QUOTES, 'UTF-8')) . '</a>';
                            }
                        }
                    }
                    echo '</div></div>';
                };
                
                // Columns — col1+col2+col3 in both 3-col and 4-col (the
                // store-info column above only renders for 4-col).
                $renderColumn($col1Title, $col1Links);
                $renderColumn($col2Title, $col2Links);
                $renderColumn($col3Title, $col3Links);

                echo '</div>';
                
                // Bottom bar — only if there's content
                if ($showCopyright || ($showSocial && !empty($socialLinks))) {
                echo '<div style="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>';
                }
                
                // Social icons
                if ($showSocial && !empty($socialLinks)) {
                    echo '<div style="display:flex;gap:16px">';
                    $renderSocialIcons();
                    echo '</div>';
                }
                
                // Payment icons
                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"/><path fill="#fff" d="M13.2 13.5l1.1-6.9h1.8l-1.1 6.9h-1.8zm7.3-6.7c-.4-.1-.9-.3-1.6-.3-1.8 0-3 .9-3 2.2 0 1 .9 1.5 1.6 1.8.7.3 1 .6 1 .9 0 .5-.6.7-1.1.7-.7 0-1.2-.1-1.8-.4l-.3-.1-.3 1.7c.5.2 1.3.4 2.2.4 1.9 0 3.1-.9 3.1-2.3 0-.8-.5-1.4-1.5-1.8-.6-.3-1-.5-1-.8 0-.3.3-.6 1-.6.6 0 1 .1 1.3.2l.2.1.3-1.6zm4.4-.2h-1.4c-.4 0-.8.1-1 .6l-2.8 6.3h1.9l.4-1.1h2.4c.1.3.2 1.1.2 1.1h1.7l-1.4-6.9zm-2.3 4.5c.2-.4.7-2 .7-2l.1-.3.2.9.4 1.9h-1.4v-.5zm-10.5-4.5l-1.7 4.7-.2-.9c-.3-1-.1.4-1.4-.4l-.1-.1 1.5 5.6h1.9l3-6.9h-2z"/></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"><rect fill="#fff" width="32" height="20" rx="2"/><circle cx="12" cy="10" r="7" fill="#EB001B"/><circle cx="20" cy="10" r="7" fill="#F79E1B"/><path fill="#FF5F00" d="M16 4.5a6.98 6.98 0 0 0-2.5 5.5 6.98 6.98 0 0 0 2.5 5.5 6.98 6.98 0 0 0 2.5-5.5 6.98 6.98 0 0 0-2.5-5.5z"/></svg></div>';
                    if ($settings['pay_amex'] !== false) echo '<div style="background:#006FCF;padding:4px 8px;border-radius:4px"><svg width="32" height="20" viewBox="0 0 32 20"><text x="16" y="13" fill="#fff" font-size="8" 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" viewBox="0 0 32 20"><text x="16" y="13" fill="#003087" font-size="8" font-weight="bold" text-anchor="middle">PayPal</text></svg></div>';
                    echo '</div>';
                }
                echo '</div>';
                } // end bottom bar
            }
            
            echo '</footer>';
            break;
        
        case 'nav_menu':
            // Shared renderer (see includes/element-renderers/nav_menu.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_nav_menu($el, $settings, 'editor');
            break;

        case 'map':
            // Shared renderer (see includes/element-renderers/map.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_map($el, $settings, 'editor');
            break;

        case 'carousel':
            // Shared renderer (see includes/element-renderers/carousel.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_carousel($el, $settings, 'editor');
            break;

        case 'carousel_data':
        case 'slider':
            // Shared renderer (see includes/element-renderers/slider.php).
            $_slChildren = [];
            if (!empty($el->children)) {
                foreach ($el->children as $_slChild) { $_slChildren[] = $_slChild; }
            }
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_slider($el, $settings, $_slChildren, 'editor', 'impulse_render_element');
            break;

        case 'product_carousel':
            global $wpdb;
            $source = $settings['source'] ?? 'query';
            $queryType = $settings['query_type'] ?? 'recent';
            $countKey = $previewDevice !== 'desktop' ? 'count_' . $previewDevice : 'count';
            $count = intval($settings[$countKey] ?? $settings['count'] ?? 8);
            $productIds = $settings['product_ids'] ?? '';
            // 'columns' is the unified key; 'visible_count' kept for back-compat
            $visibleCount       = intval($settings['columns'] ?? $settings['visible_count'] ?? 4);
            $visibleCountTablet = intval($settings['columns_tablet'] ?? $settings['visible_count_tablet'] ?? 2);
            $visibleCountMobile = intval($settings['columns_mobile'] ?? $settings['visible_count_mobile'] ?? 1);
            $gap = intval($settings['gap'] ?? 10);
            $gapTablet = intval($settings['gap_tablet'] ?? 5);
            $gapMobile = intval($settings['gap_mobile'] ?? 5);

            // carousel_mode: snap|scroll|fade|flyin|depth_rush|card_flip|stack_peel|pixel_decode|shatter|jackpot|reels
            $carouselMode = $settings['carousel_mode'] ?? 'snap';
            if ($carouselMode === 'slide') $carouselMode = 'snap'; // back-compat
            $singleCardEffects = ['fade','card_flip'];
            $isSingleCardEffect = in_array($carouselMode, $singleCardEffects);
            $isScrollMode = ($carouselMode === 'scroll');

            $autoplay = true; // always on
            $autoplaySpeed = max(1000, intval($settings['autoplay_speed'] ?? 5) * 1000);
            $transitionSpeed = intval($settings['transition_speed'] ?? 800); // 800ms default for effects
            $pauseHover = $settings['pause_hover'] ?? true;
            // scroll_speed is 1-10 scale; map to px/s: speed * 12
            $scrollSpeed = max(1, min(10, intval($settings['scroll_speed'] ?? 5))) * 12;
            $carouselHeight = intval($settings['carousel_height'] ?? 420);
            $hoverEffect = $settings['hover_effect'] ?? 'none';
            $hoverShadowSize = intval($settings['hover_shadow_size'] ?? 15);
            $sectionBg = $settings['section_bg'] ?? '';
            $sectionPaddingV = impulse_get_responsive($settings, 'section_padding_v', 0);
            $sectionPaddingH = impulse_get_responsive($settings, 'section_padding_h', 0);

            // Device-specific visible count and gap
            $previewDevice = $GLOBALS['impulse_preview_device'] ?? 'desktop';
            if ($isSingleCardEffect) {
                $activeVisibleCount = 1; $activeGap = 0;
            } elseif ($previewDevice === 'tablet') {
                $activeVisibleCount = $visibleCountTablet; $activeGap = $gapTablet;
            } elseif ($previewDevice === 'mobile') {
                $activeVisibleCount = $visibleCountMobile; $activeGap = $gapMobile;
            } else {
                $activeVisibleCount = $visibleCount; $activeGap = $gap;
            }

            // Products query
            $products_table = $wpdb->prefix . 'instinctor_products';
            $categories_table = $wpdb->prefix . 'instinctor_product_categories';
            $where = "status = 'published'";
            $orderBy = "id DESC";
            $prepArgs = array();
            if ($source === 'manual' && $productIds) {
                $ids = array_values(array_filter(array_map('intval', explode(',', $productIds))));
                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);
                }
            } else {
                switch ($queryType) {
                    case 'newest':     break;
                    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;
            $products = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT p.* FROM $products_table p WHERE $where ORDER BY $orderBy LIMIT %d",
                    ...$prepArgs
                )
            );

            // Design / defaults
            $design = [];
            if (!empty($settings['card_design'])) $design = $settings['card_design'];
            if (empty($design)) $design = get_option('instinctor_card_design', []);
            $defaults = [
                'elements' => ['image', 'title', 'price', 'add_to_cart', 'sale_badge', 'featured_badge'],
                'card' => ['background' => '#ffffff', 'border_color' => '#e0e0e0', 'border_width' => 1, 'border_radius' => 8, 'shadow' => 'small'],
                'image' => ['aspect_ratio' => 'square'],
                'title' => ['font_size' => 16, 'font_weight' => '600', 'color' => '#1d2327', 'margin_top' => 12, 'lines' => 2],
                'price' => ['font_size' => 18, 'font_weight' => '700', 'color' => '', 'sale_color' => '#dc2626', 'margin_top' => 8], // color empty = inherits title
                'add_to_cart' => ['bg_color' => '#1d2327', 'text_color' => '#ffffff', 'border_radius' => 6, 'full_width' => true, 'margin_top' => 12, 'button_text' => 'Add to Cart'],
                'sale_badge' => ['enabled' => true, 'bg_color' => '#1d2327', 'featured_bg_color' => '#1d2327', 'text_color' => '#ffffff', 'text' => 'Sale']
            ];
            foreach ($defaults as $key => $val) {
                if (!isset($design[$key])) $design[$key] = $val;
                elseif (is_array($val) && $key !== 'elements') $design[$key] = array_merge($val, $design[$key]);
            }
            if (empty($design['elements'])) $design['elements'] = $defaults['elements'];
            if (empty($design['price']['color'])) $design['price']['color'] = $design['title']['color'] ?? '#1d2327';
            $c = $design['card'];
            $elements = array_unique($design['elements']);
            $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)'];
            $shadow = $shadows[$c['shadow'] ?? 'small'] ?? 'none';
            $carouselId = 'impulse-product-carousel-' . uniqid();

            // Hover CSS
            $hoverCSS = '';
            // Single mode: card height = content only (no stretching to fill slide)
            // Snap/scroll: height:100% so all cards in a row equalise via align-items:stretch
            if ($isSingleCardEffect) {
                $hoverCSS .= '#' . $carouselId . ' .impulse-pcard { transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out; height: auto !important; }';
            } else {
                $hoverCSS .= '#' . $carouselId . ' .impulse-pcard { transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out; height: 100%; }';
            }
            if (!$isSingleCardEffect) {
                $hoverCSS .= '#' . $carouselId . ' .impulse-carousel-slide { position: relative; z-index: 1; }';
                $hoverCSS .= '#' . $carouselId . ' .impulse-carousel-slide:hover { z-index: 10; }';
            }
            if ($hoverEffect === 'lift') {
                $hoverCSS .= '#' . $carouselId . ' .impulse-pcard:hover { transform: translateY(-8px) scale(1.02); box-shadow: 0 ' . $hoverShadowSize . 'px ' . ($hoverShadowSize * 2) . 'px rgba(0,0,0,0.15) !important; }';
            } elseif ($hoverEffect === 'shadow') {
                $hoverCSS .= '#' . $carouselId . ' .impulse-pcard:hover { box-shadow: 0 ' . $hoverShadowSize . 'px ' . ($hoverShadowSize * 2) . 'px rgba(0,0,0,0.2) !important; }';
            } elseif ($hoverEffect === 'border') {
                $hoverCSS .= '#' . $carouselId . ' .impulse-pcard:hover { border-color: #1d2327 !important; }';
            }
            wp_add_inline_style('instinctor-page-inline', $hoverCSS);

            $cardStyle = 'background:' . esc_attr((string)($c['background'] ?? '')) . ';border:' . intval($c['border_width']) . 'px solid ' . esc_attr((string)($c['border_color'] ?? '')) . ';border-radius:' . intval($c['border_radius']) . 'px;' . ($flipCards ? '' : 'box-shadow:' . $shadow . ';') . 'overflow:hidden;display:flex;flex-direction:column;';
            $img = $design['image'];
            $imgPadding = ($img['aspect_ratio'] ?? 'square') === 'portrait' ? '133%' : (($img['aspect_ratio'] ?? 'square') === 'landscape' ? '75%' : '100%');
            $sectionMaxWidth = intval($settings['section_max_width'] ?? 0); // 0 = no cap unless user set it
            if ($isScrollMode) {
                // Scroll mode: fixed pixel width per card from card_carousel_width setting
                $scrollCardW = max(150, min(500, intval($settings['card_carousel_width'] ?? 250)));
                $slideWidth = $scrollCardW . 'px';
                $slideMinW = 'min-width:' . $scrollCardW . 'px;max-width:' . $scrollCardW . 'px;';
            } else {
                // Snap mode (Option A): equal-width cards filling container, n = columns setting
                // CSS calc() distributes container width across n cards minus gaps
                $gapTotal = $activeGap * max(0, $activeVisibleCount - 1);
                $slideWidth = 'calc((100% - ' . $gapTotal . 'px) / ' . $activeVisibleCount . ')';
                $slideMinW = ''; // No fixed min/max — auto-width from CSS calc
            }
            $containerStyle = '';
            if ($sectionBg && $sectionBg !== 'transparent') $containerStyle .= 'background:' . $sectionBg . ';';
            if ($sectionPaddingV > 0 || $sectionPaddingH > 0) $containerStyle .= 'padding:' . $sectionPaddingV . 'px ' . $sectionPaddingH . 'px;';
            // Carousel mode: full width by default; single effect uses card_max_width for inner slide only
            if ($isSingleCardEffect && $sectionMaxWidth > 0) {
                $containerStyle .= 'max-width:' . $sectionMaxWidth . 'px;margin-left:auto;margin-right:auto;';
            }
            // no max-width on carousel: parent container/section controls width
            $wrapperPad = '';

            echo '<div id="' . esc_attr($carouselId) . '" class="impulse-carousel impulse-product-carousel"'
                . ' data-autoplay="' . ($autoplay ? 'true' : 'false') . '"'
                . ' data-speed="' . (int) $autoplaySpeed . '"'
                . ' data-pause="' . ($pauseHover ? 'true' : 'false') . '"'
                . ' data-slides-per-view="' . (int) $visibleCount . '"'
                . ' data-max-cards="' . (int) $visibleCount . '"'
                . ' data-slides-per-view-tablet="' . (int) $visibleCountTablet . '"'
                . ' data-slides-per-view-mobile="' . (int) $visibleCountMobile . '"'
                . ' data-gap="' . (int) $gap . '"'
                . ' data-gap-tablet="' . (int) $gapTablet . '"'
                . ' data-gap-mobile="' . (int) $gapMobile . '"'
                . ' data-transition="' . (int) $transitionSpeed . '"'
                . ' data-card-width="' . (int) ($isScrollMode ? max(150, min(500, intval($settings['card_carousel_width'] ?? 250))) : 0) . '"'
                . ' data-mode="' . esc_attr($carouselMode) . '"'
                . ' data-effect="' . esc_attr($isSingleCardEffect ? $carouselMode : '') . '"'
                . ' data-scroll-speed="' . (int) $scrollSpeed . '"'
                . ' style="position:relative;overflow:hidden;' . esc_attr($containerStyle) . '">';

            if ($isSingleCardEffect) {
                // Stacked absolute slides for transition effects
                $singleH = max(300, intval($settings['carousel_height'] ?? 420));
                echo '<div class="impulse-carousel-track" data-autoheight="1" style="overflow:hidden;height:' . (int) $singleH . 'px">';
                $cardMaxWidth = intval($settings['card_max_width'] ?? 250);
                $cardMaxWidth = max(250, min(500, $cardMaxWidth));
                $perspectiveStyle = ($carouselMode === 'card_flip') ? 'perspective:1000px;' : '';
                echo '<div class="impulse-carousel-slides" style="position:relative;width:100%;max-width:' . (int) $cardMaxWidth . 'px;height:100%;margin:0 auto;' . esc_attr($perspectiveStyle) . '">';
                $items = $products ?: [];
                if (empty($items)) {
                    // Placeholder sample objects for editor preview
                    $items = [];
                    $sampleNames = ['Premium Headphones','Minimalist Watch','Leather Backpack','Smart Fitness Tracker'];
                    $samplePrices = [[149.99,99.99],[299.99,0],[189.99,0],[79.99,59.99]];
                    for ($si = 0; $si < 4; $si++) {
                        $o = new stdClass();
                        $o->name = $sampleNames[$si]; $o->price = $samplePrices[$si][0]; $o->sale_price = $samplePrices[$si][1];
                        $o->sku = ''; $o->stock_quantity = null; $o->short_description = 'Sample product.'; $o->description = '';
                        $o->id = 0; $o->is_featured = 0; $o->images = null; $o->gallery_images = null; $o->featured_image = null;
                        $items[] = $o;
                    }
                }
                foreach ($items as $idx => $product) {
                    $productImages = impulse_get_product_images($product);
                    $thumb = $productImages[0] ?? ''; $thumb2 = $productImages[1] ?? ''; $thumb3 = $productImages[2] ?? '';
                    $extraThumbs = array_slice($productImages, 3);
                    $regP = floatval($product->price); $saleP = floatval($product->sale_price);
                    $hasSale = $saleP > 0 && $regP > 0 && $saleP < $regP;
                    $desc = $product->short_description ?? '';
                    $activeStyle = $idx === 0 ? 'opacity:1;z-index:2;' : 'opacity:0;z-index:0;';
                    $imgHeightPx = intval($singleH * 0.45); // ~45% of track height for image
                echo '<div class="impulse-carousel-slide impulse-pcard-effect-slide" data-index="' . (int) $idx . '" style="' . esc_attr($activeStyle) . 'will-change:transform,opacity;">';
                    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                    echo impulse_render_product_card($design, $elements, $cardStyle, $imgPadding, $product->name, $thumb, $regP, $saleP, $hasSale, $product->sku ?? '', $product->stock_quantity ?? null, $thumb2, $product->id, $desc, $thumb3, $extraThumbs, !empty($product->is_featured), $imgHeightPx);
                    echo '</div>';
                }
                echo '</div></div>';
            } else {
                // Flex row: snap (paginated) or scroll (marquee)
                echo '<div class="impulse-carousel-track" style="overflow:hidden">';
                $trackTransition = $isScrollMode ? 'none' : 'transform ' . $transitionSpeed . 'ms ease-in-out';
                echo '<div class="impulse-carousel-slides" style="display:flex;align-items:stretch;gap:' . (int) $activeGap . 'px;transition:' . esc_attr($trackTransition) . '">';
                $flipC = !$isScrollMode && (!empty($settings['flip_card_effect']) && $settings['flip_card_effect'] !== 'none');
                if ($products) {
                    foreach ($products as $product) {
                        $productImages = impulse_get_product_images($product);
                        $thumb = $productImages[0]; $thumb2 = $productImages[1] ?? ''; $thumb3 = $productImages[2] ?? '';
                        $extraThumbs = array_slice($productImages, 3);
                        $regP = floatval($product->price); $saleP = floatval($product->sale_price);
                        $hasSale = $saleP > 0 && $regP > 0 && $saleP < $regP;
                        $desc = $product->short_description ?? wp_trim_words($product->description ?? '', 20);
                        echo '<div class="impulse-carousel-slide" style="flex:0 0 ' . esc_attr($slideWidth) . ';' . esc_attr($slideMinW) . '">';
                        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                        echo impulse_render_product_card($design, $elements, $cardStyle, $imgPadding, $product->name, $thumb, $regP, $saleP, $hasSale, $product->sku ?? '', $product->stock_quantity ?? null, $thumb2, $product->id, $desc, $thumb3, $extraThumbs, !empty($product->is_featured), 0, $flipC, $settings['flip_card_effect'] ?? 'flip-h');
                        echo '</div>';
                    }
                } else {
                    $sampleProducts = [
                        ['name' => 'Premium Wireless Headphones', 'price' => 149.99, 'sale_price' => 99.99],
                        ['name' => 'Minimalist Watch', 'price' => 299.99, 'sale_price' => 0],
                        ['name' => 'Leather Backpack', 'price' => 189.99, 'sale_price' => 0],
                        ['name' => 'Smart Fitness Tracker', 'price' => 79.99, 'sale_price' => 59.99]
                    ];
                    for ($i = 0; $i < min($count, $activeVisibleCount); $i++) {
                        $sample = $sampleProducts[$i % 4]; $hS = $sample['sale_price'] > 0;
                        echo '<div class="impulse-carousel-slide" style="flex:0 0 ' . esc_attr($slideWidth) . ';' . esc_attr($slideMinW) . '">';
                        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                        echo impulse_render_product_card($design, $elements, $cardStyle, $imgPadding, $sample['name'], '', $sample['price'], $hS ? $sample['sale_price'] : 0, $hS, '', null, '', 0, 'Sample product description.', '', [], false, 0, $flipC, $settings['flip_card_effect'] ?? 'flip-h');
                        echo '</div>';
                    }
                }
                // Scroll mode: duplicate for infinite marquee
                if ($isScrollMode) {
                    if ($products) {
                        foreach ($products as $product) {
                            $productImages = impulse_get_product_images($product);
                            $th = $productImages[0]; $th2 = $productImages[1] ?? ''; $th3 = $productImages[2] ?? '';
                            $rP = floatval($product->price); $sP = floatval($product->sale_price);
                            $hS = $sP > 0 && $rP > 0 && $sP < $rP;
                            echo '<div class="impulse-carousel-slide impulse-clone" style="flex:0 0 ' . esc_attr($slideWidth) . ';' . esc_attr($slideMinW) . '">';
                            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                            echo impulse_render_product_card($design, $elements, $cardStyle, $imgPadding, $product->name, $th, $rP, $sP, $hS, $product->sku ?? '', $product->stock_quantity ?? null, $th2, $product->id, $product->short_description ?? '', $th3, [], !empty($product->is_featured));
                            echo '</div>';
                        }
                    } else {
                        $sampleProducts = [
                            ['name' => 'Premium Wireless Headphones', 'price' => 149.99, 'sale_price' => 99.99],
                            ['name' => 'Minimalist Watch', 'price' => 299.99, 'sale_price' => 0],
                            ['name' => 'Leather Backpack', 'price' => 189.99, 'sale_price' => 0],
                            ['name' => 'Smart Fitness Tracker', 'price' => 79.99, 'sale_price' => 59.99]
                        ];
                        for ($i = 0; $i < min($count, $activeVisibleCount); $i++) {
                            $sample = $sampleProducts[$i % 4]; $hS = $sample['sale_price'] > 0;
                            echo '<div class="impulse-carousel-slide impulse-clone" style="flex:0 0 ' . esc_attr($slideWidth) . ';' . esc_attr($slideMinW) . '">';
                            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                            echo impulse_render_product_card($design, $elements, $cardStyle, $imgPadding, $sample['name'], '', $sample['price'], $hS ? $sample['sale_price'] : 0, $hS, '', null, '', 0, 'Sample product description.');
                            echo '</div>';
                        }
                    }
                }
                echo '</div></div>';
            }

            // Navigation: drag-to-navigate (arrows removed)

            echo '</div>';
            break;


        case 'grid':
            // Shared renderer (see includes/element-renderers/grid.php).
            // Phase 1 step 9 of the renderer-unification refactor.
            $_gridChildren = [];
            if (!empty($el->children)) {
                foreach ($el->children as $_gridChild) { $_gridChildren[] = $_gridChild; }
            }
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_grid($el, $settings, $_gridChildren, 'editor', 'impulse_render_element');
            break;

        case 'rows':
            // Shared renderer (see includes/element-renderers/rows.php).
            // Phase 1 step 7 of the renderer-unification refactor.
            $_cgChildren = [];
            if (!empty($el->children)) {
                foreach ($el->children as $_cgChild) { $_cgChildren[] = $_cgChild; }
            }
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_rows($el, $settings, $_cgChildren, 'editor', 'impulse_render_element');
            break;

        case 'columns':
            // Shared renderer (see includes/element-renderers/columns.php).
            // Phase 1 step 8 of the renderer-unification refactor.
            $_ffChildren = [];
            if (!empty($el->children)) {
                foreach ($el->children as $_ffChild) { $_ffChildren[] = $_ffChild; }
            }
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_columns($el, $settings, $_ffChildren, 'editor', 'impulse_render_element');
            break;

        case 'demo':
            // Marketing-page block; excluded from WP build. Gracefully
            // no-op when the file isn't present.
            if (function_exists('impulse_element_render_demo')) {
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                echo impulse_element_render_demo($el, $settings, 'editor');
            }
            break;

        case 'video':
            // Shared renderer (see includes/element-renderers/video.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_video($el, $settings, 'editor');
            break;

        case 'configurator':
            // Standalone build-a-pizza configurator block (editor render). No
            // product binding — renders its own scene from settings, or an
            // editor placeholder when unconfigured. See configurator.php.
            // configurator.php is an add-on excluded from the WP build and only
            // loaded when present (see _loader.php file_exists guard). If it's
            // missing, the frontend catches the fatal per-block, but the editor
            // render has no such catch — an unguarded call to the undefined
            // function 500s the WHOLE preview. Guard it so a missing add-on
            // degrades to a placeholder instead of killing the editor.
            if (function_exists('impulse_element_render_configurator')) {
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                echo impulse_element_render_configurator($el, $settings, 'editor');
            } else {
                echo '<div style="padding:24px;text-align:center;color:#999;font:12px -apple-system,sans-serif">Configurator add-on is not installed on this site.</div>';
            }
            break;

        case 'products':
            // Check if real products exist for this block's query before
            // delegating to the shared renderer. If none exist, fall through
            // to the editor's empty-state path which renders a CTA-as-first-card
            // ("No products yet → Add products"). When products DO exist,
            // delegate to the shared renderer which uses the canonical 4-arg
            // impulse_render_product_card() signature — matches the live frontend.
            global $wpdb;
            $_pgHasProducts = false;
            $_pgSource = $settings['source'] ?? 'query';
            if ($_pgSource === 'manual' && !empty($settings['product_ids'])) {
                $_pgIds = array_values(array_filter(array_map('intval', explode(',', (string) $settings['product_ids']))));
                if ($_pgIds) {
                    $_pgPlaceholders = implode(',', array_fill(0, count($_pgIds), '%d'));
                    // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery -- prepared below; direct query on plugin table
                    $_pgCount  = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->prefix}instinctor_products WHERE status='published' AND id IN ($_pgPlaceholders)", $_pgIds)); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $_pgPlaceholders is a built %d,%d list; sniff can't see placeholders inside the variable.
                    $_pgHasProducts = $_pgCount > 0;
                }
            } else {
                $_pgCount = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}instinctor_products WHERE status='published' LIMIT 1");
                $_pgHasProducts = $_pgCount > 0;
            }
            if ($_pgHasProducts) {
                // Delegate to shared renderer (frontend's modern path).
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                echo impulse_element_render_products($el, $settings, 'editor');
                break;
            }
            // ── Empty-state path: CTA card + 3 sample products ───────────────
            $pgDisplayMode = $settings['display_mode'] ?? 'grid';
            if ($pgDisplayMode === 'carousel') {
                if (empty($settings['carousel_mode']) || in_array($settings['carousel_mode'], ['fade','card_flip'], true)) {
                    $settings['carousel_mode'] = 'snap';
                }
                $el->section_type = 'product_carousel';
                if (is_object($el->settings)) {
                    $el->settings->carousel_mode = $settings['carousel_mode'];
                } else {
                    $el->settings['carousel_mode'] = $settings['carousel_mode'];
                }
                impulse_render_element($el);
                return;
            }
            $source = $settings['source'] ?? 'query';
            $queryType = $settings['query_type'] ?? 'recent';
            $count = intval($settings['count'] ?? 8);
            $templateSlug = $settings['template'] ?? '';
            
            // Limit to 4 products when inside a slider to prevent layout breaking
            if (isset($settings['slide_index']) || !empty($GLOBALS['impulse_inside_slider'])) {
                $count = min($count, 4);
            }
            
            // Layout settings from element (responsive)
            $columns = intval($settings['columns'] ?? 4);
            $columnsTablet = intval($settings['columns_tablet'] ?? 2);
            $columnsMobile = min(2, intval($settings['columns_mobile'] ?? 1));
            $gap = intval($settings['gap'] ?? 10);
            $gapTablet = intval($settings['gap_tablet'] ?? 5);
            $gapMobile = intval($settings['gap_mobile'] ?? 5);
            
            // Use device-specific values based on current preview device
            $previewDevice = $GLOBALS['impulse_preview_device'] ?? 'desktop';
            if ($previewDevice === 'tablet') {
                $activeColumns = $columnsTablet;
                $activeGap = $gapTablet;
            } elseif ($previewDevice === 'mobile') {
                $activeColumns = $columnsMobile;
                $activeGap = $gapMobile;
            } else {
                $activeColumns = $columns;
                $activeGap = $gap;
            }
            
            
            // Section settings from element
            $sectionBg = $settings['section_bg'] ?? '';
            $sectionPaddingV = intval($settings['section_padding_v'] ?? 0);
            $sectionPaddingH = intval($settings['section_padding_h'] ?? 0);
            
            // Get products from Instinctor database
            $products_table = $wpdb->prefix . 'instinctor_products';
            $categories_table = $wpdb->prefix . 'instinctor_product_categories';
            
            $where = "status = 'published'";
            $orderBy = "id DESC";
            
            $prepArgs = array();
            if ($source === 'manual' && !empty($settings['product_ids'])) {
                $ids = array_values(array_filter(array_map('intval', explode(',', $settings['product_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);
                }
            } else {
                switch ($queryType) {
                    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 'bestseller': break; // removed
                    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;
            $products = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT p.* FROM $products_table p WHERE $where ORDER BY $orderBy LIMIT %d",
                    ...$prepArgs
                )
            );
            
            // Load design from element settings
            $design = [];
            if (!empty($settings['card_design'])) {
                $design = $settings['card_design'];
            }
            
            // Defaults (visual design only - no grid/hover)
            $defaults = [
                'elements' => ['image', 'title', 'price', 'add_to_cart', 'sale_badge', 'featured_badge'],
                'card' => [
                    'background' => '#ffffff',
                    'border_color' => '#e0e0e0',
                    'border_width' => 1,
                    'border_radius' => 8,
                    'shadow' => 'small'
                ],
                'image' => ['aspect_ratio' => 'square'],
                'title' => ['font_size' => 16, 'font_weight' => '600', 'color' => '#1d2327', 'margin_top' => 12, 'lines' => 2],
                'price' => ['font_size' => 18, 'font_weight' => '700', 'color' => '', 'sale_color' => '#dc2626', 'margin_top' => 8], // color empty = inherits title
                'add_to_cart' => ['bg_color' => '#1d2327', 'text_color' => '#ffffff', 'border_radius' => 6, 'full_width' => true, 'margin_top' => 12, 'button_text' => 'Add to Cart'],
                'sale_badge' => ['enabled' => true, 'bg_color' => '#1d2327', 'featured_bg_color' => '#1d2327', 'text_color' => '#ffffff', 'text' => 'Sale'],
                'description' => ['font_size' => 13, 'color' => '#666666', 'lines' => 2, 'margin_top' => 8],
                'sku' => ['font_size' => 12, 'color' => '#999999', 'prefix' => 'SKU:', 'margin_top' => 8],
                'stock' => ['font_size' => 13, 'in_stock_color' => '#22863a', 'out_of_stock_color' => '#dc2626', 'margin_top' => 8]
            ];
            
            // Merge with defaults
            foreach ($defaults as $key => $val) {
                if (!isset($design[$key])) {
                    $design[$key] = $val;
                } elseif (is_array($val) && $key !== 'elements') {
                    $design[$key] = array_merge($val, $design[$key]);
                }
            }
            if (empty($design['elements'])) {
                $design['elements'] = $defaults['elements'];
            }
            // Price color inherits from title color if not set separately
            if (empty($design['price']['color'])) {
                $design['price']['color'] = $design['title']['color'] ?? '#1d2327';
            }
            
            // Extract card settings
            $c = $design['card'];
            $elements = array_unique($design['elements']);
            
            // Card shadow
            $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)'];
            $shadow = $shadows[$c['shadow'] ?? 'small'] ?? 'none';
            
            $gridId = 'impulse-product-grid-' . uniqid();

            // Layout: percentage cards, always full, centered
            $maxCardWidth = 380;
            $sectionMaxWidth = intval($settings['section_max_width'] ?? 1200);
            $effectiveCols = $activeColumns;
            $productCount = count($products);

            if ($productCount >= $effectiveCols * 2) {
                $pgRows = 2;
                $products = array_slice($products, 0, $effectiveCols * 2);
            } else {
                $pgRows = 1;
                if ($productCount > $effectiveCols) {
                    $products = array_slice($products, 0, $effectiveCols);
                }
            }

            $cardW = 'calc((100% - ' . (($effectiveCols - 1) * $activeGap) . 'px) / ' . $effectiveCols . ')';
            $tW = 'calc((100% - ' . (($columnsTablet - 1) * $gapTablet) . 'px) / ' . $columnsTablet . ')';
            $mW = 'calc((100% - ' . (($columnsMobile - 1) * $gapMobile) . 'px) / ' . $columnsMobile . ')';
            // Card width as PHP variable — applied inline on each card wrapper
            // (avoids all CSS selector/specificity/head-injection failure modes)
            
            $insideSlider = isset($settings['slide_index']) || !empty($GLOBALS['impulse_inside_slider']);
            
            $gridStyle = 'display:flex;flex-wrap:wrap;justify-content:center;gap:' . $activeGap . 'px;';
            if ($sectionBg && $sectionBg !== 'transparent') {
                $gridStyle .= 'background:' . $sectionBg . ';';
            }
            echo '<div style="display:flex;justify-content:center;width:100%">';
            echo '<div style="width:100%;max-width:' . (int) $sectionMaxWidth . 'px;padding:' . (int) max(20, $sectionPaddingV) . 'px ' . (int) max(20, $sectionPaddingH) . 'px;box-sizing:border-box">';
            echo '<div id="' . esc_attr($gridId) . '" class="impulse-product-grid' . ($insideSlider ? ' impulse-grid-in-slider' : '') . '" data-cols="' . (int) $effectiveCols . '" style="' . esc_attr($gridStyle) . '" data-qv="' . esc_attr(wp_json_encode(['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'] : []])) . '">';
            
            // Card style (from design)
            $cardStyle = 'background:' . esc_attr((string)($c['background'] ?? '')) . ';border:' . intval($c['border_width']) . 'px solid ' . esc_attr((string)($c['border_color'] ?? '')) . ';border-radius:' . intval($c['border_radius']) . 'px;' . (!empty($settings['flip_card_effect']) && $settings['flip_card_effect'] !== 'none' ? '' : 'box-shadow:' . $shadow . ';') . 'overflow:hidden;display:flex;flex-direction:column;width:100%;height:100%;';
            
            // Aspect ratio
            $img = $design['image'];
            $imgPadding = ($img['aspect_ratio'] ?? 'square') === 'portrait' ? '133%' : (($img['aspect_ratio'] ?? 'square') === 'landscape' ? '75%' : '100%');
            
            if (empty($products)) {
                // Empty state — the first card becomes the "Add products"
                // CTA (so the 4-card grid layout stays intact); cards 2-N
                // are placeholder products with hardcoded sample names.
                // Click posts goto-products; parent admin switches to
                // Store > Products. The CTA-as-first-card pattern beats
                // an extra above-grid banner which would push the layout
                // off and look like a layout bug.
                $sampleProducts = [
                    ['name' => 'Premium Wireless Headphones', 'price' => 149.99, 'sale_price' => 99.99],
                    ['name' => 'Minimalist Watch', 'price' => 299.99, 'sale_price' => 0],
                    ['name' => 'Leather Backpack', 'price' => 189.99, 'sale_price' => 0],
                    ['name' => 'Smart Fitness Tracker', 'price' => 79.99, 'sale_price' => 59.99]
                ];
                for ($i = 0; $i < min($count, 4); $i++) {
                    if ($i === 0) {
                        // CTA card — same outer dimensions as a product card
                        // so the grid doesn't shift. Dashed border + centred
                        // content + button.
                        echo '<div style="flex:0 0 ' . esc_attr($cardW) . ';max-width:' . esc_attr($cardW) . ';box-sizing:border-box;display:flex;align-items:stretch">';
                        echo '<div class="impulse-products-empty-card" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:24px;background:rgba(29,35,39,0.04);border:1px dashed #c9c9c9;border-radius:' . intval($c['border_radius']) . 'px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;text-align:center;min-height:280px">';
                        echo '<span style="font-size:13px;color:#555;letter-spacing:0.2px">No products yet.</span>';
                        echo '<button type="button" class="impulse-goto-products-btn" style="height:26px;padding:0 18px;font-size:11px;font-weight:600;background:#1d2327;color:#fff;border:0;border-radius:4px;cursor:pointer;letter-spacing:0.3px;white-space:nowrap">Add products &rarr;</button>';
                        echo '</div>';
                        echo '</div>';
                        continue;
                    }
                    $sample = $sampleProducts[$i % 4];
                    $hasSale = $sample['sale_price'] > 0;
                    echo '<div style="flex:0 0 ' . esc_attr($cardW) . ';max-width:' . esc_attr($cardW) . ';box-sizing:border-box;display:flex;align-items:stretch">';
                    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                    echo impulse_render_product_card($design, $elements, $cardStyle, $imgPadding, $sample['name'], '', $sample['price'], $hasSale ? $sample['sale_price'] : 0, $hasSale, '', null, '', 0, 'Sample product description for preview purposes.', '', [], false, 0, (!empty($settings['flip_card_effect']) && $settings['flip_card_effect'] !== 'none'), $settings['flip_card_effect'] ?? 'flip-h');
                    echo '</div>';
                }
            } else {
                foreach ($products as $product) {
                    $productImages = impulse_get_product_images($product);
                    $thumb = $productImages[0];
                    $thumb2 = $productImages[1];
                    $thumb3 = $productImages[2];
                    $extraThumbs = array_slice($productImages, 3);
                    // Only consider it a "sale" if BOTH prices exist and sale < regular
                    $regP = floatval($product->price);
                    $saleP = floatval($product->sale_price);
                    $hasSale = $saleP > 0 && $regP > 0 && $saleP < $regP;
                    $desc = $product->short_description ?? wp_trim_words($product->description ?? '', 20);
                    echo '<div style="flex:0 0 ' . esc_attr($cardW) . ';max-width:' . esc_attr($cardW) . ';box-sizing:border-box;display:flex;align-items:stretch">';
                    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
                    echo impulse_render_product_card($design, $elements, $cardStyle, $imgPadding, $product->name, $thumb, $regP, $saleP, $hasSale, $product->sku ?? '', $product->stock_quantity ?? null, $thumb2, $product->id, $desc, $thumb3, $extraThumbs, !empty($product->is_featured), 0, (!empty($settings['flip_card_effect']) && $settings['flip_card_effect'] !== 'none'), $settings['flip_card_effect'] ?? 'flip-h');
                    echo '</div>';
                }
            }
            echo '</div>'; // grid
            echo '</div>'; // inner wrapper (max-width)
            echo '</div>'; // outer flex centering wrapper
            if ($pgRows >= 2) {
                wp_add_inline_script('instinctor-frontend-js', '(function(){var g=document.getElementById("' . $gridId . '");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');
            }
            break;
            
        case 'mini_cart':
            $title = $settings['title'] ?? 'Your Cart';
            $emptyText = $settings['empty_text'] ?? 'Your cart is empty';
            $buttonText = $settings['button_text'] ?? 'Checkout';
            $bgColor = $settings['bg_color'] ?? '#ffffff';
            $textColor = $settings['text_color'] ?? '#1d2327';
            $width = impulse_get_responsive($settings, 'width', 320);
            $borderRadius = impulse_get_responsive($settings, 'border_radius', 8);
            $showSubtotal = $settings['show_subtotal'] ?? true;
            $showRemove = $settings['show_remove'] ?? true;
            $hideWhenEmpty = $settings['hide_when_empty'] ?? true;
            $align = impulse_get_responsive($settings, 'align', 'center');
            $buttonBg = $settings['button_bg'] ?? '#1d2327';
            $buttonColor = $settings['button_color'] ?? '#ffffff';
            $buttonSize = impulse_get_responsive($settings, 'button_size', 14);
            $imageSize = $settings['image_size'] ?? 60;
            
            $justifyMap = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'];
            $wrapStyle = 'display:flex;justify-content:' . ($justifyMap[$align] ?? 'center') . ';';
            
            // Get actual cart data
            global $wpdb;
            $miniCartItems = [];
            $miniCartTotal = 0;
            $cart = \Instinctor\Core\CartSession::get('cart') ?? ['items' => []];
            
            // Fetch product details for cart items
            if (!empty($cart['items'])) {
                $products_table = $wpdb->prefix . 'instinctor_products';
                foreach ($cart['items'] as $item) {
                    $productId = intval($item['product_id'] ?? 0);
                    $qty = intval($item['quantity'] ?? 1);
                    if ($productId > 0) {
                        $product = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$wpdb->prefix}instinctor_products WHERE id = %d", $productId));
                        if ($product) {
                            $price = floatval($product->sale_price > 0 && $product->sale_price < $product->price ? $product->sale_price : $product->price);
                            $miniCartItems[] = [
                                'id' => $productId,
                                'name' => $product->name,
                                'price' => $price,
                                'quantity' => $qty,
                                'image' => impulse_get_product_images($product)[0] ?? ''
                            ];
                            $miniCartTotal += $price * $qty;
                        }
                    }
                }
            }
            
            // Hide on frontend if cart is empty and hide_when_empty is enabled
            $isEditMode = !empty($GLOBALS['impulse_is_edit_mode']);
            if (empty($miniCartItems) && $hideWhenEmpty && !$isEditMode) {
                break; // Don't render anything
            }
            
            echo '<div class="impulse-mini-cart-wrap" style="' . esc_attr($wrapStyle) . '">';
            echo '<div class="impulse-mini-cart" style="width:' . intval($width) . 'px;background:' . esc_attr($bgColor) . ';color:' . esc_attr($textColor) . ';border-radius:' . (int) $borderRadius . 'px;box-shadow:0 4px 20px rgba(0,0,0,0.15);padding:20px">';
            echo '<div style="font-weight:600;font-size:16px;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid #eee">' . esc_html($title) . '</div>';
            
            if (empty($miniCartItems)) {
                // Show placeholder item in editor
                echo '<div style="display:flex;gap:12px;padding:12px 0;border-bottom:1px solid #eee">';
                echo '<div style="width:' . intval($imageSize) . 'px;height:' . intval($imageSize) . 'px;background:#f5f5f5;border-radius:4px;flex-shrink:0"></div>';
                echo '<div style="flex:1;min-width:0;display:flex;flex-direction:column;justify-content:space-between;height:' . intval($imageSize) . 'px">';
                echo '<div style="font-weight:500;font-size:14px">Product Name</div>';
                echo '<div style="font-size:12px;color:#666">Qty: 1</div>';
                echo '<div style="font-size:14px;font-weight:600">$29.99</div>';
                echo '</div>';
                if ($showRemove) echo '<span class="impulse-mini-cart-remove" style="color:#999;cursor:pointer;padding:4px;font-size:18px;line-height:1;align-self:flex-start">×</span>';
                echo '</div>';
                $displayTotal = '$29.99';
            } else {
                // Render actual cart items
                foreach ($miniCartItems as $item) {
                    echo '<div style="display:flex;gap:12px;padding:12px 0;border-bottom:1px solid #eee">';
                    if (!empty($item['image'])) {
                        echo '<img src="' . esc_url((string)($item['image'] ?? '')) . '" style="width:' . intval($imageSize) . 'px;height:' . intval($imageSize) . 'px;object-fit:cover;border-radius:4px;flex-shrink:0">';
                    } else {
                        echo '<div style="width:' . intval($imageSize) . 'px;height:' . intval($imageSize) . 'px;background:#f5f5f5;border-radius:4px;flex-shrink:0"></div>';
                    }
                    echo '<div style="flex:1;min-width:0;display:flex;flex-direction:column;justify-content:space-between;height:' . intval($imageSize) . 'px">';
                    echo '<div style="font-weight:500;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' . esc_html((string)($item['name'] ?? '')) . '</div>';
                    echo '<div style="font-size:12px;color:#666">Qty: ' . intval($item['quantity']) . '</div>';
                    echo '<div style="font-size:14px;font-weight:600">$' . esc_attr(number_format($item['price'], 2)) . '</div>';
                    echo '</div>';
                    if ($showRemove) echo '<span class="impulse-mini-cart-remove" data-product-id="' . intval($item['id']) . '" style="color:#999;cursor:pointer;padding:4px;font-size:18px;line-height:1;align-self:flex-start">×</span>';
                    echo '</div>';
                }
                $displayTotal = '$' . number_format($miniCartTotal, 2);
            }
            
            if ($showSubtotal) {
                echo '<div style="display:flex;justify-content:space-between;padding:16px 0;font-weight:600">';
                echo '<span>Subtotal</span><span>' . esc_html($displayTotal) . '</span>';
                echo '</div>';
            }
            echo '<button class="impulse-mini-cart-btn" style="width:100%;padding:12px;background:' . esc_attr($buttonBg) . ';color:' . esc_attr($buttonColor) . ';border:none;border-radius:6px;font-weight:600;font-size:' . intval($buttonSize) . 'px;cursor:pointer">' . esc_html($buttonText) . '</button>';
            echo '</div>';
            echo '</div>';
            break;
            
        case 'cart':
            $device = $GLOBALS['impulse_preview_device'] ?? 'desktop';
            $title = $settings['title'] ?? 'Your Order';
            $emptyText = $settings['empty_text'] ?? 'Your cart is empty';
            // Checkboxes — device-responsive, default unchecked on mobile
            $cartChk = function($key) use ($settings, $device) {
                if ($device === 'mobile') {
                    if (isset($settings[$key . '_mobile'])) return (bool)$settings[$key . '_mobile'];
                    return false;
                }
                if ($device === 'tablet' && isset($settings[$key . '_tablet'])) {
                    return (bool)$settings[$key . '_tablet'];
                }
                return ($settings[$key] ?? true) !== false;
            };
            $showImage = $cartChk('show_image');
            $showQuantity = $cartChk('show_quantity');
            $showSubtotal = $cartChk('show_subtotal');
            $showHeader = $cartChk('show_header');
            $showTable = $cartChk('show_table');
            $bgColor = $settings['bg_color'] ?? '#ffffff';
            
            // Auto-contrast: derive all text/border colors from background
            $autoText = impulse_contrast_color($bgColor);
            $isLight = ($autoText === '#111827'); // light bg → dark text
            $autoTextSub = $isLight ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.45)';
            $autoTextMid = $isLight ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.6)';
            $autoBorder = $isLight ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.12)';
            $autoBorderStrong = $isLight ? 'rgba(0,0,0,0.15)' : 'rgba(255,255,255,0.15)';
            $autoPlaceholder = $isLight ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)';
            
            $borderRadius = impulse_get_responsive($settings, 'border_radius', 4);
            $maxWidth = intval(impulse_get_responsive($settings, 'max_width', 100));
            // Legacy: old px values > 100 → full width
            if ($maxWidth > 100) $maxWidth = 100;
            
            // Build style
            $containerStyle = 'background:' . $bgColor . ';';
            $containerStyle .= 'border-radius:' . $borderRadius . 'px;';
            $containerStyle .= 'padding:20px;';
            $containerStyle .= 'width:' . $maxWidth . '%;margin:0 auto;';
            $containerStyle .= 'color:' . $autoText . ';';
            $containerStyle .= 'box-sizing:border-box;overflow:hidden;';
            
            // Get actual cart data
            global $wpdb;
            $cartItems = [];
            $cart = \Instinctor\Core\CartSession::get('cart') ?? ['items' => []];
            
            // Fetch product details for cart items
            if (!empty($cart['items'])) {
                $products_table = $wpdb->prefix . 'instinctor_products';
                foreach ($cart['items'] as $item) {
                    $productId = intval($item['product_id'] ?? 0);
                    if ($productId > 0) {
                        $product = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$wpdb->prefix}instinctor_products WHERE id = %d", $productId));
                        if ($product) {
                            $price = floatval($product->sale_price > 0 && $product->sale_price < $product->price ? $product->sale_price : $product->price);
                            $cartItems[] = [
                                'name' => $product->name,
                                'price' => $price,
                                'quantity' => intval($item['quantity'] ?? 1),
                                'image' => impulse_get_product_images($product)[0] ?? '',
                                'subtotal' => $price * intval($item['quantity'] ?? 1)
                            ];
                        }
                    }
                }
            }
            
            // Use placeholder if cart is empty
            if (empty($cartItems)) {
                $cartItems = [
                    ['name' => 'Product Name', 'price' => 29.99, 'quantity' => 1, 'image' => '', 'subtotal' => 29.99]
                ];
            }
            
            echo '<div class="impulse-cart-block" style="' . esc_attr($containerStyle) . '">';
            
            // Header (Shopping Cart title) — hardcoded 24px
            if ($showHeader) {
                echo '<h2 class="impulse-cart-title" style="margin:0 0 24px;font-size:24px;color:' . esc_attr($autoText) . '">' . esc_html($title) . '</h2>';
            }
            
            // Table header (Product, Quantity, Subtotal labels)
            if ($showTable) {
                echo '<div class="impulse-cart-table-header" style="display:grid;grid-template-columns:' . ($showImage ? '80px ' : '') . '1fr' . ($showQuantity ? ' 100px' : '') . ($showSubtotal ? ' 80px' : '') . ';gap:16px;padding-bottom:12px;border-bottom:2px solid ' . esc_attr($autoBorderStrong) . ';font-weight:600;font-size:14px;color:' . esc_attr($autoTextMid) . '">';
                if ($showImage) echo '<span></span>';
                echo '<span>Product</span>';
                if ($showQuantity) echo '<span style="text-align:center">Quantity</span>';
                if ($showSubtotal) echo '<span style="text-align:right">Subtotal</span>';
                echo '</div>';
            }
            
            // Cart items
            foreach ($cartItems as $item) {
                echo '<div class="impulse-cart-item" style="display:grid;grid-template-columns:' . ($showImage ? '80px ' : '') . '1fr' . ($showQuantity ? ' 100px' : '') . ($showSubtotal ? ' 80px' : '') . ';gap:16px;padding:16px 0;align-items:center">';
                
                // Image
                if ($showImage) {
                    if ($item['image']) {
                        echo '<div style="width:80px;height:80px;border-radius:4px;overflow:hidden"><img src="' . esc_url((string)($item['image'] ?? '')) . '" style="width:100%;height:100%;object-fit:cover"></div>';
                    } else {
                        echo '<div style="width:80px;height:80px;background:' . esc_attr($autoPlaceholder) . ';border-radius:4px"></div>';
                    }
                }
                
                // Product name and price
                echo '<div><div style="font-weight:500;color:' . esc_attr($autoText) . '">' . esc_html((string)($item['name'] ?? '')) . '</div><div style="font-size:14px;color:' . esc_attr($autoTextSub) . ';margin-top:4px">$' . esc_attr(number_format($item['price'], 2)) . '</div></div>';
                
                // Quantity - simple text arrows, inherit text color
                if ($showQuantity) {
                    echo '<div style="display:flex;justify-content:center;align-items:center;gap:2px;color:' . esc_attr($autoText) . '"><span style="font-size:24px;padding:2px 6px;line-height:1;opacity:0.35;user-select:none">&#9666;</span><span style="min-width:30px;max-width:40px;text-align:center;font-size:15px;font-weight:500">' . intval($item['quantity']) . '</span><span style="font-size:24px;padding:2px 6px;line-height:1;opacity:0.35;user-select:none">&#9656;</span></div>';
                }
                
                // Subtotal
                if ($showSubtotal) {
                    echo '<div style="text-align:right;font-weight:600;color:' . esc_attr($autoText) . '">$' . esc_attr(number_format($item['subtotal'], 2)) . '</div>';
                }
                
                echo '</div>';
            }
            
            // Footer: one flex row — button left, total right
            $cartTotal = array_sum(array_column($cartItems, 'subtotal'));
            $chkBtnText = $settings['checkout_btn_text'] ?? 'Checkout';
            $chkBtnBg   = $settings['checkout_btn_bg']   ?? '#1d2327';
            $chkBtnAutoColor = impulse_contrast_color($chkBtnBg);
            echo '<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 0 0;border-top:2px solid ' . esc_attr($autoBorderStrong) . ';margin-top:4px">';
            echo '<a href="#" style="display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 20px;background:' . esc_attr($chkBtnBg) . ';color:' . esc_attr($chkBtnAutoColor) . ';border-radius:' . (int) $borderRadius . 'px;font-size:14px;font-weight:600;text-decoration:none;cursor:default" onclick="event.preventDefault()">' . esc_html($chkBtnText) . '</a>';
            echo '<div style="text-align:right"><span style="font-size:13px;color:' . esc_attr($autoTextMid) . ';margin-right:8px">Total</span><span style="font-size:16px;font-weight:600;color:' . esc_attr($autoText) . '">$' . esc_attr(number_format($cartTotal, 2)) . '</span></div>';
            echo '</div>';
            
            echo '</div>';
            break;
            
        case 'checkout':
            $device = $GLOBALS['impulse_preview_device'] ?? 'desktop';
            $isScreenMode = true;
            $buttonBg = $settings['checkout_design_button'] ?? '#1d2327';
            $buttonText = $settings['checkout_design_button_text'] ?? '#ffffff';
            $formBg = $settings['checkout_design_form_bg'] ?? 'transparent';
            // Auto-contrast: derive text color from effective background
            $ckEffectiveBg = ($formBg !== 'transparent' && $formBg !== '') ? $formBg : ($GLOBALS['impulse_parent_bg'] ?? ($GLOBALS['impulse_page_bg_color'] ?? '#ffffff'));
            $textColor = impulse_contrast_color($ckEffectiveBg);
            $fieldBg = $settings['checkout_design_field_bg'] ?? ($isScreenMode ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.05)');
            // Field text: contrast against field bg (handle rgba by falling back)
            $ckFieldAutoText = (strpos($fieldBg, 'rgba') !== false) ? $textColor : impulse_contrast_color($fieldBg);
            $fieldBorder = $settings['checkout_design_field_border'] ?? '#d2d2d7';
            $fieldBorderWidth = impulse_device_val($settings, 'checkout_design_field_border_width', $device, 1);
            $fieldRadius = impulse_device_val($settings, 'checkout_design_field_radius', $device, 4);
            $buttonRadius = impulse_device_val($settings, 'checkout_design_button_radius', $device, 4);
            // Screen mode: no borders
            if ($isScreenMode) { $fieldBorderWidth = 0; }
            
            $fieldsJson = $settings['checkout_design_fields'] ?? '';
            if (is_string($fieldsJson) && !empty($fieldsJson)) {
                $fields = json_decode($fieldsJson, true);
            }
            if (empty($fields) || !is_array($fields)) {
                $fields = [
                    ['id' => 'email', 'label' => 'Email', 'enabled' => true, 'required' => true, 'width' => 'half'],
                    ['id' => 'phone', 'label' => 'Phone', 'enabled' => true, 'required' => false, 'width' => 'half'],
                    ['id' => 'first_name', 'label' => 'First Name', 'enabled' => true, 'required' => true, 'width' => 'half'],
                    ['id' => 'last_name', 'label' => 'Last Name', 'enabled' => true, 'required' => true, 'width' => 'half'],
                    ['id' => 'address_1', 'label' => 'Address', 'enabled' => true, 'required' => true, 'width' => 'full'],
                    ['id' => 'city', 'label' => 'City', 'enabled' => true, 'required' => true, 'width' => 'half'],
                    ['id' => 'state', 'label' => 'State', 'enabled' => true, 'required' => true, 'width' => 'half'],
                    ['id' => 'postcode', 'label' => 'ZIP Code', 'enabled' => true, 'required' => true, 'width' => 'half'],
                    ['id' => 'country', 'label' => 'Country', 'enabled' => true, 'required' => true, 'width' => 'half']
                ];
            }
            
            $sectionMaxWidth = intval($settings['section_max_width'] ?? 400);
            
            // Section header — plain text, no background
            $sectionStyle = 'font-size:12px;font-weight:600;margin:0 0 10px;text-transform:uppercase;letter-spacing:0.5px;opacity:0.6;';
            
            // Centered container
            echo '<div class="impulse-checkout-block" style="';
            echo 'width:100%;margin:0 auto;';
            if ($sectionMaxWidth > 0) {
                echo 'max-width:' . (int) $sectionMaxWidth . 'px;';
            }
            echo 'background-color:' . esc_attr($formBg) . ';';
            echo 'color:' . esc_attr($textColor) . ';';
            echo 'padding:24px">';
            
            $enabledFields = array_filter($fields, fn($f) => !empty($f['enabled']));
            
            $checkoutBtnText = $settings['checkout_button_text'] ?? 'Pay now';
            $inputBaseStyle = 'width:100%;box-sizing:border-box;background-color:' . esc_attr($fieldBg) . ';border-width:' . intval($fieldBorderWidth) . 'px;border-style:solid;border-color:' . esc_attr($fieldBorder) . ';border-radius:' . intval($fieldRadius) . 'px;padding:10px 12px;font-size:13px;color:' . esc_attr($ckFieldAutoText) . ';outline:none;font-family:inherit;';
            $renderFieldGroup = function($fieldsArr) use ($inputBaseStyle) {
                echo '<div class="impulse-checkout-fields-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:8px">';
                $fieldsArr = array_values($fieldsArr);
                foreach ($fieldsArr as $f) {
                    $width = $f['width'] ?? 'full';
                    $widthTablet = $f['width_tablet'] ?? $width;
                    $widthMobile = $f['width_mobile'] ?? 'full';
                    $classes = 'impulse-checkout-field impulse-field-' . $width;
                    $classes .= ' impulse-field-tablet-' . $widthTablet;
                    $classes .= ' impulse-field-mobile-' . $widthMobile;
                    $placeholder = $f['label'] . (($f['required'] ?? false) ? ' *' : '');
                    echo '<div class="' . esc_attr($classes) . '">';
                    echo '<input type="text" placeholder="' . esc_attr($placeholder) . '" style="' . esc_attr($inputBaseStyle) . '" tabindex="-1" autocomplete="off">';
                    echo '</div>';
                }
                echo '</div>';
            };
            
            // Shipping fields
            if (!empty($enabledFields)) {
                echo '<div style="margin-bottom:16px"><div style="' . esc_attr($sectionStyle) . '">Shipping Address</div>';
                $renderFieldGroup($enabledFields);
                echo '</div>';
            }
            
            // Payment — card 1/2, expiry 1/4, cvc 1/4
            $cardStyle = $inputBaseStyle . 'opacity:0.55;';
            echo '<div style="margin-bottom:16px"><div style="' . esc_attr($sectionStyle) . '">Payment</div>';
            echo '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">';
            echo '<input type="text" placeholder="Card number" style="' . esc_attr($cardStyle) . '" tabindex="-1" autocomplete="off">';
            echo '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">';
            echo '<input type="text" placeholder="Expiry" style="' . esc_attr($cardStyle) . '" tabindex="-1" autocomplete="off">';
            echo '<input type="text" placeholder="CVC" style="' . esc_attr($cardStyle) . '" tabindex="-1" autocomplete="off">';
            echo '</div></div></div>';
            
            // Pay button
            echo '<button class="impulse-checkout-btn" style="';
            echo 'width:100%;padding:12px 20px;margin-top:12px;';
            echo 'background-color:' . esc_attr($buttonBg) . ';';
            echo 'color:' . esc_attr($buttonText) . ';';
            echo 'border:none;';
            echo 'border-radius:' . intval($buttonRadius) . 'px;';
            echo 'font-weight:600;font-size:14px;cursor:pointer">' . esc_html($checkoutBtnText) . '</button>';
            
            echo '</div>';
            break;
        // FORM ELEMENTS
        case 'form':
            // Unified lead-capture block. Routes by form_type to the same renderer
            // the standalone types use, so the canvas matches the published page.
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction
            echo impulse_element_render_form($el, $settings, 'editor');
            break;
        case 'contact':
            // Shared renderer (see includes/element-renderers/contact.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_contact($el, $settings, 'editor');
            break;

        case 'booking':
            $bkTitle      = $settings['title'] ?? 'Book a Service';
            $bkBtnText    = esc_html($settings['button_text'] ?? 'Book Now');
            $bkBtnBg      = esc_attr($settings['button_bg'] ?? '#1d2327');
            $bkBtnColor   = esc_attr($settings['button_color'] ?? '#ffffff');
            $bkFieldBg    = esc_attr($settings['field_bg'] ?? '#ffffff');
            $bkFieldBd    = esc_attr($settings['field_border'] ?? '#e0e0e0');
            $bkFieldR     = intval($settings['field_radius'] ?? 8);
            $bkBtnR       = intval($settings['button_radius'] ?? 8);
            $bkMaxW       = intval($settings['max_width'] ?? 560);
            $bkTitleSize  = intval($settings['title_size'] ?? 22);
            $bkCardBg     = trim((string)($settings['card_bg'] ?? ''));
            // Fetch all active services
            global $wpdb;
            // Scoped to the PAGE's owner, like the frontend renderer. This query was
            // unscoped, so the canvas dropdown showed every tenant's services at once:
            // the instructor template listed nine salons' worth of manicures, with
            // duplicates for every tenant selling the same thing (Sergei 2026-07-29).
            $bkPageOwner = null;
            if (!empty($GLOBALS['impulse_preview_page_id']) && class_exists('\\Instinctor\\Services\\PageService')) {
                $bkPageRow   = \Instinctor\Services\PageService::getById((int) $GLOBALS['impulse_preview_page_id']);
                $bkPageOwner = isset($bkPageRow['owner_id']) && $bkPageRow['owner_id'] !== null && $bkPageRow['owner_id'] !== ''
                    ? (int) $bkPageRow['owner_id'] : null;
            }
            $bkScope    = class_exists('\\Instinctor\\Core\\Tenant')
                ? \Instinctor\Core\Tenant::scopeSqlFor($bkPageOwner, 'owner_id') : '1=1';
            $bkServices = $wpdb->get_results("SELECT id,name,duration,price,color,capacity FROM {$wpdb->prefix}instinctor_services WHERE status='active' AND {$bkScope} ORDER BY sort_order ASC, name ASC", ARRAY_A) ?: [];
            // A vendor template page has no service rows of its own: fall back to the
            // element's seeded demo_services so the canvas matches what publishes.
            if (empty($bkServices) && !empty($settings['demo_services'])) {
                $bkDemoSvcs = is_string($settings['demo_services'])
                    ? json_decode($settings['demo_services'], true) : $settings['demo_services'];
                if (is_array($bkDemoSvcs)) $bkServices = $bkDemoSvcs;
            }

            $bkAjax   = (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone')
                ? esc_url(rtrim(defined('INSTINCTOR_STANDALONE_URL') ? INSTINCTOR_STANDALONE_URL : '', '/') . '/admin/admin-ajax.php')
                : esc_url(admin_url('admin-ajax.php'));
            $bkRestBase = \Instinctor\Core\Router::restBase();

            $bkFStyle = 'width:100%;padding:10px 14px;background:' . $bkFieldBg . ';border:1px solid ' . $bkFieldBd . ';border-radius:' . $bkFieldR . 'px;font-size:14px;color:#1d2327;outline:none;box-sizing:border-box;font-family:inherit';
            $bkBtnStyle = 'width:100%;padding:12px;background:' . $bkBtnBg . ';color:' . $bkBtnColor . ';border:none;border-radius:' . $bkBtnR . 'px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit';

            $bkId = 'ibk-' . $id;

            $visBadge = impulse_visibility_badge($settings);
            if ($visBadge) echo '<div style="position:relative">' . wp_kses($visBadge, impulse_svg_allowed());

            // No-services fallback — guides builder to set up services first
            if (empty($bkServices)) {
                $servicesUrl = (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone')
                    ? '/instinctor/store?section=bookings'
                    : esc_url(admin_url('admin.php?page=instinctor#store/bookings'));
                echo '<div style="max-width:' . (int) $bkMaxW . 'px;margin:0 auto;padding:32px 24px;box-sizing:border-box;border:1px dashed #3a3f44;border-radius:8px;background:#1d2327;text-align:center">';
                echo '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#555" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:12px"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg>';
                echo '<div style="font-size:13px;font-weight:600;color:#e0e0e0;margin-bottom:6px">No services found</div>';
                echo '<div style="font-size:12px;color:#888;margin-bottom:16px">Add at least one active service in Store &rsaquo; Bookings &rsaquo; Services before this block will work.</div>';
                echo '<button type="button" class="impulse-goto-add-service" style="display:inline-block;padding:7px 16px;background:#2a2e33;border:1px solid #3a3f44;border-radius:4px;font-size:12px;color:#e0e0e0;cursor:pointer;pointer-events:auto;position:relative;z-index:10">Add Service &rsaquo;</button>';
                echo '</div>';
                if ($visBadge) echo '</div>';
                break;
            }

            // Scoped styles for this block instance (hover fix, etc.)
            ob_start();
            echo '#' . esc_js($bkId) . ' .ibk-cal-grid button:not([disabled]):not(.ibk-selected-day):hover { background:#f0f0f0 !important; border-color:#ddd !important; }';
            echo '#' . esc_js($bkId) . ' .ibk-slots-grid button:not([disabled]):not(.ibk-selected-slot):hover { background:#f0f0f0 !important; border-color:#ddd !important; }';
            $_inst_pcss19 = ob_get_clean(); wp_add_inline_style("instinctor-page-inline", $_inst_pcss19);
            $bkCardStyle = 'max-width:' . $bkMaxW . 'px;margin:0 auto;padding:24px;box-sizing:border-box';
            if ($bkCardBg !== '') {
                $bkCardStyle .= ';background:' . $bkCardBg . ';color:#1d2327;border-radius:14px;box-shadow:0 10px 40px rgba(0,0,0,0.45)';
            }
            echo '<div id="' . esc_attr($bkId) . '" class="impulse-booking-block" style="' . esc_attr($bkCardStyle) . '">';
            echo '<h2 style="margin:0 0 20px;font-size:' . (int) $bkTitleSize . 'px;color:#1d2327">' . esc_html($bkTitle) . '</h2>';
            echo '<div class="ibk-toast" style="display:none;background:#1d2327;color:#fff;font-size:13px;padding:10px 14px;border-radius:6px;margin-bottom:12px"></div>';

            // Step 1: service + date/time picker
            echo '<div class="ibk-step ibk-step1" data-step="1">';
            echo '<div style="display:flex;flex-direction:column;gap:12px">';

            // Service selector — mirrors the frontend rule: with one service, or with
            // the picker switched off in the panel, the form books the first service
            // and shows its name as a static line instead of a dropdown.
            $bkShowPicker = !isset($settings['show_service_select']) || (string) $settings['show_service_select'] === '1';
            if (!empty($bkServices) && (count($bkServices) === 1 || !$bkShowPicker)) {
                echo '<div style="' . esc_attr($bkFStyle) . ';display:flex;align-items:center;opacity:.9">'
                    . esc_html((string) ($bkServices[0]['name'] ?? '')) . '</div>';
            } else {
            echo '<select class="ibk-service" style="' . esc_attr($bkFStyle) . '"><option value="">Select service…</option>';
            foreach ($bkServices as $sv) {
                echo '<option value="' . intval($sv['id']) . '" data-duration="' . intval($sv['duration']) . '" data-price="' . esc_attr((string)($sv['price'] ?? '')) . '">' . esc_html((string)($sv['name'] ?? '')) . ($sv['price'] > 0 ? ' — ' . esc_html(get_option('instinctor_currency_symbol','$')) . esc_attr(number_format((float)$sv['price'],2)) : '') . '</option>';
            }
            echo '</select>';
            }

            // Calendar — month navigation
            echo '<div class="ibk-calendar-wrap">';
            echo '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">';
            echo '<button type="button" class="ibk-prev-month" style="background:none;border:none;cursor:pointer;padding:4px 8px;font-size:18px;color:#888">&#8249;</button>';
            echo '<span class="ibk-month-label" style="font-size:14px;font-weight:600;color:#1d2327"></span>';
            echo '<button type="button" class="ibk-next-month" style="background:none;border:none;cursor:pointer;padding:4px 8px;font-size:18px;color:#888">&#8250;</button>';
            echo '</div>';
            echo '<div class="ibk-cal-grid" style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;text-align:center"></div>';
            echo '</div>';

            // Time slot grid (hidden until date chosen)
            echo '<div class="ibk-slots-wrap" style="display:none">';
            echo '<div style="font-size:13px;color:#888;margin-bottom:6px">Available times</div>';
            echo '<div class="ibk-slots-grid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px"></div>';
            echo '</div>';

            echo '<button type="button" class="ibk-next-btn" style="' . esc_attr($bkBtnStyle) . ';opacity:0.45">Next &rsaquo;</button>';
            echo '</div></div>'; // gap div + step1

            // Step 2: customer details
            echo '<div class="ibk-step ibk-step2" data-step="2" style="display:none">';
            echo '<div style="display:flex;flex-direction:column;gap:12px">';
            echo '<div style="font-size:13px;color:#888;margin-bottom:-4px" class="ibk-booking-summary"></div>';
            echo '<input type="text" class="ibk-name" placeholder="Your name *" style="' . esc_attr($bkFStyle) . '">';
            echo '<input type="email" class="ibk-email" placeholder="Email *" style="' . esc_attr($bkFStyle) . '">';
            echo '<input type="tel" class="ibk-phone" placeholder="Phone" style="' . esc_attr($bkFStyle) . '">';
            echo '<textarea class="ibk-note" placeholder="Note (optional)" rows="3" style="' . esc_attr($bkFStyle) . ';resize:vertical"></textarea>';
            if (!empty($settings['allow_repeat']) && (string) $settings['allow_repeat'] === '1') {
                echo '<select class="ibk-repeat" aria-label="Repeat this appointment" style="' . esc_attr($bkFStyle) . '">';
                echo '<option value="once" selected>One time only</option>';
                echo '<option value="week:1">Every week</option>';
                echo '<option value="week:2">Every 2 weeks</option>';
                echo '<option value="month:1">Every month</option>';
                echo '</select>';
            }
            echo '<div style="display:flex;gap:10px">';
            echo '<button type="button" class="ibk-back-btn" style="flex:1;padding:12px;background:transparent;color:#888;border:1px solid #e0e0e0;border-radius:' . (int) $bkBtnR . 'px;font-size:14px;cursor:pointer;font-family:inherit">Back</button>';
            echo '<button type="button" class="ibk-submit-btn" style="flex:3;' . esc_attr($bkBtnStyle) . '">' . esc_html($settings['button_text'] ?? 'Book Now') . '</button>';
            echo '</div>';
            echo '</div></div>'; // gap div + step2

            // Step 3: confirmation
            echo '<div class="ibk-step ibk-step3" data-step="3" style="display:none">';
            echo '<div style="text-align:center;padding:24px 0">';
            echo '<div style="font-size:40px;margin-bottom:12px">✓</div>';
            echo '<div class="ibk-confirm-msg" style="font-size:16px;color:#1d2327;font-weight:600"></div>';
            echo '<div class="ibk-confirm-detail" style="font-size:13px;color:#888;margin-top:6px"></div>';
            echo '</div></div>';

            echo '</div>'; // impulse-booking-block

            if ($visBadge) echo '</div>';

            // Inline JS for this block instance
            ob_start();
            echo '(function(){';
            echo 'var _wrap = document.getElementById(' . wp_json_encode($bkId) . ');';
            echo 'if(!_wrap) return;';
            echo 'var _svcs = ' . wp_json_encode($bkServices) . ';';
            echo 'var _ajax = ' . wp_json_encode($bkAjax) . ';';
            echo 'var _nonce = ' . wp_json_encode(\Instinctor\Core\Session::token('instinctor_frontend')) . ';';
            echo 'var _ajaxNonce = ' . wp_json_encode(\Instinctor\Core\Session::token('instinctor_frontend')) . ';';
            echo 'var _restBase = ' . wp_json_encode($bkRestBase) . ';';
            echo 'var _fldBg = ' . wp_json_encode($bkFieldBg) . ';';
            echo 'var _fldBd = ' . wp_json_encode($bkFieldBd) . ';';
            echo 'var _fldR = ' . wp_json_encode($bkFieldR) . ';';
            echo 'var _btnBg = ' . wp_json_encode($bkBtnBg) . ';';
            echo 'var _btnR = ' . wp_json_encode($bkBtnR) . ';';
            echo 'var _btnColor = ' . wp_json_encode($bkBtnColor) . ';';
            echo '
var _state = { svcId:0, svcName:"", date:"", time:"", year:new Date().getFullYear(), month:new Date().getMonth() };
var $ = function(sel){ return _wrap.querySelector(sel); };

function fmtDate(y,m,d){ return y+"-"+(m+1<10?"0":"")+(m+1)+"-"+(d<10?"0":"")+d; }
function fmtDisplay(y,m,d){ var ms=["January","February","March","April","May","June","July","August","September","October","November","December"]; return ms[m]+" "+d+", "+y; }
function fmtTime(t){ var p=t.split(":"); var h=parseInt(p[0]); var suf=h>=12?"PM":"AM"; h=h%12||12; return h+":"+p[1]+" "+suf; }

function renderCalendar(){
  var g=$(".ibk-cal-grid"), lbl=$(".ibk-month-label");
  var ms=["January","February","March","April","May","June","July","August","September","October","November","December"];
  var days=["Su","Mo","Tu","We","Th","Fr","Sa"];
  lbl.textContent=ms[_state.month]+" "+_state.year;
  g.innerHTML="";
  days.forEach(function(d){ var h=document.createElement("div"); h.textContent=d; h.style.cssText="font-size:11px;color:#888;padding:4px 0"; g.appendChild(h); });
  var first=new Date(_state.year,_state.month,1).getDay();
  for(var i=0;i<first;i++){ g.appendChild(document.createElement("div")); }
  var dim=new Date(_state.year,_state.month+1,0).getDate();
  var today=new Date(); today.setHours(0,0,0,0);
  for(var d=1;d<=dim;d++){
    var dt=new Date(_state.year,_state.month,d);
    var btn=document.createElement("button");
    btn.type="button"; btn.textContent=d;
    var past=dt<today;
    var dateStr=fmtDate(_state.year,_state.month,d);
    var isSelected=(!past && dateStr===_state.date);
    if(isSelected) btn.classList.add("ibk-selected-day");
    btn.style.border="1px solid "+(past?"transparent":isSelected?_btnBg:_fldBd);
    btn.style.background=past?"transparent":isSelected?_btnBg:_fldBg;
    btn.style.borderRadius=_fldR+"px";
    btn.style.padding="6px 2px";
    btn.style.fontSize="13px";
    btn.style.lineHeight="1";
    btn.style.cursor=past?"default":"pointer";
    btn.style.color=past?"#ccc":isSelected?_btnColor:"#1d2327";
    btn.style.fontFamily="inherit";
    btn.style.boxSizing="border-box";
    btn.style.width="100%";
    if(!past){
      btn.addEventListener("click",function(ds){ return function(e){ _keepSelected(e); _state.date=ds; _state.time=""; $(".ibk-next-btn").style.opacity="0.45"; renderCalendar(); loadSlots(); }; }(dateStr));
    }
    g.appendChild(btn);
  }
}

function loadSlots(){
  if(!_state.svcId||!_state.date){ return; }
  var sw=$(".ibk-slots-wrap"), sg=$(".ibk-slots-grid");
  sw.style.display="block"; sg.innerHTML=\'<div style="color:#888;font-size:13px;grid-column:1/-1">Loading…</div>\';
  var xhr=new XMLHttpRequest();
  xhr.open("GET",_restBase+"/bookings/slots?product_id="+_state.svcId+"&date="+_state.date);
  xhr.setRequestHeader("X-Instinctor-Nonce",_nonce);
  xhr.onload=function(){
    if(xhr.status!==200){ sg.innerHTML=\'<div style="color:#888;font-size:13px;grid-column:1/-1">Could not load slots (HTTP \'+xhr.status+\').</div>\'; return; }
    var data; try{ data=JSON.parse(xhr.responseText); }catch(e){ sg.innerHTML=\'<div style="color:#888;font-size:13px;grid-column:1/-1">Invalid slot response.</div>\'; return; }
    var slots=data.slots||[];
    sg.innerHTML="";
    if(!slots.length){ sg.innerHTML=\'<div style="color:#888;font-size:13px;grid-column:1/-1">No slots available.</div>\'; return; }
    slots.forEach(function(s){
      var b=document.createElement("button");
      b.type="button"; b.textContent=s.display;
      var active=s.time===_state.time;
      b.style.cssText="padding:8px 4px;font-size:13px;border-radius:"+_fldR+"px;border:1px solid "+(active?_btnBg:_fldBd)+";background:"+(active?_btnBg:_fldBg)+";color:"+(active?"#fff":(s.available?"#1d2327":"#ccc"))+";cursor:"+(s.available?"pointer":"default");
      if(s.available){
b.addEventListener("click",function(t,el){ return function(e){ _keepSelected(e);
  _state.time=t;
  sg.querySelectorAll("button").forEach(function(x){ x.style.background=_fldBg; x.style.color="#1d2327"; x.style.borderColor=_fldBd; });
  el.style.background=_btnBg; el.style.color="#fff"; el.style.borderColor=_btnBg;
  $(".ibk-next-btn").style.opacity="1";
}; }(s.time,b));
      } else { b.disabled=true; }
      sg.appendChild(b);
    });
  };
  xhr.send();
}

function ibkToast(msg){
  var t=$(".ibk-toast"); t.textContent=msg; t.style.display="block";
  clearTimeout(t._timer); t._timer=setTimeout(function(){ t.style.display="none"; },3000);
}

function showStep(n){
  $(".ibk-toast").style.display="none";
  _wrap.querySelectorAll(".ibk-step").forEach(function(s){ s.style.display="none"; });
  _wrap.querySelector(".ibk-step"+n).style.display="block";
}

// Numeric element id for re-asserting selection in editor
var _elemId = _wrap.id.replace("ibk-","");

function _keepSelected(e) {
  if (e) e.stopPropagation();
  if (window.impulseIsPreview && window.parent && _elemId) {
    window.parent.postMessage({ type:"impulse-select", id:_elemId, elementType:"booking" }, "*");
  }
}

// Service change
var svcEl=$(".ibk-service");
function onSvcChange(){
  var opt=svcEl.options?svcEl.options[svcEl.selectedIndex]:null;
  _state.svcId=parseInt(svcEl.value)||0;
  _state.svcName=opt?opt.text:"";
  _state.date=""; _state.time="";
  renderCalendar();
  $(".ibk-slots-wrap").style.display="none";
  $(".ibk-next-btn").style.opacity="0.45";
}
if(svcEl.tagName==="SELECT") svcEl.addEventListener("change",onSvcChange);
else { _state.svcId=parseInt(svcEl.value)||0; _state.svcName=(_svcs[0]||{}).name||""; }

// Month nav
$(".ibk-prev-month").addEventListener("click",function(e){ _keepSelected(e); _state.month--; if(_state.month<0){_state.month=11;_state.year--;} _state.date=""; _state.time=""; $(".ibk-next-btn").style.opacity="0.45"; renderCalendar(); });
$(".ibk-next-month").addEventListener("click",function(e){ _keepSelected(e); _state.month++; if(_state.month>11){_state.month=0;_state.year++;} _state.date=""; _state.time=""; $(".ibk-next-btn").style.opacity="0.45"; renderCalendar(); });

// Next / Back
$(".ibk-next-btn").addEventListener("click",function(){
  if(!_state.svcId){ ibkToast("Please select a service."); return; }
  if(!_state.date){ ibkToast("Please select a date."); return; }
  if(!_state.time){ ibkToast("Please select a time slot."); return; }
  $(".ibk-booking-summary").textContent=_state.svcName+" · "+fmtDisplay(_state.year,_state.month,parseInt(_state.date.split("-")[2]))+" at "+fmtTime(_state.time);
  showStep(2);
});
$(".ibk-back-btn").addEventListener("click",function(){ showStep(1); });

// Submit
$(".ibk-submit-btn").addEventListener("click",function(){
  var name=$(".ibk-name").value.trim(), email=$(".ibk-email").value.trim();
  if(!name||!email){ ibkToast("Please enter your name and email."); return; }
  var btn=this; btn.disabled=true; btn.textContent="Booking…";
  var fd=new FormData();
  fd.append("action","instinctor_public_book");
  fd.append("nonce",_ajaxNonce);
  fd.append("service_id",_state.svcId);
  fd.append("booking_date",_state.date);
  fd.append("booking_time",_state.time);
  fd.append("customer_name",name);
  fd.append("customer_email",email);
  fd.append("customer_phone",$(".ibk-phone").value);
  fd.append("note",$(".ibk-note").value);
  var xhr=new XMLHttpRequest();
  xhr.open("POST",_ajax);
  xhr.onload=function(){
    btn.disabled=false; btn.textContent="';
            echo esc_js($bkBtnText);
            echo '";
    var res=JSON.parse(xhr.responseText);
    if(res.success){
      $(".ibk-confirm-msg").textContent="Request received";
      $(".ibk-confirm-detail").textContent=_state.svcName+" · "+fmtDisplay(_state.year,_state.month,parseInt(_state.date.split("-")[2]))+" at "+fmtTime(_state.time)+" — we will confirm shortly.";
      showStep(3);
    } else {
      ibkToast(res.data&&res.data.message?res.data.message:"Booking failed. Please try again.");
    }
  };
  xhr.send(fd);
});

// Init
if(svcEl.tagName!=="SELECT" && _state.svcId) renderCalendar();
else if(svcEl.tagName==="SELECT" && svcEl.value) onSvcChange();
else renderCalendar();
';
            echo '})();';
            $_pgJsBooking = ob_get_clean();
            wp_add_inline_script('instinctor-frontend-js', $_pgJsBooking, 'after');
            break;

        case 'login':
            // Shared renderer (see includes/element-renderers/login.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_login($el, $settings, 'editor');
            break;

        case 'register':
            // Shared renderer (see includes/element-renderers/register.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_register($el, $settings, 'editor');
            break;

        case 'shipping':
            // Shared renderer (see includes/element-renderers/shipping.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_shipping($el, $settings, 'editor');
            break;

        case 'receipt':
            // Shared renderer (see includes/element-renderers/receipt.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_receipt($el, $settings, 'editor');
            break;

        case 'account':
            // Shared renderer (see includes/element-renderers/account.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_account($el, $settings, 'editor');
            break;

        case 'nav_menu':
            $layout = $settings['layout'] ?? 'horizontal';
            $gap = $settings['gap'] ?? 24;
            $fontSize = $settings['font_size'] ?? 15;
            $textColor = $settings['text_color'] ?? '#1d2327';
            $hoverColor = $settings['hover_color'] ?? '#666666';
            $menuId = $settings['menu_id'] ?? '';
            
            $flexDir = $layout === 'vertical' ? 'column' : 'row';
            $visBadge = impulse_visibility_badge($settings);
            if ($visBadge) echo '<div style="position:relative">' . wp_kses($visBadge, impulse_svg_allowed());
            echo '<nav class="impulse-nav-menu" style="display:flex;flex-direction:' . esc_attr($flexDir) . ';gap:' . intval($gap) . 'px;font-size:' . intval($fontSize) . 'px">';
            
            // Get actual menu items if menu is selected
            if ($menuId && function_exists('wp_get_nav_menu_items')) {
                $menuItems = wp_get_nav_menu_items($menuId);
                if ($menuItems) {
                    foreach ($menuItems as $item) {
                        if ($item->menu_item_parent == 0) { // Top level only for preview
                            echo '<a href="' . esc_url((string)($item->url ?? '')) . '" style="color:' . esc_attr($textColor) . ';text-decoration:none;transition:color 0.2s" onmouseover="this.style.color=\'' . esc_attr($hoverColor) . '\'" onmouseout="this.style.color=\'' . esc_attr($textColor) . '\'">' . esc_html((string)($item->title ?? '')) . '</a>';
                        }
                    }
                } else {
                    echo '<span style="color:#999;font-style:italic">Menu is empty</span>';
                }
            } else {
                // Placeholder if no menu selected
                echo '<a href="#" style="color:' . esc_attr($textColor) . ';text-decoration:none">Home</a>';
                echo '<a href="#" style="color:' . esc_attr($textColor) . ';text-decoration:none">About</a>';
                echo '<a href="#" style="color:' . esc_attr($textColor) . ';text-decoration:none">Services</a>';
                echo '<a href="#" style="color:' . esc_attr($textColor) . ';text-decoration:none">Contact</a>';
            }
            echo '</nav>';
            if ($visBadge) echo '</div>';
            break;
        case 'social':
            $iconSize = $settings['icon_size'] ?? 20;
            $gap = $settings['gap'] ?? 12;
            $iconColor = $settings['icon_color'] ?? '#666666';
            $align = $settings['align'] ?? 'center';
            $justifyMap2 = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'];
            $justify2 = $justifyMap2[$align] ?? 'center';
            
            $_links4 = impulse_get_social_links();
            if (empty($_links4)) {
                $_links4 = [['platform'=>'facebook','url'=>'#'],['platform'=>'x','url'=>'#'],['platform'=>'instagram','url'=>'#'],['platform'=>'linkedin','url'=>'#']];
            }
            
            echo '<div class="impulse-social-icons" style="display:flex;gap:' . intval($gap) . 'px;align-items:center;justify-content:' . esc_attr($justify2) . '">';
            foreach ($_links4 as $_lk) {
                $svg = impulse_get_social_svg($_lk['platform'], $iconColor);
                if ($svg) echo '<a href="' . esc_url((string)($_lk['url'] ?? '')) . '" target="_blank" style="color:' . esc_attr($iconColor) . '">' . wp_kses($svg, impulse_svg_allowed()) . '</a>';
            }
            echo '</div>';
            break;
            
            
        // MARKETING - PRICING
        case 'pricing':
            $columns = $settings['columns'] ?? 3;
            $gap = $settings['gap'] ?? 24;
            
            echo '<div class="impulse-pricing" style="display:grid;grid-template-columns:repeat(' . intval($columns) . ',1fr);gap:' . intval($gap) . 'px">';
            // Basic plan
            echo '<div style="background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:32px;text-align:center">';
            echo '<h4 style="margin:0 0 8px;font-size:18px">Basic</h4>';
            echo '<div style="font-size:36px;font-weight:700;margin:16px 0">$9<span style="font-size:14px;font-weight:400">/mo</span></div>';
            echo '<ul style="list-style:none;padding:0;margin:24px 0;text-align:left"><li style="padding:8px 0;border-bottom:1px solid #f0f0f0">Feature 1</li><li style="padding:8px 0">Feature 2</li></ul>';
            echo '<button style="width:100%;padding:12px;background:#1d2327;color:#fff;border:none;border-radius:6px;cursor:pointer">Get Started</button>';
            echo '</div>';
            // Pro plan
            echo '<div style="background:#1d2327;border-radius:8px;padding:32px;text-align:center;color:#fff;transform:scale(1.05)">';
            echo '<h4 style="margin:0 0 8px;font-size:18px;color:#fff">Pro</h4>';
            echo '<div style="font-size:36px;font-weight:700;margin:16px 0">$29<span style="font-size:14px;font-weight:400">/mo</span></div>';
            echo '<ul style="list-style:none;padding:0;margin:24px 0;text-align:left"><li style="padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.1)">All Basic features</li><li style="padding:8px 0">Pro Feature</li></ul>';
            echo '<button style="width:100%;padding:12px;background:#22c55e;color:#fff;border:none;border-radius:6px;cursor:pointer">Get Started</button>';
            echo '</div>';
            // Enterprise plan
            echo '<div style="background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:32px;text-align:center">';
            echo '<h4 style="margin:0 0 8px;font-size:18px">Enterprise</h4>';
            echo '<div style="font-size:36px;font-weight:700;margin:16px 0">$99<span style="font-size:14px;font-weight:400">/mo</span></div>';
            echo '<ul style="list-style:none;padding:0;margin:24px 0;text-align:left"><li style="padding:8px 0;border-bottom:1px solid #f0f0f0">All Pro features</li><li style="padding:8px 0">Priority Support</li></ul>';
            echo '<button style="width:100%;padding:12px;background:#1d2327;color:#fff;border:none;border-radius:6px;cursor:pointer">Contact Us</button>';
            echo '</div>';
            echo '</div>';
            break;
            
        // (dead duplicate case: heading - removed)

        case 'shape':
            // Shape block RETIRED 2026-07-02 — shapes now live on the
            // section's bg_shapes background layer. Old shape elements
            // render nothing here and are reaped by normalizeTree (removed
            // from ALLOWED_ELEMENT_TYPES) on the next save.
            break;

        // ================================================================
        // CHART — see templates/impulse-page.php for the canonical render.
        // Preview uses the same logic so the builder matches production 1:1.
        // ================================================================
        case 'chart':
            $chEntries = $settings['chart_entries'] ?? [];
            if (is_string($chEntries)) $chEntries = json_decode($chEntries, true) ?: [];
            if (!is_array($chEntries)) $chEntries = [];
            $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)) {
                echo '<div style="padding:24px;text-align:center;color:#888;font-size:12px">Chart — add entries in the settings panel</div>';
                break;
            }
            $chType        = $settings['chart_type']        ?? 'pie';
            $chStyle       = $settings['chart_style']       ?? 'flat';
            $chOrientation = $settings['chart_orientation'] ?? 'vertical';
            $chInnerRadius = max(0, min(80, intval($settings['chart_inner_radius'] ?? 0)));
            $chLegendPos   = $settings['chart_legend_pos']  ?? 'right';
            $chLabelFormat = $settings['chart_label_format']?? 'none';
            $chTiltSens    = max(0, min(10, intval($settings['chart_tilt_sens'] ?? 0)));
            $chVolume      = !empty($settings['chart_volume']);
            // Shared helper — guarded since templates/impulse-page.php may also declare it.
            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);
                }
            }
            $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-prev-' . ($element['id'] ?? wp_rand(1000, 9999));
            // $chAnimate lives at the chart-level scope so it's available across
            // pie / bar / line / radial branches. Force-ON for everything except
            // pie + 3D (whose rendering context breaks animation in the iframe).
            $chAnimate = ($chType === 'pie' && $chVolume) ? false : true;
            $chLegendAlign = $chLegendPos === 'bottom' ? 'column' : 'row';
            $chVerticalBarRightLegend = ($chType === 'bar' && $chLegendPos === 'right' && $chOrientation !== 'horizontal');
            // Half-pie needs legend pinned to the flat baseline — flex-end + an
            // explicit height matching the half-pie SVG height so the flex
            // container's bottom is exactly at the baseline (not lower).
            $_chHalfP = ($chType === 'pie' && $chStyle === 'half');
            if ($_chHalfP) {
                $chAlignItems = 'flex-end';
                $_wrapHeightCss = ';height:' . intval(320 / 2) . 'px';
            } else {
                $chAlignItems = $chVerticalBarRightLegend ? 'flex-start' : 'center';
                $_wrapHeightCss = '';
            }
            // Bars mode is a plain block list — no legend, no flex centering.
            $_chBarsWrap = ($chType === 'bars');
            $_chWrapInline = $_chBarsWrap
                ? 'width:100%;position:relative;display:block;padding:12px'
                : 'width:100%;position:relative;display:flex;gap:32px;align-items:' . $chAlignItems . ';justify-content:center;flex-direction:' . $chLegendAlign . ';padding:12px' . $_wrapHeightCss;
            echo '<div id="' . esc_attr($chId) . '" class="impulse-chart impulse-chart-' . esc_attr($chType) . ' impulse-chart-' . esc_attr($chStyle) . '" style="' . esc_attr($_chWrapInline) . '" data-chart-type="' . esc_attr($chType) . '" data-chart-style="' . esc_attr($chStyle) . '" data-chart-orientation="' . esc_attr($chOrientation) . '" data-tilt-sens="' . (int) $chTiltSens . '">';
            if ($chType === 'pie') {
                $chIsHalf = ($chStyle === 'half');
                $chSize = 320;
                // Half-pie: SVG height = exactly half so the bottom of the SVG
                // coincides with the pie's flat baseline. flex align-items:flex-end
                // on the outer wrapper then places the legend flush with that line.
                $chSvgH = $chIsHalf ? intval($chSize / 2) : $chSize;
                $chCx = $chSize / 2; $chCy = $chSize / 2;
                $chExplode = ($chStyle === 'exploded') ? 10 : 0;
                // Shrink radius to accommodate explode offset so slices stay
                // inside the viewBox (and the CSS mask wrapper's clip 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; }
                $chSpan  = $chIsHalf ? 180 : 360;
                $chStart = $chIsHalf ? -180 : -90;
                $chVolumeTilt = $chVolume && $chStyle !== 'tilted_ring';
                // $chAnimate is set at chart-level scope earlier (above).
                // 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 ($chAnimate) {
                    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}}'
                       . '@keyframes impulse-pie-extrusion-' . esc_attr($chId) . '{to{opacity:1}}');
                    // Wrap sweep element around the stage/svg. Mask applied here clips
                    // the visible output of everything inside (including any 3D tilted
                    // SVG), in compositor 2D space.
                    // from 0deg = start at 12 o'clock, sweep clockwise.
                    $_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:' . (int) $chSize . 'px;height:' . intval($chSize * 0.8) . 'px">';
                    if ($chVolumeTilt) {
                        echo '<div class="impulse-chart-tilt" style="transform:rotateX(' . (int) $_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(' . (int) $_tiltAngle . 'deg);will-change:transform;transition:transform 0.25s ease-in-out">';
                    }
                }
                echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . (int) $chSize . ' ' . (int) $chSvgH . '" width="' . (int) $chSize . '" height="' . (int) $chSvgH . '" style="overflow:visible;display:block">';
                // Pie reveal: mask sweeps clockwise, then strokes, then extrusion.
                // No mask — per-slice stagger reveals clockwise without masks.
                // Extrusion stack for Volume — fades in after sweep + strokes (~1.9s).
                if ($chVolume && $chAnimate) {
                    echo '<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="1900ms" dur="500ms" fill="freeze"/>';
                } elseif ($chVolume) {
                    echo '<g>';
                }
                if ($chVolume) {
                    $chDepth = 16;
                    for ($layer = $chDepth; $layer >= 1; $layer--) {
                        $shadeFactor = 0.45 + (0.35 * ($chDepth - $layer) / $chDepth);
                        $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;
                            $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';
                            }
                            echo '<path class="impulse-chart-extrusion" data-slice-index="' . (int) $_extI . '" d="' . esc_attr($pathL) . '" fill="' . esc_attr(impulse_chart_shade($rr['color'], $shadeFactor)) . '" stroke="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 = ($r['value'] / $chTotal * 100);
                    // Wrap each slice in its own <g> so the SMIL opacity animation
                    // runs on the GROUP, which works (verified for extrusion <g>).
                    // SMIL opacity on <path> itself wasn't rendering — browser quirk.
                    $_angleFromStart = $chAngle - $chStart;
                    $_sliceDelay     = round(($_angleFromStart / $chSpan) * 900);
                    $_sliceDur       = max(120, round(($sweep / $chSpan) * 900));
                    if ($chAnimate) {
                        // Wrapper mask handles the radial sweep. Strokes fade in
                        // at 1.4s (right after the sweep completes).
                        echo '<path d="' . esc_attr($path) . '" fill="' . esc_attr($r['color']) . '" stroke="#fff" stroke-width="2" stroke-opacity="0">'
                           . '<animate attributeName="stroke-opacity" from="0" to="1" begin="1400ms" dur="500ms" fill="freeze"/>'
                           . '</path>';
                    } else {
                        echo '<path d="' . esc_attr($path) . '" fill="' . esc_attr($r['color']) . '" stroke="#fff" stroke-width="2"></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 (masked when animated)
                echo '</svg>';
                if ($chStyle === 'tilted_ring' || ($chVolume && $chStyle !== 'tilted_ring')) { echo '</div></div>'; }
                if ($chAnimate) { 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) {
                    // Gap + label row below bars; matches frontend spacing for WYSIWYG.
                    $chW = 460; $chPadX = 24; $chPadY = 24; $chLabelH = 18; $chLabelGap = 16;
                    $chBarTop = $chPadY;
                    $chBarMaxH = 188;
                    $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 ' . (int) $chW . ' ' . (int) $chH . '" width="' . (int) $chW . '" height="' . (int) $chH . '" style="overflow:visible;display:block">';
                    $chVd = $chVolume ? 8 : 0;
                    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);
                            $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 . ',' . $sideY1 . ' ' . $sideX2 . ',' . $sideY2 . ' ' . $sideX3 . ',' . $sideY3 . ' ' . $sideX4 . ',' . $sideY4) . '" fill="' . esc_attr($sideColor) . '"></polygon>';
                            $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 . ',' . $topY1 . ' ' . $topX2 . ',' . $topY2 . ' ' . $topX3 . ',' . $topY3 . ' ' . $topX4 . ',' . $topY4) . '" fill="' . esc_attr($topColor) . '"></polygon>';
                        }
                        // Vertical bar — rendered at final geometry. WAAPI JS
                        // below animates transform:scaleY(0→1) from the bottom
                        // edge. SMIL on rect height/y is unreliable in modern
                        // Chrome (SVG 2 migrated these to CSS properties).
                        $_bAnimStyle = $chAnimate ? 'transform-box:fill-box;transform-origin:center bottom;transform:scaleY(0);' : '';
                        echo '<rect class="impulse-chart-bar-slice" data-slice-index="' . (int) $i . '" 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']) . '" style="' . esc_attr($_bAnimStyle) . '"></rect>';
                        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 {
                    $chW = 460; $chLabelW = 90; $chPadX = 16; $chPadY = 16;
                    $chBarH = max(16, (260 - $chPadY * 2 - $chGap * max(0, $chN - 1)) / max(1, $chN));
                    $chH = intval($chPadY * 2 + $chBarH * $chN + $chGap * max(0, $chN - 1));
                    $chBarLeft = $chPadX + $chLabelW + 8;
                    $chBarMaxW = $chW - $chPadX - $chBarLeft;
                    $chRound = ($chStyle === 'rounded') ? min(6, $chBarH / 4) : 0;
                    echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . (int) $chW . ' ' . (int) $chH . '" width="' . (int) $chW . '" height="' . (int) $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);
                            $hx = $chBarLeft + $w;
                            echo '<polygon points="' . esc_attr($hx . ',' . $y . ' ' . ($hx + $chVd) . ',' . ($y - $chVd) . ' ' . ($hx + $chVd) . ',' . ($y + $chBarH - $chVd) . ' ' . $hx . ',' . ($y + $chBarH)) . '" fill="' . esc_attr($sideColor) . '"></polygon>';
                            echo '<polygon points="' . esc_attr($chBarLeft . ',' . $y . ' ' . ($chBarLeft + $chVd) . ',' . ($y - $chVd) . ' ' . ($hx + $chVd) . ',' . ($y - $chVd) . ' ' . $hx . ',' . $y) . '" fill="' . esc_attr($topColor) . '"></polygon>';
                        }
                        // Horizontal bar — WAAPI animates scaleX(0→1) from the left edge.
                        $_bAnimStyleH = $chAnimate ? 'transform-box:fill-box;transform-origin:left center;transform:scaleX(0);' : '';
                        echo '<rect class="impulse-chart-bar-slice" data-slice-index="' . (int) $i . '" 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']) . '" style="' . esc_attr($_bAnimStyleH) . '"></rect>';
                    }
                    echo '</svg>';
                }
            } elseif ($chType === 'line') {
                $chW = 460; $chH = 240; $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];
                }
                echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . (int) $chW . ' ' . (int) $chH . '" width="' . (int) $chW . '" height="' . (int) $chH . '" style="overflow:visible;display:block">';
                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><linearGradient id="' . esc_attr($_gradId) . '" x1="0" x2="0" y1="0" y2="1">'
                       . '<stop offset="0%" stop-color="' . esc_attr($_lightLine) . '"/>'
                       . '<stop offset="100%" stop-color="' . esc_attr($_darkLine) . '"/>'
                       . '</linearGradient>'
                       . '<filter id="' . esc_attr($_shadowId) . '" x="-20%" y="-20%" width="140%" height="140%">'
                       . '<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>'
                       . '<feOffset dx="0" dy="4" result="off"/>'
                       . '<feComponentTransfer result="shadow"><feFuncA type="linear" slope="0.35"/></feComponentTransfer>'
                       . '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>'
                       . '</filter></defs>';
                }
                $linePath = '';
                foreach ($points as $k => $p) $linePath .= ($k === 0 ? 'M ' : ' L ') . round($p['x'], 1) . ' ' . round($p['y'], 1);
                if ($chStyle === 'filled') {
                    $areaPath = $linePath
                              . ' L ' . round(end($points)['x'], 1) . ' ' . $chLineBottom
                              . ' L ' . round($points[0]['x'], 1) . ' ' . $chLineBottom . ' Z';
                    if ($chVolume) {
                        $_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) . ')"></path>';
                    } else {
                        echo '<path class="impulse-chart-line-area" d="' . esc_attr($areaPath) . '" fill="' . esc_attr($chLineColor) . '" fill-opacity="0.18"></path>';
                    }
                }
                // Line rendered fully; WAAPI JS animates stroke-dashoffset on
                // the path (reveal) and r on the dots (pop) after the line draws.
                $_lineInitDash = $chAnimate ? ' stroke-dasharray="3000" stroke-dashoffset="3000"' : '';
                if ($chVolume) {
                    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) . ')"' . wp_kses_post($_lineInitDash) . '></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"' . wp_kses_post($_lineInitDash) . '></path>';
                }
                foreach ($points as $_pi => $p) {
                    // Circle rendered at final radius — WAAPI animates it 0→5.
                    echo '<circle class="impulse-chart-line-dot" data-slice-index="' . (int) $_pi . '" cx="' . esc_attr(round($p['x'], 1)) . '" cy="' . esc_attr(round($p['y'], 1)) . '" r="5" fill="' . esc_attr($p['row']['color']) . '" stroke="#fff" stroke-width="2"' . ($chAnimate ? ' style="transform-box:fill-box;transform-origin:center center;transform:scale(0)"' : '') . '></circle>';
                    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($p['row']['label'], 0, 12, '…')) . '</text>';
                }
                echo '</svg>';
            } elseif ($chType === 'bars') {
                // Compact horizontal bar list — mirrors the frontend renderer at
                // templates/impulse-page.php for visual parity in the editor.
                // Keyframe is idempotent on rerender (same name/body each time).
                if ($chAnimate) {
                    wp_add_inline_style('instinctor-page-inline', '@keyframes impulse-chart-bars-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}');
                }
                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 = $chAnimate ? ($i * 120) : 0;
                    $fillAnim = $chAnimate
                        ? '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="' . (int) $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') {
                $chSize = 260; $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)));
                $chR = ($chSize / 2 - $chStrokeW / 2 - 6) * ($chRingDiameter / 90);
                if ($chR < $chStrokeW) $chR = $chStrokeW;
                $first = $chRows[0];
                $val = max(0, min(100, floatval($first['value'])));
                $circ = 2 * M_PI * $chR;
                $filled = $circ * ($val / 100);
                $gap = $circ - $filled;
                echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . (int) $chSize . ' ' . (int) $chSize . '" width="' . (int) $chSize . '" height="' . (int) $chSize . '" style="overflow:visible;display:block">';
                if ($chVolume) {
                    $_rD = impulse_chart_shade($first['color'], 0.55);
                    $_rL = impulse_chart_shade($first['color'], 1.18);
                    $_rG = 'impulse-chart-radial-grad-' . esc_attr($chId);
                    $_rS = 'impulse-chart-radial-shadow-' . esc_attr($chId);
                    echo '<defs><radialGradient id="' . esc_attr($_rG) . '" 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($_rL) . '"/>'
                       . '<stop offset="' . esc_attr(round(min(100, ($chR + $chStrokeW / 2) / $chR) * 100)) . '%" stop-color="' . esc_attr($_rD) . '"/>'
                       . '</radialGradient>'
                       . '<filter id="' . esc_attr($_rS) . '" x="-20%" y="-20%" width="140%" height="140%">'
                       . '<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>'
                       . '<feOffset dx="0" dy="6" result="off"/>'
                       . '<feComponentTransfer result="shadow"><feFuncA type="linear" slope="0.28"/></feComponentTransfer>'
                       . '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>'
                       . '</filter></defs>';
                }
                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 — entry-animation version renders empty and is swept
                // by the inline JS below (0 → full → target). Mirrors the frontend
                // so the editor iframe shows the same animation preview.
                $_initDA = $chAnimateEnt ? '0 ' . round($circ, 2) : round($filled, 2) . ' ' . round($gap, 2);
                // WAAPI JS animation (added below, after the SVG closes) drives
                // the dasharray growth. Static SVG transform keeps ring at 12 o'clock.
                $_ringAttrs = 'cx="' . $chCx . '" cy="' . $chCy . '" r="' . $chR . '" fill="none" stroke-width="' . $chStrokeW . '" stroke-linecap="round" stroke-dasharray="' . $_initDA . '" transform="rotate(-90 ' . $chCx . ' ' . $chCy . ')" data-circ="' . round($circ, 2) . '" data-filled="' . round($filled, 2) . '" data-gap="' . round($gap, 2) . '"';
                if ($chVolume) {
                    echo '<circle class="impulse-chart-slice" ' . wp_kses_post($_ringAttrs) . ' stroke="url(#' . esc_attr($_rG) . ')" filter="url(#' . esc_attr($_rS) . ')" style="cursor:default"></circle>';
                } else {
                    echo '<circle class="impulse-chart-slice" ' . wp_kses_post($_ringAttrs) . ' stroke="' . esc_attr($first['color']) . '" style="cursor:default"></circle>';
                }
                echo '<text x="' . esc_attr($chCx) . '" y="' . esc_attr($chCy) . '" text-anchor="middle" dominant-baseline="middle" fill="#1d2327" font-size="48" font-weight="700">' . esc_attr(number_format($val, 0)) . '%</text>';
                if (!empty($first['label'])) {
                    echo '<text x="' . esc_attr($chCx) . '" y="' . esc_attr($chCy + 32) . '" text-anchor="middle" fill="#888" font-size="12">' . esc_html($first['label']) . '</text>';
                }
                echo '</svg>';
            }
            // Bar + Radial duplicate info already shown elsewhere (bar axis labels,
            // radial centre label). Skip legend for both — it only helps pie / 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';
                if ($chType === 'bar' && $chLegendPos === 'right' && !($chOrientation === 'horizontal')) {
                    $_legendStyle .= ';margin-bottom:' . (18 + 16) . 'px';
                }
                echo '<div class="impulse-chart-legend" style="' . esc_attr($_legendStyle) . '">';
                foreach ($legendRows as $i => $r) {
                    echo '<div style="display:flex;align-items:center;gap:8px;font-size:12px;color:#1d2327">';
                    echo '<span style="width:16px;height:16px;border-radius:3px;background:' . esc_attr($r['color']) . ';flex-shrink:0"></span>';
                    echo '<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' . esc_html($r['label']) . '</span>';
                    echo '</div>';
                }
                echo '</div>';
            }
            // $chAnimate was set at chart-level scope near the top of this block.
            echo '</div>';
            // Preview-side chart JS — mirrors the frontend block so editor preview
            // shows the same tilt and hover behavior the live site will render.
            $_chAttrs = '';
            ob_start();
            echo '(function(){var root=document.getElementById(' . wp_json_encode($chId) . ');if(!root)return;';
            echo 'root.dataset.animate=' . wp_json_encode($chAnimate ? '1' : '0') . ';';
            // Radial chart: WAAPI animation via el.animate() on the slice. This
            // is what visibly animated in 2.1.135 — keeping it focused on radial
            // only (other chart types use their own CSS/SMIL animations).
            // Bar + line entry via WAAPI. scaleY/scaleX transform on <rect> with
            // transform-box:fill-box gives reliable grow-from-edge animation.
            // stroke-dashoffset animation on <path> draws the line. r=0→5 pops dots.
            echo 'if(root.dataset.animate==="1"&&root.dataset.chartType==="bar"&&typeof Element.prototype.animate==="function"&&!matchMedia("(prefers-reduced-motion:reduce)").matches){';
            // Orientation from data attribute — consistent across all bars in the
            // chart, not derived per-rect geometry (which flipped when short
            // bars had width > height).
            echo '  var _isH=root.dataset.chartOrientation==="horizontal";';
            echo '  root.querySelectorAll("rect.impulse-chart-bar-slice").forEach(function(r,i){';
            echo '    r.animate([{transform:_isH?"scaleX(0)":"scaleY(0)"},{transform:_isH?"scaleX(1)":"scaleY(1)"}],{duration:800,delay:i*120,easing:"cubic-bezier(0.4, 0, 0.2, 1)",fill:"both"});';
            echo '  });';
            echo '}';
            echo 'if(root.dataset.animate==="1"&&root.dataset.chartType==="line"&&typeof Element.prototype.animate==="function"&&!matchMedia("(prefers-reduced-motion:reduce)").matches){';
            echo '  var _lineP=root.querySelector("path.impulse-chart-line-path");';
            echo '  if(_lineP){_lineP.animate([{strokeDashoffset:3000},{strokeDashoffset:0}],{duration:1200,easing:"cubic-bezier(0.4, 0, 0.2, 1)",fill:"both"});}';
            // Premium dot animation: every dot starts bunched at the first data
            // point and slides outward along the line, each one arriving exactly
            // when the line sweep reaches its x-coordinate. Feels like the line
            // is dragging the dots into place, not sprinkling them one-by-one.
            echo '  var _dots=Array.from(root.querySelectorAll("circle.impulse-chart-line-dot"));';
            echo '  if(_dots.length>0){';
            echo '    var _fx=parseFloat(_dots[0].getAttribute("cx"))||0,_fy=parseFloat(_dots[0].getAttribute("cy"))||0;';
            echo '    var _xs=_dots.map(function(d){return parseFloat(d.getAttribute("cx"))||0;});';
            echo '    var _min=Math.min.apply(null,_xs),_max=Math.max.apply(null,_xs),_span=(_max-_min)||1;';
            echo '    _dots.forEach(function(c,idx){';
            echo '      var _x=parseFloat(c.getAttribute("cx"))||0;';
            echo '      var _y=parseFloat(c.getAttribute("cy"))||0;';
            echo '      var _t=((_x-_min)/_span)*1100;';  // ms when the line-draw reaches this dot's x
            echo '      if(idx===0||_t<60){';  // first dot (and any that would arrive immediately) just fade in
            echo '        c.animate([{transform:"scale(0)",opacity:0},{transform:"scale(1.15)",opacity:1,offset:0.7},{transform:"scale(1)",opacity:1}],{duration:450,easing:"cubic-bezier(0.4, 0, 0.2, 1)",fill:"both"});';
            echo '        return;';
            echo '      }';
            echo '      var _dx=_fx-_x,_dy=_fy-_y;';  // offset from final → start-bunch at first-dot position
            echo '      c.animate([';
            echo '        {transform:"translate("+_dx+"px,"+_dy+"px) scale(0.35)",opacity:0,offset:0},';
            echo '        {transform:"translate("+_dx+"px,"+_dy+"px) scale(0.9)",opacity:0.9,offset:0.08},';  // materialize at start
            echo '        {transform:"translate(0,0) scale(1.2)",opacity:1,offset:0.88},';  // overshoot arrival
            echo '        {transform:"translate(0,0) scale(1)",opacity:1,offset:1}';  // settle at true position
            echo '      ],{duration:_t+250,easing:"cubic-bezier(0.4, 0, 0.2, 1)",fill:"both"});';
            echo '    });';
            echo '  }';
            echo '}';
            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 — just a smooth 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 '}';
            // Tilt for tilted_ring — mouse X/Y drives rotateX/Y on the .impulse-chart-tilt container.
            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,rx=baseRx,ry=0,rxT=baseRx,ryT=0,ease=null;';
            echo '    function setTarget(){rxT=baseRx+my*sens*10;ryT=mx*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 '  }';
            echo '}';
            // Radial mouse tilt — rotates the ring back and forth with mouse X.
            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 '})();';
            $_pgJsChart = ob_get_clean();
            wp_add_inline_script('instinctor-frontend-js', $_pgJsChart, 'after');
            break;

        case 'price_list':
            // Shared renderer (see includes/element-renderers/price_list.php).
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_price_list($el, $settings, 'editor');
            break;

        case 'posts':
            // Blog index — auto-lists published type='post' pages.
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shared sub-renderer return; escaped at construction (each renderer is EscapeOutput=0), wp_kses_post would strip its SVG/gradient/handlers
            echo impulse_element_render_posts($el, $settings, 'editor');
            break;

        case 'bundle':
            global $wpdb;
            $bundleId     = intval($settings['bundle_id'] ?? 0);
            $layout       = 'stack';
            $showSavings  = ($settings['show_savings'] ?? true) !== false && ($settings['show_savings'] ?? '1') !== '0';
            $showImages   = ($settings['show_images'] ?? true) !== false && ($settings['show_images'] ?? '1') !== '0';
            $showPrices   = ($settings['show_prices'] ?? true) !== false && ($settings['show_prices'] ?? '1') !== '0';
            $btnLabel     = $settings['btn_label'] ?? 'Add Bundle to Cart';
            $accentColor  = esc_attr($settings['accent_color'] ?? '#1d2327');
            $fieldColor   = esc_attr($settings['field_color'] ?? '#f5f5f5');
            $borderRadius = intval($settings['border_radius'] ?? 4);
            $accentText  = impulse_contrast_color($accentColor);
            $fieldText   = impulse_contrast_color($fieldColor);
            $fieldTextSub = ($fieldText === '#000000') ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.5)';

            $bundle   = $bundleId ? \Instinctor\Services\ProductBundleService::getBundle($bundleId) : null;
            $products = [];
            if ($bundle && !empty($bundle['products'])) {
                $pt = $wpdb->prefix . 'instinctor_products';
                foreach (array_map('intval', $bundle['products']) as $pid) {
                    $p = $wpdb->get_row(
                        $wpdb->prepare("SELECT id, name, price, sale_price, images, gallery_images, featured_image FROM {$pt} WHERE id = %d", $pid), // phpcs:ignore
                        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';

            $wrapStyle = $layout === 'grid'
                ? 'display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px;'
                : 'display:flex;flex-direction:column;gap:8px;';
            $bundleWidthPx = intval($settings['bundle_width'] ?? 400);
            if ($bundleWidthPx < 340) $bundleWidthPx = 340;
            if ($bundleWidthPx > 600) $bundleWidthPx = 600;
            $bundleWidthStyle = 'max-width:' . $bundleWidthPx . 'px;width:100%;margin:0 auto;';

            echo '<div class="impulse-product-bundle" style="font-family:inherit;padding:12px;box-sizing:border-box;' . esc_attr($bundleWidthStyle) . '">';

            if (!$bundle || empty($products)) {
                echo '<div style="padding:24px;text-align:center;color:#999;font-size:13px;background:#f5f5f5;border-radius:6px">';
                echo $bundleId ? 'Bundle #' . (int) $bundleId . ' — no products found.' : 'No bundle selected. Pick one in the panel.';
                echo '</div></div>';
                break;
            }

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

            // Products
            echo '<div class="impulse-bundle-products" style="' . esc_attr($wrapStyle) . 'margin-bottom:16px">';
            foreach ($products as $p) {
                $frStyle = 'border-radius:' . $borderRadius . '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;
                echo '<div class="impulse-bundle-item" style="' . esc_attr($itemStyle) . '">';
                if ($layout === 'stack') {
                    echo '<div style="flex:1;min-width:0">';
                    echo '<div style="font-size:13px;font-weight:500;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_attr(number_format($p['effective_price'], 2)) . '</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">';
                    }
                } else {
                    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;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_attr(number_format($p['effective_price'], 2)) . '</div>';
                    }
                    echo '</div>';
                }
                echo '</div>';
            }
            echo '</div>';

            // Price + CTA button in one row
            echo '<div class="impulse-bundle-footer" style="display:flex;align-items:center;gap:12px">';
            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_attr(number_format($pricing['discounted'], 2)) . '</span>';
                    echo '<span class="impulse-bundle-original" style="font-size:14px;opacity:0.45;text-decoration:line-through">$' . esc_attr(number_format($pricing['original'], 2)) . '</span>';
                } else {
                    echo '<span class="impulse-bundle-price" style="font-size:22px;font-weight:700;color:' . esc_attr($accentColor) . '">$' . esc_attr(number_format($pricing['original'], 2)) . '</span>';
                }
                echo '</div>';
            } else {
                echo '<div style="flex:1"></div>';
            }
            echo '<button type="button" class="impulse-bundle-add-btn" style="display:inline-flex;align-items:center;justify-content:center;padding:0 20px;height:40px;background:' . esc_attr($accentColor) . ';color:' . esc_attr($accentText) . ';border:none;border-radius:' . (int) $borderRadius . 'px;font-size:14px;font-weight:600;cursor:default;white-space:nowrap;flex-shrink:0">';
            echo esc_html($btnLabel);
            echo '</button>';
            echo '</div>';

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

        default:

            echo 'Unknown element type: ' . esc_html($type);
            echo '</div>';
    }
    
    echo '</div>'; // .impulse-el
    // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
    }
}

    // On standalone, execute immediately — add_action is a no-op, parse_request never fires.
    // On WordPress, register via add_action as normal.
    $impulse_preview_callback = function($wp = null) {
        if (!isset($GLOBALS['impulse_preview_page_id'])) return;

        $pageId = $GLOBALS['impulse_preview_page_id'];
        if (!$pageId) return;

        // WP frontend editor requests: verify capability now that WP is fully loaded
        if (!empty($GLOBALS['_impulse_wp_editor_request'] ?? false)
            || (!function_exists('is_admin') || !is_admin())) {
            if (!empty($_GET['edit'])
                && function_exists('current_user_can')
                && !current_user_can('edit_pages')) {
                return; // Not authorised — bail silently
            }
        }

        // Buffer all output so render endpoint can set JSON headers even if WP outputs first
        ob_start();

        // ── Single-element render endpoint flag ──────────────────────────────
        if (isset($_GET['impulse_render_element'])) {
            $GLOBALS['impulse_render_element_id'] = intval($_GET['impulse_render_element']);
        }
        
        // Prevent caching
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Cache-Control: post-check=0, pre-check=0', false);
        header('Pragma: no-cache');
        header('Expires: Thu, 01 Jan 1970 00:00:00 GMT');
        
        // Verify the page exists via PageService (canonical, no WP post dependency)
        $pageRow = \Instinctor\Services\PageService::getById($pageId);
        if (!$pageRow) {
            http_response_code(404);
            echo '<p style="font-family:sans-serif;padding:40px;color:#555">Page not found (ID: ' . intval($pageId) . ')</p>';
            // Close the buffer opened above before exiting so it never crosses
            // into another component's output flow (WP.org: no open ob_start()).
            while (ob_get_level() > 0) { ob_end_flush(); }
            exit;
        }

        $pageName = $pageRow['name'] ?? '';

        // In WP mode: optionally set global $post for wp_footer hooks.
        // Skip silently if no matching WP post (standalone-created pages have no WP post).
        if (function_exists('get_post') && function_exists('setup_postdata')) {
            $wpPost = get_post($pageId);
            if ($wpPost) {
                global $post;
                $post = $wpPost;
                setup_postdata($post);
            }
        }

        // Get page settings — PageService is canonical (instinctor_pages table),
        // with automatic wp_postmeta fallback for pages not yet migrated.
        $pageSettings = \Instinctor\Services\PageService::getSettings($pageId);
        if (!is_array($pageSettings)) {
            $pageSettings = [];
        }

        // Get page padding and spacing settings for body styling
        $pagePaddingTop = $pageSettings['padding_top'] ?? 0;
        $pagePaddingRight = $pageSettings['padding_right'] ?? 0;
        $pagePaddingLeft = $pageSettings['padding_left'] ?? 0;
        $pageElementSpacing = $pageSettings['element_spacing'] ?? 20;

        // Screen mode — must be determined BEFORE pageBgColor.
        $screenMode = true;
        $GLOBALS['impulse_screen_tunnel'] = !empty($pageSettings['screen_tunnel']);

        // Compute effective background color — every page is screen mode.
        // With no explicit bg_color, derive from cloud effect:
        //   palette effects → #ffffff, everything else (stars, ambient, none) → #000000
        $cloudEff = $pageSettings['screen_cloud_effect'] ?? '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','particles'];
        $isClassicPage = ($pageSettings['body_scroll'] ?? '') === 'classic';
        // Publish scroll mode so the shared section renderer gives classic top-level
        // sections free content height in the editor too (matches the frontend). This
        // runs before both the full-page and single-element render paths. 2026-07-02.
        $GLOBALS['impulse_body_scroll'] = $isClassicPage ? 'classic' : 'snap';
        if ($isClassicPage) {
            $screenDefaultBg = '#ffffff'; // classic = normal website → white (env doesn't render here)
        } elseif (in_array($cloudEff, $paletteEffects)) {
            $screenDefaultBg = '#ffffff';
        } elseif (in_array($cloudEff, $forceBlackEffects)) {
            $screenDefaultBg = '#000000';
        } else {
            $screenDefaultBg = '#f5f5f5';
        }
        if ($isClassicPage) {
            // Classic page: white unless the user set an explicit bg_color.
            $pageBgColor = (!empty($pageSettings['bg_color']) && $pageSettings['bg_color'] !== 'transparent')
                ? $pageSettings['bg_color'] : '#ffffff';
        } elseif (in_array($cloudEff, $forceBlackEffects)) {
            $pageBgColor = '#000000';
        } elseif (in_array($cloudEff, $paletteEffects)) {
            $pageBgColor = '#ffffff';
        } elseif ($cloudEff === 'none' || $cloudEff === '') {
            // No effect: page bg_color is the source of truth. Older DBs can
            // hold a stale 'screen_none_bg_color' from earlier switches
            // (typically '#000000') which would otherwise override the user's
            // explicit page bg. v2.1.1231.
            $pageBgColor = (!empty($pageSettings['bg_color']) && $pageSettings['bg_color'] !== 'transparent')
                ? $pageSettings['bg_color']
                : $screenDefaultBg;
        } else {
            // User-controllable bg: prefer per-effect key so switching to a locked
            // effect and back (which corrupts bg_color) doesn't break reload
            $perEffBg = $pageSettings['screen_' . $cloudEff . '_bg_color'] ?? '';
            $pageBgColor = (!empty($perEffBg) && $perEffBg !== 'transparent')
                ? $perEffBg
                : ((!empty($pageSettings['bg_color']) && $pageSettings['bg_color'] !== 'transparent')
                    ? $pageSettings['bg_color']
                    : $screenDefaultBg);
        }

        $GLOBALS['impulse_page_bg_color']  = $pageBgColor;
        $GLOBALS['impulse_page_settings']  = $pageSettings;
        // Skip heavy page setup for render-element requests
        if (empty($GLOBALS['impulse_render_element_id'])) {
        // Adaptive empty cell colors based on page bg luminance
        $_validHex = (is_string($pageBgColor) && preg_match('/^#[0-9a-fA-F]{6}$/', $pageBgColor));
        $_acR = $_validHex ? hexdec(substr($pageBgColor, 1, 2)) : 245;
        $_acG = $_validHex ? hexdec(substr($pageBgColor, 3, 2)) : 245;
        $_acB = $_validHex ? hexdec(substr($pageBgColor, 5, 2)) : 245;
        $_acLum = ($_acR * 0.299 + $_acG * 0.587 + $_acB * 0.114);
        $GLOBALS['impulse_cell_empty_bg'] = ($_acLum < 128) ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.025)';
        $GLOBALS['impulse_cell_empty_fg'] = ($_acLum < 128) ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)';
        $GLOBALS['impulse_page_is_dark'] = ($_acLum < 128);
        $GLOBALS['impulse_page_max_content_width'] = intval($pageSettings['max_content_width'] ?? 1440);
        $GLOBALS['impulse_page_content_width'] = intval($pageSettings['content_width'] ?? 100);
        } // end skip for render-element
        
        // Get elements — read from instinctor_pages (canonical store).
        // PageService::getElements() falls back to wp_postmeta for un-migrated pages.
        $elementsData = \Instinctor\Services\PageService::getElements($pageId);
        if (!is_array($elementsData)) {
            $elementsData = [];
        }
        
        // Convert array rows to objects
        $elements = [];
        foreach ($elementsData as $row) {
            $el = new \stdClass();
            $el->id = $row['id'] ?? 0;
            $el->template_id = $pageId;
            $el->section_type = $row['type'] ?? '';
            $el->settings = is_array($row['settings']) ? $row['settings'] : [];
            $el->parent_id = $row['parent_id'] ?? null;
            $el->sort_order = $row['sort_order'] ?? 0;
            $el->children = [];
            $elements[] = $el;
        }
        

        // Sort by sort_order
        usort($elements, function($a, $b) {
            return $a->sort_order - $b->sort_order;
        });
        
        // Build element tree (for nested elements)
        $elementTree = [];
        $elementMap = [];
        foreach ($elements as $el) {
            $elementMap[$el->id] = $el;
        }
        foreach ($elements as $el) {
            // Guard: skip self-referencing elements (parent_id === id)
            if ($el->parent_id && $el->parent_id !== $el->id && isset($elementMap[$el->parent_id])) {
                $elementMap[$el->parent_id]->children[] = $el;
            } else {
                $elementTree[] = $el;
            }
        }
        
        // Disable emoji scripts in thumbnail mode to prevent console errors
        if (isset($_GET['thumbnail']) && function_exists('remove_action')) {
            remove_action('wp_head', 'print_emoji_detection_script', 7);
            remove_action('wp_print_styles', 'print_emoji_styles');
            remove_action('admin_print_scripts', 'print_emoji_detection_script');
            remove_action('admin_print_styles', 'print_emoji_styles');
        }
        
        // Pre-scan: collect root-level sections for the screen-name UI.
        $GLOBALS['impulse_screen_sections'] = [];
        $screenIdx = 0;
        foreach ($elementTree as $_rootEl) {
            if ($_rootEl->section_type === 'section') {
                $GLOBALS['impulse_screen_sections'][] = [
                    'id' => $_rootEl->id,
                    'name' => 'Screen ' . ($screenIdx + 1),
                    'index' => $screenIdx];
                $screenIdx++;
            }
        }
        
        ?>
<?php if (empty($GLOBALS['impulse_render_element_id'])): ?>
<!DOCTYPE html>
<!-- impulse-preview v<?php echo esc_html(INSTINCTOR_VERSION); ?> -->
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<?php
/* Device-EXPLICIT viewport for the preview iframe. `width=device-width`
   resolved to whatever the iframe element happened to be at first layout —
   and because the canvas CSS narrows that element to 430/768 only AFTER the
   content's viewport has already resolved (often at the initial ~1920), the
   mobile/tablet preview kept laying out at desktop width. The page then
   looked fine in-engine (everything fit the wide stage) while a real phone
   just clipped the oversized render — the "second line cropped on mobile"
   report. Pinning an explicit width makes the content ALWAYS lay out at the
   true device width, so screens (100vw), the kinetic width-clamp, and every
   responsive rule see the real phone/tablet size. Sergei 2026-06-02. */
$_pvDevice = $GLOBALS['impulse_preview_device'] ?? 'desktop';
$_pvVpW = ($_pvDevice === 'mobile') ? '430' : (($_pvDevice === 'tablet') ? '768' : 'device-width');
?>
<meta name="viewport" content="width=<?php echo esc_attr($_pvVpW); ?>,initial-scale=1">
<title><?php echo esc_html($pageName); ?> - Preview</title>
<!-- Editor-preview flag — read by Three.js engines to cap renderer
     pixel ratio at 1.0 inside the editor iframe. Live frontend keeps
     full DPR; this only affects /admin/preview/N. Cuts pixel count to
     ~1/4 on retina, big GPU savings while editing. 2.0.864. -->
<script>window._impulseEditorPreview = true;</script>
<?php
// Strip all theme/plugin CSS from the preview — only our own styles should load.
// Theme stylesheets cause layout interference (widths, display overrides, etc.).
if (function_exists('wp_head') && empty($GLOBALS['impulse_render_element_id'])) {
    add_action('wp_print_styles', function() {
        global $wp_styles;
        if (empty($wp_styles->queue)) return;
        foreach ($wp_styles->queue as $handle) {
            $src = $wp_styles->registered[$handle]->src ?? '';
            // Keep only styles from instinctor plugin
            if (strpos($src, 'instinctor') === false) {
                wp_dequeue_style($handle);
            }
        }
    }, 9999);
    wp_head();
}
?>

<!-- jQuery: WP-bundled in WP context, local vendor in standalone.
     Inline scripts further down use $ at parse time, so emit IMMEDIATELY
     (wp_print_scripts in WP, our shim's _instinctor_emit_script in standalone)
     instead of relying on wp_footer to flush. -->
<?php
if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
    \Instinctor\Core\Assets::enqueueMany(['instinctor-jquery', 'instinctor-jquery-ui']);
    if (function_exists('_instinctor_emit_script')) {
        _instinctor_emit_script('instinctor-jquery', false);
        _instinctor_emit_script('instinctor-jquery-ui', false);
    }
} else {
    wp_enqueue_script('jquery');
    wp_enqueue_script('jquery-ui-sortable');
    wp_enqueue_script('jquery-ui-draggable');
    wp_print_scripts(['jquery', 'jquery-ui-sortable', 'jquery-ui-draggable']);
}
// SortableJS — drives the iframe-internal block reorder/move (replaced
// jQuery UI Sortable for in-page drags). Loaded straight from the plugin
// vendor folder; the admin shell loads it too via the standalone enqueue
// pipeline but the iframe needs its own copy.
\Instinctor\Core\Assets::enqueue('instinctor-sortable');
?>
<?php
?>
<script>
// Polyfill: .closest() only exists on Element — scroll/wheel events can target Document, Text, SVG nodes etc.
(function() {
    var _ec = Element.prototype.closest;
    var _safe = function(s) {
        var el = this instanceof Element ? this : (this.parentElement || this.parentNode);
        while (el && !(el instanceof Element)) el = el.parentNode;
        return el ? _ec.call(el, s) : null;
    };
    if (typeof Document !== 'undefined') Document.prototype.closest = _safe;
    if (typeof Text !== 'undefined') Text.prototype.closest = _safe;
    if (typeof Comment !== 'undefined') Comment.prototype.closest = _safe;
    if (typeof SVGElement !== 'undefined' && !SVGElement.prototype.closest) SVGElement.prototype.closest = _safe;
    // Catch-all: any future node type
    if (!Node.prototype.hasOwnProperty('closest')) {
        Object.defineProperty(Node.prototype, 'closest', { value: _safe, writable: true, configurable: true });
    }
})();
var instinctorFrontend = {
    ajaxUrl: '<?php echo esc_url(admin_url('admin-ajax.php')); ?>',
    apiUrl: '<?php echo esc_url(rest_url('instinctor/v1/')); ?>',
    nonce: '<?php echo esc_js(function_exists('wp_create_nonce') ? wp_create_nonce('instinctor_frontend') : \Instinctor\Core\Session::token('instinctor_frontend')); ?>',
    currency: '<?php echo esc_js(get_option('instinctor_currency', 'USD')); ?>',
    currencySymbol: '$',
    currencyPosition: 'left'
};
// Element action functions - must be available before HTML renders
/* ── Exact colour pick (parent asks, canvas answers) ────────────────────────
   The native EyeDropper reads physical screen pixels, so a wide-gamut display
   hands back a colour-managed approximation rather than the value the block is
   actually set to (Sergei 2026-07-28). Here the colour is read straight off
   getComputedStyle, so what lands in the picker is exactly what the element
   renders with. Hover paints an outline; click samples; Esc cancels. */
var _elPickOn = false, _elPickPrev = null;
function _elPickColorOf(el) {
    var cs = getComputedStyle(el);
    var bg = cs.backgroundColor || '';
    var transparent = !bg || bg === 'transparent' || /rgba\(\s*0,\s*0,\s*0,\s*0\s*\)/.test(bg);
    /* A block with no fill of its own means the author is pointing at its TEXT
       colour (a heading, a label): fall back to `color` rather than reporting
       "no colour" or walking up to an ancestor's background, which would be a
       different element than the one they clicked. */
    var src = transparent ? (cs.color || '') : bg;
    var m = src.match(/rgba?\(([^)]+)\)/);
    if (!m) return '';
    var p = m[1].split(',');
    var r = parseInt(p[0], 10) || 0, g = parseInt(p[1], 10) || 0, b = parseInt(p[2], 10) || 0;
    var a = p.length > 3 ? parseFloat(p[3]) : 1;
    if (a === 0) return '';
    var hx = function (n) { return ('0' + Math.max(0, Math.min(255, n)).toString(16)).slice(-2); };
    return '#' + hx(r) + hx(g) + hx(b);
}
function _elPickPaint(el) {
    if (_elPickPrev && _elPickPrev !== el) _elPickPrev.style.removeProperty('outline');
    if (el) el.style.setProperty('outline', '2px solid #4da3ff', 'important');
    _elPickPrev = el;
}
function _elPickStop(send) {
    if (!_elPickOn) return;
    _elPickOn = false;
    _elPickPaint(null);
    document.body.style.removeProperty('cursor');
    document.removeEventListener('mousemove', _elPickMove, true);
    document.removeEventListener('click', _elPickClick, true);
    document.removeEventListener('keydown', _elPickKey, true);
    if (send && window.parent) window.parent.postMessage({ type: 'impulse-elpick-cancel' }, '*');
}
function _elPickMove(e) {
    if (!_elPickOn) return;
    var el = e.target && e.target.closest ? (e.target.closest('.impulse-el, .impulse-section') || e.target) : e.target;
    _elPickPaint(el);
}
function _elPickClick(e) {
    if (!_elPickOn) return;
    e.preventDefault();
    e.stopPropagation();
    var el = e.target;
    var hex = _elPickColorOf(el);
    /* Wrappers are often transparent; climb to the nearest ancestor that has a
       real colour so a click near an edge still returns something useful. */
    var hop = el, guard = 0;
    while (!hex && hop && hop.parentElement && guard++ < 4) {
        hop = hop.parentElement;
        hex = _elPickColorOf(hop);
    }
    _elPickStop(false);
    if (window.parent) window.parent.postMessage({ type: 'impulse-elpick-result', hex: hex }, '*');
}
function _elPickKey(e) {
    if (e.key === 'Escape') { e.preventDefault(); _elPickStop(true); }
}
window.addEventListener('message', function (ev) {
    if (!ev || !ev.data || ev.data.type !== 'impulse-elpick-start') return;
    if (_elPickOn) return;
    _elPickOn = true;
    document.body.style.setProperty('cursor', 'crosshair', 'important');
    document.addEventListener('mousemove', _elPickMove, true);
    document.addEventListener('click', _elPickClick, true);
    document.addEventListener('keydown', _elPickKey, true);
});

function impulseDelete(id) {
    if (window.parent) {
        window.parent.postMessage({ type: 'impulse-delete', id: id, fromToolbar: true }, '*');
    }
}
function impulseDuplicate(id) {
    if (window.parent) {
        window.parent.postMessage({ type: 'impulse-duplicate', id: id }, '*');
    }
}
// Select-parent toolbar button — capture-phase mousedown so the click
// doesn't get swallowed by any other body-level handler. Walks the DOM for
// the nearest .impulse-el ancestor of the current element and selects it,
// mirroring the right-click menu's "Select Parent" action but reachable
// from the inline toolbar so users don't need to dig into the Structure
// panel for one-step navigation up the tree.
document.addEventListener('mousedown', function(e) {
    if (e.button !== 0) return;
    var btn = e.target.closest && e.target.closest('.impulse-el-parent');
    if (!btn) return;
    e.preventDefault();
    e.stopPropagation();
    e.stopImmediatePropagation();
    var elNode  = btn.closest('.impulse-el');
    if (!elNode || !window.parent) return;
    var parentEl = elNode.parentElement ? elNode.parentElement.closest('.impulse-el') : null;
    if (!parentEl) {
        // Root-level element (no parent .impulse-el wrapper) — render path
        // already gated the button on $parentId, so this is a defensive
        // no-op for edge cases (e.g. mid-refresh DOM).
        return;
    }
    // Cell-nested block: the meaningful "parent" is the CELL it sits in, not
    // the whole grid/rows/columns structure — select the cell exactly like a
    // direct cell click would (Sergei 2026-07-20). Only when the cell really
    // belongs to the immediate parent structure; nested structures fall
    // through to the plain parent select.
    var cellNode = elNode.parentElement ? elNode.parentElement.closest('.impulse-grid-cell, .impulse-rows-cell, .impulse-layout-cell') : null;
    if (cellNode && parentEl.contains(cellNode)) {
        document.querySelectorAll('.impulse-el.selected').forEach(function(x) { x.classList.remove('selected'); });
        document.querySelectorAll('.impulse-grid-cell.selected, .impulse-rows-cell.selected, .impulse-layout-cell.selected').forEach(function(x) { x.classList.remove('selected'); });
        cellNode.classList.add('selected');
        // impulse-cell-select is the unified canvas-cell selection message:
        // it sets the parent-side cell state AND rebuilds the panel with the
        // cell's controls (the legacy grid-cell-select/rows-select pair only
        // works when the container panel is already open).
        var cellMsg;
        if (cellNode.classList.contains('impulse-grid-cell')) {
            cellMsg = { type: 'impulse-cell-select', parentId: parentEl.dataset.id, parentType: 'grid', cellKey: cellNode.getAttribute('data-cell') };
        } else if (cellNode.classList.contains('impulse-rows-cell')) {
            cellMsg = { type: 'impulse-cell-select', parentId: parentEl.dataset.id, parentType: 'rows', cellKey: cellNode.getAttribute('data-row') + '-' + cellNode.getAttribute('data-col') };
        } else {
            cellMsg = { type: 'impulse-cell-select', parentId: parentEl.dataset.id, parentType: 'columns', cellKey: cellNode.getAttribute('data-col') + '-' + cellNode.getAttribute('data-row') };
        }
        window.parent.postMessage(cellMsg, '*');
        return;
    }
    document.querySelectorAll('.impulse-el.selected').forEach(function(x) { x.classList.remove('selected'); });
    parentEl.classList.add('selected');
    window.parent.postMessage({
        type: 'impulse-select',
        id: parentEl.dataset.id,
        elementType: parentEl.dataset.type
    }, '*');
}, true);

// (Wrap toolbar button + its capture-phase mousedown relay removed
// 2026-07-19 — Wrap in container lives in the right-click context menu.)

// ── Toolbar lock ─────────────────────────────────────────────────────────
// Disambiguates which element's toolbar the pointer is targeting. The
// toolbar floats above its element (translateY(-100%)), landing inside
// the parent's box — so without this, reaching a nested child's toolbar
// drifts into the parent's hover zone and you click the parent's
// Duplicate / Delete by mistake. We tag the real target element with
// .imp-tb-active; CSS above forces that toolbar on and ancestors off.
// Pointer over a toolbar → resolve to the toolbar's OWNING element, so
// the lock holds even while the cursor sits on the floating toolbar.
(function() {
    function setActive(el) {
        var prev = document.querySelector('.impulse-el.imp-tb-active');
        if (prev === el) return;
        if (prev) prev.classList.remove('imp-tb-active');
        if (el) {
            el.classList.add('imp-tb-active');
            // Flip the floating toolbar to sit INSIDE the element's top edge when
            // there's no room above it (element flush against the canvas top —
            // e.g. a full-viewport screen or its first block). Without this,
            // translateY(-100%) pushes the toolbar off-screen and it gets clipped.
            // Height-aware threshold: the toolbar floats UP by its own height, so
            // it clips whenever the element's top is closer to the canvas top than
            // that height. The old fixed 36px was SHORTER than a multi-icon toolbar
            // (e.g. a button's ~34-40px bar), so an element ~40px down didn't flip
            // yet its toolbar still clipped. Measure the real toolbar height and
            // flip within (height + small gap). Sergei 2026-07-04.
            try {
                var _tb = el.querySelector(':scope > .impulse-el-toolbar');
                var _tbH = (_tb && _tb.offsetHeight) ? _tb.offsetHeight : 34;
                // Clip-aware flip: the toolbar floats UP by its own height, so it
                // clips whenever that lifted top crosses the top edge of the
                // NEAREST ancestor that clips overflow — a grid cell, the section
                // (both overflow:hidden), or the viewport. The old check only
                // compared against the viewport top, so a block clipped by a
                // mid-canvas cell/section never flipped. Walk to the first
                // clipping ancestor and flip (dock inside) if the lifted toolbar
                // would cross its top.
                var _clipTop = 0;
                var _anc = el.parentElement;
                while (_anc && _anc !== document.body && _anc !== document.documentElement) {
                    var _cs = window.getComputedStyle(_anc);
                    if (_cs && (_cs.overflow === 'hidden' || _cs.overflowY === 'hidden' || _cs.overflow === 'clip' || _cs.overflow === 'auto' || _cs.overflow === 'scroll')) {
                        _clipTop = _anc.getBoundingClientRect().top;
                        break;
                    }
                    _anc = _anc.parentElement;
                }
                el.classList.toggle('imp-tb-flip', (el.getBoundingClientRect().top - _tbH - 8) < _clipTop);
            } catch (e) {}
        }
    }
    document.addEventListener('mouseover', function(e) {
        if (!e.target || !e.target.closest) return;
        var tb = e.target.closest('.impulse-el-toolbar');
        var el = tb ? tb.closest('.impulse-el') : e.target.closest('.impulse-el');
        setActive(el || null);
    });
    // Pointer leaves the iframe entirely — drop the lock.
    document.addEventListener('mouseleave', function() { setActive(null); });
})();

// Context menu — right-click sends context info to parent for menu rendering
document.addEventListener('contextmenu', function(e) {
    if (e.shiftKey) return; // Shift+right-click = browser default
    e.preventDefault();
    var target = e.target;
    // Match any of the row-bearing container cell types. rows + columns
    // carry row/col on data-attrs; masonry grid carries a single cell index
    // and the row is derived by the parent JS from the grid's columns setting.
    var cell = target.closest('.impulse-rows-cell, .impulse-grid-cell, .impulse-layout-cell');
    var el = target.closest('.impulse-el');
    var info = { type: 'impulse-context-menu', x: e.clientX, y: e.clientY };
    if (cell) {
        var grid = cell.closest('.impulse-el[data-type="rows"], .impulse-el[data-type="grid"], .impulse-el[data-type="columns"]');
        if (grid) {
            var gType = grid.dataset.type;
            if (gType === 'rows' || gType === 'columns') {
                info.cell = { gridId: grid.dataset.id, gridType: gType, row: parseInt(cell.dataset.row), col: parseInt(cell.dataset.col) };
            } else if (gType === 'grid') {
                info.cell = { gridId: grid.dataset.id, gridType: gType, cellIdx: parseInt(cell.dataset.cell) };
            }
        }
    }
    if (el) {
        // pushState: the parent menu needs to know whether this block is pinned
        // so it can show "Position: pushed to ..." + Reset instead of offering a
        // push that is already on. The parent has no settings for the block at
        // menu time (it only gets id/type here), so read it off the DOM: the
        // renderer emits data-push-top / data-push-bottom for exactly these
        // flags, and the button's legacy pin lands as an inline margin-top:auto.
        // Sergei 2026-07-28: "where is the button that resets push?".
        var _pinned = '';
        if (el.hasAttribute('data-push-top')) _pinned = 'top';
        else if (el.hasAttribute('data-push-bottom')) _pinned = 'bottom';
        else {
            var _pinInner = el.querySelector(':scope > .impulse-button-wrap');
            if (_pinInner && /margin-top:\s*auto/.test(_pinInner.getAttribute('style') || '')) _pinned = 'bottom';
        }
        /* A push moves EVERY sibling, so the block that visibly jumped is rarely
           the block carrying the flag - right-clicking the thing that moved
           offered "Push to..." and no way back (Sergei 2026-07-28, asked twice).
           Report the pinned SIBLING too, so the menu can offer its reset from
           wherever the user actually clicked. */
        var _pinSib = null;
        if (!_pinned) {
            // Right-clicking INSIDE the pinned block (a badge in a pushed grid)
            // resolves el to the child, which carries no flag - the flag lives on
            // an ANCESTOR. Check ancestors first, then any pinned block in the
            // same section: a push redistributes the whole column, so whichever
            // block the user clicked, the pinned one is why it moved
            // (Sergei 2026-07-29: Reset missing when clicking the pushed badges).
            var _pinEl = el.parentElement && el.parentElement.closest('.impulse-el[data-push-top], .impulse-el[data-push-bottom]');
            if (!_pinEl) {
                var _pinSec = el.closest('.impulse-el[data-type="section"]');
                if (_pinSec) {
                    var _cands = _pinSec.querySelectorAll('.impulse-el[data-push-top], .impulse-el[data-push-bottom]');
                    for (var _ci = 0; _ci < _cands.length; _ci++) {
                        if (_cands[_ci] === el || _cands[_ci].contains(el) || el.contains(_cands[_ci])) continue;
                        _pinEl = _cands[_ci];
                        break;
                    }
                }
            }
            if (_pinEl && _pinEl !== el) {
                _pinSib = {
                    id: _pinEl.dataset.id,
                    type: _pinEl.dataset.type,
                    where: _pinEl.hasAttribute('data-push-top') ? 'top' : 'bottom'
                };
            }
        }
        info.element = { id: el.dataset.id, type: el.dataset.type, hiddenMobile: el.hasAttribute('data-hide-mobile'), pushState: _pinned, pinnedSibling: _pinSib };
        var parentEl = el.parentElement ? el.parentElement.closest('.impulse-el') : null;
        if (parentEl) {
            info.parent = { id: parentEl.dataset.id, type: parentEl.dataset.type };
            // Grandparent — the container ABOVE the immediate parent. Powers the
            // "Move Out of X" action: a block trapped in a full-bleed grid/rows
            // cell has no droppable section area to drag onto, so we let the user
            // lift it one level up (into the section) from the context menu.
            var gpEl = parentEl.parentElement ? parentEl.parentElement.closest('.impulse-el') : null;
            if (gpEl) info.grandparent = { id: gpEl.dataset.id, type: gpEl.dataset.type };
        }
        // Select element
        if (!el.classList.contains('selected')) {
            document.querySelectorAll('.impulse-el.selected').forEach(function(x) { x.classList.remove('selected'); });
            el.classList.add('selected');
            window.parent.postMessage({ type: 'impulse-select', id: el.dataset.id, elementType: el.dataset.type }, '*');
        }
    }
    // Multi-selection: if the user shift-clicked 2+ sibling blocks and is
    // right-clicking one of them, hand the whole set to the parent so the
    // menu can offer "Wrap N in panel".
    if (window.__imMultiSel && window.__imMultiSel.length >= 2 && el && window.__imMultiSel.indexOf(el.dataset.id) !== -1) {
        info.multiSel = window.__imMultiSel.slice();
    }
    window.parent.postMessage(info, '*');
});
// Ctrl+Arrow canvas navigation: when the CANVAS has focus (the user just
// clicked a block), keydown lands in THIS document and the parent's shortcut
// handler never fires. Forward it (skip while typing - Ctrl+arrow is word-jump
// there). Parent runs impulseKbCanvasNav. Sergei 2026-07-23.
document.addEventListener('keydown', function(e) {
    if (!(e.ctrlKey || e.metaKey)) return;
    if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
    var t = e.target;
    if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
    e.preventDefault();
    if (window.parent) window.parent.postMessage({ type: 'impulse-kb-nav', down: e.key === 'ArrowDown' }, '*');
});

// Dismiss parent context menu on any left-click inside iframe
document.addEventListener('mousedown', function(e) {
    if (e.button === 0 && window.parent) {
        window.parent.postMessage({ type: 'impulse-dismiss-menu' }, '*');
    }
});

// ── Shift/Cmd-click multi-selection ───────────────────────────────────────
// Additive + opt-in: holding Shift (or Cmd/Ctrl) and clicking a block toggles
// it into a multi-selection SET, WITHOUT disturbing the normal single-click
// selection. Right-clicking any member then offers "Wrap N in panel" (parent
// builds the menu from the `multiSel` array shipped in the context-menu msg).
// Siblings-only: the first pick fixes the parent; picks from a different
// parent are ignored (wrapping across nesting levels is undefined). A plain
// (un-modified) click clears the set. Capture phase so we can suppress the
// default select before it runs. Sergei 2026-05-28.
window.__imMultiSel = [];
var __imMultiParent = null;
var __IM_MULTI_BLOCK_TYPES = ['section','rows','columns','grid','header','footer','slider','modal'];
function __imClearMultiSel() {
    if (!window.__imMultiSel.length) return;
    window.__imMultiSel = [];
    __imMultiParent = null;
    document.querySelectorAll('.impulse-el.impulse-multisel').forEach(function(x){ x.classList.remove('impulse-multisel'); });
    if (window.parent) window.parent.postMessage({ type: 'impulse-multi-select', ids: [] }, '*');
}
window.__imClearMultiSel = __imClearMultiSel;
document.addEventListener('click', function(e) {
    var add = e.shiftKey || e.metaKey || e.ctrlKey;
    if (!add) { __imClearMultiSel(); return; }   // plain click resets the set
    var el = e.target.closest && e.target.closest('.impulse-el');
    if (!el || __IM_MULTI_BLOCK_TYPES.indexOf(el.dataset.type) !== -1) return;
    e.preventDefault();
    e.stopPropagation();
    var parentWrap = el.parentElement ? el.parentElement.closest('.impulse-el') : null;
    var pid = parentWrap ? parentWrap.dataset.id : '__root__';
    var id  = el.dataset.id;
    if (!window.__imMultiSel.length) {
        __imMultiParent = pid;
        // Seed the set with the block that's ALREADY singly-selected (the
        // implicit anchor), so "click A, shift-click B" wraps BOTH — not just B.
        // Without this the first modifier-click only tracked the newly-clicked
        // block, so two visually-selected blocks reported a multi-set of one and
        // the right-click "Wrap N" options didn't appear until a THIRD block was
        // added (in the canvas AND the Structure panel, which share the set).
        // Same-parent sibling + non-container only. Sergei 2026-06-15.
        var __imAnchor = document.querySelector('.impulse-el.selected');
        if (__imAnchor && __imAnchor !== el && __imAnchor.dataset.id && __imAnchor.dataset.id !== id
            && __IM_MULTI_BLOCK_TYPES.indexOf(__imAnchor.dataset.type) === -1) {
            var __imAnchorPW = __imAnchor.parentElement ? __imAnchor.parentElement.closest('.impulse-el') : null;
            var __imAnchorPid = __imAnchorPW ? __imAnchorPW.dataset.id : '__root__';
            if (__imAnchorPid === pid) {
                window.__imMultiSel.push(__imAnchor.dataset.id);
                __imAnchor.classList.add('impulse-multisel');
            }
        }
    } else if (pid !== __imMultiParent) {
        return; // siblings only
    }
    var ix = window.__imMultiSel.indexOf(id);
    if (ix === -1) { window.__imMultiSel.push(id); el.classList.add('impulse-multisel'); }
    else           { window.__imMultiSel.splice(ix, 1); el.classList.remove('impulse-multisel'); if (!window.__imMultiSel.length) __imMultiParent = null; }
    if (window.parent) window.parent.postMessage({ type: 'impulse-multi-select', ids: window.__imMultiSel.slice() }, '*');
}, true);
// Parent → iframe: apply a multi-selection chosen in the Structure panel onto
// the canvas blocks, so both surfaces show the same set and the canvas
// right-click menu's `multiSel` stays in sync. Sergei 2026-05-28.

// Crop overlay engine (corner / node / tangent handles + path math) was
// extracted to assets/js/impulse-crop-overlay.js in v2.1.1273 — loaded via
// the `instinctor-crop-overlay` asset handle in the enqueue list below.
// Same iframe context, same impulse-image-crop-* message protocol; the
// only difference is the engine now lives in its own file so the video
// block can wire it up too.

// Toolbar counter-scale: the editor publishes the canvas fit scale
// (impulse-canvas-scale, applyTransform in admin-core.js) whenever screen-mode
// fit/zoom changes. Boost the element toolbars by 1/scale (clamped 1..3) so
// their buttons keep a constant ~28 PHYSICAL px on any monitor - at fit 0.55
// the 22px-era buttons rendered ~12 real px and were unhittable. 2026-07-18.
function _impSetTbBoost(scale) {
    var s = parseFloat(scale);
    if (!s || s <= 0) s = 1;
    var boost = Math.max(1, Math.min(3, 1 / s));
    document.documentElement.style.setProperty('--impulse-tb-boost', boost.toFixed(3));
    // Precomputed px twin: outline-width refuses calc(px * var) in Chrome,
    // so cell-guide widths consume this ready-made length instead.
    document.documentElement.style.setProperty('--impulse-tb-px', boost.toFixed(2) + 'px');
}
window.addEventListener('message', function(e) {
    if (e.data && e.data.type === 'impulse-canvas-scale') _impSetTbBoost(e.data.scale);
});
// Seed from the editor's last-published value in case the first message
// fired before this listener existed (iframe reload after a fit was set).
if (window._impulseEditorScale) _impSetTbBoost(window._impulseEditorScale);
window.addEventListener('message', function(e) {
    if (!e.data || e.data.type !== 'impulse-multisel-apply') return;
    var ids = Array.isArray(e.data.ids) ? e.data.ids.slice() : [];
    window.__imMultiSel = ids;
    document.querySelectorAll('.impulse-el.impulse-multisel').forEach(function(x){ x.classList.remove('impulse-multisel'); });
    ids.forEach(function(mid){
        var mel = document.querySelector('.impulse-el[data-id="' + mid + '"]');
        if (mel) mel.classList.add('impulse-multisel');
    });
});
// Dismiss parent context menu on scroll inside iframe
document.addEventListener('scroll', function() {
    if (window.parent) {
        window.parent.postMessage({ type: 'impulse-dismiss-menu' }, '*');
    }
}, true);

/* Animator/heading alignment tracer removed in 2.1.63.
   Root cause was the element-config max_width selector mismatch (fixed
   in 2.1.60) plus the line-height inconsistency between editor preview
   (1.3) and frontend (1.1) renderers (locked in 2.1.62). Final verification
   trace showed heading rest position stable at the same x,y across three
   consecutive color edits — no left-jump, no down-jump. */
</script>
<?php
// Editor preview iframe — same renderer stack as the live frontend.
// Emit immediately so subsequent inline scripts in the body can rely on
// engine globals (Impulse3D, ImpulseFX, etc.) being defined.
$_iframe_handles = [
    'instinctor-frontend',
    'instinctor-frontend-js',
    'instinctor-animations',
    'instinctor-motion-lava',
    'instinctor-three',
    'instinctor-wave',
    'instinctor-3d-effects',
    'instinctor-fx-engine',
    'instinctor-timeline',
    'instinctor-gl-transitions',
    'instinctor-3d-text',
    'instinctor-kinetic',
    'instinctor-depth-parallax',
    'instinctor-depth-mesh',
    'instinctor-crop-overlay',
    'instinctor-section-fx',
    'instinctor-overlay-fx',
    'instinctor-textwall',
    'instinctor-countdown',
    // Mesh background CSS. On the frontend this is enqueued lazily, only
    // when a section actually renders a mesh (section.php singleton gate).
    // The editor must load it UNCONDITIONALLY: a designer can turn a solid
    // section into a mesh on the fly (fill swatch 2), and the live-update /
    // refresh injects .impulse-mesh markup with no chance to retro-enqueue
    // the stylesheet. Without it the mesh has no position:absolute/inset:0,
    // collapses to 0 height, and the "gradient" silently renders nothing
    // (reads as a white, unchangeable section). v2.1.1955.
    'instinctor-mesh',
    'instinctor-feature-parallax',
    'instinctor-icon-list-hover',
    'instinctor-cell-depth',
    'instinctor-heading-tilt',
    'instinctor-interactions',
    // Screen reveals live here. It was enqueued for the FRONTEND only
    // (templates/impulse-page.php), so enabling a reveal in the editor did
    // nothing until a reload. Bails out early on classic pages by its own
    // guard, so adding it costs nothing there.
    'instinctor-screens',
    'instinctor-bg-parallax',
    'instinctor-sortable',
    'instinctor-configurator',
    'instinctor-frameloop',
    'instinctor-network',
    'instinctor-network-css',
    'instinctor-marquee-css',
    'instinctor-page-inline',
];
\Instinctor\Core\Assets::enqueueMany($_iframe_handles);
// Defer instinctor-frontend-js to a LATE emit (just before wp_footer, after the whole body
// renders) so per-element inline scripts attached to it during rendering — the neon divider
// glow, hero sandbox, charts, booking, grid-fit — are captured. It has no deps and nothing
// depends on it (footer=true), so deferring is safe; emitting it here, before the body,
// silently dropped every one of those inline scripts (regression from ac344ed). v2.1.1992.
$_iframe_handles_early = array_values(array_diff($_iframe_handles, ['instinctor-frontend-js']));
if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
    if (function_exists('_instinctor_emit_script')) {
        foreach ($_iframe_handles_early as $_h) {
            _instinctor_emit_script($_h, false);
            _instinctor_emit_script($_h, true);
        }
    }
} elseif (function_exists('wp_print_scripts')) {
    wp_print_scripts($_iframe_handles_early);
}
?>

<?php
// Load Google Maps API if configured. Registered + enqueued through WordPress
// (not a raw inline <script src>) so it is managed like any other dependency; the
// impulseMapsCallback fan-out is attached as a "before" inline script so it exists
// when the Maps API invokes it.
$maps_api_key = get_option('instinctor_maps_api_key', '');
if ($maps_api_key) {
    $maps_src = 'https://maps.googleapis.com/maps/api/js?key=' . rawurlencode($maps_api_key) . '&callback=impulseMapsCallback';
    $maps_cb  = 'function impulseMapsCallback(){if(window.impulseMapCallbacks){window.impulseMapCallbacks.forEach(function(cb){cb();});}}';
    if (function_exists('wp_print_scripts')) {
        wp_register_script('instinctor-google-maps', $maps_src, array(), INSTINCTOR_VERSION, true);
        wp_add_inline_script('instinctor-google-maps', $maps_cb, 'before');
        wp_enqueue_script('instinctor-google-maps');
        wp_print_scripts('instinctor-google-maps');
    } else {
        
    }
}
?>
<?php $isThumbnail = isset($_GET['thumbnail']); ?>
<?php $isEditor = isset($_GET['edit']); ?>
<?php if ($isThumbnail || $isEditor): ?>
<?php
\Instinctor\Core\Assets::enqueue('instinctor-html2canvas');
if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
    if (function_exists('_instinctor_emit_script')) _instinctor_emit_script('instinctor-html2canvas', true);
} elseif (function_exists('wp_print_scripts')) {
    wp_print_scripts(['instinctor-html2canvas']);
}
?>
<?php endif; ?>
<style id="impulse-iframe-css">
*{box-sizing:border-box;margin:0;padding:0}
*:focus,*:focus-visible,*:focus-within{outline:none!important;box-shadow:none!important}
button:focus,button:focus-visible,input:focus,input:focus-visible,select:focus,select:focus-visible,textarea:focus,textarea:focus-visible{outline:none!important;box-shadow:none!important}
html,body{scrollbar-width:none;-ms-overflow-style:none}html{background:<?php echo esc_attr($pageBgColor); ?>}
html::-webkit-scrollbar,body::-webkit-scrollbar{display:none;width:0;height:0}
body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:1.6;color:#1a1a1a;background:<?php echo esc_attr($pageBgColor ?: '#ffffff'); ?>;min-height:100vh;overflow-x:hidden;--impulse-content-max-width:<?php echo intval($pageSettings['max_content_width'] ?? 1440); ?>px;caret-color:transparent;display:flex;flex-direction:column}
input:-webkit-autofill,input:-webkit-autofill:hover,input:-webkit-autofill:focus,textarea:-webkit-autofill{-webkit-box-shadow:0 0 0 1000px #fff inset!important;-webkit-text-fill-color:#1d2327!important;transition:background-color 5000s ease 0s}
body > *{flex-shrink:0}
<?php if (!$isThumbnail): ?>
/* Editor scroll room so last block can be dragged/scrolled to center */
/* Disabled when page has a footer — footer should sit at the bottom */
/* margin-bottom:50vh removed — last-section lift handles scroll centering */
<?php endif; ?>
#wpadminbar,html.wp-toolbar{display:none!important;padding-top:0!important}
body.admin-bar{margin-top:0!important}
img{max-width:100%;height:auto}

<?php if ($isThumbnail): ?>
/* Thumbnail mode: hide interactive elements and any fixed widgets */
.impulse-el-toolbar,
#instinctor-chat-widget,.instinctor-chat-widget,.instinctor-chat-button,
[style*="position: fixed"],
[style*="position:fixed"],
.chat-widget,.chat-bubble,.chat-icon,.live-chat,
.instinctor-chat,.instinctor-chat-widget,
#instinctor-chat,#instinctor-chat-widget,
.back-to-top,.scroll-to-top,.totop,.impulse-back-to-top,
.floating-button,.fab,.floating-action,
.cookie-notice,.cookie-banner,
.popup,.modal-overlay,
div[class*="chat"],div[class*="widget"][style*="fixed"],
div[id*="chat"],div[id*="widget"],
iframe[src*="chat"],iframe[src*="widget"],
.impulse-dropzone,
.fa-comment,.fa-comments,.fa-message,
[class*="messenger"],[id*="messenger"],
[class*="intercom"],[id*="intercom"],
[class*="crisp"],[id*="crisp"],
[class*="tawk"],[id*="tawk"],
[class*="zendesk"],[id*="zendesk"],
[class*="drift"],[id*="drift"],
.instinctor-purchase-notification{display:none!important;visibility:hidden!important}
body{pointer-events:none}
.impulse-el{cursor:default}
.impulse-el:hover{background:transparent}
/* Stack nav buttons must be interactive in editor */
.impulse-acc-stack-nav{pointer-events:auto}
.impulse-acc-stack-nav button,.impulse-acc-stack-num{pointer-events:auto;cursor:pointer}
<?php endif; ?>
<?php if (empty($GLOBALS['impulse_is_edit_mode'])): ?>
/* Non-edit mode: hide popup/off-canvas (they need JS triggers to show) */
.impulse-el[data-type="modal"],.impulse-el[data-type="modal"]{display:none!important}
<?php endif; ?>

/* Hide WordPress/theme injected elements */
#query-monitor-main,#debug-bar,.query-monitor,.debug-bar-php-warnings,#qm,.qm-button,
[id^="wp-"],#wp-accessibility-toolbar,.screen-reader-text,.skip-link,
.jetpack-lazy-image,.site-footer,.site-header,#site-header,#site-footer,
.ab-icon,.ab-item,.ab-submenu,.ab-label,.screen-reader-shortcut,
[style*="position: fixed"][style*="z-index: 9999"],[style*="position:fixed"][style*="z-index:9999"],
#cookie-notice,.cookie-notice,.cookie-banner,.cookie-consent,
.wp-block-spacer,.wp-embed-responsive{display:none!important}

/* Element base */
.impulse-el{position:relative;transition:outline 0.15s;cursor:default;flex-shrink:0;border:none!important;box-shadow:none;outline:0 solid transparent;--impulse-bg-color:initial;--impulse-text-color:initial;--impulse-color:initial;--impulse-border-color:initial;--impulse-border-radius:initial;--impulse-border-width:initial;--impulse-font-size:initial;--impulse-size:initial;--impulse-weight:initial;--impulse-line-height:initial;--impulse-letter-spacing:initial;--impulse-padding-x:initial;--impulse-padding-y:initial;--impulse-padding-top:initial;--impulse-padding-right:initial;--impulse-padding-bottom:initial;--impulse-padding-left:initial;--impulse-gap:initial;--impulse-width:initial;--impulse-height:initial;--impulse-radius:initial;--impulse-opacity:initial;--impulse-icon-spacing:initial;--impulse-icon-size:initial;--impulse-icon-color:initial;--impulse-icon-bg:initial;--impulse-icon-border-color:initial;--impulse-title-size:initial;--impulse-text-size:initial;--impulse-subtitle-size:initial;--impulse-label-size:initial;--impulse-number-size:initial;--impulse-number-color:initial;--impulse-label-color:initial;--impulse-icon-border-width:initial;--impulse-icon-padding:initial;--impulse-spacing:initial;--impulse-thickness:initial}
.impulse-el:focus,.impulse-el:focus-visible,.impulse-el[draggable]:focus,.impulse-el[draggable]:focus-visible{outline:none!important;border:none!important;box-shadow:none!important}
.impulse-el *{-webkit-user-drag:none;user-drag:none}
/* Only structural containers show selection outline */
.impulse-el[data-type="section"].selected{outline:1px solid #999;outline-offset:-1px}
/* Multi-selection (Shift/Cmd-click) — distinct from single .selected so the
   user can see which blocks will be wrapped. Greyscale only. */
.impulse-el.impulse-multisel{outline:2px solid #999 !important;outline-offset:-2px;background-color:rgba(153,153,153,0.10)}
/* Shrink-to-content `.impulse-el-fit` (v2.1.1318-1320) reverted in
   v2.1.1321 — broke video block's percentage-width inner wraps in
   screen-mode editor. */
/* Content blocks - no outline by default, show on hover */
.impulse-el:not([data-type="section"]):not([data-type="header"]):not([data-type="footer"]):not([data-type="grid"]):not([data-type="rows"]):not([data-type="columns"]):not([data-type="modal"]):not([data-type="modal"]):not([data-type="divider"]){outline:0 solid transparent}
.impulse-el:not([data-type="section"]):not([data-type="header"]):not([data-type="footer"]):not([data-type="grid"]):not([data-type="rows"]):not([data-type="columns"]):not([data-type="modal"]):not([data-type="modal"]):not([data-type="divider"]):hover:not(:has(.impulse-el:hover)){outline:1px solid #ccc;outline-offset:-1px}
.impulse-el:not([data-type="section"]):not([data-type="header"]):not([data-type="footer"]):not([data-type="grid"]):not([data-type="rows"]):not([data-type="columns"]):not([data-type="modal"]):not([data-type="modal"]):not([data-type="divider"]).selected{outline:1px solid #999;outline-offset:-1px}
/* Selection pulse retired — drops should appear cleanly, no scale animation. */
/* Container types - never show outer outline/border */
.impulse-el[data-type="divider"]:hover,.impulse-el[data-type="divider"].selected{outline:none!important;border:none!important;box-shadow:none!important}
/* The .impulse-el wrapper must claim full cell width unconditionally so the
   divider's percentage-based `width` resolves and its own margin-auto centring
   works. Mirrors the heading rule (line 13268) — same problem, same shape:
   without these !important flags the wrapper shrinks-to-fit inside flex cells,
   the divider collapses to its inner-line natural width, and the divider
   "jumps" between centered (when wrapper happens to be 100%) and left-pinned
   (after a click triggers a flex layout pass that re-applies shrink-to-fit). */
.impulse-el[data-type="divider"]{padding:20px 0;cursor:pointer;width:100%!important;max-width:100%!important;align-self:stretch!important;flex-shrink:1!important;min-width:0!important;box-sizing:border-box!important}
.impulse-placeholder-img{opacity:0.1}
.impulse-el[data-type="table"]:has([contenteditable]:focus) .impulse-el-toolbar{opacity:0!important;pointer-events:none!important}
.impulse-el[data-type="grid"],.impulse-el[data-type="rows"],.impulse-el[data-type="columns"]{outline:0 solid transparent!important;border:none!important;box-shadow:none!important;overflow:visible;position:relative}
.impulse-el[data-type="grid"].selected,.impulse-el[data-type="rows"].selected,.impulse-el[data-type="columns"].selected{outline:0 solid transparent!important;border:none!important;box-shadow:none!important}
/* Grid cell selection styling */
.impulse-el[data-type="grid"].editing,
.impulse-el[data-type="rows"].editing,
.impulse-el[data-type="grid"].impulse-dragging,
.impulse-el[data-type="rows"].impulse-dragging{outline:none!important;border:none!important;box-shadow:none!important}
.impulse-grid-cell.selected{outline:1px solid #999!important;outline-offset:-1px}
.impulse-grid-cell{cursor:pointer;transition:border-color 0.15s,background 0.15s}
.impulse-grid-cell.impulse-grid-cell-empty,
.impulse-rows-cell.impulse-rows-cell-empty,
.impulse-layout-cell.impulse-layout-cell-empty{background:rgba(128,128,128,0.04)}
.impulse-grid-cell:hover{border-color:#999!important}
.impulse-grid-cell.drop-target{outline:1px solid rgba(153,153,153,0.6)!important;outline-offset:-1px;background:transparent!important}
.impulse-grid-cell.drop-target .impulse-cell-empty-indicator{color:rgba(130,130,130,0.6)!important}

/* Hide empty indicators during internal reorder - just show the cell highlight */
body.impulse-reordering .impulse-cell-empty-indicator{opacity:0.5}
/* Plus indicator color matches the L-bracket hover colour for a unified idle look. */
.impulse-grid-cell .impulse-cell-empty-indicator,.impulse-rows-cell .impulse-cell-empty-indicator,.impulse-layout-cell .impulse-cell-empty-indicator{color:rgba(130,130,130,0.35)!important}
/* cell-empty-indicator-hide-when-filled — when a cell has actual content
   (any .impulse-el child), the + placeholder must vanish. */
.impulse-grid-cell:has(.impulse-el) .impulse-cell-empty-indicator,
.impulse-rows-cell:has(.impulse-cell-content > .impulse-el) .impulse-cell-empty-indicator,
.impulse-rows-cell:has(.impulse-el) .impulse-cell-empty-indicator,
.impulse-layout-cell:has(.impulse-cell-content > .impulse-el) .impulse-cell-empty-indicator,
.impulse-layout-cell:has(.impulse-el) .impulse-cell-empty-indicator{
  display:none !important
}
/* cell-empty-tint-hide-when-filled — same contract as the indicator above.
   All three renderers now mark empty cells with a dedicated class:
   `.impulse-grid-cell-empty` / `.impulse-rows-cell-empty` /
   `.impulse-layout-cell-empty`. The tint paints via CSS only on those
   classed cells, so when a cell gains a `.impulse-el` descendant (via
   live drop, refresh, or undo / redo) the tint must vanish — even if
   the class wasn't stripped by the JS drop-handler. Targeting the
   empty-class + :has() means user-set per-cell backgrounds (cell_bg_X
   in Grid, cell-data background in Rows/Columns) are NEVER overridden,
   since those cells never carry the empty marker class. Locked
   2026-05-27. */
.impulse-grid-cell-empty:has(.impulse-el),
.impulse-rows-cell-empty:has(.impulse-cell-content > .impulse-el),
.impulse-rows-cell-empty:has(.impulse-el),
.impulse-layout-cell-empty:has(.impulse-cell-content > .impulse-el),
.impulse-layout-cell-empty:has(.impulse-el){
  background:transparent !important
}

/* brackets-suppress-when-filled — L-brackets only make sense on empty cells.
   The moment a cell has content, nuke both pseudos so nothing peeks through.
   Gated by body:not(.impulse-show-cell-borders) so the cell-guides toggle
   can re-purpose the same ::before for its dashed inset border on filled
   cells too — without this guard, the bracket-suppress rule's !important
   was killing the toggle's pseudo regardless of body class.
   2026-05-23: also exclude .impulse-cell-grad-border — the gradient-ring
   border the user configured uses the same ::before pseudo (inset:0 +
   mask exclude) as the bracket. Without :not(.impulse-cell-grad-border)
   this rule would display:none the gradient ring on every populated
   cell, so cells with a configured gradient border rendered with no
   visible border in the editor while the frontend (no editor CSS)
   showed it correctly. */
body:not(.impulse-show-cell-borders) .impulse-grid-cell:not(.impulse-cell-grad-border):has(.impulse-el)::before,
body:not(.impulse-show-cell-borders) .impulse-grid-cell:not(.impulse-cell-grad-border):has(.impulse-el)::after,
body:not(.impulse-show-cell-borders) .impulse-rows-cell:not(.impulse-cell-grad-border):has(.impulse-cell-content > .impulse-el)::before,
body:not(.impulse-show-cell-borders) .impulse-rows-cell:not(.impulse-cell-grad-border):has(.impulse-cell-content > .impulse-el)::after,
body:not(.impulse-show-cell-borders) .impulse-rows-cell:not(.impulse-cell-grad-border):has(.impulse-el)::before,
body:not(.impulse-show-cell-borders) .impulse-rows-cell:not(.impulse-cell-grad-border):has(.impulse-el)::after,
body:not(.impulse-show-cell-borders) .impulse-layout-cell:not(.impulse-cell-grad-border):has(.impulse-cell-content > .impulse-el)::before,
body:not(.impulse-show-cell-borders) .impulse-layout-cell:not(.impulse-cell-grad-border):has(.impulse-cell-content > .impulse-el)::after,
body:not(.impulse-show-cell-borders) .impulse-layout-cell:not(.impulse-cell-grad-border):has(.impulse-el)::before,
body:not(.impulse-show-cell-borders) .impulse-layout-cell:not(.impulse-cell-grad-border):has(.impulse-el)::after{
  display:none !important
}
/* cell-empty-indicator-center v4 — force flex-center ONLY on empty cells
   (so the + indicator lands dead-center). Populated cells must respect the
   inline alignment from `cell_halign_<i>` / `cell_valign_<i>`, otherwise the
   per-cell alignment buttons appear to do nothing.
   `.impulse-grid-cell-empty` is added by the renderer when the cell has no
   content; populated cells have an `.impulse-el` child. */
/* The CLASS is a server-render snapshot: it stays on the cell after a block is
   dropped in, until the next full re-render. With the class alone in this
   selector, dropped content inherited the centre-the-plus rule and landed
   mid-cell regardless of the top default, then "unstuck" after any edit that
   re-rendered the grid (Sergei 2026-08-03: "requires a few clicks here and
   there to align"). Pairing the class with :not(:has(.impulse-el)) makes the
   rule stop applying the instant a child exists, with no re-render needed. */
.impulse-grid-cell.impulse-grid-cell-empty:not(:has(.impulse-el)),
.impulse-grid-cell:not(:has(.impulse-el)),
.impulse-layout-cell:not(:has(.impulse-el)){
  display:flex!important;align-items:center!important;justify-content:center!important;position:relative
}
.impulse-grid-cell:not(.impulse-grid-cell-empty){
  position:relative
}
/* EMPTY cells only: center the "+" placeholder. Cells WITH content must respect
   their valign/halign (top by default). The old unscoped rule forced
   justify-content:center!important on ALL cell content, overriding the server's
   top inline -- so content centered on load and only "unstuck" after toggling the
   align control (align-bottom then align-top). Scoped with :not(:has(.impulse-el)).
   Sergei 2026-07-09. */
.impulse-rows-cell .impulse-cell-content:not(:has(> .impulse-el)),
.impulse-layout-cell .impulse-cell-content:not(:has(> .impulse-el)){
  display:flex!important;align-items:center!important;justify-content:center!important;text-align:center
}
/* Belt-and-braces: explicit centering on the indicator itself regardless of parent */
.impulse-grid-cell > .impulse-cell-empty-indicator,
.impulse-rows-cell .impulse-cell-empty-indicator,
.impulse-layout-cell .impulse-cell-empty-indicator{
  margin:auto;text-align:center;width:auto;display:block
}
/* Cell-chrome visibility crank + counter-scale (Sergei 2026-07-19, laptop
   report): on a scaled screen-mode canvas the 1px guides fell below one
   physical pixel (cells read as invisible) and the 20px "+" shrank to ~10
   real px. --impulse-tb-boost = clamp(1/fitScale,1..3), maintained by the
   impulse-canvas-scale listener, holds these at constant physical size;
   base contrast also raised a notch. Empty cells get an always-on guide
   outline (editor chrome only - filled cells keep user styling). */
.impulse-grid-cell .impulse-cell-empty-indicator,
.impulse-rows-cell .impulse-cell-empty-indicator,
.impulse-layout-cell .impulse-cell-empty-indicator{
  font-size:calc(26px * var(--impulse-tb-boost,1))!important;
  color:rgba(120,120,120,0.55)!important;
  line-height:1
}
.impulse-grid-cell.impulse-grid-cell-empty,
.impulse-grid-cell:not(:has(.impulse-el)):not(.impulse-cell-user-bg),
.impulse-rows-cell.impulse-rows-cell-empty,
.impulse-layout-cell.impulse-layout-cell-empty{
  background:rgba(128,128,128,0.09);
  /* Fixed 2px: Chrome refuses var()/calc() in outline-width (falls back to
     1px), so no counter-scaling here - 2px reads ~1 physical px on a half-
     scale canvas and stays subtle at full scale. Editor chrome only. */
  outline-style:dashed;
  outline-color:rgba(130,130,130,0.4);
  outline-width:2px;
  outline-offset:-2px
}
/* Chrome truth from the LIVE DOM (Sergei 2026-07-20): the -empty class is
   computed at render time, so a cell emptied client-side (block deleted /
   dragged out) lost ALL its chrome - no tint, no dashes, no + - and read
   as blank page space. The :has()-based selector above keys on actual
   content; .impulse-cell-user-bg (static, settings-driven) exempts cells
   whose color fill is user styling. Filled cells drop the chrome even if
   the stale -empty class is still present: */
.impulse-grid-cell.impulse-grid-cell-empty:has(.impulse-el){
  background:transparent;outline-style:none
}
/* Drag-over affordance lives in the unified DRAG MODE block further down
   (single source of truth for drag visuals - rules here lose the cascade
   to it). See "you never know if the block you're dragging will fall into
   the cell", Sergei 2026-07-20. */
.impulse-grid-cell.selected,
.impulse-rows-cell.selected{
  outline-width:2px!important
}
.impulse-grid-cell.drop-target{
  outline-width:2px!important
}
/* When a cell is selected, show very light borders on ALL cells in the grid */
.impulse-rows-cell.selected{outline:1px solid #ccc!important;outline-offset:-1px}
.impulse-el[data-type="rows"]:has(.impulse-rows-cell.selected) .impulse-rows-cell{outline:1px solid #ddd;outline-offset:-1px}
.impulse-rows-cell{cursor:pointer;transition:outline 0.15s, background-color 0.15s;outline:0 solid transparent}
/* When rows element is selected, show borders on all cells */
.impulse-el[data-type="rows"].selected .impulse-rows-cell{outline:1px solid #ddd;outline-offset:-1px}
.impulse-el[data-type="rows"].selected .impulse-rows-cell:hover{outline:1px solid rgba(128,128,128,0.6);outline-offset:-1px;background-color:rgba(128,128,128,0.05)}
.impulse-rows-cell:hover{outline:1px solid #ccc;outline-offset:-1px}
/* Populated cells stay invisible — once a cell has any element inside it,
   the editor-noise (hover outline, drop-target hints, the entire visual
   chrome) suppresses. A configured per-cell background is NOT editor noise
   — it is real, user-set content — so it is deliberately preserved here.
   Only EMPTY cells get the affordances
   that help the user find drop targets. The element inside a populated cell
   is what gets selected/edited, never the cell itself.
   EXCEPT during drag: a populated cell that's been marked .drop-target by
   the JS dropzone resolver MUST still show its outline so the user can see
   the drop is being accepted — :not(.drop-target) keeps the suppression off
   it during the drag-over moment. */
.impulse-grid-cell:has(.impulse-el):not(.drop-target),
.impulse-grid-cell:has(.impulse-el):hover:not(.drop-target),
.impulse-rows-cell:has(.impulse-el):not(.drop-target),
.impulse-rows-cell:has(.impulse-el):hover:not(.drop-target),
.impulse-rows-cell:has(.impulse-cell-content > .impulse-el):not(.drop-target),
.impulse-rows-cell:has(.impulse-cell-content > .impulse-el):hover:not(.drop-target),
.impulse-layout-cell:has(.impulse-el):not(.drop-target),
.impulse-layout-cell:has(.impulse-el):hover:not(.drop-target),
.impulse-layout-cell:has(.impulse-cell-content > .impulse-el):not(.drop-target),
.impulse-layout-cell:has(.impulse-cell-content > .impulse-el):hover:not(.drop-target){
  outline:none !important;
}
/* During drag operations the populated-cell suppression still wins —
   we don't want a cell that already has content to look like a drop
   target. Empty cells continue to highlight via the rules below. */
body.impulse-reordering .impulse-grid-cell:has(.impulse-el),
body.impulse-reordering .impulse-rows-cell:has(.impulse-el),
body.impulse-reordering .impulse-rows-cell:has(.impulse-cell-content > .impulse-el),
body.impulse-reordering .impulse-layout-cell:has(.impulse-el),
body.impulse-reordering .impulse-layout-cell:has(.impulse-cell-content > .impulse-el){
  outline:none !important;
  background-color:transparent !important;
}
/* Cell toolbar - shown on selected cell, positioned above */
/* Cell toolbar removed — actions moved to right-click context menu */
.impulse-rows-cell.selected{overflow:visible!important}
/* During drag, show all rows cells as potential drop targets with light borders */
body.impulse-reordering .impulse-rows-cell{outline:1px solid rgba(153,153,153,0.25);outline-offset:-1px}
body.impulse-reordering .impulse-rows-cell:hover{outline:1px solid rgba(153,153,153,0.6);outline-offset:-1px;background-color:transparent}
.impulse-rows-cell.drop-target{outline:1px solid rgba(153,153,153,0.6)!important;outline-offset:-1px;background-color:transparent!important}
/* Cell content must be constrained to cell height */
.impulse-cell-content{min-height:0!important}
/* Images inside cell grid should respect row height */
.impulse-el[data-type="image"]{display:flex;justify-content:center;align-items:flex-start}.impulse-el[data-type="image"]>.impulse-image-wrapper{flex-shrink:0}.impulse-cell-content > .impulse-el[data-type="image"]{flex:1;min-height:0;max-height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;width:auto!important;align-self:auto!important}.impulse-cell-content > .impulse-el[data-type="image"]:has([data-hover="slices"]),.impulse-cell-content > .impulse-el[data-type="image"]:has([data-hover="magnetic"]){overflow:visible!important}.impulse-rows-cell:has([data-hover="slices"]),.impulse-rows-cell:has([data-hover="magnetic"]),.impulse-layout-cell:has([data-hover="slices"]),.impulse-layout-cell:has([data-hover="magnetic"]){overflow:visible!important}
.impulse-cell-content > .impulse-el[data-type="image"] img,
.impulse-cell-content > .impulse-el[data-type="image"] .impulse-image-placeholder{max-height:100%;max-width:100%;width:auto;height:auto;object-fit:contain;min-height:0!important}
.impulse-rows-col-divider{background:transparent;transition:background 0.15s}
.impulse-rows-col-divider:hover,.impulse-rows-col-divider.dragging{background:rgba(0,0,0,0.3)}
.impulse-rows-col-divider::before{content:'';position:absolute;top:0;bottom:0;left:3px;width:2px;background:#666;opacity:0;transition:opacity 0.15s}
.impulse-rows-col-divider:hover::before,.impulse-rows-col-divider.dragging::before{opacity:1}
/* Layout Grid */
.impulse-layout-grid{display:grid}
.impulse-el[data-type="columns"].selected{outline:none!important}
.impulse-layout-col{display:flex;flex-direction:column}
.impulse-layout-cell{position:relative;flex:1;display:flex;flex-direction:column}
.impulse-layout-cell.selected{outline:1px solid #ccc!important;outline-offset:-1px}
.impulse-el[data-type="columns"]:has(.impulse-layout-cell.selected) .impulse-layout-cell{outline:1px solid #ddd;outline-offset:-1px}
.impulse-layout-cell{cursor:pointer;transition:outline 0.15s, background-color 0.15s;outline:0 solid transparent}
.impulse-el[data-type="columns"].selected .impulse-layout-cell{outline:1px solid #ddd;outline-offset:-1px}
.impulse-el[data-type="columns"].selected .impulse-layout-cell:hover{outline:1px solid rgba(128,128,128,0.6);outline-offset:-1px;background-color:rgba(128,128,128,0.05)}
.impulse-layout-cell:hover{outline:1px solid #ccc;outline-offset:-1px}
body.impulse-reordering .impulse-layout-cell{outline:1px solid rgba(153,153,153,0.25);outline-offset:-1px}
body.impulse-reordering .impulse-layout-cell:hover{outline:1px solid rgba(153,153,153,0.6);outline-offset:-1px;background-color:transparent}
/* unified-cell-hover v7 — camera/AR viewfinder corner brackets + 3D bend.
   Four L-shaped brackets fade in at the cell's corners when hovered.
   No shadow, no border, no full outline — pure HUD energy. */
.impulse-grid-cell,.impulse-rows-cell,.impulse-layout-cell{
  transition:transform 320ms cubic-bezier(0.4, 0, 0.2, 1),
             background-color 240ms ease;
  position:relative;z-index:0
}
.impulse-grid-cell:hover:not(.drop-target),
.impulse-rows-cell:hover:not(.drop-target),
.impulse-layout-cell:hover:not(.drop-target){
  /* Hover feedback comes from the corner-bracket pseudos below — we do NOT
     touch background-color or border-color here. A configured cell_bg/border
     is real user content; the previous rule clobbered it with !important. */
  outline:none!important;box-shadow:none!important;
  z-index:4
}
/* Cell hover-zoom (transform:scale) was removed — the corner brackets below
   already mark the hovered cell, the scale was distracting on cells with
   content. The :has() carve-out for populated cells was unreliable in some
   DOM shapes anyway, so retiring the whole transform path is cleaner. */
/* Corner bracket system — four ::before/::after pseudos across two layers.
   Uses linear-gradient backgrounds with hard color stops to paint only the
   L-shaped arm in each corner. No element math — each corner is a 14x14 box
   with two 2px stripes. Brackets carry both light + dark so they register
   on any background. */
.impulse-grid-cell::before,.impulse-rows-cell::before,.impulse-layout-cell::before,
.impulse-grid-cell::after,.impulse-rows-cell::after,.impulse-layout-cell::after{
  content:'';position:absolute;width:14px;height:14px;
  opacity:0;pointer-events:none;z-index:5;
  transition:opacity 220ms cubic-bezier(0.4, 0, 0.2, 1),
             transform 320ms cubic-bezier(0.4, 0, 0.2, 1)
}
/* Top-left bracket: L-shape via border-top + border-left, background transparent. */
.impulse-grid-cell::before,.impulse-rows-cell::before,.impulse-layout-cell::before{
  top:4px;left:4px;background:transparent;
  border-top:1.5px solid rgba(130,130,130,0.35);
  border-left:1.5px solid rgba(130,130,130,0.35);
  transform:translate(-3px,-3px)
}
/* Bottom-right bracket: L-shape via border-bottom + border-right. */
.impulse-grid-cell::after,.impulse-rows-cell::after,.impulse-layout-cell::after{
  bottom:4px;right:4px;background:transparent;
  border-bottom:1.5px solid rgba(130,130,130,0.35);
  border-right:1.5px solid rgba(130,130,130,0.35);
  transform:translate(3px,3px)
}
/* Activate all four on hover — opacity up, translate to natural position */
.impulse-grid-cell:hover::before,.impulse-rows-cell:hover::before,.impulse-layout-cell:hover::before{
  opacity:1;transform:translate(0,0)
}
.impulse-grid-cell:hover::after,.impulse-rows-cell:hover::after,.impulse-layout-cell:hover::after{
  opacity:1;transform:translate(0,0)
}
/* drag-over bracket reveal retired — during drag, the unified grey outline
   (see body.drag-active / body.impulse-reordering rules above) is the only
   visual. Brackets were competing with the outline and causing the "flash". */
.impulse-grid-cell.drop-target,.impulse-rows-cell.drop-target,.impulse-layout-cell.drop-target{
  background-color:transparent !important
}
/* Bracket colour adapts to background — brighter on dark, darker on light */
.impulse-grid-cell:hover::before,.impulse-grid-cell:hover::after,
.impulse-rows-cell:hover::before,.impulse-rows-cell:hover::after,
.impulse-layout-cell:hover::before,.impulse-layout-cell:hover::after{
  color:rgba(130,130,130,0.9)
}

/* ============================================================
   DRAG MODE: suppress ALL cell hover effects so the unified
   grey outline is the single source of visual truth. No 3D
   bend, no corner brackets, no bg tint, no transitions —
   just the outline moving from dim to dark on the hovered cell.
   ============================================================ */
body.drag-active .impulse-grid-cell,
body.drag-active .impulse-rows-cell,
body.drag-active .impulse-layout-cell,
body.impulse-reordering .impulse-grid-cell,
body.impulse-reordering .impulse-rows-cell,
body.impulse-reordering .impulse-layout-cell{
  transition:none !important;
  transform:none !important;
}
/* Drag outline visuals live in the UNIFIED DRAG / DROP VISUAL LANGUAGE
   block further down (it wins the cascade - keep one source of truth). */
body.drag-active .impulse-grid-cell:hover,
body.drag-active .impulse-rows-cell:hover,
body.drag-active .impulse-layout-cell:hover,
body.impulse-reordering .impulse-grid-cell:hover,
body.impulse-reordering .impulse-rows-cell:hover,
body.impulse-reordering .impulse-layout-cell:hover{
  transform:none !important;
  border-color:transparent !important;
  box-shadow:none !important;
  z-index:auto;
}
/* Kill corner-bracket pseudos during drag — they fade in on hover and add flicker */
body.drag-active .impulse-grid-cell::before,body.drag-active .impulse-grid-cell::after,
body.drag-active .impulse-rows-cell::before,body.drag-active .impulse-rows-cell::after,
body.drag-active .impulse-layout-cell::before,body.drag-active .impulse-layout-cell::after,
body.impulse-reordering .impulse-grid-cell::before,body.impulse-reordering .impulse-grid-cell::after,
body.impulse-reordering .impulse-rows-cell::before,body.impulse-reordering .impulse-rows-cell::after,
body.impulse-reordering .impulse-layout-cell::before,body.impulse-reordering .impulse-layout-cell::after{
  opacity:0 !important;
  transition:none !important;
}
.impulse-el.editing{outline:1px solid #666;outline-offset:-1px}

/* ============================================================
   GLOBAL HOVER SUPPRESSION DURING DRAG / REORDER
   The unified drag-target outline + drop-inside receiving-container
   outline are the ONLY visual signals during drag. Every other
   hover effect on canvas elements (outline, toolbar reveal, pseudo
   overlays, transforms, box-shadow) is neutralised until drag ends.
   `:not(.drop-inside)` preserves the receiving-container signal.
   ============================================================ */
body.drag-active .impulse-el:hover:not(.drop-inside),
body.impulse-reordering .impulse-el:hover:not(.drop-inside){
  outline-color:transparent !important;
  outline-width:0 !important;
  border-color:transparent !important;
  box-shadow:none !important;
  transform:none !important;
  background-color:transparent !important;
}
/* Hide element toolbars during drag — no accidental button hits, no flicker. */
body.drag-active .impulse-el:hover > .impulse-el-toolbar,
body.drag-active .impulse-el-toolbar,
body.impulse-reordering .impulse-el:hover > .impulse-el-toolbar,
body.impulse-reordering .impulse-el-toolbar{
  opacity:0 !important;
  pointer-events:none !important;
  transform:none !important;
  transition:none !important;
}
/* Kill decorative hover pseudos during drag (bracket corners, shine, etc.).
   Preserve drop-above/drop-below indicator lines — those ::before/::after
   pseudos ARE the drop-point signal. */
body.drag-active .impulse-el:hover:not(.drop-above):not(.drop-below)::before,
body.drag-active .impulse-el:hover:not(.drop-above):not(.drop-below)::after,
body.impulse-reordering .impulse-el:hover:not(.drop-above):not(.drop-below)::before,
body.impulse-reordering .impulse-el:hover:not(.drop-above):not(.drop-below)::after{
  opacity:0 !important;
  transform:none !important;
  transition:none !important;
}

/* Freeze element transitions during drag so siblings don't animate as
   Sortable shifts them around. Elements snap to new positions cleanly. */
body.drag-active .impulse-el,
body.impulse-reordering .impulse-el{
  transition:none !important;
  animation:none !important;
}

/* Hidden element indicator - greyed out with tag */
.impulse-el.impulse-device-hidden{opacity:0.35;pointer-events:auto}
.impulse-el.impulse-device-hidden::before{content:'Hidden';position:absolute;top:4px;left:4px;background:#666;color:#fff;font-size:9px;font-weight:600;padding:2px 6px;border-radius:3px;z-index:100;text-transform:uppercase;letter-spacing:0.5px}

/* Icon Box */
.impulse-icon{overflow:visible}
.impulse-icon-wrap{transition:transform 0.7s ease,filter 0.7s ease}
@keyframes impulse-icon-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.15)}}
.impulse-effect-pulse .impulse-icon-wrap{animation:impulse-icon-pulse 2s ease infinite}
@keyframes impulse-icon-float{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
.impulse-effect-float .impulse-icon-wrap{animation:impulse-icon-float 3s ease infinite}
@keyframes impulse-icon-wobble{0%{transform:rotate(0)}15%{transform:rotate(6deg)}30%{transform:rotate(-5deg)}45%{transform:rotate(4deg)}60%{transform:rotate(-2deg)}75%{transform:rotate(1deg)}100%{transform:rotate(0)}}
.impulse-effect-wobble .impulse-icon-wrap{animation:impulse-icon-wobble 2s ease infinite}
.impulse-icon-image{object-fit:contain;display:block}
.impulse-social-hover-shine a{position:relative;overflow:hidden}
.impulse-social-hover-shine a::before{content:'';position:absolute;top:0;left:-75%;width:50%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.4),transparent);transform:skewX(-25deg);transition:max-height 0.6s cubic-bezier(0.4, 0, 0.2, 1);pointer-events:none;z-index:2}
.impulse-social-hover-shine a:hover::before{animation:impulse-img-shine 1s ease forwards}
.impulse-social-hover-spin a{transition:transform 0.7s}.impulse-social-hover-spin a:hover{transform:rotate(360deg)}
.impulse-social-hover-grow a{transition:transform 0.3s}.impulse-social-hover-grow a:hover{transform:scale(1.2)}
/* Icon-list hover — 3D effects. Parallax + Tilt driven by impulse-table-hover.js. */
.impulse-table-hover{perspective:900px}
.impulse-table-hover li{transition:transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);transform-style:preserve-3d;will-change:transform}
.impulse-table-hover li > span:first-child,.impulse-table-hover li > a > span:first-child{transition:transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);transform-style:preserve-3d}
.impulse-hh-pop li:hover{transform:translate3d(0,0,24px);box-shadow:0 10px 28px rgba(0,0,0,0.14)}
.impulse-hh-burst li > span:first-child,.impulse-hh-burst li > a > span:first-child{position:relative;transition:transform 0.28s cubic-bezier(0.4, 0, 0.2, 1)}
.impulse-hh-burst li:hover > span:first-child,.impulse-hh-burst li:hover > a > span:first-child{transform:translate3d(0,0,30px) scale(1.18)}
.impulse-hh-burst li > span:first-child::before,.impulse-hh-burst li > a > span:first-child::before{content:'';position:absolute;inset:-10%;border-radius:50%;background:radial-gradient(circle,currentColor 0%,currentColor 30%,transparent 70%);opacity:0;transform:scale(0.2);pointer-events:none;z-index:-1}
.impulse-hh-burst li:hover > span:first-child::before,.impulse-hh-burst li:hover > a > span:first-child::before{animation:impulseIconBurst 0.7s cubic-bezier(0.4, 0, 0.2, 1) both}
@keyframes impulseIconBurst{0%{opacity:0.45;transform:scale(0.2)}60%{opacity:0.12;transform:scale(2.0)}100%{opacity:0;transform:scale(2.5)}}
@keyframes impulseIconStroke{0%{stroke-dashoffset:var(--sl,200)}100%{stroke-dashoffset:0}}
.impulse-table-stagger li{opacity:0;animation:impulse-stagger-in 0.5s ease forwards}
@keyframes impulse-stagger-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}

/* Root-level elements - structural containers only get visual indicator */
body > .impulse-el[data-type="section"]{outline:1px solid #e0e0e0;outline-offset:-1px;width:100%}

/* All block types should fill their container width by default */
.impulse-el{width:100%;box-sizing:border-box;align-self:stretch;flex-shrink:0}
/* Cell halign override: when a grid cell sets halign=left/right,
   children become content-sized and align-self defers to the cell's
   align-items (flex-start / flex-end). Without this, .impulse-el's
   align-self:stretch above would defeat the cell's halign. */
.impulse-grid-cell[data-halign="left"] > .impulse-el,
.impulse-grid-cell[data-halign="right"] > .impulse-el{width:auto;max-width:100%;align-self:auto}
/* Explicit cell halign beats a block's inline align-self (image/text blocks emit
   align-self:var(--impulse-ca,center) inline, which outranks a plain rule).
   Width stays the block's own. Sergei 2026-07-25, salon 526 section 8. */
.impulse-grid-cell[data-halign="left"] > .impulse-el{align-self:flex-start!important}
.impulse-grid-cell[data-halign="right"] > .impulse-el{align-self:flex-end!important}
/* Row-flow cells: children must content-size for ANY halign, or a
   width:100% child fills the row and flex-wrap stacks the rest -
   "row flow only works with an arrow on". 2026-07-19. */
.impulse-grid-cell[data-flow="row"] > .impulse-el{width:auto;max-width:100%;align-self:auto}
/* A sole card in a centered cell fills its track, so a 2x2 of cards reads as
   equal boxes instead of each one hugging its own copy. Frontend parity with
   .impulse-grid-cell[data-halign=center] > .impulse-section.impulse-stretch in
   frontend.css - width already comes from .impulse-el{width:100%} here, so only
   the height fill needs stating. Sergei 2026-07-30. */
.impulse-grid-cell[data-halign="center"] > .impulse-el:only-child{height:100%}
.impulse-grid-cell[data-halign="center"] > .impulse-el:only-child .impulse-section.impulse-stretch{height:100%}
@media(max-width:768px){.impulse-el[data-type="table"]{width:100%!important;max-width:100%!important;min-width:0;flex-shrink:1}}@media(min-width:769px){.impulse-el[data-type="checkout"],.impulse-el[data-type="contact"],.impulse-el[data-type="login"],.impulse-el[data-type="register"],.impulse-el[data-type="shipping"],.impulse-el[data-type="table"]{max-width:50%;margin-left:auto;margin-right:auto}}
.impulse-el[data-type="cart"]{max-width:800px;margin-left:auto;margin-right:auto;width:100%;box-sizing:border-box}.impulse-el[data-type="map"],.impulse-el[data-type="booking"],.impulse-el[data-type="unfold"],.impulse-el[data-type="modal"]{max-width:var(--impulse-content-max-width,1440px);margin-left:auto;margin-right:auto;width:100%;box-sizing:border-box}
.impulse-el[data-type="video"]{margin-left:auto;margin-right:auto;width:100%;box-sizing:border-box}

/* Element toolbar - button group, no hover */
.impulse-el-toolbar{position:absolute;top:1px;right:10px;display:flex;opacity:0;transform:translateY(-100%) scaleY(0.7);transform-origin:top center;transition:opacity 0.18s ease,transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);z-index:10000;overflow:visible;pointer-events:none;border-radius:3px;box-shadow:0 2px 8px rgba(0,0,0,0.12)}
/* Narrow elements (vertical dividers): toolbar would otherwise anchor at
   right:10px and extend ~88px LEFT, bleeding into the previous sibling
   cell's content. Anchor to the element's RIGHT edge with left:100% so the
   toolbar sticks to the right side (over the cell gap, which is dead space)
   instead of overlapping the previous element. !important needed because the
   downstream :hover/.selected rules below reset transform with higher
   default specificity. */
/* Only VERTICAL dividers need the toolbar pushed to the right edge of the
   wrapper — they're narrow horizontally and the default toolbar position
   would overflow into the previous cell's content. Horizontal dividers
   use the standard toolbar position (top:1px;right:10px) so the cursor
   doesn't have to travel from the line in the centre of the section all
   the way to the right edge — that traversal often left the divider's
   padding hover zone and dismissed the toolbar before it could be clicked. */
.impulse-el[data-type="divider"]:has(.impulse-divider[data-orientation="vertical"])>.impulse-el-toolbar{right:auto !important;left:100% !important;transform-origin:top left !important;transform:translate(2px,-100%) scaleY(0.7) !important}
.impulse-el[data-type="divider"]:has(.impulse-divider[data-orientation="vertical"]):hover>.impulse-el-toolbar,
.impulse-el[data-type="divider"]:has(.impulse-divider[data-orientation="vertical"]).selected>.impulse-el-toolbar{transform:translate(2px,0) scaleY(1) !important}
/* Capture mode: hide editor chrome. Applied by thumbnail-capture handler; removed on completion. */
body.impulse-capturing .impulse-el-toolbar,
body.impulse-capturing .impulse-dropzone,
body.impulse-capturing .impulse-add-element,
body.impulse-capturing .impulse-add-section,
body.impulse-capturing .impulse-empty-section,
body.impulse-capturing .impulse-element-label{display:none !important}
<?php if (empty($GLOBALS['impulse_is_edit_mode'])): ?>
.impulse-el-toolbar{display:none !important}
.impulse-dropzone{display:none !important}
.impulse-empty-page,.impulse-empty-hint,.impulse-empty-placeholder,.impulse-cell-empty-indicator{display:none !important}
.impulse-rows-cell,.impulse-grid-cell,.impulse-layout-cell{outline:none !important;cursor:default !important}
.impulse-element-label,.impulse-add-element,.impulse-add-section,.impulse-empty-section{display:none !important}
.impulse-el{outline:none !important;border:none !important;box-shadow:none !important;cursor:default !important}
.impulse-el:hover,.impulse-el.selected,.impulse-el:focus,.impulse-el:focus-within,.impulse-el:focus-visible{outline:none !important;border:none !important;box-shadow:none !important}
.impulse-el::before,.impulse-el::after{display:none !important}
.impulse-el[data-type="section"]:hover,.impulse-el[data-type="section"].selected{outline:0 solid transparent !important}
[contenteditable]{outline:none !important;cursor:default !important;-webkit-user-modify:read-only !important}
.impulse-text a,.impulse-text a:visited,.impulse-text a:hover,.impulse-text a:active,
[contenteditable] a,[contenteditable] a:visited,[contenteditable] a:hover,[contenteditable] a:active{color:inherit !important;text-decoration:underline}
<?php else: ?>
/* Edit mode: block chat widget from opening and disrupting editor layout */
#instinctor-chat-widget{pointer-events:none !important}
#instinctor-chat-widget .instinctor-chat-window,
#instinctor-chat-widget [class*="chat-window"],
#instinctor-chat-widget [class*="chat-panel"]{display:none !important;visibility:hidden !important}
<?php endif; ?>
.impulse-el-toolbar *{-webkit-user-drag:none;user-drag:none}
/* Only show toolbar on innermost hovered element - not parent containers.
   v2.1.1379: visible-state KEEPS translateY(-100%) so the toolbar floats
   just above the element instead of sliding back down to top:1px inside it
   (which overlapped text content on text/heading blocks — the bg-image-
   behind-text "Time-critical moves..." report). The pre-existing ::before
   bridge at top:-24px already extends the hover hit-area upward so the
   cursor doesn't lose the element while travelling to the floated toolbar. */
.impulse-el:hover:not(:has(.impulse-el:hover))>.impulse-el-toolbar{opacity:1;transform:translateY(-100%) scaleY(1);pointer-events:auto}
.impulse-el.selected>.impulse-el-toolbar{opacity:1;transform:translateY(-100%) scaleY(1);pointer-events:auto;z-index:10010}
/* Hide parent toolbar when any child is selected — prevents toolbar swap on hover race */
.impulse-el:has(.impulse-el.selected)>.impulse-el-toolbar{opacity:0!important;pointer-events:none!important}
/* Hover bridge: extend element hit area upward so mouse doesn't leave element when reaching toolbar */
.impulse-el.selected:not([data-type="section"])::before{content:'';position:absolute;top:-24px;left:0;right:0;height:24px;z-index:99;pointer-events:auto}
/* Pause auto-hide when toolbar itself is hovered */
.impulse-el-toolbar:hover{opacity:1 !important;transform:translateY(-100%) scaleY(1) !important;pointer-events:auto !important}
/* Top-flip: when an element is flush against the canvas top (a full-viewport
   screen, or its first block) the toolbar can't float above without being
   clipped off-screen. .imp-tb-flip (toggled by the toolbar-lock JS on hover)
   drops it to sit INSIDE the top edge instead. High specificity + !important so
   it wins over every translateY(-100%) state above. */
.impulse-el.imp-tb-flip>.impulse-el-toolbar,
.impulse-el.imp-tb-flip:hover>.impulse-el-toolbar,
.impulse-el.imp-tb-flip.selected>.impulse-el-toolbar,
.impulse-el.imp-tb-flip.imp-tb-active>.impulse-el-toolbar,
.impulse-el.imp-tb-flip>.impulse-el-toolbar:hover{transform:translateY(0) scaleY(1)!important}
/* ALL sections flip their toolbar INSIDE the top edge (translateY(0)), hover or
   not. Every .impulse-el[data-type="section"] carries overflow:hidden, so a
   toolbar floating ABOVE the top edge (translateY(-100%)) lands in the clipped
   region and vanishes. Top-level sections are full-viewport screens whose top
   sits at the canvas top (and a sidebar screen-select shows the toolbar with no
   hover); NESTED sections (a section inside another section/cell) have the same
   overflow:hidden and clipped the toolbar identically — most visible on an empty
   nested section, whose toolbar was "totally cropped" (Sergei 2026-06-29). The
   selector was previously scoped to .impulse-content-wrapper > … (top-level only);
   broadened to every section so nested ones stop clipping their own toolbar. */
.impulse-el[data-type="section"]>.impulse-el-toolbar{transform:translateY(0) scaleY(1)!important}
/* Carousel / slider viewport clips its children on ALL sides — coverflow
   scales + rotates the side slides, scroll mode clips the track — so a child
   block's toolbar can't float free of it and reads as a cropped sliver no
   matter how it top-flips. We can't portal a toolbar out of the transformed,
   overflow-hidden stage, so hide child toolbars inside a carousel entirely; the
   element stays selectable and fully editable via the Structure panel. The
   slider/carousel block's OWN toolbar sits OUTSIDE .impulse-carousel (sibling of
   it under the block's .impulse-el), so it is unaffected. Sergei 2026-07-07. */
.impulse-carousel .impulse-el-toolbar{display:none !important}
/* Toolbar lock (impulseToolbarLock JS below). The toolbar floats ABOVE
   its element via translateY(-100%), so it sits geometrically inside the
   PARENT's box — moving the mouse up from a child to click the child's
   toolbar drifts into the parent's hover zone, the parent's toolbar
   appears in the same spot, and the click lands on the WRONG element
   (e.g. duplicating the whole grid instead of the text inside it).
   The JS marks whichever .impulse-el the pointer is really targeting
   (element OR its toolbar) with .imp-tb-active; these rules then force
   that one toolbar visible and every ancestor's toolbar off. */
.impulse-el.imp-tb-active>.impulse-el-toolbar{opacity:1!important;transform:translateY(-100%) scaleY(1)!important;pointer-events:auto!important;z-index:10011}
.impulse-el:has(.impulse-el.imp-tb-active)>.impulse-el-toolbar{opacity:0!important;pointer-events:none!important}
/* Sections clip their own overflow, so even in the imp-tb-active state (the real
   pointer-target state on hover) their toolbar must stay INSIDE the top edge
   (translateY(0)). The rule above re-floats every element's toolbar to
   translateY(-100%) — fine for non-clipping elements, but a section's
   overflow:hidden then crops it (most visible on an empty nested section, whose
   "totally cropped" toolbar was the bug). Specificity ([data-type] + .imp-tb-active)
   beats the generic .imp-tb-active rule, so this wins. (Sergei 2026-06-29) */
.impulse-el[data-type="section"].imp-tb-active>.impulse-el-toolbar{transform:translateY(0) scaleY(1)!important}
/* Auto-fade: toolbar hides after timeout on selected blocks */
.impulse-el.toolbar-faded>.impulse-el-toolbar{opacity:0!important;pointer-events:none!important;transition:opacity 0.4s ease}
.impulse-el.toolbar-faded:hover>.impulse-el-toolbar{opacity:1!important;pointer-events:auto!important}
.impulse-el.toolbar-faded{outline-color:transparent!important;transition:outline-color 0.4s ease}
/* Block enter/exit animations retired — blocks appear/disappear in place. */
.impulse-el.impulse-exiting{opacity:0;pointer-events:none;transition:opacity 0.12s ease}
/* Toolbar button press depression */
.impulse-el-toolbar button:active{transform:scale(0.88)!important;transition:transform 0.04s ease!important}
/* Cursor intentionality */
.impulse-el:not([data-type="section"]):not([data-type="grid"]):not([data-type="rows"]):not([data-type="columns"]):not([data-type="modal"]):not([data-type="modal"]){cursor:default}
.impulse-el-drag{cursor:grab!important}
.impulse-el-drag:active,.ui-sortable-helper .impulse-el-drag{cursor:grabbing!important}
.impulse-el[data-type="text"]:not(.selected) [contenteditable]{cursor:text}
.impulse-el.selected [contenteditable]{cursor:text}
/* Text block contenteditable must accept pointer events (body is pointer-events:none in edit mode). */
.impulse-el[data-type="text"] [contenteditable],
.impulse-el[data-type="text"] .impulse-text{pointer-events:auto}
.impulse-el[data-type="text"].editing [contenteditable]{-webkit-user-modify:read-write !important;user-modify:read-write !important}
.impulse-el.toolbar-faded:hover{outline-color:#999!important}
.impulse-el.toolbar-faded::before{display:none!important}
@keyframes impulsePlPulse{0%,100%{opacity:1}50%{opacity:0.2}}
@keyframes impulsePlShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
@keyframes impulsePlWaveBg{0%{background-position-x:0}100%{background-position-x:-200px}}
@keyframes impulseDivAurora{0%{background-position:0% 50%}100%{background-position:-200% 50%}}
/* Custom tooltips for toolbar buttons - no native title */
/* 28px hit targets (was 22px), COUNTER-SCALED against the canvas fit scale
   (Sergei 2026-07-18): screen-mode pages render at a fixed 1920x1080 design
   viewport and the whole canvas is transform:scale()'d to fit the editor
   window (computeFitScale), so on smaller screens the toolbar shrank with it
   and became unhittable. --impulse-tb-boost = clamp(1/fitScale, 1..3), set by
   the impulse-canvas-scale message listener below, keeps the buttons at a
   constant ~28 PHYSICAL px at any canvas scale. Page mode (no scaling) keeps
   boost=1. */
.impulse-el-toolbar button{width:calc(28px * var(--impulse-tb-boost,1));height:calc(28px * var(--impulse-tb-boost,1));background:#e0e0e0;border:none;border-left:1px solid #ccc;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:calc(12px * var(--impulse-tb-boost,1));line-height:1;color:#666;padding:0;transition:background 0.15s;position:relative}
.impulse-el-toolbar button[data-tip]:hover::after{content:attr(data-tip);position:absolute;top:100%;left:50%;transform:translateX(-50%);background:#1d2327;color:#e0e0e0;font-size:calc(11px * var(--impulse-tb-boost,1));padding:4px 8px;border-radius:3px;white-space:nowrap;z-index:10000;pointer-events:none;margin-top:4px;box-shadow:0 2px 8px rgba(0,0,0,0.3)}
/* Suppress the toolbar tooltip while a popover (or any other interaction)
   is in progress on the same button. Added to the button on mousedown by
   the wrap-button capture handler; the button removes it on mouseleave. */
.impulse-el-toolbar button.impulse-tip-suppressed[data-tip]:hover::after{display:none!important}
.impulse-el-toolbar button:first-child{border-left:none;border-radius:3px 0 0 3px}
.impulse-el-toolbar button:last-child{border-radius:0 3px 3px 0}
.impulse-el-toolbar button:hover{background:#d0d0d0}
.impulse-el-toolbar button svg{width:calc(14px * var(--impulse-tb-boost,1));height:calc(14px * var(--impulse-tb-boost,1));stroke:currentColor;fill:none;stroke-width:2;pointer-events:none}
.impulse-el-toolbar .impulse-el-drag svg circle{fill:currentColor;stroke:none}
/* The Modal chip is canvas chrome, not content: it is the only handle onto a
   page-level modal, so it has to stay clickable at any fit scale. Same
   --impulse-tb-boost the toolbars use (Sergei 2026-08-05, laptop report: the
   chip and the cell-guides toggle were both too small to see). */
.impulse-modal-chip{height:calc(26px * var(--impulse-tb-boost,1))!important;padding:0 calc(10px * var(--impulse-tb-boost,1))!important;font-size:calc(11px * var(--impulse-tb-boost,1))!important;gap:calc(6px * var(--impulse-tb-boost,1))!important;border-radius:calc(3px * var(--impulse-tb-boost,1))!important}
.impulse-modal-chip svg{width:calc(12px * var(--impulse-tb-boost,1))!important;height:calc(12px * var(--impulse-tb-boost,1))!important}
.impulse-el-toolbar .impulse-el-drag{cursor:grab}
.impulse-el-toolbar .impulse-el-drag:active{cursor:grabbing}

/* =========================================================================
   UNIFIED DRAG / DROP VISUAL LANGUAGE
   - All drop targets: 1px solid rgba(153,153,153,0.25), outline-offset -1px
   - Hovered/active target: same line, darker (0.6)
   - No dashed outlines anywhere
   - No fill tints
   - No post-drop pulses or jumps
   - Source being dragged: opacity only, no outline
   - The sortable placeholder bar is the only moving/visible insert indicator
   ========================================================================= */

/* Source element being dragged */
.impulse-el.impulse-dragging{opacity:0.4 !important;outline:none !important;display:block !important}
.impulse-el.impulse-dragging,.impulse-el.impulse-dragging *{pointer-events:none !important}

/* All drop targets during drag (and reorder). Reworked 2026-07-20
   (Sergei: "you never know if the block you're dragging will fall into
   the cell") - the old 1px 0.25-alpha line was invisible in practice:
   every cell now shows a clear dashed catch area while a drag is live,
   and the cell under the cursor gets a solid line + tint. */
body.drag-active .impulse-grid-cell,
body.drag-active .impulse-rows-cell,
body.drag-active .impulse-layout-cell,
body.impulse-reordering .impulse-grid-cell,
body.impulse-reordering .impulse-rows-cell,
body.impulse-reordering .impulse-layout-cell{
    outline:2px dashed rgba(130,130,130,0.55) !important;
    outline-offset:-2px !important;
}
body.drag-active .impulse-grid-cell:hover,
body.drag-active .impulse-rows-cell:hover,
body.drag-active .impulse-layout-cell:hover,
body.drag-active .impulse-grid-cell.drop-target,
body.drag-active .impulse-rows-cell.drop-target,
body.drag-active .impulse-layout-cell.drop-target,
body.impulse-reordering .impulse-grid-cell:hover,
body.impulse-reordering .impulse-rows-cell:hover,
body.impulse-reordering .impulse-layout-cell:hover,
body.impulse-reordering .impulse-grid-cell.drop-target,
body.impulse-reordering .impulse-rows-cell.drop-target,
body.impulse-reordering .impulse-layout-cell.drop-target{
    outline:2px solid #777 !important;
    outline-offset:-2px !important;
    background-color:rgba(128,128,128,0.14) !important;
}

/* Sortable placeholder — the insert-point indicator.
   IMPORTANT: do NOT shrink the dragged element's height. The previous rule
   forced it to 6px, which combined with the grid cell's
   `display:flex; flex-direction:column; justify-content:center` to vertically
   re-center the shrunken element inside the cell — producing the famous
   ~250px offset between cursor and helper. Keep the original height so the
   dragged element stays in place. Visual treatment = opacity + dashed
   outline only. */
.impulse-sortable-placeholder{opacity:0.35 !important;outline:1px dashed rgba(153,153,153,0.55) !important;outline-offset:-2px;background:rgba(255,255,255,0.04) !important}
.impulse-sortable-placeholder *{pointer-events:none}

/* Drag helper tilt + shadow */
.ui-sortable-helper{transform:rotate(-2deg) scale(1.04) !important;box-shadow:0 10px 32px rgba(0,0,0,0.5),0 2px 8px rgba(0,0,0,0.25) !important;opacity:0.97 !important}

/* ── Compact drag chip ────────────────────────────────────────────────
   SortableJS forceFallback clones the WHOLE .impulse-el as the drag
   ghost. A Title with an enlarged empty area (designers grow it to
   stop text crop) then drags as a screen-filling ghost that hides the
   drop targets and visual order. onStart replaces the ghost's contents
   with a small labelled pill and tags it .impulse-drag-chip-host. The
   host loses the cloned block's box (width/height/bg/shadow) so only
   the pill shows. Applied to every Title and to any oversized block;
   small blocks keep their familiar full ghost. Sergei 2026-05-28. */
.impulse-sortable-drag.impulse-drag-chip-host{
    width:auto !important; height:auto !important;
    min-width:0 !important; max-width:none !important;
    min-height:0 !important; max-height:none !important;
    padding:0 !important; margin:0 !important;
    background:transparent !important; border:none !important;
    box-shadow:none !important; overflow:visible !important;
    outline:none !important; opacity:1 !important;
    transform-origin:top left;
}
.impulse-drag-chip{
    display:inline-flex; align-items:center; gap:7px;
    background:#1d2327; color:#fff; border-radius:7px;
    padding:8px 12px;
    box-shadow:0 8px 24px rgba(0,0,0,0.4);
    font:600 12px/1.2 "Helvetica Neue",Helvetica,Arial,sans-serif;
    white-space:nowrap; max-width:260px;
}
.impulse-drag-chip svg{flex-shrink:0; opacity:0.6}
.impulse-drag-chip b{
    display:inline-block; overflow:hidden; text-overflow:ellipsis;
    max-width:220px; font-weight:600; vertical-align:bottom;
}

/* Inline editing */
[contenteditable]{cursor:text;caret-color:auto;position:relative}
[contenteditable]:focus{outline:none}
[contenteditable].is-empty:before{content:attr(data-placeholder);color:#999;pointer-events:none;position:absolute;top:0;left:0;right:0}
[contenteditable].is-empty:focus:before{color:#bbb}

/* Heading (heading) placeholder — shows when all lines are empty, disappears when text is entered */
.impulse-el[data-type="title"] .impulse-dt-line.is-empty::before{content:attr(data-placeholder);color:#bbb;pointer-events:none}
.impulse-el[data-type="title"] .impulse-dt-line.is-empty{min-height:1em}
.impulse-el[data-type="title"]{width:100%!important;max-width:100%!important;align-self:stretch!important;flex-shrink:1!important;min-width:0!important;box-sizing:border-box!important}



/* Drop zones - invisible drop targets, no visual distraction */
.impulse-dropzone{min-height:40px;border:none;display:none;margin:4px 0;background:transparent;transition:background 0.2s}
/* Cell-borders toggle (editor only) — flipped on by the canvas's
   .impulse-canvas-cellborders-toggle button. Default is OFF (clean preview,
   matches frontend). When body has impulse-show-cell-borders, every cell
   gets a thin dashed outline so the user can see the grid structure while
   composing. Pure additive overlay — never touches saved values, never
   leaks to frontend.
   Bg-adaptive via mix-blend-mode:difference on a ::before pseudo: a white
   border drawn under difference inverts its blend against the cell's
   backdrop — appears black on white pages, white on dark scene-mode pages.
   The previous static rgba(255,255,255,0.85) outline disappeared on white
   backgrounds (which is the page-mode default) — no contrast at all.
   ::before pseudo lets us anchor the line without forcing position:relative
   on the cell itself (cell layouts use static positioning for grid flow). */
body.impulse-show-cell-borders .impulse-grid-cell,
body.impulse-show-cell-borders .impulse-rows-cell,
body.impulse-show-cell-borders .impulse-layout-cell,
body.impulse-show-cell-borders .impulse-grid-cell:has(.impulse-el),
body.impulse-show-cell-borders .impulse-rows-cell:has(.impulse-el),
body.impulse-show-cell-borders .impulse-rows-cell:has(.impulse-cell-content > .impulse-el),
body.impulse-show-cell-borders .impulse-layout-cell:has(.impulse-el),
body.impulse-show-cell-borders .impulse-layout-cell:has(.impulse-cell-content > .impulse-el) {
    position:relative;
}
body.impulse-show-cell-borders .impulse-grid-cell::before,
body.impulse-show-cell-borders .impulse-rows-cell::before,
body.impulse-show-cell-borders .impulse-layout-cell::before,
body.impulse-show-cell-borders .impulse-grid-cell:has(.impulse-el)::before,
body.impulse-show-cell-borders .impulse-rows-cell:has(.impulse-el)::before,
body.impulse-show-cell-borders .impulse-rows-cell:has(.impulse-cell-content > .impulse-el)::before,
body.impulse-show-cell-borders .impulse-layout-cell:has(.impulse-el)::before,
body.impulse-show-cell-borders .impulse-layout-cell:has(.impulse-cell-content > .impulse-el)::before {
    content:'';
    position:absolute;
    inset:0;
    /* Reset corner-bracket sizing/opacity/offset that the same ::before
       carries by default — without these, the toggle would draw a dashed
       border on the invisible 14×14 hover-bracket box in the cell corner
       (opacity:0, width:14px, transform:translate(-3px,-3px)) and you'd
       see nothing on screen. */
    width:auto !important;
    height:auto !important;
    opacity:1 !important;
    transform:none !important;
    background:transparent !important;
    pointer-events:none;
    /* Fixed mid-grey instead of mix-blend-mode:difference — many grid cells
       set `isolation:isolate` (creates a stacking context), which kills
       difference's blend with the page background. Result: the dashed line
       was invisible on light + dark scenes alike. #999 is a compromise that
       reads OK on both white and dark scene-mode backdrops. */
    border:1px dashed #999 !important;
    mix-blend-mode:normal !important;
    z-index:9999;
}
/* Hover :before rule has equal specificity (0,2,1) and would otherwise
   beat the toggle rule by source order on hovered cells — the bracket
   re-applies translate(0,0) on hover and competes for the pseudo. Force
   our reset to win regardless of hover state. */
body.impulse-show-cell-borders .impulse-grid-cell:hover::before,
body.impulse-show-cell-borders .impulse-rows-cell:hover::before,
body.impulse-show-cell-borders .impulse-layout-cell:hover::before {
    transform:none !important;
}
/* Suppress the ::after corner bracket entirely while guides are on — the
   full-cell dashed outline already shows the cell, the corner bracket on
   hover would just stack on top and add visual noise. */
body.impulse-show-cell-borders .impulse-grid-cell::after,
body.impulse-show-cell-borders .impulse-rows-cell::after,
body.impulse-show-cell-borders .impulse-layout-cell::after {
    opacity:0 !important;
}
/* Table block (table) — outline each row + the header track so the row
   structure is visible. mid-grey so it reads on both light and dark
   backdrops without needing a blend pseudo (li bg already handles its own
   zebra contrast). */
body.impulse-show-cell-borders .impulse-el[data-type="table"] .impulse-table > li {
    box-shadow:inset 0 0 0 1px rgba(128,128,128,0.45) !important;
}
body.impulse-show-cell-borders .impulse-el[data-type="table"] .impulse-table-headers {
    box-shadow:inset 0 0 0 1px rgba(128,128,128,0.6) !important;
}

/* Empty section: show a visible drop zone during drag so jQuery UI sortable
   has a real hit area inside the .impulse-container and the user sees where
   their dragged element will land. The .impulse-section-empty-dropzone class
   is added by the section renderer when the section has no children; it
   opts the dropzone out of the global drag-active hide rule above. */
body.drag-active .impulse-section-empty-dropzone{
  display:block !important;min-height:120px;
  outline:1px dashed rgba(255,255,255,0.25);outline-offset:-8px;border-radius:4px;
  background:rgba(255,255,255,0.04);
}
body.drag-active .impulse-section-empty-dropzone.drop-target{
  outline:1px solid rgba(153,153,153,0.6) !important;background:rgba(255,255,255,0.08);
}
.impulse-tab-dropzone{display:none;min-height:40px;border:none;border-radius:4px;background:transparent;color:#999;font-size:12px;align-items:center;justify-content:center}
.impulse-tab-dropzone:empty{min-height:20px;opacity:0.5}
/* Show tab dropzones when tab panel is empty or during drag */
.impulse-tab-panel:not(:has(.impulse-el)) .impulse-tab-dropzone{display:flex;background:#f5f5f5}
body.drag-active .impulse-tab-dropzone{display:flex}
/* Toggle dropzone */
.impulse-toggle-dropzone{display:none;min-height:40px;border:none;border-radius:4px;background:transparent;color:#999;font-size:12px;align-items:center;justify-content:center}
.impulse-toggle-dropzone:empty{min-height:20px;opacity:0.5}
.impulse-toggle-content:not(:has(.impulse-el)) .impulse-toggle-dropzone{display:flex;background:#f5f5f5}
body.drag-active .impulse-toggle-dropzone{display:flex}

/* Content slider dropzone - always visible as it IS the slide content area */
.impulse-new-slider-dropzone{display:flex !important;flex-direction:column;border:none !important;background:transparent !important;position:relative;z-index:2;width:100%;height:100%;align-items:center !important;justify-content:center}
.impulse-new-slider-dropzone > *:not(.impulse-cell-empty-indicator){position:relative;z-index:1;flex-shrink:0;width:100% !important;max-width:100% !important;box-sizing:border-box}
.impulse-new-slider-dropzone > .impulse-el[data-type="section"]{flex-shrink:1}
.impulse-new-slider-dropzone .impulse-cell-empty-indicator{display:block !important;position:static !important;width:auto !important;transform:none !important;top:auto !important;left:auto !important;pointer-events:none}
.impulse-new-slider-dropzone:empty::after{content:'Drop elements here';color:#666}
.impulse-carousel-track{max-height:100vh}
.impulse-carousel-slide-empty{background:transparent;outline:1px solid rgba(153,153,153,0.25);outline-offset:-1px}
/* Mobile stack: slides shown vertically, no carousel behavior */
.impulse-new-slider[data-mobile-stack="1"] .impulse-carousel-track{height:auto !important;min-height:auto !important;overflow:visible !important}
.impulse-new-slider[data-mobile-stack="1"] .impulse-carousel-slides{display:flex !important;flex-direction:column !important;height:auto !important;min-height:auto !important;transform:none !important;transition:none !important}
.impulse-new-slider[data-mobile-stack="1"] .impulse-carousel-slide{position:relative !important;width:100% !important;flex-shrink:0 !important;opacity:1 !important;transform:none !important}
.impulse-new-slider[data-mobile-stack="1"] .impulse-carousel-nav,.impulse-new-slider[data-mobile-stack="1"] .impulse-carousel-dots,.impulse-new-slider[data-mobile-stack="1"] .impulse-slide-nav{display:none !important}
.impulse-product-carousel .impulse-carousel-slides{cursor:grab}.impulse-product-carousel .impulse-carousel-slides:active{cursor:grabbing}
.impulse-image-wrapper{position:relative;perspective:900px;overflow:hidden;touch-action:pan-y;--cyl-px:50%;--cyl-py:50%;--cyl-ea:0;--cyl-sx:50%;--cyl-sy:50%;--cyl-ha:0}
/* edge shadow removed */
.impulse-image-wrapper::before{content:"";position:absolute;inset:0;pointer-events:none;z-index:3;background:radial-gradient(circle at var(--cyl-sx) var(--cyl-sy),rgba(255,255,255,var(--cyl-ha)) 0%,transparent 75%)}
.impulse-image-wrapper img{will-change:transform}

/* Product card flip */
.impulse-flip-card{perspective:1000px;cursor:pointer}.impulse-flip-card .impulse-pcard{box-shadow:none!important}.impulse-flip-inner{position:relative;transform-style:preserve-3d;transition:transform 0.6s ease}.impulse-flip-front{overflow:hidden}.impulse-flip-back{position:absolute;inset:0;overflow:hidden;display:flex;flex-direction:column}
.pfc-flip-h:hover .impulse-flip-inner,.pfc-flip-h.flipped .impulse-flip-inner{transform:rotateY(180deg)}.pfc-flip-h .impulse-flip-front,.pfc-flip-h .impulse-flip-back{backface-visibility:hidden;-webkit-backface-visibility:hidden}.pfc-flip-h .impulse-flip-back{transform:rotateY(180deg)}
.pfc-flip-v:hover .impulse-flip-inner,.pfc-flip-v.flipped .impulse-flip-inner{transform:rotateX(180deg)}.pfc-flip-v .impulse-flip-front,.pfc-flip-v .impulse-flip-back{backface-visibility:hidden;-webkit-backface-visibility:hidden}.pfc-flip-v .impulse-flip-back{transform:rotateX(180deg)}
.pfc-zoom .impulse-flip-front{transition:transform 0.4s ease,opacity 0.3s ease}.pfc-zoom .impulse-flip-back{transform:scale(0.86);opacity:0;transition:transform 0.4s ease,opacity 0.4s ease}.pfc-zoom:hover .impulse-flip-front,.pfc-zoom.flipped .impulse-flip-front{opacity:0;transform:scale(1.05)}.pfc-zoom:hover .impulse-flip-back,.pfc-zoom.flipped .impulse-flip-back{transform:scale(1);opacity:1}
.pfc-push{overflow:hidden}.pfc-push .impulse-flip-front{transition:transform 0.5s ease}.pfc-push .impulse-flip-back{transform:translateX(100%);transition:transform 0.5s ease}.pfc-push:hover .impulse-flip-front,.pfc-push.flipped .impulse-flip-front{transform:translateX(-100%)}.pfc-push:hover .impulse-flip-back,.pfc-push.flipped .impulse-flip-back{transform:translateX(0)}
.pfc-swing .impulse-flip-front{transform-origin:left center;transition:transform 0.6s cubic-bezier(0.175,0.885,0.32,1.275);backface-visibility:hidden;-webkit-backface-visibility:hidden}.pfc-swing .impulse-flip-back{transform:rotateY(-90deg);transform-origin:left center;transition:transform 0.6s cubic-bezier(0.175,0.885,0.32,1.275);backface-visibility:hidden;-webkit-backface-visibility:hidden}.pfc-swing:hover .impulse-flip-front,.pfc-swing.flipped .impulse-flip-front{transform:rotateY(90deg)}.pfc-swing:hover .impulse-flip-back,.pfc-swing.flipped .impulse-flip-back{transform:rotateY(0deg)}
.pfc-fade .impulse-flip-front{transition:opacity 0.7s ease}.pfc-fade .impulse-flip-back{opacity:0;transition:opacity 0.7s ease}.pfc-fade:hover .impulse-flip-front,.pfc-fade.flipped .impulse-flip-front{opacity:0}.pfc-fade:hover .impulse-flip-back,.pfc-fade.flipped .impulse-flip-back{opacity:1}
/* Product carousel: single-card effect mode — image fills 55% of card, rest goes to content */
/* Effect-slide card: explicit px heights via data attrs — no % chains */
.impulse-pcard-effect-slide{position:absolute!important;inset:0!important;display:flex!important;flex-direction:column!important;overflow:hidden!important}
.impulse-pcard-effect-slide .impulse-pcard{display:flex!important;flex-direction:column!important;overflow:hidden!important;box-sizing:border-box!important;height:100%!important;width:100%!important}
.impulse-pcard-effect-slide .impulse-pcard-image{flex:none!important;overflow:hidden!important;position:relative!important;width:100%!important}
.impulse-pcard-effect-slide .impulse-pcard-image-inner{position:relative!important;padding-bottom:0!important;width:100%!important;overflow:hidden!important}
.impulse-pcard-effect-slide .impulse-pcard-image-inner img:not(.impulse-placeholder-img){position:absolute!important;inset:0!important;width:100%!important;height:100%!important;object-fit:contain!important}
.impulse-pcard-effect-slide .impulse-pcard-image-inner>div{position:absolute!important;inset:0!important;display:flex!important;align-items:center!important;justify-content:center!important}
.impulse-pcard-effect-slide .impulse-pcard-content{flex:1!important;min-height:0!important;overflow:hidden!important;display:flex!important;flex-direction:column!important}
.impulse-pcard-effect-slide .impulse-pcard-btn-wrap{flex:none!important;margin-top:auto!important}

/* Section inside slider - disable stretch, allow fill height */
.impulse-new-slider-dropzone .impulse-section.impulse-stretch{width:100% !important;left:auto !important;right:auto !important;margin-left:0 !important;margin-right:0 !important;position:relative !important}
/* When section wrapper has fill class, make it fill available height */
.impulse-new-slider-dropzone > .impulse-el-fill-slide{flex:1 1 100% !important;display:flex !important;flex-direction:column !important;min-height:0 !important}
.impulse-new-slider-dropzone .impulse-section.impulse-fill-slide{flex:1 !important;height:100% !important;min-height:0 !important;display:flex !important;flex-direction:column !important}
.impulse-new-slider-dropzone .impulse-section.impulse-fill-slide > .impulse-container{flex:1 !important;height:100% !important;display:flex !important;flex-direction:column !important}

/* Product grid inside slider - centered, natural height */
.impulse-new-slider-dropzone .impulse-el[data-type="products"]{display:block !important}
.impulse-grid-in-slider{width:100%}

/* Ensure video and other elements work inside slider */
.impulse-new-slider-dropzone .impulse-video-outer{position:relative;z-index:1;width:100% !important}
.impulse-new-slider-dropzone .impulse-video{position:relative;width:100% !important;height:0 !important}
.impulse-new-slider-dropzone .impulse-video iframe,
.impulse-new-slider-dropzone .impulse-video video{position:absolute !important;top:0 !important;left:0 !important;width:100% !important;height:100% !important}
.impulse-new-slider-dropzone .impulse-el{position:relative;z-index:1;width:100% !important}

/* During drag, make new slider dropzones fully visible and targetable */
body.drag-active .impulse-new-slider{overflow:visible !important}
body.drag-active .impulse-new-slider .impulse-carousel-track{overflow:visible !important}
body.drag-active .impulse-new-slider .impulse-carousel-slides{overflow:visible !important}
/* Don't disable pointer-events on slides - instead enable them specifically on dropzone */
body.drag-active .impulse-new-slider .impulse-carousel-slide{position:relative}
body.drag-active .impulse-new-slider-dropzone{
    pointer-events:auto !important;
    outline:1px solid rgba(153,153,153,0.25) !important;
    outline-offset:-1px;
    background:transparent !important;
    min-height:100px;
    z-index:100 !important;
    position:relative !important;
}
body.drag-active .impulse-new-slider-dropzone:hover,
body.drag-active .impulse-new-slider-dropzone.active,
.impulse-new-slider-dropzone.active{background:transparent !important;outline:1px solid rgba(153,153,153,0.6) !important;outline-offset:-1px}
.impulse-new-slider-dropzone.active .impulse-cell-empty-indicator,
body.drag-active .impulse-new-slider-dropzone:hover .impulse-cell-empty-indicator{color:rgba(130,130,130,0.6)!important}

/* Dropzones: container dropzones stay hidden during drag — detection is geometry-based */
/* Only specialized dropzones (tabs, toggles, slides, root) become visible */
body.drag-active .impulse-dropzone:not(.impulse-tab-dropzone):not(.impulse-toggle-dropzone):not(.impulse-new-slider-dropzone):not(.impulse-root-dropzone):not(.impulse-section-empty-dropzone){display:none !important}
/* Parent indicator: cursor feedback only, no visual overlay */
.impulse-el.drop-inside::after{display:none}

/* Reordering: container dropzones stay hidden */
body.impulse-reordering .impulse-root-dropzone{display:block !important}
body.impulse-reordering .impulse-el.drop-inside::after{display:none}

/* Drop position indicators — full-width soft grey line above or below the
   nearest sibling, painted as a ::before (drop-above) or ::after (drop-below)
   pseudo-element so the line sits outside the block's content flow and does
   not affect layout. This is the primary "your block lands here" signal. */
.impulse-el.drop-above,.impulse-el.drop-below{position:relative}
.impulse-el.drop-above::before,
.impulse-el.drop-below::after{
    content:'';
    position:absolute;
    left:0;right:0;
    height:2px;
    background:rgba(153,153,153,0.7);
    pointer-events:none;
    z-index:1000;
    border-radius:1px;
}
.impulse-el.drop-above::before{top:-1px}
.impulse-el.drop-below::after{bottom:-1px}
/* Lock cursor to 'grabbing' across the whole viewport during an active drag
   so it never flips to text I-beam when passing over copy, or to default
   arrow over empty areas. Applied to everything incl. pseudo-elements. */
body.drag-active,body.drag-active *,body.drag-active *::before,body.drag-active *::after{cursor:grabbing !important}
/* Hovered/active receiving container — same line, darker. No fill. */
.impulse-el.drop-inside{position:relative;outline:1px solid rgba(153,153,153,0.6) !important;outline-offset:-1px;background-color:transparent !important}

/* Container becomes a drop target during drag - minimal outline */
body.drag-active .impulse-el[data-type="section"],
body.drag-active .impulse-el[data-type="grid"],
body.drag-active .impulse-el[data-type="slider"],
body.drag-active .impulse-el[data-type="modal"],
body.drag-active .impulse-el[data-type="modal"],
body.impulse-reordering .impulse-el[data-type="section"],
body.impulse-reordering .impulse-el[data-type="grid"],
body.impulse-reordering .impulse-el[data-type="slider"],
body.impulse-reordering .impulse-el[data-type="modal"],
body.impulse-reordering .impulse-el[data-type="modal"]{
    min-height:60px;
}
/* Ensure cells are always hittable during external drag even when empty */
body.drag-active .impulse-rows-cell,
body.drag-active .impulse-layout-cell,
body.drag-active .impulse-grid-cell{min-height:40px}
body.drag-active .impulse-rows-cell .impulse-cell-content,
body.drag-active .impulse-layout-cell .impulse-cell-content{min-height:40px}

/* Container drop target - no visual change */
.impulse-el.drop-target{outline:none !important}
/* Final dominance for cell drop-target — wins over the populated-cell
   suppression and the generic :hover rule by both selector specificity
   (`body` extra qualifier) and source order. Without this, dragging a new
   block over a cell that already has content shows no outline because
   :has(.impulse-el):not(.drop-target) is the same specificity as
   .drop-target alone but appears later — :not() lets us keep the
   suppression while letting drop-target visually win. */
body .impulse-grid-cell.drop-target,
body .impulse-rows-cell.drop-target,
body .impulse-layout-cell.drop-target{
  outline:1px solid rgba(153,153,153,0.85)!important;
  outline-offset:-1px!important;
  background-color:rgba(153,153,153,0.08)!important;
  box-shadow:none!important;
  border-color:transparent!important;
}

/* Empty container placeholder */
.impulse-empty-placeholder{min-height:80px;border:1px solid rgba(153,153,153,0.25);border-radius:8px;display:flex;align-items:center;justify-content:center;color:rgba(128,128,128,0.3);font-size:13px;margin:16px;background:transparent}

/* Root dropzone at page bottom - invisible, still functional */
.impulse-root-dropzone{margin-top:20px;position:relative;min-height:40px;width:100%}
body.drag-active .impulse-root-dropzone,
body.impulse-reordering .impulse-root-dropzone{min-height:50px;border:none;background:transparent;display:block;width:100%;border-radius:6px}
body.drag-active .impulse-root-dropzone.active,
body.impulse-reordering .impulse-root-dropzone:hover{background:transparent}

/* Container visual boundaries */
.impulse-el[data-type="section"]{outline:0 solid transparent;margin-bottom:0;position:relative;overflow:hidden;width:100%;max-width:100%;box-sizing:border-box;z-index:0}
/* Footer-present pages: content-wrapper has `flex:1` (set inline by the
   renderer so empty / footerless pages fill viewport so the trailing
   50vh drag affordance reads as deliberate). When a footer DOES exist
   the flex-grow stretches the wrapper 30-50px past its content,
   creating a visible page-bg gap between the last section and the
   footer. Force `flex:0 0 auto` so the wrapper is content-height —
   footer sits flush against the last section. */
body:not([data-screen-mode="1"]):has(.impulse-el[data-type="footer"]) .impulse-content-wrapper{flex:0 0 auto !important}
.impulse-el[data-type="section"] > .impulse-section{overflow:hidden}
/* Compact section (v2.1.1326): bg hugs the wrapped content rather than
   the full row, parent's content_align (--impulse-ca on the parent
   .impulse-container) positions it left / center / right. The inner
   .impulse-container drops its content_width % since the percentage is
   meaningless when the parent is shrink-to-fit. Padding stays from the
   panel settings — that gives the bg breathing room around the content.
   max-width:100% prevents card-mode from overflowing its parent on
   narrow viewports. */
/* `is-compact` lives on the INNER .impulse-section (the renderer sets
   it on the <section> tag). Shrink the inner to its content; the
   renderer emits margin-left/right:auto inline based on the section's
   own content_align so it positions itself within its block parent
   (.impulse-el outer). Inner .impulse-container also drops its %
   width since the percentage is meaningless on a max-content parent. */
.impulse-el[data-type="section"] > .impulse-section.is-compact{width:max-content !important;max-width:100% !important}
/* Mirror of frontend.css v2.1.1357 — inner container fills the compact
   section's content box so explicit Width slider settings render edge-
   to-edge content instead of a max-content bubble centred in gray bands.
   See the comment in frontend.css for the full rationale. */
.impulse-el[data-type="section"] > .impulse-section.is-compact > .impulse-container{width:100% !important;max-width:100% !important}
/* Ambient motion: inner section overflow visible so content isn't clipped during scale/translate; wrapper keeps overflow:hidden to crop the moving section to its slot. */
.impulse-el[data-type="section"] > .impulse-section[data-section-ambient]{overflow:visible}
/* Tilt: 3D rotation needs wrapper overflow:visible too so perspective-transformed edges aren't clipped. */
.impulse-el[data-type="section"]:has(> section[data-section-tilt]){overflow:visible}
.impulse-el[data-type="section"] > .impulse-section[data-section-tilt]{overflow:visible}
.impulse-el[data-type="section"]:has(.impulse-logo-animator) > .impulse-section{overflow:visible}
.impulse-el[data-type="section"]:has(.impulse-logo-animator) > .impulse-section > .impulse-container{overflow:visible}
.impulse-el[data-type="section"]:has(.impulse-logo-animator){overflow:visible}
.impulse-el[data-type="title"]{overflow:visible}
/* 2026-05-24: breathing-room padding inside the animator container.
   Canvas backing is sized to container.offsetWidth/Height; without
   padding the text fills 96% of canvas width and ~85% of height,
   leaving no room for breathing/scale/tilt animations to grow into.
   Any growth beyond the canvas backing is rasterised to nothing —
   ancestor overflow:visible can't restore pixels that were never
   drawn. The em values scale with the heading's font-size so the
   margin stays proportional across all title sizes. */
.impulse-logo-animator{padding:0.3em 0.5em;box-sizing:content-box}
.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}
/* Safety net: whenever the animator has a canvas (animation drew on it, gradient loop
   running, 3D depth active, etc.), the DOM asset MUST stay hidden — otherwise env
   changes or late-arriving opacity restorers can make the DOM text reappear on top
   of the canvas, producing a doubled/offset text. */
.impulse-logo-animator:has(> .impulse-la-canvas) > .impulse-la-asset,
.impulse-logo-animator:has(> .impulse-la-3d-canvas) > .impulse-la-asset{opacity:0!important}
.impulse-el[data-type="section"]:has(.impulse-divider) > .impulse-section{overflow:visible}
.impulse-el[data-type="section"]:has(.impulse-divider) > .impulse-section > .impulse-container{overflow:visible}
.impulse-el[data-type="section"]:has(.impulse-display-text) > .impulse-section{overflow:visible}
.impulse-el[data-type="section"]:has(.impulse-display-text) > .impulse-section > .impulse-container{overflow:visible}
.impulse-el[data-type="section"]:has(.impulse-display-text){overflow:visible}
.impulse-content-wrapper:has(.impulse-display-text){overflow:visible}
/* trailing-drop-space — 50vh empty area after the last block, exists so
   the user can scroll past their content and drag-drop new blocks at the
   bottom of the page. Without an affordance new users mistake it for a
   bug ("why is my page so tall?"); we paint it as a visible drop target:
   dashed editor-chrome border + Lucide plus icon + small "Drop block
   here" label. Idle 0.5 opacity so it sits quiet; bumps to 0.85 on
   body.drag-active when the user has actually picked up a block.
   The icon (a Lucide plus, stroke #bbb, 28px) is inlined as a data: URL
   so this rule is fully self-contained — no asset fetch on first paint.
   Scene-mode (data-screen-mode="1") suppresses the drop zone. */
body:not([data-screen-mode="1"]) .impulse-content-wrapper::after{
    content:'Drop block here';
    display:flex;align-items:center;justify-content:center;
    height:15vh;flex-shrink:0;flex-grow:0;
    margin:8px 16px 0;
    color:#999;font-size:11px;letter-spacing:0.06em;text-transform:uppercase;
    text-align:center;padding-top:32px;
    background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 24 24' fill='none' stroke='%23bbb' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><line x1='12' y1='5' x2='12' y2='19'/><line x1='5' y1='12' x2='19' y2='12'/></svg>");
    background-repeat:no-repeat;
    background-position:center calc(50% - 4px);
    background-size:28px 28px;
    border:1px dashed #d0d0d0;border-radius:6px;
    opacity:0.5;transition:opacity 0.15s;
}
body.drag-active .impulse-content-wrapper::after{opacity:0.85}
/* Footer present → suppress the trailing 50vh entirely. The footer
itself becomes the natural visual end of the page; leaving 50vh below it
creates empty space the user scrolls past for nothing.
Note: the footer is rendered OUTSIDE .impulse-content-wrapper (as a body-
level sibling, see the render flow further down), so the old selector
`:has(> .impulse-el[data-type="footer"]:last-child)` on the content
wrapper never matched and the suppression silently failed. Walk up to body
and check for any footer descendant instead — if a footer exists
anywhere in the tree the wrapper's trailing affordance is suppressed. */
body:not([data-screen-mode="1"]):has(.impulse-el[data-type="footer"]) .impulse-content-wrapper::after{height:0;padding:0;margin:0;border:none;background:none;content:''}
.impulse-el[data-type="section"].selected{outline:1px solid #ddd;outline-offset:0}
/* HIDDEN-ON-DEVICE feedback. The attributes were emitted and the context menu
   read them, but nothing ever SHOWED it: a block hidden on mobile looked exactly
   like one that was not (Sergei 2026-08-10).
   Shown ONLY on the tab where the block is actually absent. On any other tab it
   renders normally, because there it IS normal: a desktop-tab canvas telling you
   about a mobile rule is noise about a device you are not looking at. And on the
   tab that hid it, the device name is redundant with the tab itself, so the tag
   is just HIDDEN. Editor only; the published page omits the element. */
body:not(.impulse-device-tablet):not(.impulse-device-mobile) .impulse-el[data-hide-desktop="1"],
body.impulse-device-tablet .impulse-el[data-hide-tablet="1"],
body.impulse-device-mobile .impulse-el[data-hide-mobile="1"]{
    position:relative;opacity:.38;filter:grayscale(1);
}
body:not(.impulse-device-tablet):not(.impulse-device-mobile) .impulse-el[data-hide-desktop="1"]::after,
body.impulse-device-tablet .impulse-el[data-hide-tablet="1"]::after,
body.impulse-device-mobile .impulse-el[data-hide-mobile="1"]::after{
    content:'HIDDEN';position:absolute;top:0;right:0;z-index:9991;
    background:#1d2327;color:#999;font:9px/1 -apple-system,BlinkMacSystemFont,sans-serif;
    letter-spacing:.06em;padding:3px 5px;border-radius:0 0 0 3px;pointer-events:none;
}
/* Header offset belongs to the FIRST screen only: it is the one the fixed header
   overlaps. Sections emit padding-top:calc(<pad> + var(--impulse-header-height))
   inline, so zero the variable everywhere except the first screen and the calc
   resolves to the author's own padding.
   Each section sits in its OWN .impulse-screen > .impulse-page >
   .impulse-content-wrapper, so every section is :first-child of its wrapper and
   the screen index is the only discriminator. Descendant selector, not child:
   there are two wrappers in between. Sergei 2026-08-07, a section set to 7px was
   rendering 76px. */
/* "any screen preceded by another screen", not ":not(:first-child)": the screen
   wrapper holds non-screen elements too (canvases), so the first SCREEN is not
   the first CHILD and the hero was losing its offset. */
.impulse-screen ~ .impulse-screen .impulse-section,
.impulse-el[data-type="section"] ~ .impulse-el[data-type="section"] .impulse-section {
    --impulse-header-height: 0px;
}
.impulse-el[data-type="section"].impulse-section-empty{outline:1px solid rgba(128,128,128,0.15);outline-offset:0;min-height:60px}

/* Modal (editor-as-runtime): shows the "Modal" chip in flow by default. When the modal
   .impulse-el is selected, the full-viewport scrim slides in over the iframe, giving the
   author the actual runtime surface to drop blocks into. */
.impulse-el[data-type="modal"]{display:inline-block!important;width:max-content!important;max-width:max-content!important;align-self:flex-start!important;outline:none!important;margin:2px 4px!important;position:relative;overflow:visible;box-sizing:border-box}
.impulse-el[data-type="modal"] .impulse-modal-chip{display:inline-flex}
.impulse-el[data-type="modal"] .impulse-modal-scrim{display:none}
.impulse-el[data-type="modal"].selected .impulse-modal-chip{opacity:0.55}
.impulse-el[data-type="modal"].selected .impulse-modal-scrim{display:block!important}
.impulse-el[data-type="modal"] > .impulse-el-toolbar{display:none!important}
/* Modal toolbar sits just outside the chip's right edge, vertically centred. */
.impulse-el[data-type="modal"] > .impulse-el-toolbar{top:50%!important;left:100%!important;right:auto!important;margin-left:4px;transform:translate(-6px,-50%) scale(0.9)!important;transform-origin:left center!important}
.impulse-el[data-type="modal"]:hover > .impulse-el-toolbar,
.impulse-el[data-type="modal"]:focus-within > .impulse-el-toolbar{transform:translate(0,-50%) scale(1)!important}
.impulse-modal-content:has(.impulse-el) > .impulse-modal-dropzone{display:none}
.impulse-modal-dropzone::before,.impulse-modal-dropzone::after{display:none!important}
.impulse-modal-dropzone,.impulse-modal-dropzone:hover{transform:none!important;transition:background-color 240ms ease!important}
.impulse-el[data-type="modal"] .impulse-modal-container{outline:none}
.impulse-el[data-type="modal"].selected .impulse-modal-container{outline:1px solid #999;outline-offset:-1px}
/* When the modal is open (scrim visible), only the drop-inside feedback on it should show
   the modal boundary. Suppress the generic .drop-inside rule applied to the outer .impulse-el
   since that rule uses the whole (tiny) chip bounds — we want the real modal container lit. */
.impulse-el[data-type="modal"].drop-inside{outline:none!important;background:transparent!important}
.impulse-el[data-type="modal"].drop-inside .impulse-modal-container{outline:1px solid rgba(153,153,153,0.6)!important;outline-offset:-1px;background-color:transparent}

/* Off-Canvas panel */
.impulse-el[data-type="modal"]{outline:1px solid #ddd;outline-offset:-1px;margin-bottom:2px;position:relative;overflow:hidden;width:100%;max-width:100%;box-sizing:border-box}
.impulse-el[data-type="modal"]:hover{outline:1px solid #bbb;outline-offset:-1px}
.impulse-el[data-type="modal"].selected{outline:1px solid #999;outline-offset:-1px}
.impulse-el[data-type="modal"]::before{content:'off-canvas';position:absolute;top:0;left:0;background:#f5f5f5;color:#999;font-size:9px;padding:2px 6px;text-transform:uppercase;letter-spacing:0.5px;opacity:0;transition:opacity 0.3s;z-index:5}
.impulse-el[data-type="modal"]:hover::before{opacity:1}

/* 2026-05-23: section / screen labels removed entirely. In screen mode
   the Screens sidebar already lists every screen by number and name; the
   floating "Screen N" badge added clutter without information. The
   non-screen-mode "Section" label was equally redundant once Structure
   tab + sidebar were established. Removed both ::before rules so the
   editor canvas stays clean. */
/* Screen mode: every direct-child section IS a screen.
   2026-05-23: screen toolbars hidden entirely. Structure panel + Screens
   sidebar cover all screen-level actions; a floating screen toolbar in
   the corner just clashed with the header's own toolbar at the same
   position. */
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"] > .impulse-el-toolbar{display:none !important}

/* Empty page state */
.impulse-empty-page{text-align:center;padding:100px 40px;color:#999}
.impulse-empty-page p{margin:0 0 8px}
.impulse-empty-hint{font-size:14px}

/* Section styling */
.impulse-section{background:transparent;position:relative;z-index:1}
/* Background-mode Kinetic paints at z-index:-1; keep that inside the section.
   The z-index:1 above already isolates here, but state it so a future change
   to that value cannot silently drop the block behind the section. Mirrors
   assets/css/frontend.css. */
.impulse-section:has(.impulse-kinetic-bg){isolation:isolate}
/* The editor wraps EVERY element in .impulse-el{position:relative}; the
   frontend does not. That wrapper captured a background-mode block's
   inset:0 and, with no in-flow child left, collapsed to zero height. The
   block lost its box, the engine fell back to its 480px stage floor, and
   the composition rendered huge and cut off around mid-screen. Promote the
   wrapper itself to the section-filling layer so the block fills IT. */
/* z-index:-1 would paint it below the in-flow siblings but also below the
   editor's own hit targets, leaving the block unselectable on the canvas.
   Keep the wrapper at 0 and lift the SIBLINGS to 1 instead: same paint
   order, but the background stays a normal, clickable element. */
/* !important: the editor writes position:relative INLINE on .impulse-el for
   selection and drag, and inline beats any selector however specific. Without
   it the wrapper stayed relative and collapsed to 0 height, so a background
   block anchored to a 0-tall box that the container centred - the block then
   painted downward from the section's midpoint. */
/* z-index:-1, not 0. At 0 the wrapper only stayed behind because the rule
   below lifted its siblings to 1 - and that rule uses a direct-child
   combinator, so it reaches nothing when the section's content is nested any
   deeper (a grid, a row). An absolutely positioned box at z-index 0 then
   paints OVER plain in-flow content and the backdrop covers the section
   (Sergei 2026-08-08, academy section 3). At -1 it is behind everything
   regardless of how the content is nested, and the isolation:isolate above
   keeps it from escaping past the section's own background. */
/* DESCENDANT, not direct child. The `>` form only matched when the block sat
   as an immediate child of its .impulse-el; the editor nests deeper than that
   in some sections, and there the rule silently never applied - which is why
   two stacking fixes in a row changed nothing (Sergei 2026-08-08, academy
   section 3). */
.impulse-el:has(.impulse-kinetic-bg){position:absolute !important;inset:0 !important;width:auto !important;height:auto !important;z-index:-1 !important;margin:0 !important}
/* pointer-events stays AUTO. This carried none for one build, as a guard while
   the stacking was still wrong - and it made the block impossible to select in
   the canvas: every click went through to the section behind it (Sergei
   2026-08-08). With z-index:-1 above actually taking effect the guard is not
   needed and costs the block its own hit area: the backdrop paints behind the
   content, so a click over a heading selects the heading, and a click over the
   empty part of the section selects the backdrop, which is what it should be. */
.impulse-el:has(.impulse-kinetic-bg) .impulse-kinetic-bg{z-index:auto;position:absolute;inset:0}
.impulse-container:has(.impulse-kinetic-bg) > .impulse-el:not(:has(> .impulse-kinetic-bg)){position:relative;z-index:1}

/* Background shapes layer - always clipped by section */
.impulse-bg-shape-layer{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:3;overflow:hidden}
/* 2026-05-23: was z-index:1!important. The `!important` overrode the inline
   z-index:0 we now emit from section.php. Removed so the inline (z-index:0,
   which keeps overlay below .impulse-container's z-index:4 content layer)
   actually takes effect. Overlay now sits in the same layer as bg-video
   and paints above it via DOM order, but always below section content. */
.impulse-section-overlay{z-index:0}
.impulse-section-bg-video{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0;pointer-events:none}
.impulse-section-bg-video-fallback{position:absolute;top:0;left:0;width:100%;height:100%;background-size:cover;background-position:center;z-index:0;pointer-events:none;display:none}
@media(hover:none),(pointer:coarse){.impulse-section-bg-video{display:none!important}.impulse-section-bg-video-fallback{display:block!important}}
.impulse-section-animation{z-index:2}
/* 2026-05-29: .impulse-container needs to sit above any positioned bg sibling.
   Previously gated on .impulse-bg-shape-layer only, so a section with an
   animation_layer (z-index:2) but no bg-shape left the container unpositioned
   — and the positioned animation layer painted on top of the heading + grid,
   appearing as "the gradient erases text on hover" (mouse parallax sweeps the
   coloured blobs across content). Extended to every section-bg sibling so
   content is always above bg art. */
.impulse-bg-shape-layer ~ .impulse-container,
.impulse-section-animation ~ .impulse-container,
.impulse-section-bg-video ~ .impulse-container,
.impulse-section-bg-video-fallback ~ .impulse-container,
.impulse-section-overlay ~ .impulse-container{position:relative;z-index:4}
/* A background-mode Kinetic block must fill the SECTION. The overlay rule
   above makes the container position:relative, which turns it into the
   containing block and caps the block at the container's max-width (1440
   inside a 1920 section), so Background stopped short of full width.
   Drop the positioning only when a background block is present: the
   content keeps its own stacking from the sibling rule, which already
   sits above the overlay. Must come AFTER the overlay rule - equal
   specificity, so source order decides. */
.impulse-container:has(.impulse-kinetic-bg){position:static !important}
/* The container is what was swallowing the clicks. It spans the whole section
   and is NOT an .impulse-el, so the editor walked up from it to the section and
   opened section settings - every time the pointer was over the empty part of a
   section carrying a backdrop (Sergei 2026-08-08). Make the container itself
   transparent to the pointer and hand it back to its real children: content
   still selects content, and empty space now reaches the backdrop sitting at
   z-index:-1 underneath, which is the block the user is aiming at. */
.impulse-container:has(.impulse-kinetic-bg){pointer-events:none}
/* DESCENDANT: content nests inside grids and rows, so a direct-child rule
   handed the pointer back to the outer wrapper only and left everything deeper
   unclickable - the backdrop then won every point in the section, which is the
   opposite mistake. An .impulse-el only occupies its own box, so empty space
   still falls through to the backdrop. */
.impulse-container:has(.impulse-kinetic-bg) .impulse-el{pointer-events:auto}
/* !important because the container carries position:relative;z-index:4 as an
   INLINE style from the renderer, which no stylesheet rule can outrank. The
   z-index goes inert with it, so the content relies on the sibling rule above
   (z-index:1) to stay above a section overlay, which sits at 0. */
.impulse-bg-shape-el{pointer-events:auto;cursor:move;transition:opacity 0.15s}
.impulse-bg-shape-el:hover{outline:1px dashed rgba(100,100,255,0.4);outline-offset:2px}
.impulse-bg-shape-el svg{display:block;width:100%;height:100%}

/* Grid - no visible border on wrapper or grid */
.impulse-el[data-type="grid"] .impulse-grid-wrapper,
.impulse-el[data-type="grid"] .impulse-grid,
.impulse-el[data-type="rows"] .impulse-rows{border:none!important;outline:none!important}
/* Grid live min-height reads its OWN variable. It used to read
   --impulse-min-height, which is the SECTION's live min-height var - and a
   custom property INHERITS, so the moment a section wrote its own height the
   value landed on every descendant grid, whose single auto row then stretched
   to the full screen height and pushed the section far past its height setting
   (Sergei 2026-07-30, installer-site section 6: touching any background
   gradient swatch, which triggers the section live-update, blew the section
   from 561px to 1012px until a hard refresh). Distinct names cannot collide. */
.impulse-el[data-type="grid"] .impulse-grid-wrapper{min-height:var(--impulse-grid-min-height,auto);margin-left:auto;margin-right:auto}
.impulse-el[data-type="grid"] .impulse-grid{min-height:var(--impulse-grid-min-height,auto)}

/* Row styling */  
.impulse-row{border:1px solid #eee;background:rgba(0,0,0,0.01);min-height:60px;position:relative}

/* Column styling */
.impulse-column{border:1px solid #eee;background:#fff;min-height:40px}

/* Container - no auto-centering, alignment controlled by section flex */
.impulse-container{margin-left:var(--impulse-content-margin-left,auto);margin-right:var(--impulse-content-margin-right,auto);padding:0;width:100%;max-width:var(--impulse-content-max-width,100%)}
.impulse-container-full{width:100%;max-width:none;padding:0}

/* Section */
.impulse-section{width:100%;max-width:100%;box-sizing:border-box}

/* Screen Mode - screens stacked, no scrolling, pure CSS transitions */
/* --impulse-vh is synced to window.innerHeight by JS on load/resize to avoid iframe
   height races on reload where 100vh resolves against pre-sizing iframe height. */
html:has(body[data-screen-mode="1"]),body[data-screen-mode="1"]{overflow:hidden;height:var(--impulse-vh,100vh);max-height:var(--impulse-vh,100vh)}
body[data-screen-mode="1"] .impulse-content-wrapper{position:relative;height:var(--impulse-vh,100vh);overflow:hidden;gap:0!important;padding:0!important;margin-bottom:0!important}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"]{position:absolute;top:0;left:0;width:100%;height:var(--impulse-vh,100vh);z-index:0;visibility:hidden;pointer-events:none}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"]:first-child{z-index:2;visibility:visible;pointer-events:auto}
.impulse-snav-current{font-size:13px;font-weight:500;opacity:0.7;letter-spacing:0.02em}
.impulse-snav-panel{position:absolute;top:100%;left:0;width:auto;min-width:120px;max-width:25vw;padding:12px 20px;z-index:10000;display:none;flex-direction:column;gap:0;background:none}
.impulse-snav-panel.open{display:flex!important}
.impulse-snav-link{display:block;padding:10px 0;text-decoration:none;font-size:15px;font-weight:500;border-bottom:1px solid rgba(128,128,128,0.15);transition:opacity 0.15s;opacity:0.5;white-space:nowrap}
.impulse-snav-link:last-child{border-bottom:none}
.impulse-snav-link.active{opacity:1}
.impulse-snav-link:hover{opacity:0.55}
@media(max-width:1024px){.impulse-snav-panel{max-width:50vw}}
@media(max-width:767px){.impulse-snav-panel{max-width:100vw;left:0}
.impulse-screen-nav{padding:6px 10px!important}
.impulse-screen-nav .impulse-screen-nav-logo span{font-size:14px!important}
}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"].impulse-screen-active{z-index:2;visibility:visible;pointer-events:auto}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"].impulse-screen-animating{z-index:3;visibility:visible;pointer-events:none}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"] > .impulse-section{height:var(--impulse-vh,100vh);min-height:var(--impulse-vh,100vh);max-height:var(--impulse-vh,100vh);overflow:hidden}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"]:has(.impulse-display-text) > .impulse-section{overflow:visible}
/* 2026-05-24: same carve-out for the Title animator (.impulse-logo-animator).
   Without it, the screen-mode `overflow:hidden` above wins on specificity
   over the generic `.impulse-el[data-type="section"]:has(.impulse-logo-animator)
   > .impulse-section{overflow:visible}` rule earlier in this file, so the
   animator's breathing scale + tilt sweep gets clipped at the screen edge
   (visible as cyclic shave-and-restore on the title text). */
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"]:has(.impulse-logo-animator) > .impulse-section{overflow:visible}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"]:has(.impulse-logo-animator){overflow:visible}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"].impulse-screen-active > .impulse-section{height:auto;max-height:none;min-height:var(--impulse-vh,100vh);overflow:visible}
/* 2026-05-29: when active screen's content exceeds 100vh, the OUTER section
   must be the scroll container so the wheel/touch handlers can read
   scrollTop/scrollHeight and gate screen transitions on edge-reached. The
   inner .impulse-section above is already height:auto + overflow:visible, so
   the outer's overflow-y:auto picks up the natural overflow as scroll. Without
   this, scrollHeight === clientHeight, the edge gates always evaluate TRUE,
   and every wheel/swipe fired a screen transition instead of an internal
   scroll — Sergei's "content expands over hero background" report. */
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"].impulse-screen-active{overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain}
/* Zone merge (Sergei 2026-06-03): env is page-wide — it renders behind every
   screen, so there is no body-zone env-hide. The old `.impulse-past-hero`
   canvas-hide rule was removed; _updatePastHero() no longer adds the class. */
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"] > .impulse-section > .impulse-container{height:100%;display:flex;flex-direction:column}
body[data-screen-mode="1"]:not(.impulse-scroll-classic) .impulse-content-wrapper > .impulse-el[data-type="section"] > .impulse-section > .impulse-container{max-width:none!important}
/* Tall Screens on mobile: unlock all the 100vh / overflow:hidden rules so
   each section is content-tall (with min-height:100vh as anchor) and the
   body scrolls naturally end-to-end. Solves the "grid stacks to 1-col on
   mobile and overflows the fixed viewport" problem. */
/* Active screen scrolls internally when its content overflows the viewport.
   Edge-triggered transitions in the wheel/touch handlers fire goToScreen()
   only when scrollTop is at 0/end. */
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"].impulse-screen-active{overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;scrollbar-width:none;-ms-overflow-style:none}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"].impulse-screen-active::-webkit-scrollbar{width:0;height:0;background:transparent}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"]:not(.impulse-screen-active){overflow:hidden}
/* Scene editing mode: hide flying screens completely — same
   single-screen behavior as selecting any regular screen from the SCENE list. */
body.impulse-scene-editing[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"]{visibility:hidden !important;pointer-events:none !important;opacity:0 !important}
/* Images in root screens: prevent overflow, scale down to fit viewport */
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"] .impulse-el[data-type="image"] img{max-height:100vh;object-fit:contain}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"] .impulse-el[data-type="image"] .impulse-image-wrapper{max-height:100vh}
/* Screen mode: suppress hover/select outlines ONLY on the root screen
   sections themselves (the section IS the screen — outlines would
   compete with the transition stage). Children inside (heading, text,
   image, etc.) keep their normal hover/select feedback so the designer
   can see what they're about to click → Ctrl+D / Duplicate operates on
   the expected target, not the parent screen.

   Previous rule blanket-suppressed every .impulse-el → user clicked
   "the title" → couldn't see whether title or section was selected →
   Ctrl+D duplicated the section. */
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"].selected,
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"]:hover{outline:none!important;border:none!important;box-shadow:none!important}
/* Classic scroll EXCEPTION: sections here can be shorter than the viewport, so
   (unlike self-evident full-screen cinematic sections) the designer needs to
   SEE which one is selected. On these sections `outline` is unreliable — it's
   pinned by the screen engine, so even inline !important can't set it — and an
   inset box-shadow hides behind full-bleed backgrounds. A pseudo-element border
   overlay paints ABOVE the section content, so it shows on every section type.
   Selected = solid #555; hover = lighter #999 beneath it. pointer-events:none
   keeps clicks passing through to child blocks. */
/* Top and bottom only, and light. A section always spans the full width, so the
   left and right edges mark nothing the eye did not already know, and #555 read
   as a black box drawn around the design. #e0e0e0 at 2px still reads instantly
   against the canvas without competing with the content. Sergei 2026-08-07. */
body.impulse-scroll-classic .impulse-content-wrapper > .impulse-el[data-type="section"].selected::after{content:'' !important;position:absolute !important;inset:0 !important;border:0 !important;border-top:2px solid #e0e0e0 !important;border-bottom:2px solid #e0e0e0 !important;pointer-events:none !important;z-index:9990 !important;display:block !important}
body.impulse-scroll-classic .impulse-content-wrapper > .impulse-el[data-type="section"]:hover:not(.selected)::after{content:'' !important;position:absolute !important;inset:0 !important;border:0 !important;border-top:1px solid #999 !important;border-bottom:1px solid #999 !important;pointer-events:none !important;z-index:9990 !important;display:block !important}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-root-dropzone{display:none !important}
body[data-screen-mode="1"]:not(.impulse-scroll-classic) .impulse-content-wrapper > .impulse-el[data-type="section"] .impulse-dropzone:not(.impulse-new-slider-dropzone){display:none !important}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"] .impulse-empty-section{display:none !important}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"] .impulse-add-element{display:none !important}
/* Toolbar: hidden on animating screens, no transition participation */
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"] > .impulse-el-toolbar{transition:none !important}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"].impulse-screen-animating > .impulse-el-toolbar{opacity:0 !important;pointer-events:none !important}
/* Layout blocks with fill_container inside a screen stretch to fill it */
body[data-screen-mode="1"] .impulse-el[data-fill="1"]{flex:1;min-height:0;height:100%}
/* Per-block "Push to bottom" (right-click -> Push to bottom): margin-top:auto in
   the section's flex column drops this .impulse-el + those after it to the bottom
   edge, leaving earlier blocks pinned at the top. Both scroll modes. v2.1.2164. */
.impulse-container > .impulse-el[data-push-bottom="1"]{margin-top:auto !important}
/* Per-block "Push to top" (right-click -> Push to top): margin-bottom:auto pins
   this .impulse-el + those before it to the top, dropping later blocks down.
   Mutually exclusive with push-bottom. Both scroll modes. */
.impulse-container > .impulse-el[data-push-top="1"]{margin-bottom:auto !important}
/* PER-DEVICE push, editor mirror of the frontend rules. Absent attribute =
   inherit desktop, "1" = pin, "0" = explicitly do not pin. The device tabs
   resize the canvas, so these breakpoints fire in the builder exactly as they
   do on the published page. Sergei 2026-08-07. */
@media (max-width: 1024px) {
.impulse-container > .impulse-el[data-push-bottom-tablet="1"]{margin-top:auto !important}
.impulse-container > .impulse-el[data-push-bottom-tablet="0"]{margin-top:0 !important}
.impulse-container > .impulse-el[data-push-top-tablet="1"]{margin-bottom:auto !important}
.impulse-container > .impulse-el[data-push-top-tablet="0"]{margin-bottom:0 !important}
}
@media (max-width: 767px) {
.impulse-container > .impulse-el[data-push-bottom-mobile="1"]{margin-top:auto !important}
.impulse-container > .impulse-el[data-push-bottom-mobile="0"]{margin-top:0 !important}
.impulse-container > .impulse-el[data-push-top-mobile="1"]{margin-bottom:auto !important}
.impulse-container > .impulse-el[data-push-top-mobile="0"]{margin-bottom:0 !important}
}
/* Counterweight for CENTRED sections: one child's auto margin absorbs ALL the
   free space, so pushing a block to an edge slammed everything else to the
   opposite edge. A zero-size spacer with the opposite auto margin makes flexbox
   split the space between the two, so the remaining content stays centred
   (approximately: offset only by the pushed block's own height). Top/bottom
   valign keeps the old behaviour, which is already what those layouts expect. */
/* Only when pushed on ONE side: a section pinned at BOTH edges (eyebrow pushed
   top + title/button pushed bottom, the classic hero split) is already balanced
   by its own two auto margins - adding counterweights there splits the space
   four ways and everything drifts toward the middle (Sergei 2026-07-29,
   generic-site hero). */
.impulse-container.impulse-valign-center:has(> .impulse-el[data-push-bottom="1"]):not(:has(> .impulse-el[data-push-top="1"]))::before{content:'';margin-bottom:auto}
.impulse-container.impulse-valign-center:has(> .impulse-el[data-push-top="1"]):not(:has(> .impulse-el[data-push-bottom="1"]))::after{content:'';margin-top:auto}
/* ONE bottom anchor per container - mirror of the frontend rules in
   assets/css/frontend.css, kept byte-identical in intent so the canvas and the
   published page can never disagree. Every auto margin in the flex column takes
   an EQUAL share of the free space, so a second block asking for the bottom
   makes the column spread instead of grouping (eyebrow / title / button in
   thirds: Sergei 2026-07-29/30). First claim wins; later ones are neutralised.
   Covers both spellings: push_bottom, and the Button's legacy btn_pin_bottom
   (inline margin, tagged .impulse-btn-pin-bottom). CSS rather than PHP because
   sibling order survives the editor re-rendering a single block on its own. */
.impulse-container > .impulse-el[data-push-bottom="1"] ~ .impulse-el[data-push-bottom="1"]{margin-top:0 !important}
/* Mirror of the multi-push-bottom rule above, same flexbox trap: EVERY auto
   margin takes an EQUAL share of the free space, so two blocks pushed to the top
   plus one pushed to the bottom made three competing autos and an evenly spread
   column, with the second block stranded mid-section (Sergei 2026-08-03, academy
   hero). Only the LAST push-top keeps its auto margin. Also fixes top-only
   sections, where the ::after counterweight was the third competing auto. */
.impulse-container > .impulse-el[data-push-top="1"]:has(~ .impulse-el[data-push-top="1"]){margin-bottom:0 !important}
.impulse-container > .impulse-el[data-push-bottom="1"] ~ * .impulse-btn-pin-bottom,
.impulse-container > .impulse-el[data-push-bottom="1"] ~ .impulse-btn-pin-bottom{margin-top:0 !important}
/* "Fit parent" (fill_container) also fills in CLASSIC scroll, not only screen mode
   (the section is a flex column either way). data-fill is only set on an explicit
   opt-in, so this can't over-stretch un-opted layouts. v2.1.2164. */
body.impulse-scroll-classic .impulse-el[data-fill="1"]{flex:1 1 auto;min-height:0}
body[data-screen-mode="1"] .impulse-el[data-fill="1"] > .impulse-new-slider,
body[data-screen-mode="1"] .impulse-el[data-fill="1"] > .impulse-grid-wrapper,
body[data-screen-mode="1"] .impulse-el[data-fill="1"] > .impulse-rows,
body[data-screen-mode="1"] .impulse-el[data-fill="1"] > .impulse-layout-grid{height:100%!important;min-height:0!important}
body[data-screen-mode="1"] .impulse-el[data-fill="1"] > .impulse-grid-wrapper > .impulse-grid{height:100%!important;min-height:0!important}

/* ── Sandbox visibility — DEPTH REVEAL ────────────────────────────────
   Sandbox reveals "from depth" — same vibe as the scene's Depth-reveal
   transition. Scales up from scale(0.55) to scale(1) with overshoot
   spring on entry. Exits by falling back into depth at scale(0.7) +
   fade, with snappy ease-in-out.

   pointer-events on the sandbox itself ALWAYS stays :none (full-viewport
   overlay; auto would capture wheel events globally). Rail/dock children
   carry their own pointer-events:auto. */
body[data-screen-mode="1"] .impulse-hero-sandbox{opacity:0;visibility:hidden;transform:scale(0.55);pointer-events:none;transform-origin:center center;transition:none;will-change:opacity,transform}
/* Visible — depth-spring forward (overshoot).
   The previous version was a `:has(...:not(:first-of-type).impulse-screen-active)`
   selector that watched the active-class hand-off live. In the merged-mode
   architecture the editor engine briefly flaps the active class during screen
   transitions, which made the rule match/unmatch every couple hundred ms —
   sandbox flashed in then vanished. We now drive the reveal off
   `body.impulse-sandbox-revealed`, which impulse-hero-sandbox.php's _sbToggle
   adds whenever a non-first screen activates (frontend AND editor — _sbToggle
   understands both DOM shapes). The class is stable across the transition, so
   no flapping. !important is needed to override the screen-mode baseline that
   pins opacity:0 + scale(0.55) on the sandbox at rest. */
body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox{opacity:1 !important;visibility:visible !important;transform:scale(1) !important;transition:none !important}
/* Exit — fall back into depth (scale 0.7 + fade, snappy ease-in-out). */
body[data-screen-mode="1"].impulse-hero-exiting .impulse-hero-sandbox{opacity:0 !important;visibility:hidden !important;transform:scale(0.7) !important;transition:opacity 220ms cubic-bezier(0.4, 0, 0.2, 1),transform 260ms cubic-bezier(0.4, 0, 0.2, 1),visibility 0s linear 260ms !important}
/* Chevrons pointing at sandbox panels — only on the last hero
   screen where the sandbox is most useful. Two chevrons: one above
   the transitions rail, one above the env+thumbs dock. Pulse downward
   to draw attention. Lucide chevron-down SVG, white-grey, subtle. */
/* Chevrons pointing at sandbox panels — fly in dramatically from the
   centre of the viewport (roughly where the "Try it yourself" title
   sits) when the last screen activates. Dock chevron drops from above; rail
   chevron flies in from the left. Both scale up + de-blur during
   flight for a sense of distance → arrival. Pulse keyframes on the
   inner SVG keep running independently of the wrapper fly-in —
   they're on different elements, so the transforms compose visually
   without fighting.

   Math-based centring (calc(50% - 24px)) instead of translateX/Y(-50%)
   frees up `transform` on the wrapper for the fly-in animation. SVG
   inside still uses transform freely for the pulse. */
.impulse-hero-sandbox-chevron{position:absolute;left:calc(50% - 24px);width:48px;height:48px;color:rgba(255,255,255,0.85);pointer-events:none;opacity:0;visibility:hidden}
.impulse-hero-sandbox-chevron svg{width:100%;height:100%;display:block;animation:impulse-chevron-pulse 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite}
/* Rail chevron sits to the LEFT of the rail (rail is on the right side
   of the screen), pointing RIGHT (→) at the effect buttons via -90deg
   rotation. Math-based vertical centring. */
.impulse-hero-sandbox-chevron-rail{top:calc(50% - 24px);right:calc(100% + 10px);left:auto}
.impulse-hero-sandbox-chevron-rail svg{animation:impulse-chevron-pulse-right 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite}
/* Pulse opacity range tightened from 0.25↔0.6 to 0.65↔1 so the
   chevron is always solidly visible — the pulse signals motion, not
   "blink out then back". */
@keyframes impulse-chevron-pulse-right{0%,100%{transform:rotate(-90deg) translateY(0);opacity:0.65}50%{transform:rotate(-90deg) translateY(8px);opacity:1}}
/* Dock chevron is a sibling of dock — positioned from viewport so it sits
   above the copy line. Copy at bottom:106px (~14px tall), 10px gap, chevron
   bottom at 130px → chevron occupies bottom 130-178px. */
.impulse-hero-sandbox-chevron-dock{bottom:130px;top:auto}
@keyframes impulse-chevron-pulse{0%,100%{transform:translateY(0);opacity:0.65}50%{transform:translateY(8px);opacity:1}}
/* Fly-in keyframes. translateY(-32vh) / translateX(-35vw) start
   positions place the chevron roughly where the "Try it yourself"
   title sits (viewport centre, top half), scaled to 0.35 with a 6px
   blur for a "coming from a distance" feel. By 40% the chevron is
   close enough to be fully opaque, then settles into its dock
   position with the springy easing on the activation rule below. */
@keyframes impulse-chevron-fly-dock{0%{transform:translateY(-32vh) scale(0.35);opacity:0;filter:blur(6px)}40%{opacity:1;filter:blur(2px)}100%{transform:translateY(0) scale(1);opacity:1;filter:blur(0)}}
@keyframes impulse-chevron-fly-rail{0%{transform:translateX(-35vw) scale(0.35);opacity:0;filter:blur(6px)}40%{opacity:1;filter:blur(2px)}100%{transform:translateX(0) scale(1);opacity:1;filter:blur(0)}}
/* Snap-out: cancel any in-flight fly-in, scale-down fade matching the
   sandbox snap-out timing. Includes hint labels alongside chevrons. */
body[data-screen-mode="1"].impulse-hero-exiting .impulse-hero-sandbox-chevron,
body[data-screen-mode="1"].impulse-hero-exiting .impulse-hero-sandbox-hint{opacity:0 !important;visibility:hidden !important;transform:scale(0.7) !important;animation:none !important;transition:opacity 180ms cubic-bezier(0.4, 0, 0.2, 1),transform 180ms cubic-bezier(0.4, 0, 0.2, 1),visibility 0s linear 180ms !important}

.impulse-screen-nav{position:fixed;right:12px;top:50%;transform:translateY(-50%);z-index:9999;display:flex;flex-direction:column;gap:8px}
.impulse-screen-nav-dot{width:8px;height:8px;border-radius:50%;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.4);cursor:pointer;transition:all .3s ease;padding:0}
.impulse-screen-nav-dot:hover{background:rgba(0,0,0,.5);transform:scale(1.3)}
.impulse-screen-nav-dot.active{background:#1d2327;border-color:#1d2327;transform:scale(1.3)}

/* Stretch section - breaks out of any parent container to full viewport width */
.impulse-stretch{width:100vw;position:relative;left:50%;right:50%;margin-left:-50vw;margin-right:-50vw}
/* Content wrapper sections - disable stretch to stay within page boundaries */
.impulse-content-wrapper .impulse-stretch{width:100%!important;left:auto!important;right:auto!important;margin-left:0!important;margin-right:0!important}
/* But NOT when inside tabs or other containers - MUST respect container */
.impulse-tabs-content .impulse-stretch,
.impulse-tab-panel .impulse-stretch,
.impulse-tabs-content .impulse-section,
.impulse-tab-panel .impulse-section{width:100%!important;left:auto!important;right:auto!important;margin-left:0!important;margin-right:0!important;position:relative!important}
/* Ensure tabs content doesn't overflow */
.impulse-tabs-content{overflow:hidden}
.impulse-tab-panel{overflow:hidden}

/* Accordion and Toggle - full width */
.impulse-unfold,.impulse-toggle{width:100%;box-sizing:border-box}
.impulse-el[data-type="unfold"],.impulse-el[data-type="toggle"]{width:100%}

/* Text - full width in all contexts */
.impulse-container .impulse-el[data-type="cart"]{width:100%!important;max-width:100%;align-self:stretch}
.impulse-text{width:100%;display:block}

/* Site Header & Footer - outside content wrapper, always full width */
.impulse-el[data-type="header"],
.impulse-el[data-type="footer"]{
    width:100%;
    box-sizing:border-box
}
/* Every page is a stack of full-viewport screens — the header MUST overlay,
   never push screens down. Wins over .sticky-header below via !important. */
.impulse-el[data-type="header"]{position:absolute!important;top:0;left:0;right:0;z-index:1000}
.impulse-el[data-type="header"].sticky-header{position:sticky;top:0;z-index:1000;background:#fff}
.impulse-el[data-type="header"].auto-hide-header{transition:transform 0.3s ease}
.impulse-el[data-type="header"].auto-hide-header.header-hidden{transform:translateY(-100%)}
.impulse-el[data-type="header"] .impulse-header,
.impulse-el[data-type="footer"] .impulse-footer{width:100%}
/* Snap-mode footer (v2.1.1843) — editor parity with templates/impulse-page.php.
   The footer is NOT its own stacked screen (the screen engine only activates
   sections, so a footer screen would stay hidden forever — the "footer only on
   frontend" bug). Instead it pins to the bottom of the last screen and reveals
   only while that screen is active (class toggled by JS below). */
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="footer"]{position:absolute;left:0;right:0;bottom:0;top:auto;width:100%;height:auto;z-index:6;visibility:visible;pointer-events:auto}
body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="footer"].impulse-snap-footer-hide{visibility:hidden;pointer-events:none}
/* Mobile: hide the pinned footer (frontend parity). The old rule set it
   position:static, which dropped it to the TOP of the content flow on the
   Mobile device tab — Sergei: "sits on the very top of the screen, absolutely
   inappropriate." Hidden on mobile to match templates/impulse-page.php. */
@media(max-width:759px){body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="footer"]{display:none!important}}
/* CLASSIC pages flow continuously in the editor (Sergei 2026-06-29: "the page
   IS classic scroll and it should end with the footer"). The screen-stacking
   rules above are gated on data-screen-mode="1", which classic pages also
   carry, so without this they'd be stacked screen-by-screen and the footer
   would pin/hide. Undo it for .impulse-scroll-classic: every section flows
   top-to-bottom (full-viewport min-height, matching the live page), the body
   scrolls, and the footer ends the page in normal flow. Snap pages (no
   .impulse-scroll-classic) keep the screen-by-screen preview. */
html:has(body.impulse-scroll-classic),body.impulse-scroll-classic{overflow-y:auto !important;height:auto !important;max-height:none !important}
body.impulse-scroll-classic .impulse-content-wrapper{height:auto !important;max-height:none !important;overflow:visible !important}
body.impulse-scroll-classic .impulse-content-wrapper > .impulse-el[data-type="section"]{position:relative !important;top:auto !important;left:auto !important;height:auto !important;min-height:0 !important;max-height:none !important;visibility:visible !important;opacity:1 !important;pointer-events:auto !important;z-index:auto !important}
body.impulse-scroll-classic .impulse-content-wrapper > .impulse-el[data-type="section"] > .impulse-section{height:auto !important;min-height:0 !important;max-height:none !important;overflow:visible !important}
body.impulse-scroll-classic .impulse-content-wrapper > .impulse-el[data-type="footer"]{position:relative !important;left:auto !important;right:auto !important;bottom:auto !important;top:auto !important;height:auto !important;visibility:visible !important;pointer-events:auto !important}
/* Classic footers stay fully interactive in the editor: the snap-footer hide
   rule (screen-mode) sets visibility:hidden;pointer-events:none, and this
   override restored visibility but NOT pointer-events, so on classic pages the
   footer was visible yet unclickable (clicks fell through to section 1). Match
   the hide rule's specificity and restore both. Sergei 2026-07-03. */
body.impulse-scroll-classic .impulse-content-wrapper > .impulse-el[data-type="footer"].impulse-snap-footer-hide{visibility:visible !important;pointer-events:auto !important}
/* Footer pinned to the bottom of the editor viewport on short classic pages.
   The single classic scroll container is height:auto (grows with content) but
   on a short page (e.g. the Solutions hub) that left the footer floating in
   the middle with empty space below. Give the wrapper a one-viewport minimum
   AND make it a flex column so the footer's margin-top:auto absorbs the slack
   and lands at the very bottom — mirroring the live .impulse-page-wrapper. On
   tall pages height:auto still wins, so the footer just flows after content.
   Scoped with :has() to footer-bearing wrappers only. Sergei 2026-07-04. */
body.impulse-scroll-classic .impulse-content-wrapper:has(> .impulse-el[data-type="footer"]){min-height:var(--impulse-vh,100vh) !important;display:flex !important;flex-direction:column !important}
body.impulse-scroll-classic .impulse-content-wrapper > .impulse-el[data-type="footer"]{margin-top:auto !important}
/* Undo/redo confirmation flash — a brief double ring (white + dark so it reads
   on any background: dark cinematic canvas OR a light section) drawn around the
   block that was just reverted/reapplied. Triggered by admin-core's
   _undoTargetedRefresh so the user sees exactly what changed without a full
   reload. Sergei 2026-07-04. */
@keyframes impulse-undo-flash{0%{box-shadow:0 0 0 0 rgba(255,255,255,0),0 0 0 0 rgba(0,0,0,0)}22%{box-shadow:0 0 0 3px rgba(255,255,255,.95),0 0 0 6px rgba(0,0,0,.5)}100%{box-shadow:0 0 0 0 rgba(255,255,255,0),0 0 0 0 rgba(0,0,0,0)}}
.impulse-el.impulse-undo-flash{animation:impulse-undo-flash .95s ease-in-out !important;position:relative;z-index:9995}

<?php
/* Footers do NOT belong on cinematic pages on client installs (Sergei
   2026-07-02). Hide the pinned footer in the editor on client sites +
   subdomains, mirroring the isInstinctorApp() gate in templates/impulse-page.php
   so the editor matches the frontend. The main instinctor.com app keeps its
   intentional cinematic footer; classic pages are untouched (:not(.impulse-scroll-classic)). */
if (!(class_exists('\\Instinctor\\Core\\Plugin') && \Instinctor\Core\Plugin::isInstinctorApp())) {
    echo 'body[data-screen-mode="1"]:not(.impulse-scroll-classic) .impulse-content-wrapper > .impulse-el[data-type="footer"]{display:none!important}';
}
?>
<?php
/* Resolve chrome-hide count from new int keys with legacy bool
   fallback — mirrors templates/impulse-page.php so editor preview
   matches frontend exactly. Was previously bound to the legacy bool
   only AND targeted only :first-child, which left the new
   hide_chrome_on_first_screens / _mobile settings invisible in the
   editor and made screens 2+ render with chrome when designers had
   asked for them to be hidden. */
$_edHideCount = isset($pageSettings['hide_chrome_on_first_screens'])
    ? max(0, intval($pageSettings['hide_chrome_on_first_screens']))
    : (!empty($pageSettings['hide_chrome_on_first_screen']) ? 1 : 0);
$_edHideCountMobile = isset($pageSettings['hide_chrome_on_first_screens_mobile'])
    ? max(0, intval($pageSettings['hide_chrome_on_first_screens_mobile']))
    : $_edHideCount;
$_edHideAny = ($_edHideCount > 0 || $_edHideCountMobile > 0);

/* Header element ids — needed to match the header renderer's
   `#impulse-header-N{position:relative!important}` mobile rule with
   ID specificity. The frontend version (templates/impulse-page.php)
   uses $headerElements from the tree-split; here we walk the
   already-loaded $elements array and pull every header id.
   Without this override, on mobile the header switches to
   position:relative and TAKES LAYOUT SPACE in flow even though
   visibility:hidden + translateY(-100%) hide it visually — result
   is the cosmos-above-screen gap and the visible header on the
   editor mobile tab even when chrome should be hidden. */
$_edHeaderIds = [];
if (isset($elements) && is_array($elements)) {
    foreach ($elements as $_eEl) {
        $_eType = is_object($_eEl) ? ($_eEl->section_type ?? '') : ($_eEl['type'] ?? '');
        if ($_eType !== 'header') continue;
        $_eId = is_object($_eEl) ? ($_eEl->id ?? null) : ($_eEl['id'] ?? null);
        if ($_eId) $_edHeaderIds[] = (string) $_eId;
    }
}

/* Build a comma-joined :has() selector that matches when ANY of the
   first N sections is currently .impulse-screen-active. Uses
   :nth-child(K) so the rule fires for screen index K-1 (1-based CSS,
   0-based screen index).

   IMPORTANT: the optional $el suffix is appended INSIDE the loop so
   every comma-joined branch carries the descendant. Concatenating the
   suffix OUTSIDE — i.e. `$_edHideHasSel($n) . ' .impulse-header'`
   — would only scope the LAST branch, leaving the earlier branches as
   bare body selectors that then inherit the rule's declarations
   (including `transform: translateY(-100%)`) and yank the entire
   editor canvas off-screen. Mirrors the frontend's `$_hideBodyPrefix`
   pattern in templates/impulse-page.php. */
$_edHideHasSel = function ($n, $el = '') {
    if ($n < 1) return '';
    $parts = [];
    $suf = $el !== '' ? ' ' . $el : '';
    for ($i = 1; $i <= $n; $i++) {
        $parts[] = 'body[data-screen-mode="1"]:has(.impulse-content-wrapper > .impulse-el[data-type="section"]:nth-child(' . $i . ').impulse-screen-active)' . $suf;
    }
    return implode(',', $parts);
};
$_edShowHasSel = function ($n) {
    if ($n < 1) return '';
    $chain = '';
    for ($i = 1; $i <= $n; $i++) {
        $chain .= ':not(:has(.impulse-content-wrapper > .impulse-el[data-type="section"]:nth-child(' . $i . ').impulse-screen-active))';
    }
    return 'body[data-screen-mode="1"]' . $chain;
};

if ($_edHideAny):
    /* Base rules — hides on screens 0..N-1 (where N = $_edHideCount).
       Editor mirrors frontend selectors but uses :has() instead of
       data-impulse-screen because the editor doesn't maintain that
       body attribute (it stacks sections and toggles impulse-screen-
       active instead). */
    $_baseShowSel = $_edShowHasSel($_edHideCount);
?>
<?php if ($_edHideCount > 0): ?>
<?php /* The echoes below emit CSS SELECTOR strings (contain `>` and [data-type="x"]) built only from hardcoded literals + integer screen counters + internal element ids. No HTML escaper (esc_html/esc_attr/wp_kses) can be applied without corrupting the selector, and there is no user input. Emitted raw by necessity. */ ?>
<?php // phpcs:disable WordPress.Security.EscapeOutput -- CSS selector strings built from hardcoded literals + integer screen counters + internal element ids; no user input, and no CSS-context escaper (esc_html/esc_attr/wp_kses) can be applied without corrupting the selector ?>
<?php echo $_edHideHasSel($_edHideCount, '.impulse-header'); ?>,
<?php echo $_edHideHasSel($_edHideCount, '.impulse-el[data-type="header"]'); ?>,
<?php echo $_edHideHasSel($_edHideCount, '[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 $_edHideHasSel($_edHideCount, '#instinctor-chat'); ?>,
<?php echo $_edHideHasSel($_edHideCount, '#instinctor-chat-bar'); ?> {
    opacity: 0 !important;
    visibility: hidden !important;
    pointer-events: none !important;
    transition: opacity 480ms ease,
                visibility 0s linear 480ms !important;
}
<?php echo $_baseShowSel; ?> .impulse-header,
<?php echo $_baseShowSel; ?> .impulse-el[data-type="header"],
<?php echo $_baseShowSel; ?> [id^="impulse-header-"] {
    transform: translateY(0);
    transition: opacity 480ms ease,
                transform 480ms ease,
                visibility 0s linear 0s;
}
<?php echo $_baseShowSel; ?> #instinctor-chat,
<?php echo $_baseShowSel; ?> #instinctor-chat-bar {
    transition: opacity 480ms ease,
                visibility 0s linear 0s;
}
<?php
    /* Header position:absolute override — id specificity beats the
       header renderer's mobile #impulse-header-N{position:relative}
       rule so the header releases its flow space on chrome-hidden
       screens. Without this the editor mobile tab shows the header
       even when chrome should be hidden. Mirror of the same fix in
       templates/impulse-page.php. */
    if (!empty($_edHeaderIds)) {
        $_hdrEdOverrides = [];
        foreach ($_edHeaderIds as $_hId) {
            $_hIdSel = '#impulse-header-' . $_hId;
            for ($_hi = 1; $_hi <= $_edHideCount; $_hi++) {
                $_hdrEdOverrides[] = 'body[data-screen-mode="1"]:has(.impulse-content-wrapper > .impulse-el[data-type="section"]:nth-child(' . $_hi . ').impulse-screen-active) ' . $_hIdSel;
            }
        }
        if (!empty($_hdrEdOverrides)):
?>
<?php echo implode(',', $_hdrEdOverrides); ?> {
    position: absolute !important;
}
<?php   endif;
    }
?>
<?php endif; ?>
<?php if ($_edHideCountMobile > $_edHideCount):
    /* Mobile extension — applies to viewports ≤767px only. Same
       :has(:nth-child) pattern but built for indices [N..M-1]. */
    /* Same comma-joined :has() pattern as $_edHideHasSel — and same
       bug surface: the $el suffix MUST be appended inside the loop so
       every branch is scoped to the intended descendant. */
    $_mobileExtraHasSel = function ($from, $to, $el = '') {
        $parts = [];
        $suf = $el !== '' ? ' ' . $el : '';
        for ($i = $from + 1; $i <= $to; $i++) {
            $parts[] = 'body[data-screen-mode="1"]:has(.impulse-content-wrapper > .impulse-el[data-type="section"]:nth-child(' . $i . ').impulse-screen-active)' . $suf;
        }
        return implode(',', $parts);
    };
?>
@media (max-width:767px) {
<?php
    /* Same header position:absolute override extended to the
       mobile-extra screens (indices N..M-1). */
    if (!empty($_edHeaderIds)) {
        $_hdrEdMobile = [];
        foreach ($_edHeaderIds as $_hId) {
            $_hIdSel = '#impulse-header-' . $_hId;
            for ($_hi = $_edHideCount + 1; $_hi <= $_edHideCountMobile; $_hi++) {
                $_hdrEdMobile[] = 'body[data-screen-mode="1"]:has(.impulse-content-wrapper > .impulse-el[data-type="section"]:nth-child(' . $_hi . ').impulse-screen-active) ' . $_hIdSel;
            }
        }
        if (!empty($_hdrEdMobile)):
?>
<?php echo implode(',', $_hdrEdMobile); ?> {
    position: absolute !important;
}
<?php   endif;
    }
?>
<?php echo $_mobileExtraHasSel($_edHideCount, $_edHideCountMobile, '.impulse-header'); ?>,
<?php echo $_mobileExtraHasSel($_edHideCount, $_edHideCountMobile, '.impulse-el[data-type="header"]'); ?>,
<?php echo $_mobileExtraHasSel($_edHideCount, $_edHideCountMobile, '[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 $_mobileExtraHasSel($_edHideCount, $_edHideCountMobile, '#instinctor-chat'); ?>,
<?php echo $_mobileExtraHasSel($_edHideCount, $_edHideCountMobile, '#instinctor-chat-bar'); ?> {
    opacity: 0 !important;
    visibility: hidden !important;
    pointer-events: none !important;
    transition: opacity 480ms ease,
                visibility 0s linear 480ms !important;
}
}
<?php // phpcs:enable WordPress.Security.EscapeOutput -- end CSS-selector raw-emit region ?>
<?php endif; ?>
<?php endif; ?>

/* Row */
.impulse-row{display:flex;flex-wrap:wrap;gap:24px}
.impulse-col{flex:1;min-width:0}

/* Heading */

/* Fit Text to Container */
.impulse-fit-text{overflow:hidden}
.impulse-fit-text h1,.impulse-fit-text h2,.impulse-fit-text h3,.impulse-fit-text h4,.impulse-fit-text h5,.impulse-fit-text h6{white-space:nowrap;overflow:hidden;text-overflow:clip}

/* Text */
.impulse-text{min-height:1em;word-break:break-word;overflow-wrap:break-word}
/* Line balancing - mirrors assets/css/frontend.css so the canvas wraps exactly
   as the published page does. Statements balance; non-centred copy inside a grid
   cell fills its measure instead (Sergei 2026-07-31). */
.impulse-text{text-wrap:balance}
.impulse-grid-cell .impulse-text:not(.impulse-text-center){text-wrap:pretty}
.impulse-text p{margin:0 0 0.85em;min-height:1em}
.impulse-text p:last-child{margin-bottom:0}
/* Semantic HTML inside text blocks — headings, lists, dividers */
.impulse-text h1{font-size:2em;font-weight:700;line-height:1.2;margin:0.75em 0 0.4em}
.impulse-text h2{font-size:1.5em;font-weight:700;line-height:1.25;margin:1.2em 0 0.4em}
.impulse-text h3{font-size:1.2em;font-weight:600;line-height:1.3;margin:1em 0 0.35em}
.impulse-text h4{font-size:1em;font-weight:600;line-height:1.4;margin:0.9em 0 0.3em}
.impulse-text h5,.impulse-text h6{font-size:0.9em;font-weight:600;line-height:1.4;margin:0.8em 0 0.25em}
.impulse-text h1:first-child,.impulse-text h2:first-child,.impulse-text h3:first-child,
.impulse-text h4:first-child,.impulse-text h5:first-child,.impulse-text h6:first-child{margin-top:0}
.impulse-text ul,.impulse-text ol{padding-left:1.5em;margin:0 0 0.85em}
.impulse-text ul:last-child,.impulse-text ol:last-child{margin-bottom:0}
.impulse-text li{margin-bottom:0.25em}
.impulse-text li:last-child{margin-bottom:0}
.impulse-text hr{border:none;border-top:1px solid #e0e0e0;margin:1.5em 0}
.impulse-text blockquote{border-left:3px solid #e0e0e0;margin:1em 0;padding:0.5em 0 0.5em 1em;color:#666}
.impulse-text pre,.impulse-text code{font-family:monospace;font-size:0.9em;background:#f5f5f5;border-radius:3px}
.impulse-text pre{padding:1em;overflow-x:auto;margin:0 0 0.85em}
.impulse-text code{padding:0.1em 0.3em}
.impulse-text strong,.impulse-text b{font-weight:700}

/* Image */
.impulse-image{text-align:center}
.impulse-image img{display:inline-block;transition:all 0.5s ease}
.impulse-image a{display:inline-block;text-decoration:none;border:none;outline:none}
.impulse-image a:focus,.impulse-image a:active{outline:none;border:none}
.impulse-image a img{border:none}
.impulse-image-placeholder{background:#4b5563;padding:20px;border-radius:8px;text-align:center;cursor:pointer;transition:all 0.2s;display:flex;align-items:center;justify-content:center;min-height:60px}
.impulse-image-placeholder:hover{background:#6b7280}
<?php impulse_effects_css(); ?>

/* Button — single render path; styling lives entirely on the inline
   `style` attr emitted by the renderer. The legacy .impulse-button-primary
   / -secondary / -outline variant classes were never wired to any setting
   and the .impulse-btn-badge rules belonged to the retired badge mode.
   Both deleted in 2.1.646. */

/* Icon */
.impulse-icon{text-align:center}
.impulse-icon i{display:inline-block}

/* Spacer */

/* Divider */
.impulse-divider{width:100%;overflow:visible}
.impulse-el:has(> .impulse-divider){overflow:visible}
.impulse-divider hr{border:none;border-top:1px solid #e0e0e0}

/* Hero */
.impulse-hero-center{text-align:center}
.impulse-hero-left{text-align:left}

/* Products */
.impulse-products-title{text-align:center;margin-bottom:32px}
.impulse-products-title h2{font-size:32px;font-weight:700;margin:0}
.impulse-products-grid{display:grid;gap:24px}
.impulse-product-card{background:#fff;overflow:hidden;transition:all 0.2s}
.impulse-product-card:hover{box-shadow:0 4px 12px rgba(0,0,0,0.1)}
.impulse-product-image{aspect-ratio:1;background:#f5f5f5;display:flex;align-items:center;justify-content:center}
.impulse-product-image img{width:100%;height:100%;object-fit:cover}
.impulse-product-info{padding:16px}
.impulse-product-name{font-weight:600;margin-bottom:4px}
.impulse-product-price{color:#1d2327;font-weight:600}

/* Features */
.impulse-feature{text-align:center;padding:24px}
.impulse-feature i{font-size:40px;margin-bottom:16px}
.impulse-feature h3{font-size:18px;font-weight:600;margin:0 0 8px}
.impulse-feature p{font-size:14px;color:#666;margin:0}

/* Testimonials */

/* CTA */
.impulse-cta{text-align:center;padding:60px 24px}
.impulse-cta h2{font-size:36px;font-weight:700;margin:0 0 12px}
.impulse-cta p{font-size:18px;opacity:0.9;margin:0 0 28px}
.impulse-cta a{display:inline-block;padding:14px 32px;border-radius:6px;font-weight:600;text-decoration:none}

/* Newsletter */
.impulse-newsletter-form{display:flex;gap:12px;max-width:440px;margin:0 auto}
.impulse-newsletter-form input{flex:1;padding:14px;border:1px solid rgba(255,255,255,0.3);border-radius:6px;font-size:15px;background:rgba(255,255,255,0.1);color:inherit}
.impulse-newsletter-form input::placeholder{opacity:0.6}
.impulse-newsletter-form button{padding:14px 28px;border:none;border-radius:6px;font-weight:600;cursor:pointer}

/* FAQ */

/* Empty state */
.impulse-empty{min-height:300px;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#f8f9fa;text-align:center;padding:40px}
.impulse-empty h2{color:#666;margin:0 0 8px}
.impulse-empty p{color:#999;margin:0}

/* Video */
.impulse-video{overflow:hidden}

/* Product Single */
.impulse-product-single{display:grid;grid-template-columns:1fr 1fr;gap:40px;align-items:start}
.impulse-product-single-image img{width:100%}
.impulse-product-single-info h1{font-size:28px;margin:0 0 16px}
.impulse-product-single-price{font-size:24px;font-weight:700;color:#1d2327;margin-bottom:16px}
.impulse-product-single-desc{color:#666;margin-bottom:24px}
.impulse-add-to-cart{background:#1d2327;color:#fff;border:none;padding:14px 28px;font-size:16px;font-weight:600;border-radius:6px;cursor:pointer}
.impulse-add-to-cart:hover{background:#333}

/* Add to Cart Block */
.impulse-add-to-cart-block{display:flex;align-items:center;gap:12px}
.impulse-qty-input{border:1px solid #ddd;border-radius:4px;font-size:16px}
.impulse-atc-btn{padding:12px 24px;font-weight:600;border:none;border-radius:6px;cursor:pointer}
.impulse-atc-btn-primary{background:#1d2327;color:#fff}
.impulse-atc-btn-secondary{background:#f0f0f1;color:#1d2327}

/* Categories */
.impulse-categories-grid{display:grid;gap:20px}
.impulse-category-card{background:#f8f9fa;padding:24px;text-align:center;transition:all 0.2s}
.impulse-category-card:hover{background:#eee}
.impulse-category-name{font-weight:600;margin-bottom:4px}
.impulse-category-count{color:#666;font-size:14px}

/* Grid in masonry mode — children break at column boundaries, gap via margin-bottom */
.impulse-grid-masonry > *{break-inside:avoid;-webkit-column-break-inside:avoid;page-break-inside:avoid;display:block;margin-bottom:var(--impulse-masonry-gap,16px);width:100%}
.impulse-grid-masonry > *:last-child{margin-bottom:0}

/* Countdown */
.impulse-cd{display:flex;align-items:flex-start;flex-wrap:wrap;justify-content:center}
.impulse-cd-unit{display:flex;flex-direction:column;align-items:center}
.impulse-cd-label{margin-top:6px;text-transform:uppercase;letter-spacing:0.5px}
.impulse-cd-sep{display:flex;align-items:center;height:var(--cd-card-h);padding:0 2px}
.impulse-cd-sep-line{width:2px;height:60%;border-radius:1px;background:var(--cd-sep,#999)}
.impulse-cd-sep-colon{font-weight:700;line-height:1}
.impulse-cd-sep-dot{display:block;flex-shrink:0;width:6px;height:6px;border-radius:50%}
.impulse-cd-card{position:relative;line-height:1;border-radius:var(--cd-radius,8px)}
.impulse-cd-card-top,.impulse-cd-card-bottom{position:relative;overflow:hidden;background:var(--cd-bg,#f5f5f5)}
.impulse-cd-card-top{border-bottom:1px solid rgba(0,0,0,0.12);border-radius:var(--cd-radius,8px) var(--cd-radius,8px) 0 0;box-shadow:0 2px 4px rgba(0,0,0,0.06)}
.impulse-cd-card-bottom{border-radius:0 0 var(--cd-radius,8px) var(--cd-radius,8px);box-shadow:0 2px 4px rgba(0,0,0,0.06)}
.impulse-cd-card-top span{position:absolute;left:50%;top:100%;transform:translate(-50%,-50%)}
.impulse-cd-card-bottom span{position:absolute;left:50%;top:0;transform:translate(-50%,-50%)}
.impulse-cd-flip-overlay{position:absolute;left:0;right:0;overflow:hidden;backface-visibility:hidden}
.impulse-cd-flip-top-overlay{top:0;transform-origin:bottom center}
.impulse-cd-flip-bottom-overlay{bottom:0;transform-origin:top center;transform:rotateX(90deg)}
.impulse-cd-ring-wrap{position:relative;display:flex;align-items:center;justify-content:center}
.impulse-cd-ring-value{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;line-height:1}
.impulse-cd-ring-progress{transition:stroke-dashoffset 0.6s ease}
.impulse-cd-slot-window{position:relative;overflow:hidden}
.impulse-cd-slot-current{display:block;line-height:1}
.impulse-cd-slot-anim{position:absolute;left:0;right:0;display:flex;align-items:center;justify-content:center;line-height:1}
.impulse-cd-block{display:flex;align-items:center;justify-content:center;line-height:1;position:relative}
.impulse-cd-minimal .impulse-cd-value{line-height:1;transition:opacity 0.3s ease}
.impulse-cd.impulse-cd-urgent .impulse-cd-value{animation:impulse-cd-urgency-pulse 1s ease infinite}
@keyframes impulse-cd-urgency-pulse{0%,100%{opacity:1}50%{opacity:0.6}}

/* Banner */

/* Entrance Animations — paired with the inline animation-name +
   animation-play-state:paused that PHP emits on each .impulse-animated
   element. The opacity:0 default hides the element until the
   IntersectionObserver adds .impulse-visible and flips play-state to
   running, so it animates 0→1 cleanly. Without this rule (frontend.css
   line ~2559) the element paints in its natural visible state first,
   then the animation snaps it to frame 0 on play, then animates up —
   reads as "fully appears, disappears, re-appears." Sergei 2026-05-30. */
.impulse-animated{opacity:0;animation-fill-mode:forwards}
.impulse-animated.impulse-visible{animation-play-state:running !important}

/* Animation Keyframes */
@keyframes impulse-emerge{from{opacity:0;transform:scale(var(--ea-scale,0.9));filter:blur(var(--ea-blur,4px))}to{opacity:1;transform:scale(1);filter:blur(0)}}
@keyframes impulse-surface{from{opacity:0;transform:perspective(800px) rotateX(var(--ea-tilt,15deg)) translateZ(var(--ea-depth,-60px))}to{opacity:1;transform:perspective(800px) rotateX(0) translateZ(0)}}
@keyframes impulse-unveil-up{from{opacity:1;clip-path:inset(100% 0 0 0)}to{opacity:1;clip-path:inset(0 0 0 0)}}
@keyframes impulse-unveil-down{from{opacity:1;clip-path:inset(0 0 100% 0)}to{opacity:1;clip-path:inset(0 0 0 0)}}
@keyframes impulse-unveil-left{from{opacity:1;clip-path:inset(0 100% 0 0)}to{opacity:1;clip-path:inset(0 0 0 0)}}
@keyframes impulse-unveil-right{from{opacity:1;clip-path:inset(0 0 0 100%)}to{opacity:1;clip-path:inset(0 0 0 0)}}
@keyframes impulse-resolve{from{opacity:1;filter:blur(var(--ea-blur,12px))}to{opacity:1;filter:blur(0)}}
@keyframes impulse-glitch{0%{transform:translate(0);clip-path:inset(0);filter:none}10%{transform:translate(-5px,3px);clip-path:inset(20% 0 40% 0);filter:hue-rotate(90deg)}20%{transform:translate(3px,-2px);clip-path:inset(60% 0 5% 0);filter:hue-rotate(180deg)}30%{transform:translate(-3px,0);clip-path:inset(5% 0 60% 0);filter:hue-rotate(270deg)}40%{transform:translate(5px,2px);clip-path:inset(40% 0 20% 0);filter:none}50%{transform:translate(-2px,-3px);clip-path:inset(0);filter:hue-rotate(120deg)}60%{transform:translate(0);clip-path:inset(0);filter:none;opacity:0}100%{transform:translate(0);clip-path:inset(0);filter:none;opacity:0}}
.impulse-flip-glitching{animation:impulse-glitch 0.5s steps(1,end) forwards!important;transition:none!important}
@keyframes impulse-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.05)}}
.impulse-map-circle{transition:border-radius 0.5s cubic-bezier(0.4,0,0.2,1)}
.impulse-map-circle:hover{border-radius:0!important}
.impulse-map-shine::before{content:'';position:absolute;top:0;left:-100%;width:60%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.15),transparent);transition:none;pointer-events:none}
.impulse-google-map:hover .impulse-map-shine::before{left:150%;transition:left 0.8s ease}

/* Pricing Table Responsive — try to keep cards side-by-side on mobile with
   compressed paddings/gaps/type sizes. Only collapse to single column when
   the viewport is genuinely too small for the content to breathe. */
@media (max-width: 1024px) {
    .impulse-pricing-grid[style*="grid-template-columns: repeat(4"] { grid-template-columns: repeat(2, 1fr) !important; }
}
@media (max-width: 768px) {
    /* Mobile: vertical column that must fit inside one viewport (screen-mode
       locks scroll). Arc tilts around X axis. Cards compressed to fit.
       max-width off; horizontal bounds follow the parent section's padding. */
    .impulse-pricing-wrapper { max-width: none !important; }
    .impulse-pricing-grid { grid-template-columns: 1fr !important; gap: 6px !important; }
    .impulse-pricing-arc { perspective: 1400px !important; perspective-origin: center center !important; }
    .impulse-pricing-arc .impulse-pricing-card { transform-origin: center center !important; }
    .impulse-pricing-ribbon-slot { height: 14px !important; }
    .impulse-pricing-ribbon-banner { font-size: 8px !important; letter-spacing: 0.5px !important; }
    .impulse-pricing-header { padding: 4px 12px 0 !important; min-height: 0 !important; }
    .impulse-pricing-title { font-size: 13px !important; letter-spacing: 0 !important; line-height: 1.15; }
    .impulse-pricing-subtitle { font-size: 10px !important; min-height: 0 !important; margin-top: 0 !important; line-height: 1.2; }
    .impulse-pricing-price { padding: 2px 12px !important; }
    .impulse-price-currency { font-size: 12px !important; }
    .impulse-price-amount { font-size: 22px !important; }
    .impulse-price-period, .impulse-pricing-period { font-size: 10px !important; }
    .impulse-pricing-features { padding: 2px 12px !important; }
    .impulse-pricing-feature { margin-bottom: 2px !important; gap: 6px !important; }
    .impulse-pricing-feature svg { width: 10px !important; height: 10px !important; flex-shrink: 0; }
    .impulse-pricing-feature > span { font-size: 11px !important; line-height: 1.2; }
    .impulse-pricing-button { font-size: 11px !important; padding: 6px 12px !important; letter-spacing: 0 !important; }
    .impulse-pricing-card > div:last-child { padding: 4px 12px 6px !important; }
    .impulse-pricing-toggle { flex-wrap: wrap; gap: 12px !important; }
}
.impulse-pricing-ribbon-corner { overflow: hidden; }
.impulse-pricing-card { overflow: hidden; }

/* Checkout Fields Responsive Grid */
.impulse-checkout-fields-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.impulse-checkout-field.impulse-field-full { grid-column: span 2; }
.impulse-checkout-field.impulse-field-half { grid-column: span 1; }

/* Device-specific field widths applied via body class from device change */
body.impulse-device-tablet .impulse-checkout-field.impulse-field-tablet-full { grid-column: span 2 !important; }
body.impulse-device-tablet .impulse-checkout-field.impulse-field-tablet-half { grid-column: span 1 !important; }
body.impulse-device-mobile .impulse-checkout-field.impulse-field-mobile-full { grid-column: span 2 !important; }
body.impulse-device-mobile .impulse-checkout-field.impulse-field-mobile-half { grid-column: span 1 !important; }

/* Hide number input spinners in cart/quantity selectors */
.impulse-cart-block input[type="number"],
.impulse-quantity-selector input[type="number"] {
    -moz-appearance: textfield;
}
.impulse-cart-block input[type="number"]::-webkit-outer-spin-button,
.impulse-cart-block input[type="number"]::-webkit-inner-spin-button,
.impulse-quantity-selector input[type="number"]::-webkit-outer-spin-button,
.impulse-quantity-selector input[type="number"]::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}
/* Animated Headline */
.impulse-display-text{position:relative;width:100%;display:block;overflow:visible}.impulse-el[data-type="title"]{overflow:visible}
.impulse-display-text h1,.impulse-display-text h2,.impulse-display-text h3,.impulse-display-text h4,.impulse-display-text h5,.impulse-display-text h6,.impulse-display-text p{white-space:pre-line}
.impulse-display-text ::selection{background:rgba(0,100,255,0.2)}
.impulse-display-text ::-moz-selection{background:rgba(0,100,255,0.2)}
.impulse-dt-line{transform-origin:center center}
.impulse-display-text [data-field="text"]:empty:before{content:attr(data-placeholder);color:#ccc;pointer-events:none}
</style>
<?php if ($isThumbnail): ?>
<script>
// Suppress unhandled promise rejections in thumbnail mode (from WP emoji loader, etc.)
window.addEventListener('unhandledrejection', function(e) { e.preventDefault(); });
</script>
<?php endif; ?>
<script>
// Global autofill disable — all inputs in editor preview. Skip form blocks
// (.impulse-form and descendants) — the readonly+one-time-code combo on real
// form fields causes Chrome to clear the placeholder and treat them as OTP.
document.addEventListener('DOMContentLoaded',function(){
    function killAutofill(root){
        (root||document).querySelectorAll('input,select,textarea').forEach(function(el){
            if (el.closest('.impulse-form, .impulse-contact-form, .impulse-login-form, .impulse-register-form')) return;
            el.setAttribute('autocomplete','off');
            if(el.type==='password') el.setAttribute('autocomplete','new-password');
            if(!el.hasAttribute('data-af-killed')){
                el.setAttribute('data-af-killed','1');
            }
        });
    }
    killAutofill();
    new MutationObserver(function(muts){muts.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1)killAutofill(n);});});}).observe(document.body,{childList:true,subtree:true});
});
</script>
<script>
// Hide broken images gracefully — prevents 404 console noise and blank img boxes
document.addEventListener('error', function(e) {
    if (e.target.tagName === 'IMG') {
        e.target.style.display = 'none';
    }
}, true);
</script>
</head>
<?php
// Check for footer existence before body tag
$_hasFooter = false;
foreach ($elementTree as $_el) {
    if (($_el->section_type ?? '') === 'footer') { $_hasFooter = true; break; }
}
$_bodyClasses = [];
if (!$_hasFooter && !empty($GLOBALS['impulse_is_edit_mode'])) $_bodyClasses[] = 'impulse-no-footer';
// Native (classic) vertical scroll: emit the same body class the frontend
// renderer uses so the preview engine bails to native scroll and the shared
// frontend.css classic rules apply (no snap, no reveals). Parity with
// templates/impulse-page.php:1462.
if (($pageSettings['body_scroll'] ?? 'snap') === 'classic') $_bodyClasses[] = 'impulse-scroll-classic';
$_bodyClassAttr = !empty($_bodyClasses) ? ' class="' . implode(' ', $_bodyClasses) . '"' : '';

// Body-bg parity with applyPageSettingsToPreview() in admin-core.js. When
// scene-mode is on and the cloud effect is `depth_image`, the depth canvas
// sits at z-index:-1 behind body content. An opaque body bg covers it,
// hiding the depth image entirely until the user manually re-triggers
// init (which goes through the JS path that sets body bg to transparent).
// Match that behaviour at PHP render time so depth bg is visible on first
// paint after a hard refresh — no need to reopen the 3D image panel.
$_bodyBgVal = $pageBgColor;
$_cloudEffForBg = $pageSettings['screen_cloud_effect'] ?? 'none';
// Depth-map fallback. The depth-parallax loader only initialises when BOTH
// data-depth-image AND data-depth-map are present, so an empty screen_depth_map
// (e.g. a flaky Platform verify left it unsaved during the pick) makes the 3D
// image env silently vanish on the next refresh. When a depth_image env has an
// image but no saved map, derive the conventional `<image>a.png` pair so the env
// survives a reload. Only derived when genuinely empty — never overrides a saved
// (possibly generated) map.
$_depthImgVal = $pageSettings['screen_depth_image'] ?? '';
$_depthMapVal = $pageSettings['screen_depth_map'] ?? '';
if ($_depthImgVal !== '' && $_depthMapVal === '' && $_cloudEffForBg === 'depth_image') {
    $_depthMapVal = preg_replace('/(\.[^\/\.?]+)(\?.*)?$/', 'a.png$2', $_depthImgVal) ?: '';
}
if (!empty($screenMode) && $_cloudEffForBg === 'depth_image'
    && $_depthImgVal !== '' && $_depthMapVal !== '') {
    $_bodyBgVal = 'transparent';
}
?>
<body<?php echo wp_kses_post($_bodyClassAttr); ?> style="--impulse-padding-top:<?php echo intval($pagePaddingTop); ?>px;--impulse-padding-left:<?php echo intval($pagePaddingLeft); ?>px;--impulse-padding-right:<?php echo intval($pagePaddingRight); ?>px;--impulse-padding-bottom:<?php echo intval($pageSettings['padding_bottom'] ?? 0); ?>px;--impulse-element-spacing:<?php echo intval($pageElementSpacing); ?>px;background:<?php echo esc_attr($_bodyBgVal); ?>"<?php echo $screenMode ? ' data-screen-mode="1"' : ''; ?><?php if ($screenMode) { echo ' data-screen-transition="' . esc_attr($pageSettings['screen_transition'] ?? 'depth') . '"'; echo ' data-screen-cooldown="' . intval($pageSettings['screen_cooldown'] ?? 150) . '"'; echo ' data-screen-duration="' . intval($pageSettings['screen_duration'] ?? 700) . '"'; echo ' data-screen-intensity="' . intval($pageSettings['screen_intensity'] ?? 50) . '"';
$_heroSpeedDisp = isset($pageSettings['hero_speed']) ? intval($pageSettings['hero_speed']) : 76;
echo ' data-screen-hero-count="0"'; /* Hero zones removed 2026-06-16: editor must match the frontend — every screen reveals as a single unit by its own transition, never the zone-gated per-block 3D decompose. Forces the no-zone path; leftover hero_screen_count is ignored. */
echo ' data-screen-hero-transition="' . esc_attr($pageSettings['hero_transition'] ?? ($pageSettings['screen_transition'] ?? 'z_depth')) . '"';
echo ' data-screen-hero-intensity="' . (isset($pageSettings['hero_intensity']) ? intval($pageSettings['hero_intensity']) : intval($pageSettings['screen_intensity'] ?? 50)) . '"';
echo ' data-screen-hero-duration="' . (int) max(300, min(2000, (int) round(2000 - ($_heroSpeedDisp / 100) * 1700))) . '"'; if (!empty($pageSettings['nav_dots'])) echo ' data-screen-nav-dots="1"';
// Loop emits data-screen-infinite="1" when the checkbox is on. Backward
// compat: a legacy key (`hero_loop`) survives in DB rows that haven't
// been re-saved since the rename, so we fall through to it. Default is
// TRUE when neither key is present — matches templates/impulse-page.php
// frontend behaviour (visitors shouldn't dead-end at the last screen).
$_loopExplicit = $pageSettings['screen_infinite_loop'] ?? ($pageSettings['hero_loop'] ?? null);
$_loopOn       = ($_loopExplicit === null) ? true : !empty($_loopExplicit);
if ($_loopOn) echo ' data-screen-infinite="1"';
if (!empty($pageSettings['screen_tunnel'])) echo ' data-screen-tunnel="1"'; echo ' data-screen-tunnel-speed="' . esc_attr($pageSettings['screen_tunnel_speed'] ?? '1') . '"'; echo ' data-screen-cloud-effect="' . esc_attr($pageSettings['screen_cloud_effect'] ?? 'none') . '"';
echo ' data-outer-opacity="' . esc_attr($pageSettings['screen_outer_opacity'] ?? '') . '"';
echo ' data-depth-enabled="' . (
    ($_depthImgVal !== '' && $_depthMapVal !== '')
    && (!empty($pageSettings['screen_depth_enabled']) || ($pageSettings['screen_cloud_effect'] ?? '') === 'glass')
    ? '1' : '0'
) . '"';
echo ' data-depth-image="' . esc_attr($_depthImgVal) . '"';
echo ' data-depth-map="' . esc_attr($_depthMapVal) . '"';
echo ' data-depth-strength="' . intval($pageSettings['screen_depth_strength'] ?? 50) . '"'; echo ' data-depth-mode="' . ((($pageSettings['screen_depth_mode'] ?? 'parallax') === 'relief') ? 'relief' : 'parallax') . '"'; echo ' data-depth-light="' . intval(max(0, min(70, intval($pageSettings['screen_depth_light'] ?? 50)))) . '"'; echo ' data-depth-dolly="' . intval(max(0, min(10, intval($pageSettings['screen_depth_dolly'] ?? 7)))) . '"'; echo ' data-depth-pivot="' . intval(max(0, min(100, intval($pageSettings['screen_depth_pivot'] ?? 50)))) . '"'; echo ' data-depth-zoom="' . intval(max(100, min(140, intval($pageSettings['screen_depth_zoom'] ?? 100)))) . '"'; echo ' data-screen-stars="' . ((!isset($pageSettings['screen_stars']) || $pageSettings['screen_stars'] !== '0') ? '1' : '0') . '"'; echo ' data-screen-tunnel-fill="' . (!empty($pageSettings['screen_tunnel_fill']) ? '1' : '0') . '"'; echo ' data-screen-mouse-sens-level="' . esc_attr(number_format((floatval($pageSettings['screen_mouse_sens_level'] ?? 5) / 10), 2, '.', '')) . '"'; echo ' data-screen-grid-color="' . esc_attr($pageSettings['neon_grid_color'] ?? '') . '"'; echo ' data-page-bg-color="' . esc_attr($pageBgColor) . '"'; echo ' data-screen-neon-hue="' . (isset($pageSettings['screen_effect_hue']) ? intval($pageSettings['screen_effect_hue']) : -1) . '"'; echo ' data-screen-bw-accent-color="' . esc_attr($pageSettings['screen_bw_accent_color'] ?? '') . '"'; echo ' data-screen-bw-element-size="' . intval($pageSettings['screen_bw_element_size'] ?? 100) . '"'; echo ' data-screen-bw-element-count="' . intval($pageSettings['screen_bw_element_count'] ?? 50) . '"'; echo ' data-screen-lava-color1="' . esc_attr($pageSettings['screen_lava_color1'] ?? '#333333') . '"'; echo ' data-screen-lava-color2="' . esc_attr($pageSettings['screen_lava_color2'] ?? '#999999') . '"'; echo ' data-screen-lava-color3="' . esc_attr($pageSettings['screen_lava_color3'] ?? '#666666') . '"'; echo ' data-screen-lava-color4="' . esc_attr($pageSettings['screen_lava_color4'] ?? '#cccccc') . '"';
echo ' data-screen-wax-color4="' . esc_attr($pageSettings['screen_wax_color4'] ?? '#cccccc') . '"'; echo ' data-screen-lava-speed="' . intval($pageSettings['screen_lava_speed'] ?? 10) . '"'; echo ' data-screen-lava-intensity="' . intval($pageSettings['screen_lava_intensity'] ?? 5) . '"'; echo ' data-screen-wave-color1="' . esc_attr($pageSettings['screen_wave_color1'] ?? '#6b8eff') . '"'; echo ' data-screen-wave-color2="' . esc_attr($pageSettings['screen_wave_color2'] ?? '#e256a0') . '"'; echo ' data-screen-wave-color3="' . esc_attr($pageSettings['screen_wave_color3'] ?? '#ff8054') . '"'; echo ' data-screen-wave-color4="' . esc_attr($pageSettings['screen_wave_color4'] ?? '#9333ea') . '"'; echo ' data-screen-wave-speed="' . intval($pageSettings['screen_wave_speed'] ?? 10) . '"'; echo ' data-screen-wave-preset="' . esc_attr($pageSettings['screen_wave_preset'] ?? 'flow') . '"'; echo ' data-screen-wave-intensity="' . intval($pageSettings['screen_wave_intensity'] ?? 10) . '"'; /* Per-effect 3D-family resolution: each effect stores its own palette + params under screen_{eff}_{suffix}. */ $__pe3d = $pageSettings['screen_cloud_effect'] ?? 'none'; $__pe3dGet = static function(string $suffix, string $def) use ($pageSettings, $__pe3d): string { $k = 'screen_' . $__pe3d . '_' . $suffix; return !empty($pageSettings[$k]) ? (string) $pageSettings[$k] : $def; }; echo ' data-screen-3d-color1="' . esc_attr($__pe3dGet('color1', '#333333')) . '"'; echo ' data-screen-3d-color2="' . esc_attr($__pe3dGet('color2', '#999999')) . '"'; echo ' data-screen-3d-color3="' . esc_attr($__pe3dGet('color3', '#666666')) . '"'; echo ' data-screen-3d-color4="' . esc_attr($__pe3dGet('color4', '#cccccc')) . '"'; echo ' data-screen-3d-speed="' . intval($__pe3dGet('speed', '10')) . '"'; echo ' data-screen-3d-preset="' . esc_attr($__pe3dGet('preset', '')) . '"'; echo ' data-screen-3d-intensity="' . intval($__pe3dGet('intensity', ($__pe3d === 'grid' ? '1' : '10'))) . '"'; echo ' data-screen-3d-count="' . intval($__pe3dGet('count', '7')) . '"'; echo ' data-screen-3d-variation="' . intval($__pe3dGet('variation', '3')) . '"'; echo ' data-screen-3d-density="' . intval($__pe3dGet('density', '7')) . '"'; echo ' data-screen-3d-halo="' . intval($__pe3dGet('halo', '4')) . '"'; echo ' data-screen-3d-bg-color="' . esc_attr($__pe3dGet('bg_color', '')) . '"'; echo ' data-screen-prism-bg="' . esc_attr($pageSettings['screen_prism_bg'] ?? '') . '"'; echo ' data-screen-prism-shape="' . intval($pageSettings['screen_prism_shape'] ?? 0) . '"'; } ?>>
<?php
        // Top CTA bar removed — the live frontend retired it (the bar in
        // templates/impulse-page.php is gated off), so the editor preview
        // must not render it either. Page-level chrome is now a real nav block.
        // Preview payment bar removed — payment-first model.

        // Separate elements into header, content, and footer
        $headerElements = [];
        $contentElements = [];
        $footerElements = [];
        
        foreach ($elementTree as $element) {
            $type = $element->type ?? $element->element_type ?? $element->section_type ?? '';
            if ($type === 'header') {
                $headerElements[] = $element;
            } elseif ($type === 'footer') {
                $footerElements[] = $element;
            } else {
                $contentElements[] = $element;
            }
        }

        // In screen mode the footer is NOT its own screen — it pins to the
        // bottom of the LAST screen (parity with templates/impulse-page.php's
        // snap-mode footer). Merge it into the content flow as a direct child
        // (rendered after the last section); CSS pins it and the JS below
        // reveals it only while the last section screen is active. v2.1.1843.
        $_hasSnapFooter = false;
        if (!empty($footerElements)) {
            $contentElements = array_merge($contentElements, $footerElements);
            $footerElements = [];
            $_hasSnapFooter = true;
        }

        // Render elements
        // impulse_render_element() defined below at file scope
        // render-element dispatch moved to end of file

        
        // In the editor preview, the wrapper's bottom padding is dropped: the
        // trailing chrome is the 50vh `::after` drop-zone alone. Leaving the
        // page's saved padding_bottom in place compounded with the spacer and
        // the trailing area read as 60-80vh (or more on tall pages with big
        // section padding). Frontend still honours padding_bottom — that
        // wrapper is emitted from impulse-page.php, not here.
        if (empty($elementTree)) {
            // Empty page - still wrap in content wrapper with padding
            echo '<div class="impulse-content-wrapper" style="padding-top:' . intval($pagePaddingTop) . 'px;padding-right:' . intval($pagePaddingRight) . 'px;padding-bottom:0;padding-left:' . intval($pagePaddingLeft) . 'px;display:flex;flex-direction:column;gap:' . intval($pageElementSpacing) . 'px;flex:1;min-height:0;">';
            if (!empty($GLOBALS['impulse_is_edit_mode'])) {
                // Root dropzone only shown when empty
                echo '<div class="impulse-dropzone" data-parent="0"></div>';
            }
            echo '</div>'; // .impulse-content-wrapper
        } else {
            // Render header elements (outside content wrapper)
            foreach ($headerElements as $element) {
                impulse_render_element($element);
            }

            // Overlay reveal-after-mount gate (mirrors impulse-page.php).
            // Hides every .impulse-gradient-overlay for 250ms after mount
            // so the screens engine has time to lift screen 1 to z:2 before
            // the overlay paints — without this the overlay momentarily
            // covers an un-mounted screen and reads as a black flash.
            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 — emitted at content-wrapper level, ABOVE
            // any sections, so screen transitions don't pulse it. Singleton
            // by guard flag; the per-section emission below is now blocked
            // for scene mode. Fix shipped 2.0.911.
            if (!empty($GLOBALS['impulse_page_settings'])) {
                $_pwPs = $GLOBALS['impulse_page_settings'];
                $_pwShape  = $_pwPs['screen_gradient_shape'] ?? '';
                $_pwD = isset($_pwPs['screen_gradient_diameter'])
                    ? intval($_pwPs['screen_gradient_diameter'])
                    : ($_pwShape === 'glow' ? 80 : ($_pwShape === 'linear' ? 200 : ($_pwShape === 'frame' ? 110 : 100)));
                $_pwR = isset($_pwPs['screen_gradient_roundness'])
                    ? intval($_pwPs['screen_gradient_roundness'])
                    : ($_pwShape === 'linear' ? 0 : ($_pwShape === 'frame' ? 80 : 100));
                $_pwAg = intval($_pwPs['screen_gradient_angle']    ?? 0);
                $_pwSf = isset($_pwPs['screen_gradient_softness'])
                    ? intval($_pwPs['screen_gradient_softness'])
                    : ($_pwShape === 'frame' ? 60 : 100);
                $_pwC1 = $_pwPs['screen_gradient_color1'] ?? 'rgba(0,0,0,0)';
                $_pwC2 = $_pwPs['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%;'
                        . '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>';
                    $GLOBALS['impulse_grad_overlay_emitted_preview'] = true;
                }
            }

            // Content wrapper — see note above the empty-page branch about
            // why padding-bottom is forced to 0 in editor preview.
            echo '<div class="impulse-content-wrapper" style="padding-top:' . intval($pagePaddingTop) . 'px;padding-right:' . intval($pagePaddingRight) . 'px;padding-bottom:0;padding-left:' . intval($pagePaddingLeft) . 'px;display:flex;flex-direction:column;gap:' . intval($pageElementSpacing) . 'px;flex:1;min-height:0;">';

            /* A page-level modal has no parent screen: it is a page overlay, so it
               renders as a sibling of the sections and its chip then sat in the
               top-left corner of EVERY screen (Sergei 2026-08-05). The screen the
               author means is the one carrying the trigger, so find the element
               whose link points at this popup id and show the chip only while that
               screen is active. A modal nothing links to (timer / scroll / exit
               triggers) falls back to the first screen so it stays reachable. */
            $_edModalRules = [];
            if (!empty($contentElements)) {
                $_edSet = static function ($el, $k) {
                    $st = is_object($el) ? ($el->settings ?? []) : ($el['settings'] ?? []);
                    $st = (array) $st;
                    return isset($st[$k]) ? $st[$k] : null;
                };
                $_edKids = static function ($el) {
                    $c = is_object($el) ? ($el->children ?? []) : ($el['children'] ?? []);
                    return is_array($c) ? $c : [];
                };
                // Does this subtree contain something that opens #$anchor?
                $_edOpens = static function ($el, $anchor) use (&$_edOpens, $_edKids) {
                    $st = (array) (is_object($el) ? ($el->settings ?? []) : ($el['settings'] ?? []));
                    foreach ($st as $v) {
                        if (is_string($v) && $v !== '' && strpos($v, '#' . $anchor) !== false) return true;
                    }
                    foreach ($_edKids($el) as $k) { if ($_edOpens($k, $anchor)) return true; }
                    return false;
                };
                // First section's DOM position, used when nothing links to the modal.
                $_edFirstSec = 0;
                foreach ($contentElements as $_i => $_e) {
                    $_t = is_object($_e) ? ($_e->type ?? $_e->section_type ?? '') : ($_e['type'] ?? '');
                    if ($_t === 'section') { $_edFirstSec = $_i + 1; break; }
                }
                foreach ($contentElements as $_i => $_e) {
                    $_t = is_object($_e) ? ($_e->type ?? $_e->section_type ?? '') : ($_e['type'] ?? '');
                    if ($_t !== 'modal') continue;
                    $_mid = (string) (is_object($_e) ? ($_e->id ?? '') : ($_e['id'] ?? ''));
                    if ($_mid === '') continue;
                    $_anchor = (string) $_edSet($_e, 'popup_id');
                    $_owner  = 0;
                    if ($_anchor !== '') {
                        foreach ($contentElements as $_j => $_c) {
                            $_ct = is_object($_c) ? ($_c->type ?? $_c->section_type ?? '') : ($_c['type'] ?? '');
                            if ($_ct !== 'section') continue;
                            if ($_edOpens($_c, $_anchor)) { $_owner = $_j + 1; break; }
                        }
                    }
                    if ($_owner < 1) $_owner = $_edFirstSec;
                    if ($_owner < 1) continue;
                    $_edModalRules[] = [$_mid, $_owner];
                }
            }
            if (!empty($_edModalRules)) {
                $_edModalCss = '';
                foreach ($_edModalRules as [$_mid, $_owner]) {
                    $_sel = '.impulse-content-wrapper > .impulse-el[data-type="modal"][data-id="' . $_mid . '"]';
                    $_edModalCss .= 'body[data-screen-mode="1"] ' . $_sel . '{display:none!important}'
                        . 'body[data-screen-mode="1"]:has(.impulse-content-wrapper > .impulse-el[data-type="section"]:nth-child(' . (int) $_owner . ').impulse-screen-active) ' . $_sel . '{display:inline-block!important}'
                        // Selected means the author is editing it; never yank the
                        // scrim out from under them because a screen changed.
                        . 'body[data-screen-mode="1"] ' . $_sel . '.selected{display:inline-block!important}';
                }
                // phpcs:disable WordPress.Security.EscapeOutput -- CSS selector string built from hardcoded literals, internal element ids and an integer position; no CSS-context escaper can be applied without corrupting the selector.
                echo '<style id="impulse-modal-screen-scope">' . $_edModalCss . '</style>';
                // phpcs:enable WordPress.Security.EscapeOutput
            }

            foreach ($contentElements as $element) {
                impulse_render_element($element);
            }

            // Root dropzone at bottom of content area
            echo '<div class="impulse-dropzone impulse-root-dropzone" data-parent="0" style="max-width:100%;margin-left:auto;margin-right:auto;"></div>';

            echo '</div>'; // .impulse-content-wrapper

            // Reveal the pinned snap-mode footer only while the LAST screen is
            // active. The screen engine only adds .impulse-screen-active to
            // [data-type="section"], so we mirror the last section's active
            // state onto the footer. v2.1.1843.
            if (!empty($_hasSnapFooter)) {
                // Editor-runtime: emit inline in document order. This must NOT route through
                // wp_add_inline_script('instinctor-frontend-js', ...) — the editor prints that
                // handle EARLY (line ~4998), long before this point, so a late 'after'
                // registration is silently dropped and the footer then shows on every screen.
                // Regression introduced in the WP.org enqueue conversion (ac344ed), restored
                // to a direct echo here. v2.1.1992. // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- editor-only preview chrome, parse-time coupled to the preview DOM, never on a public page.
                echo '<script>(function(){'
                   . 'var w=document.querySelector(".impulse-content-wrapper");if(!w)return;'
                   . 'var f=w.querySelector(":scope > .impulse-el[data-type=\'footer\']");if(!f)return;'
                   . 'function sync(){'
                   .   'var s=w.querySelectorAll(":scope > .impulse-el[data-type=\'section\']");'
                   .   'var l=s.length?s[s.length-1]:null;'
                   .   'f.classList.toggle("impulse-snap-footer-hide",!(l&&l.classList.contains("impulse-screen-active")));'
                   . '}'
                   . 'sync();'
                   . 'document.addEventListener("impulse-screen-changed",sync);'
                   . 'if(window.MutationObserver){new MutationObserver(sync).observe(w,{attributes:true,attributeFilter:["class"],subtree:true,childList:true});}'
                   . '})();</script>';
            }

            // Persistent Instinctor brand lockup (top-center chrome) — editor
            // parity with templates/impulse-page.php. OPT-IN via the page
            // setting `screen_brand_logo`; suppressed on sections flagged
            // `screen_brand_logo_hide`. v2.1.1845.
            if (!empty($pageSettings['screen_brand_logo'])) {
                $_brandHideIds = [];
                foreach ($contentElements as $_be) {
                    $_bs = is_object($_be) ? ($_be->settings ?? []) : ($_be['settings'] ?? []);
                    if (is_object($_bs)) $_bs = (array) $_bs;
                    if (is_string($_bs)) $_bs = json_decode($_bs, true) ?: [];
                    $_bid = is_object($_be) ? ($_be->id ?? null) : ($_be['id'] ?? null);
                    if (!empty($_bs['screen_brand_logo_hide']) && !empty($_bid)) {
                        $_brandHideIds[] = $_bid;
                    }
                }
                require_once INSTINCTOR_PLUGIN_DIR . 'includes/impulse-brand-logo.php';
                // phpcs:disable WordPress.Security.EscapeOutput -- shared sub-renderer return, escaped at construction; wp_kses_post would strip its SVG/gradient markup
                echo impulse_screen_brand_logo_html($_brandHideIds);
                // phpcs:enable WordPress.Security.EscapeOutput
            }

            // Hero sandbox controls — same overlay the frontend renders,
            // visible inside the editor canvas iframe too. Helper handles
            // its own idempotency.
            if (function_exists('impulse_render_hero_sandbox')) {
                impulse_render_hero_sandbox($pageSettings);
            }
            // Chat-screen pointer — animated arrow toward floating chat
            // trigger. Visible on the section flagged with chat_marker.
            if (function_exists('impulse_render_chat_pointer')) {
                impulse_render_chat_pointer($pageSettings);
            }

            // Render footer elements (outside content wrapper)
            foreach ($footerElements as $element) {
                impulse_render_element($element);
            }

            // Global footer preview — render a single-row mock of the live
            // frontend footer so the user can see the chrome from inside the
            // editor. Skip when the page already defines its own footer
            // block (avoids double-rendering).
            $_hasInlineFooter = false;
            foreach ($footerElements as $_fe) {
                if (($_fe['type'] ?? '') === 'footer') { $_hasInlineFooter = true; break; }
            }
            // Mirror the public-frontend skip rule (templates/impulse-page.php):
            $_pgSlug = (string) ($pageRow['slug'] ?? '');
            // Scene-mode pages stay full-bleed cinematic — no chrome
            // footer underneath the scene. Mirror the frontend rule
            // (templates/impulse-page.php $_isFooterStripped).
            $_skipGlobalFooter = true; // footer is itself a screen; the global footer is never used
            if (!$_hasInlineFooter && !$_skipGlobalFooter) {
                try {
                    $_globalFooter = \Instinctor\Services\PageService::getByType('global_footer');
                } catch (\Throwable $_e) { $_globalFooter = null; }
                if (!empty($_globalFooter)) {
                    $_gfPg   = $_globalFooter['settings'] ?? [];
                    $_gfEls  = $_globalFooter['elements'] ?? [];
                    $_gfFt   = null;
                    foreach ($_gfEls as $_gfEl) {
                        if (($_gfEl['type'] ?? '') === 'footer') { $_gfFt = $_gfEl['settings'] ?? []; break; }
                    }
                    if ($_gfFt !== null) {
                        $_bg     = $_gfFt['bg_color']     ?? ($_gfPg['bg_color'] ?? '#16181c');
                        $_text   = $_gfFt['text_color']   ?? '#a8acb3';
                        $_pt     = intval($_gfPg['padding_top']    ?? 28);
                        $_pb     = intval($_gfPg['padding_bottom'] ?? 28);
                        $_pl     = intval($_gfPg['padding_left']   ?? 32);
                        $_pr     = intval($_gfPg['padding_right']  ?? 32);
                        $_mw     = intval($_gfPg['max_width']      ?? 1280);
                        $_links  = array_merge(
                            $_gfFt['col1_links'] ?? [],
                            $_gfFt['col2_links'] ?? [],
                            $_gfFt['col3_links'] ?? []
                        );
                        $_siteName = \Instinctor\Core\Config::get('instinctor_store_name', 'Instinctor');
                        // Tagline (short one-liner), not Description (paragraph
                        // for Instagent / SEO). Same rationale as the inline
                        // footer renderer above and impulse-page.php.
                        $_siteDesc = \Instinctor\Core\Config::get('instinctor_store_tagline', '');
                        $_copy     = '© ' . gmdate('Y') . ' ' . $_siteName;
                        if ($_siteDesc) $_copy .= ' — ' . rtrim($_siteDesc, '.');
                        $_copy    .= '. All rights reserved.';
                        echo '<footer class="impulse-global-footer" style="background:' . esc_attr($_bg) . ';padding:' . (int)$_pt . 'px ' . (int)$_pr . 'px ' . (int)$_pb . 'px ' . (int)$_pl . 'px;">';
                        echo '<div style="max-width:' . (int)$_mw . 'px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap">';
                        echo '<div style="font-size:13px;color:' . esc_attr($_text) . '">' . esc_html($_copy) . '</div>';
                        echo '<div style="display:flex;align-items:center;gap:24px;font-size:14px">';
                        foreach ($_links as $_lk) {
                            $_lbl = trim((string) ($_lk['label'] ?? ''));
                            $_url = trim((string) ($_lk['url']   ?? ''));
                            if ($_lbl === '' || $_url === '' || $_url === '#') continue;
                            echo '<a href="' . esc_url($_url) . '" style="color:' . esc_attr($_text) . ';text-decoration:none">' . esc_html($_lbl) . '</a>';
                        }
                        echo '</div></div></footer>';
                    }
                }
            }
        }
?>
<script>
// In WordPress, jQuery loads in no-conflict mode — $ is not defined globally.
// Alias it here so all editor scripts below work without modification.
window.$ = window.$ || window.jQuery;
(function() {
    var wpRestNonce = '<?php echo esc_js(function_exists('wp_create_nonce') ? wp_create_nonce('wp_rest') : \Instinctor\Core\Session::token('wp_rest')); ?>';
    var apiBase = '<?php echo esc_js(rest_url('instinctor/v1/')); ?>';
    
    // Flag to indicate this is editor preview mode (loaded in iframe)
    // Only treat as editor preview when actually inside an iframe (the editor)
    window.impulseIsPreview = <?php echo isset($_GET['impulse_preview']) ? 'true' : 'false'; ?> && (window !== window.top);
    
    // ========================================
    // ========================================
    
    // Spring-driven button hover effects
    (function() {
        var springFX = { bounce:1, jelly:1, rubber:1, blur:1 };
        
        function springAnim(el, config) {
            // Cancel any running animation
            if (el._springRAF) cancelAnimationFrame(el._springRAF);
            // Disable CSS transitions during spring animation
            el.style.transition = 'background 0.3s ease-in-out, color 0.3s ease-in-out, border-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out';
            var start = performance.now();
            var dur = config.dur || 600;
            function frame(now) {
                var t = Math.min((now - start) / dur, 1);
                config.apply(el, t);
                if (t < 1) { el._springRAF = requestAnimationFrame(frame); }
                else { el._springRAF = null; config.done(el); }
            }
            el._springRAF = requestAnimationFrame(frame);
        }
        
        function springVal(t, freq, decay) {
            return Math.exp(-decay * t) * Math.cos(freq * Math.PI * 2 * t);
        }
        
        var effects = {
            bounce: {
                enter: function(el) {
                    springAnim(el, { dur: 800, apply: function(el, t) {
                        var osc = springVal(t, 2.5, 4);
                        el.style.transform = 'translateY(' + (-10 * (1 - osc)) + 'px)';
                    }, done: function(el) { el.style.transform = 'translateY(-10px)'; } });
                },
                leave: function(el) {
                    springAnim(el, { dur: 600, apply: function(el, t) {
                        var osc = springVal(t, 2, 5);
                        el.style.transform = 'translateY(' + (-10 * osc) + 'px)';
                    }, done: function(el) { el.style.transform = ''; el.style.transition = ''; } });
                }
            },
            jelly: {
                enter: function(el) {
                    springAnim(el, { dur: 900, apply: function(el, t) {
                        var osc = springVal(t, 3, 3) * 0.18;
                        el.style.transform = 'scaleX(' + (1 - osc) + ') scaleY(' + (1 + osc * 0.7) + ')';
                    }, done: function(el) { el.style.transform = 'scale(1.02)'; } });
                },
                leave: function(el) {
                    springAnim(el, { dur: 500, apply: function(el, t) {
                        var osc = springVal(t, 2.5, 5) * 0.06;
                        el.style.transform = 'scaleX(' + (1.02 - osc) + ') scaleY(' + (1.02 + osc) + ')';
                    }, done: function(el) { el.style.transform = ''; el.style.transition = ''; } });
                }
            },
            rubber: {
                enter: function(el) {
                    springAnim(el, { dur: 1000, apply: function(el, t) {
                        var osc = springVal(t, 2.2, 2.5);
                        el.style.transform = 'scaleX(' + (1 + osc * 0.25) + ') scaleY(' + (1 - osc * 0.12) + ')';
                    }, done: function(el) { el.style.transform = 'scale(1.04)'; } });
                },
                leave: function(el) {
                    springAnim(el, { dur: 600, apply: function(el, t) {
                        var osc = springVal(t, 2, 4) * 0.1;
                        el.style.transform = 'scaleX(' + (1.04 + osc) + ') scaleY(' + (1.04 - osc) + ')';
                    }, done: function(el) { el.style.transform = ''; el.style.transition = ''; } });
                }
            },
            blur: {
                enter: function(el) {
                    springAnim(el, { dur: 400, apply: function(el, t) {
                        var b = (1 - t) * 4;
                        el.style.filter = 'blur(' + b.toFixed(1) + 'px) brightness(1.15)';
                        el.style.transform = 'scale(' + (1 + t * 0.06) + ')';
                    }, done: function(el) { el.style.filter = 'brightness(1.15)'; el.style.transform = 'scale(1.06)'; } });
                },
                leave: function(el) {
                    springAnim(el, { dur: 300, apply: function(el, t) {
                        var b = t * 3;
                        el.style.filter = 'blur(' + (b > 0.5 ? 0 : b).toFixed(1) + 'px)';
                        el.style.transform = 'scale(' + (1.06 - t * 0.06) + ')';
                    }, done: function(el) { el.style.filter = ''; el.style.transform = ''; el.style.transition = ''; } });
                }
            }
        };
        
        function initSpringHover() {
            document.querySelectorAll('[class*="impulse-btn-hover-"]').forEach(function(btn) {
                if (btn._springBound) return;
                btn._springBound = true;
                var fxName = null;
                btn.className.split(' ').forEach(function(c) {
                    var m = c.match(/^impulse-btn-hover-(.+)$/);
                    if (m && springFX[m[1]]) fxName = m[1];
                });
                if (!fxName || !effects[fxName]) return;
                btn.addEventListener('mouseenter', function() { effects[fxName].enter(btn); });
                btn.addEventListener('mouseleave', function() { effects[fxName].leave(btn); });
            });
        }
        initSpringHover();
        var obs = new MutationObserver(function() { setTimeout(initSpringHover, 150); });
        obs.observe(document.body, { childList: true, subtree: true });
    })();
    
    // Icon List inline text editing
    (function() {
        var _ilSaveTimer = null;
        // Collect cell content via innerHTML (not textContent) so users
        // can keep <br>, <strong>, <em>, <span style>, etc. typed into
        // a cell. Renderer reads through impulse_kses_richtext so only
        // a safe HTML allowlist survives the round trip. v2.1.1314.
        // Strip Chrome/Firefox's stray trailing <br> that browsers
        // auto-inject inside focused empty contenteditables — saving
        // that would resurrect the placeholder mid-cell.
        function _ilHtml(span) {
            if (!span) return '';
            var h = span.innerHTML || '';
            return h.replace(/<br\s*\/?>\s*$/i, '');
        }
        function ilCollectAndSave(elWrap) {
            if (!elWrap) return;
            var elId = elWrap.dataset.id;
            var items = [];
            var list = elWrap.querySelector('.impulse-table');
            if (list) {
                list.querySelectorAll('li[data-item-idx]').forEach(function(li) {
                    var textSpan = li.querySelector('[data-col="text"]');
                    // v2.1.1375: collect ALL data columns col2..col6. Previously
                    // only col2 + col3 were read; tables with 4-6 columns lost
                    // col4..col6 on every inline edit because the parent-side
                    // merge dropped any key not present in the message.
                    var rec = { text: _ilHtml(textSpan) };
                    for (var c = 2; c <= 6; c++) {
                        var colSpan = li.querySelector('[data-col="col' + c + '"]');
                        if (colSpan) rec['col' + c] = _ilHtml(colSpan);
                    }
                    items.push(rec);
                });
            }
            // Collect headers (HTML, same rationale).
            var headers = {};
            elWrap.querySelectorAll('[data-header]').forEach(function(h) {
                headers[h.dataset.header] = _ilHtml(h);
            });
            window.parent.postMessage({
                type: 'impulse-table-inline',
                id: elId,
                items: items,
                headers: headers
            }, '*');
        }
        
        document.addEventListener('input', function(e) {
            var span = e.target;
            if (!span.hasAttribute('contenteditable')) return;
            var elWrap = span.closest('.impulse-el[data-type="table"]');
            if (!elWrap) return;
            if (!span.dataset.col && !span.dataset.header) return;
            clearTimeout(_ilSaveTimer);
            _ilSaveTimer = setTimeout(function() { ilCollectAndSave(elWrap); }, 400);
        });

        // Class-based empty detection for ALL table cells. Browsers inject
        // a stray <br> into focused empty contenteditables, so :empty isn't
        // reliable — instead we toggle .is-empty based on innerText. The CSS
        // rule emitted by the renderer hangs the "New item" placeholder off
        // .is-empty::before. Treat trimmed innerText OR a single bare <br>
        // (Firefox/Chrome auto-injection) as "really empty".
        function ilCellIsEmpty(el) {
            var txt = (el.innerText || '').replace(/\u200B/g, '').trim();
            if (txt) return false;
            // Empty visually if the only content is whitespace + at most one <br>.
            var html = (el.innerHTML || '').replace(/<br\s*\/?>/gi, '').trim();
            return !html;
        }
        function ilSyncEmptyClass(el) {
            if (ilCellIsEmpty(el)) el.classList.add('is-empty');
            else el.classList.remove('is-empty');
        }
        function ilSyncAll(root) {
            (root || document).querySelectorAll(
                '.impulse-el[data-type="table"] [data-col], ' +
                '.impulse-el[data-type="table"] [data-header]'
            ).forEach(ilSyncEmptyClass);
        }
        // Initial pass + observer for newly-mounted cells.
        ilSyncAll();
        new MutationObserver(function(muts) {
            muts.forEach(function(m) {
                m.addedNodes.forEach(function(n) {
                    if (n.nodeType !== 1) return;
                    if (n.matches && (n.matches('[data-col]') || n.matches('[data-header]'))) {
                        ilSyncEmptyClass(n);
                    } else if (n.querySelectorAll) {
                        ilSyncAll(n);
                    }
                });
            });
        }).observe(document.body, { childList: true, subtree: true });
        // Toggle on every input event (covers typing + paste).
        document.addEventListener('input', function(e) {
            var span = e.target;
            if (!span.hasAttribute || !span.hasAttribute('contenteditable')) return;
            if (!span.closest || !span.closest('.impulse-el[data-type="table"]')) return;
            if (!span.dataset.col && !span.dataset.header) return;
            ilSyncEmptyClass(span);
        });
        // Re-sync on focus — Firefox sometimes injects a <br> ON focus that
        // would falsely flip the cell to "non-empty" before any input fires.
        document.addEventListener('focusin', function(e) {
            var span = e.target;
            if (!span.hasAttribute || !span.hasAttribute('contenteditable')) return;
            if (!span.closest || !span.closest('.impulse-el[data-type="table"]')) return;
            if (!span.dataset.col && !span.dataset.header) return;
            ilSyncEmptyClass(span);
        });
        // And on blur — strip the auto-<br> back out so the saved value stays
        // truly empty (otherwise PHP renders the cell with a literal <br>
        // child on next reload and ilCellIsEmpty's HTML check passes but
        // browsers may treat it as content for caret positioning).
        document.addEventListener('focusout', function(e) {
            var span = e.target;
            if (!span.hasAttribute || !span.hasAttribute('contenteditable')) return;
            if (!span.closest || !span.closest('.impulse-el[data-type="table"]')) return;
            if (!span.dataset.col && !span.dataset.header) return;
            if (ilCellIsEmpty(span) && span.innerHTML !== '') span.innerHTML = '';
            ilSyncEmptyClass(span);
        });

        // Paste sanitiser — strip ALL formatting from clipboard so pasting from
        // Word / Notion / a webpage doesn't drag inline styles, fonts, colors,
        // or random spans into the cell. The Table block's own typography
        // settings stay authoritative; users can reformat with the panel.
        document.addEventListener('paste', function(e) {
            var span = e.target;
            if (!span.hasAttribute || !span.hasAttribute('contenteditable')) return;
            if (!span.closest || !span.closest('.impulse-el[data-type="table"]')) return;
            if (!span.dataset.col && !span.dataset.header) return;
            e.preventDefault();
            var cd = e.clipboardData || window.clipboardData;
            if (!cd) return;
            var text = cd.getData('text/plain') || '';
            // execCommand still the most compatible way to insert at caret
            // inside a contenteditable while keeping undo history.
            try { document.execCommand('insertText', false, text); }
            catch (_e) {
                var sel = window.getSelection();
                if (sel && sel.rangeCount) {
                    sel.deleteFromDocument();
                    sel.getRangeAt(0).insertNode(document.createTextNode(text));
                    sel.collapseToEnd();
                }
            }
        });
        
        // Prevent contenteditable clicks from selecting the element
        document.addEventListener('mousedown', function(e) {
            if (e.target.hasAttribute('contenteditable') && e.target.closest('.impulse-el[data-type="table"]')) {
                e.stopPropagation();
            }
        }, true);
        
        // Enter handling inside Table cells:
        //   • Shift+Enter inserts a <br> at the caret (line break inside cell)
        //   • Plain Enter moves focus to the same column in the next row
        // v2.1.1314 — was: plain Enter always moved to next row, Shift+Enter
        // wasn't intercepted at all so browser default fired (which is
        // sometimes a <div>+<br>, not the clean <br> we want).
        document.addEventListener('keydown', function(e) {
            if (e.key !== 'Enter' || !e.target.hasAttribute('contenteditable') || !e.target.closest('.impulse-table')) return;
            e.preventDefault();
            if (e.shiftKey) {
                // execCommand is deprecated but still the most reliable cross-
                // browser way to insert a single <br> at the caret inside a
                // contenteditable. document.execCommand('insertLineBreak')
                // gives consistent <br> across Chrome/Firefox/Safari.
                document.execCommand('insertLineBreak');
                // Trigger an input event so the existing debounced save picks
                // up the change (insertLineBreak fires input on Chrome but be
                // defensive in case a browser doesn't).
                e.target.dispatchEvent(new Event('input', { bubbles: true }));
                return;
            }
            var li = e.target.closest('li[data-item-idx]');
            if (!li) return;
            var nextLi = li.nextElementSibling;
            if (nextLi) {
                var col = e.target.dataset.col;
                var nextSpan = nextLi.querySelector('[data-col="' + col + '"]');
                if (nextSpan) { nextSpan.focus(); }
            }
        });
    })();
    
    // Click on element - select it and open settings in parent (use event delegation for dynamic elements)
    // Block enter/exit animation via MutationObserver
    (function() {
        if (window === window.top) return;
        var wrapper = document.querySelector('.impulse-content-wrapper');
        if (!wrapper) return;
        var obs = new MutationObserver(function(mutations) {
            mutations.forEach(function(m) {
                m.addedNodes.forEach(function(node) {
                    if (node.nodeType === 1 && node.classList && node.classList.contains('impulse-el')) {
                        node.classList.add('impulse-entering');
                        setTimeout(function() { node.classList.remove('impulse-entering'); }, 220);
                    }
                });
            });
        });
        obs.observe(wrapper, { childList: true, subtree: true });
    })();

    // Report page content height to parent so iframe auto-sizes (enables canvas scroll)
    // PHP sets data-screen-mode="1" on body for screen mode pages — never resize those
    var _isScreenModePage = true;
    if (window !== window.top && !_isScreenModePage) {
        var _reportH = function() {
            if (_isScreenModePage) return;
            var h = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
            window.parent.postMessage({ type: 'impulse-page-height', height: h }, '*');
        };
        setTimeout(_reportH, 150);
        if (window.ResizeObserver) {
            var _hObs = new ResizeObserver(_reportH);
            _hObs.observe(document.body);
        }
    }

    // Social block empty-state helper link — relay click to parent admin frame.
    // Capture phase + stopImmediatePropagation so it fires before .impulse-el
    // selection handlers can swallow the click.
    document.addEventListener('click', function(e) {
        var btn = e.target.closest('.impulse-goto-seo-site');
        if (!btn) return;
        e.preventDefault();
        e.stopImmediatePropagation();
        window.parent.postMessage({ type: 'impulse-goto-seo-site' }, '*');
    }, true);

    // Booking block empty-state "Add Service" — open Store > Bookings and
    // click the topbar Add Service button in the parent admin.
    document.addEventListener('click', function(e) {
        var btn = e.target.closest('.impulse-goto-add-service');
        if (!btn) return;
        e.preventDefault();
        e.stopImmediatePropagation();
        window.parent.postMessage({ type: 'impulse-goto-add-service' }, '*');
    }, true);

    // Products block empty-state "Add Products" — open Store > Products.
    // Same pattern as Add Service above. Capture phase + stopImmediate
    // so it fires before the .impulse-el selection handler swallows it.
    document.addEventListener('click', function(e) {
        var btn = e.target.closest('.impulse-goto-products-btn');
        if (!btn) return;
        e.preventDefault();
        e.stopImmediatePropagation();
        window.parent.postMessage({ type: 'impulse-goto-products' }, '*');
    }, true);

        document.body.addEventListener('click', function(e) {
        if (window === window.top) return; // Not in editor iframe — let frontend.js handle all clicks
        var el = e.target.closest('.impulse-el');
        if (!el) return;
        
        // Let toolbar button onclick handlers work - don't interfere
        if (e.target.closest('.impulse-el-toolbar')) {
            e.stopPropagation(); // Prevent parent container from handling click
            return; // Let the button's onclick fire
        }
        
        // Let functional UI elements work in editor — cart qty buttons, checkout links,
        // unfold headers, form inputs, quickview close, etc.
        // Instagent placeholder controls (.impulse-ai-gen-*) are also in this allowlist
        // so the in-block Generate button + settings deep-link reach the document-level
        // postMessage bridge instead of being killed by stopPropagation below.
        if (e.target.closest(
            '.impulse-tab-btn, .impulse-toggle-header,' +
            '.instinctor-qty-btn, .instinctor-cart-item-remove, .impulse-quickview-close,' +
            '.impulse-checkout-btn, .instinctor-btn, a[href],' +
            '.impulse-pcard-btn, .impulse-bundle-add-btn,' +
            '.impulse-ai-gen-btn, .impulse-ai-gen-settings-link,' +
            '[data-action], .impulse-screen-nav-btn, .impulse-acc-stack-prev, .impulse-acc-stack-next,' +
            // Spotlight unfold + reveal triggers: title row must toggle
            // the panel in the editor without the selection handler
            // killing the native click.
            '.impulse-acc-spot-header, .impulse-acc-reveal-title,' +
            // v2.1.1394: contact-form custom dropdown — trigger button + open
            // list options must reach their own click handler in the iframe
            // without the editor selection killing it.
            '.impulse-form-dropdown-trigger, .impulse-form-dropdown-option'
        )) {
            // Still select the element
            document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
            el.classList.add('selected');
            var _sid = el.dataset.id, _stype = el.dataset.type;
            if (window.parent && _sid && _stype) {
                window.parent.postMessage({ type: 'impulse-select', id: _sid, elementType: _stype }, '*');
            }
            return; // Let the native click fire
        }
        
        // v2.1.1378: cell click on canvas selects THE CELL, not just the
        // parent grid. Mirrors the Structure-panel cell-row click behaviour
        // so left- and right-side surfaces stay in sync. Triggers ONLY when
        // the topmost .impulse-el under the cursor is the grid/rows/
        // columns itself; a nested content block (heading inside a cell,
        // etc.) is closer to the target and short-circuits this branch via
        // the closest('.impulse-el') hit at the top of the handler.
        var type = el.dataset.type;
        if (type === 'grid' || type === 'rows' || type === 'columns') {
            var _cellEl = e.target.closest('.impulse-rows-cell, .impulse-grid-cell, .impulse-layout-cell');
            // Walk up: only counts as a cell hit if the cell belongs to THIS
            // grid (the closest .impulse-el). Without the gate, nested grids
            // would mis-attribute cells to the outer parent.
            if (_cellEl && _cellEl.closest('.impulse-el') === el) {
                var _cellKey;
                if (type === 'grid') {
                    _cellKey = _cellEl.dataset.cell;
                } else if (type === 'rows') {
                    _cellKey = _cellEl.dataset.row + '-' + _cellEl.dataset.col;
                } else { // columns
                    // Structure-panel encodes cellKey as "col-row" for columns
                    // (see admin-core buildStructureItem). Match that here so
                    // both surfaces store the same key string.
                    _cellKey = _cellEl.dataset.col + '-' + _cellEl.dataset.row;
                }
                // Visual: clear all .selected, mark this grid + this cell.
                document.querySelectorAll('.impulse-el.selected').forEach(function(x) { x.classList.remove('selected'); });
                document.querySelectorAll(
                    '.impulse-rows-cell.selected, .impulse-grid-cell.selected, .impulse-layout-cell.selected'
                ).forEach(function(c) { c.classList.remove('selected'); });
                el.classList.add('selected');
                _cellEl.classList.add('selected');
                if (window.parent && _cellKey !== undefined && _cellKey !== null && _cellKey !== '') {
                    window.parent.postMessage({
                        type: 'impulse-cell-select',
                        parentId: el.dataset.id,
                        parentType: type,
                        cellKey: String(_cellKey)
                    }, '*');
                }
                e.stopPropagation();
                return;
            }
        }
        
        // For text elements, focus the editable content
        if (type === 'text') {
            var textEl = el.querySelector('.impulse-text [contenteditable]');
            if (textEl && !e.target.closest('[contenteditable]')) {
                e.stopPropagation();
                // Send select message before focusing
                var id = el.dataset.id;
                if (window.parent && id) {
                    window.parent.postMessage({ type: 'impulse-select', id: id, elementType: type }, '*');
                }
                document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
                el.classList.add('selected');
                textEl.focus();
                return;
            }
        }
        
        if (e.target.closest('[contenteditable]')) {
            // Still send select message even when clicking on contenteditable
            e.stopPropagation(); // Prevent bubbling to parent containers
            var id = el.dataset.id;
            if (window.parent && id && type) {
                window.parent.postMessage({ type: 'impulse-select', id: id, elementType: type }, '*');
            }
            document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
            el.classList.add('selected');
            return;
        }
        e.stopPropagation();
        
        document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
        el.classList.add('selected');
        
        var id = el.dataset.id;
        if (window.parent && id && type) {
            window.parent.postMessage({ type: 'impulse-select', id: id, elementType: type }, '*');
        }
    });
    
    // Click on body to deselect and show page settings
    document.body.addEventListener('click', function(e) {
        if (window === window.top) return; // Not in editor iframe
        if (!e.target.closest('.impulse-el') && !e.target.closest('.impulse-grid-cell') && !e.target.closest('.impulse-rows-cell') && !e.target.closest('.impulse-layout-cell')) {
            // Don't deselect when clicking inside the quickview modal or on the hover preview image
            if (e.target.closest('.impulse-quickview-overlay')) return;
            if (e.target.id === 'impulse-qv-preview') return;
            document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
            document.querySelectorAll('.impulse-grid-cell').forEach(function(x) { x.classList.remove('selected'); });
            document.querySelectorAll('.impulse-rows-cell').forEach(function(x) { x.classList.remove('selected'); });
            document.querySelectorAll('.impulse-layout-cell').forEach(function(x) { x.classList.remove('selected'); });
            if (window.parent) {
                window.parent.postMessage({ type: 'impulse-deselect' }, '*');
            }
            if (window._impulseResetScrollTracker) window._impulseResetScrollTracker();
        }
    });
    
    // ── Toolbar auto-fade: hide toolbar 2.5s after selection, show on hover ──
    (function() {
        var fadeTimer = null;
        var FADE_DELAY = 2500;
        var fadedEl = null;
        function startFade() {
            clearTimeout(fadeTimer);
            fadeTimer = setTimeout(function() {
                var sel = document.querySelector('.impulse-el.selected');
                if (sel) { sel.classList.add('toolbar-faded'); fadedEl = sel; }
            }, FADE_DELAY);
        }
        // Hook: after any selection, start fade timer
        // All selection paths go through postMessage to parent — intercept that
        var origPost = window.parent && window.parent.postMessage ? window.parent.postMessage.bind(window.parent) : null;
        // Simpler: just watch for .selected appearing via periodic check after click
        document.addEventListener('click', function() {
            // Clear previous fade
            if (fadedEl) { fadedEl.classList.remove('toolbar-faded'); fadedEl = null; }
            clearTimeout(fadeTimer);
            // Start new fade timer
            startFade();
        }, true);
        // Hover: unfade and restart
        document.addEventListener('mouseenter', function(e) {
            var el = e.target.closest('.impulse-el.selected');
            if (!el) return;
            if (fadedEl) { fadedEl.classList.remove('toolbar-faded'); fadedEl = null; }
            clearTimeout(fadeTimer);
            startFade();
        }, true);
    })();

    // ── Diagnostic helpers (call from DevTools console) ──────────────────────
    window.impulseDbg = function() {
        var out = {};
        var toolbars = document.querySelectorAll('.impulse-el-toolbar');
        var selected = document.querySelector('.impulse-el.selected');
        var animators = document.querySelectorAll('.impulse-logo-animator');
        out.toolbarCount = toolbars.length;
        out.selectedEl = selected ? (selected.dataset.type + '#' + selected.dataset.id) : null;
        if (selected) {
            var tb = selected.querySelector(':scope > .impulse-el-toolbar');
            if (tb) {
                var cs = getComputedStyle(tb);
                out.selectedToolbar = {
                    opacity: cs.opacity,
                    transform: cs.transform,
                    display: cs.display,
                    pointerEvents: cs.pointerEvents,
                    zIndex: cs.zIndex,
                    hasFadedClass: selected.classList.contains('toolbar-faded'),
                    classes: selected.className
                };
            } else {
                out.selectedToolbar = 'NO TOOLBAR IN DOM';
            }
        }
        out.animators = Array.prototype.map.call(animators, function(a) {
            var asset = a.querySelector('.impulse-la-asset');
            return {
                id: a.id,
                preset: a.dataset.preset || '(none)',
                assetOpacity: asset ? getComputedStyle(asset).opacity : 'NO ASSET',
                assetInlineOp: asset ? asset.style.opacity : '',
                hasCanvas: !!a.querySelector('.impulse-la-canvas')
            };
        });
        out.body = {
            width: document.body.offsetWidth,
            height: document.body.offsetHeight,
            viewportW: window.innerWidth,
            viewportH: window.innerHeight,
            screenMode: document.body.dataset.screenMode === '1'
        };
        console.log('[IMPULSE DBG]', out);
        return out;
    };

    // ── Defensive: clear any stale toolbar-faded on load so toolboxes are visible ──
    document.addEventListener('DOMContentLoaded', function() {
        document.querySelectorAll('.impulse-el.toolbar-faded').forEach(function(el) {
            el.classList.remove('toolbar-faded');
        });
        // Restore animator asset visibility in case CSS rule or stale inline opacity hid it
        document.querySelectorAll('.impulse-la-asset').forEach(function(a) {
            if (!a.style.opacity || a.style.opacity === '0') {
                a.style.opacity = '1';
            }
        });
        // [IMPULSE] iframe-loaded banner removed in v2.1.1309 — dev
        // hint; impulseDbg() still works without the announcement.
    });
    if (document.readyState !== 'loading') {
        // DOM already ready — fire immediately
        document.querySelectorAll('.impulse-el.toolbar-faded').forEach(function(el) {
            el.classList.remove('toolbar-faded');
        });
        document.querySelectorAll('.impulse-la-asset').forEach(function(a) {
            if (!a.style.opacity || a.style.opacity === '0') {
                a.style.opacity = '1';
            }
        });
    }

    // ── Scene sizing fix: force body height to iframe viewport on load + resize ──
    // In screen mode, sections use height:100vh. Parent canvas CSS uses aspect-ratio:16/9
    // + height:auto which computes iframe height from width — on hard refresh the width
    // can be measured at a moment when the container hasn't settled, producing a taller
    // iframe that overflows the canvas area (scene goes behind the bottom panel).
    // We continuously clamp body + sections to the iframe's actual viewport height using
    // multiple strategies: immediate sync, RAF loop for early frames, resize events, and
    // ResizeObserver on <html>.
    (function() {
        function measuredHeight() {
            // Prefer documentElement.clientHeight — it measures the actual viewport inside
            // the iframe, excluding scrollbars. Matches what 100vh resolves to.
            return document.documentElement.clientHeight || window.innerHeight || 0;
        }
        function syncBodyHeight() {
            // Native vertical scroll (classic): the body is its own scroll
            // container (see _impulseApplyNativeHeights). Do NOT tear its height
            // down or pin sections here - just keep --impulse-vh fresh and bail,
            // otherwise we strip the body-scroller height every resize/retry.
            if (document.body.classList.contains('impulse-scroll-classic')) {
                var _cvh = measuredHeight();
                if (_cvh >= 100) document.documentElement.style.setProperty('--impulse-vh', _cvh + 'px');
                return;
            }
            // Page mode: body must be free to grow past one viewport.
            // syncBodyHeight previously returned early in this case — but if a
            // prior render was in screen-mode the inline locks stuck on
            // body and the page couldn't scroll. Always tear those locks
            // down when not in screen-mode. (Native scroll is handled above.)
            if (document.body.dataset.screenMode !== '1') {
                ['height','max-height','min-height'].forEach(function(p){
                    document.body.style.removeProperty(p);
                });
                document.documentElement.style.removeProperty('height');
                // Keep --impulse-vh fresh.
                var hv = measuredHeight();
                if (hv >= 100) document.documentElement.style.setProperty('--impulse-vh', hv + 'px');
                return;
            }
            var h = measuredHeight();
            if (h < 100) return; // ignore transient zero/tiny measurements
            // Use setProperty with 'important' so nothing in the cascade can override.
            document.body.style.setProperty('height', h + 'px', 'important');
            document.body.style.setProperty('max-height', h + 'px', 'important');
            document.body.style.setProperty('min-height', h + 'px', 'important');
            document.documentElement.style.setProperty('height', h + 'px', 'important');
            document.documentElement.style.setProperty('--impulse-vh', h + 'px');
            // Section-height sync: in scene-mode pages, lock every root
            // section to viewport height.
            var _sectionSel = 'body[data-screen-mode="1"] .impulse-content-wrapper > .impulse-el[data-type="section"]';
            document.querySelectorAll(_sectionSel).forEach(function(s) {
                s.style.setProperty('height', h + 'px', 'important');
                var inner = s.querySelector(':scope > .impulse-section');
                if (inner) {
                    // 2026-05-29: ACTIVE screen's inner must be allowed to grow
                    // past viewport when content overflows — otherwise the outer's
                    // `overflow-y:auto` (added in the same release) sees
                    // scrollHeight === clientHeight, the wheel/touch handlers
                    // think we're at both edges, and every scroll fires a
                    // screen transition. Only lock `min-height` so the section
                    // still fills the viewport when content is short. The
                    // inactive screens stay rigidly clamped to viewport so they
                    // don't blow out the wrapper.
                    if (s.classList.contains('impulse-screen-active')) {
                        inner.style.removeProperty('height');
                        inner.style.removeProperty('max-height');
                        inner.style.setProperty('min-height', h + 'px', 'important');
                    } else {
                        inner.style.setProperty('height', h + 'px', 'important');
                        inner.style.setProperty('min-height', h + 'px', 'important');
                        inner.style.setProperty('max-height', h + 'px', 'important');
                    }
                }
            });
        }
        // Initial sync + cascade of retries to catch late layout settling.
        syncBodyHeight();
        [0, 16, 50, 120, 250, 500, 1000, 2000].forEach(function(ms) {
            setTimeout(syncBodyHeight, ms);
        });

        // Resize events (iframe viewport change)
        window.addEventListener('resize', syncBodyHeight);
        // ResizeObserver on <html> to catch any size change the browser reports
        if (window.ResizeObserver) {
            var ro = new ResizeObserver(syncBodyHeight);
            ro.observe(document.documentElement);
        }
        // Expose for manual testing
        window.impulseSyncBodyHeight = syncBodyHeight;
    })();

    // Drag-to-reposition section background (all modes use percentage positioning)
    (function() {
        var dragging = false;
        var dragSection = null;
        var startX, startY, startPosX, startPosY;
        var contW, contH;
        
        document.body.addEventListener('mousedown', function(e) {
            if (e.button !== 0) return;
            // Don't intercept clicks on child blocks, toolbars, etc
            var target = e.target;
            if (target.closest('.impulse-el-toolbar') || target.closest('button') || target.closest('a') || target.closest('input') || target.closest('textarea')) return;
            
            var el = target.closest('.impulse-el[data-type="section"]');
            if (!el) return;
            var section = el.querySelector('section.impulse-section');
            if (!section) return;
            var bgImage = getComputedStyle(section).backgroundImage;
            if (!bgImage || bgImage === 'none') return;
            
            e.preventDefault();
            dragging = true;
            dragSection = section;
            startX = e.clientX;
            startY = e.clientY;
            contW = section.offsetWidth;
            contH = section.offsetHeight;
            
            // Parse current percentage position (all modes store as %)
            var posStr = getComputedStyle(section).backgroundPosition;
            var parts = posStr.split(/\s+/);
            // Convert px values to % if legacy data
            if (parts[0] && parts[0].indexOf('px') !== -1) {
                startPosX = contW > 0 ? (parseFloat(parts[0]) / contW) * 100 : 50;
                startPosY = contH > 0 ? (parseFloat(parts[1]) / contH) * 100 : 50;
            } else {
                startPosX = parseFloat(parts[0]) || 50;
                startPosY = parseFloat(parts[1]) || 50;
            }
            document.body.style.cursor = 'grabbing';
        });
        
        document.addEventListener('mousemove', function(e) {
            if (!dragging || !dragSection) return;
            var dx = e.clientX - startX;
            var dy = e.clientY - startY;
            
            // All modes: percentage positioning
            var pctX = startPosX - (dx / contW) * 100;
            var pctY = startPosY - (dy / contH) * 100;
            pctX = Math.max(0, Math.min(100, pctX));
            pctY = Math.max(0, Math.min(100, pctY));
            dragSection.style.backgroundPosition = pctX.toFixed(1) + '% ' + pctY.toFixed(1) + '%';
        });
        
        document.addEventListener('mouseup', function(e) {
            if (!dragging || !dragSection) return;
            dragging = false;
            document.body.style.cursor = '';
            
            var rawPos = dragSection.style.backgroundPosition;
            var parts = rawPos.split(/\s+/);
            var px = parseFloat(parts[0]) || 50;
            var py = parseFloat(parts[1]) || 50;
            // Snap to nearest preset if close (within 5%)
            var snapPoints = [0, 50, 100];
            var snapThreshold = 5;
            snapPoints.forEach(function(sp) {
                if (Math.abs(px - sp) < snapThreshold) px = sp;
                if (Math.abs(py - sp) < snapThreshold) py = sp;
            });
            var pos = px + '% ' + py + '%';
            dragSection.style.backgroundPosition = pos;
            
            var el = dragSection.closest('.impulse-el[data-type="section"]');
            if (el && window.parent) {
                window.parent.postMessage({ type: 'impulse-bg-position-changed', id: el.dataset.id, position: pos }, '*');
            }
            dragSection = null;
        });
        
        // Show grab cursor on hover for sections with background images
        document.body.addEventListener('mouseover', function(e) {
            var el = e.target.closest('.impulse-el[data-type="section"]');
            if (!el) return;
            var section = el.querySelector('section.impulse-section');
            if (!section) return;
            var bgImage = getComputedStyle(section).backgroundImage;
            if (bgImage && bgImage !== 'none') {
                section.style.cursor = 'grab';
            } else {
                section.style.cursor = '';
            }
        });
    })();
    
    // Click on grid cell - select it for element addition AND open grid settings (using event delegation for dynamic cells)
    document.body.addEventListener('click', function(e) {
        if (window === window.top) return; // Not in editor iframe
        var cell = e.target.closest('.impulse-grid-cell');
        if (!cell) return;
        
        // Check if clicked directly on a child .impulse-el inside the cell (not the grid itself)
        var clickedEl = e.target.closest('.impulse-el');
        var gridEl = cell.closest('.impulse-el[data-type="grid"]');
        
        // If clicked on a child element that's inside this cell (and not the grid wrapper), let it handle its own click
        if (clickedEl && clickedEl !== gridEl && cell.contains(clickedEl)) {
            return; // Don't intercept - let the element's click handler work
        }
        
        e.stopPropagation();
        // Deselect all elements and other cells
        document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
        document.querySelectorAll('.impulse-grid-cell').forEach(function(x) { x.classList.remove('selected'); });
        // Select THIS cell only — do NOT also mark the parent grid as
        // .selected. The grid's settings panel still opens (via the
        // impulse-select postMessage below), but the Structure tree
        // should highlight the specific cell the user clicked, not the
        // whole grid block. Marking both confused users about which
        // node was actually being acted on.
        cell.classList.add('selected');
        // Get parent grid element and cell index
        var parentId = cell.getAttribute('data-parent');
        var cellIndex = cell.getAttribute('data-cell');
        if (window.parent && parentId) {
            // Send both cell selection (for dropping elements) AND element selection (to open settings)
            window.parent.postMessage({ type: 'impulse-grid-cell-select', parentId: parentId, cellIndex: cellIndex }, '*');
            window.parent.postMessage({ type: 'impulse-select', id: parentId, elementType: 'grid' }, '*');
        }
    }, true); // Use capture phase to handle before other handlers
    
    // Click on cell grid cell - select it for styling (shift for multi-select)
    document.body.addEventListener('click', function(e) {
        if (window === window.top) return; // Not in editor iframe
        var cell = e.target.closest('.impulse-rows-cell');
        if (cell) {
            // Don't handle if clicked on a child element inside the cell
            var childEl = e.target.closest('.impulse-el:not([data-type="rows"])');
            if (childEl && cell.contains(childEl)) {
                return; // Let the child element handle its own click
            }
            
            e.stopPropagation();
            
            // Get parent cell grid ID and cell row/col
            var parentGrid = cell.closest('.impulse-el[data-type="rows"]');
            var parentId = parentGrid ? parentGrid.getAttribute('data-id') : null;
            var rowIdx = cell.getAttribute('data-row');
            var colIdx = cell.getAttribute('data-col');
            var cellKey = rowIdx + '-' + colIdx;
            
            // Update selection visual immediately in iframe
            if (!e.shiftKey) {
                // Regular click - deselect all cells, select only this one
                document.querySelectorAll('.impulse-rows-cell').forEach(function(c) { 
                    c.classList.remove('selected'); 
                });
            }
            cell.classList.toggle('selected');
            
            if (parentGrid) {
                // Cell click selects the CELL only, not the parent structure
                // (Grid / Rows / Columns). The structure's settings panel still
                // opens via the impulse-rows-select postMessage; we just
                // don't outline the whole structure box. Mirrors the masonry
                // grid handler. Sergei 2026-06-03.
                document.querySelectorAll('.impulse-el').forEach(function(el) { el.classList.remove('selected'); });
            }
            
            if (window.parent && parentId) {
                window.parent.postMessage({ 
                    type: 'impulse-rows-select', 
                    parentId: parentId, 
                    cellKey: cellKey,
                    shiftKey: e.shiftKey,
                    elementType: 'rows'
                }, '*');
            }
        }
    }, true);
    
    // Click on layout grid cell - select it for styling
    document.body.addEventListener('click', function(e) {
        var cell = e.target.closest('.impulse-layout-cell');
        if (cell) {
            var childEl = e.target.closest('.impulse-el:not([data-type="columns"])');
            if (childEl && cell.contains(childEl)) {
                return;
            }
            
            e.stopPropagation();
            
            var parentGrid = cell.closest('.impulse-el[data-type="columns"]');
            var parentId = parentGrid ? parentGrid.getAttribute('data-id') : null;
            var colIdx = cell.getAttribute('data-col');
            var rowIdx = cell.getAttribute('data-row');
            var cellKey = colIdx + '-' + rowIdx; // col-row for columns
            
            if (!e.shiftKey) {
                document.querySelectorAll('.impulse-layout-cell').forEach(function(c) { 
                    c.classList.remove('selected'); 
                });
            }
            cell.classList.toggle('selected');
            
            if (parentGrid) {
                // Cell click selects the CELL only, not the parent structure
                // (Grid / Rows / Columns). The structure's settings panel still
                // opens via the impulse-rows-select postMessage; we just
                // don't outline the whole structure box. Mirrors the masonry
                // grid handler. Sergei 2026-06-03.
                document.querySelectorAll('.impulse-el').forEach(function(el) { el.classList.remove('selected'); });
            }
            
            if (window.parent && parentId) {
                window.parent.postMessage({ 
                    type: 'impulse-rows-select', 
                    parentId: parentId, 
                    cellKey: cellKey,
                    shiftKey: e.shiftKey,
                    elementType: 'columns'
                }, '*');
            }
        }
    }, true);
    
    // Cell grid hover effects
    document.body.addEventListener('mouseenter', function(e) {
        var cell = e.target.closest('.impulse-rows-cell');
        if (cell && cell.dataset.hoverOverlay) {
            var overlay = cell.querySelector('.impulse-cell-overlay');
            if (overlay) {
                overlay.style.backgroundColor = cell.dataset.hoverOverlay;
            }
        }
    }, true);
    
    document.body.addEventListener('mouseleave', function(e) {
        var cell = e.target.closest('.impulse-rows-cell');
        if (cell && cell.dataset.hoverOverlay) {
            var overlay = cell.querySelector('.impulse-cell-overlay');
            if (overlay) {
                // Restore original overlay color from cell data
                var originalColor = cell.dataset.overlayColor || 'transparent';
                overlay.style.backgroundColor = originalColor;
            }
        }
    }, true);
    
    // Cell grid divider dragging (per-row)
    (function() {
        var dragging = false;
        var dividerRow = null;
        var dividerIdx = null;
        var rowEl = null;
        var gridEl = null;
        var startX = 0;
        var startWidths = [];
        
        document.body.addEventListener('mousedown', function(e) {
            var divider = e.target.closest('.impulse-rows-divider');
            if (divider) {
                e.preventDefault();
                e.stopPropagation();
                dragging = true;
                dividerRow = parseInt(divider.getAttribute('data-row'));
                dividerIdx = parseInt(divider.getAttribute('data-divider'));
                rowEl = divider.closest('.impulse-rows-row');
                gridEl = divider.closest('.impulse-rows');
                startX = e.clientX;
                
                // Get current widths for this row
                try {
                    var rows = JSON.parse(gridEl.getAttribute('data-rows') || '[]');
                    startWidths = rows[dividerRow] ? rows[dividerRow].widths.slice() : [];
                } catch(ex) { startWidths = []; }
                
                // Normalize start widths to sum to 100%
                if (startWidths.length > 0) {
                    var sum = startWidths.reduce(function(a, b) { return a + b; }, 0);
                    if (Math.abs(sum - 100) > 0.01) {
                        startWidths = startWidths.map(function(w) { return (w / sum) * 100; });
                    }
                }
                
                divider.classList.add('dragging');
                document.body.style.cursor = 'col-resize';
            }
        }, true);
        
        document.body.addEventListener('mousemove', function(e) {
            if (!dragging || !rowEl) return;
            
            var rowWidth = rowEl.offsetWidth;
            var deltaX = e.clientX - startX;
            var deltaPct = (deltaX / rowWidth) * 100;
            
            var widths = startWidths.slice();
            var leftIdx = dividerIdx;
            var rightIdx = dividerIdx + 1;
            
            if (widths[leftIdx] === undefined || widths[rightIdx] === undefined) return;
            
            // Calculate combined width of the two cells being resized
            var combinedWidth = widths[leftIdx] + widths[rightIdx];
            
            var newLeftWidth = widths[leftIdx] + deltaPct;
            var newRightWidth = widths[rightIdx] - deltaPct;
            
            // Min width 10%
            var minWidth = 10;
            if (newLeftWidth < minWidth) {
                newLeftWidth = minWidth;
                newRightWidth = combinedWidth - minWidth;
            }
            if (newRightWidth < minWidth) {
                newRightWidth = minWidth;
                newLeftWidth = combinedWidth - minWidth;
            }
            
            // Round to 2 decimal places
            widths[leftIdx] = Math.round(newLeftWidth * 100) / 100;
            widths[rightIdx] = Math.round(newRightWidth * 100) / 100;
            
            // Ensure total is exactly 100%
            var total = widths.reduce(function(a, b) { return a + b; }, 0);
            if (Math.abs(total - 100) > 0.01) {
                var diff = 100 - total;
                widths[rightIdx] = Math.round((widths[rightIdx] + diff) * 100) / 100;
            }
            
            // Update cell widths visually
            var cells = rowEl.querySelectorAll('.impulse-rows-cell');
            var numCells = widths.length;
            
            cells.forEach(function(cell, idx) {
                if (widths[idx] !== undefined) {
                    cell.style.flex = widths[idx].toFixed(2) + ' 1 0%';
                }
            });
            
            // Store updated widths temporarily
            rowEl.setAttribute('data-temp-widths', JSON.stringify(widths));
        });
        
        document.body.addEventListener('mouseup', function(e) {
            if (dragging && gridEl && rowEl) {
                dragging = false;
                document.querySelectorAll('.impulse-rows-divider.dragging').forEach(function(d) {
                    d.classList.remove('dragging');
                });
                document.body.style.cursor = '';
                
                // Get final widths
                var finalWidths = [];
                try {
                    finalWidths = JSON.parse(rowEl.getAttribute('data-temp-widths') || '[]');
                } catch(ex) {
                    finalWidths = startWidths;
                }
                
                var parentEl = gridEl.closest('.impulse-el[data-type="rows"]');
                var parentId = parentEl ? parentEl.getAttribute('data-id') : null;
                
                if (window.parent && parentId && finalWidths.length > 0) {
                    window.parent.postMessage({ 
                        type: 'impulse-rows-row-widths', 
                        parentId: parentId, 
                        rowIndex: dividerRow,
                        widths: finalWidths 
                    }, '*');
                }
                
                rowEl = null;
                gridEl = null;
            }
        });
    })();
    
    // Inline editing for text elements
    // First: ensure every text block has a contenteditable div (failsafe if PHP didn't render it)
    document.querySelectorAll('.impulse-el[data-type="text"] .impulse-text').forEach(function(textWrapper) {
        var editable = textWrapper.querySelector('[contenteditable]');
        if (!editable) {
            editable = document.createElement('div');
            editable.setAttribute('contenteditable', 'true');
            editable.setAttribute('data-field', 'content');
            editable.setAttribute('data-placeholder', 'Your text here');
            editable.style.position = 'relative';
            editable.style.minHeight = '1em';
            editable.style.outline = 'none';
            editable.classList.add('is-empty');
            textWrapper.appendChild(editable);
        }
    });
    
    // Text block inline-editing handlers — delegated at document level so they
    // survive refreshElement() swaps (which replace the contenteditable node).
    function _impulseTextCheckEmpty(el) {
        var text = el.innerText.trim();
        var html = el.innerHTML.replace(/<br\s*\/?>/gi, '').replace(/<p>\s*<\/p>/gi, '').replace(/<div>\s*<\/div>/gi, '').trim();
        if ((!text || text === '') && (!html || html === '')) {
            el.classList.add('is-empty');
        } else {
            el.classList.remove('is-empty');
        }
    }
    function _impulseTextEditable(target) {
        if (!target || !target.closest) return null;
        var p = target.closest('.impulse-el[data-type="text"] .impulse-text [contenteditable]');
        return p || null;
    }
    // Initial check for any already-rendered text blocks
    document.querySelectorAll('.impulse-el[data-type="text"] .impulse-text [contenteditable]').forEach(_impulseTextCheckEmpty);
    // Re-check whenever the editor swaps DOM (new contenteditable nodes won't have listeners,
    // but delegation below handles them automatically)
    new MutationObserver(function(muts) {
        muts.forEach(function(m) {
            m.addedNodes.forEach(function(n) {
                if (n.nodeType !== 1) return;
                if (n.matches && n.matches('.impulse-el[data-type="text"] .impulse-text [contenteditable]')) {
                    _impulseTextCheckEmpty(n);
                } else if (n.querySelectorAll) {
                    n.querySelectorAll('.impulse-el[data-type="text"] .impulse-text [contenteditable]').forEach(_impulseTextCheckEmpty);
                }
            });
        });
    }).observe(document.body, { childList: true, subtree: true });

    document.addEventListener('focusin', function(e) {
        var p = _impulseTextEditable(e.target);
        if (!p) return;
        var el = p.closest('.impulse-el');
        el.classList.add('editing');
        document.querySelectorAll('.impulse-el').forEach(function(x) { if (x !== el) x.classList.remove('selected', 'editing'); });
        _impulseTextCheckEmpty(p);
        if (window.parent && el.dataset.id && el.dataset.type) {
            window.parent.postMessage({ type: 'impulse-select', id: el.dataset.id, elementType: el.dataset.type }, '*');
        }
    });
    // Resolve the device-aware content key based on the iframe's URL.
    // Desktop → 'content', tablet → 'content_tablet', mobile → 'content_mobile'.
    // Empty value on tablet/mobile clears the override (PHP renderer falls back to desktop).
    function _impulseContentKey() {
        var d = (new URLSearchParams(location.search).get('device') || 'desktop').toLowerCase();
        return d === 'tablet' ? 'content_tablet'
             : d === 'mobile' ? 'content_mobile'
             : 'content';
    }
    document.addEventListener('focusout', function(e) {
        var p = _impulseTextEditable(e.target);
        if (!p) return;
        var el = p.closest('.impulse-el');
        el.classList.remove('editing');
        // v2.1.1380: switched from innerText to textContent for the empty-check.
        // innerText respects CSS visibility — it returns '' whenever the
        // editable's parent is display:none / visibility:hidden / opacity:0
        // / inside an ancestor with any of the above. Screen mode hides the
        // previous screen on transition, so if the user edits a text block
        // inline and THEN clicks another screen in the Structure panel, the
        // focusout fires AFTER the screen has been hidden by the transition.
        // The legacy `if (!plainText) p.innerHTML = ''` then wiped the user's
        // real content (innerText empty because hidden) before saving ''.
        // textContent ignores rendering and returns the actual text, so the
        // empty-check fires only when the element is genuinely empty. The
        // markup-strip guard below still catches genuinely-empty inputs
        // whose innerHTML carries only <br>/<p></p>/<div></div> artefacts
        // from contenteditable browser quirks. Home > screen 2 > 3rd card
        // (id 21) was the repro.
        var plainText = (p.textContent || '').trim();
        var rawHtml = (p.innerHTML || '')
            .replace(/<br\s*\/?>/gi, '')
            .replace(/<p>\s*<\/p>/gi, '')
            .replace(/<div>\s*<\/div>/gi, '')
            .trim();
        if (!plainText && !rawHtml) p.innerHTML = '';
        _impulseTextCheckEmpty(p);
        var richContent = p.innerHTML;
        var _key = _impulseContentKey();
        saveElementSetting(el.dataset.id, _key, richContent);
        if (window.parent) {
            window.parent.postMessage({ type: 'impulse-content-changed', id: el.dataset.id, field: _key, value: richContent }, '*');
        }
    });
    document.addEventListener('input', function(e) {
        var p = _impulseTextEditable(e.target);
        if (!p) return;
        _impulseTextCheckEmpty(p);
        var el = p.closest('.impulse-el');
        if (window.parent) {
            window.parent.postMessage({ type: 'impulse-content-changed', id: el.dataset.id, field: _impulseContentKey(), value: p.innerHTML }, '*');
        }
    });
    document.addEventListener('keydown', function(e) {
        var p = _impulseTextEditable(e.target);
        if (p && e.key === 'Escape') p.blur();
    });
    
    


    // Display Text - 3D Depth Engine (preview) — per-line depth
    (function() {
        var depthEls = document.querySelectorAll('.impulse-display-text[data-dt-perspective]');
        if (!depthEls.length) return;
        
        depthEls.forEach(function(el) {
            var tag = el.querySelector('h1,h2,h3,h4,h5,h6,p,div');
            if (!tag) return;
            
            var lineEls = Array.prototype.slice.call(tag.querySelectorAll('.impulse-dt-line'));
            if (lineEls.length < 1) return;
            
            var perspVal = parseInt(el.dataset.dtPerspective) || 800;
            var hasMouse = el.dataset.dtMouse === '1';
            var dofVal = el.dataset.dtDof ? parseInt(el.dataset.dtDof) / 100 : 0;
            var chromVal = el.dataset.dtChromatic ? parseInt(el.dataset.dtChromatic) / 100 : 0;
            var hasShadow = el.dataset.dtShadow === '1';
            var hasOrbit = el.dataset.dtOrbit === '1';
            var orbitActive = false;
            var total = lineEls.length;
            
            tag.style.perspective = perspVal + 'px';
            tag.style.transformStyle = 'preserve-3d';
            
            // Read per-line depth from data-depth attribute
            var maxAbsDepth = 0;
            lineEls.forEach(function(line) {
                var z = parseInt(line.dataset.depth) || 0;
                line.dataset.baseZ = z;
                line.style.transformStyle = 'preserve-3d';
                line.style.willChange = 'transform,filter';
                line.style.transform = 'translateZ(' + z + 'px)';
                if (Math.abs(z) > maxAbsDepth) maxAbsDepth = Math.abs(z);
            });
            
            function applyShadows(line, absRatio, signedRatio, mx) {
                mx = mx || 0;
                var shadows = [];
                if (chromVal > 0) {
                    var offset = chromVal * 5;
                    var r = ((signedRatio + mx * 0.5) * offset).toFixed(1);
                    var b = ((-signedRatio + mx * 0.5) * offset).toFixed(1);
                    var alpha = (chromVal * 0.4).toFixed(2);
                    shadows.push(r + 'px 0 0 rgba(255,0,50,' + alpha + ')');
                    shadows.push(b + 'px 0 0 rgba(0,80,255,' + alpha + ')');
                }
                if (hasShadow) {
                    var sBlur = (absRatio * 8 + 2).toFixed(0);
                    var sY = (absRatio * 4 + 1).toFixed(0);
                    var sAlpha = (0.15 + absRatio * 0.25).toFixed(2);
                    shadows.push('0 ' + sY + 'px ' + sBlur + 'px rgba(0,0,0,' + sAlpha + ')');
                }
                line.style.textShadow = shadows.length ? shadows.join(',') : '';
            }
            
            // Static effects — DOF uses abs(depth), chromatic/shadow use depth ratios
            lineEls.forEach(function(line) {
                var z = parseFloat(line.dataset.baseZ) || 0;
                var absRatio = maxAbsDepth > 0 ? Math.abs(z) / maxAbsDepth : 0;
                var signedRatio = maxAbsDepth > 0 ? z / maxAbsDepth : 0;
                if (dofVal > 0) line.style.filter = 'blur(' + (absRatio * dofVal * 5).toFixed(1) + 'px)';
                if (chromVal > 0 || hasShadow) applyShadows(line, absRatio, signedRatio, 0);
            });
            
            function resetLines() {
                lineEls.forEach(function(line) {
                    var baseZ = parseFloat(line.dataset.baseZ) || 0;
                    var absRatio = maxAbsDepth > 0 ? Math.abs(baseZ) / maxAbsDepth : 0;
                    var signedRatio = maxAbsDepth > 0 ? baseZ / maxAbsDepth : 0;
                    line.style.transition = 'transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease-in-out, text-shadow 0.4s ease-in-out';
                    line.style.transform = 'translateZ(' + baseZ + 'px)';
                    if (dofVal > 0) line.style.filter = 'blur(' + (absRatio * dofVal * 5).toFixed(1) + 'px)';
                    if (chromVal > 0 || hasShadow) applyShadows(line, absRatio, signedRatio, 0);
                });
            }
            
            var orbitFrame = null, orbitT = 0;
            function orbitLoop() {
                if (!orbitActive) return;
                orbitT += 0.015;
                lineEls.forEach(function(line, i) {
                    var baseZ = parseFloat(line.dataset.baseZ) || 0;
                    var phase = (i / total) * Math.PI * 2;
                    var r = maxAbsDepth * 0.4;
                    line.style.transition = 'none';
                    line.style.transform = 'translateZ(' + baseZ + 'px) translateX(' + (Math.sin(orbitT + phase) * r * 0.6).toFixed(1) + 'px) translateY(' + (Math.cos(orbitT * 0.7 + phase) * r * 0.25).toFixed(1) + 'px) rotateY(' + (Math.sin(orbitT + phase) * 8).toFixed(1) + 'deg) rotateX(' + (Math.cos(orbitT * 0.5 + phase) * 4).toFixed(1) + 'deg)';
                });
                orbitFrame = requestAnimationFrame(orbitLoop);
            }
            
            if (hasMouse || hasOrbit) {
                // Shared parallax application function
                function applyParallax(mx, my) {
                    lineEls.forEach(function(line) {
                        var baseZ = parseFloat(line.dataset.baseZ) || 0;
                        var absRatio = maxAbsDepth > 0 ? Math.abs(baseZ) / maxAbsDepth : 0;
                        var signedRatio = maxAbsDepth > 0 ? baseZ / maxAbsDepth : 0;
                        var tx = mx * 35 * signedRatio;
                        var ty = my * 12 * signedRatio;
                        line.style.transition = 'transform 80ms linear';
                        line.style.transform = 'translateZ(' + baseZ + 'px) translateX(' + tx.toFixed(1) + 'px) translateY(' + ty.toFixed(1) + 'px)';
                        if (dofVal > 0) line.style.filter = 'blur(' + (absRatio * dofVal * 5 * (1 + Math.abs(mx) * 0.8)).toFixed(1) + 'px)';
                        if (chromVal > 0 || hasShadow) applyShadows(line, absRatio, signedRatio, mx);
                    });
                }
                
                // Scope to parent section (full area) instead of just heading element
                var scope = el.closest('.impulse-section') || el.closest('.impulse-el[data-type="section"]') || el.parentElement;
                var cachedRect = null;
                scope.addEventListener('mouseenter', function(e) {
                    if (hasMouse) {
                        cachedRect = scope.getBoundingClientRect();
                    } else if (hasOrbit) {
                        orbitActive = true; orbitT = 0; orbitLoop();
                    }
                });
                if (hasMouse) {
                    scope.addEventListener('mousemove', function(e) {
                        if (!cachedRect) cachedRect = scope.getBoundingClientRect();
                        var mx = (e.clientX - cachedRect.left) / cachedRect.width - 0.5;
                        var my = (e.clientY - cachedRect.top) / cachedRect.height - 0.5;
                        applyParallax(mx, my);
                    });
                    
                    // Gyroscope for mobile — maps device tilt to parallax
                    if ('DeviceOrientationEvent' in window && 'ontouchstart' in window) {
                        var gyroReady = false;
                        // iOS 13+ requires permission
                        if (typeof DeviceOrientationEvent.requestPermission === 'function') {
                            el.addEventListener('touchstart', function gyroPermission() {
                                DeviceOrientationEvent.requestPermission().then(function(state) {
                                    if (state === 'granted') gyroReady = true;
                                }).catch(function(){});
                                el.removeEventListener('touchstart', gyroPermission);
                            }, { once: true });
                        } else {
                            gyroReady = true;
                        }
                        var gyroSmooth = { mx: 0, my: 0 };
                        window.addEventListener('deviceorientation', function(e) {
                            if (!gyroReady || !hasMouse) return;
                            // gamma: -90..90 (left/right tilt), beta: -180..180 (front/back)
                            var gx = (e.gamma || 0) / 45; // normalize to ~-1..1
                            var gy = ((e.beta || 0) - 45) / 45; // 45° = neutral hold angle
                            gx = Math.max(-1, Math.min(1, gx)) * 0.5;
                            gy = Math.max(-1, Math.min(1, gy)) * 0.5;
                            // Smooth with lerp to avoid jitter
                            gyroSmooth.mx += (gx - gyroSmooth.mx) * 0.12;
                            gyroSmooth.my += (gy - gyroSmooth.my) * 0.12;
                            applyParallax(gyroSmooth.mx, gyroSmooth.my);
                        });
                    }
                }
                scope.addEventListener('mouseleave', function() {
                    cachedRect = null;
                    if (orbitActive) { orbitActive = false; if (orbitFrame) cancelAnimationFrame(orbitFrame); }
                    resetLines();
                });
            }
        });
    })();
    
    
    // Buttons - prevent anchor navigation, select element on click
    // No inline editing - use settings panel for text changes
    document.querySelectorAll('.impulse-el[data-type="button"] .impulse-button').forEach(function(anchor) {
        anchor.addEventListener('click', function(e) {
            e.preventDefault();
            e.stopPropagation();
            var el = this.closest('.impulse-el');
            document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
            el.classList.add('selected');
            if (window.parent && el.dataset.id) {
                window.parent.postMessage({ type: 'impulse-select', id: el.dataset.id, elementType: 'button' }, '*');
            }
        });
    });
    
    // ── Global: prevent ALL anchor navigation in editor ──
    // Links should never navigate the preview iframe away from the edited page.
    // Screen nav links, social links, footer links, text links — all blocked.
    //
    // IMPORTANT: We only call preventDefault() here, NOT stopPropagation().
    // Buttons are typically rendered as <a href="..."> wrapping their visible
    // content. If we stopped propagation in capture phase, the bubble-phase
    // selection handlers further down (.impulse-el body click handler, the
    // .impulse-button anchor handler) would never fire — so clicking a button
    // would block navigation BUT also fail to select the element for editing.
    // preventDefault alone is enough to kill navigation; let propagation
    // continue so selection still works.
    if (window !== window.top) {
        document.addEventListener('click', function(e) {
            if (!e.target || !e.target.closest) return;
            var a = e.target.closest('a[href]');
            if (!a) return;
            var href = a.getAttribute('href') || '';
            // Allow # links (in-page), javascript: void, and empty
            if (!href || href === '#' || href.startsWith('javascript:')) return;
            // Allow contenteditable clicks (text editing)
            if (a.closest('[contenteditable="true"]')) return;
            e.preventDefault();
        }, true); // capture phase — fires before any other handler
        // Block all button clicks in content — forms, booking, cart, etc.
        // Same rule: preventDefault only, no stopPropagation, so the element
        // can still be selected for editing.
        document.addEventListener('click', function(e) {
            if (!e.target || !e.target.closest) return;
            var btn = e.target.closest('button[type="submit"], .impulse-form-submit, .ibk-submit-btn, .impulse-bundle-add-btn, .impulse-checkout-btn, .impulse-pcard-btn');
            if (!btn) return;
            // Allow editor toolbar buttons
            if (btn.closest('.impulse-el-toolbar')) return;
            e.preventDefault();
        }, true);
        // Also prevent form submissions — forms are for display only in editor
        document.addEventListener('submit', function(e) {
            e.preventDefault();
        }, true);
    }
    
    // Click on image placeholder to select element (not open media)
    document.querySelectorAll('.impulse-image-placeholder').forEach(function(placeholder) {
        placeholder.style.cursor = 'pointer';
        placeholder.addEventListener('click', function(e) {
            e.stopPropagation();
            var el = this.closest('.impulse-el');
            document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected', 'editing'); });
            el.classList.add('selected');
            if (window.parent) {
                window.parent.postMessage({ type: 'impulse-select', id: el.dataset.id, elementType: el.dataset.type }, '*');
            }
        });
    });
    
    // Click on existing image to select element (not open media)
    document.querySelectorAll('.impulse-el[data-type="image"] img').forEach(function(img) {
        img.style.cursor = 'pointer';
        img.addEventListener('click', function(e) {
            e.stopPropagation();
            var el = this.closest('.impulse-el');
            document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected', 'editing'); });
            el.classList.add('selected');
            if (window.parent) {
                window.parent.postMessage({ type: 'impulse-select', id: el.dataset.id, elementType: el.dataset.type }, '*');
            }
        });
    });
    
    // Click on video overlay (captures clicks on videos with iframes)
    document.querySelectorAll('.impulse-video-overlay').forEach(function(overlay) {
        overlay.addEventListener('click', function(e) {
            e.preventDefault();
            e.stopPropagation();
            var videoEl = this.closest('.impulse-el[data-type="video"]');
            if (videoEl) {
                document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected', 'editing'); });
                videoEl.classList.add('selected');
                if (window.parent) {
                    window.parent.postMessage({ type: 'impulse-select', id: videoEl.dataset.id, elementType: 'video' }, '*');
                }
            }
        });
    });
    
    var _previewPageId = parseInt((new URLSearchParams(location.search).get('impulse_preview') || (location.pathname.match(/\/admin\/preview\/(\d+)/) || [])[1] || '')) || 0;
    function saveElementSetting(id, key, value) {
        var settings = {};
        settings[key] = value;
        fetch(apiBase + 'elements/' + id, {
            method: 'PUT',
            headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpRestNonce },
            body: JSON.stringify({ settings: settings, page_id: _previewPageId })
        });
    }
    
    // FAQ toggle
    document.querySelectorAll('.impulse-faq-question').forEach(function(q) {
        q.addEventListener('click', function() {
            var answer = this.nextElementSibling;
            answer.style.display = answer.style.display === 'none' ? 'block' : 'none';
        });
    });
    
    // Listen for drag state from parent
    var pendingElement = null;
    var pendingTemplate = null;
    
    // ========================================
    // UNIFIED DROP TARGET DETECTION
    // Used by both external drag (from panel) and overlay highlight
    // ========================================
    var _containerTypes = ['section', 'container', 'row', 'column',  'stack', 'inline', 'slider', 'modal'];
    
    function findExternalTarget(x, y) {
        // Use elementsFromPoint to get the full stack — cells may be covered by child
        // elements or have zero intrinsic height, so a single-element lookup misses them.
        var elements = document.elementsFromPoint(x, y);
        if (!elements || !elements.length) return null;

        // Scene-editing mode: flying screens are hidden and the scene root is the only
        // visible layer. Even though screens have pointer-events:none, they still appear
        // in elementsFromPoint and their descendants win the z-stack, so drops leak into
        // the last screen. Filter anything inside a flying screen out of the lookup so
        // subsequent priority logic sees the scene root as the topmost target.
        if (document.body.classList.contains('impulse-scene-editing')) {
            elements = elements.filter(function(el) {
                return !(el.closest && el.closest('.impulse-el[data-type="section"]'));
            });
            if (!elements.length) return null;
        }

        // Use the topmost element for dropzone / special-zone detection
        var target = elements[0];
        
        // Priority 1: Tab dropzone
        var tabDz = target.closest('.impulse-tab-dropzone');
        if (tabDz) return { type: 'tab', element: tabDz, parentId: parseInt(tabDz.dataset.parent) || 0, tabIndex: parseInt(tabDz.dataset.tab) };
        
        // Priority 2: Slider dropzone (slider or data-slide)
        var sliderDz = target.closest('.impulse-new-slider-dropzone') || target.closest('.impulse-dropzone[data-slide]');
        if (sliderDz) {
            return { type: 'slide', element: sliderDz, parentId: parseInt(sliderDz.dataset.parent) || 0, slideIndex: parseInt(sliderDz.dataset.slide) };
        }
        
        // Priority 3: Toggle dropzone
        var toggleDz = target.closest('.impulse-toggle-dropzone');
        if (toggleDz) return { type: 'toggle', element: toggleDz, parentId: parseInt(toggleDz.dataset.parent) || 0 };

        // Priority 3b: Overlay (popup) dropzone — always resolves as a child-drop of the popup.
        // The popup's own edge-zone heuristic could otherwise misread drops near its top/bottom
        // as before/after siblings, placing new blocks outside the overlay.
        var popupDz = target.closest('.impulse-modal-dropzone');
        if (popupDz) {
            var popupId = parseInt(popupDz.dataset.parent) || 0;
            // When the modal is open/selected, its scrim is portalled to iframe body,
            // so popupDz.closest('.impulse-el') returns null. Look up the live popup
            // .impulse-el by id across the whole document.
            var popupOwnerEl = popupDz.closest('.impulse-el') || document.querySelector('.impulse-el[data-id="' + popupId + '"]');
            return { type: 'element', element: popupOwnerEl, id: popupId, elType: 'modal', position: 'inside', dropzoneEl: popupDz };
        }
        
        // Priority 4: Grid cells — use closest() so dropping over a child block inside
        // a cell still resolves the cell correctly. Iterate the z-stack; first match wins.
        // When the cell has existing children, prefer a between-children drop position
        // (type='element', position='before' or 'after') so the user can land a new block
        // between two existing ones — the plain 'grid-cell' / 'cell-grid-cell' result
        // appends to the end with no positional info.
        function _pickBetween(cell, _x, _y) {
            // Pick the child block the cursor is nearest to and decide before/after by
            // whether the cursor is in its upper or lower half. Returns null when cursor
            // is below all children (caller falls through to plain cell drop = append).
            var kids = Array.from(cell.querySelectorAll(':scope > .impulse-el, :scope > .impulse-cell-content > .impulse-el'));
            if (!kids.length) return null;
            for (var k = 0; k < kids.length; k++) {
                var kRect = kids[k].getBoundingClientRect();
                if (kRect.height <= 0) continue;
                if (_y < kRect.top) {
                    // Cursor above this child → insert before it
                    return { type: 'element', element: kids[k], id: parseInt(kids[k].dataset.id) || 0, elType: kids[k].dataset.type, position: 'before' };
                }
                if (_y >= kRect.top && _y <= kRect.bottom) {
                    // Cursor inside this child's box → upper half = before, lower half = after
                    var mid = kRect.top + (kRect.height / 2);
                    var pos = _y < mid ? 'before' : 'after';
                    return { type: 'element', element: kids[k], id: parseInt(kids[k].dataset.id) || 0, elType: kids[k].dataset.type, position: pos };
                }
            }
            // Cursor below all children → append (return null so caller does plain cell drop)
            return null;
        }
        for (var i = 0; i < elements.length; i++) {
            var el = elements[i];
            if (!el.closest) continue;

            // Regular grid cell — matches even when cursor is over a child inside the cell
            var gridCell = el.closest('.impulse-grid-cell');
            if (gridCell) {
                var grid = gridCell.closest('.impulse-el[data-type="grid"]');
                if (grid) {
                    var betweenG = _pickBetween(gridCell, x, y);
                    if (betweenG) return betweenG;
                    return { type: 'grid-cell', element: gridCell, parentId: parseInt(grid.dataset.id) || 0, cellIndex: parseInt(gridCell.dataset.cell) };
                }
            }
            // rows cell
            var cellGridCell = el.closest('.impulse-rows-cell');
            if (cellGridCell) {
                var cellGrid = cellGridCell.closest('.impulse-el[data-type="rows"]');
                if (cellGrid) {
                    var betweenC = _pickBetween(cellGridCell, x, y);
                    if (betweenC) return betweenC;
                    return { type: 'cell-grid-cell', element: cellGridCell, parentId: parseInt(cellGrid.dataset.id) || 0, row: parseInt(cellGridCell.dataset.row), col: parseInt(cellGridCell.dataset.col) };
                }
            }
            // columns cell
            var layoutCell = el.closest('.impulse-layout-cell');
            if (layoutCell) {
                var layoutGrid = layoutCell.closest('.impulse-el[data-type="columns"]');
                if (layoutGrid) {
                    var betweenL = _pickBetween(layoutCell, x, y);
                    if (betweenL) return betweenL;
                    return { type: 'cell-grid-cell', element: layoutCell, parentId: parseInt(layoutGrid.dataset.id) || 0, row: parseInt(layoutCell.dataset.row), col: parseInt(layoutCell.dataset.col) };
                }
            }
        }

        // Priority 4b: Brute-force cell hit-test against bounding rects.
        // elementsFromPoint can miss cells when an fx canvas / bg shape layer /
        // depth overlay sits above them, or when the cell is inside a section
        // that resolves first via closest('.impulse-el'). In screen mode we MUST
        // restrict to the visible screen — hidden screens are absolute-positioned
        // at the same coords and would win the rect test by DOM order otherwise.
        // We CAN'T rely on .impulse-screen-active here: the editor's screen state
        // can desync from what the user sees (transition aborted, scene list
        // click during cooldown, etc.). Instead, find the section that's actually
        // rendered (computed visibility:visible) and contains the cursor.
        var _isScreenMode = document.body.hasAttribute('data-screen-mode') && document.body.getAttribute('data-screen-mode') === '1';
        var _bfCells;
        if (_isScreenMode) {
            // Find the screen the user is actually editing. CSS class
            // (.impulse-screen-active) can desync from reality, so prefer signals
            // in this order:
            //   1. window.impulseScreenMode.getCurrentScreen() — runtime truth
            //   2. URL ?screen=N — set by editor on navigation/refresh
            //   3. .impulse-screen-active class — last-resort
            //   4. First section
            var _candidateSections = document.querySelectorAll('.impulse-content-wrapper > .impulse-el[data-type="section"]');
            var _editScreen = null;
            try {
                if (window.impulseScreenMode && typeof window.impulseScreenMode.getCurrentScreen === 'function') {
                    var _idx = window.impulseScreenMode.getCurrentScreen();
                    if (_candidateSections[_idx]) _editScreen = _candidateSections[_idx];
                }
            } catch(_e) {}
            if (!_editScreen) {
                try {
                    var _urlIdx = parseInt(new URLSearchParams(window.location.search).get('screen'));
                    if (!isNaN(_urlIdx) && _candidateSections[_urlIdx]) _editScreen = _candidateSections[_urlIdx];
                } catch(_e2) {}
            }
            if (!_editScreen) {
                _editScreen = document.querySelector('.impulse-el[data-type="section"].impulse-screen-active');
            }
            if (!_editScreen && _candidateSections.length) _editScreen = _candidateSections[0];
            _bfCells = _editScreen ? _editScreen.querySelectorAll('.impulse-grid-cell, .impulse-rows-cell, .impulse-layout-cell') : [];
        } else {
            _bfCells = document.querySelectorAll('.impulse-grid-cell, .impulse-rows-cell, .impulse-layout-cell');
        }
        // Iterate from innermost-on-screen first (last in DOM order tends to be
        // deeper/later content) so nested cells don't get shadowed by earlier
        // outer cells with overlapping rects.
        for (var _bi = _bfCells.length - 1; _bi >= 0; _bi--) {
            var _bc = _bfCells[_bi];
            var _br = _bc.getBoundingClientRect();
            if (_br.width <= 0 || _br.height <= 0) continue;
            if (x < _br.left || x > _br.right || y < _br.top || y > _br.bottom) continue;
            if (_bc.classList.contains('impulse-grid-cell')) {
                var _bg = _bc.closest('.impulse-el[data-type="grid"]');
                if (_bg) return { type: 'grid-cell', element: _bc, parentId: parseInt(_bg.dataset.id) || 0, cellIndex: parseInt(_bc.dataset.cell) };
            } else if (_bc.classList.contains('impulse-rows-cell')) {
                var _bcg = _bc.closest('.impulse-el[data-type="rows"]');
                if (_bcg) return { type: 'cell-grid-cell', element: _bc, parentId: parseInt(_bcg.dataset.id) || 0, row: parseInt(_bc.dataset.row), col: parseInt(_bc.dataset.col) };
            } else if (_bc.classList.contains('impulse-layout-cell')) {
                var _bff = _bc.closest('.impulse-el[data-type="columns"]');
                if (_bff) return { type: 'cell-grid-cell', element: _bc, parentId: parseInt(_bff.dataset.id) || 0, row: parseInt(_bc.dataset.row), col: parseInt(_bc.dataset.col) };
            }
        }
        
        // Priority 5: Regular elements — NO walk-to-root, detect on actual element
        var impEl = target.closest('.impulse-el');
        if (impEl) {
            var elType = impEl.dataset.type;
            var isContainer = _containerTypes.indexOf(elType) !== -1;
            var rect = impEl.getBoundingClientRect();
            var relY = y - rect.top;

            // Cell fallback for grid/rows/columns: when the cursor is inside a
            // container grid block but elementsFromPoint didn't surface a cell ancestor
            // (can happen in screen-mode with overlapping fx/canvas layers), iterate
            // the grid's cell rects and return the one the point falls into. Without
            // this the resolver falls through to before/after on the whole grid and
            // the cells appear unresponsive.
            if (elType === 'grid' || elType === 'rows' || elType === 'columns') {
                var _cellSelMap = {
                    grid: '.impulse-grid-cell',
                    rows: '.impulse-rows-cell',
                    columns: '.impulse-layout-cell'
                };
                var _cellNodes = impEl.querySelectorAll(_cellSelMap[elType]);
                for (var _ci = 0; _ci < _cellNodes.length; _ci++) {
                    var _cr = _cellNodes[_ci].getBoundingClientRect();
                    if (x >= _cr.left && x <= _cr.right && y >= _cr.top && y <= _cr.bottom) {
                        var _cell = _cellNodes[_ci];
                        if (elType === 'grid') {
                            return { type: 'grid-cell', element: _cell, parentId: parseInt(impEl.dataset.id) || 0, cellIndex: parseInt(_cell.dataset.cell) };
                        }
                        return { type: 'cell-grid-cell', element: _cell, parentId: parseInt(impEl.dataset.id) || 0, row: parseInt(_cell.dataset.row), col: parseInt(_cell.dataset.col) };
                    }
                }
            }

            // For slider/carousel container, redirect to its ACTIVE slide dropzone
            if (elType === 'slider' || elType === 'carousel') {
                var _sliderEl = impEl.querySelector('.impulse-new-slider');
                var _curSlide = _sliderEl ? (parseInt(_sliderEl.dataset.currentSlide) || 0) : 0;
                var csDropzone = impEl.querySelector('.impulse-new-slider-dropzone[data-slide="' + _curSlide + '"]')
                               || impEl.querySelector('.impulse-new-slider-dropzone');
                if (csDropzone) return { type: 'slide', element: csDropzone, parentId: parseInt(csDropzone.dataset.parent) || 0, slideIndex: parseInt(csDropzone.dataset.slide) };
            }
            
            // Skip rows / columns containers — they should only be targeted via cells (Priority 4)
            if (elType === 'rows' || elType === 'columns') {
                // Fall through to root dropzone or null
            } else if (isContainer) {
                // Container: small edge zones for before/after, center for inside
                var edgeThreshold = Math.min(25, rect.height * 0.12);
                if (edgeThreshold < 8) edgeThreshold = 8;
                if (relY < edgeThreshold) {
                    return { type: 'element', element: impEl, id: impEl.dataset.id, elType: elType, position: 'before' };
                } else if (relY > rect.height - edgeThreshold) {
                    return { type: 'element', element: impEl, id: impEl.dataset.id, elType: elType, position: 'after' };
                } else {
                    return { type: 'element', element: impEl, id: impEl.dataset.id, elType: elType, position: 'inside' };
                }
            } else {
                // If this block lives inside a basic-grid cell, route the drop
                // to the CELL so the new block lands in the same cell as the
                // block it was dropped beside. A sibling-only before/after
                // result loses the cell index; the server then falls back to
                // maxSort+1, pushing sort_order out of the cell range so the
                // block vanishes / renders outside the cells. Sergei 2026-06-05.
                var _gcell = impEl.closest && impEl.closest('.impulse-grid-cell');
                if (_gcell) {
                    var _gparent = impEl.closest('.impulse-el[data-type="grid"]');
                    if (_gparent) {
                        return { type: 'grid-cell', element: _gcell, parentId: parseInt(_gparent.dataset.id) || 0, cellIndex: parseInt(_gcell.dataset.cell) };
                    }
                }
                // Non-container: top half = before, bottom half = after
                return { type: 'element', element: impEl, id: impEl.dataset.id, elType: elType, position: relY < rect.height / 2 ? 'before' : 'after' };
            }
        }
        
        // Priority 6: Root dropzone
        var rootDz = target.closest('.impulse-root-dropzone');
        if (rootDz) return { type: 'root', element: rootDz, parentId: 0 };
        
        return null;
    }
    
    function showExternalFeedback(result) {
        clearExternalFeedback();
        if (!result) return;
        switch (result.type) {
            case 'tab': case 'slide': case 'toggle': case 'root':
                result.element.classList.add('active');
                break;
            case 'grid-cell': case 'cell-grid-cell':
                result.element.classList.add('drop-target');
                break;
            case 'element':
                if (result.position === 'before') result.element.classList.add('drop-above');
                else if (result.position === 'after') result.element.classList.add('drop-below');
                else if (result.position === 'inside') result.element.classList.add('drop-inside');
                break;
        }
        // Modal dropzone: light it up with drop-target so the user sees the popup-content
        // surface accept the drop (mirrors grid-cell feedback).
        if (result.dropzoneEl) result.dropzoneEl.classList.add('drop-target');
        // Populated Modal: when the cursor is over a child inside the modal (before/after
        // edge of that child), findExternalTarget reports the child — so the modal itself
        // doesn't light up. Users need to see which container they're dropping INTO, not
        // just the sibling they're dropping next to. Also highlight the modal's .impulse-el
        // whenever the drop target sits inside one.
        if (result.element && result.element.closest) {
            var _popupHost = result.element.closest('.impulse-el[data-type="modal"], .impulse-el[data-type="modal"]');
            if (_popupHost && _popupHost !== result.element) {
                _popupHost.classList.add('drop-inside');
            }
        }
    }
    
    function clearExternalFeedback() {
        document.querySelectorAll('.drop-above, .drop-below, .drop-inside, .drop-target').forEach(function(el) {
            el.classList.remove('drop-above', 'drop-below', 'drop-inside', 'drop-target');
        });
        document.querySelectorAll('.impulse-dropzone.active, .impulse-root-dropzone.active').forEach(function(el) {
            el.classList.remove('active');
        });
    }
    
    function sendDropMessage(result, element, template) {
        if (!result || !window.parent) return;
        var msg = { type: 'impulse-drop', element: element, template: template, position: -1 };
        // Look up parent type for nesting validation
        var parentEl;
        switch (result.type) {
            case 'tab':
                msg.parentId = result.parentId; msg.tabIndex = result.tabIndex;
                parentEl = document.querySelector('.impulse-el[data-id="' + result.parentId + '"]');
                if (parentEl) msg.parentType = parentEl.dataset.type;
                break;
            case 'slide':
                msg.parentId = result.parentId; msg.slideIndex = result.slideIndex;
                parentEl = document.querySelector('.impulse-el[data-id="' + result.parentId + '"]');
                if (parentEl) msg.parentType = parentEl.dataset.type;
                break;
            case 'toggle':
                msg.parentId = result.parentId;
                parentEl = document.querySelector('.impulse-el[data-id="' + result.parentId + '"]');
                if (parentEl) msg.parentType = parentEl.dataset.type;
                break;
            case 'grid-cell':
                msg.parentId = result.parentId; msg.gridCellIndex = parseInt(result.cellIndex);
                msg.parentType = 'grid';
                break;
            case 'cell-grid-cell':
                msg.parentId = result.parentId; msg.cellGridData = { row: parseInt(result.row), col: parseInt(result.col) };
                parentEl = document.querySelector('.impulse-el[data-id="' + result.parentId + '"]');
                msg.parentType = parentEl ? parentEl.dataset.type : 'rows';
                break;
            case 'element':
                if (result.position === 'inside') {
                    msg.parentId = parseInt(result.id) || 0;
                    parentEl = document.querySelector('.impulse-el[data-id="' + result.id + '"]');
                    if (parentEl) {
                        msg.parentType = parentEl.dataset.type;
                        if (parentEl.dataset.type === 'section' && document.body.hasAttribute('data-screen-mode')) {
                            msg.parentIsScreen = !!(parentEl.parentElement && parentEl.parentElement.classList.contains('impulse-content-wrapper'));
                        }
                    }
                } else if (result.position === 'before') {
                    msg.insertBeforeId = parseInt(result.id) || 0;
                } else if (result.position === 'after') {
                    msg.insertAfterId = parseInt(result.id) || 0;
                }
                break;
            case 'root':
                msg.parentId = 0; break;
        }
        window.parent.postMessage(msg, '*');
    }
    
    window.addEventListener('message', function(e) {
        if (!e.data || !e.data.type) return;
        if (e.data.type === 'impulse-drag-start') {
            document.body.classList.add('drag-active');
            pendingElement = e.data.element || null;
            pendingTemplate = e.data.template || null;
            // Self-heal: aborted screen transitions can leave the visible screen
            // with stale inline `pointer-events:none` (from mid-animation state),
            // making its cells reject drops even though the screen looks focused.
            // Force-apply focus state to currentScreen so the screen is guaranteed
            // interactive before the user releases the drag.
            try {
                // Classic-scroll pages carry data-screen-mode="1" (legacy stub) but
                // their getCurrentScreen() always returns 0, so jumpToScreen(0) here
                // yanked the whole page to the top the instant a palette block drag
                // began — leaving no chance to drop it in place. The self-heal is a
                // cinematic-only concern; skip it entirely on classic. (Sergei 2026-06-29)
                if (document.body.getAttribute('data-screen-mode') === '1' &&
                    !document.body.classList.contains('impulse-scroll-classic') &&
                    window.impulseScreenMode && typeof window.impulseScreenMode.jumpToScreen === 'function') {
                    var _curS = window.impulseScreenMode.getCurrentScreen();
                    var _activeEl = document.querySelector('.impulse-screen-active');
                    var _wrap = document.querySelector('.impulse-content-wrapper');
                    var _screens = _wrap ? _wrap.querySelectorAll(':scope > .impulse-el[data-type="section"]') : [];
                    if (_screens[_curS] && _activeEl !== _screens[_curS]) {
                        window.impulseScreenMode.jumpToScreen(_curS);
                    }
                }
            } catch(_e) {}
        }
        if (e.data.type === 'impulse-drag-end') {
            document.body.classList.remove('drag-active');
            clearExternalFeedback();
            pendingElement = null;
            pendingTemplate = null;
        }
        if (e.data.type === 'impulse-deselect-grid-cell') {
            document.querySelectorAll('.impulse-grid-cell').forEach(function(x) { x.classList.remove('selected'); });
        }

        // Navigate carousel to specific slide — sent by panel slide buttons and structure tab
        if (e.data.type === 'impulse-go-to-slide') {
            var _gtsEl = document.querySelector('.impulse-el[data-id="' + e.data.sliderId + '"] .impulse-new-slider');
            if (_gtsEl) {
                var _gtsIdx = parseInt(e.data.slideIndex) || 0;
                if (_gtsEl._goToSlide) {
                    _gtsEl._goToSlide(_gtsIdx);
                } else {
                    var _gtsTrack = _gtsEl.querySelector('.impulse-carousel-track');
                    var _offset = _gtsIdx * (_gtsTrack ? _gtsTrack.offsetWidth : 0);
                    var _gtsSlides = _gtsEl.querySelector('.impulse-carousel-slides');
                    if (_gtsSlides) _gtsSlides.style.transform = 'translateX(-' + _offset + 'px)';
                }
                _gtsEl.dataset.currentSlide = _gtsIdx;
            } else {
            }
        }

        // ── Fetch + return rendered HTML for a new element (no-CORS, same-origin) ──
        if (e.data.type === 'impulse-fetch-element') {
            var _fetchId   = e.data.elementId;
            var _fetchType = e.data.elementType;
            var _fetchPage = e.data.pageId;
            var _fetchDev  = e.data.device || 'desktop';
            // Build render URL — detect WP vs standalone from current iframe URL
            var _base = location.protocol + '//' + location.host;
            var _url;
            if (location.search.indexOf('impulse_preview=') !== -1) {
                // WP mode: use same URL structure as the iframe itself
                _url = _base + location.pathname + '?impulse_preview=' + _fetchPage + '&impulse_render_element=' + _fetchId + '&device=' + _fetchDev + '&edit=1';
            } else {
                // Standalone mode
                _url = _base + '/admin/preview/' + _fetchPage + '?impulse_render_element=' + _fetchId + '&device=' + _fetchDev + '&edit=1';
            }
            fetch(_url, { credentials: 'include' })
                .then(function(r) { return r.json(); })
                .then(function(data) {
                    window.parent.postMessage({
                        type:        'impulse-element-fetched',
                        elementId:   _fetchId,
                        elementType: _fetchType,
                        html:        data.html || '',
                        parentId:    data.parentId || null
                    }, '*');
                })
                .catch(function() {
                    window.parent.postMessage({
                        type: 'impulse-element-fetched',
                        elementId: _fetchId,
                        elementType: _fetchType,
                        html: ''
                    }, '*');
                });
        }
        if (e.data.type === 'impulse-deselect-cell-grid-cell') {
            document.querySelectorAll('.impulse-rows-cell').forEach(function(x) { x.classList.remove('selected'); });
            document.querySelectorAll('.impulse-layout-cell').forEach(function(x) { x.classList.remove('selected'); });
        }
        // Handle select-element from parent (e.g. screen thumb click)
        if (e.data.type === 'select-element') {
            var selId = e.data.id;
            document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
            if (selId) {
                var selEl = document.querySelector('.impulse-el[data-id="' + selId + '"]');
                if (selEl) {
                    // Skip marking the parent .selected when a cell inside it
                    // is currently selected. The Structure panel reads the
                    // .selected class to decide which row to highlight; without
                    // this guard, clicking a Grid cell ended up highlighting
                    // BOTH the cell row AND the parent Grid row, making the
                    // selection state ambiguous.
                    var hasSelectedChildCell = selEl.querySelector(
                        '.impulse-grid-cell.selected,'
                      + '.impulse-rows-cell.selected,'
                      + '.impulse-layout-cell.selected'
                    );
                    if (!hasSelectedChildCell) selEl.classList.add('selected');
                }
            }
        }
        if (e.data.type === 'impulse-deselect') {
            document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
            document.querySelectorAll('.impulse-grid-cell').forEach(function(x) { x.classList.remove('selected'); });
            document.querySelectorAll('.impulse-rows-cell').forEach(function(x) { x.classList.remove('selected'); });
            document.querySelectorAll('.impulse-layout-cell').forEach(function(x) { x.classList.remove('selected'); });
            if (window._impulseResetScrollTracker) window._impulseResetScrollTracker();
        }
        // Device change - update visibility indicators and responsive form fields
        if (e.data.type === 'impulse-device-change') {
            var device = e.data.device || 'desktop';
            var hideAttr = 'data-hide-' + device;
            
            // Update body class for device-specific checkout field widths
            document.body.classList.remove('impulse-device-desktop', 'impulse-device-tablet', 'impulse-device-mobile');
            document.body.classList.add('impulse-device-' + device);
            
            document.querySelectorAll('.impulse-el').forEach(function(el) {
                if (el.hasAttribute(hideAttr)) {
                    el.classList.add('impulse-device-hidden');
                } else {
                    el.classList.remove('impulse-device-hidden');
                }
            });
            
            // Toggle header menu vs burger based on device
            var header = document.querySelector('.impulse-header');
            if (header) {
                var desktopMenu = header.querySelector('.impulse-header-menu');
                var mobileToggle = header.querySelector('.impulse-mobile-toggle');
                var mobileMenu = header.querySelector('.impulse-mobile-menu');
                if (device === 'desktop') {
                    // Must use !important to override <style> block's !important rules from mobile render
                    if (desktopMenu) desktopMenu.style.setProperty('display', 'flex', 'important');
                    if (mobileToggle) mobileToggle.style.setProperty('display', 'none', 'important');
                    if (mobileMenu) mobileMenu.style.setProperty('display', 'none', 'important');
                    header.classList.remove('mobile-open');
                } else {
                    if (desktopMenu) desktopMenu.style.setProperty('display', 'none', 'important');
                    if (mobileToggle) mobileToggle.style.setProperty('display', 'flex', 'important');
                    if (mobileMenu) { mobileMenu.style.removeProperty('display'); }
                }
            }
            
            // Update responsive form field widths
            var gap = 16; // Default gap, will be overridden by parent form if available
            document.querySelectorAll('.impulse-form-field-wrap').forEach(function(wrap) {
                var widthAttr = 'data-width-' + device;
                var width = parseInt(wrap.getAttribute(widthAttr)) || 100;
                if (width < 100) {
                    wrap.style.width = 'calc(' + width + '% - ' + (gap / 2) + 'px)';
                } else {
                    wrap.style.width = '100%';
                }
            });
        }
        // Inline CSS property update — used for live focal point, etc.
        if (e.data.type === 'impulse-css-update') {
            var nodes = document.querySelectorAll(e.data.selector);
            var prop = e.data.property;
            var val = e.data.value;
            nodes.forEach(function(node) {
                if (prop.indexOf('--') === 0) {
                    node.style.setProperty(prop, val);
                } else {
                    node.style[prop] = val;
                }
            });
        }
        // Single element visibility update (from toggle in settings panel)
        if (e.data.type === 'impulse-hide-element') {
            var el = document.querySelector('.impulse-el[data-id="' + e.data.id + '"]');
            if (el) {
                // Detect if this is a screen-mode section (capture before removal)
                var _isScreenSection = el.getAttribute('data-type') === 'section'
                    && el.parentElement && el.parentElement.classList.contains('impulse-content-wrapper')
                    && document.body.getAttribute('data-screen-mode') === '1';
                // Capture slide parent refs BEFORE animation (el still in DOM)
                var _hideDz    = el.closest('.impulse-new-slider-dropzone');
                var _hideSlide = _hideDz ? _hideDz.closest('.impulse-carousel-slide') : null;
                // Capture sibling before removal for scroll-to
                var nextEl = el.nextElementSibling;
                while (nextEl && !nextEl.classList.contains('impulse-el')) nextEl = nextEl.nextElementSibling;
                if (!nextEl) {
                    var prevEl = el.previousElementSibling;
                    while (prevEl && !prevEl.classList.contains('impulse-el')) prevEl = prevEl.previousElementSibling;
                    nextEl = prevEl;
                }
                var h = el.offsetHeight;
                el.style.overflow = 'hidden';
                el.style.height = h + 'px';
                el.style.transition = 'transform 0.25s cubic-bezier(0.4,0,0.2,1), opacity 0.25s cubic-bezier(0.4,0,0.2,1), height 0.2s cubic-bezier(0.4,0,0.2,1) 0.1s, margin 0.2s cubic-bezier(0.4,0,0.2,1) 0.1s, padding 0.2s cubic-bezier(0.4,0,0.2,1) 0.1s';
                el.style.transformOrigin = 'center center';
                var _scrollSibling = nextEl;
                requestAnimationFrame(function() {
                    el.style.opacity = '0';
                    el.style.transform = 'scale(0.8)';
                    setTimeout(function() {
                        el.style.height = '0';
                        el.style.marginTop = '0';
                        el.style.marginBottom = '0';
                        el.style.paddingTop = '0';
                        el.style.paddingBottom = '0';
                        setTimeout(function() {
                            el.remove();
                            // Screen mode: refresh screen list so adjacent screen becomes active
                            if (_isScreenSection && window.impulseScreenMode && window.impulseScreenMode.refreshAfterDelete) {
                                window.impulseScreenMode.refreshAfterDelete();
                            }
                            // Show empty-page placeholder if no blocks remain
                            var wrapper = document.querySelector('.impulse-content-wrapper');
                            if (wrapper && !wrapper.querySelector('.impulse-el')) {
                                var empty = wrapper.querySelector('.impulse-empty-page');
                                if (!empty) {
                                    empty = document.createElement('div');
                                    empty.className = 'impulse-empty-page';
                                    empty.innerHTML = '<p>Your page is empty</p><p class="impulse-empty-hint">Click or drag blocks from the left panel</p>';
                                    wrapper.appendChild(empty);
                                } else {
                                    empty.style.display = '';
                                }
                            }
                            // Restore empty-slide placeholder when last block removed from a carousel slide
                            if (_hideDz && _hideSlide && !_hideDz.querySelector('.impulse-el')) {
                                _hideSlide.classList.add('impulse-carousel-slide-empty');
                                if (!_hideDz.querySelector('.impulse-cell-empty-indicator')) {
                                    var _ind = document.createElement('span');
                                    _ind.className = 'impulse-cell-empty-indicator';
                                    _ind.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:20px;color:rgba(128,128,128,0.45);pointer-events:none;z-index:3;user-select:none';
                                    _ind.textContent = '+';
                                    _hideDz.appendChild(_ind);
                                }
                            }
                            // Scroll to nearest sibling after removal (skip in screen mode)
window._impulseScopedScroll = window._impulseScopedScroll || function(el, block) {
    // Scoped scroll-to-element. NEVER scrollIntoView here: it propagates
    // through every scrollable ancestor ACROSS the iframe boundary, shoving
    // the editor's overflow:hidden canvas wrapper (unrecoverable without a
    // reload) and the admin document itself. Scroll only the nearest
    // scrollable container INSIDE this document. 2026-07-19.
    if (!el) return;
    var sc = el.parentElement;
    while (sc && sc !== document.body) {
        var cs = getComputedStyle(sc);
        if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') && sc.scrollHeight > sc.clientHeight + 1) break;
        sc = sc.parentElement;
    }
    if (!sc) sc = document.body;
    var r = el.getBoundingClientRect(), sr = sc.getBoundingClientRect();
    var eTop = r.top - sr.top + sc.scrollTop;
    var target;
    if (block === 'nearest') {
        if (r.top >= sr.top && r.bottom <= sr.bottom) return; // already visible
        target = eTop;
    } else {
        target = eTop - Math.max(0, (sc.clientHeight - el.offsetHeight) / 2);
    }
    target = Math.max(0, target);
    try { sc.scrollTo({ top: target, behavior: 'smooth' }); } catch (_e) { sc.scrollTop = target; }
};
                            if (_scrollSibling && document.body.dataset.screenMode !== '1') {
                                window._impulseScopedScroll(_scrollSibling, 'center');
                            }
                        }, 200);
                    }, 100);
                });
            }
        }
        // Clone element — instant visual duplicate before server responds
        if (e.data.type === 'impulse-clone-element') {
            var srcEl = document.querySelector('.impulse-el[data-id="' + e.data.id + '"]');
            if (srcEl) {
                var clone = srcEl.cloneNode(true);
                clone.setAttribute('data-id', 'temp-dup');
                clone.classList.remove('selected');
                // Deselect all, then select clone
                document.querySelectorAll('.impulse-el.selected').forEach(function(x) { x.classList.remove('selected'); });
                clone.classList.add('selected');
                // Insert after original
                srcEl.parentNode.insertBefore(clone, srcEl.nextSibling);
                // Animate in
                clone.style.opacity = '0';
                clone.style.transform = 'scale(0.92)';
                clone.style.transition = 'opacity 0.3s ease-in-out, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
                requestAnimationFrame(function() {
                    clone.style.opacity = '1';
                    clone.style.transform = 'scale(1)';
                });
                // Scroll the new clone into view immediately. Done in the iframe
                // (rather than waiting for the post-remap impulse-select roundtrip)
                // so the scroll feels instant — duplicating a small button on a
                // long page used to leave the user staring at the original while
                // the clone landed off-screen below.
                requestAnimationFrame(function() {
                    var r = clone.getBoundingClientRect();
                    if (r.height <= 0) return;
                    var winH = window.innerHeight || document.documentElement.clientHeight || 0;
                    // Tall element (taller than 60% of the viewport): use 'nearest'
                    // so we don't overshoot the top edge; small element: centre it.
                    var block = r.height > winH * 0.6 ? 'nearest' : 'center';
window._impulseScopedScroll = window._impulseScopedScroll || function(el, block) {
    // Scoped scroll-to-element. NEVER scrollIntoView here: it propagates
    // through every scrollable ancestor ACROSS the iframe boundary, shoving
    // the editor's overflow:hidden canvas wrapper (unrecoverable without a
    // reload) and the admin document itself. Scroll only the nearest
    // scrollable container INSIDE this document. 2026-07-19.
    if (!el) return;
    var sc = el.parentElement;
    while (sc && sc !== document.body) {
        var cs = getComputedStyle(sc);
        if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') && sc.scrollHeight > sc.clientHeight + 1) break;
        sc = sc.parentElement;
    }
    if (!sc) sc = document.body;
    var r = el.getBoundingClientRect(), sr = sc.getBoundingClientRect();
    var eTop = r.top - sr.top + sc.scrollTop;
    var target;
    if (block === 'nearest') {
        if (r.top >= sr.top && r.bottom <= sr.bottom) return; // already visible
        target = eTop;
    } else {
        target = eTop - Math.max(0, (sc.clientHeight - el.offsetHeight) / 2);
    }
    target = Math.max(0, target);
    try { sc.scrollTo({ top: target, behavior: 'smooth' }); } catch (_e) { sc.scrollTop = target; }
};
                    window._impulseScopedScroll(clone, block);
                });
            }
        }
        // Remap IDs on cloned element — called after server returns real IDs
        if (e.data.type === 'impulse-remap-ids') {
            var idMap = e.data.id_map || {};
            // Find the temp clone
            var tempEl = document.querySelector('.impulse-el[data-id="temp-dup"]');
            if (tempEl && idMap[e.data.old_id]) {
                var newRootId = idMap[e.data.old_id];
                tempEl.setAttribute('data-id', newRootId);
                // Update toolbar onclick handlers
                var dupBtn = tempEl.querySelector('.impulse-el-duplicate');
                if (dupBtn) dupBtn.setAttribute('onclick', 'impulseDuplicate(' + newRootId + ')');
                var delBtn = tempEl.querySelector('.impulse-el-delete');
                if (delBtn) delBtn.setAttribute('onclick', 'impulseDelete(' + newRootId + ')');
                // Remap all child element IDs
                tempEl.querySelectorAll('.impulse-el[data-id]').forEach(function(child) {
                    var oldChildId = parseInt(child.getAttribute('data-id'));
                    if (idMap[oldChildId]) {
                        var newChildId = idMap[oldChildId];
                        child.setAttribute('data-id', newChildId);
                        var cDup = child.querySelector(':scope > .impulse-el-toolbar .impulse-el-duplicate');
                        if (cDup) cDup.setAttribute('onclick', 'impulseDuplicate(' + newChildId + ')');
                        var cDel = child.querySelector(':scope > .impulse-el-toolbar .impulse-el-delete');
                        if (cDel) cDel.setAttribute('onclick', 'impulseDelete(' + newChildId + ')');
                    }
                });
                // Cloned subtree has stale canvases from the source — remove them and
                // re-init headings (3D text) and logo animators so new IDs register.
                tempEl.querySelectorAll('.impulse-display-text[data-dt-3d-mode] canvas').forEach(function(cv) { cv.remove(); });
                tempEl.querySelectorAll('.impulse-la-canvas').forEach(function(cv) { cv.remove(); });
                tempEl.querySelectorAll('.impulse-el[data-type="title"], .impulse-el[data-id="' + newRootId + '"][data-type="title"]').forEach(function(hEl) {
                    var hid = parseInt(hEl.getAttribute('data-id'), 10);
                    if (hid) window.postMessage({ type: 'impulse-3d-text-init', id: hid }, '*');
                });
                if (tempEl.getAttribute('data-type') === 'title') {
                    window.postMessage({ type: 'impulse-3d-text-init', id: newRootId }, '*');
                }
                // 2026-05-24: cloneNode(true) preserves the source animator's
                // DOM id (`la-{oldId}`). Two elements with the same id is
                // invalid HTML and `document.getElementById` returns only the
                // FIRST occurrence — so calling impulseLogoAnimatorRun(c.id)
                // would re-init the ORIGINAL, leaving the duplicate's canvas
                // unbuilt and its asset at the renderer's default opacity:0
                // (invisible until hard refresh). Rewrite c.id to match the
                // new data-id first, then init against the new id.
                tempEl.querySelectorAll('.impulse-logo-animator').forEach(function(c) {
                    var laParent = c.closest('.impulse-el');
                    var newDataId = laParent && laParent.getAttribute('data-id');
                    if (newDataId && /^[a-zA-Z0-9_-]+$/.test(String(newDataId))) {
                        c.id = 'la-' + newDataId;
                    }
                    if (c.id && window.impulseLogoAnimatorRun) window.impulseLogoAnimatorRun(c.id);
                });
                // Notify parent of selection + remap completion
                var _elType = tempEl.getAttribute('data-type') || '';
                window.parent.postMessage({ type: 'impulse-select', id: newRootId, elementType: _elType }, '*');
                window.parent.postMessage({ type: 'impulse-remap-done', elementType: _elType }, '*');
            }
        }
        if (e.data.type === 'impulse-capture-thumbnail') {
            // Delay 2 rAF frames to ensure effect canvas has rendered at least once
            requestAnimationFrame(function() { requestAnimationFrame(function() {

            // Capture env canvas FIRST — before any DOM manipulation disrupts rendering
            var _earlyEnvDataUrl = null;
            var _earlyEnvIds = ['impulse-motion-lava-canvas','impulse-wave-canvas','impulse-3d-canvas','impulse-fx-canvas','impulse-tunnel-canvas'];
            // Prefer computed body bg — the WebGL canvas has alpha=0 pixels
            // in unlit regions, so filling the snapshot with the resolved
            // background colour keeps the env's locked tone in the gaps.
            var _earlyBg = getComputedStyle(document.body).backgroundColor
                        || document.body.style.background
                        || '#000000';
            // rgba(0,0,0,0) means transparent — treat as black so the canvas
            // alpha-channel pixels resolve cleanly during JPEG flatten.
            if (/^rgba?\([^)]*,\s*0(?:\.0+)?\s*\)$/.test(_earlyBg)) _earlyBg = '#000000';
            for (var _eei = 0; _eei < _earlyEnvIds.length; _eei++) {
                var _eec = document.getElementById(_earlyEnvIds[_eei]);
                if (_eec && _eec.width > 10 && _eec.height > 10) {
                    // Synchronous capture: drawImage + toDataURL
                    // 2x rAF delay above ensures canvas has rendered at least one frame
                    try {
                        var _sn = document.createElement('canvas');
                        _sn.width = window.innerWidth || _eec.width;
                        _sn.height = window.innerHeight || _eec.height;
                        var _sc = _sn.getContext('2d');
                        _sc.fillStyle = _earlyBg;
                        _sc.fillRect(0, 0, _sn.width, _sn.height);
                        _sc.drawImage(_eec, 0, 0, _sn.width, _sn.height);
                        var _u = _sn.toDataURL('image/jpeg', 0.9);
                        if (_u && _u.length > 500) { _earlyEnvDataUrl = _u; break; }
                    } catch(_be) {}
                    break;
                }
            }


            // Hide editor chrome for clean capture — use body class so we can reliably restore.
            // Previously this set inline `display:none !important` on every toolbar, which was
            // never restored — leaving all toolbars invisible for the rest of the session.
            document.body.classList.add('impulse-capturing');
            // Safety: always restore after 8s even if no completion message fires.
            clearTimeout(window._impulseCaptureRestoreTimer);
            window._impulseCaptureRestoreTimer = setTimeout(function() {
                document.body.classList.remove('impulse-capturing');
            }, 8000);
            // Completion helper — always remove the class before notifying parent.
            function _captureDone(dataUrl) {
                document.body.classList.remove('impulse-capturing');
                clearTimeout(window._impulseCaptureRestoreTimer);
                try { window.parent.postMessage({ type: 'impulse-thumbnail-captured', pageId: pageId, dataUrl: dataUrl }, '*'); } catch(e) {}
            }
            document.querySelectorAll('.impulse-el.selected').forEach(function(el) { el.classList.remove('selected'); });
            document.querySelectorAll('.impulse-el').forEach(function(el) {
                el.style.outline = 'none';
                el.style.removeProperty('box-shadow');
            });

            var pageId = (new URLSearchParams(location.search).get('impulse_preview') || (location.pathname.match(/\/admin\/preview\/(\d+)/) || [])[1] || '');

            // In screen mode, temporarily disable it so all sections stack naturally for capture
            var wasScreenMode = document.body.hasAttribute('data-screen-mode');
            var screenModeStyle = document.getElementById('impulse-screen-mode-style');
            if (wasScreenMode) {
                document.body.removeAttribute('data-screen-mode');
                if (screenModeStyle) screenModeStyle.disabled = true;
                // Force content-wrapper to flow layout — setProperty with !important overrides JS-set inline styles
                var wrapper = document.querySelector('.impulse-content-wrapper');
                if (wrapper) {
                    wrapper.style.setProperty('position', 'relative', 'important');
                    wrapper.style.setProperty('overflow', 'visible', 'important');
                    wrapper.style.setProperty('height', 'auto', 'important');
                    wrapper.style.setProperty('min-height', '0', 'important');
                    var secs = wrapper.querySelectorAll(':scope > .impulse-el[data-type="section"]');
                    secs.forEach(function(s, i) {
                        if (i === 0) {
                            s.style.setProperty('position', 'relative', 'important');
                            s.style.setProperty('visibility', 'visible', 'important');
                            s.style.setProperty('opacity', '1', 'important');
                            s.style.setProperty('display', 'block', 'important');
                            s.style.setProperty('width', '100%', 'important');
                            // Keep viewport-like height so flex centering works
                            s.style.setProperty('height', '800px', 'important');
                            s.style.setProperty('min-height', '0', 'important');
                            s.style.setProperty('top', 'auto', 'important');
                            s.style.setProperty('left', 'auto', 'important');
                            s.style.setProperty('transform', 'none', 'important');
                            s.style.setProperty('z-index', '2', 'important');
                            s.style.setProperty('pointer-events', 'auto', 'important');
                            // Preserve flex centering on inner section/container
                            var innerS = s.querySelector('.impulse-section');
                            if (innerS) {
                                innerS.style.setProperty('height', '100%', 'important');
                                innerS.style.setProperty('min-height', '0', 'important');
                                innerS.style.setProperty('max-height', 'none', 'important');
                                innerS.style.setProperty('display', 'flex', 'important');
                                innerS.style.setProperty('flex-direction', 'column', 'important');
                                innerS.style.setProperty('justify-content', 'center', 'important');
                            }
                            var innerC = s.querySelector('.impulse-container');
                            if (innerC) {
                                innerC.style.setProperty('justify-content', 'center', 'important');
                            }
                        } else {
                            s.style.setProperty('display', 'none', 'important');
                        }
                    });
                }
                window.scrollTo(0, 0);
            }
            // Hide editor chrome only — NOT effect canvases (html2canvas captures them natively)
            document.querySelectorAll('svg[id*="impulse-cloud"], svg[id*="impulse-tunnel"], .impulse-screen-nav').forEach(function(el) {
                el.style.setProperty('display', 'none', 'important');
            });
            // Restore Animator text visibility (canvas is hidden, show the asset instead)
            document.querySelectorAll('.impulse-la-asset').forEach(function(el) {
                el.style.setProperty('opacity', '1', 'important');
            });
            // Fix body for capture — screen engine sets overflow:hidden + height:100vh
            document.body.style.setProperty('overflow', 'visible', 'important');
            // Neutralize stagger animations (icon lists etc)
            document.querySelectorAll('.impulse-table-stagger li').forEach(function(el) {
                el.style.setProperty('opacity', '1', 'important');
                el.style.setProperty('animation', 'none', 'important');
            });

            // Pre-flatten gradient-text on the LIVE document, before the
            // var() resolver below runs. The resolver walks every element
            // whose inline style contains var() and writes the computed
            // values back with !important (so html2canvas, which can't
            // evaluate CSS variables, sees usable styles). For gradient-
            // text surfaces (.impulse-text inner div, .impulse-dt-line)
            // the resolver pins `background: linear-gradient(...)` and
            // `color: transparent` with !important, which then survives
            // every later attempt by the onclone walker to flatten back
            // to a solid colour — html2canvas paints the gradient as a
            // rectangle and the glyphs render transparent. By flattening
            // here (before the resolver runs) the resolver only sees the
            // solid style we just wrote, and the rectangle never appears.
            // Originals are stashed in __gradFlatten__ for post-capture
            // restore (`finish` removes the impulse-capturing class but
            // we need to actually rewrite the inline styles).
            var _gradFlattenSnap = [];
            (function _preFlattenGradientText() {
                function _h2lum(c){
                    var pa=(c||'').replace('#','');
                    if (pa.length===3) pa=pa.split('').map(function(x){return x+x;}).join('');
                    if (pa.length!==6) return 128;
                    return 0.299*parseInt(pa.slice(0,2),16)+0.587*parseInt(pa.slice(2,4),16)+0.114*parseInt(pa.slice(4,6),16);
                }
                function _mixHex(a,b){
                    function _h(x){return parseInt(x,16);}
                    var pa=(a||'').replace('#',''), pb=(b||'').replace('#','');
                    if (pa.length===3) pa=pa.split('').map(function(c){return c+c;}).join('');
                    if (pb.length===3) pb=pb.split('').map(function(c){return c+c;}).join('');
                    if (pa.length!==6 || pb.length!==6) return '#1d2327';
                    var r=Math.round((_h(pa.slice(0,2))+_h(pb.slice(0,2)))/2);
                    var gv=Math.round((_h(pa.slice(2,4))+_h(pb.slice(2,4)))/2);
                    var bv=Math.round((_h(pa.slice(4,6))+_h(pb.slice(4,6)))/2);
                    return 'rgb('+r+','+gv+','+bv+')';
                }
                // Build the set of elements to flatten. Primary source is the
                // data-fill="gradient" attribute — the PHP renderer emits it
                // on every gradient-text surface (.impulse-text inner div,
                // .impulse-dt-line, etc.) along with data-grad-1/2 carrying
                // the original stop colours. Fallback: any element whose
                // *computed* style sets `-webkit-background-clip:text`. This
                // catches:
                //   (a) pages saved before the data-fill emit landed
                //   (b) any future gradient-text surface that hasn't opted
                //       in yet via the attr
                //   (c) anything where the live DOM was mutated to add the
                //       gradient styles after PHP render (live-update path)
                var _gradTargets = [];
                document.querySelectorAll('[data-fill="gradient"]').forEach(function(el) {
                    _gradTargets.push(el);
                });
                // Also walk computed styles. Limit to a sensible subset so
                // we don't iterate every element on a heavy page.
                document.querySelectorAll('.impulse-text, .impulse-text *, .impulse-dt-line, .impulse-la-asset').forEach(function(el) {
                    if (_gradTargets.indexOf(el) !== -1) return;
                    var cs = getComputedStyle(el);
                    var bgClip = cs.webkitBackgroundClip || cs.backgroundClip;
                    var txtFill = cs.webkitTextFillColor || '';
                    if (bgClip === 'text' || txtFill === 'rgba(0, 0, 0, 0)' || txtFill === 'transparent') {
                        _gradTargets.push(el);
                    }
                });
                // Sample the nearest ancestor that actually paints a
                // background so the contrast check below picks a readable
                // colour. Walks up the tree until it finds an element whose
                // computed background-color is not fully transparent.
                function _readBgLum(el) {
                    var n = el;
                    while (n && n !== document.documentElement) {
                        var bg = getComputedStyle(n).backgroundColor;
                        var m = bg && bg.match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\)/);
                        if (m) {
                            var alpha = m[4] !== undefined && m[4] !== '' ? parseFloat(m[4]) : 1;
                            if (alpha > 0.05) {
                                return 0.299*parseInt(m[1],10) + 0.587*parseInt(m[2],10) + 0.114*parseInt(m[3],10);
                            }
                        }
                        n = n.parentElement;
                    }
                    // Fallback: read body / html background, then default to light.
                    var bbg = getComputedStyle(document.body).backgroundColor;
                    var bm = bbg && bbg.match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
                    if (bm) return 0.299*parseInt(bm[1],10) + 0.587*parseInt(bm[2],10) + 0.114*parseInt(bm[3],10);
                    return 200;
                }
                _gradTargets.forEach(function(el) {
                    var g1 = el.getAttribute('data-grad-1') || '';
                    var g2 = el.getAttribute('data-grad-2') || '';
                    var solid;
                    if (g1 && g2) {
                        var l1 = _h2lum(g1), l2 = _h2lum(g2);
                        // Stops are near-identical (e.g. user picked
                        // #ffffff → #ffffff or hasn't edited the second
                        // stop yet). Use either one directly — overriding
                        // to a contrast colour here would invert intent
                        // (light-on-dark text would render dark, etc).
                        if (Math.abs(l1 - l2) < 20) {
                            solid = g1;
                        } else {
                            // True gradient (different stops). Mix gives the
                            // visually neutral midpoint of the two colours.
                            // Then check contrast against the nearest painted
                            // ancestor — if the mix is too close in luminance
                            // to the background, pick the stop that contrasts
                            // most instead of the mix. This rescues cases like
                            // a #999 → #fff gradient on a dark scene bg, where
                            // the mix (≈#ccc) is fine on light pages but lost
                            // on dark ones, and `#ffffff` (the lighter stop)
                            // reads cleanly.
                            var bgLum = _readBgLum(el);
                            var mix = _mixHex(g1, g2);
                            var mixLum;
                            var mm = mix.match(/(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
                            if (mm) mixLum = 0.299*parseInt(mm[1],10) + 0.587*parseInt(mm[2],10) + 0.114*parseInt(mm[3],10);
                            else    mixLum = (l1 + l2) / 2;
                            if (Math.abs(mixLum - bgLum) < 60) {
                                // Low contrast — pick the stop with more
                                // distance from the bg.
                                solid = (Math.abs(l1 - bgLum) > Math.abs(l2 - bgLum)) ? g1 : g2;
                            } else {
                                solid = mix;
                            }
                        }
                    } else if (g1 || g2) {
                        solid = g1 || g2;
                    } else {
                        // No data-grad-* attrs — pick a contrast colour off
                        // the surface the element sits on.
                        var lumNoAttr = _readBgLum(el);
                        solid = lumNoAttr > 128 ? '#1d2327' : '#ffffff';
                    }
                    _gradFlattenSnap.push({ el: el, style: el.getAttribute('style') || '' });
                    var st = el.style;
                    st.removeProperty('background-image');
                    st.removeProperty('background');
                    st.setProperty('background', 'none', 'important');
                    st.setProperty('background-image', 'none', 'important');
                    st.setProperty('background-color', 'transparent', 'important');
                    st.setProperty('-webkit-background-clip', 'border-box', 'important');
                    st.setProperty('background-clip', 'border-box', 'important');
                    st.setProperty('-webkit-text-fill-color', solid, 'important');
                    st.setProperty('color', solid, 'important');
                });
            })();
            // Post-capture restore — wraps _captureDone so the editor's
            // text doesn't stay flattened after the thumbnail upload.
            var _origCaptureDone = _captureDone;
            _captureDone = function(dataUrl) {
                for (var _gi = 0; _gi < _gradFlattenSnap.length; _gi++) {
                    var _snap = _gradFlattenSnap[_gi];
                    if (_snap.el && _snap.style) _snap.el.setAttribute('style', _snap.style);
                    else if (_snap.el) _snap.el.removeAttribute('style');
                }
                _origCaptureDone(dataUrl);
            };

            function doCapture() {
                try {

                // Use env captured before DOM manipulation
                var _envDataUrl = _earlyEnvDataUrl;

                var viewportW = Math.max(window.innerWidth || 0, document.documentElement.clientWidth || 0, 1200);

                // Read actual background color
                var bodyBg = getComputedStyle(document.body).backgroundColor;
                var useBg = '#ffffff';
                if (bodyBg && bodyBg !== 'rgba(0, 0, 0, 0)' && bodyBg !== 'transparent') {
                    useBg = bodyBg;
                } else {
                    var dataBg = document.body.dataset.pageBgColor;
                    if (dataBg && dataBg.charAt(0) === '#') useBg = dataBg;
                }

                // Capture target: body for screen mode (includes fixed canvases), firstSec for regular
                var captureEl, captureW, captureH;
                if (wasScreenMode) {
                    captureEl = document.body;
                    captureW = viewportW;
                    captureH = 800;
                    document.body.style.setProperty('height', '800px', 'important');
                } else {
                    var firstSec = document.querySelector('.impulse-el[data-type="section"]');
                    if (firstSec) {
                        firstSec.style.setProperty('position', 'relative', 'important');
                        firstSec.style.setProperty('visibility', 'visible', 'important');
                        firstSec.style.setProperty('opacity', '1', 'important');
                        firstSec.style.setProperty('display', 'block', 'important');
                        firstSec.style.setProperty('overflow', 'hidden', 'important');
                        var innerSec = firstSec.querySelector('.impulse-section');
                        if (innerSec) innerSec.style.setProperty('overflow', 'hidden', 'important');
                        void firstSec.offsetHeight;
                        captureEl = firstSec;
                        captureW = Math.min(firstSec.offsetWidth || viewportW, viewportW);
                        captureH = Math.max(firstSec.offsetHeight || 600, 300);
                    } else {
                        captureEl = document.body;
                        captureW = viewportW;
                        captureH = document.body.scrollHeight || 750;
                    }
                }

                // Resolve CSS custom properties (html2canvas can't handle var())
                var _resolveProps = ['background-color','background','color','text-align','line-height','letter-spacing','gap','font-family','font-size','font-weight','width','max-width','min-height','height','margin-top','margin-bottom','margin-left','margin-right','padding-top','padding-bottom','padding-left','padding-right','border-radius','border-color','border-width','justify-content','align-items','flex-direction','opacity','display'];
                var _resolved = [];
                captureEl.querySelectorAll('*').forEach(function(el) {
                    var inlineStyle = el.getAttribute('style') || '';
                    var cs = getComputedStyle(el);
                    var saved = {};
                    if (inlineStyle.indexOf('var(') !== -1) {
                        _resolveProps.forEach(function(p) {
                            var v = cs.getPropertyValue(p);
                            if (v && v !== 'none' && v !== 'normal' && v !== 'auto') {
                                el.style.setProperty(p, v, 'important');
                                saved[p] = true;
                            }
                        });
                    } else {
                        ['color', 'background-color'].forEach(function(p) {
                            var v = cs.getPropertyValue(p);
                            if (v && v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') {
                                el.style.setProperty(p, v);
                                saved[p] = true;
                            }
                        });
                    }
                    if (Object.keys(saved).length) _resolved.push({el: el, props: saved});
                });

                // Screenshot: one html2canvas call captures everything.
                // The onclone callback copies WebGL canvas pixels to cloned canvases
                // (html2canvas clones the DOM, but cloned canvases are blank).
                html2canvas(captureEl, {
                    width: captureW,
                    height: captureH,
                    windowWidth: captureW,
                    windowHeight: captureH,
                    scale: 0.4,
                    useCORS: true,
                    allowTaint: true,
                    logging: false,
                    backgroundColor: useBg,
                    imageTimeout: 2000,
                    onclone: function(clonedDoc) {
                        // For screen mode: composite env canvas directly onto the cloned body bg
                        // instead of relying on html2canvas to render fixed-position canvases.
                        var origCanvases = document.querySelectorAll('canvas');
                        var clonedCanvases = clonedDoc.querySelectorAll('canvas');
                        for (var ci = 0; ci < origCanvases.length; ci++) {
                            if (!clonedCanvases[ci] || origCanvases[ci].width < 10 || origCanvases[ci].height < 10) continue;
                            try {
                                clonedCanvases[ci].width = origCanvases[ci].width;
                                clonedCanvases[ci].height = origCanvases[ci].height;
                                var cctx = clonedCanvases[ci].getContext('2d');
                                cctx.drawImage(origCanvases[ci], 0, 0);
                                // Make cloned canvas position:absolute so html2canvas renders it
                                clonedCanvases[ci].style.setProperty('position', 'absolute', 'important');
                                clonedCanvases[ci].style.setProperty('top', '0', 'important');
                                clonedCanvases[ci].style.setProperty('left', '0', 'important');
                                clonedCanvases[ci].style.setProperty('z-index', '0', 'important');
                                clonedCanvases[ci].style.setProperty('width', '100%', 'important');
                                clonedCanvases[ci].style.setProperty('height', '100%', 'important');
                            } catch(e) {}
                        }
                        // Remove broken images
                        clonedDoc.querySelectorAll('img').forEach(function(img) {
                            if (!img.complete || img.naturalWidth === 0) img.style.display = 'none';
                        });
                        // Hide every .impulse-la-asset in the clone — the rendered
                        // animator output lives in the sibling .impulse-la-canvas
                        // (preserved by the canvas-copy block above) and the asset
                        // is opacity:0 on the live page via a stylesheet rule.
                        // html2canvas reads INLINE style (which has the asset's
                        // gradient background-image + color:transparent for the
                        // background-clip:text effect), doesn't honour the
                        // stylesheet rule reliably, and ALSO doesn't fully honour
                        // -webkit-text-fill-color:transparent — so the asset
                        // rendered into the thumbnail as a gradient rectangle
                        // with the heading text drawn on top in its computed
                        // colour. Forcing display:none in the clone is cleaner
                        // than trying to strip the gradient styles individually:
                        // the canvas overlay already carries the correct visual.
                        clonedDoc.querySelectorAll('.impulse-la-asset').forEach(function(a) {
                            a.style.setProperty('display', 'none', 'important');
                        });

                        // Gradient text (NON-animator path). Display-text spans
                        // (.impulse-dt-line), Text blocks (inner contenteditable
                        // div inside .impulse-text), and any future gradient-text
                        // surface all carry data-fill="gradient" + data-grad-1/2
                        // attrs. The generic flatten loop below catches most
                        // cases via inline-style sniffing, but reading
                        // el.style.webkitBackgroundClip in the clone is fragile —
                        // some surfaces normalise the property and the loop
                        // skips them, leaving the thumbnail showing a gradient
                        // rectangle with the text painted transparent. Belt-
                        // and-suspenders: explicitly walk every gradient-text
                        // surface (matched by the attr the PHP renderer
                        // emits alongside the gradient style) and rewrite the
                        // inline style to a solid mid-tone before the generic
                        // flatten runs.
                        clonedDoc.querySelectorAll('[data-fill="gradient"]').forEach(function(span) {
                            var g1 = span.getAttribute('data-grad-1') || '';
                            var g2 = span.getAttribute('data-grad-2') || '';
                            // Luminance-aware fallback. The original mix-the-stops
                            // approach failed on dark-scene designs that use
                            // light-on-dark gradient text (e.g. #999→#fff). The
                            // mix is rgb(204,204,204), invisible against any
                            // thumbnail env capture that sampled a light frame.
                            // Decision tree:
                            //   both stops very light → brand dark (readable)
                            //   both stops very dark  → white (readable)
                            //   otherwise             → mix (preserves user intent)
                            function _h2lum(c){
                                var pa = (c||'').replace('#','');
                                if (pa.length===3) pa = pa.split('').map(function(x){return x+x;}).join('');
                                if (pa.length!==6) return 128;
                                return 0.299*parseInt(pa.slice(0,2),16) + 0.587*parseInt(pa.slice(2,4),16) + 0.114*parseInt(pa.slice(4,6),16);
                            }
                            function _mixHex(a, b) {
                                function _h(x){return parseInt(x,16);}
                                var pa = a.replace('#',''), pb = b.replace('#','');
                                if (pa.length===3) pa = pa.split('').map(function(c){return c+c;}).join('');
                                if (pb.length===3) pb = pb.split('').map(function(c){return c+c;}).join('');
                                if (pa.length!==6 || pb.length!==6) return '#1d2327';
                                var r=Math.round((_h(pa.slice(0,2))+_h(pb.slice(0,2)))/2);
                                var gv=Math.round((_h(pa.slice(2,4))+_h(pb.slice(2,4)))/2);
                                var bv=Math.round((_h(pa.slice(4,6))+_h(pb.slice(4,6)))/2);
                                return 'rgb('+r+','+gv+','+bv+')';
                            }
                            var solid;
                            if (g1 && g2) {
                                var l1 = _h2lum(g1), l2 = _h2lum(g2);
                                if (l1 > 200 && l2 > 200)      solid = '#1d2327';
                                else if (l1 < 50 && l2 < 50)   solid = '#ffffff';
                                else                            solid = _mixHex(g1, g2);
                            } else {
                                solid = g1 || g2 || '#1d2327';
                            }
                            // Wipe the gradient longhands AND the shorthand, then
                            // force a solid fill via both color and -webkit-text-
                            // fill-color so neither cascade source brings the
                            // gradient back. Setting an explicit `background:
                            // transparent` (not just removing background-image)
                            // because Safari/WebKit keep the parsed gradient
                            // around when only the longhand is removed.
                            var st = span.style;
                            st.removeProperty('background-image');
                            st.removeProperty('background');
                            st.setProperty('background', 'none', 'important');
                            st.setProperty('background-image', 'none', 'important');
                            st.setProperty('background-color', 'transparent', 'important');
                            st.setProperty('-webkit-background-clip', 'border-box', 'important');
                            st.setProperty('background-clip', 'border-box', 'important');
                            st.setProperty('-webkit-text-fill-color', solid, 'important');
                            st.setProperty('color', solid, 'important');
                            // Some renders also pin the gradient on the parent
                            // .impulse-text wrapper (frontend renderer path).
                            // If the parent carries the same inline gradient,
                            // flatten it too so html2canvas can't paint the
                            // outer rectangle through inheritance / cascade.
                            var p = span.parentElement;
                            if (p && (p.style.webkitBackgroundClip === 'text' || p.style.backgroundClip === 'text')) {
                                var ps = p.style;
                                ps.removeProperty('background-image');
                                ps.removeProperty('background');
                                ps.setProperty('background', 'none', 'important');
                                ps.setProperty('background-image', 'none', 'important');
                                ps.setProperty('background-color', 'transparent', 'important');
                                ps.setProperty('-webkit-background-clip', 'border-box', 'important');
                                ps.setProperty('background-clip', 'border-box', 'important');
                                ps.setProperty('-webkit-text-fill-color', solid, 'important');
                                ps.setProperty('color', solid, 'important');
                            }
                        });
                        // Flatten gradient-text headings for html2canvas. The page
                        // uses `background:linear-gradient(...); -webkit-background-
                        // clip:text; -webkit-text-fill-color:transparent` to get
                        // gradient text. html2canvas can't reproduce background-
                        // clip:text — it renders the gradient as a solid coloured
                        // rectangle and the text disappears (transparent fill). We
                        // catch every cloned element using that trick, strip the
                        // gradient + transparent fill, and put a midpoint solid
                        // colour back so the thumbnail shows text in a sensible
                        // colour. The original page (in the editor and on the
                        // frontend) is untouched — only the html2canvas clone.
                        try {
                            var els = clonedDoc.querySelectorAll('*');
                            for (var k = 0; k < els.length; k++) {
                                var el = els[k];
                                var s  = el.style;
                                var bgClip = s.webkitBackgroundClip || s.backgroundClip;
                                var txtFill = s.webkitTextFillColor || '';
                                if (bgClip !== 'text' && txtFill !== 'transparent') continue;
                                // Pull the matching live element to read its data-grad-* attrs
                                // (the renderer emits them alongside the inline gradient style).
                                var liveSrc = null;
                                if (el.id) liveSrc = document.getElementById(el.id);
                                var c1 = (liveSrc && liveSrc.getAttribute('data-grad-1')) || el.getAttribute('data-grad-1') || '';
                                var c2 = (liveSrc && liveSrc.getAttribute('data-grad-2')) || el.getAttribute('data-grad-2') || '';
                                // Midpoint of the two hex colours, fallback to dark.
                                function _mix(a, b) {
                                    function _h(x){return parseInt(x,16);}
                                    var pa = a.replace('#',''), pb = b.replace('#','');
                                    if (pa.length===3) pa = pa.split('').map(function(c){return c+c;}).join('');
                                    if (pb.length===3) pb = pb.split('').map(function(c){return c+c;}).join('');
                                    if (pa.length!==6 || pb.length!==6) return '#1d2327';
                                    var r=Math.round((_h(pa.slice(0,2))+_h(pb.slice(0,2)))/2);
                                    var g=Math.round((_h(pa.slice(2,4))+_h(pb.slice(2,4)))/2);
                                    var bb=Math.round((_h(pa.slice(4,6))+_h(pb.slice(4,6)))/2);
                                    return 'rgb('+r+','+g+','+bb+')';
                                }
                                var solid = (c1 && c2) ? _mix(c1, c2) : (c1 || '#1d2327');
                                // Strip gradient + transparent fill; force solid colour.
                                s.removeProperty('background-image');
                                s.background = 'transparent';
                                s.webkitBackgroundClip = '';
                                s.backgroundClip = '';
                                s.webkitTextFillColor = solid;
                                s.color = solid;
                            }
                        } catch (gradErr) {}
                    }
                }).then(function(canvas) {
                    _resolved.forEach(function(r) { for (var p in r.props) r.el.style.removeProperty(p); });
                    if (wasScreenMode) {
                        document.body.setAttribute('data-screen-mode', '1');
                        if (screenModeStyle) screenModeStyle.disabled = false;
                    }
                    var thumb = document.createElement('canvas');
                    thumb.width = 480; thumb.height = 300;
                    var ctx = thumb.getContext('2d');

                    // Crop the source to the thumb's 16:10 aspect BEFORE
                    // drawing, so drawImage scales both axes by the same
                    // factor and the picture inside doesn't end up squashed
                    // or stretched. Previously the code only cropped height
                    // (srcCropH) and always used the full canvas.width — so
                    // when the captured section was wider than 16:10 (typical
                    // hero), drawImage scaled width and height by different
                    // factors, stretching content vertically and reading as
                    // a ~4:3 look even though the thumbnail itself was 16:10.
                    var _thumbAspect = thumb.width / thumb.height;       // 1.6
                    var _srcAspect   = canvas.width / canvas.height;
                    var _srcW, _srcH;
                    if (_srcAspect > _thumbAspect) {
                        // Source is wider than 16:10 — crop width, keep height.
                        _srcH = canvas.height;
                        _srcW = Math.round(canvas.height * _thumbAspect);
                    } else {
                        // Source is taller than (or equal to) 16:10 — crop height, keep width.
                        _srcW = canvas.width;
                        _srcH = Math.round(canvas.width / _thumbAspect);
                    }
                    _srcW = Math.min(_srcW, canvas.width);
                    _srcH = Math.min(_srcH, canvas.height);

                    // Centre the crop. Previously this used (0,0) as the source
                    // origin, so when the captured section was much wider than
                    // 16:10 (the typical hero — e.g. 1920×400 → aspect 4.8 vs
                    // thumb 1.6), only the LEFT slice survived: centered content
                    // ended up shoved against the thumbnail's right edge, half
                    // cropped off. Centering the crop puts the section's middle
                    // — which is where headings, animators, and CTAs sit by
                    // design — at the thumbnail's middle.
                    var _srcX = Math.max(0, Math.round((canvas.width  - _srcW) / 2));
                    var _srcY = Math.max(0, Math.round((canvas.height - _srcH) / 2));

                    // Draw html2canvas result (has useBg as background)
                    ctx.drawImage(canvas, _srcX, _srcY, _srcW, _srcH, 0, 0, thumb.width, thumb.height);

                    function finishThumb(envImg) {
                        if (envImg) {
                            // Slip env behind content using destination-over:
                            // only fills pixels not already painted by html2canvas
                            ctx.globalCompositeOperation = 'destination-over';
                            // Centre-crop env to the thumb's 16:10 aspect FIRST.
                            // The early env capture snapshots the iframe at
                            // window.innerWidth × window.innerHeight — typically
                            // wider than 16:10 (e.g. 1280×720 → 1.78). Drawing
                            // that straight into 480×300 stretches axes by
                            // different factors, squashing the env vertically
                            // (and pulling all the foreground content into
                            // looking taller than it should). Same fix shape as
                            // the html2canvas crop above.
                            var _envAspect = envImg.naturalWidth && envImg.naturalHeight
                                ? envImg.naturalWidth / envImg.naturalHeight
                                : _thumbAspect;
                            var _esW, _esH;
                            if (_envAspect > _thumbAspect) {
                                _esH = envImg.naturalHeight;
                                _esW = Math.round(envImg.naturalHeight * _thumbAspect);
                            } else {
                                _esW = envImg.naturalWidth;
                                _esH = Math.round(envImg.naturalWidth / _thumbAspect);
                            }
                            _esW = Math.min(_esW, envImg.naturalWidth || _esW);
                            _esH = Math.min(_esH, envImg.naturalHeight || _esH);
                            var _esX = Math.max(0, Math.round(((envImg.naturalWidth  || _esW) - _esW) / 2));
                            var _esY = Math.max(0, Math.round(((envImg.naturalHeight || _esH) - _esH) / 2));
                            ctx.drawImage(envImg, _esX, _esY, _esW, _esH, 0, 0, 480, 300);
                            ctx.globalCompositeOperation = 'source-over';
                        }
                        var dataUrl;
                        try { dataUrl = thumb.toDataURL('image/jpeg', 0.8); } catch(ex) { dataUrl = null; }
                        _captureDone(dataUrl);
                    }

                    if (_envDataUrl) {
                        var envImg = new Image();
                        envImg.onload = function() { finishThumb(envImg); };
                        envImg.onerror = function() { finishThumb(null); };
                        envImg.src = _envDataUrl;
                    } else {
                        finishThumb(null);
                    }
                }).catch(function() {
                    _resolved.forEach(function(r) { for (var p in r.props) r.el.style.removeProperty(p); });
                    if (wasScreenMode) {
                        document.body.setAttribute('data-screen-mode', '1');
                        if (screenModeStyle) screenModeStyle.disabled = false;
                    }
                    _captureDone(null);
                });
                } catch(captureErr) {
                    _captureDone(null);
                }
            }

            // Wait for async toBlob env capture, then choose path
            var _envWaitStart = Date.now();
            function _waitForEnv(cb) {
                if (_earlyEnvDataUrl || Date.now() - _envWaitStart > 600) { cb(); }
                else { setTimeout(function(){ _waitForEnv(cb); }, 30); }
            }

            _waitForEnv(function() {
                if (wasScreenMode && _earlyEnvDataUrl) {
                    // Screen mode with env: draw env + content section
                    var _directImg = new Image();
                    _directImg.onload = function() {
                        var _cSec = document.querySelector('.impulse-el[data-type="section"]');
                        if (_cSec) {
                            _cSec.style.setProperty('position','relative','important');
                            _cSec.style.setProperty('visibility','visible','important');
                            _cSec.style.setProperty('opacity','1','important');
                            _cSec.style.setProperty('display','block','important');
                            _cSec.style.setProperty('height','800px','important');
                            _cSec.style.setProperty('overflow','hidden','important');
                        }
                        function _useEnvOnly() {
                            var _dt=document.createElement('canvas'); _dt.width=480; _dt.height=300;
                            _dt.getContext('2d').drawImage(_directImg, 0, 0, 480, 300);
                            var _dUrl; try{_dUrl=_dt.toDataURL('image/jpeg',0.85);}catch(ex){_dUrl=null;}
                            _captureDone(_dUrl);
                        }
                        function _runH2c() {
                            if (typeof html2canvas !== 'function') { _useEnvOnly(); return; }
                            html2canvas(_cSec || document.body, {
                                width: window.innerWidth||1200, height: 800,
                                windowWidth: window.innerWidth||1200, windowHeight: 800,
                                scale: 0.4, useCORS: true, allowTaint: true, logging: false,
                                backgroundColor: null, imageTimeout: 2000,
                                onclone: function(cd){
                                    // Hide only the FULLSCREEN bg canvases (env,
                                    // cloud, tunnel — all position:fixed). The env
                                    // is composited separately via _directImg
                                    // because html2canvas can't render fixed-
                                    // position canvases reliably.
                                    //
                                    // INLINE canvases (3D-text engine, logo
                                    // animator) hold the actual rendered text/
                                    // image and MUST stay visible — they're the
                                    // only place a 3D-depth heading or animator-
                                    // preset block exists in the captured view.
                                    // Hiding them blank-erased headings like the
                                    // "Instinctor Team" 3D-text on page 99.
                                    //
                                    // For each inline canvas we still need to
                                    // pixel-copy from the live original because
                                    // html2canvas clones the DOM but cloned
                                    // canvases come back blank.
                                    var _origCanvases = document.querySelectorAll('canvas');
                                    var _cdCanvases   = cd.querySelectorAll('canvas');
                                    for (var _ci = 0; _ci < _origCanvases.length; _ci++) {
                                        var _oc = _origCanvases[_ci];
                                        var _cc = _cdCanvases[_ci];
                                        if (!_cc) continue;
                                        var _ocPos = getComputedStyle(_oc).position;
                                        if (_ocPos === 'fixed') {
                                            // Fullscreen bg — drop from output
                                            _cc.style.display = 'none';
                                            continue;
                                        }
                                        // Inline — copy pixels so the cloned
                                        // canvas isn't blank.
                                        if (_oc.width < 10 || _oc.height < 10) continue;
                                        try {
                                            _cc.width  = _oc.width;
                                            _cc.height = _oc.height;
                                            _cc.getContext('2d').drawImage(_oc, 0, 0);
                                        } catch (_) {}
                                    }
                                    var _sec=cd.querySelector('.impulse-el[data-type="section"]');
                                    if(_sec){
                                        _sec.style.setProperty('background','transparent','important');
                                        _sec.style.setProperty('background-color','transparent','important');
                                        var _is=_sec.querySelector('.impulse-section');
                                        if(_is){_is.style.setProperty('background','transparent','important');_is.style.setProperty('background-color','transparent','important');}
                                    }
                                    // Screen-mode capture path — hits gig/profile/scene pages.
                                    // Mirror the main capture path's dt-line gradient flatten
                                    // here so heading gradient text renders as solid color in
                                    // the thumb instead of as a gradient rectangle. The main
                                    // path's onclone at the top of this file has the same
                                    // logic — kept in sync until the two paths are merged.
                                    // Luminance-aware fallback — see comment on
                                    // the main-capture flatten block above. The
                                    // mix-only approach hid white-on-dark gradient
                                    // text in the thumbnail (page 99 case).
                                    function _h2lum(c){
                                        var pa=(c||'').replace('#','');
                                        if (pa.length===3) pa=pa.split('').map(function(x){return x+x;}).join('');
                                        if (pa.length!==6) return 128;
                                        return 0.299*parseInt(pa.slice(0,2),16)+0.587*parseInt(pa.slice(2,4),16)+0.114*parseInt(pa.slice(4,6),16);
                                    }
                                    function _mixHex(a, b) {
                                        function _h(x){return parseInt(x,16);}
                                        var pa=(a||'').replace('#',''), pb=(b||'').replace('#','');
                                        if (pa.length===3) pa=pa.split('').map(function(c){return c+c;}).join('');
                                        if (pb.length===3) pb=pb.split('').map(function(c){return c+c;}).join('');
                                        if (pa.length!==6 || pb.length!==6) return '#1d2327';
                                        var r=Math.round((_h(pa.slice(0,2))+_h(pb.slice(0,2)))/2);
                                        var gv=Math.round((_h(pa.slice(2,4))+_h(pb.slice(2,4)))/2);
                                        var bv=Math.round((_h(pa.slice(4,6))+_h(pb.slice(4,6)))/2);
                                        return 'rgb('+r+','+gv+','+bv+')';
                                    }
                                    cd.querySelectorAll('[data-fill="gradient"]').forEach(function(span){
                                        var g1=span.getAttribute('data-grad-1')||'';
                                        var g2=span.getAttribute('data-grad-2')||'';
                                        var solid;
                                        if (g1 && g2) {
                                            var l1=_h2lum(g1), l2=_h2lum(g2);
                                            if (l1>200 && l2>200)      solid='#1d2327';
                                            else if (l1<50 && l2<50)   solid='#ffffff';
                                            else                        solid=_mixHex(g1,g2);
                                        } else {
                                            solid=g1||g2||'#1d2327';
                                        }
                                        var st=span.style;
                                        st.removeProperty('background-image');
                                        st.removeProperty('background');
                                        st.setProperty('background','transparent','important');
                                        st.setProperty('-webkit-background-clip','border-box','important');
                                        st.setProperty('background-clip','border-box','important');
                                        st.setProperty('-webkit-text-fill-color',solid,'important');
                                        st.setProperty('color',solid,'important');
                                    });
                                    // Also hide every .impulse-la-asset in this path —
                                    // animator gradient text on a scene page would render
                                    // as a rectangle here too without it.
                                    cd.querySelectorAll('.impulse-la-asset').forEach(function(a){
                                        a.style.setProperty('display','none','important');
                                    });
                                }
                            }).then(function(cc) {
                                var _dt=document.createElement('canvas'); _dt.width=480; _dt.height=300;
                                var _dc=_dt.getContext('2d');
                                _dc.drawImage(_directImg, 0, 0, 480, 300);
                                var _fs=480/cc.width, _ch=Math.min(cc.height,Math.round(300/_fs));
                                _dc.drawImage(cc, 0, 0, cc.width, _ch, 0, 0, 480, 300);
                                var _dUrl; try{_dUrl=_dt.toDataURL('image/jpeg',0.85);}catch(ex){_dUrl=null;}
                                _captureDone(_dUrl);
                            }).catch(_useEnvOnly);
                        }
                        if (typeof html2canvas === 'function') {
                            setTimeout(_runH2c, 50);
                        } 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(_runH2c, 50); };
                            _sc.onerror=_useEnvOnly;
                            document.head.appendChild(_sc);
                        }
                    };
                    _directImg.onerror = function() { _captureDone(null); };
                    _directImg.src = _earlyEnvDataUrl;
                } else {
                    // No env or non-screen-mode: use html2canvas path
                    startCapture();
                }
            });

            // Load html2canvas on demand — not bundled in <head> to save 199KB on every preview load
            function startCapture() {
                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() { _captureDone(null); };
                    document.head.appendChild(sc);
                }
            }
            // startCapture is called from _waitForEnv above
            }); }); // end rAF delay
        }
        
        if (e.data.type === 'impulse-scene-screens-dim') {
            document.body.classList.toggle('impulse-scene-editing', !!e.data.hidden);
        }

        if (e.data.type === 'impulse-visibility-update') {
            var el = document.querySelector('.impulse-el[data-id="' + e.data.id + '"]');
            if (el) {
                var attrName = 'data-hide-' + e.data.device;
                if (e.data.hidden) { el.setAttribute(attrName, '1'); }
                else { el.removeAttribute(attrName); }
                // Only reflect the dim indicator when we're actually viewing that
                // device, so toggling "Hide on mobile" from the desktop tab doesn't
                // grey the element out on desktop. Switching device tabs re-evaluates.
                var _curDev = document.body.classList.contains('impulse-device-mobile') ? 'mobile'
                            : document.body.classList.contains('impulse-device-tablet') ? 'tablet' : 'desktop';
                if (e.data.device === _curDev) {
                    el.classList.toggle('impulse-device-hidden', !!e.data.hidden);
                }
            }
        }
        // Live animation update - re-init canvas animation without iframe reload
        if (e.data.type === 'impulse-update-animation') {
            var sectionId = e.data.sectionId;
            var layers = e.data.layers || [];
            var fullscreen = e.data.fullscreen || false;
            var idPrefix = sectionId === 'page' ? 'page-anim' : 'section-anim-' + sectionId;
            
            layers.forEach(function(layerConfig, idx) {
                var layerAnimId = idPrefix + '-' + idx;

                // Hidden layer OR no effect chosen — destroy any running
                // animation and remove the wrapper. Both are non-destructive
                // (the layer stays in the data structure); no canvas renders.
                //
                // The no-effect skip MUST mirror the PHP renderer
                // (impulse_render_animation_layers, which does
                // `if ($layerEffect === '' || 'none') continue;`). Without
                // it, enabling section animation and adding a fresh layer —
                // which starts with effect:'' — fell through to
                // ImpulseAnimations.init() with an empty effect, and the
                // animation engine defaults an empty/unknown effect to
                // 'rings'. Result: rings emitted before the user picked any
                // preset (all effect buttons off). When the user later
                // clicks an effect button, the next update message re-inits
                // the layer properly.
                var _noEffect = !layerConfig || !layerConfig.effect
                    || layerConfig.effect === '' || layerConfig.effect === 'none';
                if ((layerConfig && layerConfig._visible === false) || _noEffect) {
                    if (window.ImpulseAnimations) window.ImpulseAnimations.destroy(layerAnimId);
                    var hiddenWrap = document.getElementById(layerAnimId);
                    if (hiddenWrap) hiddenWrap.remove();
                    return;
                }

                // Update wrapper CSS (z-index, opacity, blend) without reload
                var wrapper = document.getElementById(layerAnimId);
                if (!wrapper) {
                    // Layer was added (or duplicated) — create wrapper on the fly.
                    // Page mode: position:fixed full viewport, mounted on body.
                    // Section mode: position:absolute, mounted inside the section
                    // wrapper (matches PHP-rendered hierarchy).
                    var div = document.createElement('div');
                    div.id = layerAnimId;
                    var canvas = document.createElement('canvas');
                    canvas.dataset.layer = '0';
                    canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none';
                    div.appendChild(canvas);
                    if (sectionId === 'page') {
                        var baseStyleP = 'position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;overflow:hidden';
                        div.className = 'impulse-page-animation';
                        div.style.cssText = baseStyleP + ';z-index:' + (layerConfig.z_index || 0) + ';opacity:' + ((layerConfig.opacity !== undefined ? layerConfig.opacity : 100) / 100) + ';mix-blend-mode:' + (layerConfig.blend_mode || 'normal');
                        document.body.insertBefore(div, document.body.firstChild);
                    } else {
                        var baseStyleS = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;overflow:hidden';
                        div.className = 'impulse-section-animation';
                        div.style.cssText = baseStyleS + ';z-index:' + (layerConfig.z_index || 0) + ';opacity:' + ((layerConfig.opacity !== undefined ? layerConfig.opacity : 100) / 100) + ';mix-blend-mode:' + (layerConfig.blend_mode || 'normal');
                        // Mount inside the section element. Section's outer DOM
                        // node has data-id matching sectionId.
                        var sectionEl = document.querySelector('.impulse-el[data-id="' + sectionId + '"]');
                        var host = sectionEl ? sectionEl.querySelector('.impulse-section, section, .impulse-el-section, .impulse-el-content') || sectionEl : null;
                        if (host) host.insertBefore(div, host.firstChild);
                    }
                    wrapper = div;
                }
                if (wrapper) {
                    wrapper.style.zIndex = layerConfig.z_index !== undefined ? String(layerConfig.z_index) : '0';
                    wrapper.style.opacity = (layerConfig.opacity !== undefined ? layerConfig.opacity : 100) / 100;
                    wrapper.style.mixBlendMode = layerConfig.blend_mode || 'normal';
                }
                
                if (window.ImpulseAnimations) {
                    // Update existing animation config in place (no flicker)
                    var updated = window.ImpulseAnimations.update(layerAnimId, layerConfig);
                    if (!updated) {
                        // Animation doesn't exist yet - full init needed
                        window.ImpulseAnimations.init(layerAnimId, { layers: [layerConfig], fullscreen: fullscreen }, true);
                    }
                }
            });
            
            // Remove extra layers if count decreased
            var existingLayers = document.querySelectorAll(sectionId === 'page' ? '.impulse-page-animation' : '.impulse-section-animation[id^="' + idPrefix + '"]');
            existingLayers.forEach(function(el) {
                var elIdx = parseInt(el.id.split('-').pop());
                if (elIdx >= layers.length) {
                    if (window.ImpulseAnimations) window.ImpulseAnimations.destroy(el.id);
                    el.remove();
                }
            });
        }
        // Highlight target during drag (visual feedback from overlay)
        if (e.data.type === 'impulse-highlight-target') {
            document.body.classList.add('drag-active');
            var result = findExternalTarget(e.data.x, e.data.y);
            showExternalFeedback(result);
        }
        // Drop at position — unified resolution
        if (e.data.type === 'impulse-find-drop-at-position') {
            var result = findExternalTarget(e.data.x, e.data.y);
            if (result) {
                sendDropMessage(result, e.data.element, e.data.template);
            } else {
                // Fallback: drop at page root
                if (window.parent) {
                    window.parent.postMessage({ type: 'impulse-drop', element: e.data.element, template: e.data.template, parentId: 0, position: -1 }, '*');
                }
            }
            clearExternalFeedback();
            document.body.classList.remove('drag-active');
            pendingElement = null;
            pendingTemplate = null;
        }
    });    
    // Global cleanup: ensure drag-active is removed on mouseup (failsafe)
    document.addEventListener('mouseup', function() {
        setTimeout(function() {
            if (document.body.classList.contains('drag-active') && !pendingElement) {
                document.body.classList.remove('drag-active');
                clearExternalFeedback();
            }
        }, 100);
    });
    
    // Cleanup when focus leaves the iframe
    window.addEventListener('blur', function() {
        document.body.classList.remove('drag-active');
        clearExternalFeedback();
        pendingElement = null;
        pendingTemplate = null;
    });
    
    // Drag and drop zones
    document.addEventListener('dragenter', function(e) {
        document.body.classList.add('drag-active');
    });
    
    // Auto-scroll while dragging near the top/bottom edge of the iframe.
    // SCROLL_ZONE = px from edge that triggers, SCROLL_SPEED = px/frame.
    // RAF loop keeps scrolling until the mouse leaves the edge zone or drag
    // ends. Without this, dragging a block past the visible area parks at
    // the edge — user has to release, scroll manually, re-pick the block.
    var _dsScrollDir = 0;
    var _dsScrollRaf = null;
    function _dsScrollTick() {
        if (!_dsScrollDir) { _dsScrollRaf = null; return; }
        window.scrollBy(0, _dsScrollDir);
        _dsScrollRaf = requestAnimationFrame(_dsScrollTick);
    }
    function _dsSetDir(dir) {
        _dsScrollDir = dir;
        if (dir && !_dsScrollRaf) _dsScrollRaf = requestAnimationFrame(_dsScrollTick);
    }

    document.addEventListener('dragover', function(e) {
        e.preventDefault();
        // Auto-scroll near edges. 80px zone, ramp speed 4-14px/frame based on
        // how deep into the zone the cursor is — gentle near outer threshold,
        // fast at the very edge.
        var SCROLL_ZONE = 80;
        var dy = 0;
        if (e.clientY < SCROLL_ZONE) {
            dy = -Math.ceil(4 + (SCROLL_ZONE - e.clientY) / SCROLL_ZONE * 10);
        } else if (window.innerHeight - e.clientY < SCROLL_ZONE) {
            dy = Math.ceil(4 + (SCROLL_ZONE - (window.innerHeight - e.clientY)) / SCROLL_ZONE * 10);
        }
        _dsSetDir(dy);
        // All visual feedback is handled via impulse-highlight-target from the parent overlay
        // This handler only prevents the default to allow drops
        if (!pendingElement) return;
        // Report position to parent for overlay-based detection
        if (window.parent) {
            window.parent.postMessage({ type: 'impulse-drag-position', x: e.clientX, y: e.clientY }, '*');
        }
    });
    document.addEventListener('drop',     function() { _dsSetDir(0); });
    document.addEventListener('dragend',  function() { _dsSetDir(0); });
    window.addEventListener('blur',       function() { _dsSetDir(0); });
    
    document.addEventListener('dragleave', function(e) {
        if (e.target === document.body || e.target === document.documentElement) {
            document.body.classList.remove('drag-active');
            clearExternalFeedback();
        }
    });
    
    // Drop handler — unified through findExternalTarget
    function handleDrop(e) {
        e.preventDefault();
        e.stopPropagation();
        if (!pendingElement) return;
        
        var result = findExternalTarget(e.clientX, e.clientY);
        if (result) {
            sendDropMessage(result, pendingElement, pendingTemplate);
        } else {
            // Fallback: drop at page root
            if (window.parent) {
                window.parent.postMessage({ type: 'impulse-drop', element: pendingElement, parentId: 0, position: -1 }, '*');
            }
        }
        
        clearExternalFeedback();
        document.body.classList.remove('drag-active');
        pendingElement = null;
        pendingTemplate = null;
    }
    
    document.addEventListener('drop', handleDrop);
    
    // Mouseup fallback — unified through findExternalTarget
    document.addEventListener('mouseup', function(e) {
        if (!pendingElement || !document.body.classList.contains('drag-active')) return;
        
        var result = findExternalTarget(e.clientX, e.clientY);
        if (result) {
            sendDropMessage(result, pendingElement, pendingTemplate);
        }
        
        clearExternalFeedback();
        document.body.classList.remove('drag-active');
        pendingElement = null;
        pendingTemplate = null;
    });    
    // Modal editor close button — deselects the modal .impulse-el so the scrim hides.
    document.addEventListener('click', function(ev) {
        var btn = ev.target.closest && ev.target.closest('.impulse-modal-close-editor');
        if (!btn) return;
        ev.preventDefault();
        ev.stopPropagation();
        var popupEl = btn.closest('.impulse-el[data-type="modal"]');
        if (popupEl) popupEl.classList.remove('selected');
        if (window.parent) window.parent.postMessage({ type: 'impulse-deselect' }, '*');
    }, true);

    // Modal scrim portal — move the scrim to iframe body when the modal opens so no
    // transformed ancestor can contain its position:fixed and stretch it to ancestor size.
    // Tracks owner by data-modal-owner attribute (not a WeakMap) so refreshElement swaps
    // (which replace the owner node) can re-link a fresh owner to its orphan scrim.
    (function() {
        function openModal(popupEl) {
            var scrim = popupEl.querySelector(':scope > .impulse-modal-scrim');
            if (!scrim) {
                // Orphan scrim may already be in body from a prior refresh — find and reveal.
                scrim = document.body.querySelector(':scope > .impulse-modal-scrim[data-modal-owner="' + popupEl.dataset.id + '"]');
                if (scrim) scrim.style.display = 'block';
                return;
            }
            scrim.setAttribute('data-modal-owner', popupEl.dataset.id);
            // Portal to body only if not already there. After portal the CSS selector
            // .impulse-el[data-type="modal"].selected .impulse-modal-scrim no longer matches
            // (scrim is no longer a descendant), so we must force display:block explicitly.
            if (scrim.parentNode !== document.body) {
                document.body.appendChild(scrim);
            }
            scrim.style.display = 'block';
        }
        function closeModal(popupEl) {
            var id = popupEl.dataset.id;
            var scrim = document.body.querySelector(':scope > .impulse-modal-scrim[data-modal-owner="' + id + '"]');
            if (scrim) {
                scrim.style.display = 'none';
                popupEl.appendChild(scrim);
            }
        }
        // Clean up orphan scrims left in body after a refreshElement replaces the owner,
        // and re-portal the FRESH scrim (which carries updated position/animation/edge-effect
        // CSS from the latest render) so setting changes actually take effect visually.
        function reclaimOrphans() {
            // First: drop any body-level scrim whose owner popupEl was replaced — the fresh
            // one in the popup will carry the new styles.
            document.body.querySelectorAll(':scope > .impulse-modal-scrim[data-modal-owner]').forEach(function(scrim) {
                var id = scrim.getAttribute('data-modal-owner');
                var popupEl = document.querySelector('.impulse-el[data-type="modal"][data-id="' + id + '"]');
                if (!popupEl) { scrim.remove(); return; }
                // A fresh scrim inside the popup means the popup was just re-rendered. Discard
                // the stale portalled one so the fresh one (with new CSS) can be portalled.
                var freshScrim = popupEl.querySelector(':scope > .impulse-modal-scrim');
                if (freshScrim) {
                    scrim.remove();
                    if (popupEl.classList.contains('selected')) openModal(popupEl);
                }
            });
            // Then: any popup still selected but without a body-portalled scrim gets opened.
            document.querySelectorAll('.impulse-el[data-type="modal"].selected').forEach(function(popupEl) {
                var id = popupEl.dataset.id;
                var already = document.body.querySelector(':scope > .impulse-modal-scrim[data-modal-owner="' + id + '"]');
                if (!already) openModal(popupEl);
            });
        }
        var observer = new MutationObserver(function(muts) {
            muts.forEach(function(m) {
                if (m.type === 'childList') {
                    // If any popup el was added or removed, reclaim orphans.
                    if (m.addedNodes.length || m.removedNodes.length) reclaimOrphans();
                    return;
                }
                if (m.type !== 'attributes' || m.attributeName !== 'class') return;
                var t = m.target;
                if (!t.matches || !t.matches('.impulse-el[data-type="modal"]')) return;
                if (t.classList.contains('selected')) { openModal(t); return; }
                // Popup lost .selected — but if the new selection is a DESCENDANT
                // (the user clicked a child inside the open modal, or just dropped
                // a block there and the editor auto-selected it), keep the modal
                // open. Only close when the selection moves outside the modal.
                //
                // v2.1.1402: defer the check by a frame. The editor's body-level
                // click handler does `removeClass('selected') from ALL` THEN
                // `addClass('selected') on the new target` — this MutationObserver
                // fires the moment .selected leaves the popup, BEFORE the new
                // selection is set. Synchronous `querySelector('.impulse-el.selected')`
                // returned null at that moment, so the contains() check missed
                // every nested click and closeModal fired. RAF runs after the
                // body handler completes, so the new selection is reliably set.
                requestAnimationFrame(function() {
                    if (t.classList.contains('selected')) return; // re-selected mid-frame
                    var scrim = document.body.querySelector(':scope > .impulse-modal-scrim[data-modal-owner="' + t.dataset.id + '"]');
                    var newSel = document.querySelector('.impulse-el.selected');
                    if (newSel && scrim && scrim.contains(newSel)) return;
                    closeModal(t);
                });
            });
        });
        observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'], childList: true });
        // On initial load, open any already-selected modals.
        document.querySelectorAll('.impulse-el[data-type="modal"].selected').forEach(openModal);
    })();

    // Legacy one-shot overlay preview listener — kept to avoid breaking any stray caller.
    // New editor-as-runtime architecture renders the modal live in the iframe, so no preview
    // scrim is needed. Handler early-returns to become a no-op.
    window.addEventListener('message', function(e) {
        if (!e.data || e.data.type !== 'impulse-overlay-test') return;
        return; // no-op
        // Dead code below preserved only to minimise diff noise. Remove in cleanup pass.
        // The in-editor card stays undisturbed. A separate full-viewport overlay (the "scrim"
        // below) is the actual preview — it renders at documentElement level with the real
        // position (dialog, side drawer, edge banner) and the chosen entrance animation, so
        // the 3D effects play in the space they were designed for. The user sees exactly
        // what their visitors will see, without the card being clipped by its parent section.
        // Strip any existing preview — user may be clicking dots rapidly.
        var existing = document.getElementById('impulse-overlay-test');
        if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
        var d = e.data;
        var pos = d.position || 'center';
        // Positioning logic mirrors the frontend renderer at templates/impulse-page.php:10519
        var containerPos, sizeStyle, radiusStyle;
        var w = d.width || 500, h = d.height || 0, pad = d.padding || 24, r = d.radius || 8;
        if (pos === 'center') {
            containerPos = 'top:50%;left:50%;transform:translate(-50%,-50%)';
            sizeStyle = 'width:' + w + 'px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px);';
            radiusStyle = 'border-radius:' + r + 'px;';
        } else if (pos === 'center-left') {
            containerPos = 'top:0;left:0';
            sizeStyle = 'width:' + w + 'px;max-width:100vw;height:100vh;';
            radiusStyle = 'border-radius:0 ' + r + 'px ' + r + 'px 0;';
        } else if (pos === 'center-right') {
            containerPos = 'top:0;right:0';
            sizeStyle = 'width:' + w + 'px;max-width:100vw;height:100vh;';
            radiusStyle = 'border-radius:' + r + 'px 0 0 ' + r + 'px;';
        } else if (pos === 'top-center') {
            containerPos = 'top:0;left:0';
            sizeStyle = 'width:100vw;' + (h > 0 ? 'height:' + h + 'px;' : 'min-height:200px;');
            radiusStyle = 'border-radius:0 0 ' + r + 'px ' + r + 'px;';
        } else if (pos === 'bottom-center') {
            containerPos = 'bottom:0;left:0';
            sizeStyle = 'width:100vw;' + (h > 0 ? 'height:' + h + 'px;' : 'min-height:200px;');
            radiusStyle = 'border-radius:' + r + 'px ' + r + 'px 0 0;';
        } else {
            var yE = pos.indexOf('top-') === 0 ? 'top:16px' : 'bottom:16px';
            var xE = pos.lastIndexOf('-left') === pos.length - 5 ? 'left:16px' : 'right:16px';
            containerPos = yE + ';' + xE;
            sizeStyle = 'width:' + w + 'px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px);';
            radiusStyle = 'border-radius:' + r + 'px;';
        }
        // Build markup compatible with impulse-interactions.js runtime animator.
        var scrim = document.createElement('div');
        scrim.id = 'impulse-overlay-test';
        scrim.className = 'impulse-modal';
        scrim.dataset.position = pos;
        scrim.dataset.animation = d.animation || 'depth';
        scrim.dataset.trigger = 'manual';
        scrim.dataset.loop = '0';
        scrim.dataset.closeOverlay = '0';
        scrim.style.cssText = 'display:none;position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;background:' + (d.overlay || 'rgba(0,0,0,0.5)');
        var cont = document.createElement('div');
        cont.className = 'impulse-modal-container';
        // Keep the container visually clean — no border, no shadow, no inherited color rules.
        // Matches how the popup will actually render on the frontend.
        cont.style.cssText = 'position:absolute;' + containerPos + ';' + sizeStyle + 'background:' + (d.bg || '#fff') + ';' + radiusStyle + 'padding:' + pad + 'px;box-sizing:border-box;overflow-y:auto;border:none;outline:none;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#1d2327;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;';
        scrim.appendChild(cont);
        // Attach to documentElement (html) rather than body so no body-level transform or
        // overflow chain can clip the fixed-positioned scrim.
        (document.documentElement || document.body).appendChild(scrim);
        // Minimal inline runner so we don't need to reach into the interactions IIFE. Mirrors
        // the hidden/visible state logic from impulse-interactions.js but stripped to the bits
        // this preview needs (no parallax tracking — click another dot to resample).
        var edges = ['top-center','bottom-center','center-left','center-right'];
        var isEdge = edges.indexOf(pos) !== -1;
        var isCorner = pos.indexOf('-') !== -1 && !isEdge && pos !== 'center';
        var cp = (pos === 'center') ? 'translate(-50%,-50%) ' : '';
        var hiddenTransform, hiddenFilter = '', hiddenOpacity = '0';
        if (d.animation === 'depth')           hiddenTransform = cp + 'perspective(1200px) translateZ(-420px)', hiddenFilter = 'blur(14px)';
        else if (d.animation === 'tilt-fall')  hiddenTransform = cp + 'perspective(1200px) rotateZ(-6deg) translateY(-44px)';
        else if (d.animation === 'glass')      hiddenTransform = cp + 'scale(1.04)', hiddenFilter = 'blur(20px) saturate(1.3)';
        else                                    hiddenTransform = cp + 'perspective(1200px) translateZ(-120px)', hiddenFilter = 'blur(4px)';
        var visibleTransform = (isEdge || isCorner) ? 'translate(0,0)' : 'translate(-50%,-50%) scale(1)';
        scrim.style.display = 'block';
        scrim.style.opacity = '0';
        cont.style.opacity = hiddenOpacity;
        cont.style.transform = hiddenTransform;
        cont.style.filter = hiddenFilter;
        // Trigger transition to visible state. rAF is the canonical way, but we also set a
        // safety-net timeout — in some edge cases (backgrounded tab, renderer hiccup) rAF
        // can be deferred long enough that the user never sees the animation. The timeout
        // guarantees the settled state appears within ~80ms either way.
        // Generous timing — the 3D effects need space AND time. Snap-fast previews make the
        // user question whether anything happened.
        var curve = d.animation === 'tilt-fall' ? 'cubic-bezier(0.4, 0, 0.2, 1)' : 'cubic-bezier(0.4, 0, 0.2, 1)';
        var _showDone = false;
        function _triggerShow() {
            if (_showDone) return;
            _showDone = true;
            scrim.style.transition = 'opacity 0.35s ease-in-out';
            scrim.style.opacity = '1';
            cont.style.transition = 'opacity 0.75s ease-in-out, transform 1.15s ' + curve + ', filter 0.9s ease-in-out';
            cont.style.opacity = '1';
            cont.style.transform = visibleTransform;
            cont.style.filter = '';
        }
        requestAnimationFrame(_triggerShow);
        setTimeout(_triggerShow, 80);
        // Hold for 3.5s after entrance completes — total ~5s lifecycle — then fade out.
        setTimeout(function() {
            scrim.style.transition = 'opacity 0.3s ease-in-out';
            scrim.style.opacity = '0';
            setTimeout(function() { if (scrim.parentNode) scrim.parentNode.removeChild(scrim); }, 350);
        }, 4500);
    });

    // Listen for divider element reorder messages (instant DOM manipulation)
    window.addEventListener('message', function(e) {
        if (!e.data || e.data.type !== 'impulse-update-chat-styles') return;
        var s = e.data.settings;
        if (!s) return;

        // Build CSS string matching the frontend chat widget selectors
        var bw = parseInt(s.input_border_width) || 1;
        var br = parseInt(s.input_border_radius) || 4;
        var css = [
            '.instinctor-chat-header { background: ' + s.color_header + ' !important; }',
            '.instinctor-title { color: ' + s.color_header_text + ' !important; }',
            '.instinctor-status { color: ' + s.color_header_text + ' !important; }',
            '.instinctor-avatar { background: ' + s.color_avatar_circle + ' !important; }',
            '.instinctor-start-btn { background: ' + s.color_header + ' !important; color: ' + s.color_header_text + ' !important; }',
            '.instinctor-messages { background: ' + s.color_background + ' !important; max-height: ' + s.widget_height + 'px !important; }',
            '.instinctor-greeting { background: ' + s.color_agent_bubble + ' !important; }',
            '.instinctor-msg.visitor { background: ' + s.color_visitor_bubble + ' !important; }',
            '.instinctor-msg.admin { background: ' + s.color_agent_bubble + ' !important; }',
            '.instinctor-typing { background: ' + s.color_agent_bubble + ' !important; }',
            '.instinctor-input-wrap { background: ' + s.color_input_bg + ' !important; border-top-color: ' + s.color_input_border + ' !important; }',
            '.instinctor-prechat-form { border-top-color: ' + s.color_input_border + ' !important; }',
            '.instinctor-prechat-input { background: ' + s.color_input_field + ' !important; border-color: ' + s.color_input_border + ' !important; border-width: ' + bw + 'px !important; }',
            '#instinctor-input { background: ' + s.color_input_field + ' !important; border-color: ' + s.color_input_border + ' !important; border-width: ' + bw + 'px !important; border-radius: ' + br + 'px !important; }',
            '.instinctor-chat-trigger { background: ' + s.color_button + ' !important; }',
            '.instinctor-screenshot .instinctor-icon, .instinctor-attach .instinctor-icon { color: ' + s.color_icons + ' !important; }',
            '.instinctor-send .instinctor-icon { color: ' + s.color_send_icon + ' !important; }'
        ].join('\n');

        var existing = document.getElementById('instinctor-chat-live-override');
        if (existing) {
            existing.textContent = css;
        } else {
            var style = document.createElement('style');
            style.id = 'instinctor-chat-live-override';
            style.textContent = css;
            document.head.appendChild(style);
        }
    });

    // Listen for divider element reorder messages (instant DOM manipulation)
    window.addEventListener('message', function(e) {
        if (!e.data || e.data.type !== 'impulse-divider-reorder') return;
        
        var id = e.data.elementId;
        var align = e.data.align;
        
        
        var el = document.querySelector('.impulse-el[data-id="' + id + '"]');
        if (!el) {
            return;
        }
        
        // Find the flex container (could be direct child or inside link)
        var flexContainer = el.querySelector('.impulse-divider > div, .impulse-divider > a > div');
        if (!flexContainer) {
            return;
        }
        
        // Get all children
        var children = Array.from(flexContainer.children);
        var hrs = children.filter(function(c) { return c.tagName === 'HR'; });
        var element = children.find(function(c) { return c.tagName !== 'HR'; });
        
        if (!element || hrs.length === 0) {
            return;
        }
        
        // Clear container
        flexContainer.innerHTML = '';
        
        // Clone HRs to avoid issues
        var hr1 = hrs[0].cloneNode(true);
        var hr2 = hrs.length > 1 ? hrs[1].cloneNode(true) : hrs[0].cloneNode(true);
        
        // Rebuild based on alignment
        if (align === 'left') {
            // Element first, then one line
            flexContainer.appendChild(element);
            flexContainer.appendChild(hr1);
        } else if (align === 'right') {
            // Line first, then element
            flexContainer.appendChild(hr1);
            flexContainer.appendChild(element);
        } else {
            // Center: line, element, line
            flexContainer.appendChild(hr1);
            flexContainer.appendChild(element);
            flexContainer.appendChild(hr2);
        }
        
    });
})();

    // ── Quickview Modal Preview (Impulse editor: Modal tab) ──
    window.addEventListener('message', function(e) {
        if (!e.data || !e.data.type) return;

        if (e.data.type === 'impulse-quickview-preview-open') {
            // If already visible, just ensure preview class is applied
            var existingOverlay = document.querySelector('.impulse-quickview-overlay');
            if (existingOverlay && existingOverlay.classList.contains('active')) {
                existingOverlay.classList.add('impulse-editor-qv-preview');
                return;
            }

            // Find the first real product card and trigger open
            var card = document.querySelector('.impulse-pcard[data-product-id]');
            // Wait for ImpulseQuickView to be ready (frontend.js deferred)
            function doOpen() {
                if (!window.ImpulseQuickView) return;
                var productId = parseInt(card ? card.getAttribute('data-product-id') : 0) || 0;
                if (productId > 0) {
                    window.ImpulseQuickView.open(productId);
                } else if (card) {
                    window.ImpulseQuickView.openPreview($(card));
                }
            }
            if (window.ImpulseQuickView) {
                doOpen();
            } else {
                // frontend.js still loading — retry after short delay
                var _qvRetry = 0;
                var _qvWait = setInterval(function() {
                    if (window.ImpulseQuickView || ++_qvRetry > 20) {
                        clearInterval(_qvWait);
                        doOpen();
                    }
                }, 100);
            }
            // Mark overlay as editor-preview so overlay-click and Escape don't close
            setTimeout(function() {
                var overlay = document.querySelector('.impulse-quickview-overlay');
                if (overlay) overlay.classList.add('impulse-editor-qv-preview');
            }, 450);
        }

        if (e.data.type === 'impulse-quickview-preview-close') {
            var overlay = document.querySelector('.impulse-quickview-overlay');
            if (overlay) overlay.classList.remove('active', 'impulse-editor-qv-preview');
        }

        if (e.data.type === 'impulse-update-quickview-styles') {
            var s = e.data.settings || {};
            function sv(val, def) { var n = parseInt(val); return isNaN(n) ? def : n; }
            // Modal shadow maps from card shadow — same family, stronger values for large modal
            var shadowMap = {
                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)'
            };
            var shadow = shadowMap[s.card_shadow] || shadowMap.medium;
            var css = [
                '.impulse-quickview-overlay { background: rgba(0,0,0,' + (sv(s.overlay_opacity, 60) / 100) + ') !important; }',
                '.impulse-quickview-modal { border-radius: ' + sv(s.border_radius, 12) + 'px !important; max-width: ' + sv(s.max_width, 900) + 'px !important; max-height: ' + sv(s.height, 90) + 'vh !important; box-shadow: ' + shadow + ' !important; }',
                '.impulse-quickview-gallery { background: ' + (s.card_bg || '#ffffff') + ' !important; }',
                '.impulse-quickview-details { padding: ' + sv(s.details_padding, 32) + 'px !important; background: ' + (s.card_bg || '#ffffff') + ' !important; }',
                '.impulse-quickview-desc { font-size: ' + sv(s.desc_size, 14) + 'px !important; }',
                // Visibility toggles
                '.impulse-quickview-desc { display: ' + (s.show_desc !== false ? 'block' : 'none') + ' !important; }',
                '.impulse-quickview-sku { display: ' + (s.show_sku !== false ? 'block' : 'none') + ' !important; }',
                '.impulse-quickview-price { display: ' + (s.show_price !== false ? 'block' : 'none') + ' !important; }',
                '.impulse-quickview-qty-control { display: ' + (s.show_qty !== false ? 'flex' : 'none') + ' !important; }'
            ].join('\n');

            var existing = document.getElementById('impulse-qv-live-override');
            if (existing) {
                existing.textContent = css;
            } else {
                var style = document.createElement('style');
                style.id = 'impulse-qv-live-override';
                style.textContent = css;
                document.head.appendChild(style);
            }
        }
    });


// Tabs element interactivity - use capturing phase to run before element selection
(function() {
    document.addEventListener('click', function(e) {
        var tabBtn = e.target.closest('.impulse-tab-btn');
        if (!tabBtn) return;
        
        e.stopPropagation();
        e.preventDefault();
        
        var tabs = tabBtn.closest('.impulse-tabs');
        if (!tabs) return;
        
        var idx = tabBtn.dataset.tab;
        var inactiveBg = tabs.dataset.inactiveBg || '#f5f5f5';
        var activeBg = tabs.dataset.activeBg || '#ffffff';
        var textColor = tabs.dataset.textColor || '#666666';
        var borderColor = tabs.dataset.borderColor || '#e0e0e0';
        var isHorizontal = tabs.dataset.style === 'horizontal';
        
        // Update buttons
        tabs.querySelectorAll('.impulse-tab-btn').forEach(function(btn) {
            var isActive = btn.dataset.tab === idx;
            btn.classList.toggle('active', isActive);
            btn.style.background = isActive ? activeBg : inactiveBg;
            btn.style.color = textColor;
            if (isHorizontal) {
                btn.style.zIndex = isActive ? '2' : '1';
                btn.style.borderBottom = isActive ? 'none' : '1px solid ' + borderColor;
            }
        });
        
        // Update panels with fade animation
        tabs.querySelectorAll('.impulse-tab-panel').forEach(function(panel) {
            var isActive = panel.dataset.tab === idx;
            if (isActive && !panel.classList.contains('active')) {
                // Fade in new panel
                panel.style.opacity = '0';
                panel.style.display = '';
                panel.offsetHeight; // Force reflow
                panel.style.transition = 'opacity 0.2s ease-in-out';
                panel.style.opacity = '1';
            } else if (!isActive && panel.classList.contains('active')) {
                // Hide old panel
                panel.style.display = 'none';
            }
            panel.classList.toggle('active', isActive);
            panel.style.background = isActive ? activeBg : inactiveBg;
        });
    }, true);
})();

// Accordion scroll-mode engine (Stack / Rolodex / Depth Lane)
(function() {
    var ease = 'cubic-bezier(0.4, 0, 0.2, 1)';

    document.querySelectorAll('.impulse-unfold-scroll').forEach(function(acc) {
        var cards = Array.prototype.slice.call(acc.querySelectorAll('.impulse-unfold-card'));
        var stage = acc.querySelector('.impulse-unfold-stage');
        var anim = acc.dataset.anim;
        var total = cards.length;
        if (total < 2) return;
        
        var active = 0;
        var transitioning = false;
        var wheelAccum = 0;
        var wheelThreshold = 80;
        
        function measureHeight() {
            var fixedH = parseInt(acc.dataset.height) || 0;
            if (fixedH > 0) {
                stage.style.height = fixedH + 'px';
                cards.forEach(function(c) {
                    c.style.position = 'absolute';
                    c.style.top = '0';
                    c.style.left = '0';
                    c.style.width = '100%';
                    c.style.height = fixedH + 'px';
                    c.style.overflowY = 'hidden';
                });
            } else {
                var maxH = 0;
                cards.forEach(function(c) {
                    c.style.position = 'relative';
                    c.style.opacity = '1';
                    c.style.transform = 'none';
                    c.style.height = '';
                    c.style.overflowY = '';
                    var h = c.offsetHeight;
                    if (h > maxH) maxH = h;
                });
                stage.style.height = maxH + 'px';
                cards.forEach(function(c) {
                    c.style.position = 'absolute';
                    c.style.top = '0';
                    c.style.left = '0';
                    c.style.width = '100%';
                });
            }
            positionCards(false);
        }
        
        function positionCards(animate) {
            var dur = animate ? '0.55s' : '0s';
            cards.forEach(function(card, i) {
                // Circular offset for infinite loop
                var offset = i - active;
                if (offset > total / 2) offset -= total;
                if (offset < -total / 2) offset += total;
                
                card.style.transition = animate ? 'transform ' + dur + ' ' + ease + ', opacity 0.4s ease-in-out, filter 0.4s ease-in-out' : 'none';
                
                if (anim === 'rolodex') {
                    // Rolodex: vertical carousel — cards peek above/below, 2D for clip safety
                    if (offset === 0) {
                        card.style.transform = 'translateY(0) scale(1)';
                        card.style.opacity = '1';
                        card.style.filter = '';
                        card.style.zIndex = total;
                    } else if (offset < 0) {
                        // Passed: recede into depth — stay centred, scale down, blur, fade.
                        card.style.transform = 'translateY(0) scale(0.55)';
                        card.style.opacity = '0';
                        card.style.filter = 'blur(8px)';
                        card.style.zIndex = 0;
                    } else if (offset <= 2) {
                        // Upcoming: peek below with scale-down
                        var ty = offset * 14;
                        var sd = Math.max(0.75, 1 - offset * 0.12);
                        card.style.transform = 'translateY(' + ty + '%) scale(' + sd + ')';
                        card.style.opacity = Math.max(0, 1 - offset * 0.5).toFixed(2);
                        card.style.filter = 'blur(' + (offset * 3) + 'px)';
                        card.style.zIndex = total - offset;
                    } else {
                        card.style.transform = 'translateY(60%) scale(0.7)';
                        card.style.opacity = '0';
                        card.style.zIndex = 0;
                    }
                } else if (anim === 'depth') {
                    // Depth Lane: horizontal slide cascade — cards fan right and down, no Z
                    if (offset === 0) {
                        card.style.transform = 'translateX(0) translateY(0) scale(1)';
                        card.style.opacity = '1';
                        card.style.filter = '';
                        card.style.zIndex = total;
                    } else if (offset < 0) {
                        // Passed: slide fully left off-screen
                        card.style.transform = 'translateX(-100%) scale(0.85)';
                        card.style.opacity = '0';
                        card.style.filter = '';
                        card.style.zIndex = 0;
                    } else {
                        // Upcoming: fan right + down, progressive scale-down + blur
                        var tx = offset * 18;
                        var ty = offset * 4;
                        var sd = Math.max(0.6, 1 - offset * 0.1);
                        var blur = Math.min(offset * 4, 10);
                        card.style.transform = 'translateX(' + tx + '%) translateY(' + ty + '%) scale(' + sd + ')';
                        card.style.opacity = Math.max(0, 1 - offset * 0.25).toFixed(2);
                        card.style.filter = blur > 0 ? 'blur(' + blur + 'px)' : '';
                        card.style.zIndex = total - offset;
                    }
                }
            });
        }
        
        function goTo(idx) {
            if (idx === active || transitioning) return;
            transitioning = true;
            // Wrap around for infinite loop
            active = ((idx % total) + total) % total;
            acc.dataset.active = active;
            positionCards(true);
            // Update numbered nav
            var nums = acc.querySelectorAll('.impulse-acc-stack-num');
            nums.forEach(function(n) {
                var isAct = parseInt(n.dataset.idx) === active;
                n.classList.toggle('active', isAct);
                n.style.opacity = isAct ? '1' : '0.35';
                n.style.fontWeight = isAct ? '600' : '400';
            });
            setTimeout(function() { transitioning = false; }, 550);
        }
        
        // Nav buttons (up/down arrows)
        var prevBtn = acc.querySelector('.impulse-acc-stack-prev');
        var nextBtn = acc.querySelector('.impulse-acc-stack-next');
        if (prevBtn) prevBtn.addEventListener('click', function(e) { e.stopPropagation(); goTo(active - 1); });
        if (nextBtn) nextBtn.addEventListener('click', function(e) { e.stopPropagation(); goTo(active + 1); });
        // Numbered nav — click to jump
        acc.querySelectorAll('.impulse-acc-stack-num').forEach(function(n) {
            n.addEventListener('click', function(e) { e.stopPropagation(); goTo(parseInt(n.dataset.idx)); });
        });
        
        // Wheel handler — only intercepts when pointer is over the element
        var isHovered = false;
        acc.addEventListener('mouseenter', function() { isHovered = true;  wheelAccum = 0; });
        acc.addEventListener('mouseleave', function() { isHovered = false; wheelAccum = 0; });
        
        acc.addEventListener('wheel', function(e) {
            if (!isHovered) return; // let page scroll pass through
            e.preventDefault();
            e.stopPropagation();
            wheelAccum += e.deltaY;
            if (Math.abs(wheelAccum) >= wheelThreshold) {
                goTo(active + (wheelAccum > 0 ? 1 : -1));
                wheelAccum = 0;
            }
        }, { passive: false });
        
        // Touch support
        var touchStartY = 0;
        acc.addEventListener('touchstart', function(e) { touchStartY = e.touches[0].clientY; }, { passive: true });
        acc.addEventListener('touchend', function(e) {
            var dy = touchStartY - e.changedTouches[0].clientY;
            if (Math.abs(dy) > 40) goTo(active + (dy > 0 ? 1 : -1));
        });
        
        // Click to select in panel (skip if already selected).
        // CRITICAL: this is a capture-phase listener. It must NOT call
        // e.stopPropagation() — capture runs container → nav buttons, so
        // stopping here kills every click on the prev/next/number nav this
        // very engine created. Selection still works (we just postMessage),
        // and letting the event continue lets the nav buttons fire.
        acc.addEventListener('click', function(e) {
            var el = acc.closest('.impulse-el');
            if (el && window.parent && !el.classList.contains('selected')) {
                window.parent.postMessage({ type: 'impulse-select', id: el.dataset.id, elementType: el.dataset.type }, '*');
                document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
                el.classList.add('selected');
            }
        }, true);

        measureHeight();
    });
})();

// ====== NEW ACCORDION MODE ENGINES ======
(function() {
    var ease = 'cubic-bezier(0.4, 0, 0.2, 1)';


    // ---- Spotlight Reveal ----
    document.querySelectorAll('.impulse-acc-spotlight').forEach(function(acc) {
        var items = Array.prototype.slice.call(acc.querySelectorAll('.impulse-acc-spot-item'));
        var active = 0;

        // Init: smooth height transitions on all bodies
        items.forEach(function(item, i) {
            var body = item.querySelector('.impulse-acc-spot-body');
            if (!body) return;
            body.style.overflow = 'hidden';
            body.style.transition = 'max-height 0.35s ease-in-out, opacity 0.25s ease-in-out';
            if (i === active) {
                body.style.maxHeight = body.scrollHeight + 'px';
                body.style.opacity = '1';
                setTimeout(function() { body.style.maxHeight = 'none'; }, 350);
            } else {
                body.style.maxHeight = '0';
                body.style.opacity = '0';
            }
        });

        function goTo(idx) {
            if (idx === active) return;
            acc.style.minHeight = acc.offsetHeight + 'px';
            active = idx;
            acc.dataset.active = idx;
            items.forEach(function(item, i) {
                var isAct = i === idx;
                var body  = item.querySelector('.impulse-acc-spot-body');
                item.classList.toggle('active', isAct);
                if (!body) return;
                if (isAct) {
                    body.style.display = '';
                    body.style.maxHeight = '0';
                    body.offsetHeight;
                    body.style.maxHeight = body.scrollHeight + 'px';
                    body.style.opacity = '1';
                    setTimeout(function() { if (item.classList.contains('active')) body.style.maxHeight = 'none'; }, 350);
                } else {
                    if (body.style.maxHeight === 'none') body.style.maxHeight = body.scrollHeight + 'px';
                    body.offsetHeight;
                    body.style.maxHeight = '0';
                    body.style.opacity = '0';
                }
            });
            setTimeout(function() { acc.style.minHeight = ''; }, 400);
        }

        items.forEach(function(item, i) {
            item.addEventListener('click', function(e) {
                e.stopPropagation();
                var el = acc.closest('.impulse-el');
                if (el && window.parent && !el.classList.contains('selected')) {
                    window.parent.postMessage({ type: 'impulse-select', id: el.dataset.id, elementType: el.dataset.type }, '*');
                    document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
                    el.classList.add('selected');
                }
                goTo(i);
            }, true);
        });
        
        // Wheel: cycle through items, prevent page scroll
        var spotWheelAccum = 0;
        acc.addEventListener('wheel', function(e) {
            e.preventDefault();
            e.stopPropagation();
            spotWheelAccum += e.deltaY;
            if (Math.abs(spotWheelAccum) >= 80) {
                var next = active + (spotWheelAccum > 0 ? 1 : -1);
                if (next < 0) next = items.length - 1;
                if (next >= items.length) next = 0;
                goTo(next);
                spotWheelAccum = 0;
            }
        }, { passive: false });
    });

    // ---- Tiles Mode ----
    document.querySelectorAll('.impulse-acc-tiles').forEach(function(acc) {
        var tiles = Array.prototype.slice.call(acc.querySelectorAll('.impulse-acc-tile'));
        tiles.forEach(function(tile) {
            tile.addEventListener('click', function(e) {
                e.stopPropagation();
                var el = acc.closest('.impulse-el');
                if (el && window.parent && !el.classList.contains('selected')) {
                    window.parent.postMessage({ type: 'impulse-select', id: el.dataset.id, elementType: el.dataset.type }, '*');
                    document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
                    el.classList.add('selected');
                }
            });
        });
    });

})();

// Toggle element interactivity - use capturing phase to run before element selection
(function() {
    document.addEventListener('click', function(e) {
        var header = e.target.closest('.impulse-toggle-header');
        if (!header) return;
        
        // Prevent event from bubbling to element selection
        e.stopPropagation();
        
        var toggle = header.closest('.impulse-toggle');
        if (!toggle) return;
        
        // Also trigger settings panel - find the parent .impulse-el and send select message
        var el = toggle.closest('.impulse-el');
        if (el && window.parent) {
            var id = el.dataset.id;
            var type = el.dataset.type;
            if (id) {
                window.parent.postMessage({ type: 'impulse-select', id: id, elementType: type }, '*');
                document.querySelectorAll('.impulse-el').forEach(function(x) { x.classList.remove('selected'); });
                el.classList.add('selected');
            }
        }
        
        var content = toggle.querySelector('.impulse-toggle-content');
        var icon = toggle.querySelector('.impulse-toggle-icon');
        var isOpen = toggle.classList.contains('open');
        
        if (isOpen) {
            toggle.classList.remove('open');
            if (content) content.style.display = 'none';
            if (icon) icon.style.transform = '';
        } else {
            toggle.classList.add('open');
            if (content) content.style.display = '';
            if (icon) icon.style.transform = 'rotate(180deg)';
        }
    }, true); // Use capturing phase
})();

// Counter animation

// Countdown engine — all 5 display modes
(function() {
    var CIRC = 2 * Math.PI * 42;
    
    function initCountdown(el) {
        var display = el.dataset.display || 'blocks';
        var cmode = el.dataset.cmode || 'evergreen';
        var anim = el.dataset.anim || 'fade';
        var urgencyMins = parseInt(el.dataset.urgency) || 0;
        var urgencyColor = el.dataset.urgencyColor || '#555555';
        var prev = {};
        
        function getDiff() {
            if (cmode === 'clock') return -1;
            if (cmode === 'evergreen') {
                var dur = parseInt(el.dataset.duration) || 0;
                var start = parseInt(el.dataset.start) || Date.now();
                return dur - (Date.now() - start);
            }
            var t = el.dataset.target;
            if (!t) return 0;
            return new Date(t.replace(' ', 'T')) - new Date();
        }
        
        function handleExpired() {
            if (cmode === 'evergreen') {
                // Restart — reset start time
                el.dataset.start = String(Date.now());
                return;
            }
            // Fixed mode — hide
            el.style.transition = 'opacity 0.5s ease-in-out'; el.style.opacity = '0';
            setTimeout(function() { el.style.display = 'none'; }, 500);
        }
        
        // Effective animation: flip display uses overlay animation,
        // slot uses its own, minimal/rings always fade, others use selected anim
        var effectiveAnim = anim;
        if (display === 'rings') effectiveAnim = 'fade';
        
        function animateDigit(unitEl, newVal) {
            if (!unitEl) return;
            
            if (display === 'flip' && anim === 'flip') {
                var card = unitEl.querySelector('.impulse-cd-card');
                if (!card) return;
                var topHalf = card.querySelector('.impulse-cd-card-top');
                var botHalf = card.querySelector('.impulse-cd-card-bottom');
                var topSpan = topHalf ? topHalf.querySelector('span') : null;
                var botSpan = botHalf ? botHalf.querySelector('span') : null;
                if (!topSpan) return;
                var oldVal = topSpan.textContent;
                if (oldVal === newVal) return;
                
                var h = card.offsetHeight / 2;
                var bg = getComputedStyle(topHalf).backgroundColor || '#f5f5f5';
                var radius = parseInt(getComputedStyle(card).getPropertyValue('--cd-radius')) || 8;
                var font = getComputedStyle(card).getPropertyValue('--cd-font').trim() || topSpan.style.fontFamily || 'inherit';
                var sz = topSpan.style.fontSize;
                var fw = topSpan.style.fontWeight;
                var fc = topSpan.style.color;
                var rPx = radius + 'px';
                
                // Remove previous overlays if still animating
                card.querySelectorAll('.impulse-cd-flip-overlay').forEach(function(o) { o.remove(); });
                
                // Top overlay: shows OLD value, folds down
                var topOv = document.createElement('div');
                topOv.className = 'impulse-cd-flip-overlay impulse-cd-flip-top-overlay';
                topOv.style.cssText = 'position:absolute;left:0;right:0;top:0;height:' + h + 'px;background:' + bg + ';border-radius:' + rPx + ' ' + rPx + ' 0 0;border-bottom:1px solid rgba(0,0,0,0.12);box-shadow:0 2px 4px rgba(0,0,0,0.06);z-index:3;overflow:hidden;backface-visibility:hidden;transform-origin:bottom center;transform:rotateX(0deg)';
                topOv.innerHTML = '<span style="position:absolute;left:50%;top:100%;transform:translate(-50%,-50%);font-family:' + font + ';font-size:' + sz + ';font-weight:' + fw + ';color:' + fc + ';white-space:nowrap">' + oldVal + '</span>';
                
                // Bottom overlay: shows NEW value, starts folded up, unfolds
                var botOv = document.createElement('div');
                botOv.className = 'impulse-cd-flip-overlay impulse-cd-flip-bottom-overlay';
                botOv.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:' + h + 'px;background:' + bg + ';border-radius:0 0 ' + rPx + ' ' + rPx + ';box-shadow:0 2px 4px rgba(0,0,0,0.06);z-index:2;overflow:hidden;backface-visibility:hidden;transform-origin:top center;transform:rotateX(90deg)';
                botOv.innerHTML = '<span style="position:absolute;left:50%;top:0;transform:translate(-50%,-50%);font-family:' + font + ';font-size:' + sz + ';font-weight:' + fw + ';color:' + fc + ';white-space:nowrap">' + newVal + '</span>';
                
                card.appendChild(topOv);
                card.appendChild(botOv);
                
                // Update top half text (hidden behind top overlay)
                topSpan.textContent = newVal;
                
                // Force reflow — browser must compute initial styles before transition
                void topOv.offsetHeight;
                
                // Animate
                topOv.style.transition = 'transform 0.3s ease-in-out';
                topOv.style.transform = 'rotateX(-90deg)';
                setTimeout(function() {
                    if (botSpan) botSpan.textContent = newVal;
                    botOv.style.transition = 'transform 0.3s ease-in-out';
                    botOv.style.transform = 'rotateX(0deg)';
                }, 250);
                setTimeout(function() {
                    topOv.remove();
                    botOv.remove();
                }, 650);
                
            } else if (display === 'slot') {
                var win = unitEl.querySelector('.impulse-cd-slot-window');
                var cur = unitEl.querySelector('.impulse-cd-slot-current');
                if (!cur || !win) return;
                if (cur.textContent === newVal) return;
                var h = win.offsetHeight;
                var cs = getComputedStyle(cur);
                var font = cs.fontFamily;
                var sz = cs.fontSize;
                var fw = cs.fontWeight;
                var fc = cs.color;
                
                // Outgoing: starts centered, slides up out of view
                var outgoing = document.createElement('span');
                outgoing.className = 'impulse-cd-slot-anim';
                outgoing.style.cssText = 'font-family:' + font + ';font-size:' + sz + ';font-weight:' + fw + ';color:' + fc + ';top:50%;transform:translateY(-50%);transition:transform 0.4s ease-in-out,opacity 0.3s ease-in-out';
                outgoing.textContent = cur.textContent;
                
                // Incoming: starts below, slides up to center
                var incoming = document.createElement('span');
                incoming.className = 'impulse-cd-slot-anim';
                incoming.style.cssText = 'font-family:' + font + ';font-size:' + sz + ';font-weight:' + fw + ';color:' + fc + ';top:50%;transform:translateY(calc(-50% + ' + h + 'px));transition:transform 0.4s ease-in-out,opacity 0.3s ease-in-out';
                incoming.textContent = newVal;
                
                cur.style.opacity = '0';
                win.appendChild(outgoing); win.appendChild(incoming);
                
                requestAnimationFrame(function() {
                    outgoing.style.transform = 'translateY(calc(-50% - ' + h + 'px))'; outgoing.style.opacity = '0';
                    incoming.style.transform = 'translateY(-50%)';
                });
                setTimeout(function() {
                    cur.textContent = newVal; cur.style.opacity = '1';
                    outgoing.remove(); incoming.remove();
                }, 450);
                
            } else if (effectiveAnim === 'flip') {
                // Flip animation on blocks mode only (never on minimal/rings)
                var valEls = unitEl.querySelectorAll('.impulse-cd-value');
                valEls.forEach(function(v) {
                    if (v.textContent === newVal) return;
                    v.style.transition = 'none';
                    v.style.transform = 'perspective(200px) rotateX(-90deg)'; v.style.opacity = '0';
                    v.textContent = newVal;
                    void v.offsetHeight;
                    requestAnimationFrame(function() {
                        v.style.transition = 'transform 0.35s ease-in-out, opacity 0.25s ease-in-out';
                        v.style.transform = 'perspective(200px) rotateX(0)'; v.style.opacity = '1';
                    });
                });
            } else if (effectiveAnim === 'pulse') {
                var valEls = unitEl.querySelectorAll('.impulse-cd-value');
                valEls.forEach(function(v) {
                    if (v.textContent === newVal) return;
                    v.textContent = newVal;
                    v.style.transition = 'none'; v.style.transform = 'scale(1.15)';
                    void v.offsetHeight;
                    requestAnimationFrame(function() {
                        v.style.transition = 'transform 0.3s ease-in-out'; v.style.transform = 'scale(1)';
                    });
                });
            } else if (effectiveAnim === 'slide') {
                var valEls = unitEl.querySelectorAll('.impulse-cd-value');
                valEls.forEach(function(v) {
                    if (v.textContent === newVal) return;
                    v.style.transition = 'none'; v.style.transform = 'translateY(-100%)'; v.style.opacity = '0';
                    v.textContent = newVal;
                    void v.offsetHeight;
                    requestAnimationFrame(function() {
                        v.style.transition = 'transform 0.3s ease-in-out, opacity 0.2s ease-in-out';
                        v.style.transform = 'translateY(0)'; v.style.opacity = '1';
                    });
                });
            } else {
                // Fade (default for minimal, rings, and fallback)
                var valEls = unitEl.querySelectorAll('.impulse-cd-value');
                valEls.forEach(function(v) {
                    if (v.textContent === newVal) return;
                    v.style.transition = 'opacity 0.15s ease-in-out'; v.style.opacity = '0';
                    setTimeout(function() { v.textContent = newVal; v.style.opacity = '1'; }, 150);
                });
            }
        }
        
        function updateRing(unitEl, value, max) {
            var p = unitEl.querySelector('.impulse-cd-ring-progress');
            if (!p) return;
            p.setAttribute('stroke-dashoffset', CIRC * (1 - Math.max(0, Math.min(1, value / max))));
        }
        
        
        function update() {
            var vals;
            if (cmode === 'clock') {
                var now = new Date();
                vals = { days: String(now.getDate()).padStart(2,'0'), hours: String(now.getHours()).padStart(2,'0'), minutes: String(now.getMinutes()).padStart(2,'0'), seconds: String(now.getSeconds()).padStart(2,'0') };
            } else {
                var diff = getDiff();
                if (diff <= 0) { handleExpired(); if (cmode !== 'evergreen') return false; return true; }
                var d = Math.floor(diff / 864e5);
                var h = Math.floor(diff % 864e5 / 36e5);
                var m = Math.floor(diff % 36e5 / 6e4);
                var s = Math.floor(diff % 6e4 / 1e3);
                vals = { days: String(d).padStart(2,'0'), hours: String(h).padStart(2,'0'), minutes: String(m).padStart(2,'0'), seconds: String(s).padStart(2,'0') };
            }
            
            var changed = false;
            el.querySelectorAll('.impulse-cd-unit').forEach(function(u) {
                var unit = u.dataset.unit, nv = vals[unit];
                if (!nv) return;
                if (prev[unit] !== nv) { animateDigit(u, nv); changed = true; }
                prev[unit] = nv;
                if (display === 'rings') { var mx = cmode === 'clock' ? (u.dataset.unit === 'hours' ? 24 : u.dataset.unit === 'days' ? 31 : 60) : (parseInt(u.dataset.max) || 60); updateRing(u, parseInt(nv), mx); }
            });
            
            if (cmode !== 'clock' && urgencyMins > 0 && diff / 60000 <= urgencyMins && !el.classList.contains('impulse-cd-urgent')) {
                el.classList.add('impulse-cd-urgent');
                el.querySelectorAll('.impulse-cd-value').forEach(function(v) { v.style.color = urgencyColor; });
            }
            return true;
        }
        
        // Seed initial values without animation, then tick
        function seed() {
            var vals;
            if (cmode === 'clock') {
                var now = new Date();
                vals = { days: String(now.getDate()).padStart(2,'0'), hours: String(now.getHours()).padStart(2,'0'), minutes: String(now.getMinutes()).padStart(2,'0'), seconds: String(now.getSeconds()).padStart(2,'0') };
            } else {
                var diff = getDiff();
                if (diff <= 0) { handleExpired(); return; }
                var d = Math.floor(diff / 864e5), h = Math.floor(diff % 864e5 / 36e5), m = Math.floor(diff % 36e5 / 6e4), s = Math.floor(diff % 6e4 / 1e3);
                vals = { days: String(d).padStart(2,'0'), hours: String(h).padStart(2,'0'), minutes: String(m).padStart(2,'0'), seconds: String(s).padStart(2,'0') };
            }
            el.querySelectorAll('.impulse-cd-unit').forEach(function(u) {
                var unit = u.dataset.unit, nv = vals[unit];
                if (!nv) return;
                prev[unit] = nv;
                if (display === 'slot') {
                    var c = u.querySelector('.impulse-cd-slot-current');
                    if (c) { c.textContent = nv; c.style.opacity = '1'; }
                } else if (display === 'flip') {
                    var ts = u.querySelector('.impulse-cd-card-top span'), bs = u.querySelector('.impulse-cd-card-bottom span');
                    if (ts) ts.textContent = nv;
                    if (bs) bs.textContent = nv;
                } else {
                    u.querySelectorAll('.impulse-cd-value').forEach(function(v) { v.textContent = nv; });
                }
            });
        }
        seed();
        // Always 1s interval — separators blink independently
        var iv = setInterval(function() { if (!update()) { clearInterval(iv); } }, 1000);
    }
    
    document.querySelectorAll('.impulse-cd').forEach(initCountdown);
})();

// Alert dismiss
(function() {
    document.addEventListener('click', function(e) {
        var btn = e.target.closest('.impulse-alert-dismiss');
        if (!btn) return;
        
        var alert = btn.closest('.impulse-alert');
        if (alert) {
            alert.style.display = 'none';
        }
    });
})();

// Flip box hover effect — ported from templates/impulse-page.php
(function(){
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)}
function initFlipBox(box){
// Once per NODE: the reinit path now sweeps every .impulse-flip-box inside
// any swapped element, so an untouched sibling box could get inited twice -
// duplicate hover listeners double-fire the effect. Swapped-in DOM is fresh
// (no flag), so re-renders still re-arm correctly.
if(box._fbEngine)return;box._fbEngine=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;
var a=box.dataset.animation||"spring",fd=box.dataset.flipDirection||"horizontal",sd=box.dataset.slideDirection||"left",spd=parseInt(box.dataset.speed)||600;
function _sh(ms){}
// Shatter init — resolve shard color through front→back→fallback so a
// transparent computed bg never produces invisible shards.
// Back is opaque from the start (PHP no longer sets opacity:0 on shatter
// case). When front fades + shards fly, back is naturally revealed —
// no separate fade-in step. setTimeout(0) lets the browser commit the
// pre-state before the transition target so the animation actually plays.
// Slide strips
// Cursor tracking (void uses _vx/_vy for transform-origin)
// Spring — straight flip with bouncy ease-in-out
if(a==="spring"){box.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)"});box.addEventListener("mouseleave",function(){inner.style.transition="transform "+(spd/1000)+"s ease-in-out";inner.style.transform=""});return}
// magnet (new-gen) — cursor is the magnet; front slides off-card in cursor direction
// Generic mouseenter for the rest
box.addEventListener("mouseenter",function(){
if(a==="cube"){var _ct3=parseFloat(box.dataset.cubeTranslate)||125;inner.style.transform="translateZ(-"+_ct3+"px) rotateX(90deg)"}
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==="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==="zoom"){if(box._zt)clearTimeout(box._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");box._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
box.addEventListener("mouseleave",function(){
if(a==="cube"){var _ct4=parseFloat(box.dataset.cubeTranslate)||125;inner.style.transition="transform 0.5s ease-in-out";inner.style.transform="translateZ(-"+_ct4+"px)"}else if(a==="push"){inner.style.transition="transform 0.5s ease-in-out";inner.style.transform=""}
else if(a==="slideup"){back.style.transform="translateY(100%)"}
else if(a==="zoom"){if(box._zt)clearTimeout(box._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";box._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(box._gt)clearTimeout(box._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"}
});
}
document.querySelectorAll('.impulse-flip-box').forEach(initFlipBox);
window._impulseInitFlipBox = initFlipBox;
})();
// Pricing Table billing toggle
(function() {
    document.querySelectorAll('.impulse-billing-toggle').forEach(function(toggle) {
        toggle.addEventListener('change', function() {
            var wrapper = this.closest('.impulse-pricing-wrapper');
            if (!wrapper) return;
            
            var isYearly = this.checked;
            var knob = this.nextElementSibling.nextElementSibling;
            var trackBg = this.nextElementSibling;
            
            // Move toggle knob
            knob.style.left = isYearly ? '27px' : '3px';
            trackBg.style.background = isYearly ? '#1d2327' : '#ddd';
            
            // Update labels - use opacity for inactive state
            var monthlyLabel = wrapper.querySelector('.impulse-toggle-monthly');
            var yearlyLabel = wrapper.querySelector('.impulse-toggle-yearly');
            if (monthlyLabel) monthlyLabel.style.opacity = isYearly ? '0.5' : '1';
            if (yearlyLabel) yearlyLabel.style.opacity = isYearly ? '1' : '0.5';
            
            // Switch prices
            wrapper.querySelectorAll('.impulse-pricing-card').forEach(function(card) {
                var monthlyPrice = card.querySelector('.impulse-price-monthly');
                var yearlyPrice = card.querySelector('.impulse-price-yearly');
                if (monthlyPrice) monthlyPrice.style.display = isYearly ? 'none' : '';
                if (yearlyPrice) yearlyPrice.style.display = isYearly ? '' : 'none';
            });
        });
    });
    
    // Button hover effect
    document.querySelectorAll('.impulse-pricing-button').forEach(function(btn) {
        btn.addEventListener('mouseenter', function() {
            this.style.transform = 'translateY(-2px)';
            this.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
        });
        btn.addEventListener('mouseleave', function() {
            this.style.transform = '';
            this.style.boxShadow = '';
        });
    });
    
    // Tooltip functionality
    document.querySelectorAll('.impulse-pricing-feature[title]').forEach(function(feature) {
        feature.style.cursor = 'help';
    });
})();

// impulseDelete and impulseDuplicate defined in early script block

// Toolbar button clicks - event delegation for dynamically added elements
//
// Prefer the button's own data-el-id (rendered explicitly by impulse_render_element
// to bind the action to the exact element that owns the toolbar). Fall back to
// closest('.impulse-el') only if the explicit binding is missing (e.g. older
// cached HTML from before data-el-id was added). closest() is more fragile —
// if a nested .impulse-el sneaks into the DOM or the toolbar gets visually
// detached, walking up the tree can land on the wrong ancestor (e.g. the
// hero section instead of the title block inside it) and duplicate the
// wrong thing.
document.addEventListener('click', function(e) {
    var toolbarBtn = e.target.closest('.impulse-el-toolbar button');
    if (!toolbarBtn) return;

    e.stopPropagation();
    e.preventDefault();

    var explicitId = toolbarBtn.getAttribute('data-el-id');
    var id = explicitId ? parseInt(explicitId, 10) : null;
    if (!id) {
        var el = toolbarBtn.closest('.impulse-el');
        if (!el || !el.dataset.id) return;
        id = parseInt(el.dataset.id, 10);
    }
    if (!id) return;

    if (toolbarBtn.classList.contains('impulse-el-delete')) {
        impulseDelete(id);
    } else if (toolbarBtn.classList.contains('impulse-el-duplicate')) {
        impulseDuplicate(id);
    }
}, true); // Use capturing phase

// Impulse Drag and Drop Module v3.0 — jQuery UI Sortable
// Handles: Section drops, Grid cells, Cell Grid cells, Layout Grid cells, reordering, moving between containers
(function() {
    'use strict';
    
    // ========================================
    // CONFIGURATION
    // ========================================
    
    var containerTypes = ['section', 'container', 'row', 'column',  'rows', 'columns', 'stack', 'inline', 'modal'];
    var forbiddenInCells = ['section',  'rows', 'columns', 'slider', 'modal'];
    
    // All sortable container selectors
    // Container selectors are defined inside initSortables()
    
    // ========================================
    // HELPER FUNCTIONS
    // ========================================
    
    function getContainerMeta($container) {
        // Determine container type and parent element ID for postMessage
        var node = $container[0];
        
        // Root wrapper — page root level (parentId: 0)
        if (node.classList.contains('impulse-content-wrapper')) {
            return { parentId: 0, targetType: 'root' };
        }
        
        // Grid cell
        if (node.classList.contains('impulse-grid-cell')) {
            var grid = node.closest('.impulse-el[data-type="grid"]');
            return { parentId: grid ? parseInt(grid.dataset.id) : 0, targetType: 'grid', gridCellIndex: parseInt(node.dataset.cell) || 0 };
        }
        
        // Cell grid cell
        if (node.classList.contains('impulse-rows-cell')) {
            var cellGrid = node.closest('.impulse-el[data-type="rows"]');
            return { parentId: cellGrid ? parseInt(cellGrid.dataset.id) : 0, targetType: 'rows', cellRow: parseInt(node.dataset.row) || 0, cellCol: parseInt(node.dataset.col) || 0 };
        }
        
        // Layout grid cell
        if (node.classList.contains('impulse-layout-cell')) {
            var layoutGrid = node.closest('.impulse-el[data-type="columns"]');
            return { parentId: layoutGrid ? parseInt(layoutGrid.dataset.id) : 0, targetType: 'columns', cellRow: parseInt(node.dataset.row) || 0, cellCol: parseInt(node.dataset.col) || 0 };
        }
        
        // Tab dropzone
        if (node.classList.contains('impulse-tab-dropzone')) {
            return { parentId: parseInt(node.dataset.parent) || 0, targetType: 'tabs' };
        }
        
        // Toggle dropzone
        if (node.classList.contains('impulse-toggle-dropzone')) {
            return { parentId: parseInt(node.dataset.parent) || 0, targetType: 'toggle' };
        }
        
        // Slider dropzone
        if (node.classList.contains('impulse-new-slider-dropzone')) {
            return { parentId: parseInt(node.dataset.parent) || 0, targetType: 'slider', slideIndex: parseInt(node.dataset.slide) || 0 };
        }
        
        // Section container — find parent .impulse-el
        var parentEl = node.closest('.impulse-el');
        if (parentEl) {
            return { parentId: parseInt(parentEl.dataset.id) || 0, targetType: parentEl.dataset.type || 'section' };
        }
        
        return { parentId: 0, targetType: 'section' };
    }
    
    function isDescendant(parentNode, childNode) {
        return parentNode && childNode && parentNode.contains(childNode);
    }
    
    function shouldBlockMove(sourceType, targetType) {
        // Section cannot go into section
        if (sourceType === 'section' && targetType === 'section') return true;
        // Slider cannot go into slider
        if (sourceType === 'slider' && targetType === 'slider') return true;
        // Popup/modal cannot be moved into anything
        if (sourceType === 'modal') return true;
        // Forbidden types inside layout cells
        var layoutTypes = [ 'rows', 'columns'];
        if (layoutTypes.indexOf(targetType) !== -1 && forbiddenInCells.indexOf(sourceType) !== -1) return true;
        return false;
    }

    // Walk up from a DOM node to its enclosing scene-mode "screen" — the
    // top-level .impulse-el[data-type="section"] whose parent is the
    // content-wrapper (or body). Returns null if no screen ancestor.
    // Used by the cross-screen drag guard in sortable's `receive` to
    // prevent canvas drops from silently moving blocks into a hidden
    // sibling screen — which from the user's POV looks like the block
    // simply vanished.
    function _findScreenContainer(node) {
        var n = node;
        while (n && n !== document.body && n !== document) {
            if (n.classList && n.classList.contains('impulse-el')
                && n.dataset && n.dataset.type === 'section') {
                var p = n.parentElement;
                if (p && (p.classList.contains('impulse-content-wrapper')
                          || p.tagName === 'BODY')) {
                    return n;
                }
            }
            n = n.parentElement;
        }
        return null;
    }
    
    // ========================================
    // SORTABLE INITIALIZATION
    // ========================================
    
    // Inner containers — where drags originate
    var innerSelectors = [
        '.impulse-section > .impulse-container',
        '.impulse-container-el',
        '.impulse-grid-cell',
        '.impulse-rows-cell',
        '.impulse-layout-cell',
        '.impulse-tab-dropzone',
        '.impulse-toggle-dropzone',
        '.impulse-new-slider-dropzone'
    ].join(', ');
    // All containers including root — for connectWith
    var allSelectors = '.impulse-content-wrapper, ' + innerSelectors;
    
    // Fix nested sortables: override jQuery UI's _mouseCapture.
    // Guard: $.ui may not be loaded yet on first run — defer until it's available.
    function _patchSortableCapture() {
        if (!$ || !$.ui || !$.ui.sortable) return;
        var _origMouseCapture = $.ui.sortable.prototype._mouseCapture;
        $.ui.sortable.prototype._mouseCapture = function(event, overrideHandle) {
            if (this.options.handle === '.impulse-el-drag') {
                var handle = event.target.closest('.impulse-el-drag');
                if (handle) {
                    var handleEl = handle.closest('.impulse-el');
                    if (handleEl) {
                        // .impulse-el may be a direct child of this sortable
                        // (Grid, Section, Container) OR wrapped in
                        // .impulse-cell-content (Rows, Columns). Both layouts
                        // are valid; only reject when the handle's element
                        // belongs to a different (nested) sortable.
                        var parent = handleEl.parentElement;
                        var ok = (parent === this.element[0]) ||
                                 (parent && parent.classList && parent.classList.contains('impulse-cell-content')
                                  && parent.parentElement === this.element[0]);
                        if (!ok) return false;
                    }
                }
            }
            return _origMouseCapture.call(this, event, overrideHandle);
        };
    }
    if ($ && $.ui && $.ui.sortable) {
        _patchSortableCapture();
    } else {
        $(window).on('load', _patchSortableCapture);
    }

    // ========================================
    // SORTABLEJS-BASED REORDER (replaces jQuery UI sortable for in-iframe drags)
    //
    // One Sortable instance per container, all sharing group 'impulse-blocks',
    // so blocks can move freely across sections, grids, cell-grids, freeforms,
    // tabs, toggles, and slider dropzones. SortableJS hit-tests the actual
    // element under the cursor (not container registration order, which was
    // jQuery UI's nested-container failure mode), so the elementsFromPoint
    // cell-snap hack the old system needed is gone.
    //
    // Drop-target painting + drag-active body classes + parent postMessages
    // are preserved exactly — admin shell handlers (reorderElement /
    // moveElementInto) don't change. Cross-screen drops in scene mode and
    // forbidden-nesting blocks are enforced inside onMove.
    //
    // For containers where .impulse-el is wrapped in .impulse-cell-content
    // (rows + columns), Sortable mounts on the inner .impulse-cell-content
    // — that's the immediate parent of .impulse-el. getContainerMeta walks
    // back up to the outer .impulse-rows-cell / .impulse-layout-cell to
    // resolve the move-into target metadata.
    // ========================================
    var _impulseSortableInstances = [];

    // Bulletproof reset of any leftover drag state. Runs on DOMReady, onEnd,
    // onUnchoose, Escape key, and after any iframe-internal toolbar click —
    // anywhere that we want to GUARANTEE the user can pick up another block
    // immediately. The earlier 2.1.14 bug was: if a single drag cycle left
    // body.drag-active or body.impulse-reordering on, every .impulse-el-toolbar
    // gets opacity:0 + pointer-events:none (rule at line 13493), so the .impulse-el-drag
    // handles became unclickable — "no objects can be dragged at all".
    function _impulseSafetyReset() {
        document.body.classList.remove('impulse-reordering');
        document.body.classList.remove('drag-active');
        document.body.style.userSelect = '';
        document.querySelectorAll('.impulse-dragging').forEach(function (n) {
            n.classList.remove('impulse-dragging');
        });
        document.querySelectorAll('.impulse-just-settled').forEach(function (n) {
            n.classList.remove('impulse-just-settled');
        });
        // Sweep any stuck fallback overlays SortableJS may have leaked.
        document.querySelectorAll('.sortable-fallback,.sortable-drag,.impulse-sortable-drag').forEach(function (n) {
            try { n.remove(); } catch (_e) {}
        });
    }
    window.impulseSafetyReset = _impulseSafetyReset;

    // Build a compact drag-chip label for a block. EVERY in-canvas block now
    // uses the compact chip: a block's .impulse-el wrapper is full container
    // width while its actual content (e.g. a 200px button) is centered inside,
    // so the old full-size ghost rendered as a near-canvas-width INVISIBLE box
    // anchored at the top-right drag handle — the visible content fell off the
    // canvas and the user dragged 'nothing but the cursor'. The chip-host CSS
    // collapses the clone to a small pill that tracks the cursor. Titles still
    // show their own text. Sergei 2026-06-05 (was heading/oversized-only 05-28).
    function _impulseDragChipLabel(item) {
        if (!item || !item.dataset) return null;
        var type = item.dataset.type || 'block';
        var isHeading = (type === 'title');
        var label;
        if (isHeading) {
            var t = item.querySelector('.impulse-la-asset, .impulse-dt-line, h1,h2,h3,h4,h5,h6');
            label = ((t ? t.textContent : item.textContent) || '').replace(/\s+/g, ' ').trim();
            if (!label) label = 'Title';
        } else {
            var map = { section: 'Screen', flip_box: 'Flip Box', rows: 'Cells',
                        demo: 'Demo', header: 'Header', price_list: 'Price List' };
            label = map[type] || (type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, ' '));
        }
        if (label.length > 36) label = label.slice(0, 34) + '…';
        return label;
    }
    function _impulseChipEsc(s) {
        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }
    // Grab point (viewport coords) captured on drag-choose so the compact chip
    // can be anchored under the cursor in onStart. Sergei 2026-06-05.
    var _impulseChipTap = null;

    function initSortables() {
        if (typeof Sortable === 'undefined') return;
        // Always start from a known-clean state — any class/overlay leak from
        // a prior drag is wiped before fresh instances are bound.
        _impulseSafetyReset();
        // Tear down any existing instances so re-init is idempotent.
        _impulseSortableInstances.forEach(function (s) {
            try { s.destroy(); } catch (_e) {}
        });
        _impulseSortableInstances = [];

        // Containers where Sortable mounts. Two flavours:
        //   - direct: .impulse-el sits as a direct child
        //   - wrapped: .impulse-el sits inside .impulse-cell-content (rows / columns)
        // We mount Sortable directly on the immediate parent of the .impulse-el
        // children so the `draggable: '.impulse-el'` selector works without
        // nesting traversal.
        var directContainerSel = [
            '.impulse-content-wrapper',
            '.impulse-section > .impulse-container',
            '.impulse-container-el',
            '.impulse-grid-cell',
            '.impulse-tab-dropzone',
            '.impulse-toggle-dropzone',
            '.impulse-new-slider-dropzone'
        ].join(',');
        var wrappedContainerSel = [
            '.impulse-rows-cell > .impulse-cell-content',
            '.impulse-layout-cell > .impulse-cell-content'
        ].join(',');

        function commonOpts(extra) {
            return Object.assign({
                group: 'impulse-blocks',
                handle: '.impulse-el-drag',
                // The leading `>` is SortableJS-specific syntax meaning the
                // draggable must be a DIRECT child of the Sortable container.
                // Without it, `draggable: '.impulse-el'` lets every ancestor
                // Sortable also claim the inner block as the drag target —
                // the result was a heading-handle grab turning into a
                // section-wide drag because the outermost container's
                // _onTapStart resolved target=heading (any descendant matched)
                // and then animated/cloned the wrong element. Enforcing the
                // direct-child rule means each Sortable only handles items
                // that actually live one level inside it: grid-cells handle
                // their headings, .impulse-section > .impulse-container handles
                // the section's inner grid blocks, .impulse-content-wrapper
                // handles sections themselves — no overlap.
                draggable: '> .impulse-el',
                animation: 150,
                // Auto-scroll the iframe window when cursor approaches edges.
                // SortableJS picks the nearest scrollable ancestor; passing
                // window explicitly guarantees we scroll the page, not just
                // a tiny inner container the user may not have wanted.
                scroll: true,
                scrollSensitivity: 80,
                scrollSpeed: 18,
                bubbleScroll: true,
                forceFallback: true,       // unified helper across browsers
                fallbackOnBody: true,      // helper attached to <body>
                fallbackTolerance: 4,      // tiny tolerance so click ≠ drag
                ghostClass: 'impulse-sortable-placeholder',
                chosenClass: 'impulse-sortable-chosen',
                dragClass:   'impulse-sortable-drag',
                emptyInsertThreshold: 12,
                // Stop the mousedown from bubbling past whichever Sortable
                // claims it first. SortableJS attaches its mousedown listener
                // to each container; without stopPropagation, the same
                // mousedown triggers _onTapStart on every Sortable on the
                // ancestor chain. Each one runs _prepareDragStart and
                // overwrites the module-level `dragEl` variable — the
                // outermost wins because it fires last in bubble order,
                // which is why the SECTION (the outer container's matched
                // child) ended up being the actual drag target even though
                // the user grabbed an inner block's handle. With this hook
                // the deepest Sortable that the user actually clicked into
                // gets the drag, full stop — outer ones never see the event.
                onChoose: function (evt) {
                    if (evt.originalEvent && typeof evt.originalEvent.stopPropagation === 'function') {
                        evt.originalEvent.stopPropagation();
                    }
                    // Capture the grab point so the compact drag chip can be
                    // anchored UNDER the cursor in onStart (without this the
                    // chip rendered at the block's left edge, far from the
                    // top-right handle the user grabbed). Sergei 2026-06-05.
                    try {
                        var _oe = evt.originalEvent;
                        var _tp = _oe ? ((_oe.touches && _oe.touches[0]) || _oe) : null;
                        _impulseChipTap = (_tp && typeof _tp.clientX === 'number') ? { x: _tp.clientX, y: _tp.clientY } : null;
                    } catch (_eTap) { _impulseChipTap = null; }
                },
                onStart: function (evt) {
                    document.body.classList.add('impulse-reordering');
                    document.body.classList.add('drag-active');
                    document.body.style.userSelect = 'none';
                    if (window.parent) {
                        window.parent.postMessage({ type: 'impulse-drag-active', active: true }, '*');
                    }
                    if (evt.item) evt.item.classList.add('impulse-dragging');
                    // Swap the oversized fallback ghost for a compact chip.
                    // The forceFallback ghost carries our dragClass
                    // (.impulse-sortable-drag); replacing its contents is
                    // purely cosmetic — SortableJS tracks the real evt.item
                    // separately, so drop logic is unaffected. rAF defers one
                    // frame so the ghost is guaranteed in the DOM.
                    try {
                        var _chip = _impulseDragChipLabel(evt.item);
                        if (_chip) {
                            requestAnimationFrame(function () {
                                var ghost = document.querySelector('.impulse-sortable-drag');
                                if (!ghost) return;
                                ghost.classList.add('impulse-drag-chip-host');
                                ghost.innerHTML =
                                    '<span class="impulse-drag-chip">' +
                                    '<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor">' +
                                    '<circle cx="9" cy="6" r="1.4"/><circle cx="9" cy="12" r="1.4"/><circle cx="9" cy="18" r="1.4"/>' +
                                    '<circle cx="15" cy="6" r="1.4"/><circle cx="15" cy="12" r="1.4"/><circle cx="15" cy="18" r="1.4"/></svg>' +
                                    '<b>' + _impulseChipEsc(_chip) + '</b></span>';
                                // Anchor the chip just below-right of the grab
                                // point so it sits UNDER the cursor, not at the
                                // dragged block's far-left edge. Ghost is
                                // position:fixed (fallbackOnBody) and SortableJS
                                // adds transform:translate(cursor - grab); so
                                // left/top = grab point makes it render on the
                                // cursor. Sergei 2026-06-05.
                                if (_impulseChipTap) {
                                    ghost.style.left = (_impulseChipTap.x + 14) + 'px';
                                    ghost.style.top  = (_impulseChipTap.y + 10) + 'px';
                                    ghost.style.right = 'auto';
                                    ghost.style.bottom = 'auto';
                                }
                            });
                        }
                    } catch (_eChip) {}
                },
                onMove: function (evt) {
                    var item = evt.dragged;
                    var to = evt.to;
                    var sourceType = item && item.dataset ? (item.dataset.type || '') : '';
                    var targetMeta = getContainerMeta($(_resolveCellOuter(to)));
                    // Forbidden nesting — same rules the old shouldBlockMove enforced.
                    if (shouldBlockMove(sourceType, targetMeta.targetType)) return false;
                    // Block dropping into own descendant (would cycle).
                    if (item && to && isDescendant(item, to)) return false;
                    // Scene-mode cross-screen drag block. The hidden screens
                    // are still in the DOM with sortable groups attached, so
                    // without this guard a drop into a hidden screen would
                    // succeed silently and the block would "vanish" until
                    // the user navigated to that screen.
                    // CLASSIC pages carry data-screen-mode="1" too — the editor
                    // hardcodes $screenMode=true and expresses classic only via
                    // the .impulse-scroll-classic body class — but there every
                    // section is visible and stacked, so there is no hidden
                    // screen to vanish into. Without the classic exclusion this
                    // guard refused EVERY cross-section move on a classic page
                    // (within-section reorder still worked). 2.1.2145.
                    if (document.body && document.body.dataset.screenMode === '1'
                        && !document.body.classList.contains('impulse-scroll-classic')) {
                        var sourceScreen = _findScreenContainer(item);
                        var targetScreen = _findScreenContainer(to);
                        if (sourceScreen && targetScreen && sourceScreen !== targetScreen) {
                            return false;
                        }
                    }
                    return true;
                },
                // onUnchoose fires when the user mouses-down on a handle but
                // releases without crossing the fallbackTolerance threshold —
                // i.e. a tiny click that SortableJS doesn't treat as a drag.
                // Without this, the onStart side-effects (drag-active class,
                // userSelect:none) stay applied because onEnd never fires.
                onUnchoose: function (evt) {
                    _impulseSafetyReset();
                    if (window.parent) {
                        window.parent.postMessage({ type: 'impulse-drag-active', active: false }, '*');
                    }
                },
                onEnd: function (evt) {
                    _impulseSafetyReset();
                    if (evt.item) {
                        evt.item.classList.add('impulse-just-settled');
                        setTimeout(function () { if (evt.item) evt.item.classList.remove('impulse-just-settled'); }, 350);
                    }
                    if (window.parent) {
                        window.parent.postMessage({ type: 'impulse-drag-active', active: false }, '*');
                    }

                    var item = evt.item;
                    var oldParent = evt.from;
                    var newParent = evt.to;
                    if (!item || !item.dataset) return;
                    var sourceId = item.dataset.id;
                    var sourceType = item.dataset.type || '';

                    // Section-drop reroute. Sections render their children
                    // inside an inner `.impulse-container` that's narrower
                    // than the section itself — typically 240px of padding
                    // on each side. When the user drops in that padding
                    // strip (very common, since they're aiming "at the
                    // section, not exactly at the centered content"),
                    // elementFromPoint returns the <section> element. The
                    // closest Sortable ancestor is `.impulse-content-wrapper`,
                    // so SortableJS interprets the drop as a root-level
                    // reorder past the section, not a move-into. The user
                    // sees "I dropped on Section X, but my block ended up
                    // adjacent to Section X at root."
                    //
                    // Fix: when the drop landed at root and the source is
                    // NOT itself a section, check whether the cursor was
                    // actually inside a section's body. If so, override the
                    // outgoing message to a move-into for that section.
                    // Section→section is still forbidden upstream, so we
                    // gate on the source type. Grid-cell / cell-grid drops
                    // are unaffected — SortableJS already routes correctly
                    // when the cursor lands inside one of those Sortables.
                    // Reparent-into-section override, generalised for NESTED sections.
                    // SortableJS resolves a drop by the nearest ANCESTOR Sortable, but a
                    // section's body is not always that nearest one, so two cases both
                    // read as "reorder past the section" instead of "into it":
                    //   1. Root screens: the cursor's section resolves up to
                    //      .impulse-content-wrapper (the original case this block covered).
                    //   2. A section INSIDE a section is itself a draggable '> .impulse-el'
                    //      in the OUTER section's Sortable, so dropping a block over the
                    //      inner section reorders it NEXT TO the inner section in the outer
                    //      one — it never enters the inner section. That was the reported
                    //      bug: a text block that would not drop into the inner section.
                    // One rule for both: if the cursor's topmost section differs from the
                    // section the drop actually resolved into, move INTO the hovered one.
                    // A genuine same-section reorder is untouched — there the hovered
                    // section IS the resolved section, so the ids match and this no-ops.
                    // Grid-cell drops are likewise unaffected: the cell's section equals
                    // the hovered section. Sergei 2026-07-17.
                    if (sourceType !== 'section') {
                        // The section the drop landed in. content-wrapper == root (no
                        // section), otherwise the nearest section ancestor of newParent.
                        var _npSection = (newParent && newParent.classList
                            && newParent.classList.contains('impulse-content-wrapper'))
                            ? null
                            : (newParent && newParent.closest
                                ? newParent.closest('.impulse-el[data-type="section"]')
                                : null);
                        var _npSectionId = (_npSection && _npSection.dataset) ? _npSection.dataset.id : null;
                        var _oe = evt.originalEvent || (evt.changedTouches && evt.changedTouches[0]);
                        var _x = _oe ? _oe.clientX : null;
                        var _y = _oe ? _oe.clientY : null;
                        if (_x !== null && _y !== null && typeof document.elementsFromPoint === 'function') {
                            var _stack = document.elementsFromPoint(_x, _y);
                            var _sectionEl = null;
                            for (var _i = 0; _i < _stack.length; _i++) {
                                var _e = _stack[_i];
                                if (_e && _e.classList && _e.classList.contains('impulse-el') && _e.dataset && _e.dataset.type === 'section') {
                                    _sectionEl = _e;   // deepest section under the cursor wins
                                    break;
                                }
                            }
                            if (_sectionEl && _sectionEl.dataset.id
                                && _sectionEl.dataset.id !== sourceId
                                && _sectionEl.dataset.id !== _npSectionId) {
                                if (window.parent) {
                                    window.parent.postMessage({
                                        type: 'impulse-move-into',
                                        sourceId: sourceId,
                                        sourceType: sourceType,
                                        targetId: _sectionEl.dataset.id,
                                        targetType: 'section',
                                        // Reroute: Sortable left the DOM node wherever it
                                        // resolved, NOT in the rerouted target - an
                                        // in-place element refresh cannot relocate it, so
                                        // the parent must reload the preview.
                                        domStale: true
                                    }, '*');
                                }
                                return;
                            }
                        }
                    }

                    // Move-OUT-of-container rescue. The nested-Sortable
                    // disambiguation disables every ANCESTOR Sortable while a
                    // nested block is dragged (so only the deepest cell handles
                    // it — the "lost SEO section" guard). Side effect: releasing
                    // the block over the section's OWN area can't reparent it, so
                    // it snaps back to its cell (oldParent === newParent). Rescue
                    // it: if the release point's topmost drop container is the
                    // section's own container (not another cell), reparent the
                    // block to that section root. Cell-to-cell still works (sibling
                    // cells aren't disabled); genuine in-cell reorders don't fire
                    // (the cell is topmost, so the loop breaks before the section).
                    // ALSO rescue when Sortable committed the block to the LAST
                    // HOVERED CELL even though the user RELEASED over the section
                    // root: dragging out of a grid, the neighbour cell is the last
                    // valid Sortable, so it wins the drop and the release point
                    // was never consulted ("buttons only drag to neighbour cells,
                    // never to the screen root" - Sergei 2026-07-25, 868/12).
                    // Trust the release point whenever the resolved parent is a
                    // CELL-level container; a drop Sortable resolved into a
                    // section container directly keeps its position untouched.
                    var _npIsCellLevel = !!(newParent && newParent.matches
                        && newParent.matches('.impulse-grid-cell, .impulse-cell-content, .impulse-container-el, .impulse-tab-dropzone, .impulse-toggle-dropzone, .impulse-new-slider-dropzone'));
                    if (sourceType !== 'section' && (oldParent === newParent || _npIsCellLevel)
                        && typeof document.elementsFromPoint === 'function') {
                        var _re = evt.originalEvent || (evt.changedTouches && evt.changedTouches[0]);
                        var _rx = _re ? _re.clientX : null;
                        var _ry = _re ? _re.clientY : null;
                        if (_rx !== null && _ry !== null) {
                            var _rstack = document.elementsFromPoint(_rx, _ry);
                            var _rootC = null;
                            for (var _k = 0; _k < _rstack.length; _k++) {
                                var _c = _rstack[_k];
                                if (!_c || !_c.matches) continue;
                                if (_c.matches('.impulse-grid-cell, .impulse-cell-content, .impulse-container-el, .impulse-tab-dropzone, .impulse-toggle-dropzone, .impulse-new-slider-dropzone')) {
                                    break; // a nested cell is on top — Sortable owns this drop
                                }
                                if (_c.matches('.impulse-section > .impulse-container')) { _rootC = _c; break; }
                                // The section's OWN area (its padding strip OUTSIDE the
                                // inner container -- the only bare spot when a full-bleed
                                // grid fills the container) also exits the block to that
                                // section. Without this, a block in a full-bleed grid cell
                                // could only escape to a DIFFERENT section, never its own.
                                // Sergei 2026-07-09.
                                if (_c.matches('.impulse-el[data-type="section"]')) { _rootC = _c; break; }
                            }
                            if (_rootC && _rootC !== oldParent && _rootC !== newParent) {
                                var _secEl = _rootC.closest('.impulse-el[data-type="section"]');
                                if (_secEl && _secEl.dataset && _secEl.dataset.id && _secEl.dataset.id !== sourceId) {
                                    if (window.parent) {
                                        window.parent.postMessage({
                                            type: 'impulse-move-into',
                                            sourceId: sourceId,
                                            sourceType: sourceType,
                                            targetId: _secEl.dataset.id,
                                            targetType: 'section',
                                            domStale: true
                                        }, '*');
                                    }
                                    return;
                                }
                            }
                        }
                    }

                    if (oldParent === newParent) {
                        // Same-parent reorder. Find adjacent sibling .impulse-el
                        // for the position-relative-to-target API the admin shell
                        // uses (handles cell-grid sort_order ties correctly per
                        // the reorder API fix shipped earlier).
                        var prev = item.previousElementSibling;
                        while (prev && (!prev.classList.contains('impulse-el') || !prev.dataset.id)) {
                            prev = prev.previousElementSibling;
                        }
                        var next = item.nextElementSibling;
                        while (next && (!next.classList.contains('impulse-el') || !next.dataset.id)) {
                            next = next.nextElementSibling;
                        }
                        var targetId, position;
                        if (prev) { targetId = prev.dataset.id; position = 'after'; }
                        else if (next) { targetId = next.dataset.id; position = 'before'; }
                        else {
                            // Only element in the container — degenerate case;
                            // fall back to a move-into so the server still
                            // refreshes parent linkage.
                            var meta0 = getContainerMeta($(_resolveCellOuter(newParent)));
                            if (sourceId === String(meta0.parentId)) return;
                            if (window.parent) {
                                var msg0 = {
                                    type: 'impulse-move-into',
                                    sourceId: sourceId,
                                    sourceType: sourceType,
                                    targetId: meta0.parentId,
                                    targetType: meta0.targetType
                                };
                                if (meta0.gridCellIndex !== undefined) msg0.gridCellIndex = meta0.gridCellIndex;
                                if (meta0.cellRow !== undefined) msg0.cellRow = meta0.cellRow;
                                if (meta0.cellCol !== undefined) msg0.cellCol = meta0.cellCol;
                                if (meta0.slideIndex !== undefined) msg0.slideIndex = meta0.slideIndex;
                                window.parent.postMessage(msg0, '*');
                            }
                            return;
                        }
                        if (window.parent) {
                            window.parent.postMessage({
                                type: 'impulse-reorder',
                                sourceId: sourceId,
                                targetId: targetId,
                                position: position
                            }, '*');
                        }
                    } else {
                        // Cross-parent move. Resolve the destination's logical
                        // parent (cell row/col, grid cell index, slide index, etc).
                        var meta = getContainerMeta($(_resolveCellOuter(newParent)));
                        if (sourceId === String(meta.parentId)) return;
                        var msg = {
                            type: 'impulse-move-into',
                            sourceId: sourceId,
                            sourceType: sourceType,
                            targetId: meta.parentId,
                            targetType: meta.targetType
                        };
                        if (meta.gridCellIndex !== undefined) msg.gridCellIndex = meta.gridCellIndex;
                        if (meta.cellRow !== undefined) msg.cellRow = meta.cellRow;
                        if (meta.cellCol !== undefined) msg.cellCol = meta.cellCol;
                        if (meta.slideIndex !== undefined) msg.slideIndex = meta.slideIndex;
                        if (window.parent) window.parent.postMessage(msg, '*');
                    }
                }
            }, extra || {});
        }

        // When Sortable is mounted on .impulse-cell-content, getContainerMeta
        // needs the OUTER cell (.impulse-rows-cell / .impulse-layout-cell)
        // to read row/col/type. Walk up one level to reach it.
        function _resolveCellOuter(el) {
            if (el && el.classList && el.classList.contains('impulse-cell-content')) {
                return el.parentElement || el;
            }
            return el;
        }

        // In screen mode, .impulse-content-wrapper's children are SCREENS,
        // not user-draggable blocks. Sections are reordered via the Screens
        // sidebar (with the zone-divider, cross-zone, hero_screen_count
        // adjustments). Skip the content-wrapper Sortable entirely.
        var _isScreenMode = document.body && document.body.dataset.screenMode === '1';
        document.querySelectorAll(directContainerSel).forEach(function (el) {
            if (_isScreenMode && el.classList && el.classList.contains('impulse-content-wrapper')) {
                return;
            }
            _impulseSortableInstances.push(new Sortable(el, commonOpts()));
        });
        document.querySelectorAll(wrappedContainerSel).forEach(function (el) {
            _impulseSortableInstances.push(new Sortable(el, commonOpts()));
        });

        // ── Nested-Sortable disambiguation ───────────────────────────────────
        // When a click happens inside a deeply-nested .impulse-el (e.g. an
        // table inside a grid-cell inside a section), MULTIPLE Sortables
        // along the ancestor chain match the click — each computes its own
        // drag target (the inner cell picks the table, the section's
        // .impulse-container picks the whole grid). Both initiate, two
        // helper clones get rendered, neither tracks the cursor, drops
        // land in unintended containers — and worst of all, that's how the
        // user lost the SEO section (a section-sized "outer" helper drop
        // overwrote/displaced content).
        //
        // Fix: a capture-phase mousedown listener that runs BEFORE any
        // Sortable's bubble-phase handler. It finds the deepest Sortable
        // container that's an ancestor of the click, then sets
        // option('disabled', true) on every ANCESTOR Sortable until
        // mouseup. Only the innermost Sortable handles the drag.
        document.addEventListener('mousedown', function (e) {
            // Only intercept drag-handle grabs.
            if (!e.target || !e.target.closest || !e.target.closest('.impulse-el-drag')) return;
            // Walk up from the click target collecting every Sortable
            // container ancestor. Deepest (first encountered) wins.
            var node = e.target;
            var ancestorSortables = []; // ordered DEEPEST first
            while (node && node !== document) {
                for (var i = 0; i < _impulseSortableInstances.length; i++) {
                    if (_impulseSortableInstances[i].el === node) {
                        ancestorSortables.push(_impulseSortableInstances[i]);
                        break;
                    }
                }
                node = node.parentNode;
            }
            if (ancestorSortables.length < 2) return; // nothing to disambiguate
            // Disable every ancestor except the deepest.
            var deepest = ancestorSortables[0];
            var disabled = [];
            for (var j = 1; j < ancestorSortables.length; j++) {
                try {
                    ancestorSortables[j].option('disabled', true);
                    disabled.push(ancestorSortables[j]);
                } catch (_e) {}
            }
            if (!disabled.length) return;
            function cleanup() {
                disabled.forEach(function (s) { try { s.option('disabled', false); } catch (_e) {} });
                document.removeEventListener('mouseup', cleanup, true);
                document.removeEventListener('pointerup', cleanup, true);
                document.removeEventListener('touchend', cleanup, true);
            }
            document.addEventListener('mouseup', cleanup, true);
            document.addEventListener('pointerup', cleanup, true);
            document.addEventListener('touchend', cleanup, true);
        }, true); // CAPTURE phase — fires before any Sortable's bubble listener
    }


    // ========================================
    // INIT ON DOM READY
    // ========================================
    
    // Kill the browser's native HTML5 drag-and-drop inside the editor iframe.
    // Without this, mousedown-on-image (or any block with a wrapped img / text
    // selection / etc.) makes the BROWSER pick up the native drag ghost first
    // — the user sees the OS dragging an outline of the whole section as if
    // they grabbed a file, and SortableJS never gets to fire its own mousedown
    // tracking. forceFallback:true is supposed to keep SortableJS in pure
    // mouse-event territory, but dragstart fires from a separate code path
    // and overrides it. Preventing dragstart at the document level (capture
    // phase, before anything bubbles) gives SortableJS sole authority over
    // every drag inside the iframe.
    document.addEventListener('dragstart', function (e) {
        e.preventDefault();
    }, true);

    // Escape-key escape hatch + window-level mouseup safety net. If anything
    // ever leaks the drag-active class onto body (jQuery UI side, a buggy
    // commonOpts handler, an aborted drag), Escape or a top-level mouseup
    // returns the iframe to a clean state so the user is never stuck.
    document.addEventListener('keydown', function (e) {
        if (e.key === 'Escape' && document.body.classList.contains('drag-active')) {
            _impulseSafetyReset();
            if (window.parent) {
                window.parent.postMessage({ type: 'impulse-drag-active', active: false }, '*');
            }
        }
    }, true);
    window.addEventListener('mouseup', function () {
        // Defer one tick so genuine SortableJS onEnd has time to handle the
        // drop and run its own cleanup. Only resets when something was
        // actually left over after the drag fully unwound.
        setTimeout(function () {
            if (typeof Sortable !== 'undefined' && Sortable.active) return;
            if (document.body.classList.contains('drag-active') || document.body.classList.contains('impulse-reordering')) {
                _impulseSafetyReset();
                if (window.parent) {
                    window.parent.postMessage({ type: 'impulse-drag-active', active: false }, '*');
                }
            }
        }, 50);
    }, true);

    $(function() {
        initSortables();
        window.impulseReinitSortables = initSortables;
        // Re-init JS behaviors on a single element after DOM swap
        window.impulseReinitElement = function(elementId) {
            var el = document.querySelector('.impulse-el[data-id="' + elementId + '"]');
            if (!el) return;
            var type = el.dataset.type || '';
            // Text wall (section background): a re-rendered section swaps in a
            // fresh EMPTY .impulse-textwall — re-tile it so it doesn't blank on
            // every settings change. rAF lets the new layer size before measuring.
            if (window.ImpulseTextWall && el.querySelector && el.querySelector('.impulse-textwall')) {
                requestAnimationFrame(function() { try { window.ImpulseTextWall.init(el); } catch (e) {} });
            }
            // Countdown: a re-rendered block swaps in a fresh EMPTY carrier
            // (the engine builds its cards client-side), so without this it
            // sat blank until a full reload - "countdown is not working".
            // Clear the idempotence flag so init actually rebuilds it.
            if (window.ImpulseCountdown && el.querySelectorAll) {
                el.querySelectorAll('.impulse-countdown').forEach(function (cd) {
                    try { cd._cdInit = false; window.ImpulseCountdown.build(cd); } catch (_cdE) {}
                });
            }
            // Flip boxes — ALL of them, whatever the swapped element's type.
            // A container refresh (grid Duplicate Cell, section re-render)
            // swaps in fresh flip-box DOM with no hover engine attached; the
            // old type-gated single-box re-init left them inert until an
            // effect change forced another swap (Sergei 2026-07-20).
            if (window._impulseInitFlipBox && el.querySelectorAll) {
                el.querySelectorAll('.impulse-flip-box').forEach(function(fb) {
                    try { window._impulseInitFlipBox(fb); } catch (_fbE) {}
                });
            }
            // Slider
            if ((type === 'slider' || type === 'carousel') && window.ImpulseSliderInit) {
                window.ImpulseSliderInit();
            }
            // Image carousels have their own engine (impulse-carousel-init.js) -
            // re-init it too after a refresh swap, or the fresh markup sits
            // static. 2026-07-20.
            if ((type === 'slider' || type === 'carousel') && window.impulseInitCarousels) {
                window.impulseInitCarousels();
            }
            // Section/page animation layers: their boot script never re-executes
            // on a refresh swap, so read the self-describing data-anim-cfg and
            // re-init each layer on the fresh canvases. 2026-07-20.
            el.querySelectorAll('.impulse-section-animation[data-anim-cfg], .impulse-page-animation[data-anim-cfg]').forEach(function(animWrap) {
                try {
                    var animCfg = JSON.parse(animWrap.getAttribute('data-anim-cfg'));
                    if (window.ImpulseAnimations && animCfg) {
                        window.ImpulseAnimations.destroy(animWrap.id);
                        window.ImpulseAnimations.init(animWrap.id, animCfg, true);
                    }
                } catch (_animE) {}
            });
            // Image — re-init tilt/cylinder/hover effects on new wrapper
            if (type === 'image' && typeof initImpulseTilt === 'function') {
                initImpulseTilt();
            }
            // Logo animator — re-run canvas engine on the new element
            if ((type === 'title') && window.impulseLogoAnimatorRunAll) {
                window.impulseLogoAnimatorRunAll();
            }
            // Display block: if the refreshed heading now renders via the animator path
            // (picked a preset), kick the animator engine. Otherwise re-init the 3D text.
            if (type === 'title') {
                var _hdrAnim = el.querySelector('.impulse-logo-animator');
                if (_hdrAnim && window.impulseLogoAnimatorRun) {
                    var _hdrAnimId = _hdrAnim.id;
                    requestAnimationFrame(function() {
                        requestAnimationFrame(function() {
                            setTimeout(function() {
                                try { window.impulseLogoAnimatorRun(_hdrAnimId); } catch(e) {}
                            }, 60);
                        });
                    });
                } else if (window.Impulse3DText) {
                    setTimeout(function() { window.Impulse3DText.init(elementId); }, 60);
                }
            }
            // Re-init sortables so drag-drop works on new DOM nodes
            initSortables();
        };
    });
    
    // Also re-init if parent tells us (after duplicate, paste, etc. that don't full-reload)
    window.addEventListener('message', function(e) {
        if (e.data && e.data.type === 'impulse-reinit-sortables') {
            initSortables();
        }
    });

    // ========================================
    // AUTO-SCROLL TO SELECTED ELEMENT
    // ========================================
    
    // Scroll element into view, centered in viewport
    function scrollToEl(el) {
        if (!el || document.body.classList.contains('impulse-reordering')) return;
        // Screen mode: flying screens are fixed, full-viewport layers — scroll-to-center
        // would break their layout by yanking bottom-aligned blocks to the middle.
        if (document.body.dataset.screenMode === '1') return;
        var rect = el.getBoundingClientRect();
        var viewH = window.innerHeight;
        var margin = viewH * 0.15; // 15% margin from edges
        // Already comfortably visible — skip
        // (Retired in 2.0.804) "Last-section lift" used to live here as an
        // IIFE nested inside scrollToEl. It listened to scroll events and
        // applied translateY to the last section as the user scrolled near
        // the bottom, intended to "centre" the section in the viewport. In
        // practice the translation read as the section jumping upward
        // during scroll — exactly the "disappearing/jumping section" bug.
        // The IIFE was also a leak: every scrollToEl call (on every element
        // selection) registered a fresh window scroll listener. Removed
        // entirely; sections now sit at their natural document positions.
        if (rect.top >= margin && rect.bottom <= viewH - margin) return;
window._impulseScopedScroll = window._impulseScopedScroll || function(el, block) {
    // Scoped scroll-to-element. NEVER scrollIntoView here: it propagates
    // through every scrollable ancestor ACROSS the iframe boundary, shoving
    // the editor's overflow:hidden canvas wrapper (unrecoverable without a
    // reload) and the admin document itself. Scroll only the nearest
    // scrollable container INSIDE this document. 2026-07-19.
    if (!el) return;
    var sc = el.parentElement;
    while (sc && sc !== document.body) {
        var cs = getComputedStyle(sc);
        if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') && sc.scrollHeight > sc.clientHeight + 1) break;
        sc = sc.parentElement;
    }
    if (!sc) sc = document.body;
    var r = el.getBoundingClientRect(), sr = sc.getBoundingClientRect();
    var eTop = r.top - sr.top + sc.scrollTop;
    var target;
    if (block === 'nearest') {
        if (r.top >= sr.top && r.bottom <= sr.bottom) return; // already visible
        target = eTop;
    } else {
        target = eTop - Math.max(0, (sc.clientHeight - el.offsetHeight) / 2);
    }
    target = Math.max(0, target);
    try { sc.scrollTo({ top: target, behavior: 'smooth' }); } catch (_e) { sc.scrollTop = target; }
};
        window._impulseScopedScroll(el, 'center');
    }
    
    // MutationObserver: watch for .selected class added to any .impulse-el
    // Only scrolls once per NEW selection — re-selecting the same element or
    // class mutations on an already-selected element won't re-scroll.
    var _scrollTimer = null;
    var _lastScrolledId = null;
    window._impulseResetScrollTracker = function() { _lastScrolledId = null; };
    var observer = new MutationObserver(function(mutations) {
        for (var i = 0; i < mutations.length; i++) {
            var m = mutations[i];
            if (m.type === 'attributes' && m.attributeName === 'class') {
                var el = m.target;
                if (el.classList.contains('impulse-el') && el.classList.contains('selected')) {
                    var elId = el.dataset.id;
                    if (elId === _lastScrolledId) break; // Same element, don't re-scroll
                    _lastScrolledId = elId;
                    clearTimeout(_scrollTimer);
                    _scrollTimer = setTimeout(function() { scrollToEl(el); }, 150);
                    break;
                }
            }
        }
    });
    observer.observe(document.body, { attributes: true, attributeFilter: ['class'], subtree: true });
    
    // Parent can request scroll to a specific element by ID
    window.addEventListener('message', function(e) {
        if (e.data && e.data.type === 'impulse-scroll-to') {
            var el = document.querySelector('.impulse-el[data-id="' + e.data.id + '"]');
            if (el) scrollToEl(el);
        }
    });

    // ========================================
    // GLOBAL KEYBOARD SHORTCUTS
    // ========================================
    
    document.addEventListener('keydown', function(e) {
        // Don't forward if typing in contenteditable
        if (e.target.isContentEditable || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
            return;
        }
        
        // Forward undo/redo to parent
        if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
            e.preventDefault();
            if (window.parent) {
                window.parent.postMessage({ type: 'impulse-undo' }, '*');
            }
        }
        if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) {
            e.preventDefault();
            if (window.parent) {
                window.parent.postMessage({ type: 'impulse-redo' }, '*');
            }
        }
        
        // Forward delete to parent
        if (e.key === 'Delete' || e.key === 'Backspace') {
            e.preventDefault();
            if (window.parent) {
                window.parent.postMessage({ type: 'impulse-delete' }, '*');
            }
        }
        
        // Forward escape to parent
        if (e.key === 'Escape') {
            if (window.parent) {
                window.parent.postMessage({ type: 'impulse-escape' }, '*');
            }
        }
    });
    
    // ========================================
    // CAROUSEL EFFECTS LIBRARY
    // All image-carousel transition effects live here.
    // Signature: fn(container, outEl, inEl, dur, done)
    // ========================================
    var ImpulseCarouselEffects = (function() {

        // Draw img onto canvas respecting object-fit:cover
        function drawCover(ctx, img, w, h) {
            if (!img || !img.complete || !img.naturalWidth) {
                ctx.fillStyle = '#e0e0e0'; ctx.fillRect(0, 0, w, h); return;
            }
            var iw = img.naturalWidth, ih = img.naturalHeight;
            var scale = Math.max(w / iw, h / ih);
            var dw = iw * scale, dh = ih * scale;
            ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
        }

        // ── Depth ──────────────────────────────────────────────────────
        function depth_rush(container, outEl, inEl, dur, done) {
            // outEl: scale 1→1.3, opacity 1→0 (zooms out, fades away)
            // inEl:  scale 0→1,   opacity 0→1 (rushes in from depth, fades in)
            // Both transitions run for exactly `dur` ms — pure simultaneous motion
            outEl.style.transition = 'none';
            outEl.style.transform  = 'scale(1)';
            outEl.style.opacity    = '1';
            outEl.style.zIndex     = '1';
            inEl.style.transition  = 'none';
            inEl.style.transform   = 'scale(0)';
            inEl.style.opacity     = '0';
            inEl.style.zIndex      = '2';
            inEl.offsetHeight;

            var ease = dur + 'ms ease-in-out';
            outEl.style.transition = 'transform ' + ease + ', opacity ' + ease;
            outEl.style.transform  = 'scale(1.3)';
            outEl.style.opacity    = '0';

            inEl.style.transition  = 'transform ' + ease + ', opacity ' + ease;
            inEl.style.transform   = 'scale(1)';
            inEl.style.opacity     = '1';

            setTimeout(done, dur + 50);
        }

        // ── Flip ───────────────────────────────────────────────────────
        // Vertical card flip — rotates around horizontal axis (rotateX).
        function card_flip(container, outEl, inEl, dur, done) {
            var half = dur / 2;
            outEl.style.cssText += ';transition:none;transform:rotateX(0deg);opacity:1;z-index:2;backface-visibility:hidden';
            inEl.style.cssText  += ';transition:none;transform:rotateX(90deg);opacity:1;z-index:1;backface-visibility:hidden';
            outEl.offsetHeight;
            outEl.style.transition = 'transform ' + half + 'ms cubic-bezier(0.4, 0, 0.2, 1)';
            outEl.style.transform  = 'rotateX(-90deg)';
            setTimeout(function() {
                outEl.style.zIndex = '0';
                inEl.style.zIndex  = '2';
                inEl.style.transition = 'transform ' + half + 'ms cubic-bezier(0.4, 0, 0.2, 1)';
                inEl.style.transform  = 'rotateX(0deg)';
                setTimeout(done, half + 50);
            }, half);
        }

        // ── Peel ──────────────────────────────────────────────────────
        function stack_peel(container, outEl, inEl, dur, done) {
            outEl.style.cssText += ';transition:none;transform:translate3d(0,0,0) rotateZ(0deg);opacity:1;z-index:3;transform-origin:bottom left';
            inEl.style.cssText  += ';transition:none;transform:translate3d(0,0,0) rotateZ(-4deg) scale(0.92);opacity:1;z-index:1';
            outEl.offsetHeight;
            outEl.style.transition = 'transform ' + dur + 'ms cubic-bezier(0.4,0,0.2,1), opacity ' + (dur*0.3) + 'ms ' + (dur*0.7) + 'ms ease-in-out';
            outEl.style.transform  = 'translate3d(120%,-20%,0) rotateZ(12deg)';
            outEl.style.opacity    = '0';
            inEl.style.transition  = 'transform ' + dur + 'ms ' + (dur*0.15) + 'ms cubic-bezier(0.2,0,0,1)';
            inEl.style.transform   = 'translate3d(0,0,0) rotateZ(0deg) scale(1)';
            inEl.style.zIndex      = '2';
            setTimeout(done, dur + 50);
        }

        // ── Pixel Decode ─────────────────────────────────────────────────────
        // Big blocky pixels that gradually resolve into the new image
        function pixel_decode(container, outEl, inEl, dur, done) {
            var w = container.offsetWidth || container.parentElement.offsetWidth || 800;
            var h = container.offsetHeight || container.parentElement.offsetHeight || 500;
            container.style.overflow = 'hidden';
            // Steps: start with large blocks, subdivide down to fine pixels
            var STEPS = [
                { size: 48, hold: dur * 0.15 },
                { size: 24, hold: dur * 0.15 },
                { size: 12, hold: dur * 0.15 },
                { size:  6, hold: dur * 0.15 },
                { size:  3, hold: dur * 0.20 },
                { size:  1, hold: dur * 0.20 }
            ];
            var totalHold = STEPS.reduce(function(a, s) { return a + s.hold; }, 0);

            // Capture the INCOMING image at each resolution step
            // We draw the incoming image pixelated by drawing small then scaling up
            function makePixelCanvas(img, pixSize) {
                var cvs = document.createElement('canvas');
                cvs.width = w; cvs.height = h;
                var ctx = cvs.getContext('2d');
                if (pixSize <= 1) {
                    drawCover(ctx, img, w, h);
                } else {
                    // Draw small, then scale up with imageSmoothingEnabled=false for blocky look
                    var sw = Math.ceil(w / pixSize), sh = Math.ceil(h / pixSize);
                    var tmp = document.createElement('canvas');
                    tmp.width = sw; tmp.height = sh;
                    var tctx = tmp.getContext('2d');
                    tctx.imageSmoothingEnabled = false;
                    drawCover(tctx, img, sw, sh);
                    ctx.imageSmoothingEnabled = false;
                    ctx.drawImage(tmp, 0, 0, sw, sh, 0, 0, w, h);
                }
                cvs.style.cssText = 'position:absolute;inset:0;z-index:6;pointer-events:none;display:block;width:100%;height:100%';
                return cvs;
            }

            var inImg = inEl.querySelector('img');
            var outImg = outEl.querySelector('img');

            // Hide both real slides; overlay canvases handle the visuals
            outEl.style.cssText = 'position:absolute;inset:0;opacity:0;z-index:0;transition:none;transform:none';
            inEl.style.cssText  = 'position:absolute;inset:0;opacity:0;z-index:0;transition:none;transform:none';

            // Start: show outgoing image pixelated at step 0 size
            // Then step through, replacing with the incoming image at decreasing block sizes
            var currentCvs = makePixelCanvas(outImg, STEPS[0].size);
            container.appendChild(currentCvs);

            var stepIdx = 0;
            function nextStep() {
                stepIdx++;
                if (stepIdx >= STEPS.length) {
                    // Final step done — show real inEl
                    currentCvs.remove();
                    inEl.style.opacity = '1';
                    inEl.style.zIndex  = '2';
                    setTimeout(done, 60);
                    return;
                }
                var step = STEPS[stepIdx];
                var nextCvs = makePixelCanvas(inImg, step.size);
                container.appendChild(nextCvs);
                // Brief fade between canvas layers
                nextCvs.style.opacity = '0';
                nextCvs.style.transition = 'opacity 80ms linear';
                nextCvs.offsetHeight;
                nextCvs.style.opacity = '1';
                var old = currentCvs;
                currentCvs = nextCvs;
                setTimeout(function() { old.remove(); }, 120);
                setTimeout(nextStep, step.hold);
            }

            setTimeout(nextStep, STEPS[0].hold);
        }

        // ── Shatter ──────────────────────────────────────────────────────────
        function shatter(container, outEl, inEl, dur, done) {
            var w = container.offsetWidth || container.parentElement.offsetWidth || 800;
            var h = container.offsetHeight || container.parentElement.offsetHeight || 500;
            container.style.overflow = 'hidden';
            var cols = 6, rows = 4;
            var tileW = Math.floor(w / cols), tileH = Math.floor(h / rows);

            // Capture outgoing with correct cover scaling
            var cvs = document.createElement('canvas');
            cvs.width = w; cvs.height = h;
            drawCover(cvs.getContext('2d'), outEl.querySelector('img'), w, h);

            // Build shards BEFORE touching layer visibility
            var tileEls = [];
            for (var r = 0; r < rows; r++) {
                for (var cl = 0; cl < cols; cl++) {
                    var t = document.createElement('div');
                    var tx = cl * tileW, ty = r * tileH;
                    var tw = (cl === cols - 1) ? w - tx : tileW;
                    var th = (r  === rows - 1) ? h - ty : tileH;
                    t.style.cssText = 'position:absolute;left:' + tx + 'px;top:' + ty + 'px;' +
                        'width:' + tw + 'px;height:' + th + 'px;overflow:hidden;z-index:4;' +
                        'will-change:transform,opacity;transform-origin:' +
                        (50 + (cl - cols/2) * 15) + '% 50%';
                    var tc = document.createElement('canvas');
                    tc.width = tw; tc.height = th;
                    tc.style.cssText = 'display:block;width:' + tw + 'px;height:' + th + 'px';
                    tc.getContext('2d').drawImage(cvs, tx, ty, tw, th, 0, 0, tw, th);
                    t.appendChild(tc);
                    container.appendChild(t);
                    tileEls.push({ el: t, col: cl, row: r });
                }
            }

            // Hide outEl immediately (tiles are covering it at z-index:4).
            // Keep inEl hidden (opacity:0) until tiles actually start flying —
            // avoids any paint frame where new image shows before tiles render.
            outEl.style.cssText = 'position:absolute;inset:0;opacity:0;z-index:0;transition:none;transform:none';
            inEl.style.cssText  = 'position:absolute;inset:0;opacity:0;z-index:1;transition:none;transform:none';

            var cx = cols / 2, cy = rows / 2;
            // Reveal inEl after first rAF so tiles are guaranteed painted
            requestAnimationFrame(function() {
                requestAnimationFrame(function() {
                    inEl.style.opacity = '1';
                });
            });

            tileEls.forEach(function(item) {
                var dx = (item.col + 0.5 - cx) / cx;
                var dy = (item.row + 0.5 - cy) / cy;
                var dist = Math.sqrt(dx*dx + dy*dy);
                var delay = dist * dur * 0.25;
                var moveX = dx * (80 + Math.random() * 120);
                var moveY = dy * 60 + (50 + Math.random() * 100);
                var rot   = (Math.random() - 0.5) * 60;
                setTimeout(function() {
                    item.el.style.transition = 'transform ' + (dur*0.5) + 'ms cubic-bezier(0.4, 0, 0.2, 1), opacity ' + (dur*0.35) + 'ms ease-in-out';
                    item.el.style.transform  = 'translate(' + moveX + 'px,' + moveY + 'px) rotate(' + rot + 'deg) scale(0.3)';
                    item.el.style.opacity    = '0';
                }, delay);
            });

            setTimeout(function() {
                tileEls.forEach(function(item) { item.el.remove(); });
                done();
            }, dur + 60);
        }

        // ── Fade ────────────────────────────────────────────────────────────
        function fade(container, outEl, inEl, dur, done) {
            outEl.style.zIndex  = '1';
            outEl.style.opacity = '1';
            outEl.style.transition = 'none';
            inEl.style.zIndex   = '2';
            inEl.style.opacity  = '0';
            inEl.style.transition = 'none';
            inEl.offsetHeight;
            inEl.style.transition = 'opacity ' + dur + 'ms ease-in-out';
            inEl.style.opacity  = '1';
            setTimeout(function() {
                outEl.style.opacity = '0';
                done();
            }, dur);
        }

        // ── Fly In (scale zoom + fade, like image carousel flyin) ────────────
        function flyin(container, outEl, inEl, dur, done) {
            var imgEl = inEl.querySelector('img');
            outEl.style.zIndex = '1'; outEl.style.opacity = '1';
            inEl.style.zIndex  = '2'; inEl.style.opacity  = '1';
            // Apply zoom keyframe to incoming slide's bg image if present
            if (imgEl) {
                imgEl.style.animation = 'none'; imgEl.offsetHeight;
                imgEl.style.transformOrigin = 'center bottom';
                imgEl.style.animation = 'impulseFlyIn ' + Math.max(dur * 1.4, 6000) + 'ms ease-in-out forwards';
            }
            // Crossfade: inEl fades in on top
            inEl.style.transition = 'none'; inEl.style.opacity = '0'; inEl.offsetHeight;
            inEl.style.transition = 'opacity ' + dur + 'ms ease-in-out';
            inEl.style.opacity    = '1';
            setTimeout(function() { outEl.style.opacity = '0'; done(); }, dur);
        }

        // ── Jackpot (rapid vertical spin settle for content slides) ──────────
        function jackpot(container, outEl, inEl, dur, done) {
            var h = container.offsetHeight || 400;
            var spinSpeed = 28, DECAY = 0.93;
            var spinPos = 0; // distance scrolled from outEl top
            var traveled = 0, totalDist = h * 4;
            var landTarget = null, easeSpeed = 0;
            var wrap = document.createElement('div');
            wrap.style.cssText = 'position:absolute;inset:0;overflow:hidden;z-index:5';
            // Clone outEl and inEl visually into strip
            var stripH = h * 2;
            var strip = document.createElement('div');
            strip.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:' + stripH + 'px;will-change:transform';
            var outClone = outEl.cloneNode(true);
            var inClone  = inEl.cloneNode(true);
            // Strip editor dropzone classes so MutationObserver doesn't process clones
            [outClone, inClone].forEach(function(cl) {
                cl.removeAttribute('data-id');
                cl.querySelectorAll('[class*="impulse-dropzone"],[class*="impulse-new-slider-dropzone"]').forEach(function(dz) {
                    dz.className = dz.className.replace(/impulse-dropzone|impulse-new-slider-dropzone/g, '').trim();
                });
            });
            outClone.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:' + h + 'px;overflow:hidden;opacity:1;z-index:1;transform:none;transition:none';
            inClone.style.cssText  = 'position:absolute;top:' + h + 'px;left:0;width:100%;height:' + h + 'px;overflow:hidden;opacity:1;z-index:1;transform:none;transition:none';
            strip.appendChild(outClone); strip.appendChild(inClone);
            wrap.appendChild(strip); container.appendChild(wrap);
            outEl.style.opacity = '0'; inEl.style.opacity = '0';

            function starColor(type,v,a){
                if(type===0) return 'rgba(255,255,255,'+a+')';
                var h,s,l;
                if     (type===1){h=210+v*15;s=55;l=88;}
                else if(type===2){h=48+v*10; s=60;l=85;}
                else if(type===3){h=25+v*10; s=65;l=82;}
                else             {h=v*10;    s=60;l=80;}
                return 'hsla('+h+','+s+'%,'+l+'%,'+a+')';
            }

            function frame() {
                if (landTarget !== null) {
                    easeSpeed *= DECAY;
                    spinPos += easeSpeed;
                    var blur = Math.min(8, easeSpeed * 0.28);
                    strip.style.filter = blur > 0.4 ? 'blur(' + blur.toFixed(1) + 'px)' : 'none';
                    strip.style.transform = 'translateY(-' + Math.round(spinPos) + 'px)';
                    if (easeSpeed < 0.4) {
                        spinPos = h; // snap exactly to inClone
                        strip.style.filter = 'none';
                        strip.style.transform = 'translateY(-' + h + 'px)';
                        wrap.remove();
                        inEl.style.opacity = '1';
                        done();
                        return;
                    }
                } else {
                    spinPos += spinSpeed;
                    if (spinPos > stripH) spinPos -= stripH;
                    strip.style.transform = 'translateY(-' + Math.round(spinPos) + 'px)';
                    traveled += spinSpeed;
                    if (traveled >= totalDist) {
                        var minRunway = spinSpeed * 14;
                        landTarget = h + Math.ceil((spinPos + minRunway - h) / h) * h;
                        if (landTarget > stripH) landTarget = h;
                        easeSpeed = spinSpeed;
                    }
                }
                requestAnimationFrame(frame);
            }
            strip.style.filter = 'blur(8px)';
            requestAnimationFrame(frame);
        }

        // ── Reels (fast snappy vertical, multiple quick slots) ───────────────
        function reels(container, outEl, inEl, dur, done) {
            var h = container.offsetHeight || 400;
            var steps = 5, stepDur = dur / (steps + 2);
            var wrap = document.createElement('div');
            wrap.style.cssText = 'position:absolute;inset:0;overflow:hidden;z-index:5';
            var strip = document.createElement('div');
            strip.style.cssText = 'position:absolute;top:0;left:0;width:100%;will-change:transform;transition:none';
            // Build strip: outEl + (steps-1) grey placeholders + inEl
            var allEls = [outEl];
            for (var i = 0; i < steps - 1; i++) {
                var ph = document.createElement('div');
                ph.style.cssText = 'width:100%;height:' + h + 'px;background:#1a1d21';
                allEls.push(ph);
            }
            allEls.push(inEl);
            var totalH = h * allEls.length;
            strip.style.height = totalH + 'px';
            allEls.forEach(function(el, i) {
                var isSlide = el.classList && el.classList.contains('impulse-carousel-slide');
                var cl = isSlide ? el.cloneNode(true) : el;
                if (isSlide) {
                    cl.removeAttribute('data-id');
                    cl.querySelectorAll('.impulse-dropzone,.impulse-new-slider-dropzone').forEach(function(dz) {
                        dz.classList.remove('impulse-dropzone','impulse-new-slider-dropzone');
                    });
                }
                cl.style.cssText = 'position:absolute;top:' + (i * h) + 'px;left:0;width:100%;height:' + h + 'px;overflow:hidden;opacity:1;transform:none;transition:none';
                strip.appendChild(cl);
            });
            wrap.appendChild(strip); container.appendChild(wrap);
            outEl.style.opacity = '0'; inEl.style.opacity = '0';
            var targetY = (allEls.length - 1) * h;
            strip.style.filter = 'blur(6px)';
            strip.style.transition = 'transform ' + (dur * 0.85) + 'ms cubic-bezier(0.12,0,0.5,1), filter ' + (dur * 0.7) + 'ms ease-in-out';
            strip.offsetHeight;
            strip.style.transform = 'translateY(-' + targetY + 'px)';
            strip.style.filter = 'none';
            setTimeout(function() {
                wrap.remove();
                inEl.style.opacity = '1';
                done();
            }, dur + 50);
        }

        return {
            depth_rush:   depth_rush,
            card_flip:    card_flip,
            stack_peel:   stack_peel,
            pixel_decode: pixel_decode,
            shatter:      shatter,
            fade:         fade,
            flyin:        flyin,
            jackpot:      jackpot,
            reels:        reels
        };
    })();

    // ========================================
    // CAROUSEL FUNCTIONALITY
    // ========================================
    
    function initCarousels() {
        document.querySelectorAll('.impulse-image-carousel').forEach(function(carousel) {
            if (carousel._carouselInit) return;
            carousel._carouselInit = true;
            
            var slides = carousel.querySelector('.impulse-carousel-slides');
            var slideItems = carousel.querySelectorAll('.impulse-carousel-slide');
            var mode = carousel.dataset.mode || 'scroll';
            // slots renders .impulse-slot-reel strips, not .impulse-carousel-slide,
            // so it must be in this passthrough list — otherwise init early-returns
            // and the reels never spin.
            var _3dModes = ['slots','jackpot','reels','table','orbit','depth_rush','card_flip','pixel_decode','coverflow','cube','conveyor','portal'];
            if (!slides || (slideItems.length === 0 && _3dModes.indexOf(mode) === -1)) return;
            var direction = carousel.dataset.direction || 'left';
            var speed = parseInt(carousel.dataset.speed) || 20;
            var autoplay = carousel.dataset.autoplay === 'true';
            var interval = parseInt(carousel.dataset.interval) || 4000;
            var pauseOnHover = carousel.dataset.pause === 'true';
            var itemsToShow = parseInt(carousel.dataset.items) || 1;
            var gap = parseInt(carousel.dataset.gap) || 0;
            var originalCount = parseInt(carousel.dataset.slideCount) || slideItems.length;
            
            if (mode === 'scroll') {
                // Continuous marquee — GPU-accelerated, pixel-rounded
                var position = 0;
                var isPaused = false;
                var animId = null;
                
                function getSetWidth() {
                    if (originalCount < 1) originalCount = slideItems.length;
                    var w = 0;
                    for (var i = 0; i < originalCount; i++) {
                        if (slideItems[i]) {
                            var bcr = slideItems[i].getBoundingClientRect();
                            w += (bcr.width || slideItems[i].offsetWidth) + gap;
                        }
                    }
                    return w;
                }
                
                var setWidth = 0;
                
                function animate() {
                    if (!isPaused) {
                        if (setWidth === 0) setWidth = getSetWidth();
                        if (setWidth > 0) {
                            var pxPerFrame = (slides.parentElement.offsetWidth / speed) / 60;
                            if (direction === 'left') {
                                position -= pxPerFrame;
                                if (position <= -setWidth) position += setWidth;
                            } else {
                                position += pxPerFrame;
                                if (position >= 0) position -= setWidth;
                            }
                            slides.style.transform = 'translate3d(' + Math.round(position) + 'px,0,0)';
                        }
                    }
                    animId = requestAnimationFrame(animate);
                }
                
                if (autoplay) {
                    setTimeout(function() {
                        setWidth = getSetWidth();
                        var trackW = slides.parentElement.offsetWidth;
                        if (direction === 'left') {
                            // Viewport already filled: offset into the first set by trackWidth,
                            // so frame 0 shows a full screen of slides instead of starting from edge.
                            // position must stay in (-setWidth, 0] for wrap logic to work.
                            position = -trackW;
                            if (position <= -setWidth) position = -(setWidth * 0.5);
                        } else {
                            // direction right: slides move rightward.
                            // Start so viewport is filled from the right edge.
                            // position must stay in (-setWidth, 0) for wrap (position >= 0).
                            position = -(setWidth - trackW);
                            if (position >= 0) position = -(setWidth * 0.5);
                            if (position <= -setWidth) position = -(setWidth * 0.5);
                        }
                        animate();
                    }, 50);
                }
                
                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { isPaused = true; });
                    carousel.addEventListener('mouseleave', function() { isPaused = false; });
                }
                
                carousel._destroyCarousel = function() {
                    if (animId) cancelAnimationFrame(animId);
                };
                
            } else if (mode === 'slots') {
                // Slot machine: CSS-transition spin, always lands on item boundaries.
                //   • state.pos = canonical index 0..count-1
                //   • strip is rendered with (SPIN_CYCLES+1)*count + 1 items so
                //     animation can advance SPIN_CYCLES full cycles + 1 step
                //     without overflowing the strip.
                //   • Each spin: animate translateY from pos*h to
                //     (pos + SPIN_CYCLES*count + 1)*h via a single transition,
                //     then snap to canonical -nextPos*h.
                //   The item index at the end matches images[nextPos], so the
                //   snap is visually invisible (same image, same place).
                var reels = carousel.querySelectorAll('.impulse-slot-reel');
                if (reels.length === 0) return;
                var autoplayTimer = null;
                var isPaused = false;
                var reelHeight = reels[0].offsetHeight;
                var reelStates = [];
                var slotSpinning = false;
                var slotSpinningCount = 0;
                // SLOTS signature — modest spin, light blur, clean ease-in-out.
                // The mechanical-reel look: enough motion to read as a spin
                // but landing reads sharp from mid-animation onward.
                var SPIN_CYCLES = 2;
                var SPIN_DUR_MS = 2000;
                var SPIN_BLUR_PX = 5;
                var SPIN_EASING  = 'cubic-bezier(0.4, 0, 0.2, 1)';

                reels.forEach(function(reel, ri) {
                    var strip = reel.querySelector('.impulse-slot-strip');
                    var count = parseInt(reel.dataset.count) || originalCount;
                    var totalItems = strip.querySelectorAll('.impulse-slot-item').length;
                    // Force strip to canonical pos=0 at init.
                    strip.style.transition = 'none';
                    strip.style.transform = 'translateY(0)';
                    strip.style.filter = 'none';
                    reelStates.push({ strip: strip, pos: 0, count: count, totalItems: totalItems });
                });

                function spinReel(ri) {
                    var state = reelStates[ri];
                    var strip = state.strip;
                    var nextPos = (state.pos + 1) % state.count;
                    var startY  = state.pos * reelHeight;
                    var spinDist = (SPIN_CYCLES * state.count + 1) * reelHeight;
                    var endY = startY + spinDist;
                    // Pre-state — strip at canonical start, blur on.
                    strip.style.transition = 'none';
                    strip.style.transform  = 'translateY(-' + startY + 'px)';
                    strip.style.filter     = 'blur(' + SPIN_BLUR_PX + 'px)';
                    strip.offsetHeight; // force reflow so the transition picks up the new state
                    // Animate to endY. Filter clears in the back half so the
                    // landing image reads sharp.
                    strip.style.transition = 'transform ' + SPIN_DUR_MS + 'ms ' + SPIN_EASING + ', filter 350ms ease-in-out ' + (SPIN_DUR_MS - 350) + 'ms';
                    strip.style.filter     = 'none';
                    strip.style.transform  = 'translateY(-' + endY + 'px)';
                    slotSpinningCount++;
                    setTimeout(function() {
                        // Snap to canonical position — same image, no visible jump.
                        strip.style.transition = 'none';
                        strip.style.transform  = 'translateY(-' + (nextPos * reelHeight) + 'px)';
                        state.pos = nextPos;
                        slotSpinningCount--;
                        if (slotSpinningCount === 0) slotSpinning = false;
                    }, SPIN_DUR_MS + 60);
                }

                function spinReels() {
                    if (slotSpinning) return;
                    slotSpinning = true;
                    reels.forEach(function(reel, ri) {
                        // Stagger each reel's start so the bank reads as a slot machine.
                        setTimeout(function() { spinReel(ri); }, ri * 180);
                    });
                }

                if (autoplay) {
                    setTimeout(function(){ if (!isPaused) spinReels(); }, 300);
                    autoplayTimer = setInterval(function() {
                        if (!isPaused) spinReels();
                    }, interval);
                }

                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { isPaused = true; });
                    carousel.addEventListener('mouseleave', function() { isPaused = false; });
                }

                carousel._destroyCarousel = function() {
                    if (autoplayTimer) clearInterval(autoplayTimer);
                };

            } else if (mode === 'jackpot') {
                // Jackpot: CSS-transition spin with more cycles than slots and
                // a heavier blur — same alignment-guaranteed algorithm.
                var reels = carousel.querySelectorAll('.impulse-slot-reel');
                if (reels.length === 0) return;
                var autoplayTimer = null;
                var isPaused = false;
                var reelHeight = reels[0].offsetHeight;
                var spinning = false;
                var spinningCount = 0;
                var reelStates = [];
                // JACKPOT signature — anticipation pull-back, then a long
                // deliberate spin. 4 cycles in 3.5 seconds reads as a heavy
                // wheel — more total motion than slots (×2) and reels (×2.5),
                // and a slower deceleration curve so the landing image
                // surfaces gradually rather than snapping into place.
                var SPIN_CYCLES = 4;
                var SPIN_DUR_MS = 3500;
                var JP_ANTICIP_MS = 280;
                var JP_ANTICIP_PX = 28; // pull strip "up" by this before the spin
                var JP_BLUR_PX = 9;
                var JP_EASING = 'cubic-bezier(0.45,0,0.2,1)';

                reels.forEach(function(reel, ri) {
                    var strip = reel.querySelector('.impulse-slot-strip');
                    var count = parseInt(reel.dataset.count) || originalCount;
                    var totalItems = strip.querySelectorAll('.impulse-slot-item').length;
                    strip.style.transition = 'none';
                    strip.style.transform = 'translateY(0)';
                    strip.style.filter = 'none';
                    reelStates.push({ strip: strip, pos: 0, count: count, totalItems: totalItems });
                });

                function jackpotSpinReel(ri) {
                    var state = reelStates[ri];
                    var strip = state.strip;
                    var nextPos = (state.pos + 1) % state.count;
                    var startY = state.pos * reelHeight;
                    var spinDist = (SPIN_CYCLES * state.count + 1) * reelHeight;
                    var endY = startY + spinDist;
                    var dur = SPIN_DUR_MS + ri * 250; // stagger landing per reel
                    // Anticipation: pull strip "up" (translateY less negative)
                    // briefly, like a slot-machine arm winding before release.
                    strip.style.transition = 'none';
                    strip.style.transform  = 'translateY(-' + startY + 'px)';
                    strip.style.filter     = 'none';
                    strip.offsetHeight;
                    strip.style.transition = 'transform ' + JP_ANTICIP_MS + 'ms cubic-bezier(0.4,0,0.6,1)';
                    strip.style.transform  = 'translateY(-' + (startY - JP_ANTICIP_PX) + 'px)';
                    spinningCount++;
                    setTimeout(function() {
                        // Main spin — release into the long deliberate sweep.
                        strip.style.transition = 'transform ' + dur + 'ms ' + JP_EASING + ', filter 500ms ease-in-out ' + (dur - 500) + 'ms';
                        strip.style.filter     = 'blur(' + JP_BLUR_PX + 'px)';
                        strip.style.transform  = 'translateY(-' + endY + 'px)';
                        // Clear blur in back half via the transition above.
                        setTimeout(function(){ strip.style.filter = 'none'; }, dur - 480);
                        setTimeout(function() {
                            strip.style.transition = 'none';
                            strip.style.transform  = 'translateY(-' + (nextPos * reelHeight) + 'px)';
                            state.pos = nextPos;
                            spinningCount--;
                            if (spinningCount === 0) spinning = false;
                        }, dur + 60);
                    }, JP_ANTICIP_MS + 20);
                }

                function jackpotSpin() {
                    if (spinning) return;
                    spinning = true;
                    reels.forEach(function(reel, ri) { jackpotSpinReel(ri); });
                }

                if (autoplay) {
                    setTimeout(function(){ if (!isPaused && !spinning) jackpotSpin(); }, 300);
                    autoplayTimer = setInterval(function() {
                        if (!isPaused && !spinning) jackpotSpin();
                    }, interval);
                }

                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { isPaused = true; });
                    carousel.addEventListener('mouseleave', function() { isPaused = false; });
                }

                carousel._destroyCarousel = function() {
                    if (autoplayTimer) clearInterval(autoplayTimer);
                };
                
            } else if (mode === 'reels') {
                // Reels: same alignment-guaranteed algorithm as slots/jackpot,
                // but lighter blur and faster spin to give a snappier "reels"
                // feel. Strip translates DOWN only (translateY becomes more
                // negative). No overshoot, no bounce-back — the previous
                // 3-phase warp landed the strip with a downward snap that
                // looked like the image "emerged from below" after the spin.
                var reels = carousel.querySelectorAll('.impulse-slot-reel');
                if (reels.length === 0) return;
                var autoplayTimer = null;
                var isPaused = false;
                var reelHeight = reels[0].offsetHeight;
                var spinning = false;
                var spinningCount = 0;
                var reelStates = [];
                // REELS signature — more cycles per second than slots, heavy
                // blur during, sharp ease-in-out so the spin reads as a real
                // casino reels game (fast wheels, hard stop). 3 cycles in
                // 1.5s = ~1700px/sec, almost 2× the throughput of slots.
                var R_SPIN_CYCLES = 3;
                var R_SPIN_DUR_MS = 1500;
                var R_SPIN_BLUR_PX = 12;
                var R_SPIN_EASING  = 'cubic-bezier(0.35,0,0.05,1)';

                reels.forEach(function(reel, ri) {
                    var strip = reel.querySelector('.impulse-slot-strip');
                    var count = parseInt(reel.dataset.count) || originalCount;
                    var totalItems = strip.querySelectorAll('.impulse-slot-item').length;
                    strip.style.transition = 'none';
                    strip.style.transform = 'translateY(0)';
                    strip.style.filter = 'none';
                    reelStates.push({ strip: strip, pos: 0, count: count, totalItems: totalItems });
                });

                function reelsSpinReel(ri) {
                    var state = reelStates[ri];
                    var strip = state.strip;
                    var nextPos = (state.pos + 1) % state.count;
                    var startY = state.pos * reelHeight;
                    var spinDist = (R_SPIN_CYCLES * state.count + 1) * reelHeight;
                    var endY = startY + spinDist;
                    var dur = R_SPIN_DUR_MS + ri * 220;
                    strip.style.transition = 'none';
                    strip.style.transform  = 'translateY(-' + startY + 'px)';
                    strip.style.filter     = 'blur(' + R_SPIN_BLUR_PX + 'px)';
                    strip.offsetHeight;
                    strip.style.transition = 'transform ' + dur + 'ms ' + R_SPIN_EASING + ', filter 280ms ease-in-out ' + (dur - 280) + 'ms';
                    strip.style.filter     = 'none';
                    strip.style.transform  = 'translateY(-' + endY + 'px)';
                    spinningCount++;
                    setTimeout(function() {
                        strip.style.transition = 'none';
                        strip.style.transform  = 'translateY(-' + (nextPos * reelHeight) + 'px)';
                        state.pos = nextPos;
                        spinningCount--;
                        if (spinningCount === 0) spinning = false;
                    }, dur + 60);
                }

                function reelsSpin() {
                    if (spinning) return;
                    spinning = true;
                    reels.forEach(function(reel, ri) { reelsSpinReel(ri); });
                }

                if (autoplay) {
                    setTimeout(function(){ if (!isPaused && !spinning) reelsSpin(); }, 300);
                    autoplayTimer = setInterval(function() {
                        if (!isPaused && !spinning) reelsSpin();
                    }, interval);
                }

                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { isPaused = true; });
                    carousel.addEventListener('mouseleave', function() { isPaused = false; });
                }

                carousel._destroyCarousel = function() {
                    if (autoplayTimer) clearInterval(autoplayTimer);
                };

            } else if (mode === 'table' || mode === 'orbit') {
                // Turntable/Orbit/Ring: 3D circular continuous rotation, infinite
                var items = slides.querySelectorAll('.impulse-turntable-item');
                if (items.length === 0) { console.warn('[impulse carousel] orbit/table mode but no .impulse-turntable-item found'); return; }
                slides.style.position = 'relative';
                slides.style.width = '100%';
                slides.style.height = '100%';
                slides.style.overflow = 'visible';
                var totalItems = items.length;
                var isPaused = false;
                var animId = null;
                var containerW = slides.offsetWidth;
                var containerH = slides.offsetHeight;
                var TWO_PI = Math.PI * 2;
                
                var xSpread, yOffsetFactor, scaleMin, scaleMax, blurMax;
                if (mode === 'orbit') {
                    // Orbit — pronounced vertical wobble, deeper depth scaling.
                    // Reads as a 3D ring tilted toward the viewer.
                    xSpread = 0.40; yOffsetFactor = 0.20; scaleMin = 0.32; scaleMax = 1.0; blurMax = 18;
                } else {
                    // Table — flat ring viewed from above, no wobble.
                    // scaleX(cosA) below makes items compress sideways into the
                    // ring like vinyl records seen in perspective.
                    xSpread = 0.45; yOffsetFactor = 0; scaleMin = 0.45; scaleMax = 1.0; blurMax = 14;
                }
                
                var angles = [];
                for (var i = 0; i < totalItems; i++) {
                    angles.push((i / totalItems) * TWO_PI);
                }
                
                // Orbit/Table rotation: hardcoded to ~8 sec/revolution. Fast
                // enough that the visual difference between the two modes is
                // legible (orbit's vertical wobble, table's horizontal squish
                // via scaleX) within a few seconds of viewing. Decoupled from
                // `speed` so a high scroll_speed setting can't make them
                // spin like a fan.
                var radiansPerFrame = 0.012;
                var dirMult = (direction === 'right') ? -1 : 1;
                var frameCount = 0;
                
                function updatePositions() {
                    containerW = slides.offsetWidth;
                    containerH = slides.offsetHeight;
                    
                    for (var i = 0; i < totalItems; i++) {
                        var angle = angles[i];
                        var sinA = Math.sin(angle);
                        var cosA = Math.cos(angle);
                        var depth = (cosA + 1) / 2;
                        var x = sinA * xSpread * containerW;
                        var y = yOffsetFactor > 0 ? -cosA * yOffsetFactor * containerH : 0;
                        
                        var sc = scaleMin + (scaleMax - scaleMin) * depth;
                        var bl = blurMax * (1 - depth) * (1 - depth);
                        var item = items[i];
                        
                        if (mode === 'table') {
                            var scX = cosA;
                            item.style.transform = 'translateX(calc(-50% + ' + x.toFixed(1) + 'px)) translateY(' + y.toFixed(1) + 'px) scale(' + sc.toFixed(3) + ') scaleX(' + scX.toFixed(3) + ')';
                        } else {
                            item.style.transform = 'translateX(calc(-50% + ' + x.toFixed(1) + 'px)) translateY(' + y.toFixed(1) + 'px) scale(' + sc.toFixed(3) + ')';
                        }
                        item.style.filter = bl > 0.3 ? 'blur(' + bl.toFixed(1) + 'px)' : 'none';
                        item.style.opacity = '1';
                        item.style.zIndex = Math.round(depth * 1000);
                    }
                }
                
                function animate() {
                    if (!isPaused) {
                        frameCount++;
                        for (var i = 0; i < totalItems; i++) {
                            angles[i] = (angles[i] + radiansPerFrame * dirMult) % TWO_PI;
                            if (angles[i] < 0) angles[i] += TWO_PI;
                        }
                        updatePositions();
                    }
                    animId = requestAnimationFrame(animate);
                }
                
                updatePositions();
                animId = requestAnimationFrame(animate);
                
                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { isPaused = true; });
                    carousel.addEventListener('mouseleave', function() { isPaused = false; });
                }
                
                carousel._destroyCarousel = function() {
                    if (animId) cancelAnimationFrame(animId);
                };

            } else if (mode === 'cube') {
                var cuSlides = slides.querySelectorAll('.impulse-carousel-slide');
                var cuTotal = cuSlides.length;
                if (cuTotal < 2) return;
                slides.style.position = 'relative'; slides.style.overflow = 'visible'; slides.style.display = 'block'; slides.style.height = '100%'; slides.style.transformStyle = 'preserve-3d'; slides.style.transition = 'transform 0.75s cubic-bezier(0.4, 0, 0.2, 1)';
                var cuTrack = slides.parentElement;
                if (cuTrack) { cuTrack.style.perspective = '1600px'; cuTrack.style.perspectiveOrigin = 'center center'; cuTrack.style.overflow = 'visible'; }
                var cuFaceAngle = 360 / cuTotal;
                var cuApothem = 0;
                function cuCalc() { var w = slides.offsetWidth || (cuTrack ? cuTrack.offsetWidth : 600); cuApothem = (w / 2) / Math.max(Math.tan((cuFaceAngle / 2) * Math.PI / 180), 0.01); }
                cuCalc();
                cuSlides.forEach(function(s, i) {
                    s.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;transform-style:preserve-3d;backface-visibility:hidden;transition:none';
                    s.style.transform = 'rotateY(' + (i * cuFaceAngle) + 'deg) translateZ(' + cuApothem + 'px)';
                });
                // Accumulate the rotation step counter — do NOT wrap. Previously
                // `cuCurrent = (cuCurrent + 1) % cuTotal` caused the cube to snap
                // backward by (cuTotal-1) * faceAngle every full revolution,
                // which read as "rolls back and restarts". Now the angle only
                // grows in one direction, giving a clean continuous loop.
                var cuStep = 0;
                function cuUpdate() { slides.style.transform = 'translateZ(-' + cuApothem + 'px) rotateY(' + (-cuStep * cuFaceAngle) + 'deg)'; }
                cuUpdate();
                var cuAutoplay = null, cuPaused = false;
                if (autoplay) {
                    setTimeout(function(){ if (!cuPaused) { cuStep++; cuUpdate(); } }, 300);
                    cuAutoplay = setInterval(function() { if (!cuPaused) { cuStep++; cuUpdate(); } }, interval);
                }
                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { cuPaused = true; });
                    carousel.addEventListener('mouseleave', function() { cuPaused = false; });
                }
                carousel._destroyCarousel = function() { if (cuAutoplay) clearInterval(cuAutoplay); };

            } else if (mode === 'conveyor') {
                var cvSlides = slides.querySelectorAll('.impulse-carousel-slide');
                var cvTotal = cvSlides.length;
                if (cvTotal < 1) return;
                slides.style.position = 'relative'; slides.style.overflow = 'visible'; slides.style.display = 'block'; slides.style.height = '100%'; slides.style.transformStyle = 'preserve-3d'; slides.style.transform = 'rotateX(0deg)';
                var cvTrack = slides.parentElement;
                if (cvTrack) { cvTrack.style.perspective = '1400px'; cvTrack.style.perspectiveOrigin = 'center 25%'; cvTrack.style.overflow = 'visible'; }
                cvSlides.forEach(function(s) { s.style.cssText = 'position:absolute;top:50%;left:50%;width:50%;height:80%;transform-style:preserve-3d;backface-visibility:hidden;transition:transform 0.6s cubic-bezier(0.4, 0, 0.2, 1),opacity 0.6s ease-in-out,filter 0.35s ease-in-out;cursor:pointer;will-change:transform,filter'; });
                var cvCurrent = 0;
                var cvMx = 0, cvMy = 0;
                function cvUpdate() {
                    for (var i = 0; i < cvTotal; i++) {
                        var dist = i - cvCurrent;
                        if (dist >  cvTotal / 2) dist -= cvTotal;
                        if (dist < -cvTotal / 2) dist += cvTotal;
                        var abs = Math.abs(dist);
                        var sign = dist > 0 ? 1 : (dist < 0 ? -1 : 0);
                        var tx = sign * Math.min(abs, 5) * 55;
                        var tz = -abs * 160;
                        var sc = Math.max(0.55, 1 - abs * 0.1);
                        var op = abs > 4 ? 0 : Math.max(0.3, 1 - abs * 0.18);
                        var s = cvSlides[i];
                        s.style.transform = 'translate(-50%,-50%) translateX(' + tx.toFixed(1) + '%) translateZ(' + tz.toFixed(0) + 'px) scale(' + sc.toFixed(3) + ')';
                        s.style.opacity = op.toFixed(3);
                        s.style.zIndex = String(100 - abs);
                        s.style.pointerEvents = abs > 4 ? 'none' : 'auto';
                        var bl = (abs === 0) ? 0 : Math.min(14, abs * 5);
                        s.style.filter = bl > 0 ? 'blur(' + bl.toFixed(1) + 'px)' : 'none';
                    }
                }
                // Belt starts flat (horizontal). Mousemove updates the
                // *target* tilt but the actual belt position is eased toward
                // that target inside a single rAF loop. The previous version
                // recomputed and wrote a new transform on every mousemove
                // event, which on a high-refresh-rate input device fires far
                // more often than 60Hz — the belt jittered because new
                // targets arrived between ease-in-out ticks. Now mousemove only
                // updates cvMx/cvMy, never writes; rAF owns all writes.
                var cvRx = 0, cvRy = 0, cvRxT = 0, cvRyT = 0, cvEaseId = null;
                function cvWriteBelt() { slides.style.transform = 'rotateX(' + cvRx.toFixed(2) + 'deg) rotateY(' + cvRy.toFixed(2) + 'deg)'; }
                function cvEase() {
                    // Refresh target from latest cursor position on every frame.
                    // Tilt amplitudes trimmed (22 → 14, 6 → 4) so small cursor
                    // movements don't produce dramatic belt swings.
                    cvRxT = cvMy * 14;
                    cvRyT = cvMx * 4;
                    var dx = cvRxT - cvRx, dy = cvRyT - cvRy;
                    if (Math.abs(dx) < 0.03 && Math.abs(dy) < 0.03) {
                        cvRx = cvRxT; cvRy = cvRyT; cvWriteBelt(); cvEaseId = null; return;
                    }
                    // Slower follow (0.04 vs old 0.08) — reads as smooth glide
                    // rather than the snap-to-target jerk the old factor gave.
                    cvRx += dx * 0.04; cvRy += dy * 0.04; cvWriteBelt();
                    cvEaseId = requestAnimationFrame(cvEase);
                }
                function cvKickEase() { if (!cvEaseId) cvEaseId = requestAnimationFrame(cvEase); }
                cvWriteBelt();
                cvUpdate();
                cvSlides.forEach(function(s, i) { s.addEventListener('click', function(e) { if (i !== cvCurrent) { e.preventDefault(); cvCurrent = i; cvUpdate(); } }); });
                carousel.addEventListener('mousemove', function(e) {
                    var r = carousel.getBoundingClientRect();
                    cvMx = ((e.clientX - r.left) / r.width) * 2 - 1;
                    cvMy = ((e.clientY - r.top) / r.height) * 2 - 1;
                    cvKickEase();
                });
                carousel.addEventListener('mouseleave', function() {
                    cvMx = 0; cvMy = 0; cvKickEase();
                });
                var cvAutoplay = null, cvPaused = false;
                if (autoplay) cvAutoplay = setInterval(function() { if (!cvPaused) { cvCurrent = (cvCurrent + 1) % cvTotal; cvUpdate(); } }, interval);
                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { cvPaused = true; });
                    carousel.addEventListener('mouseleave', function() { cvPaused = false; });
                }
                carousel._destroyCarousel = function() { if (cvAutoplay) clearInterval(cvAutoplay); };

            } else if (mode === 'portal') {
                var ptSlides = slides.querySelectorAll('.impulse-carousel-slide');
                var ptTotal = ptSlides.length;
                if (ptTotal < 1) return;
                slides.style.position = 'relative'; slides.style.overflow = 'visible'; slides.style.display = 'block'; slides.style.height = '100%'; slides.style.transformStyle = 'preserve-3d';
                var ptTrack = slides.parentElement;
                if (ptTrack) { ptTrack.style.perspective = '1200px'; ptTrack.style.perspectiveOrigin = 'center center'; ptTrack.style.overflow = 'visible'; }
                var PT_DUR = 1100;
                ptSlides.forEach(function(s) {
                    s.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;transform-style:preserve-3d;backface-visibility:hidden;will-change:transform,clip-path,opacity;transition:clip-path ' + PT_DUR + 'ms cubic-bezier(0.4, 0, 0.2, 1),transform ' + PT_DUR + 'ms cubic-bezier(0.4, 0, 0.2, 1),opacity ' + (PT_DUR - 200) + 'ms ease-in-out;clip-path:circle(0% at 50% 50%);transform:translateZ(-800px) scale(0.6) rotateZ(-30deg);opacity:0';
                });
                ptSlides[0].style.clipPath = 'circle(140% at 50% 50%)';
                ptSlides[0].style.transform = 'translateZ(0px) scale(1) rotateZ(0deg)';
                ptSlides[0].style.opacity = '1';
                ptSlides[0].style.zIndex = '10';
                var ptCurrent = 0;
                var ptBusy = false;
                function ptGoTo(next) {
                    var prev = ptCurrent;
                    ptCurrent = ((next % ptTotal) + ptTotal) % ptTotal;
                    if (prev === ptCurrent || ptBusy) return;
                    ptBusy = true;
                    var outS = ptSlides[prev], inS = ptSlides[ptCurrent];
                    inS.style.transition = 'none';
                    inS.style.clipPath   = 'circle(0% at 50% 50%)';
                    inS.style.transform  = 'translateZ(-800px) scale(0.6) rotateZ(-30deg)';
                    inS.style.opacity    = '0';
                    inS.style.zIndex     = '11';
                    void inS.offsetHeight;
                    inS.style.transition = 'clip-path ' + PT_DUR + 'ms cubic-bezier(0.4, 0, 0.2, 1),transform ' + PT_DUR + 'ms cubic-bezier(0.4, 0, 0.2, 1),opacity ' + (PT_DUR - 200) + 'ms ease-in-out';
                    outS.style.zIndex    = '9';
                    outS.style.clipPath  = 'circle(0% at 50% 50%)';
                    outS.style.transform = 'translateZ(400px) scale(1.4) rotateZ(25deg)';
                    outS.style.opacity   = '0';
                    inS.style.clipPath   = 'circle(140% at 50% 50%)';
                    inS.style.transform  = 'translateZ(0px) scale(1) rotateZ(0deg)';
                    inS.style.opacity    = '1';
                    setTimeout(function() { inS.style.zIndex = '10'; ptBusy = false; }, PT_DUR + 50);
                }
                var ptAutoplay = null, ptPaused = false;
                if (autoplay) ptAutoplay = setInterval(function() { if (!ptPaused) ptGoTo(ptCurrent + 1); }, Math.max(interval, PT_DUR + 500));
                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { ptPaused = true; });
                    carousel.addEventListener('mouseleave', function() { ptPaused = false; });
                }
                carousel._destroyCarousel = function() { if (ptAutoplay) clearInterval(ptAutoplay); };

            } else if (mode === 'coverflow') {
                // Apple-style Coverflow (editor inline mirror of impulse-carousel-init.js)
                var cfSlides = slides.querySelectorAll('.impulse-carousel-slide');
                var cfTotal = cfSlides.length;
                if (cfTotal < 1) return;
                slides.style.position = 'relative';
                slides.style.overflow = 'visible';
                slides.style.display  = 'block';
                slides.style.height   = '100%';
                var cfTrack = slides.parentElement;
                if (cfTrack) {
                    cfTrack.style.perspective = '1400px';
                    cfTrack.style.perspectiveOrigin = 'center 50%';
                    cfTrack.style.overflow = 'hidden';
                    // Soft far edges: fade the outer bands so a shorter side (fewer
                    // back-slides that side) never shows a bare slide boundary or a hard
                    // overflow clip against the background. Plateau 20-80% keeps the
                    // active (centre) card fully crisp; only the peeking neighbours fade.
                    var _cfMask = 'linear-gradient(90deg, transparent 0%, rgba(0,0,0,0.15) 11%, #000 20%, #000 80%, rgba(0,0,0,0.15) 89%, transparent 100%)';
                    cfTrack.style.webkitMaskImage = _cfMask;
                    cfTrack.style.maskImage = _cfMask;
                }
                cfSlides.forEach(function(s) {
                    s.style.cssText = 'position:absolute;top:0;left:50%;width:60%;height:100%;transform-style:preserve-3d;backface-visibility:hidden;will-change:transform,opacity,filter;transition:transform 0.55s cubic-bezier(0.4, 0, 0.2, 1),opacity 0.55s ease-in-out,filter 0.55s ease-in-out;cursor:pointer';
                });
                var cfCurrent = 0;
                var cfAutoplayTimer = null;
                var cfPaused = false;
                function cfUpdate() {
                    for (var i = 0; i < cfTotal; i++) {
                        var dist = i - cfCurrent;
                        if (dist >  cfTotal / 2) dist -= cfTotal;
                        if (dist < -cfTotal / 2) dist += cfTotal;
                        var abs = Math.abs(dist);
                        var sign = dist > 0 ? 1 : (dist < 0 ? -1 : 0);
                        var tx = sign * Math.min(abs, 4) * 38;
                        var tz = -abs * 180;
                        var ry = abs === 0 ? 0 : sign * -55;
                        var sc = Math.max(0.55, 1 - abs * 0.08);
                        var op = abs > 3 ? 0 : Math.max(0.25, 1 - abs * 0.22);
                        var s = cfSlides[i];
                        s.style.transform = 'translate(-50%,0) translateX(' + tx.toFixed(1) + '%) translateZ(' + tz.toFixed(0) + 'px) rotateY(' + ry + 'deg) scale(' + sc.toFixed(3) + ')';
                        s.style.opacity = op.toFixed(3);
                        s.style.zIndex = String(100 - abs);
                        s.style.pointerEvents = abs > 3 ? 'none' : 'auto';
                        s.style.filter = abs === 0 ? 'none' : 'blur(' + Math.min(24, abs * 10).toFixed(1) + 'px)';
                    }
                }
                function cfGoTo(index) {
                    cfCurrent = ((index % cfTotal) + cfTotal) % cfTotal;
                    cfUpdate();
                }
                cfUpdate();
                cfSlides.forEach(function(s, i) {
                    s.addEventListener('click', function(e) {
                        if (i !== cfCurrent) { e.preventDefault(); cfGoTo(i); }
                    });
                });
                if (autoplay) {
                    cfAutoplayTimer = setInterval(function() {
                        if (!cfPaused) cfGoTo(cfCurrent + 1);
                    }, interval);
                }
                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { cfPaused = true; });
                    carousel.addEventListener('mouseleave', function() { cfPaused = false; });
                }
                carousel._destroyCarousel = function() { if (cfAutoplayTimer) clearInterval(cfAutoplayTimer); };

            } else if (mode === 'depth_rush' || mode === 'card_flip' || mode === 'pixel_decode') {
                // All effect implementations live in ImpulseCarouselEffects (defined above initCarousels)
                var allSlides = slides.querySelectorAll('.impulse-carousel-slide');
                var total = allSlides.length;
                if (total < 2) return;
                // card_flip: give stack 3D perspective + backface-hidden slides so
                // rotateY through 90° doesn't expose a zero-width edge (white vertical axis).
                if (mode === 'card_flip') {
                    slides.style.position = 'relative';
                    slides.style.overflow = 'visible';
                    slides.style.perspective = '1500px';
                    slides.style.transformStyle = 'preserve-3d';
                    var _flipTrack = slides.parentElement;
                    if (_flipTrack) _flipTrack.style.overflow = 'visible';
                }
                var _flipBackface = (mode === 'card_flip') ? 'backface-visibility:hidden;-webkit-backface-visibility:hidden;' : '';
                allSlides.forEach(function(s, i) {
                    s.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:' + (i === 0 ? '1' : '0') + ';z-index:' + (i === 0 ? '2' : '0') + ';transition:none;transform:none;' + _flipBackface;
                });
                var currentIndex = 0;
                var isAnimating = false;
                var autoplayTimer = null;
                var isPaused = false;
                var TRANS_MS = Math.max(900, Math.min(interval * 0.6, 2000));
                var effectFn = ImpulseCarouselEffects[mode];

                function showSlide(index) {
                    if (isAnimating || !effectFn) return;
                    isAnimating = true;
                    var prev = currentIndex;
                    currentIndex = index;
                    var outEl = allSlides[prev];
                    var inEl  = allSlides[index];
                    allSlides.forEach(function(s, i) {
                        if (i !== prev && i !== index) {
                            s.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0;z-index:0;transition:none;transform:none;' + _flipBackface;
                        }
                    });
                    // Safety watchdog: if the effect's `done` callback never
                    // fires (canvas draw threw on a not-yet-decoded image,
                    // etc.) the carousel would otherwise wedge with
                    // isAnimating=true forever, leaving the user stuck on
                    // one slide. Watchdog finalises after TRANS_MS+800ms.
                    var _doneFired = false;
                    var _safety = setTimeout(function(){
                        if (_doneFired) return;
                        _doneFired = true;
                        inEl.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;z-index:2;transition:none;transform:none;' + _flipBackface;
                        outEl.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0;z-index:0;transition:none;transform:none;' + _flipBackface;
                        isAnimating = false;
                    }, TRANS_MS + 800);
                    try {
                        effectFn(slides, outEl, inEl, TRANS_MS, function() {
                            if (_doneFired) return;
                            _doneFired = true;
                            clearTimeout(_safety);
                            outEl.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0;z-index:0;transition:none;transform:none;' + _flipBackface;
                            inEl.style.cssText  = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;z-index:2;transition:none;transform:none;' + _flipBackface;
                            isAnimating = false;
                        });
                    } catch (_e) {
                        // Let the safety watchdog finalise.
                    }
                }

                if (autoplay) {
                    setTimeout(function(){ if (!isPaused && !isAnimating) showSlide((currentIndex + 1) % total); }, 300);
                    autoplayTimer = setInterval(function() {
                        if (!isPaused && !isAnimating) showSlide((currentIndex + 1) % total);
                    }, interval);
                }
                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', function() { isPaused = true; });
                    carousel.addEventListener('mouseleave', function() { isPaused = false; });
                }
                carousel._destroyCarousel = function() { if (autoplayTimer) clearInterval(autoplayTimer); };
            }
        });
    }
    

    // ── Drag-to-navigate helper ──────────────────────────────────────────
    // Attaches mouse/touch drag to a carousel track element.
    // onRelease(delta)   — called on mouseup/touchend with total pixel delta
    // onDragMove(delta)  — called each frame during drag (optional, for live track follow)
    function attachCarouselDrag(track, onRelease, onDragMove, onDragStart) {
        var dragging = false, startX = 0, currentX = 0, didDrag = false, rafId = null;
        function getX(e) { return e.touches ? e.touches[0].clientX : e.clientX; }
        function onDown(e) {
            if (e.button !== undefined && e.button !== 0) return;
            dragging = true; didDrag = false;
            startX = currentX = getX(e);
            track.style.cursor = 'grabbing';
            track.style.userSelect = 'none';
            if (onDragStart) onDragStart();
            e.preventDefault();
        }
        function onMove(e) {
            if (!dragging) return;
            currentX = getX(e);
            var delta = currentX - startX;
            if (Math.abs(delta) > 3) didDrag = true;
            if (didDrag && onDragMove) {
                if (rafId) cancelAnimationFrame(rafId);
                var d = delta;
                rafId = requestAnimationFrame(function() { onDragMove(d); rafId = null; });
            }
            e.preventDefault();
        }
        function onUp() {
            if (!dragging) return;
            dragging = false;
            track.style.cursor = 'grab';
            track.style.userSelect = '';
            if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
            var delta = currentX - startX;
            onRelease(delta, didDrag);
            if (didDrag) {
                var blocker = function(ev) {
                    ev.stopPropagation(); ev.preventDefault();
                    track.removeEventListener('click', blocker, true);
                };
                track.addEventListener('click', blocker, true);
            }
        }
        track.style.cursor = 'grab';
        track.addEventListener('mousedown', onDown, { passive: false });
        document.addEventListener('mousemove', onMove, { passive: false });
        document.addEventListener('mouseup', onUp);
        track.addEventListener('touchstart', onDown, { passive: false });
        document.addEventListener('touchmove', onMove, { passive: false });
        document.addEventListener('touchend', onUp);
    }

    // Initialize slider carousels (section sliders)
    function initNewSliders() {
        document.querySelectorAll('.impulse-new-slider').forEach(function(carousel) {
            // Grid-mode sliders handled by initCarousels instead.
            if (carousel.classList.contains('impulse-image-carousel')) return;
            // Skip already-initialised sliders — re-init would reset position back to slide 0
            if (carousel._sliderInit) return;
            carousel._sliderInit = true;

            // Clear previous timer if any
            if (carousel._sliderTimer) { clearInterval(carousel._sliderTimer); carousel._sliderTimer = null; }
            
            var slides = carousel.querySelector('.impulse-carousel-slides');
            var slideItems = carousel.querySelectorAll('.impulse-carousel-slide');
            
            if (!slides || slideItems.length === 0) return;

            var effect = carousel.dataset.effect || 'slide';
            var isEffectMode = (effect !== 'slide') && ImpulseCarouselEffects && ImpulseCarouselEffects[effect];
            var direction = carousel.dataset.direction || 'right';
            var isVertical = (direction === 'up' || direction === 'down');

            // Ensure all slides have layout boxes within the slides container so overflow:hidden
            // on the track doesn't clip them. Without explicit width, a flex container defaults to
            // the track width; slides 1..N overflow outside the layout box and paint as zero BCR.
            // Read multi-slide settings
            var itemsToShow = parseInt(carousel.dataset.items) || 1;
            var gap = parseInt(carousel.dataset.gap) || 0;
            var _trackEl = carousel.querySelector('.impulse-carousel-track');
            var _trackW = _trackEl ? _trackEl.offsetWidth : 0;
            var _trackH = _trackEl ? _trackEl.offsetHeight : 0;

            // Set explicit px widths so getBoundingClientRect() works for off-screen slides.
            // Without explicit container width, slide N sits at layout x=N*trackW — outside
            // the slides container, clipped by overflow:hidden → zero BCR for dropzones.
            // Using px also avoids the PHP calc(X% - Ypx) percentages breaking when we
            // expand the container beyond track width.
            if (!isEffectMode && !isVertical && slideItems.length > 0 && _trackW > 0) {
                var slideWpx = itemsToShow <= 1
                    ? _trackW
                    : Math.max(1, (_trackW - gap * (itemsToShow - 1)) / itemsToShow);
                var containerWpx = slideItems.length * slideWpx + Math.max(0, slideItems.length - 1) * gap;
                slides.style.width = containerWpx + 'px';
                Array.prototype.forEach.call(slideItems, function(s) {
                    s.style.flexBasis = slideWpx + 'px';
                    s.style.minWidth  = slideWpx + 'px';
                    s.style.maxWidth  = slideWpx + 'px';
                    if (_trackH > 0) s.style.height = _trackH + 'px';
                });
            } else if (!isEffectMode && !isVertical && slideItems.length > 0) {
                // Fallback if track not yet laid out
                slides.style.width = (slideItems.length / Math.max(1, itemsToShow) * 100) + '%';
                var slideWidthPct = (100 / slideItems.length).toFixed(6) + '%';
                Array.prototype.forEach.call(slideItems, function(s) {
                    s.style.flexBasis = slideWidthPct;
                    s.style.minWidth  = slideWidthPct;
                    s.style.maxWidth  = slideWidthPct;
                    if (_trackH > 0) s.style.height = _trackH + 'px';
                });
            } else if (!isEffectMode && isVertical && slideItems.length > 0) {
                slides.style.height = (slideItems.length * 100) + '%';
                var slideHeightPct = (100 / slideItems.length).toFixed(6) + '%';
                Array.prototype.forEach.call(slideItems, function(s) {
                    s.style.flexBasis = slideHeightPct;
                    s.style.minHeight = slideHeightPct;
                    s.style.maxHeight = slideHeightPct;
                });
            }
            
            var isEditorPreview = window.impulseIsPreview || false;
            var autoplay = carousel.dataset.autoplay === 'true';
            var speed = parseInt(carousel.dataset.speed) || 5000;
            var transitionSpeed = parseInt(carousel.dataset.transition) || 400;
            var currentSlide = parseInt(carousel.dataset.currentSlide) || 0;
            if (currentSlide >= slideItems.length) currentSlide = 0;
            var currentIndex = currentSlide;
            var isAnimating = false;

            var isAutoHeight = carousel.dataset.heightMode === 'auto';

            function syncTrackHeight(index) {
                if (!isAutoHeight) return;
                var track = carousel.querySelector('.impulse-carousel-track');
                if (!track) return;
                var targetSlide = slideItems[index];
                if (!targetSlide) return;
                // Temporarily make visible to measure
                var prevVis = targetSlide.style.visibility;
                var prevPos = targetSlide.style.position;
                // For horizontal slide mode — slide is translated off-screen; measure natural height
                var h = targetSlide.scrollHeight;
                if (h > 0) track.style.height = h + 'px';
            }

            function goToSlide(index, instant) {
                if (index < 0) index = slideItems.length - 1;
                if (index >= slideItems.length) index = 0;

                if (isEffectMode) {
                    if (isAnimating) return;
                    if (index === currentIndex) return;
                    isAnimating = true;
                    var prev = currentIndex;
                    currentIndex = index;
                    carousel.dataset.currentSlide = index;
                    var outEl = slideItems[prev];
                    var inEl  = slideItems[index];
                    // Reset all others instantly
                    Array.prototype.forEach.call(slideItems, function(s, i) {
                        if (i !== prev && i !== index) {
                            s.style.transition = 'none'; s.style.opacity = '0';
                            s.style.transform = ''; s.style.zIndex = '0';
                        }
                    });
                    var dur = Math.max(600, transitionSpeed);
                    syncTrackHeight(index);
                    var _fxDone = false;
                    var _finish = function() {
                        if (_fxDone) return; _fxDone = true;
                        outEl.style.transition = 'none'; outEl.style.opacity = '0'; outEl.style.zIndex = '0'; outEl.style.transform = 'none';
                        inEl.style.transition  = 'none'; inEl.style.opacity  = '1'; inEl.style.zIndex  = '2'; inEl.style.transform  = 'none';
                        isAnimating = false;
                    };
                    // Safety: if the effect's done callback never fires (canvas draw on a
                    // not-yet-decoded image, interrupted transition), finalise anyway so
                    // isAnimating can't wedge true and freeze the slider forever.
                    var _fxSafety = setTimeout(_finish, dur + 800);
                    try {
                        ImpulseCarouselEffects[effect](slides, outEl, inEl, dur, function() {
                            clearTimeout(_fxSafety); _finish();
                        });
                    } catch (_e) { clearTimeout(_fxSafety); _finish(); }
                } else {
                    // Detect wrap-around: jump without transition to avoid reverse animation
                    var prevIndex = currentIndex;
                    currentIndex = index;
                    carousel.dataset.currentSlide = index;
                    var track = carousel.querySelector('.impulse-carousel-track');
                    var _step = isVertical ? track.offsetHeight : (slideItems.length > 0 && slideItems[0].offsetWidth > 0 ? slideItems[0].offsetWidth + gap : track.offsetWidth);
                    var offset = index * _step;
                    var isWrap = instant || (prevIndex === slideItems.length - 1 && index === 0) || (prevIndex === 0 && index === slideItems.length - 1);
                    if (isWrap) {
                        slides.style.transition = 'none';
                        slides.style.transform = isVertical ? 'translateY(-' + offset + 'px)' : 'translateX(-' + offset + 'px)';
                        slides.offsetHeight; // force reflow
                        slides.style.transition = 'transform ' + transitionSpeed + 'ms ease-in-out';
                    } else {
                        slides.style.transform = isVertical ? 'translateY(-' + offset + 'px)' : 'translateX(-' + offset + 'px)';
                    }
                    syncTrackHeight(index);
                }
            }
            
            // Go to current slide immediately (instant)
            if (!isEffectMode) {
                slides.style.transition = 'none';
                goToSlide(currentSlide, true);
                slides.offsetHeight;
                slides.style.transition = 'transform ' + transitionSpeed + 'ms ease-in-out';
            } else {
                // Effect (stacked) mode: explicitly show the current slide and hide the rest on
                // (re-)init, so an in-editor structural refresh (switching effect) never leaves
                // the slider blank. Effect mode otherwise relied on server opacity, which a stale
                // re-render loses -> all slides hidden until a hard refresh.
                Array.prototype.forEach.call(slideItems, function(s, i) {
                    s.style.transition = 'none';
                    s.style.opacity = (i === currentSlide) ? '1' : '0';
                    s.style.zIndex  = (i === currentSlide) ? '2' : '0';
                    s.style.transform = 'none';
                });
            }

            // Safety net: an in-editor live-update (toggling autoplay/effect) can re-render the
            // slider mid-transition and leave every slide at opacity 0. Recover to the current
            // slide whenever nothing is visible, so a toggle can never blank the slider.
            function ensureVisible() {
                if (!isEffectMode) return;
                var _any = false;
                for (var _v = 0; _v < slideItems.length; _v++) {
                    if (parseFloat(getComputedStyle(slideItems[_v]).opacity) > 0.05) { _any = true; break; }
                }
                if (_any) return;
                // Nothing visible = broken state (interrupted transition or stale
                // re-render). Recover even if a transition still claims to be running,
                // and clear that claim so autoplay is not wedged with isAnimating=true.
                isAnimating = false;
                var _idx = (currentIndex >= 0 && currentIndex < slideItems.length) ? currentIndex : 0;
                for (var _k = 0; _k < slideItems.length; _k++) {
                    slideItems[_k].style.transition = 'none';
                    slideItems[_k].style.opacity = (_k === _idx) ? '1' : '0';
                    slideItems[_k].style.zIndex  = (_k === _idx) ? '2' : '0';
                    slideItems[_k].style.transform = 'none';
                }
            }
            setTimeout(ensureVisible, 160);

            // Re-snap on resize — debounced so rapid panel open/close doesn't fight transitions
            if (!isEffectMode) {
                var _resizeTimer = null;
                window.addEventListener('resize', function() {
                    clearTimeout(_resizeTimer);
                    _resizeTimer = setTimeout(function() {
                        // Recalculate px widths for new track size
                        if (!isVertical && _trackEl) {
                            var newTrackW = _trackEl.offsetWidth;
                            if (newTrackW > 0) {
                                var newSlideWpx = itemsToShow <= 1
                                    ? newTrackW
                                    : Math.max(1, (newTrackW - gap * (itemsToShow - 1)) / itemsToShow);
                                var newContainerWpx = slideItems.length * newSlideWpx + Math.max(0, slideItems.length - 1) * gap;
                                slides.style.width = newContainerWpx + 'px';
                                Array.prototype.forEach.call(slideItems, function(s) {
                                    s.style.flexBasis = newSlideWpx + 'px';
                                    s.style.minWidth  = newSlideWpx + 'px';
                                    s.style.maxWidth  = newSlideWpx + 'px';
                                });
                            }
                        }
                        slides.style.transition = 'none';
                        goToSlide(currentIndex, true);
                        slides.offsetHeight;
                        slides.style.transition = 'transform ' + transitionSpeed + 'ms ease-in-out';
                    }, 60);
                });
            }
            
            // Autoplay
            var autoDir = (direction === 'left' || direction === 'up') ? -1 : 1;
            if (autoplay) {
                carousel._sliderTimer = setInterval(function() {
                    ensureVisible();
                    if (!isAnimating) goToSlide(currentIndex + autoDir);
                }, speed);
            }
            
            function restartAutoplay() {
                if (carousel._sliderTimer) { clearInterval(carousel._sliderTimer); carousel._sliderTimer = null; }
                if (autoplay) {
                    carousel._sliderTimer = setInterval(function() {
                        if (!isAnimating) goToSlide(currentIndex + autoDir);
                    }, speed);
                }
            }

            // Drag-to-navigate (section slider) — attach once only
            if (!carousel._dragAttached) {
                carousel._dragAttached = true;
                (function() {
                    var sliderTrack = carousel.querySelector('.impulse-carousel-slides');
                    if (!sliderTrack) return;
                    function getStep() {
                        return slideItems.length > 0 && slideItems[0].offsetWidth > 0
                            ? slideItems[0].offsetWidth + gap
                            : (carousel.querySelector('.impulse-carousel-track') || {offsetWidth:0}).offsetWidth;
                    }
                    attachCarouselDrag(sliderTrack,
                        function(delta, didDrag) { // onRelease
                            if (didDrag) sliderTrack.style.transition = '';
                            if (Math.abs(delta) >= 40) {
                                goToSlide(currentIndex + (delta < 0 ? 1 : -1));
                            } else if (didDrag) {
                                goToSlide(currentIndex); // snap back
                            }
                            restartAutoplay();
                        },
                        function(delta) { // onDragMove — live visual feedback
                            var base = -(currentIndex * getStep());
                            sliderTrack.style.transition = 'none';
                            sliderTrack.style.transform = 'translateX(' + (base + delta) + 'px)';
                        }
                    );
                })();
            }
            
            // Store for external access
            carousel._goToSlide = goToSlide;
        });
    }
    
    // =====================================================================
    // =====================================================================
    // PRODUCT CAROUSEL INIT
    // Modes: scroll (marquee) | snap (paginated, min 250px/card) | fade | card_flip
    // =====================================================================

    // Card-native effect functions (operate on full slide divs, not raw images)
    function pcardFade(container, outEl, inEl, dur, done) {
        // Reset: out is visible on top, in is hidden below
        outEl.style.transition = 'none';
        outEl.style.opacity = '1';
        outEl.style.zIndex = '2';
        inEl.style.transition = 'none';
        inEl.style.opacity = '0';
        inEl.style.zIndex = '1';
        // rAF + 16ms guarantees the reset paints before the transition fires
        requestAnimationFrame(function() {
            setTimeout(function() {
                inEl.style.transition = 'opacity ' + dur + 'ms ease-in-out';
                outEl.style.transition = 'opacity ' + dur + 'ms ease-in-out';
                inEl.style.opacity = '1';
                outEl.style.opacity = '0';
                setTimeout(done, dur);
            }, 16);
        });
    }

    function pcardFlip(container, outEl, inEl, dur, done) {
        var half = Math.round(dur / 2);
        // 2D scaleX squeeze — immune to overflow:hidden flattening 3D
        outEl.style.transition = 'none'; outEl.style.transform = 'scaleX(1)'; outEl.style.opacity = '1'; outEl.style.zIndex = '2'; outEl.style.transformOrigin = 'center center';
        inEl.style.transition = 'none'; inEl.style.transform = 'scaleX(0)'; inEl.style.opacity = '1'; inEl.style.zIndex = '1'; inEl.style.transformOrigin = 'center center';
        requestAnimationFrame(function() {
            setTimeout(function() {
                outEl.style.transition = 'transform ' + half + 'ms ease-in-out';
                outEl.style.transform = 'scaleX(0)';
                setTimeout(function() {
                    outEl.style.zIndex = '1';
                    inEl.style.zIndex = '2';
                    inEl.style.transition = 'transform ' + half + 'ms ease-in-out';
                    inEl.style.transform = 'scaleX(1)';
                    setTimeout(done, half);
                }, half);
            }, 16);
        });
    }

    var pcardEffects = { fade: pcardFade, card_flip: pcardFlip };

    function initProductCarousels() {
        document.querySelectorAll('.impulse-product-carousel').forEach(function(carousel) {
            if (carousel._productCarouselInit) return;
            carousel._productCarouselInit = true;

            var mode = carousel.dataset.mode || 'snap';
            if (mode === 'slide') mode = 'snap'; // back-compat
            var track = carousel.querySelector('.impulse-carousel-slides');
            if (!track) return;

            if (mode === 'scroll') {
                // ── Infinite marquee with drag override ───────────────────
                var position = 0;
                var isPaused = false;   // hover pause
                var isDragPaused = false; // drag pause (separate so hover can't interfere)
                var dragStartPos = 0;
                var animId = null;
                var speed = parseFloat(carousel.dataset.scrollSpeed) || 60;
                var originalSlides = Array.prototype.slice.call(track.querySelectorAll('.impulse-carousel-slide:not(.impulse-clone)'));
                if (!originalSlides.length) return;
                track.style.transform = 'translateX(0)';
                track.style.transition = 'none';
                function getSetWidth() {
                    var w = 0;
                    originalSlides.forEach(function(s) { w += s.offsetWidth + parseInt(carousel.dataset.gap || 0); });
                    return w;
                }
                var setWidth = 0;
                function animate() {
                    if (!isPaused && !isDragPaused) {
                        position -= speed / 60;
                        if (position <= -setWidth) position += setWidth;
                        track.style.transform = 'translateX(' + position + 'px)';
                    }
                    animId = requestAnimationFrame(animate);
                }
                function waitForWidth() {
                    setWidth = getSetWidth();
                    if (setWidth > 0) {
                        animId = requestAnimationFrame(animate);
                    } else {
                        requestAnimationFrame(waitForWidth);
                    }
                }
                requestAnimationFrame(waitForWidth);
                if (carousel.dataset.pause === 'true') {
                    carousel.addEventListener('mouseenter', function() { isPaused = true; });
                    carousel.addEventListener('mouseleave', function() { isPaused = false; });
                }
                // Drag overrides marquee — user can manually scrub
                attachCarouselDrag(track,
                    function(delta, wasDrag) {  // onRelease
                        if (wasDrag && setWidth > 0) {
                            position = dragStartPos + delta;
                            // Normalise into valid range
                            while (position > 0) position -= setWidth;
                            while (position < -setWidth) position += setWidth;
                        }
                        isDragPaused = false;
                    },
                    function(delta) {  // onDragMove
                        track.style.transition = 'none';
                        track.style.transform = 'translateX(' + (dragStartPos + delta) + 'px)';
                    },
                    function() {  // onDragStart — freeze marquee position
                        dragStartPos = position;
                        isDragPaused = true;
                    }
                );
                carousel._destroyProductCarousel = function() { if (animId) cancelAnimationFrame(animId); };

            } else if (pcardEffects[mode]) {
                // ── Single-card transition (fade / flip) ─────────────────
                var effectFn = pcardEffects[mode];
                var slideItems = Array.prototype.slice.call(carousel.querySelectorAll('.impulse-carousel-slide'));
                if (!slideItems.length) return;
                // Explicitly set initial state so all slides are in a known position
                slideItems.forEach(function(s, i) {
                    s.style.transition = 'none';
                    s.style.opacity = i === 0 ? '1' : '0';
                    s.style.zIndex = i === 0 ? '2' : '0';
                    s.style.transform = '';
                });
                // Auto-height: measure first card via ghost clone, set track height
                var autoHeightTrack = track.closest('.impulse-product-carousel') ? track : null;
                if (autoHeightTrack && track.dataset.autoheight === '1') {
                    function setAutoHeight(slideEl) {
                        var ghost = slideEl.cloneNode(true);
                        ghost.style.cssText = 'position:fixed;top:-9999px;left:0;width:' + (track.offsetWidth || 400) + 'px;visibility:hidden;pointer-events:none;opacity:0;z-index:-1';
                        document.body.appendChild(ghost);
                        var h = ghost.scrollHeight;
                        document.body.removeChild(ghost);
                        if (h > 200) track.style.height = h + 'px';
                    }
                    // Delay to allow images/fonts to affect layout
                    requestAnimationFrame(function() { setTimeout(function() { setAutoHeight(slideItems[0]); }, 50); });
                }
                var autoplay = carousel.dataset.autoplay === 'true';
                var autoplaySpeed = parseInt(carousel.dataset.speed) || 5000;
                var pauseOnHover = carousel.dataset.pause === 'true';
                var transitionSpeed = parseInt(carousel.dataset.transition) || 500;
                if (mode === 'card_flip') transitionSpeed = Math.max(transitionSpeed, 500);
                var currentIndex = 0;
                var isAnimating = false;
                var autoplayTimer = null;

                function goTo(index) {
                    if (isAnimating) return;
                    if (index < 0) index = slideItems.length - 1;
                    if (index >= slideItems.length) index = 0;
                    if (index === currentIndex) return;
                    isAnimating = true;
                    var prev = currentIndex;
                    currentIndex = index;
                    var outEl = slideItems[prev];
                    var inEl = slideItems[index];
                    // Hide all others immediately
                    slideItems.forEach(function(s, i) {
                        if (i !== prev && i !== index) {
                            s.style.transition = 'none'; s.style.opacity = '0';
                            s.style.transform = ''; s.style.zIndex = '0';
                        }
                    });
                    effectFn(track, outEl, inEl, transitionSpeed, function() {
                        outEl.style.transition = 'none'; outEl.style.opacity = '0';
                        outEl.style.zIndex = '0'; outEl.style.transform = '';
                        inEl.style.transition = 'none'; inEl.style.opacity = '1';
                        inEl.style.zIndex = '2'; inEl.style.transform = '';
                        isAnimating = false;
                    });
                }

                // Drag to navigate (effect modes — gesture only, no physical track movement)
                attachCarouselDrag(track,
                    function(delta, wasDrag) {
                        if (wasDrag && Math.abs(delta) >= 40) { goTo(currentIndex + (delta < 0 ? 1 : -1)); }
                        restartAP();
                    },
                    null,
                    function() { stopAP(); isAnimating = false; }
                );

                function startAP() {
                    if (autoplay && !autoplayTimer)
                        autoplayTimer = setInterval(function() { if (!isAnimating) goTo(currentIndex + 1); }, autoplaySpeed);
                }
                function stopAP() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
                function restartAP() { stopAP(); startAP(); }
                startAP();
                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', stopAP);
                    carousel.addEventListener('mouseleave', startAP);
                }
                // Live speed update from panel slider
                carousel.addEventListener('impulse:speed-change', function() {
                    autoplaySpeed = parseInt(carousel.dataset.speed) || 5000;
                    restartAP();
                });
                // Live transition speed update
                carousel.addEventListener('impulse:transition-change', function() {
                    transitionSpeed = parseInt(carousel.dataset.transition) || 500;
                    if (mode === 'card_flip') transitionSpeed = Math.max(transitionSpeed, 500);
                });
                carousel._destroyProductCarousel = function() { stopAP(); };

            } else {
                // ── Paginated snap — forward-only, infinite clone loop ────
                var slides = Array.prototype.slice.call(track.querySelectorAll('.impulse-carousel-slide:not(.impulse-pc-clone)'));
                var autoplay = carousel.dataset.autoplay === 'true';
                var autoplaySpeed = parseInt(carousel.dataset.speed) || 5000;
                var pauseOnHover = carousel.dataset.pause === 'true';
                var transitionSpeed = parseInt(carousel.dataset.transition) || 400;
                var currentIndex = 0;
                var isSnapAnimating = false;
                var autoplayTimer = null;
                if (!slides.length) return;

                // Snap mode: spv cards visible, centered group, no partial cards
                var spv = Math.max(1, parseInt(carousel.dataset.maxCards) || parseInt(carousel.dataset.slidesPerView) || 4);
                var gap = parseInt(carousel.dataset.gap) || 0;
                var centerOffset = 0;
                var originalCount = slides.length;

                // Clone slides so tail buffer >= spv, cycling through real slides.
                // With rc=3 and spv=4 this produces 4 tail clones, ensuring the
                // viewport at currentIndex=originalCount is always fully populated.
                var _tailCount = Math.max(spv, originalCount);
                for (var ci = 0; ci < _tailCount; ci++) {
                    var clone = slides[ci % originalCount].cloneNode(true);
                    clone.setAttribute('aria-hidden', 'true');
                    clone.classList.add('impulse-pc-clone');
                    track.appendChild(clone);
                }

                function getSlideW() {
                    return slides[0] ? (slides[0].getBoundingClientRect().width || slides[0].offsetWidth || 200) : 200;
                }

                function updateCenterOffset() {
                    var containerW = carousel.getBoundingClientRect().width || carousel.offsetWidth;
                    var sw = getSlideW();
                    var trackW = spv * sw + gap * (spv - 1);
                    centerOffset = Math.floor((containerW - trackW) / 2);
                }

                // Init after layout settles so getBoundingClientRect is reliable
                requestAnimationFrame(function() { updateCenterOffset(); goToSnap(0, false); });

                function goToSnap(index, animate) {
                    if (isSnapAnimating) return;
                    isSnapAnimating = true;
                    currentIndex = index;
                    var slideW = getSlideW();
                    track.style.transition = animate === false ? 'none' : 'transform ' + transitionSpeed + 'ms ease-in-out';
                    track.style.transform = 'translateX(' + (centerOffset - currentIndex * (slideW + gap)) + 'px)';
                    setTimeout(function() {
                        if (currentIndex >= originalCount) {
                            track.style.transition = 'none';
                            void track.offsetHeight;
                            currentIndex = currentIndex - originalCount;
                            track.style.transform = 'translateX(' + (centerOffset - currentIndex * (getSlideW() + gap)) + 'px)';
                        }
                        isSnapAnimating = false;
                    }, (animate === false ? 0 : transitionSpeed) + 30);
                }

                attachCarouselDrag(track,
                    function(delta, wasDrag) {
                        if (!wasDrag) { restartAP2(); return; }
                        if (Math.abs(delta) >= 40) {
                            var target = currentIndex + (delta < 0 ? 1 : -1);
                            if (target < 0) target = originalCount - 1;
                            goToSnap(target);
                        } else {
                            // Snap back to current position smoothly
                            var slideW = getSlideW();
                            track.style.transition = 'transform 200ms ease-in-out';
                            track.style.transform = 'translateX(' + (centerOffset - currentIndex * (slideW + gap)) + 'px)';
                        }
                        restartAP2();
                    },
                    function(delta) {
                        // Live follow during drag — always wins
                        var slideW = getSlideW();
                        var base = centerOffset - currentIndex * (slideW + gap);
                        track.style.transition = 'none';
                        track.style.transform = 'translateX(' + (base + delta) + 'px)';
                    },
                    function() {
                        // Drag start: kill autoplay and clear any in-progress animation lock
                        stopAP2();
                        isSnapAnimating = false;
                    }
                );

                function startAP2() {
                    if (autoplay && !autoplayTimer)
                        autoplayTimer = setInterval(function() {
                            if (!isSnapAnimating) goToSnap(currentIndex + 1);
                        }, autoplaySpeed);
                }
                function stopAP2() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
                function restartAP2() { stopAP2(); startAP2(); }
                startAP2();
                if (pauseOnHover) {
                    carousel.addEventListener('mouseenter', stopAP2);
                    carousel.addEventListener('mouseleave', startAP2);
                }
                // Live speed update from panel slider (no reload needed)
                carousel.addEventListener('impulse:speed-change', function() {
                    autoplaySpeed = parseInt(carousel.dataset.speed) || 5000;
                    restartAP2();
                });
                carousel._destroyProductCarousel = function() { stopAP2(); if (resizeObs2) resizeObs2.disconnect(); };
                // Reinit if container width changes (e.g. panel open/close, responsive resize)
                var resizeObs2 = null;
                if (typeof ResizeObserver !== 'undefined') {
                    var lastW2 = carousel.getBoundingClientRect().width || carousel.offsetWidth;
                    resizeObs2 = new ResizeObserver(function() {
                        var newW = carousel.getBoundingClientRect().width;
                        if (Math.abs(newW - lastW2) > 4) {
                            lastW2 = newW;
                            updateCenterOffset();
                            var sw = getSlideW();
                            track.style.transition = 'none';
                            track.style.transform = 'translateX(' + (centerOffset - currentIndex * (sw + gap)) + 'px)';
                        }
                    });
                    resizeObs2.observe(carousel);
                }
            }
        });
    }

        // Initialize new sliders on load
    initNewSliders();
    
    // Product flip cards: hover flips (CSS), click navigates, mobile tap-to-flip then navigate
    document.querySelectorAll('.impulse-flip-card[data-flip="true"]').forEach(function(card) {
        var isTouchDevice = ('ontouchstart' in window) || navigator.maxTouchPoints > 0;
        card.addEventListener('click', function(e) {
            var link = card.dataset.link;
            if (isTouchDevice && !card.classList.contains('flipped')) {
                card.classList.add('flipped');
                return;
            }
            if (link && link !== '#') {
                if (window !== window.top) return; // Block in editor
                window.location.href = link;
            }
        });
        card.addEventListener('mouseleave', function() {
            card.classList.remove('flipped');
        });
    });

    // Initialize carousels on load and after any DOM changes
    // Product carousels deferred: layout must settle before offsetWidth is reliable
    initCarousels();
    setTimeout(initProductCarousels, 50);
    
    // Re-init when content changes (for live preview)
    var carouselObserver = new MutationObserver(function(mutations) {
        var hasRealChange = false;
        mutations.forEach(function(mutation) {
            if (mutation.addedNodes.length) {
                mutation.addedNodes.forEach(function(node) {
                    // Ignore JS-added carousel clones and Logo Animator canvases
                    if (node.nodeType === 1 
                        && !node.classList.contains('impulse-pc-clone') 
                        && !node.classList.contains('impulse-clone')
                        && !node.classList.contains('impulse-la-canvas')) {
                        hasRealChange = true;
                    }
                });
            }
        });
        if (!hasRealChange) return;
        mutations.forEach(function(mutation) {
            if (mutation.addedNodes.length) {
                // Destroy old carousel instances before re-init
                document.querySelectorAll('.impulse-product-carousel').forEach(function(c) {
                    if (c._destroyProductCarousel) { c._destroyProductCarousel(); }
                    c._productCarouselInit = false;
                });
                document.querySelectorAll('.impulse-image-carousel').forEach(function(c) {
                    if (c._destroyCarousel) { c._destroyCarousel(); c._carouselInit = false; }
                });
                setTimeout(initProductCarousels, 80);
                setTimeout(initCarousels, 100);
                setTimeout(initNewSliders, 100);
            }
        });
    });
    carouselObserver.observe(document.body, { childList: true, subtree: true });
    
    // ========================================
    // AUTO-HIDE HEADER — scroll direction detection
    // ========================================
    (function() {
        var lastScrollY = 0;
        var ticking = false;
        function onScroll() {
            var header = document.querySelector('.auto-hide-header');
            if (!header) return;
            var currentY = window.scrollY || document.documentElement.scrollTop;
            if (currentY > lastScrollY && currentY > 60) {
                header.classList.add('header-hidden');
            } else {
                header.classList.remove('header-hidden');
            }
            lastScrollY = currentY < 0 ? 0 : currentY;
        }
        window.addEventListener('scroll', function() {
            if (!ticking) { requestAnimationFrame(function() { onScroll(); ticking = false; }); ticking = true; }
        }, { passive: true });
    })();
    
    // ========================================
    // ========================================
    // SCREEN MODE — Reveal engine v5
    //
    // Key changes from v4:
    //   - No filter:blur() on screen elements — avoids stacking context
    //     that traps position:fixed toolbars/badges inside the blur
    //   - Tunnel canvas injected outside wrapper (on body) so it renders
    //     behind all content without stacking-context issues
    //   - Improved z_depth formula (deeper, visible range)
    //   - perspective() on wrapper for crisp 3D depth transforms
    // ========================================
    (function() {
        var body = document.body;
        if (!body.dataset.screenMode) return;

        // Scene-mode navigation: the entire content-wrapper is the stage.
        var wrapper = document.querySelector('.impulse-content-wrapper');
        if (!wrapper) return;

        // Native (classic) vertical scroll: no snap, no reveals, no env in the
        // preview either. Force screens visible + in flow, expose a scroll-based
        // API so editor screen-jumps still work, and bail. Mirrors the frontend
        // (assets/js/impulse-screens.js); CSS (body.impulse-scroll-classic)
        // handles the layout.
        if (document.body.classList.contains('impulse-scroll-classic')) {
            // Native (classic) vertical scroll. Everything here is RE-RUNNABLE: the
            // editor re-renders the preview (autosave / CSS-inject) by swapping the
            // screen elements without re-running this engine, which would otherwise
            // revert them to the screen-mode CSS height (100vh) - the 'needs a hard
            // refresh' bug. So the pass (a) re-queries the DOM every call (never
            // caches nodes), (b) is exposed as window._impulseApplyNativeHeights for
            // the editor's live slider drag, (c) runs on a few timers, and (d) re-runs
            // via a MutationObserver whenever the editor swaps the DOM. Each screen's
            // data-native-min-vh drives its min-height; frontend mirrors this in
            // assets/js/impulse-screens.js.
            var _qWrap = function () { return document.querySelector('.impulse-content-wrapper') || document.querySelector('.impulse-screen-wrapper'); };
            var _qScreens = function () { var _w = _qWrap(); return _w ? _w.querySelectorAll(':scope > .impulse-el[data-type="section"]') : []; };
            window._impulseApplyNativeHeights = function () {
                var _ph = document.documentElement, _pb = document.body, _w = _qWrap();
                // Body-as-scroller: html is fixed to the iframe viewport with
                // overflow hidden so the BODY becomes a real scroll container
                // (overflow propagation to the viewport only happens when html
                // overflow is visible). An internal scroll container wheel-
                // scrolls even though the iframe element is overflow:clip, which
                // the document-scroll model could not.
                _ph.style.setProperty('overflow','hidden','important'); _ph.style.setProperty('height','100vh','important'); _ph.style.setProperty('max-height','100vh','important');
                _pb.style.setProperty('overflow-y','auto','important'); _pb.style.setProperty('overflow-x','hidden','important'); _pb.style.setProperty('height','100vh','important'); _pb.style.setProperty('max-height','100vh','important');
                // Reserve the scrollbar gutter permanently. Without it, a section
                // sized to exactly one viewport tips the body into overflow, the
                // scrollbar appears, the content box narrows by its width, and every
                // block visibly jumps sideways the moment Min height reaches 100
                // (Sergei 2026-07-26: "content is pushed to the right at 100vh").
                _pb.style.setProperty('scrollbar-gutter','stable','important');
                if (_w) { _w.style.setProperty('height','auto','important'); _w.style.setProperty('max-height','none','important'); _w.style.setProperty('overflow','visible','important'); }
                var _list = _qScreens();
                for (var _ci = 0; _ci < _list.length; _ci++) {
                    var _cse = _list[_ci];
                    _cse.style.position = 'relative'; _cse.style.opacity = '1';
                    _cse.style.transform = 'none'; _cse.style.filter = 'none';
                    _cse.style.clipPath = ''; _cse.style.setProperty('visibility', 'visible', 'important');
                    _cse.style.setProperty('height', 'auto', 'important'); _cse.style.setProperty('max-height', 'none', 'important');
                    // Min height resolves against the DESIGN viewport (the 16:9
                    // device height), not the canvas: the classic editor canvas
                    // stretches to the full workspace height (2.1.2395), so raw
                    // vh units made "100vh" mean "full stretched canvas" - a
                    // hero taller than any real screen. Apply as px computed
                    // from min(canvas height, design height) so 100 = one real
                    // viewport on every monitor. Editor-only; the frontend has
                    // a true viewport and keeps vh. Sergei 2026-07-19.
                    var _mv = parseInt(_cse.dataset.nativeMinVh);
                    var _dvw = window.innerWidth;
                    var _dvDesign = _dvw >= 1200 ? 1080 : (_dvw >= 700 ? 1024 : 932);
                    var _dvRef = Math.min(window.innerHeight, _dvDesign);
                    var _mvv = (_mv > 0 ? Math.round(_mv / 100 * _dvRef) + 'px' : '0');
                    _cse.style.setProperty('min-height', _mvv, 'important');
                    // The direct inner .impulse-section holds the background + the
                    // 9-dot alignment, so it must FILL the Min height (not collapse
                    // to content) - otherwise the visible block never resizes and
                    // content can't center. Deeper containers stay content-sized.
                    var _is = _cse.querySelector(':scope > .impulse-section');
                    if (_is) { _is.style.setProperty('height','auto','important'); _is.style.setProperty('min-height', _mvv, 'important'); _is.style.setProperty('max-height','none','important'); _is.style.setProperty('overflow','visible','important'); }
                }
            };
            window._impulseApplyNativeHeights();
            setTimeout(window._impulseApplyNativeHeights, 0);
            setTimeout(window._impulseApplyNativeHeights, 250);
            setTimeout(window._impulseApplyNativeHeights, 600);
            // Min height is baked to PX from window.innerHeight, so any later
            // change of the iframe's height leaves a STALE floor. In the editor
            // chrome the workspace sizes the canvas after these timers have run,
            // so a 100vh hero came back taller than the viewport on every reload
            // and only a nudge of the slider (which re-runs this) fixed it
            // (Sergei 2026-08-03). Re-run on resize: an iframe element resize
            // fires resize on its inner window, which covers the initial sizing,
            // device-tab switches, panel open/close and window resizes alike.
            var _nhRZ; window.addEventListener('resize', function () {
                clearTimeout(_nhRZ); _nhRZ = setTimeout(window._impulseApplyNativeHeights, 80);
            });
            // Fonts/images can still shift the canvas after 600ms.
            window.addEventListener('load', function () { setTimeout(window._impulseApplyNativeHeights, 60); });
            try {
                // Observe ONLY the wrapper's direct children (screen add/remove/swap),
                // NOT subtree: a subtree observer fires on every kinetic / 3D-text
                // animation mutation, storming _impulseApplyNativeHeights into a
                // layout-thrash freeze (the 'title disappears while dragging height'
                // bug). Style writes from the pass don't change childList, so the
                // screen-swap re-render is still caught without any feedback loop.
                var _nhW = _qWrap();
                if (_nhW) {
                    var _nhT; var _nhMo = new MutationObserver(function () { clearTimeout(_nhT); _nhT = setTimeout(window._impulseApplyNativeHeights, 60); });
                    _nhMo.observe(_nhW, { childList: true });
                }
            } catch (e) {}
            function _shimScrollTo(el) {
                if (!el) return;
                var sc = document.body; // classic mode: body is the scroller
                var eTop = el.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop;
                var top = Math.max(0, eTop - Math.max(0, (sc.clientHeight - el.offsetHeight) / 2));
                try { sc.scrollTo({ top: top, behavior: 'smooth' }); } catch (_e) { sc.scrollTop = top; }
            }
            window.ImpulseScreens = window.impulseScreenMode = {
                // Scoped scroll - NOT scrollIntoView. scrollIntoView propagates
                // through every scrollable ancestor ACROSS the iframe boundary:
                // it shoved the editor's overflow:hidden canvas wrapper (which
                // can never scroll back) and the admin document itself, so
                // adding a section on a classic page visually wrecked the whole
                // editor until reload (Sergei 2026-07-19). Scroll ONLY the
                // iframe's own body scroller.
                jumpToScreen: function(i){ _shimScrollTo(_qScreens()[i]); },
                goToScreen:   function(i){ _shimScrollTo(_qScreens()[i]); },
                getCurrentScreen: function(){ return 0; },
                getScreenCount:   function(){ return _qScreens().length; },
                reinitTunnel: function(){}, updateSettings: function(){}
            };
            return;
        }

        var transition  = body.dataset.screenTransition  || 'z_depth';
        var cooldown    = parseInt(body.dataset.screenCooldown)  || 150;
        var duration    = parseInt(body.dataset.screenDuration)  || 700;
        var intensity   = body.dataset.screenIntensity !== undefined ? parseInt(body.dataset.screenIntensity) : 50;
        var showDots    = body.dataset.screenNavDots    === '1';
        var infiniteLoop = body.dataset.screenInfinite  === '1';
        var tunnelOn    = body.dataset.screenTunnel     === '1';
        var tunnelSpeed = parseFloat(body.dataset.screenTunnelSpeed) || 1;
        var easing      = 'cubic-bezier(0.4, 0, 0.2, 1)';

        // Screens are direct-child sections of wrapper (the content-wrapper).
        var _screenSel = ':scope > .impulse-el[data-type="section"]';
        var screens = wrapper.querySelectorAll(_screenSel);
        if (!screens.length) {
            // No screens yet — still expose reinitTunnel so editor checkbox updates work
            window.ImpulseScreens = window.impulseScreenMode = {
                reinitTunnel: function(tOn, cEff, unused, speed) {
                    var mode = cEff || 'none';
                    // tear down old
                    var old = document.getElementById('impulse-cloud-svg-preview');
                    if (old) old.remove();
                    var ovc = document.getElementById('impulse-tunnel-canvas-preview');
                    if (ovc) ovc.remove();
                    if (mode === 'none' || mode === '') return;
                    // stub — full engine starts once screens exist; this just clears/sets bg
                }
            };
            return;
        }

        var currentScreen = 0;
        // Restore screen index from URL param (set by editor refresh)
        try {
            var _urlScreen = new URLSearchParams(window.location.search).get('screen');
            if (_urlScreen !== null) currentScreen = Math.max(0, parseInt(_urlScreen) || 0);
        } catch(e) {}
        var isTransitioning = false;
        var cooldownTimer = null;
        // Monotonic navigation generation — bumped per goToScreen; in-flight
        // decompose loops, deferred cleanups and the lock-release timer check
        // it and bail when a newer navigation supersedes them.
        var _navGen = 0;

        // ── Inject screen-mode CSS (no borders) ──────
        var styleEl = document.createElement('style');
        styleEl.textContent =
            '.impulse-el[data-type="section"]{border:none!important;outline:none!important;box-shadow:none!important}';
        document.head.appendChild(styleEl);
        // 2026-05-23: screen-level chrome (toolbar + label) removed entirely.
        // Structure panel + Screens sidebar already cover every screen-level
        // action (Duplicate, Delete, Rename, reorder). A floating screen
        // toolbox in the top-right corner clashed with the header's own
        // toolbox at the same position. Cleaner to have zero chrome on the
        // canvas and let designers use the dedicated panels.
        function syncScreenChrome() { /* no-op — chrome retired */ }
        window._impulseSyncScreenChrome = syncScreenChrome;

        var presets = {
            // Body-zone curated effects — content-respecting, no clip-path or
            // overshoot. Hero gets the dramatic catalog below.
            fade:  { enter: { opacity: '0' },
                     exit:  { opacity: '0' } },
            lift:  { enter: { transform: 'translateY(32px)',  opacity: '0' },
                     exit:  { transform: 'translateY(-32px)', opacity: '0' } },
            slide: { enter: { transform: 'translateX(40px)',  opacity: '0' },
                     exit:  { transform: 'translateX(-40px)', opacity: '0' } },
            // Curated body reveals — MUST match the frontend (impulse-screens.js).
            // These were missing from the EDITOR engine, so a body screen set to
            // scroll/glide fell through getPreset's `presets[t] || presets.rise`
            // to the BOUNCY `rise` spring (springDamp overshoot) — the "screens
            // 10/11 bounce up-down a few times, glide==scroll" bug. Clean
            // translateY, no overshoot. Sergei 2026-05-29.
            scroll: { enter: { transform: 'translateY(100%)',  opacity: '1' },
                      exit:  { transform: 'translateY(-100%)', opacity: '1' } },
            glide:  { enter: { transform: 'translateY(48px)',  opacity: '0' },
                      exit:  { transform: 'translateY(-48px)', opacity: '0' } },
            fade:   { enter: { transform: 'scale(1)', opacity: '0' },
                      exit:  { transform: 'scale(1)', opacity: '0' } },
            rise: { complex: 'rise' },
            fall: { complex: 'fall' },
            iris: {
                clip: true,
                enter: { clip: 'circle(0% at 50% 50%)' },
                exit:  { transform: 'scale(0.92)', opacity: '0' },
                visible: { clip: 'circle(100% at 50% 50%)' }
            },
            split: {
                clip: true,
                enter: { clip: 'inset(0 50% 0 50%)' },
                exit:  { transform: 'scale(1.04)', opacity: '0' },
                visible: { clip: 'inset(0 0% 0 0%)' }
            },
            dissolve: { complex: 'dissolve' },
            depth_spring: { complex:'depth_spring' },
            depth_vortex: { complex:'depth_vortex' },
            depth_zoom: { complex:'depth_zoom' },
            morph: { complex:'morph' },
            door: { complex:'door' },
            swirl: { complex:'swirl' },
            squeeze: { complex:'squeeze' },
            radial: { complex:'radial' },
            
            hexagonalize: { complex:'hexagonalize' },
            burn: { complex:'burn' },
            pixelize: { complex:'pixelize' },
            
            wind: { complex:'wind' },
            water_drop: { complex:'water_drop' },
            dreamy: { complex:'dreamy' },
            ripple: { complex:'ripple' }
        };

        // Resolve a screen's zone reveal. Each screen belongs to a zone —
        // Hero (index < heroCount) or Body — and uses that zone's transition
        // + intensity. Resolved per element so a parked screen never inherits
        // another zone's reveal (no shared mutable transition state).
        function _zoneReveal(el) {
            var t = body.dataset.screenTransition || transition;
            var inten = intensity;
            // Per-screen reveal (zone merge, Sergei 2026-06-03): the screen's own
            // data-reveal wins; falls through to legacy zone logic when unset.
            // Mirrors impulse-screens.js so editor WYSIWYG matches the frontend.
            if (el && el.dataset && el.dataset.reveal) {
                var _pin = parseInt(el.dataset.revealIntensity);
                return { t: el.dataset.reveal, inten: isNaN(_pin) ? inten : _pin };
            }
            var hc = parseInt(body.dataset.screenHeroCount) || 0;
            var idx = el ? Array.prototype.indexOf.call(screens, el) : currentScreen;
            if (idx < 0) idx = currentScreen;
            if (idx < hc) {
                t = body.dataset.screenHeroTransition || t;
                var hi = parseInt(body.dataset.screenHeroIntensity);
                if (!isNaN(hi)) inten = hi;
            } else {
                var bi = parseInt(body.dataset.screenIntensity);
                if (!isNaN(bi)) inten = bi;
            }
            return { t: t, inten: inten };
        }
        // The move's duration comes from the destination screen's own
        // data-reveal-speed-ms when set (per-screen Speed slider, added in
        // v2.1.1335), then falls back to the legacy hero/body zone duration,
        // then to the page-level default. Mirrors impulse-screens.js so the
        // editor's WYSIWYG matches the frontend.
        function _zoneDuration(idx) {
            var sc = screens && screens[idx];
            if (sc) {
                var ms = parseInt(sc.dataset.revealSpeedMs);
                if (!isNaN(ms) && ms > 0) return ms;
            }
            var hc = parseInt(body.dataset.screenHeroCount) || 0;
            var d = parseInt(idx < hc ? body.dataset.screenHeroDuration : body.dataset.screenDuration);
            return isNaN(d) ? duration : d;
        }
        function getPreset(el) {
            var zr = _zoneReveal(el);
            var t = zr.t;
            if (t === 'z_depth') {
                var ti = Math.max(0, Math.min(100, zr.inten)) / 100;
                // Deep range: intensity=0 → scale(0.70), intensity=50 → scale(0.375), intensity=100 → scale(0.05)
                var s = 0.30 + ti * 0.65;
                return {
                    enter: { transform: 'perspective(700px) scale(' + (1 - s).toFixed(3) + ')', opacity: '0' },
                    exit:  { transform: 'perspective(700px) scale(' + (1 + s * 0.5).toFixed(3) + ')', opacity: '0' }
                };
            }
            return presets[t] || presets.rise;
        }

        function transStr(p) {
            var d = duration + 'ms';
            var e = (p && p.easing) || easing;
            var props = 'transform ' + d + ' ' + e + ', opacity ' + d + ' ' + e;
            if (p && p.clip) props += ', clip-path ' + d + ' ' + e;
            return props;
        }

        function clearStyles(el) {
            el.style.clipPath = '';
            el.style.transformOrigin = '';
            el.style.filter = '';
            var svg = el.querySelector('.impulse-dissolve-svg');
            if (svg) svg.remove();
        }

        // ── Child Decomposition — volumetric depth for blocks within screens ──
        // Strategy: For entering screens, override screen-level opacity to 1 immediately
        // and let individual children handle their own fade-in from depth.
        // The screen transform (scale) is kept for macro movement; children add Z-depth on top.
        var _decompCache = new WeakMap();

        // Map of block types → deep child selectors for decomposition
        // Sub-selectors per block type for the reveal engine's decomposition pass.
        // Must stay in lockstep with impulse-screens.js on the frontend — a drift here
        // silently disables per-child decomposition in the editor only (cards fly in
        // as one block instead of individually). Plans was mapping to `.impulse-plan-col`
        // which never existed; cards are `.impulse-pricing-card`. Products was
        // `.impulse-product-card`; actual class is `.impulse-pcard, .impulse-flip-card`.
        var DECOMPOSE_SUBS = {
            'checkout': '.impulse-checkout-block > div > div:first-child, .impulse-checkout-field, .impulse-checkout-block input[placeholder], .impulse-checkout-btn',
            'contact': '.impulse-contact-form > *:not(input[type="hidden"]):not(.impulse-form-message), .impulse-form > *:not(input[type="hidden"])',
            'login': '.impulse-login-form > *:not(input[type="hidden"]):not(.impulse-form-message)',
            'register': '.impulse-register-form > *:not(input[type="hidden"]):not(.impulse-form-message)',
            'grid': '.impulse-grid-cell',
            'rows': '.impulse-rows-cell',
            'columns': '.impulse-layout-col',
            'products': '.impulse-pcard, .impulse-flip-card',
            'bundle': '.impulse-bundle-item',
            'plans': '.impulse-pricing-card',
            'price_list': '.impulse-price-item',
            'unfold': '.impulse-acc-spot-item, .impulse-unfold-card, .impulse-acc-tile, .impulse-acc-reveal-item',
            'table': '.impulse-table-item',
            'social': '.impulse-social-icons > a',
            'carousel': '.impulse-carousel-slide',
            'slider': '.impulse-carousel-slide',
            'network': '.impulse-network-card',
            'schedule': '.isch-dayhdr, .isch-card'
        };

        function getDecompChildren(screenEl) {
            if (!screenEl) return [];
            // Builder-demo screen owns its own entry choreography (panel
            // slide-ins + canvas fly-in via CSS keyframes triggered on
            // `.impulse-screen-animating`). The decomp stagger animates
            // the OUTER demo wrapper in 3D space and stretches cleanup
            // to duration*2+200ms — both undesirable here. Bail so the
            // demo's own choreography is the only motion on entry, and
            // cleanup fires at the normal duration+50ms.
            if (screenEl.querySelector(':scope > .impulse-section > .impulse-container > .impulse-el[data-type="demo"], :scope > .impulse-container > .impulse-el[data-type="demo"]')) {
                return [];
            }
            // No caching in editor — DOM changes as user adds/removes blocks
            var container = screenEl.querySelector('.impulse-container');
            var topBlocks = container
                ? Array.from(container.querySelectorAll(':scope > .impulse-el'))
                : Array.from(screenEl.querySelectorAll(':scope > .impulse-section > .impulse-container > .impulse-el'));
            var children = [];
            for (var b = 0; b < topBlocks.length; b++) {
                // Per-block reveal opt-out — mirror of impulse-screens.js. A block
                // (or container) flagged data-reveal-off rides in with the screen
                // instead of staggering separately. v2.1.2146.
                if (topBlocks[b].dataset && topBlocks[b].dataset.revealOff === '1') continue;
                var type = topBlocks[b].getAttribute('data-type');
                var subSel = DECOMPOSE_SUBS[type];
                if (subSel) {
                    var subs = Array.from(topBlocks[b].querySelectorAll(subSel));
                    if (subs.length > 1) {
                        for (var s = 0; s < subs.length; s++) children.push(subs[s]);
                        continue;
                    }
                }
                children.push(topBlocks[b]);
            }
            // Dedup: remove any child whose ancestor is also in the list
            var deduped = [];
            for (var c = 0; c < children.length; c++) {
                var nested = false;
                var p = children[c].parentElement;
                while (p) {
                    if (children.indexOf(p) !== -1) { nested = true; break; }
                    p = p.parentElement;
                }
                if (!nested) deduped.push(children[c]);
            }
            return deduped;
        }

        function decomposeEnter(screenEl, onComplete) {
            var children = getDecompChildren(screenEl);
            if (!children.length) { if (onComplete) onComplete(); return; }
            // Generation token — a newer navigation cancels this rAF loop.
            var _decGen = _navGen;
            var effect = transition;
            var dur = duration * 2.0;
            var stag = 0.15, act = 0.85, n = children.length, start = null;
            var alt = function(i) { return i % 2 === 0 ? 1 : -1; };
            // Per-block reveal overrides — editor mirror of impulse-screens.js.
            // ORDER overrides the stagger slot (0=first..10=last), DELAY adds an
            // ms offset; both applied to childStart in frame(); pose (t) is left
            // untouched. OFF children were dropped in getDecompChildren. v2.1.2146.
            for (var rvi = 0; rvi < n; rvi++) {
                var _rn = children[rvi], _rord = null, _rdly = 0;
                while (_rn && _rn.dataset) {
                    if (_rord === null && _rn.dataset.revealOrder != null && _rn.dataset.revealOrder !== '') _rord = parseFloat(_rn.dataset.revealOrder);
                    if (!_rdly && _rn.dataset.revealDelay) _rdly = parseFloat(_rn.dataset.revealDelay) || 0;
                    if (_rn === screenEl) break;
                    _rn = _rn.parentElement;
                }
                children[rvi]._revStagT = (_rord !== null && !isNaN(_rord)) ? Math.max(0, Math.min(1, _rord / 10)) : null;
                children[rvi]._revDelayFrac = (_rdly && dur) ? (_rdly / (dur * 0.65)) : 0;
            }

            // Canvas-backed titles (3D-text outline_depth or the logo animator)
            // paint onto their own GPU layer; the depth-spring transform +
            // perspective context churn re-rasterises that layer every time the
            // spring crosses its rest point, reading as the title "shaking three
            // times" on loop-around. Reveal these with OPACITY ONLY (no
            // transform, no blur, flattened subtree) so the layer never moves and
            // never re-rasters. Mirrors impulse-screens.js _decompFlat. Per-child,
            // so it works for ANY child count — the old _isOutlineDepthSolo only
            // fired when n===1, so screen 1's heading+text pair kept the full
            // bounce. Sergei 2026-05-29.
            function _decompFlat(ch) {
                if (!ch) return false;
                var h = ch.querySelector('.impulse-display-text[data-dt-mode]');
                if (h && h.getAttribute('data-dt-mode') === 'outline_depth') return true;
                if (ch.matches && ch.matches('.impulse-display-text[data-dt-mode="outline_depth"]')) return true;
                if (ch.querySelector('.impulse-logo-animator')) return true;
                if (ch.classList && ch.classList.contains('impulse-logo-animator')) return true;
                // v2.1.1372: frosted-glass sections must reveal with opacity only.
                // The decompose reveal puts `filter: blur(8px)` on the child during
                // entrance, which establishes a backdrop-root on the wrapper and
                // blocks the descendant `backdrop-filter` from sampling the actual
                // page behind. When the reveal completes and the filter clears,
                // the backdrop-filter snaps to a correctly sampled image — read as
                // "frosted glass applies a beat AFTER the section is visible". An
                // opacity-only reveal keeps the backdrop-root chain clean from the
                // first frame, so frosted glass is live throughout the entrance.
                if (ch.querySelector && ch.querySelector('section.impulse-frosted')) return true;
                if (ch.matches && ch.matches('section.impulse-frosted')) return true;
                return false;
            }
            var _flat = children.map(_decompFlat);
            // Mirror of impulse-screens.js: preserve a child's resting transform (network cards
            // are transform-centered) through the decompose so they don't snap on landing. v2.1.1995.
            var _bases = children.map(function(c){ var b = c.getAttribute('data-decomp-base'); return b ? b + ' ' : ''; });

            // Find deepest container and set up 3D context
            var container = screenEl.querySelector('.impulse-container') || screenEl;
            container.style.perspective = '500px';
            container.style.perspectiveOrigin = '50% 50%';
            container.style.transformStyle = 'preserve-3d';

            // Walk ancestor chain: lift overflow:hidden + set preserve-3d up to screenEl.
            // v2.1.1371: ALSO record EVERY walker for transformStyle restore — not
            // just the overflow-modified ones. Previously only `_overflowRestore`
            // ancestors had their transformStyle cleared on cleanup, so any walker
            // that didn't have overflow:hidden (like the `.impulse-el` wrapper of a
            // frosted section) kept its inline `transform-style: preserve-3d`
            // forever. preserve-3d promotes the element to its own GPU layer, which
            // breaks `backdrop-filter` sampling on descendants — the long-standing
            // "frosted glass dies on scroll back" symptom. The fix is at the source:
            // restore every walker we touched, not just the overflow-modified subset.
            var _overflowRestore = [];
            var _ancestorRestore = [];
            var walker = container.parentElement;
            while (walker && walker !== screenEl.parentElement) {
                var cs = getComputedStyle(walker);
                if (cs.overflow === 'hidden' || cs.overflowX === 'hidden' || cs.overflowY === 'hidden') {
                    _overflowRestore.push({ el: walker, val: walker.style.overflow });
                    walker.style.overflow = 'visible';
                }
                _ancestorRestore.push({ el: walker, prev: walker.style.transformStyle });
                walker.style.transformStyle = 'preserve-3d';
                walker = walker.parentElement;
            }

            // Negative Z = behind screen. Minimal XY — depth is the star.
            function initState(i, t) {
                switch (effect) {
                    case 'depth_spring':
                        return { tx: alt(i) * 15, ty: 10 * t, tz: -2000 - 800 * t, rx: 5 + 3 * t, ry: alt(i) * 4 };
                    case 'depth_vortex':
                        return { tx: alt(i) * 30, ty: alt(i) * 20, tz: -2500 - 1000 * t, rx: alt(i) * 8, ry: alt(i) * (12 + 6 * t) };
                    case 'depth_zoom':
                        dur = duration * 1.8; stag = 0.15; act = 0.85;
                        return { tx: alt(i) * 10, ty: 5 * t, tz: -3000 - 500 * t, rx: 3 * t, ry: alt(i) * 2 };
                    case 'rise':
                        dur = duration * 2.0; stag = 0.15; act = 0.85;
                        return { tx: alt(i) * 15, ty: 30 + 15 * t, tz: -2000 - 600 * t, rx: 6 + 3 * t, ry: alt(i) * 3 };
                    case 'fall':
                        dur = duration * 2.0; stag = 0.15; act = 0.85;
                        return { tx: alt(i) * 15, ty: -(30 + 15 * t), tz: -2000 - 600 * t, rx: -(6 + 3 * t), ry: alt(i) * 3 };
                    case 'iris':
                        dur = duration * 1.6;
                        return { tx: alt(i) * 8, ty: alt(i) * 5, tz: -2800 - 800 * t, rx: alt(i) * (4 + 2 * t), ry: alt(i) * (4 + 2 * t) };
                    case 'split':
                        dur = duration * 1.6;
                        return { tx: alt(i) * (40 + 20 * t), ty: alt(i) * 8, tz: -2000 - 600 * t, rx: alt(i) * 3, ry: alt(i) * (8 + 4 * t) };
                    case 'dissolve':
                        dur = duration * 2.0;
                        var jx = Math.sin(i * 2.7 + 0.5) * 25, jy = Math.cos(i * 3.1 + 0.3) * 18;
                        return { tx: jx, ty: jy, tz: -2200 - 1200 * Math.random(), rx: alt(i) * (5 + 4 * Math.random()), ry: alt(i) * (5 + 4 * Math.random()) };
                    default: // z_depth
                        return { tx: alt(i) * (20 + 10 * t), ty: 15 + 8 * t, tz: -2200 - 900 * t, rx: 5 + 3 * t, ry: alt(i) * (4 + 2 * t) };
                }
            }

            // Per-effect spring — reduced amplitude, no full-screen overshoot.
            // Keep the damped cosine for ALL n. The visible settle wobble is
            // what makes the reveal feel like a real spring; removing it kills
            // the entrance character. The flash on single-child screens comes
            // from the Z axis specifically — handled below as a tz clamp at
            // application time, not by replacing the ease-in-out.
            function ease(cp) {
                if (cp >= 1) return 1;
                switch (effect) {
                    case 'depth_spring':
                        return 1 - Math.exp(-4.5 * cp) * Math.cos(3.5 * Math.PI * 2 * cp) * 0.3;
                    case 'depth_vortex':
                        return 1 - Math.exp(-4 * cp) * Math.cos(3 * Math.PI * 2 * cp) * 0.3;
                    case 'depth_zoom':
                        return 1 - Math.exp(-4.5 * cp) * Math.cos(3 * Math.PI * 2 * cp) * 0.25;
                    case 'rise': case 'fall':
                        return 1 - Math.exp(-5 * cp) * Math.cos(2.5 * Math.PI * 2 * cp) * 0.2;
                    case 'iris':
                        return 1 - Math.exp(-5 * cp) * Math.cos(2 * Math.PI * 2 * cp) * 0.2;
                    case 'split':
                        return 1 - Math.exp(-4.5 * cp) * Math.cos(2.5 * Math.PI * 2 * cp) * 0.2;
                    case 'dissolve':
                        return 1 - Math.exp(-5.5 * cp) * Math.cos(2 * Math.PI * 2 * cp) * 0.15;
                    default:
                        // z_depth baseline — SNAP-in with a single small terminal overshoot (a
                        // "click"), matching the frontend easeOutBack (impulse-screens.js). Grid
                        // cells / network cards snap confidently into place instead of the old
                        // relaxed ease-out-cubic glide. easeOutBack overshoots ONCE (not the multi-
                        // crossing damped cosine that re-rasterised canvas titles), and titles
                        // reveal opacity-only so this transform overshoot never touches them.
                        // Named springs above keep their own wobble. v2.1.1998.
                        var sB = 1.35, c = cp - 1;
                        return 1 + c * c * ((sB + 1) * c + sB);
                }
            }

            for (var i = 0; i < n; i++) {
                var t = n > 1 ? i / (n - 1) : 0;
                var s = initState(i, t);
                // Walk UP from child to container — set preserve-3d on EVERY intermediate element
                var anc = children[i].parentElement;
                while (anc && anc !== container) {
                    anc.style.transformStyle = 'preserve-3d';
                    anc.style.overflow = 'visible';
                    anc = anc.parentElement;
                }
                children[i].style.transition = 'none';
                if (_flat[i]) {
                    children[i].style.transformStyle = 'flat';
                    children[i].style.transform = '';
                    children[i].style.filter = '';
                } else {
                    children[i].style.transformStyle = 'preserve-3d';
                    children[i].style.transform = _bases[i] + 'translate3d(' + s.tx.toFixed(0) + 'px,' + s.ty.toFixed(0) + 'px,' + s.tz.toFixed(0) + 'px) rotateX(' + s.rx.toFixed(1) + 'deg) rotateY(' + s.ry.toFixed(1) + 'deg)';
                    children[i].style.filter = 'blur(8px)';
                }
                children[i].style.opacity = '0';
            }

            function frame(ts) {
                if (!start) start = ts;
                // Superseded by a newer navigation — finish instantly.
                var _superseded = (_navGen !== _decGen);
                var p = _superseded ? 1 : Math.min((ts - start) / (dur * 0.65), 1); // snap: ~0.65x glide. v2.1.1998
                for (var i = 0; i < n; i++) {
                    var t = n > 1 ? i / (n - 1) : 0;
                    var _tStag = (children[i]._revStagT != null) ? children[i]._revStagT : t;
                    var childStart = stag * _tStag + (children[i]._revDelayFrac || 0);
                    var cp = Math.max(0, Math.min(1, (p - childStart) / act));
                    var ep = ease(cp);
                    var s = initState(i, t);
                    var tx = s.tx * (1 - ep);
                    var ty = s.ty * (1 - ep);
                    var tz = s.tz * (1 - ep);
                    var rx = s.rx * (1 - ep);
                    var ry = s.ry * (1 - ep);
                    // tz ceiling rules:
                    //
                    //   1. Universal cap at +400 — keeps depth_zoom-class
                    //      large s.tz spring overshoots from punching past
                    //      the container's perspective(600px) near plane.
                    //
                    //   2. Single-child screen whose lone child is an
                    //      outline+depth heading: clamp tz>0 to zero. The
                    //      flash-three-times symptom is specific to this
                    //      combination — outline + stroke-spin + (usually)
                    //      heavy mesh distortion in the same section. The
                    //      Z-axis scale spike through the perspective
                    //      transform compounds with the continuously
                    //      animated stroke and the distortion filter into
                    //      visible flashes at each oscillation peak. Plain
                    //      depth or flat-mode single-child screens keep
                    //      the full bounce — only outline_depth gets the
                    //      gentle no-positive-overshoot ride.
                    if (tz > 400) tz = 400;
                    // Dissolve in over first 40% of child's progress — appear mid-flight
                    var alpha = Math.min(1, cp * 2.5);
                    if (_flat[i]) {
                        // Canvas-backed title (outline_depth / logo animator):
                        // opacity-only reveal, transform + blur held empty so the
                        // GPU layer never moves and never re-rasterises (no shake).
                        // Per-child — works regardless of how many siblings the
                        // screen has, unlike the old n===1-only guard.
                        children[i].style.transform = '';
                        children[i].style.filter = '';
                    } else {
                        // Blur proportional to remaining Z distance
                        var blurAmt = 8 * (1 - Math.min(1, cp * 1.8));
                        children[i].style.transform = _bases[i] + 'translate3d(' + tx.toFixed(1) + 'px,' + ty.toFixed(1) + 'px,' + tz.toFixed(0) + 'px) rotateX(' + rx.toFixed(2) + 'deg) rotateY(' + ry.toFixed(2) + 'deg)';
                        children[i].style.filter = blurAmt > 0.3 ? 'blur(' + blurAmt.toFixed(1) + 'px)' : '';
                    }
                    children[i].style.opacity = alpha.toFixed(3);
                }
                // Hard wall-time cap: a decompose reveal is at most dur (≤4s)
                // long. If `p` somehow never reaches 1 the rAF loop would run
                // forever; navigating away then leaks one such loop every
                // time, and ~28 leaked loops freeze the editor. The cap
                // guarantees the loop ends and falls into the restore branch.
                if (p < 1 && ts - start < 4000) requestAnimationFrame(frame);
                else {
                    for (var i = 0; i < n; i++) {
                        // Clean intermediate ancestors
                        var anc = children[i].parentElement;
                        while (anc && anc !== container) {
                            anc.style.transformStyle = '';
                            anc.style.overflow = '';
                            anc = anc.parentElement;
                        }
                        children[i].style.transition = '';
                        children[i].style.transform = '';
                        children[i].style.opacity = '';
                        children[i].style.filter = '';
                        children[i].style.transformStyle = '';
                    }
                    container.style.perspective = '';
                    container.style.perspectiveOrigin = '';
                    container.style.transformStyle = '';
                    for (var ri = 0; ri < _overflowRestore.length; ri++) {
                        _overflowRestore[ri].el.style.overflow = _overflowRestore[ri].val || '';
                    }
                    // v2.1.1371: restore transformStyle on EVERY walker, not just
                    // the overflow-modified subset. Without this, ancestors like the
                    // `.impulse-el` wrapper kept `transform-style: preserve-3d` as
                    // inline forever, GPU-promoting themselves and breaking
                    // backdrop-filter sampling on descendant frosted sections.
                    for (var ai = 0; ai < _ancestorRestore.length; ai++) {
                        _ancestorRestore[ai].el.style.transformStyle = _ancestorRestore[ai].prev || '';
                    }
                    if (onComplete && !_superseded) onComplete();
                }
            }
            requestAnimationFrame(frame);
        }

        function decomposeExit(screenEl) {
            var children = getDecompChildren(screenEl);
            if (!children.length) return;
            var _decGen = _navGen;
            var n = children.length;
            var dur = duration * 0.8;
            var stag = 0.15, act = 0.85, start = null;
            var alt = function(i) { return i % 2 === 0 ? 1 : -1; };

            // Set up 3D context
            var container = screenEl.querySelector('.impulse-container') || screenEl;
            container.style.perspective = '500px';
            container.style.perspectiveOrigin = '50% 50%';
            container.style.transformStyle = 'preserve-3d';

            // Walk ancestor chain.
            // v2.1.1371: track walkers + their prior inline style so we can fully
            // restore them in cleanup. Previously decomposeExit had NO cleanup of
            // these inline writes at all — `transform-style: preserve-3d` and
            // `overflow: visible` leaked onto every ancestor permanently. Same
            // root cause as the decomposeEnter leak — frosted glass on a screen
            // we've ever scrolled away from dies because its `.impulse-el`
            // wrapper now has GPU-layer-promoting preserve-3d inline.
            var _exitAncestorRestore = [];
            var walker = container.parentElement;
            while (walker && walker !== screenEl.parentElement) {
                _exitAncestorRestore.push({
                    el: walker,
                    prevTS: walker.style.transformStyle,
                    prevOV: walker.style.overflow
                });
                walker.style.transformStyle = 'preserve-3d';
                walker.style.overflow = 'visible';
                walker = walker.parentElement;
            }

            // Walk from each child up to container
            var _exitInnerRestore = [];
            for (var i = 0; i < n; i++) {
                var anc = children[i].parentElement;
                while (anc && anc !== container) {
                    _exitInnerRestore.push({
                        el: anc,
                        prevTS: anc.style.transformStyle,
                        prevOV: anc.style.overflow
                    });
                    anc.style.transformStyle = 'preserve-3d';
                    anc.style.overflow = 'visible';
                    anc = anc.parentElement;
                }
                children[i].style.transformStyle = 'preserve-3d';
                children[i].style.transition = 'none';
            }

            // Target state: fly into depth
            function targetState(i, t) {
                return { tx: alt(i) * (15 + 10 * t), ty: 10 + 8 * t, tz: -2000 - 800 * t, rx: 5 + 3 * t, ry: alt(i) * (4 + 2 * t) };
            }

            function frame(ts) {
                if (!start) start = ts;
                var p = (_navGen !== _decGen) ? 1 : Math.min((ts - start) / dur, 1);
                for (var i = 0; i < n; i++) {
                    var t = n > 1 ? i / (n - 1) : 0;
                    var childStart = stag * (1 - t); // reverse stagger — last element exits first
                    var cp = Math.max(0, Math.min(1, (p - childStart) / act));
                    var ep = cp * cp * (3 - 2 * cp); // smoothstep ease-in-out
                    var s = targetState(i, t);
                    var tx = s.tx * ep;
                    var ty = s.ty * ep;
                    var tz = s.tz * ep;
                    var rx = s.rx * ep;
                    var ry = s.ry * ep;
                    children[i].style.transform = 'translate3d(' + tx.toFixed(1) + 'px,' + ty.toFixed(1) + 'px,' + tz.toFixed(0) + 'px) rotateX(' + rx.toFixed(2) + 'deg) rotateY(' + ry.toFixed(2) + 'deg)';
                    children[i].style.opacity = Math.max(0, 1 - cp * 1.5).toFixed(3);
                }
                // Hard wall-time cap — see decomposeEnter; guarantees the
                // rAF loop terminates so it can never leak on navigation.
                if (p < 1 && ts - start < 4000) requestAnimationFrame(frame);
                else {
                    for (var i = 0; i < n; i++) {
                        children[i].style.transform = '';
                        children[i].style.opacity = '';
                        children[i].style.filter = '';
                        children[i].style.transformStyle = '';
                    }
                    // v2.1.1371: full ancestor + container cleanup. Previously
                    // missing entirely — see comment up at the walker for why.
                    container.style.perspective = '';
                    container.style.perspectiveOrigin = '';
                    container.style.transformStyle = '';
                    for (var ai = 0; ai < _exitAncestorRestore.length; ai++) {
                        var r = _exitAncestorRestore[ai];
                        r.el.style.transformStyle = r.prevTS || '';
                        r.el.style.overflow = r.prevOV || '';
                    }
                    for (var ii = 0; ii < _exitInnerRestore.length; ii++) {
                        var ri = _exitInnerRestore[ii];
                        ri.el.style.transformStyle = ri.prevTS || '';
                        ri.el.style.overflow = ri.prevOV || '';
                    }
                }
            }
            requestAnimationFrame(frame);
        }

        function cleanupDecomposition(screenEl) {
            // Order matters here. The cells get a CSS transition rule from
            // the iframe-css block (transform 320ms cubic-bezier(...)). If
            // we clear the inline `transition: none` BEFORE clearing the
            // inline transform, the browser spots both changes in the same
            // tick and decides to animate the transform from its current
            // deep-state value back to none. On a non-active screen
            // (visibility:hidden), that transition gets parked at
            // currentTime=0 with no chance to progress — cells stay pinned
            // at translateZ(-3778px) etc. and the entire grid renders
            // pancake-flat ("screen 2 squeezed to zero width", page 113
            // repro, 2026-05-27). Suppress the transition while clearing
            // transform/opacity/filter, force a reflow to commit the values
            // without an animation, then release the inline transition so
            // future transforms (hover, etc.) re-enable the rule. Locked
            // 2026-05-27.
            var children = getDecompChildren(screenEl);
            for (var i = 0; i < children.length; i++) {
                children[i].style.transition = 'none';
                children[i].style.transform  = '';
                children[i].style.opacity    = '';
                children[i].style.filter     = '';
            }
            // Single reflow at the end commits all the new values without
            // animation. Reading offsetHeight on one child is enough — the
            // browser flushes pending style changes for the whole subtree.
            if (children.length) void children[0].offsetHeight;
            for (var j = 0; j < children.length; j++) {
                children[j].style.removeProperty('transition');
            }
        }

        // 2026-05-29: free the active screen's inner section so its layout box
        // can grow past viewport-height when content overflows. The
        // syncBodyHeight() loop locks every inner to 100vh with inline
        // !important — that wins over the CSS .impulse-screen-active override,
        // so the outer's overflow-y:auto saw scrollHeight===clientHeight and
        // every scroll fired a screen transition. Pair: lock again on
        // enter/exit so inactive screens stay rigid (their content can't push
        // the wrapper around).
        function _freeInnerHeight(el) {
            var inner = el && el.querySelector && el.querySelector(':scope > .impulse-section');
            if (!inner) return;
            inner.style.removeProperty('height');
            inner.style.removeProperty('max-height');
            // min-height stays at viewport (set elsewhere); explicit re-stamp
            // so it survives a previous re-render that cleared inline styles.
            var _vhPx = (document.documentElement.clientHeight || window.innerHeight || 0);
            if (_vhPx >= 100) inner.style.setProperty('min-height', _vhPx + 'px', 'important');
        }
        function _lockInnerHeight(el) {
            var inner = el && el.querySelector && el.querySelector(':scope > .impulse-section');
            if (!inner) return;
            var _vhPx = (document.documentElement.clientHeight || window.innerHeight || 0);
            if (_vhPx < 100) return;
            inner.style.setProperty('height',     _vhPx + 'px', 'important');
            inner.style.setProperty('min-height', _vhPx + 'px', 'important');
            inner.style.setProperty('max-height', _vhPx + 'px', 'important');
        }

        /* backdrop-filter keeps painting a blurred box on a visibility:hidden
           element (GPU compositing), so a compact frosted section on a
           hidden/inactive screen ghosts as an empty frosted rectangle over
           the active screen. The inline backdrop-filter is !important, so
           visibility:hidden + any CSS rule can't suppress it — neutralise it
           inline while the screen is hidden, restore when shown. Mirrors the
           frontend fix in impulse-screens.js so the editor stays WYSIWYG. */
        function _toggleFrostBf(f,on){if(on){if(f.hasAttribute('data-frost-bf')){var v=f.getAttribute('data-frost-bf');if(v){f.style.setProperty('backdrop-filter',v,'important');f.style.setProperty('-webkit-backdrop-filter',v,'important');}else{f.style.removeProperty('backdrop-filter');f.style.removeProperty('-webkit-backdrop-filter');}f.removeAttribute('data-frost-bf');}}else{if(!f.hasAttribute('data-frost-bf'))f.setAttribute('data-frost-bf',f.style.getPropertyValue('backdrop-filter')||f.style.getPropertyValue('-webkit-backdrop-filter')||'');f.style.setProperty('backdrop-filter','none','important');f.style.setProperty('-webkit-backdrop-filter','none','important');}}
        function setScreenFrost(el,on){try{if(!el||!el.querySelectorAll)return;var l=el.querySelectorAll('[style*="backdrop-filter"]');for(var i=0;i<l.length;i++)_toggleFrostBf(l[i],on);if(el.matches&&el.matches('[style*="backdrop-filter"]'))_toggleFrostBf(el,on);}catch(_e){}}
        function applyEnterState(el) {
            setScreenFrost(el, false);
            var p = getPreset(el);
            el.style.transition = 'none';
            el.style.zIndex = 0;
            _lockInnerHeight(el);
            // Use setProperty with !important to beat the
            // .impulse-screen-animating CSS rule (visibility:visible) that
            // can win over a plain inline visibility:hidden when the class
            // is still on the element at the moment of the cleanup. Without
            // this, the previous (last) screen could keep visibility:visible
            // through wrap-around and stay painted on top of screen 0.
            el.style.setProperty('visibility', 'hidden', 'important');
            el.style.setProperty('pointer-events', 'none', 'important');
            // decomposeExit/Enter set inline overflow:visible + transformStyle
            // on screen ancestors. Clear them so CSS rules reapply (active
            // screen needs overflow:auto to scroll, our CSS would otherwise
            // be masked by the inline override).
            el.style.removeProperty('overflow');
            el.style.removeProperty('transform-style');
            clearStyles(el);
            cleanupDecomposition(el);
            if (p.clip) {
                el.style.opacity = '1'; el.style.transform = 'scale(1)';
                el.style.clipPath = p.enter.clip;
            } else if (!p.complex) {
                el.style.opacity = p.enter.opacity;
                el.style.transform = p.enter.transform;
            } else {
                el.style.opacity = '1'; el.style.transform = 'scale(1)';
            }
            el.classList.remove('impulse-screen-active', 'impulse-screen-animating');
        }

        function applyExitState(el) {
            setScreenFrost(el, false);
            var p = getPreset(el);
            el.style.transition = 'none';
            el.style.zIndex = 0;
            _lockInnerHeight(el);
            el.style.setProperty('visibility', 'hidden', 'important');
            el.style.setProperty('pointer-events', 'none', 'important');
            el.style.removeProperty('overflow');
            el.style.removeProperty('transform-style');
            clearStyles(el);
            cleanupDecomposition(el);
            if (window.impulseAnimatorReset) window.impulseAnimatorReset(el);
            if (p.clip) {
                el.style.opacity = p.exit.opacity; el.style.transform = p.exit.transform;
            } else if (!p.complex) {
                el.style.opacity = p.exit.opacity; el.style.transform = p.exit.transform;
            } else {
                el.style.opacity = '0'; el.style.transform = 'scale(1)';
            }
            el.classList.remove('impulse-screen-active', 'impulse-screen-animating');
        }

        var _isNavigating = false;

        // Zone merge (Sergei 2026-06-03): Hero and Body are one flat screen
        // sequence now, with a single PAGE-WIDE environment that renders behind
        // EVERY screen. The old body-zone env-hide is gone — the editor keeps
        // the env visible on all screens, matching the frontend snap engine
        // (which never added .impulse-past-hero either; the hide only ever
        // fired in the classic/free-scroll + footer path). Kept as a no-op
        // clear so any stale class from a prior session is wiped.
        function _updatePastHero() {
            try { document.body.classList.remove('impulse-past-hero'); } catch (_e) {}
        }

        function applyFocusState(el) {
            setScreenFrost(el, true);
            el.style.transition = 'none';
            el.style.zIndex = 2;
            el.style.opacity = '1';
            _freeInnerHeight(el);
            // 2026-05-23: empty string instead of scale(1). scale(1) is
            // visually identity but still a transform → creates a containing
            // block for position:fixed descendants, trapping the screen
            // toolbar inside the section's stacking context (below header).
            // Empty clears any leftover transition transform without
            // re-creating the trap.
            el.style.transform = '';
            el.style.filter = '';
            // decomposeExit leaves inline overflow:visible on the screen,
            // which masks our CSS overflow:auto rule. Clear it so the
            // active screen scrolls again.
            el.style.removeProperty('overflow');
            el.style.removeProperty('transform-style');
            // Use setProperty with !important so we beat any stale inline styles
            // an aborted transition may have left behind. Without this, a previous
            // applyEnterState's inline pointer-events:none survives and the CSS
            // .impulse-screen-active rule (no !important) can't override it,
            // making cells inside the focused screen reject drag-drops.
            el.style.setProperty('visibility', 'visible', 'important');
            el.style.setProperty('pointer-events', 'auto', 'important');
            clearStyles(el);
            cleanupDecomposition(el);
            // Restore display on translateZ'd descendants — the
            // complex-transition exit path sets display:none on them to
            // force Chrome to drop their GPU layer (stops the tiers CTA
            // flash on the next screen). Undo that here when this screen
            // becomes active again.
            var _leaky = el.querySelectorAll('[style*="translateZ"], [style*="translate3d"]');
            for (var _li = 0; _li < _leaky.length; _li++) {
                if (_leaky[_li].style.display === 'none') _leaky[_li].style.display = '';
            }
            // Only hide assets when navigating — animation will fire and show canvas.
            // On initial load without navigation, keep asset visible (editor has no preset
            // auto-run, so nothing would restore the asset after hiding it).
            if (_isNavigating) {
                el.querySelectorAll('.impulse-la-asset').forEach(function(a){ a.style.opacity='0'; });
            } else {
                // Ensure asset is visible on initial focus (defeats the global .impulse-la-asset{opacity:0} CSS)
                el.querySelectorAll('.impulse-la-asset').forEach(function(a){ a.style.opacity='1'; });
            }
            el.classList.add('impulse-screen-active');
            el.classList.remove('impulse-screen-animating');
            // Only trigger animator on actual navigation, not settings-change reinit
            if (_isNavigating && window.impulseOnScreenFocus) window.impulseOnScreenFocus(el);
            _isNavigating = false;
            // Re-init any sliders inside this screen that were initialized while hidden
            // (hidden elements have zero dimensions, so layout was broken at first init)
            setTimeout(function() {
                el.querySelectorAll('.impulse-new-slider').forEach(function(s) {
                    s._sliderInit = false;
                });
                if (typeof initNewSliders === 'function') initNewSliders();
            }, 50);
        }

        // Initialize
        screens.forEach(function(el, i) {
            // Clamp to valid range
            if (currentScreen >= screens.length) currentScreen = screens.length - 1;
            if (i === currentScreen) applyFocusState(el); else if (i < currentScreen) applyExitState(el); else applyEnterState(el);
        });
        _updatePastHero();
        // Seed timeline progress for the initial screen so its t=0 keyframes
        // render on page load. Without this, the wheel handler is what
        // eventually calls apply() — and the user sees the screen's natural
        // (non-keyframed) state until they first scroll, which manifests as
        // "Start state isn't applied until I scroll back". The screen-mode
        // frontend has the same fix at the end of impulse-screens.js init;
        // editor preview iframe needs an equivalent seed. apply() is a
        // no-op when the screen has no keyframes, so calling unconditionally
        // is safe.
        try {
            if (window.ImpulseTimeline && screens[currentScreen] &&
                window.ImpulseTimeline.hasTimeline(screens[currentScreen])) {
                window.ImpulseTimeline.apply(screens[currentScreen], 0);
                _tlTarget = 0;
                _tlProgress = 0;
            }
        } catch (_eTLseed) {}
        // Fire impulse-screen-changed on initial mount too so listeners
        // (ink divider's paint-in re-trigger, etc.) run on page load —
        // not just on subsequent navigations. Wrapped in a microtask so
        // the rest of init() finishes laying out screens before listeners
        // start querying for the active one.
        setTimeout(function() {
            document.dispatchEvent(new Event('impulse-screen-changed'));
        }, 0);
        // Notify editor of initial screen so block-click targets the correct
        // screen. `true` marks this as the init fire — distinguishes it from a
        // user-driven transition so the parent doesn't drop the selection
        // every time an element refresh re-runs this init.
        notifyParent(true);

        // Nav dots
        var dotsContainer = null;
        function createDots() {
            if (dotsContainer) dotsContainer.remove();
            dotsContainer = document.createElement('div');
            dotsContainer.className = 'impulse-screen-nav';
            screens.forEach(function(el, i) {
                var dot = document.createElement('button');
                dot.className = 'impulse-screen-nav-dot' + (i === currentScreen ? ' active' : '');
                dot.addEventListener('click', function() { goToScreen(i); });
                dotsContainer.appendChild(dot);
            });
            body.appendChild(dotsContainer);
        }
        if (showDots && screens.length > 1) createDots();

        // Screen nav bar — update current screen name + active link
        var screenNames = <?php echo wp_json_encode(array_map(function($s) { return $s['name']; }, $GLOBALS['impulse_screen_sections'] ?? [])); ?>;
        function updateScreenNav() {
            var nameEl = document.getElementById('impulse-snav-current');
            if (nameEl && screenNames[currentScreen]) {
                nameEl.value = screenNames[currentScreen];
            }
            var links = document.querySelectorAll('.impulse-snav-link');
            links.forEach(function(l) {
                l.classList.toggle('active', parseInt(l.dataset.screenIndex) === currentScreen);
            });
            // Auto-color: detect current screen bg luminance, adapt nav text
            var nav = document.querySelector('.impulse-screen-nav');
            if (nav && nav.dataset.autoColor === '1' && screens[currentScreen]) {
                var screenEl = screens[currentScreen];
                var bg = getComputedStyle(screenEl).backgroundColor || getComputedStyle(document.body).backgroundColor;
                var m = bg.match(/\d+/g);
                if (m && m.length >= 3) {
                    var lum = (0.299 * parseInt(m[0]) + 0.587 * parseInt(m[1]) + 0.114 * parseInt(m[2])) / 255;
                    var col = lum > 0.6 ? '#333333' : '#ffffff';
                    nav.querySelectorAll('.impulse-screen-nav-logo span, .impulse-screen-nav-toggle, .impulse-snav-link, .impulse-screen-nav-logo a').forEach(function(el) {
                        el.style.color = col;
                    });
                }
            }
        }
        // Wire panel link clicks
        document.addEventListener('click', function(e) {
            var link = e.target.closest('.impulse-snav-link');
            if (!link) return;
            e.preventDefault();
            var idx = parseInt(link.dataset.screenIndex);
            if (!isNaN(idx)) goToScreen(idx);
            var panel = document.getElementById('impulse-snav-panel');
            if (panel) panel.classList.remove('open');
        });
        // Close panel on click outside
        document.addEventListener('click', function(e) {
            var panel = document.getElementById('impulse-snav-panel');
            if (!panel || !panel.classList.contains('open')) return;
            if (e.target.closest('.impulse-snav-panel') || e.target.closest('.impulse-screen-nav-toggle')) return;
            panel.classList.remove('open');
        });

        function updateDots() {
            if (!dotsContainer) return;
            dotsContainer.querySelectorAll('.impulse-screen-nav-dot').forEach(function(d, i) {
                d.classList.toggle('active', i === currentScreen);
            });
        }

        // ── Screen strip removed from preview/frontend — editor-only feature ──
        function createThumbStrip() {}
        function updateThumbStrip() {}

        function notifyParent(isInit) {
            // `isInit:true` flags the initial fire from the screen engine's
            // own init() — distinct from a user-driven screen transition.
            // Editor uses the flag to skip the release-selection branch on
            // re-inits (an element refresh during settings edits would
            // otherwise drop the user's selection and reset the right
            // panel back to Scene Settings).
            if (window.parent) window.parent.postMessage({
                type: 'impulse-screen-change',
                screenIndex: currentScreen,
                isInit: !!isInit
            }, '*');
        }

        // ==========================================
        // COMPLEX TRANSITIONS (unchanged from v4)
        // ==========================================

        function makeOverlay(css) {
            var ov = document.createElement('div');
            ov.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;pointer-events:none;overflow:hidden' + (css || '');
            return ov;
        }

        function prepNewScreen(el) {
            setScreenFrost(el, true);
            el.style.transition = 'none';
            el.style.visibility = 'visible';
            el.style.zIndex = 1;
            el.style.opacity = '1';
            el.style.transform = 'scale(1)';
            el.style.filter = '';
            clearStyles(el);
        }

        function doDissolve(oldEl, newEl, forward, callback) {
            prepNewScreen(newEl);
            var uid='impulse-dissolve-'+Date.now();
            var svgNS='http://www.w3.org/2000/svg';
            var svg=document.createElementNS(svgNS,'svg'); svg.setAttribute('class','impulse-dissolve-svg'); svg.setAttribute('style','position:absolute;width:0;height:0;pointer-events:none');
            var defs=document.createElementNS(svgNS,'defs');
            var filter=document.createElementNS(svgNS,'filter'); filter.setAttribute('id',uid); filter.setAttribute('x','-20%'); filter.setAttribute('y','-20%'); filter.setAttribute('width','140%'); filter.setAttribute('height','140%');
            var turb=document.createElementNS(svgNS,'feTurbulence'); turb.setAttribute('type','fractalNoise'); turb.setAttribute('baseFrequency','0.015'); turb.setAttribute('numOctaves','3'); turb.setAttribute('seed',String(Math.floor(Math.random()*100))); turb.setAttribute('result','noise');
            var disp=document.createElementNS(svgNS,'feDisplacementMap'); disp.setAttribute('in','SourceGraphic'); disp.setAttribute('in2','noise'); disp.setAttribute('scale','0'); disp.setAttribute('xChannelSelector','R'); disp.setAttribute('yChannelSelector','G');
            filter.appendChild(turb); filter.appendChild(disp); defs.appendChild(filter); svg.appendChild(defs); oldEl.appendChild(svg);
            oldEl.style.zIndex=3; oldEl.style.filter='url(#'+uid+')';
            var startTime=null,maxDisp=300;
            function animate(ts){ if(!startTime)startTime=ts; var progress=Math.min((ts-startTime)/duration,1); var ep=1-Math.pow(1-progress,3); disp.setAttribute('scale',String(Math.floor(ep*maxDisp))); oldEl.style.opacity=String(Math.max(0,1-progress*1.3)); if(progress<1)requestAnimationFrame(animate); else callback(); }
            requestAnimationFrame(animate);
        }

        // Hard-cut the outgoing screen in one frame. In a preserve-3d scene the
        // browser paints by 3D Z-position, not z-index, so an old screen flown
        // toward the viewer (positive translateZ) renders IN FRONT of the
        // incoming one and ghosts over it — the "previous flashes over next"
        // artifact. Removing it outright is the only reliable kill. Mirrors the
        // frontend impulse-screens.js fix so the editor preview stays WYSIWYG.
        // Sergei 2026-06-02.
        function _hardCutScreen(el) {
            if (!el) return;
            el.style.transition = 'none';
            el.style.zIndex = 0;
            el.style.opacity = '0';
            el.style.transform = '';
            el.style.filter = '';
            el.style.setProperty('visibility', 'hidden', 'important');
        }

        function doRise(oldEl, newEl, forward, callback) {
            var dur = duration * 1.4; prepNewScreen(newEl);
            newEl.style.zIndex = 3; newEl.style.opacity = '0'; newEl.style.transform = 'translateY(100vh)'; _hardCutScreen(oldEl);
            var start = null;
            function frame(ts) { if (!start) start = ts; var t = Math.min((ts - start) / dur, 1); var sp = springDamp(t, 2.0, 5);
                var nY = 100 * (1 - sp); newEl.style.transform = 'translateY(' + nY.toFixed(2) + 'vh)'; newEl.style.opacity = Math.min(1, sp * 2.5).toFixed(3);
                if (t < 1) requestAnimationFrame(frame); else { newEl.style.filter = ''; newEl.style.transform = ''; newEl.style.transformStyle = 'preserve-3d'; callback(); }
            } requestAnimationFrame(frame);
        }

        function doFall(oldEl, newEl, forward, callback) {
            var dur = duration * 1.4; prepNewScreen(newEl);
            newEl.style.zIndex = 3; newEl.style.opacity = '0'; newEl.style.transform = 'translateY(-100vh)'; _hardCutScreen(oldEl);
            var start = null;
            function frame(ts) { if (!start) start = ts; var t = Math.min((ts - start) / dur, 1); var sp = springDamp(t, 1.8, 4.5);
                var nY = -100 * (1 - sp); newEl.style.transform = 'translateY(' + nY.toFixed(2) + 'vh)'; newEl.style.opacity = Math.min(1, sp * 2.5).toFixed(3);
                if (t < 1) requestAnimationFrame(frame); else { newEl.style.filter = ''; newEl.style.transform = ''; newEl.style.transformStyle = 'preserve-3d'; callback(); }
            } requestAnimationFrame(frame);
        }

        function springDamp(t, freq, decay) {
            if (t >= 1) return 1;
            var w = freq * Math.PI * 2;
            return 1 - Math.exp(-decay * t) * Math.cos(w * t);
        }

        function doDepthSpring(oldEl, newEl, forward, callback) {
            var dur = duration * 1.6; prepNewScreen(newEl);
            newEl.style.zIndex=3;newEl.style.opacity='0';newEl.style.transform='perspective(700px) translateZ(-2000px) scale(0.1)';newEl.style.filter='blur(20px)';_hardCutScreen(oldEl);
            var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var sp=springDamp(t,2.5,5);
            var nZ=-2000*(1-sp),nS=0.1+0.9*sp,nBlur=20*(1-sp),nAlpha=Math.min(1,sp*2.5);
            newEl.style.transform='perspective(700px) translateZ('+nZ.toFixed(0)+'px) scale('+nS.toFixed(4)+')';newEl.style.filter='blur('+Math.max(0,nBlur).toFixed(1)+'px)';newEl.style.opacity=nAlpha.toFixed(3);
            if(t<1)requestAnimationFrame(frame);else{newEl.style.filter='';newEl.style.transform='';newEl.style.transformStyle='preserve-3d';callback();}}requestAnimationFrame(frame);
        }

        function doDepthVortex(oldEl, newEl, forward, callback) {
            var dur = duration * 1.8; prepNewScreen(newEl);
            newEl.style.zIndex=3;newEl.style.opacity='0';newEl.style.transform='perspective(700px) translateZ(-1800px) rotate(-180deg) scale(0.05)';newEl.style.filter='blur(18px)';_hardCutScreen(oldEl);
            var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var sp=springDamp(t,2.0,4);
            var nZ=-1800*(1-sp),nRot=-180*(1-sp),nS=0.05+0.95*sp,nBlur=18*(1-sp),nAlpha=Math.min(1,sp*2.5);
            newEl.style.transform='perspective(700px) translateZ('+nZ.toFixed(0)+'px) rotate('+nRot.toFixed(1)+'deg) scale('+nS.toFixed(4)+')';newEl.style.filter='blur('+Math.max(0,nBlur).toFixed(1)+'px)';newEl.style.opacity=nAlpha.toFixed(3);
            if(t<1)requestAnimationFrame(frame);else{newEl.style.filter='';newEl.style.transform='';newEl.style.transformStyle='preserve-3d';callback();}}requestAnimationFrame(frame);
        }

        function doDepthZoom(oldEl, newEl, forward, callback) {
            var dur = duration * 2.0; prepNewScreen(newEl);
            newEl.style.zIndex=3;newEl.style.opacity='0';newEl.style.transform='perspective(700px) translateZ(-3000px) scale(0.02)';newEl.style.filter='blur(30px)';_hardCutScreen(oldEl);
            var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var sp=springDamp(t,3.0,6);
            var nZ=-3000*(1-sp),nS=0.02+0.98*sp,nBlur=30*Math.max(0,1-sp*1.5),nAlpha=Math.min(1,sp*3);
            newEl.style.transform='perspective(700px) translateZ('+nZ.toFixed(0)+'px) scale('+nS.toFixed(4)+')';newEl.style.filter='blur('+Math.max(0,nBlur).toFixed(1)+'px)';newEl.style.opacity=nAlpha.toFixed(3);
            if(t<1)requestAnimationFrame(frame);else{newEl.style.filter='';newEl.style.transform='';newEl.style.transformStyle='preserve-3d';callback();}}requestAnimationFrame(frame);
        }

        function doMorph(oldEl,newEl,forward,callback){var dur=duration*1.2;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='0';newEl.style.transform='scale(1.08) skewX(3deg)';newEl.style.filter='blur(8px)';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var e=t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;newEl.style.opacity=Math.min(1,e*1.8).toFixed(3);newEl.style.transform='scale('+(1.08-0.08*e).toFixed(3)+') skewX('+(3*(1-e)).toFixed(1)+'deg)';newEl.style.filter='blur('+(8*(1-e)).toFixed(1)+'px)';oldEl.style.transform='scale('+(1+0.08*e).toFixed(3)+') skewX('+(-3*e).toFixed(1)+'deg)';oldEl.style.opacity=(1-e).toFixed(3);oldEl.style.filter='blur('+(8*e).toFixed(1)+'px)';if(t<1)requestAnimationFrame(frame);else{newEl.style.filter='';newEl.style.transform='';oldEl.style.filter='';callback();}}requestAnimationFrame(frame);}

        function doDoor(oldEl,newEl,forward,callback){var dur=duration*1.1;prepNewScreen(newEl);newEl.style.zIndex=1;newEl.style.opacity='1';oldEl.style.zIndex=3;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var e=1-Math.pow(1-t,3);var half=50*e;oldEl.style.clipPath='inset(0 '+half.toFixed(1)+'% 0 '+half.toFixed(1)+'%)';oldEl.style.transform='perspective(800px) translateZ('+(200*e).toFixed(0)+'px)';oldEl.style.opacity=(1-t*t).toFixed(3);if(t<1)requestAnimationFrame(frame);else{oldEl.style.clipPath='';oldEl.style.transform='';callback();}}requestAnimationFrame(frame);}

        function doSwirl(oldEl,newEl,forward,callback){var dur=duration*1.4;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='0';newEl.style.transform='rotate(180deg) scale(0)';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var sp=springDamp(t,1.5,4);newEl.style.transform='rotate('+(180*(1-sp)).toFixed(1)+'deg) scale('+Math.max(0.01,sp).toFixed(3)+')';newEl.style.opacity=Math.min(1,sp*2).toFixed(3);oldEl.style.transform='rotate('+(-90*t).toFixed(1)+'deg) scale('+(1-0.5*t).toFixed(3)+')';oldEl.style.opacity=(1-t).toFixed(3);if(t<1)requestAnimationFrame(frame);else{newEl.style.transform='';oldEl.style.transform='';callback();}}requestAnimationFrame(frame);}

        function doSqueeze(oldEl,newEl,forward,callback){var dur=duration*1.1;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='0';newEl.style.transform='scaleY(0.01)';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var e=1-Math.pow(1-t,3);oldEl.style.transform='scaleY('+(1-e).toFixed(3)+')';oldEl.style.opacity=t<0.5?'1':(2-2*t).toFixed(3);newEl.style.transform='scaleY('+Math.max(0.01,e).toFixed(3)+')';newEl.style.opacity=Math.min(1,e*1.5).toFixed(3);if(t<1)requestAnimationFrame(frame);else{newEl.style.transform='';oldEl.style.transform='';callback();}}requestAnimationFrame(frame);}

        function doRadial(oldEl,newEl,forward,callback){var dur=duration*1.1;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='1';newEl.style.clipPath='circle(0% at 50% 50%)';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var e=1-Math.pow(1-t,2);newEl.style.clipPath='circle('+(e*150).toFixed(1)+'% at 50% 50%)';if(t<1)requestAnimationFrame(frame);else{newEl.style.clipPath='';callback();}}requestAnimationFrame(frame);}


        function doHexagon(oldEl,newEl,forward,callback){var dur=duration*1.2;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='1';newEl.style.clipPath='polygon(50% 50%,50% 50%,50% 50%,50% 50%,50% 50%,50% 50%)';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var e=1-Math.pow(1-t,2.5);var r=e*150;var pts=[];for(var i=0;i<6;i++){var a=i*60-90;var rad=a*Math.PI/180;pts.push((50+Math.cos(rad)*r).toFixed(1)+'% '+(50+Math.sin(rad)*r).toFixed(1)+'%');}newEl.style.clipPath='polygon('+pts.join(',')+')';if(t<1)requestAnimationFrame(frame);else{newEl.style.clipPath='';callback();}}requestAnimationFrame(frame);}

        function doBurn(oldEl,newEl,forward,callback){var dur=duration*1.4;prepNewScreen(newEl);newEl.style.zIndex=1;newEl.style.opacity='1';oldEl.style.zIndex=3;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var e=1-Math.pow(1-t,2);oldEl.style.filter='brightness('+(1+e*3).toFixed(1)+') saturate('+(1+e*2).toFixed(1)+') contrast('+(1+e*4).toFixed(1)+')';oldEl.style.opacity=Math.max(0,1-e*1.3).toFixed(3);if(t<1)requestAnimationFrame(frame);else{oldEl.style.filter='';callback();}}requestAnimationFrame(frame);}

        function doPixelize(oldEl,newEl,forward,callback){var dur=duration;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='0';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var px=Math.max(1,Math.floor(t*40));oldEl.style.filter='blur('+px+'px)';oldEl.style.opacity=Math.max(0,1-t*1.2).toFixed(3);newEl.style.opacity=Math.min(1,Math.max(0,(t-0.3)/0.7)).toFixed(3);var px2=Math.max(0,Math.floor((1-t)*30));newEl.style.filter='blur('+px2+'px)';if(t<1)requestAnimationFrame(frame);else{oldEl.style.filter='';newEl.style.filter='';callback();}}requestAnimationFrame(frame);}


        function doWind(oldEl,newEl,forward,callback){var dur=duration*1.2;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='0';newEl.style.transform='translateX(100%) skewX(-8deg)';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var e=1-Math.pow(1-t,2.5);newEl.style.transform='translateX('+(100*(1-e)).toFixed(1)+'%) skewX('+(-8*(1-e)).toFixed(1)+'deg)';newEl.style.opacity=Math.min(1,e*2).toFixed(3);oldEl.style.transform='translateX('+(-50*e).toFixed(1)+'%) skewX('+(5*e).toFixed(1)+'deg)';oldEl.style.opacity=Math.max(0,1-e*1.5).toFixed(3);if(t<1)requestAnimationFrame(frame);else{newEl.style.transform='';oldEl.style.transform='';callback();}}requestAnimationFrame(frame);}

        function doWaterDrop(oldEl,newEl,forward,callback){var dur=duration*1.3;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='1';newEl.style.clipPath='circle(0% at 50% 50%)';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var sp=springDamp(t,2.0,5);var r=sp*150;newEl.style.clipPath='circle('+r.toFixed(1)+'% at 50% 50%)';oldEl.style.filter='blur('+(t*6).toFixed(1)+'px)';oldEl.style.transform='scale('+(1+t*0.05).toFixed(3)+')';if(t<1)requestAnimationFrame(frame);else{newEl.style.clipPath='';oldEl.style.filter='';oldEl.style.transform='';callback();}}requestAnimationFrame(frame);}

        function doDreamy(oldEl,newEl,forward,callback){var dur=duration*1.5;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='0';newEl.style.transform='scale(1.1)';newEl.style.filter='blur(15px) brightness(1.3)';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var e=t*t*(3-2*t);newEl.style.opacity=e.toFixed(3);newEl.style.transform='scale('+(1.1-0.1*e).toFixed(3)+')';newEl.style.filter='blur('+(15*(1-e)).toFixed(1)+'px) brightness('+(1.3-0.3*e).toFixed(2)+')';oldEl.style.filter='blur('+(15*e).toFixed(1)+'px) brightness('+(1+0.3*e).toFixed(2)+')';oldEl.style.opacity=(1-e).toFixed(3);if(t<1)requestAnimationFrame(frame);else{newEl.style.filter='';newEl.style.transform='';oldEl.style.filter='';callback();}}requestAnimationFrame(frame);}

        function doRipple(oldEl,newEl,forward,callback){var dur=duration*1.2;prepNewScreen(newEl);newEl.style.zIndex=3;newEl.style.opacity='0';newEl.style.transform='scale(0.8)';oldEl.style.zIndex=1;var start=null;function frame(ts){if(!start)start=ts;var t=Math.min((ts-start)/dur,1);var sp=springDamp(t,3.0,4);newEl.style.transform='scale('+Math.max(0.01,0.8+0.2*sp).toFixed(3)+')';newEl.style.opacity=Math.min(1,sp*2).toFixed(3);oldEl.style.transform='scale('+(1+0.3*t).toFixed(3)+')';oldEl.style.opacity=Math.max(0,1-t*1.5).toFixed(3);oldEl.style.filter='blur('+(t*8).toFixed(1)+'px)';if(t<1)requestAnimationFrame(frame);else{newEl.style.transform='';oldEl.style.transform='';oldEl.style.filter='';callback();}}requestAnimationFrame(frame);}

        // ==========================================
        // MAIN NAVIGATION
        // ==========================================

        function impulseRunScreenEntrance(screenEl, bgColor) {
            var preset = screenEl.dataset.entrancePreset || 'none';
            if (preset === 'none' || !preset) return;
            var duration = parseInt(screenEl.dataset.entranceDuration) || 1200;
            var W = screenEl.offsetWidth || window.innerWidth;
            var H = screenEl.offsetHeight || window.innerHeight;
            var assetEl = screenEl.querySelector('.impulse-logo-animator, .impulse-display-text, .impulse-image img, img');
            var cx = W * 0.5, cy = H * 0.5;
            if (assetEl) {
                try {
                    var sRect = screenEl.getBoundingClientRect();
                    var aRect = assetEl.getBoundingClientRect();
                    var ax = (aRect.left + aRect.width * 0.5) - sRect.left;
                    var ay = (aRect.top + aRect.height * 0.5) - sRect.top;
                    if (ax > 0 && ax < W && ay > 0 && ay < H) { cx = ax; cy = ay; }
                } catch(e) {}
            }
            var cv = document.createElement('canvas');
            cv.width = W; cv.height = H;
            cv.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:9998;pointer-events:none;';
            var prevOverflow = screenEl.style.overflow;
            screenEl.style.overflow = 'hidden';
            screenEl.appendChild(cv);
            var ctx = cv.getContext('2d');
            var maxR = Math.sqrt(Math.max(cx,W-cx)*Math.max(cx,W-cx)+Math.max(cy,H-cy)*Math.max(cy,H-cy))*1.05;
            function eOut(t){return 1-Math.pow(1-t,2.5);}
            var start = null;
            function frame(ts) {
                if (!start) start = ts;
                var t = Math.min((ts-start)/duration, 1);
                var p = eOut(t);
                ctx.globalCompositeOperation = 'source-over';
                ctx.fillStyle = bgColor || '#000';
                ctx.fillRect(0, 0, W, H);
                ctx.globalCompositeOperation = 'destination-out';
                ctx.fillStyle = 'rgba(0,0,0,1)';
                var r = p * maxR;
                if (preset === 'iris') {
                    ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fill();
                } else if (preset === 'diamond') {
                    ctx.save(); ctx.translate(cx,cy); ctx.rotate(Math.PI/4);
                    ctx.fillRect(-r,-r,r*2,r*2); ctx.restore();
                } else if (preset === 'hex') {
                    ctx.beginPath();
                    for (var i=0;i<6;i++){var a=i*Math.PI/3-Math.PI/6;i===0?ctx.moveTo(cx+r*Math.cos(a),cy+r*Math.sin(a)):ctx.lineTo(cx+r*Math.cos(a),cy+r*Math.sin(a));}
                    ctx.closePath(); ctx.fill();
                } else if (preset === 'shatter') {
                    var N=18, gap=Math.sin(t*Math.PI*0.5)*0.08;
                    for (var s=0;s<N;s++){var a1=(s/N)*Math.PI*2,a2=((s+1)/N)*Math.PI*2-gap;ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(cx+r*Math.cos(a1),cy+r*Math.sin(a1));ctx.lineTo(cx+r*Math.cos(a2),cy+r*Math.sin(a2));ctx.closePath();ctx.fill();}
                } else if (preset === 'bands') {
                    var nB=12, bH=H/nB;
                    for (var b=0;b<nB;b++){var bcy=(b+0.5)*bH,dist=Math.abs(bcy-cy)/H,del=dist*0.45,bt=Math.max(0,Math.min(1,(t-del)/(1-del))),bp=eOut(bt),bw=bp*W;ctx.fillRect(cx-bw*0.5,b*bH,bw,bH+1);}
                }
                if (t < 1) { requestAnimationFrame(frame); }
                else { cv.remove(); screenEl.style.overflow = prevOverflow; }
            }
            requestAnimationFrame(frame);
        }

        function goToScreen(index) {
            _isNavigating = true;
            if (index < 0 || index >= screens.length || index === currentScreen) return;
            if (isTransitioning || cooldownTimer) return;

            isTransitioning = true;
            var _myNavGen = ++_navGen;
            duration = _zoneDuration(index);
            // Release the navigation lock quickly so rapid scrolling stays
            // responsive. The next navigation bumps _navGen (cancelling this
            // transition's decompose loops) and re-preps its own screens, so
            // it never needs this cleanup to have finished or the visual
            // transition to be complete. Cap at 500ms so a long cinematic
            // transition can still be interrupted mid-flight.
            setTimeout(function() { if (_navGen === _myNavGen) isTransitioning = false; }, Math.min(duration + 80, 500));
            if (window.impulsePreWarmScreen) window.impulsePreWarmScreen(screens[index]);
            var wrappingForward = (currentScreen === screens.length - 1 && index === 0);
            var wrappingBackward = (currentScreen === 0 && index === screens.length - 1);
            var goingForward = wrappingForward ? true : (wrappingBackward ? false : index > currentScreen);
            // Seed the incoming screen's timeline so it starts at 0 on a
            // forward entry or 1 on a backward entry. Without this, a user
            // scrolling past a keyframed screen (or looping around from
            // the last screen to the first) leaves progress saturated and
            // the re-entered screen locks at its endpoint instead of
            // playing from the beginning.
            if (typeof _tlTarget !== 'undefined') {
                if (typeof _tlRaf !== 'undefined' && _tlRaf) {
                    cancelAnimationFrame(_tlRaf); _tlRaf = 0;
                }
                _tlTarget   = goingForward ? 0 : 1;
                _tlProgress = _tlTarget;
                if (_screenHasTimeline(screens[index])) {
                    _applyTimelineProgress(screens[index], _tlProgress);
                }
            }
            // Hyperlapse only on forward scroll
            if (goingForward && window._impulseHyperJump) window._impulseHyperJump();
            if (window._impulseCosmosHyperJump) window._impulseCosmosHyperJump();
            // Fireflies swarm circle-and-settle on scroll — mirror of the frontend
            // impulse-screens.js dispatch (which is fireflies-specific). The editor
            // only fired the cosmos variant, so in the builder the fireflies env
            // flashed on transition instead of orbiting. Guard-by-existence: this is
            // only registered on the iframe window when fireflies is the active env.
            if (window._impulseFirefliesHyperJump) window._impulseFirefliesHyperJump();
            var oldEl = screens[currentScreen];
            var newEl = screens[index];
            var p = getPreset(newEl);
            oldEl.classList.add('impulse-screen-animating');
            newEl.classList.add('impulse-screen-animating');
            // Update strip immediately — don't wait for transition to finish
            currentScreen = index;
            _updatePastHero();
            updateThumbStrip();
            notifyParent();

            var _cleanupRan = false;
            function cleanup() {
                if (_cleanupRan) return;
                _cleanupRan = true;
                // Superseded by a newer navigation — its cleanup owns the
                // screens now; this stale pass must not clobber it.
                if (_navGen !== _myNavGen) return;
                // Apply focus to the target FIRST so :has() reveal rules
                // (e.g. the editor sandbox: body[data-screen-mode="1"]:has(
                // .impulse-content-wrapper > .impulse-el:not(:first-of-type)
                // .impulse-screen-active)) don't briefly un-match during the
                // loop. With forEach in array order, navigating from
                // screen 1 to screen 2 removes active from s1 BEFORE adding
                // it to s2 — a single frame with no non-first-of-type
                // active triggers :has() invalidation, the base sandbox
                // rule (opacity:0) kicks in, the sandbox starts its exit
                // animation, then the rule re-matches and reverses.
                // Result: visible flash-and-disappear on every navigation.
                applyFocusState(screens[index]);
                screens.forEach(function(el, i) {
                    if (i === index) return;
                    if (i < index) applyExitState(el);
                    else applyEnterState(el);
                });
                // Belt-and-suspenders pass — defensively force-hide every
                // non-active screen one more time. Catches the wrap-around
                // case where the previous "last" screen was kept visible
                // because some inline style or animating-class residue
                // outlived the cleanup pass above. We re-stamp visibility
                // !important + pointer-events:none + opacity:0 directly.
                // Idempotent for non-active screens, no-op for the active
                // one.
                screens.forEach(function(el, i) {
                    if (i === index) return;
                    el.style.setProperty('visibility', 'hidden', 'important');
                    el.style.setProperty('pointer-events', 'none', 'important');
                    el.classList.remove('impulse-screen-active', 'impulse-screen-animating');
                });
                currentScreen = index;
                _updatePastHero();
                isTransitioning = false;
                updateDots();
                updateScreenNav();
                updateThumbStrip();
                notifyParent();
                cooldownTimer = setTimeout(function() { cooldownTimer = null; }, cooldown);
                // Mirror the frontend impulse-screens.js cleanup —
                // dispatch impulse-screen-changed so any element that
                // wants to re-fire on screen activation (e.g. ink
                // divider's paint-in animation, future scroll-linked
                // effects) gets the signal in editor preview too.
                // Without this, screen-mode pages in the editor never
                // re-trigger the ink paint when the user navigates
                // between screens — IntersectionObserver doesn't see
                // screen swaps in screen-mode because every section
                // remains layout-mounted.
                document.dispatchEvent(new Event('impulse-screen-changed'));
            }
            // Safety hard-cap: regardless of which transition path runs
            // (entrance preset, complex effect, decomposeEnter, simple
            // setTimeout), force cleanup to finish within `duration*3 + 500`
            // ms. Without this, a swallowed callback (decomposeEnter rAF
            // pause, complex-fn never invoking its done() arg, etc.) left
            // `isTransitioning=true` forever AND left the previous screen
            // visible on top of the new one — exactly the "first screen
            // stays along all others" symptom. The _cleanupRan re-entry
            // guard makes this a no-op when the normal path already ran.
            var _safetyMs = (typeof duration === 'number' ? duration : 700) * 3 + 500;
            setTimeout(cleanup, _safetyMs);

            var entrancePreset = newEl.dataset.entrancePreset || 'none';
            if (entrancePreset !== 'none') {
                newEl.style.transition = 'none'; newEl.style.visibility = 'visible';
                newEl.style.zIndex = 3; newEl.style.opacity = '1';
                newEl.style.transform = 'scale(1)'; newEl.style.pointerEvents = 'none';
                void newEl.offsetHeight;
                impulseRunScreenEntrance(newEl, newEl.dataset.entranceBg || '#000000');
                oldEl.style.zIndex = 1; oldEl.style.transition = transStr(p);
                oldEl.style.opacity = p.exit ? p.exit.opacity : '0'; oldEl.style.transform = p.exit ? p.exit.transform : 'scale(0.95)';
                decomposeExit(oldEl);
                setTimeout(cleanup, duration + 50);
                return;
            }

            if (p.complex) {
                var fn = {rise:doRise,fall:doFall,dissolve:doDissolve,depth_spring:doDepthSpring,depth_vortex:doDepthVortex,depth_zoom:doDepthZoom,morph:doMorph,door:doDoor,swirl:doSwirl,squeeze:doSqueeze,radial:doRadial,hexagonalize:doHexagon,burn:doBurn,pixelize:doPixelize,wind:doWind,water_drop:doWaterDrop,dreamy:doDreamy,ripple:doRipple}[p.complex];
                if (fn) {
                    var _cc = getDecompChildren(newEl);
                    for (var ci = 0; ci < _cc.length; ci++) _cc[ci].style.opacity = '0';
                    // Collect translateZ'd descendants in the OUTGOING screen
                    // (pricing cards' CTA wrappers, headers, etc). Chrome's
                    // compositor keeps these GPU layers alive through the
                    // transition and past cleanup — they flash on the next
                    // screen. Hide them with display:none at exit end so
                    // the layer is dropped; reset on next focus.
                    var _oldLeaky = oldEl.querySelectorAll('[style*="translateZ"], [style*="translate3d"]');
                    // Fire decomp early — at 40% of flight, not after it finishes
                    var _decompFired = false;
                    if (_cc.length > 0) {
                        var flightDur = duration * 1.6; // approximate average flight duration
                        setTimeout(function() {
                            if (!_decompFired) {
                                _decompFired = true;
                                decomposeEnter(newEl, null);
                            }
                        }, flightDur * 0.4);
                    }
                    fn(oldEl, newEl, goingForward, function() {
                        for (var lk = 0; lk < _oldLeaky.length; lk++) {
                            _oldLeaky[lk].style.display = 'none';
                        }
                        if (_cc.length > 0 && !_decompFired) {
                            _decompFired = true;
                            decomposeEnter(newEl, cleanup);
                        } else {
                            cleanup();
                        }
                    });
                    return;
                }
            }

            var trans = transStr(p);

            // Check if entering screen has decomposable children. Skip
            // decomposition entirely for BODY-zone targets — fade/lift/slide
            // animate the screen as a single piece, and staggered per-block
            // entry on top of that produces visible "shaking" on content-heavy
            // bodies (heading + text + grid). Hero keeps decomposition because
            // its cinematic single-headline screens benefit from depth-stagger.
            var _heroCountForDecomp = parseInt(body.dataset.screenHeroCount) || 0;
            var _isBodyTarget = (index >= _heroCountForDecomp);
            var _hasDecomp = !_isBodyTarget && getDecompChildren(newEl).length > 0;

            clearStyles(newEl); // drop any stale clip-path left by a prior zone's reveal

            // Pre-hide decomposable children SYNCHRONOUSLY before newEl is made
            // visible in the branches below. decomposeEnter (called at the end of
            // this function) hides them on its first rAF frame, but by then the
            // screen has already painted with the children at full opacity — the
            // "content appears, disappears, then staggers in" flash on every
            // screen. The complex-transition path above already pre-hides this
            // way (see _cc[ci].style.opacity='0'). Sergei 2026-06-05.
            if (_hasDecomp) {
                var _ccPre = getDecompChildren(newEl);
                for (var _ph = 0; _ph < _ccPre.length; _ph++) _ccPre[_ph].style.opacity = '0';
            }

            if (p.clip) {
                if (goingForward) {
                    newEl.style.transition='none'; newEl.style.visibility='visible';
                    newEl.style.zIndex=3; newEl.style.opacity='1';
                    newEl.style.transform='scale(1)'; newEl.style.clipPath=p.enter.clip;
                    newEl.style.pointerEvents='none';
                    void newEl.offsetHeight;
                    newEl.style.transition=trans; newEl.style.clipPath=p.visible.clip;
                    oldEl.style.zIndex=1; oldEl.style.transition=trans;
                    oldEl.style.opacity=p.exit.opacity; oldEl.style.transform=p.exit.transform;
                } else {
                    newEl.style.transition='none'; newEl.style.visibility='visible';
                    newEl.style.zIndex=1; newEl.style.opacity='1';
                    newEl.style.transform='scale(1)'; clearStyles(newEl);
                    oldEl.style.transition='none'; oldEl.style.zIndex=3;
                    oldEl.style.clipPath=p.visible.clip;
                    void oldEl.offsetHeight;
                    oldEl.style.transition=trans; oldEl.style.clipPath=p.enter.clip;
                    oldEl.style.opacity=p.exit.opacity;
                }
            } else {
                if (_hasDecomp) {
                    // Skip screen-level transition on newEl — decomposeEnter handles everything
                    newEl.style.transition='none'; newEl.style.visibility='visible';
                    newEl.style.zIndex=3; newEl.style.opacity='1';
                    newEl.style.transform='none'; newEl.style.filter='';
                    newEl.style.pointerEvents='none';
                    // Old screen still exits normally
                    oldEl.style.zIndex=1;
                    if (p.exitOrigin) oldEl.style.transformOrigin=p.exitOrigin;
                    oldEl.style.transition=trans; oldEl.style.opacity=p.exit.opacity;
                    oldEl.style.transform=p.exit.transform;
                } else if (goingForward) {
                    newEl.style.transition='none'; newEl.style.visibility='visible';
                    newEl.style.zIndex=3; newEl.style.opacity=p.enter.opacity;
                    newEl.style.transform=p.enter.transform; newEl.style.pointerEvents='none';
                    if (p.origin) newEl.style.transformOrigin=p.origin;
                    void newEl.offsetHeight;
                    newEl.style.transition=trans; newEl.style.opacity='1'; newEl.style.transform='scale(1)';
                    oldEl.style.zIndex=1;
                    if (p.exitOrigin) oldEl.style.transformOrigin=p.exitOrigin;
                    oldEl.style.transition=trans; oldEl.style.opacity=p.exit.opacity;
                    oldEl.style.transform=p.exit.transform;
                } else {
                    newEl.style.transition='none'; newEl.style.visibility='visible';
                    newEl.style.zIndex=3; newEl.style.opacity=p.exit.opacity;
                    newEl.style.transform=p.exit.transform; newEl.style.pointerEvents='none';
                    if (p.exitOrigin) newEl.style.transformOrigin=p.exitOrigin;
                    void newEl.offsetHeight;
                    newEl.style.transition=trans; newEl.style.opacity='1'; newEl.style.transform='scale(1)';
                    oldEl.style.zIndex=1;
                    if (p.origin) oldEl.style.transformOrigin=p.origin;
                    oldEl.style.transition=trans; oldEl.style.opacity=p.enter.opacity;
                    oldEl.style.transform=p.enter.transform;
                }
            }
            decomposeExit(oldEl);
            if (_hasDecomp) {
                decomposeEnter(newEl, cleanup);
                // ALSO schedule a setTimeout cleanup. If decomposeEnter's
                // rAF callback gets swallowed (browser throttling, focus
                // loss, errored frame), the setTimeout still fires the
                // cleanup. _cleanupRan guard makes it a no-op if the rAF
                // path beat us to it.
                setTimeout(cleanup, (duration * 2.0) + 200);
            } else {
                setTimeout(cleanup, duration + 50);
            }
        }

        // Timeline scroll-link — wheel input drives a target progress 0..1
        // through keyframes; a RAF loop smoothly lerps the rendered
        // progress toward the target so the user sees continuous animation
        // between discrete wheel ticks.
        var _tlTarget   = 0;
        var _tlProgress = 0;
        var _tlRaf      = 0;
        function _screenHasTimeline(screenEl) {
            return !!(screenEl && window.ImpulseTimeline &&
                window.ImpulseTimeline.hasTimeline(screenEl));
        }
        function _applyTimelineProgress(screenEl, p) {
            if (window.ImpulseTimeline) window.ImpulseTimeline.apply(screenEl, p);
        }
        function _tlTick() {
            _tlRaf = 0;
            var s = screens[currentScreen];
            if (!s) return;
            if (!_screenHasTimeline(s)) return;
            var diff = _tlTarget - _tlProgress;
            if (Math.abs(diff) < 0.002) {
                _tlProgress = _tlTarget;
                _applyTimelineProgress(s, _tlProgress);
                return;
            }
            // 8% per frame → ~36 frames at 60fps ≈ 600ms from start to
            // 95% of target. Slow enough that a single wheel tick
            // triggers a visibly animated transition through the
            // keyframes, not an instant jump. Continuous scrolls bump
            // the target and the lerp just keeps chasing — it never
            // queues up and the user always sees smooth motion.
            _tlProgress += diff * 0.08;
            _applyTimelineProgress(s, _tlProgress);
            _tlRaf = requestAnimationFrame(_tlTick);
        }
        function _tlSchedule() {
            if (!_tlRaf) _tlRaf = requestAnimationFrame(_tlTick);
        }
        window._impulseResetTimelineProgress = function(forward) {
            var s = screens[currentScreen];
            if (_tlRaf) { cancelAnimationFrame(_tlRaf); _tlRaf = 0; }
            _tlTarget   = forward ? 0 : 1;
            _tlProgress = _tlTarget;
            if (_screenHasTimeline(s)) _applyTimelineProgress(s, _tlProgress);
        };

        // ── Per-screen Sensitivity (v2.1.1350) — editor mirror ──────────
        // Same signed accumulator the frontend uses (impulse-screens.js).
        // Tracks net gesture ticks; only fires goToScreen once |accum|
        // reaches the DESTINATION screen's data-reveal-sensitivity. Clears
        // on screen change + a 1.5s soft timeout. Wheel + touch flick
        // route through here; sidebar clicks and keyboard nav (if any)
        // stay direct so they advance immediately.
        var _eSensAccum = 0;
        var _eSensTimer = null;
        function _eSensReset() {
            _eSensAccum = 0;
            if (_eSensTimer) { clearTimeout(_eSensTimer); _eSensTimer = null; }
        }
        function _eDestSensitivity(dir) {
            var idx = currentScreen + (dir > 0 ? 1 : -1);
            if (dir > 0 && idx >= screens.length) idx = infiniteLoop ? 0 : -1;
            if (dir < 0 && idx < 0)               idx = infiniteLoop ? screens.length - 1 : -1;
            if (idx < 0 || idx >= screens.length) return 1;
            var raw = screens[idx].dataset.revealSensitivity;
            var n = parseInt(raw, 10);
            if (!n || n < 1) return 1;
            return n > 4 ? 4 : n;
        }
        // Skip-aware step: walks past screens with data-screen-skip="1" so
        // wheel nav mirrors the frontend (impulse-screens.js). Designer can
        // still reach the skipped screen via dot nav / goToScreen.
        function _eIsSkipped(idx) {
            return idx >= 0 && idx < screens.length
                && screens[idx].getAttribute('data-screen-skip') === '1';
        }
        function _eNextVisibleIdx(from, step) {
            var i = from + step;
            while (i >= 0 && i < screens.length && _eIsSkipped(i)) i += step;
            return i;
        }
        function _eGestureAdvance(dir) {
            if (isTransitioning || cooldownTimer) return;
            _eSensAccum += dir;
            if (_eSensTimer) clearTimeout(_eSensTimer);
            _eSensTimer = setTimeout(_eSensReset, 1500);
            var sgn = _eSensAccum > 0 ? 1 : (_eSensAccum < 0 ? -1 : 0);
            if (sgn === 0) return;
            var need = _eDestSensitivity(sgn);
            if (Math.abs(_eSensAccum) < need) return;
            _eSensReset();
            if (sgn > 0) {
                var nxt = _eNextVisibleIdx(currentScreen, +1);
                if (nxt >= screens.length) { if (infiniteLoop) goToScreen(_eNextVisibleIdx(-1, +1) >= 0 ? _eNextVisibleIdx(-1, +1) : 0); }
                else if (nxt >= 0) goToScreen(nxt);
            } else {
                var prv = _eNextVisibleIdx(currentScreen, -1);
                if (prv < 0) { if (infiniteLoop) goToScreen(_eNextVisibleIdx(screens.length, -1) >= 0 ? _eNextVisibleIdx(screens.length, -1) : screens.length - 1); }
                else goToScreen(prv);
            }
        }

        // Generic "Open screen on click" handler — mirror of the frontend's
        // openScreen() in assets/js/impulse-screens.js. Triggers:
        //   [data-open-screen="<id>"]    (button setting, recommended)
        //   a[href^="#screen-"]          (legacy hash anchor)
        //   [data-link^="#screen-"]      (button-as-anchor)
        // The id resolves against each section's data-id (the element id).
        // Behavior: unskip + dispatch impulse-screen-revealed (so configurator
        // and other blocks can reset state) + goToScreen + re-skip on first
        // change away. Capture phase so the body-level a[href] handler can't
        // swallow it via stopPropagation.
        function _editorResolveScreenIdx(raw) {
            if (!raw) return -1;
            for (var i = 0; i < screens.length; i++) {
                var d = screens[i].getAttribute('data-id') || screens[i].id;
                if (d === raw || String(d) === String(raw)) return i;
            }
            return -1;
        }
        document.addEventListener('click', function (e) {
            var t = e.target && e.target.closest && e.target.closest(
                '[data-open-screen],a[href^="#screen-"],[data-link^="#screen-"]'
            );
            if (!t) return;
            var raw = t.getAttribute('data-open-screen');
            if (!raw) {
                var h = t.getAttribute('href') || t.getAttribute('data-link') || '';
                raw = h.replace(/^#screen-/, '');
            }
            if (!raw) return;
            var idx = _editorResolveScreenIdx(raw);
            if (idx < 0) return;
            e.preventDefault();
            var bridge = screens[idx];
            bridge.removeAttribute('data-screen-skip');
            bridge.classList.remove('impulse-screen-skipped');
            bridge.dispatchEvent(new CustomEvent('impulse-screen-revealed', { bubbles: true, detail: { index: idx } }));
            goToScreen(idx);
            var rebound = false;
            var onChange = function () {
                if (rebound) return;
                if (currentScreen !== idx) {
                    rebound = true;
                    bridge.setAttribute('data-screen-skip', '1');
                    bridge.classList.add('impulse-screen-skipped');
                    document.removeEventListener('impulse-screen-changed', onChange);
                }
            };
            document.addEventListener('impulse-screen-changed', onChange);
        }, true);
        document.addEventListener('impulse-screen-changed', _eSensReset);

        wrapper.addEventListener('wheel', function(e) {
            var _aw = screens[currentScreen];
            // Edge detection: if the active screen has tall content, let
            // native scroll consume the wheel until scrollTop is at the edge
            // in the direction of input. Then preventDefault and run the
            // transition logic below.
            if (_aw) {
                var _down = e.deltaY > 0;
                var _up   = e.deltaY < 0;
                var _atTop    = _aw.scrollTop <= 0;
                var _atBottom = _aw.scrollTop + _aw.clientHeight >= _aw.scrollHeight - 1;
                if ((_down && !_atBottom) || (_up && !_atTop)) return;
            }
            e.preventDefault();
            if (isTransitioning || cooldownTimer) return;
            var active = screens[currentScreen];
            if (_screenHasTimeline(active)) {
                var before = _tlTarget;
                // Scroll distance per full 0→1 traversal. 10vh means a
                // single wheel tick on a normal desktop wheel pushes the
                // target fully to saturation; paired with the slow lerp
                // above, this means one tick = one full visible animation
                // through the keyframes. Second tick then advances the
                // screen. Authors can override via
                // `data-scroll-distance="Xvh|Ypx"`.
                var distRaw = active && active.dataset && active.dataset.scrollDistance;
                var dist = window.innerHeight * 0.1;
                if (distRaw) {
                    var m = String(distRaw).match(/^([\d.]+)(vh|px)?$/);
                    if (m) dist = m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) / 100 * window.innerHeight;
                }
                var unclamped = _tlTarget + e.deltaY / dist;
                _tlTarget = Math.max(0, Math.min(1, unclamped));
                _tlSchedule();
                // Screen-advance only when the user keeps scrolling in the
                // same direction AFTER the target is already saturated.
                // First tick that reaches the endpoint stays on this screen
                // so the lerp can play the animation through — second tick
                // advances. Without this "bumper", a single fast scroll
                // would snap to the endpoint and jump to the next screen
                // without the user seeing the in-between animation.
                var saturatedForward  = (before >= 1 && e.deltaY > 0);
                var saturatedBackward = (before <= 0 && e.deltaY < 0);
                if (!saturatedForward && !saturatedBackward) return;
            }
            if (e.deltaY > 10)       _eGestureAdvance(1);
            else if (e.deltaY < -10) _eGestureAdvance(-1);
        }, { passive: false });

        // Keyboard navigation through screens — DISABLED in the editor
        // (v2.1.1316, Sergei 2026-06-08). Arrow keys + Home/End hijacked
        // the caret inside contenteditable cells (Title text, Table
        // cells, Heading, Text block), making it impossible to move the
        // caret with arrows while editing. The editor has its own
        // screen navigation via the sidebar's screen list, plus wheel +
        // touch swipe, so giving up arrow-key navigation costs nothing
        // here. Frontend keeps arrow-key navigation (impulse-screens.js
        // line 1637) — designers preview the live site behavior there.

        // Touch swipe
        var touchStartY = 0, touchStartX = 0, touchActive = false;
        var _touchStartScroll = 0;
        wrapper.addEventListener('touchstart', function(e) {
            // Don't intercept touches inside a carousel/slider — let them handle swipe navigation
            if (e.target.closest('.impulse-new-slider')) return;
            if (e.touches.length === 1) {
                touchStartY = e.touches[0].clientY;
                touchStartX = e.touches[0].clientX;
                touchActive = true;
                var _at = screens[currentScreen];
                _touchStartScroll = _at ? _at.scrollTop : 0;
            }
        }, { passive: true });
        wrapper.addEventListener('touchmove', function(e) {
            // Native scroll inside the active screen handles tall content.
            // We only fire goToScreen on touchend if the swipe started at an
            // edge AND moved past it.
        }, { passive: true });
        wrapper.addEventListener('touchend', function(e) {
            if (!touchActive) return;
            touchActive = false;
            var dy = touchStartY - e.changedTouches[0].clientY;
            var dx = touchStartX - e.changedTouches[0].clientX;
            // Only trigger if vertical swipe > 40px and more vertical than horizontal
            if (Math.abs(dy) < 40 || Math.abs(dy) < Math.abs(dx)) return;
            if (isTransitioning || cooldownTimer) return;
            // Edge gate: only fire screen-advance when the user was already
            // at the corresponding scroll edge of the active screen at
            // touchstart, AND the screen is still at that edge now (so
            // mid-content swipes that just scrolled internally don't trigger
            // a transition).
            var _ae = screens[currentScreen];
            var _atTopNow    = !_ae || _ae.scrollTop <= 0;
            var _atBottomNow = !_ae || _ae.scrollTop + _ae.clientHeight >= _ae.scrollHeight - 1;
            var _startedTop    = _touchStartScroll <= 0;
            var _startedBottom = !_ae || _touchStartScroll + _ae.clientHeight >= _ae.scrollHeight - 1;
            if (dy > 0) {
                // Swipe up → next screen, only if at bottom edge throughout.
                if (!_startedBottom || !_atBottomNow) return;
                _eGestureAdvance(1);
            } else {
                // Swipe down → prev screen, only if at top edge throughout.
                if (!_startedTop || !_atTopNow) return;
                _eGestureAdvance(-1);
            }
        }, { passive: true });

        // ==========================================
        // TUNNEL — canvas rendered on body (outside
        // wrapper stacking context)
        // ==========================================
        var _tunnelToken = { dead: false }; // replaced each initTunnel call; frame checks its own copy
        function initTunnel(tOn, cEff, tSpeed, tStars, tIntensity, tunnelFill, mouseSensOn) {
            if(tunnelFill===undefined) tunnelFill=false;
            if(mouseSensOn===undefined) mouseSensOn=true;
            // Kill previous loop via its token
            _tunnelToken.dead = true;
            var myToken = { dead: false };
            _tunnelToken = myToken;
            // Tear down mesh gradient if active
            if (window._impulseMotionLava) { window._impulseMotionLava.destroy(); window._impulseMotionLava = null; }
            var _oldLavaCv = document.getElementById('impulse-motion-lava-canvas');
            if (_oldLavaCv) _oldLavaCv.remove();
            if (window._impulseWave) { window._impulseWave.destroy(); window._impulseWave = null; }
            var _oldWaveCv = document.getElementById('impulse-wave-canvas');
            if (_oldWaveCv) _oldWaveCv.remove();
            if (window._impulse3D) { window._impulse3D.destroy(); window._impulse3D = null; }
            if (window.Impulse3D) Impulse3D.destroyAll();
            var _old3dCv = document.getElementById('impulse-3d-canvas');
            if (_old3dCv) _old3dCv.remove();
            if (window._impulseFX && window.ImpulseFX) { var __oldFxCv = document.getElementById('impulse-fx-canvas'); if (__oldFxCv) ImpulseFX.destroy(__oldFxCv); window._impulseFX = null; }
            var _oldFxCv2 = document.getElementById('impulse-fx-canvas'); if (_oldFxCv2) _oldFxCv2.remove();
            // Tear down old DOM
            var _oldSvg = document.getElementById('impulse-cloud-svg-preview');
            if (_oldSvg) _oldSvg.remove();
            var _oldCv = document.getElementById('impulse-tunnel-canvas-preview');
            if (_oldCv) _oldCv.remove();
            var _oldSpace = document.getElementById('impulse-space-wrap');
            if (_oldSpace) _oldSpace.remove();
            // Resolve effective values — clouds never on dark bg or when stars on
            var runTunnel = !!tOn;
            var _bgCol2 = (body.dataset.pageBgColor || '#ffffff').replace(/\s/g,'');
            var _bgHx = _bgCol2.charAt(0)==='#' ? [parseInt(_bgCol2.slice(1,3)||'ff',16),parseInt(_bgCol2.slice(3,5)||'ff',16),parseInt(_bgCol2.slice(5,7)||'ff',16)] : [255,255,255];
            var _bgDark2 = (_bgHx[0]*0.299+_bgHx[1]*0.587+_bgHx[2]*0.114) < 80;
            var runCloud = runTunnel ? 'none' : (cEff || 'none');
            var AMBIENT_EFFECTS = ['lava','wave','bubbles','particles','crystal','blob','wormhole','fireflies','plexus','fractal','caustics','glass','bubble','flow','aurora','clouds','aura','topography','neon','noise','dots','ripple','swirl','kaleidoscope','columns','nebula','agate','storm','lens','asteroid','attractor','grid'];
            var isAmbientEffect = AMBIENT_EFFECTS.indexOf(runCloud) !== -1;
            var _3dEffects = ['cosmos','bubbles','particles','crystal','blob','wormhole','fireflies','plexus','columns','asteroid','attractor','grid'];
            var runSpeed = parseFloat(tSpeed) || 1;
            var runInt   = parseInt(tIntensity) || 50;
            // Always restore page bg first — effect branches will override if needed.
            // EXCEPTION: depth_image relies on a transparent body so the depth-
            // parallax canvas (z-index:-1, painted by impulse-depth-parallax.js)
            // is visible behind the page content. Writing #f5f5f5 here covered
            // the canvas until the next applyPageSettingsToPreview pass corrected
            // it ~1s later — and from a hard refresh the user saw an opaque grey
            // page until they manually re-triggered the panel.
            if (runCloud !== 'depth_image') {
                document.body.style.background = _bgCol2;
                document.documentElement.style.background = _bgCol2;
            } else {
                document.body.style.setProperty('background', 'transparent', 'important');
                document.documentElement.style.setProperty('background', 'transparent', 'important');
            }
            if (!runTunnel && runCloud === 'none') return;
            // Update outer closures used by updateSettings path
            tunnelOn    = runTunnel;
            intensity   = runInt;
            tunnelSpeed = runSpeed;
        if (true) { // scope block
            var cv = document.createElement('canvas');
            cv.id = 'impulse-tunnel-canvas-preview';
            var BLUR_PAD = 16;
            cv.style.cssText = 'position:fixed;top:-'+BLUR_PAD+'px;left:-'+BLUR_PAD+'px;width:calc(100% + '+BLUR_PAD*2+'px);height:calc(100% + '+BLUR_PAD*2+'px);z-index:0;pointer-events:none;';
            document.body.insertBefore(cv, document.body.firstChild);
            wrapper.style.position = 'relative';
            wrapper.style.zIndex   = '1';
            if (runTunnel || isAmbientEffect) {
                // Category 1: Effects with opaque canvas that covers body — force black
                var _forceBlack = ['cosmos','neon','caustics','glass','flow','aurora','aura','topography','lava','noise','ripple','swirl','kaleidoscope','columns','lens','asteroid','attractor','turing'];
                // Category 2: Palette clouds — force white
                if (runCloud === 'wave') {
                    document.body.style.cssText += 'background:#ffffff!important;';
                    document.documentElement.style.background = '#ffffff';
                    document.body.setAttribute('data-page-bg-color', '#ffffff');
                } else if (runTunnel || _forceBlack.indexOf(runCloud) !== -1) {
                    document.body.style.cssText += 'background:#000000!important;';
                    document.documentElement.style.background = '#000000';
                    document.body.setAttribute('data-page-bg-color', '#000000');
                } else {
                    // Category 3: User-controlled bg (particles, crystal, blob, plexus, fireflies, bubbles, terrain, wave, fractal, bubble)
                    document.body.style.cssText += 'background:' + _bgCol2 + '!important;';
                    document.documentElement.style.background = _bgCol2;
                    document.body.setAttribute('data-page-bg-color', _bgCol2);
                    document.body.dataset.pageBgColor = _bgCol2;
                }

                screens.forEach(function(s) { s.style.background = 'transparent'; });
            }

            // Universe — fully procedural CSS cosmos
            var spaceSceneEl = null;
            if (runCloud === 'sphere') {
                cv.style.zIndex = runCloud==='sphere' ? '-1' : '1';
                wrapper.style.zIndex = '2';
                // Inject all cosmos keyframes once
                if (!document.getElementById('impulse-cosmos-css')) {
                    var _css = document.createElement('style');
                    _css.id = 'impulse-cosmos-css';
                    _css.textContent = [
                        '@keyframes impulse-twinkle{0%,100%{opacity:0.12}50%{opacity:0.9}}',
                        '@keyframes impulse-drift-far{0%{background-position:0% 0%}50%{background-position:3% 2%}100%{background-position:0% 0%}}',
                        '@keyframes impulse-drift-mid{0%{background-position:0% 0%}50%{background-position:-4% 3%}100%{background-position:0% 0%}}',
                        '@keyframes impulse-drift-near{0%{background-position:0% 0%}50%{background-position:2% -3%}100%{background-position:0% 0%}}',
                        '@keyframes impulse-breathe-far{0%,100%{opacity:0.7}50%{opacity:1}}',
                        '@keyframes impulse-breathe-mid{0%,100%{opacity:0.8}50%{opacity:1}}',
                        '@keyframes impulse-breathe-near{0%,100%{opacity:0.85}50%{opacity:1}}',
                        '@keyframes impulse-flare-pulse{0%,100%{transform:translateZ(40px) scale(0.92);opacity:0.7}50%{transform:translateZ(40px) scale(1.12);opacity:1}}',
                        '.impulse-atmo-dither::after{content:"";position:absolute;top:-50%;left:-50%;width:200%;height:200%;',
                        'background:url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'200\' height=\'200\'%3E%3Cfilter id=\'n\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.8\' numOctaves=\'4\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'200\' height=\'200\' filter=\'url(%23n)\' opacity=\'1\'/%3E%3C/svg%3E") repeat;',
                        'mix-blend-mode:screen;opacity:0.04;pointer-events:none}'
                    ].join('');
                    document.head.appendChild(_css);
                }
                // Palettes
                var _spacePalettes = {
                    '1': {
                        far:   'radial-gradient(ellipse at 80% 85%, rgba(160,50,100,0.22) 0%, transparent 50%), radial-gradient(ellipse at 15% 20%, rgba(70,50,160,0.12) 0%, transparent 45%)',
                        mid:   'radial-gradient(ellipse at 70% 75%, rgba(180,60,120,0.15) 0%, transparent 40%), radial-gradient(ellipse at 25% 35%, rgba(80,50,160,0.1) 0%, transparent 40%)',
                        near:  'radial-gradient(ellipse at 50% 95%, rgba(140,40,90,0.08) 0%, transparent 35%)',
                        flare: 'radial-gradient(circle at 72% 82%, rgba(255,180,200,0.25) 0%, transparent 14%)'
                    },
                    '2': {
                        far:   'radial-gradient(ellipse at 35% 50%, rgba(200,120,40,0.22) 0%, transparent 45%), radial-gradient(ellipse at 72% 25%, rgba(40,160,180,0.18) 0%, transparent 45%), radial-gradient(ellipse at 20% 75%, rgba(50,80,180,0.14) 0%, transparent 40%)',
                        mid:   'radial-gradient(ellipse at 40% 45%, rgba(220,150,50,0.16) 0%, transparent 35%), radial-gradient(ellipse at 65% 35%, rgba(60,180,170,0.12) 0%, transparent 35%)',
                        near:  'radial-gradient(ellipse at 45% 55%, rgba(200,130,50,0.06) 0%, transparent 30%)',
                        flare: 'radial-gradient(circle at 38% 48%, rgba(255,220,150,0.35) 0%, rgba(255,180,100,0.12) 8%, transparent 20%)'
                    },
                    '3': {
                        far:   'radial-gradient(ellipse at 40% 35%, rgba(180,120,80,0.2) 0%, transparent 40%), radial-gradient(ellipse at 55% 55%, rgba(100,80,160,0.12) 0%, transparent 50%), radial-gradient(ellipse at 70% 60%, rgba(50,80,140,0.1) 0%, transparent 40%)',
                        mid:   'radial-gradient(ellipse at 42% 38%, rgba(200,140,100,0.14) 0%, transparent 30%), radial-gradient(ellipse at 60% 50%, rgba(140,100,180,0.08) 0%, transparent 35%)',
                        near:  'radial-gradient(ellipse at 45% 40%, rgba(180,130,90,0.06) 0%, transparent 25%)',
                        flare: 'radial-gradient(circle at 42% 36%, rgba(255,200,160,0.3) 0%, rgba(255,170,130,0.1) 10%, transparent 22%)'
                    }
                };
                var _spaceVariant = body.dataset.screenSpaceVariant || '1';
                var _pal = _spacePalettes[_spaceVariant] || _spacePalettes['1'];
                var spaceWrap = document.createElement('div');
                spaceWrap.id = 'impulse-space-wrap';
                spaceWrap.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none;perspective:500px;overflow:hidden';
                // Scene container — single transform target
                spaceSceneEl = document.createElement('div');
                spaceSceneEl.id = 'impulse-space-scene';
                spaceSceneEl.style.cssText = 'position:absolute;top:-20%;left:-20%;width:140%;height:140%;transform-style:preserve-3d;transform-origin:center center;will-change:transform;transform:translateZ(0)';
                // 1. Stars — 3 depth layers for parallax
                var _starLayers=[
                    {z:-80,count:150,minSz:0.3,maxSz:0.7,minOp:0.08,maxOp:0.25,twkChance:0.15},
                    {z:0,  count:120,minSz:0.5,maxSz:1.1,minOp:0.15,maxOp:0.45,twkChance:0.25},
                    {z:80, count:60, minSz:0.8,maxSz:1.8,minOp:0.25,maxOp:0.7, twkChance:0.35}
                ];
                _starLayers.forEach(function(_sl){
                    var layer=document.createElement('div');layer.style.cssText='position:absolute;top:-15%;left:-15%;width:130%;height:130%;transform:translateZ('+_sl.z+'px);pointer-events:none';
                    for(var _ssi=0;_ssi<_sl.count;_ssi++){
                        var _sd=document.createElement('div');
                        var _ssz=(_sl.minSz+Math.random()*(_sl.maxSz-_sl.minSz)).toFixed(1);
                        var _sb=(_sl.minOp+Math.random()*(_sl.maxOp-_sl.minOp)).toFixed(2);
                        var _twk=Math.random()<_sl.twkChance?';animation:impulse-twinkle '+(3+Math.random()*5).toFixed(1)+'s ease-in-out '+(Math.random()*6).toFixed(1)+'s infinite':'';
                        _sd.style.cssText='position:absolute;left:'+(Math.random()*100).toFixed(1)+'%;top:'+(Math.random()*100).toFixed(1)+'%;width:'+_ssz+'px;height:'+_ssz+'px;border-radius:50%;background:#fff;opacity:'+_sb+_twk;
                        layer.appendChild(_sd);
                    }
                    spaceSceneEl.appendChild(layer);
                });
                // 2. Atmosphere layers — only for stars effect (not Earth)
                if (_spaceVariant !== '0' && runCloud !== 'sphere') {
                var _atmoLayers = [
                    { bg: _pal.far,  z: 20, cls: 'impulse-atmo-far impulse-atmo-dither',  anim: 'impulse-drift-far 90s ease-in-out infinite, impulse-breathe-far 8s ease-in-out infinite' },
                    { bg: _pal.mid,  z: 60, cls: 'impulse-atmo-mid impulse-atmo-dither',  anim: 'impulse-drift-mid 60s ease-in-out infinite, impulse-breathe-mid 5s ease-in-out infinite' },
                    { bg: _pal.near, z: 100, cls: 'impulse-atmo-near impulse-atmo-dither', anim: 'impulse-drift-near 40s ease-in-out infinite, impulse-breathe-near 6s ease-in-out infinite' }
                ];
                _atmoLayers.forEach(function(l) {
                    var d = document.createElement('div');
                    d.className = l.cls;
                    d.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;background:' + l.bg + ';transform:translateZ(' + l.z + 'px);animation:' + l.anim;
                    spaceSceneEl.appendChild(d);
                });
                // 3. Optical flare — pulsing glow
                var flareDiv = document.createElement('div');
                flareDiv.className = 'impulse-atmo-flare';
                flareDiv.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;background:' + _pal.flare + ';animation:impulse-flare-pulse 4s ease-in-out infinite';
                spaceSceneEl.appendChild(flareDiv);
                }
                // 3b. Milky Way band — only for Earth
                if(runCloud==='sphere'){
                var milkyWay=document.createElement('div');
                milkyWay.style.cssText='position:absolute;top:-30%;left:-20%;width:140%;height:160%;transform:translateZ(10px) rotate(-25deg);pointer-events:none;background:linear-gradient(to bottom,transparent 35%,rgba(180,170,200,0.015) 42%,rgba(200,195,210,0.025) 48%,rgba(210,200,220,0.03) 50%,rgba(200,195,210,0.025) 52%,rgba(180,170,200,0.015) 58%,transparent 65%);opacity:0.5';
                spaceSceneEl.appendChild(milkyWay);
                }
                // 4. Shimmer dots at medium depth
                var shimLayer = document.createElement('div');
                shimLayer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;transform:translateZ(80px)';
                for (var _si = 0; _si < 55; _si++) {
                    var dot = document.createElement('div');
                    var sz = (0.8 + Math.random() * 2.2).toFixed(1);
                    var dur = (2 + Math.random() * 4).toFixed(1);
                    var del = (Math.random() * 5).toFixed(1);
                    var glow = Math.random() < 0.25;
                    dot.style.cssText = 'position:absolute;left:' + (Math.random() * 100).toFixed(1) + '%;top:' + (Math.random() * 100).toFixed(1) + '%;width:' + sz + 'px;height:' + sz + 'px;border-radius:50%;background:#fff;opacity:0.15;animation:impulse-twinkle ' + dur + 's ease-in-out ' + del + 's infinite' + (glow ? ';box-shadow:0 0 ' + (parseFloat(sz) * 2) + 'px rgba(255,255,255,0.6)' : '');
                    shimLayer.appendChild(dot);
                }
                spaceSceneEl.appendChild(shimLayer);
                spaceWrap.appendChild(spaceSceneEl);
                document.body.insertBefore(spaceWrap, document.body.firstChild);
            }

            var ctx = cv.getContext('2d');
            var W, H, cx, cy;

            // Three offscreen layer canvases for DOF compositing
            var cvFar  = document.createElement('canvas');
            var cvMid  = document.createElement('canvas');
            var cvNear = document.createElement('canvas');
            var ctxFar  = cvFar.getContext('2d');
            var ctxMid  = cvMid.getContext('2d');
            var ctxNear = cvNear.getContext('2d');
            // SVG cloud layer — only created when clouds are active
            // _bgDark2 computed at top of initTunnel; use it here before cloudBgDark is declared below
            var svgNS = 'http://www.w3.org/2000/svg';


            function resize() {
                W = cv.width = window.innerWidth  + BLUR_PAD * 2;
                H = cv.height = window.innerHeight + BLUR_PAD * 2;
                cx = W / 2; cy = H / 2;
                cvFar.width  = cvMid.width  = cvNear.width  = W;
                cvFar.height = cvMid.height = cvNear.height = H;
                // SVG cloud uses percentage sizing — no resize needed
            }
            window.addEventListener('resize', resize, { passive: true });
            resize();

            // Clouds are incompatible with Star Field (black bg = quantization artifacts)
            // cloudEffect and blend mode resolved at top of initTunnel
            var cloudEffect = runCloud;
            var cloudBgDark = _bgDark2;
            var FOV    = 600;
            // SPD computed per-frame in frame() so speed slider works live
            var Z_NEAR = 40, Z_FAR = 2800;
            var FOCAL_Z   = Z_NEAR + (Z_FAR - Z_NEAR) * 0.35;
            var Z_MID_IN  = Z_NEAR + (Z_FAR - Z_NEAR) * 0.055; // blur starts very close to camera
            var Z_MID_OUT = Z_NEAR + (Z_FAR - Z_NEAR) * 0.60;

            // ── Comets + flares ────────────────────────────────────────────────
            var NCOMET = 2;
            function spawnComet(init) {
                var ang = Math.random() * Math.PI * 2;
                var rad = 10 + Math.random() * 260;
                var z   = init ? (Z_NEAR*3 + Math.random() * (Z_FAR - Z_NEAR*3)) : Z_FAR;
                var sc0 = FOV / z;
                return { x: Math.cos(ang)*rad, y: Math.sin(ang)*rad, z: z,
                         psx: cx + Math.cos(ang)*rad*sc0,
                         psy: cy + Math.sin(ang)*rad*sc0,
                         width: 0.8 + Math.random() * 1.0,
                         bright: 0.85 + Math.random() * 0.15 };
            }
            var comets = [];
            for (var _ci=0; _ci<NCOMET; _ci++) comets.push(spawnComet(true));
            var flare = null; // optical flares removed

            function starColor(type,v,a){
                if(type===0)return 'rgba(255,255,255,'+a+')';
                var h,s,l;
                if(type===1){h=210+v*15;s=55;l=88;}
                else if(type===2){h=48+v*10;s=60;l=85;}
                else if(type===3){h=25+v*10;s=65;l=82;}
                else{h=v*10;s=60;l=80;}
                return 'hsla('+h+','+s+'%,'+l+'%,'+a+')';
            }




            var frameCount = 0;
            // ── Neon shared ───────────────────────────────────────────────────────
            var neonHueClock = 0;
            var neonHueManual = -1;
            function getNeonHue(off){ return neonHueManual>=0 ? (neonHueManual+(off||0))%360 : (neonHueClock*0.18+(off||0))%360; }
            function hslToRgb(h,s,l){h%=360;s/=100;l/=100;var c=(1-Math.abs(2*l-1))*s;var x=c*(1-Math.abs((h/60)%2-1));var m=l-c/2;var r=0,g=0,b=0;if(h<60){r=c;g=x;}else if(h<120){r=x;g=c;}else if(h<180){g=c;b=x;}else if(h<240){g=x;b=c;}else if(h<300){r=x;b=c;}else{r=c;b=x;}return[Math.round((r+m)*255),Math.round((g+m)*255),Math.round((b+m)*255)];}

            // ── Mouse / gyro vanishing point offset ───────────────────────────────
            var neonMouseX = 0, neonMouseY = 0;
            var _neonTargX = 0, _neonTargY = 0;
            /* 3D camera rotation state — updated each frame from neonMouseX/Y */
            var _camYaw=0,_camPitch=0,_cosYaw=1,_sinYaw=0,_cosPitch=1,_sinPitch=0;
            /* rp(wx,wy,wz): rotate world point by camera yaw+pitch, project to screen.
               Returns {sx,sy,z,sc} or null if behind near plane. */
            function rp(wx,wy,wz){
                var rx=wx*_cosYaw-wz*_sinYaw, rzy=wx*_sinYaw+wz*_cosYaw;
                var ry=wy*_cosPitch+rzy*_sinPitch, rz=-wy*_sinPitch+rzy*_cosPitch;
                if(rz<Z_NEAR*0.08)return null;
                var sc=FOV/rz; return{sx:cx+rx*sc,sy:cy+ry*sc,z:rz,sc:sc};
            }
            /* Grid color override — hex from panel setting, derived hue for hsla() */
            var _gridColorHex = body.dataset.screenGridColor || '';
            var _gridHue = -1;  /* -1 = auto-cycle */
            if (_gridColorHex && /^#[0-9a-fA-F]{6}$/.test(_gridColorHex)) {
                var _gc=parseInt(_gridColorHex.slice(1),16);
                var _gcR=(_gc>>16)&255,_gcG=(_gc>>8)&255,_gcB=_gc&255;
                var _gcMx=Math.max(_gcR,_gcG,_gcB)/255,_gcMn=Math.min(_gcR,_gcG,_gcB)/255,_gcD=_gcMx-_gcMn;
                if(_gcD>0.001){
                    if(_gcMx===_gcR/255)_gridHue=(60*((_gcG-_gcB)/_gcD%6+6)%6);
                    else if(_gcMx===_gcG/255)_gridHue=60*((_gcB-_gcR)/_gcD+2);
                    else _gridHue=60*((_gcR-_gcG)/_gcD+4);
                } else { _gridHue=0; }
            }
            (function(){
                var el = wrapper || document.body;
                el.addEventListener('mousemove', function(e){
                    var r = el.getBoundingClientRect();
                    _neonTargX = ((e.clientX - r.left) / r.width  - 0.5) * 2;
                    _neonTargY = ((e.clientY - r.top)  / r.height - 0.5) * 2;
                }, {passive:true});

                // ── iOS gyro consent ────────────────────────────────────────
                // requestPermission() only exists on iOS 13+. On all other
                // platforms register the listener directly and return.
                if (!window.DeviceOrientationEvent) return;

                var _gyroActive = false;
                var _LS_KEY = 'instinctor_gyro';

                function _startGyro() {
                    if (_gyroActive) return;
                    _gyroActive = true;
                    window.addEventListener('deviceorientation', function(e) {
                        if (e.gamma != null) _neonTargX = Math.max(-1, Math.min(1, e.gamma / 30));
                        if (e.beta  != null) _neonTargY = Math.max(-1, Math.min(1, (e.beta - 30) / 30));
                    }, {passive:true});
                }

                if (typeof DeviceOrientationEvent.requestPermission !== 'function') {
                    // Non-iOS — register immediately, no consent needed
                    _startGyro();
                    return;
                }

                // iOS — determine button color from body background luminance
                function _btnColors() {
                    var bg = window.getComputedStyle(document.body).backgroundColor;
                    var m = bg.match(/\d+/g);
                    if (!m) return {bg:'#000',fg:'#fff'};
                    var r=+m[0],g=+m[1],b=+m[2];
                    var lum = (0.299*r + 0.587*g + 0.114*b) / 255;
                    return lum > 0.5 ? {bg:'#111',fg:'#fff'} : {bg:'#fff',fg:'#111'};
                }

                function _showBtn() {
                    if (document.getElementById('_igyro_btn')) return;
                    var c = _btnColors();
                    var btn = document.createElement('button');
                    btn.id = '_igyro_btn';
                    btn.textContent = 'Enable 3D';
                    btn.style.cssText = [
                        'position:fixed','bottom:22px','left:50%','transform:translateX(-50%)',
                        'z-index:99999','padding:7px 18px','border:none','border-radius:20px',
                        'font:600 12px/1 -apple-system,sans-serif','letter-spacing:.04em',
                        'cursor:pointer','opacity:.88','transition:opacity .2s',
                        'background:'+c.bg,'color:'+c.fg,
                        '-webkit-tap-highlight-color:transparent'
                    ].join(';');
                    btn.addEventListener('touchstart', function(ev) {
                        ev.preventDefault();
                        DeviceOrientationEvent.requestPermission().then(function(state) {
                            if (state === 'granted') {
                                try { localStorage.setItem(_LS_KEY, '1'); } catch(e) {}
                                _startGyro();
                            }
                            btn.remove();
                        }).catch(function() { btn.remove(); });
                    }, {passive:false});
                    document.body.appendChild(btn);
                }

                // Return visit — silently re-request on first touch, no button shown
                if (localStorage.getItem(_LS_KEY) === '1') {
                    var _silentHandler = function(ev) {
                        document.removeEventListener('touchstart', _silentHandler);
                        DeviceOrientationEvent.requestPermission().then(function(state) {
                            if (state === 'granted') { _startGyro(); }
                            else {
                                // User revoked in Settings — show button again
                                try { localStorage.removeItem(_LS_KEY); } catch(e) {}
                                _showBtn();
                            }
                        }).catch(function() {});
                    };
                    document.addEventListener('touchstart', _silentHandler, {passive:true});
                } else {
                    // First visit — show button after brief delay
                    setTimeout(_showBtn, 800);
                }
            })();



            // ── Neon Grid init ────────────────────────────────────────────────────


            // ── Checker Tunnel init ────────────────────────────────────────────────────
            var chtOffset = 0;
            var chtTileCount = 0; // absolute wrap counter for stable cell parity


            // ── Motion Lava (WebGL) ──────────────────────────────────────────────
            if (runCloud === 'lava' && window.ImpulseMotionLava) {
                var lvCanvas = document.createElement('canvas');
                lvCanvas.id = 'impulse-motion-lava-canvas';
                lvCanvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none;';
                document.body.insertBefore(lvCanvas, document.body.firstChild);
                wrapper.style.position = 'relative';
                wrapper.style.zIndex = '1';
                // Fallbacks aligned with the PHP body-attr emitter (grey scale).
                // Pre-2.1.63 these were the historic Lava demo rainbow (#ef008f /
                // #6ec3f4 / #7038ff / #ffba27). If any setting ever saves as empty
                // string the body attr emits `data-screen-lava-color1=""`, JS sees
                // an empty string (falsy) and dropped through to the bright pink
                // default — producing visible red/blue patches on what the user
                // expected to be a neutral or green palette.
                var lvC1 = body.dataset.screenLavaColor1 || '#333333';
                var lvC2 = body.dataset.screenLavaColor2 || '#999999';
                var lvC3 = body.dataset.screenLavaColor3 || '#666666';
                var lvC4 = body.dataset.screenLavaColor4 || '#cccccc';
                var lvSpd = (parseInt(body.dataset.screenLavaSpeed) || 10) / 10;
                var lvInt = parseInt(body.dataset.screenLavaIntensity) || 5;
                window._impulseMotionLava = new ImpulseMotionLava(lvCanvas, {
                    colors: [lvC1, lvC2, lvC3, lvC4],
                    speed: lvSpd,
                    amp: Math.round(320 * (0.5 + (lvInt-1)/9.0)), noiseFreq: 1.0, shadowScale: 1.0
                });
                window._impulseMotionLava.play();
            }



            // ── Wave (Three.js Ribbons) ────────────────────────────────────────────
            if (runCloud === 'wave' && window.ImpulseWave) {
                var wvCanvas = document.createElement('canvas');
                wvCanvas.id = 'impulse-wave-canvas';
                wvCanvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none;';
                document.body.insertBefore(wvCanvas, document.body.firstChild);
                wrapper.style.position = 'relative';
                wrapper.style.zIndex = '1';
                var wvC1 = body.dataset.screenWaveColor1 || '#ef008f';
                var wvC2 = body.dataset.screenWaveColor2 || '#6ec3f4';
                var wvC3 = body.dataset.screenWaveColor3 || '#7038ff';
                var wvC4 = body.dataset.screenWaveColor4 || '#ffba27';
                var wvSpd = (parseInt(body.dataset.screenWaveSpeed) || 10) / 10;
                var wvPreset = body.dataset.screenWavePreset || 'flow';
                var wvIntensity = (parseInt(body.dataset.screenWaveIntensity) || 10) / 3.33;
                window._impulseWave = new ImpulseWave(wvCanvas, {
                    colors: [wvC1, wvC2, wvC3, wvC4],
                    speed: wvSpd,
                    preset: wvPreset,
                    intensity: wvIntensity
                });
                window._impulseWave.play();
                // Apply the wave B swatch (data-screen-3d-bg-color) when set,
                // else fall back to the page background.
                var _waveSw = body.getAttribute('data-screen-3d-bg-color');
                var _waveBgAttr = (_waveSw && _waveSw.charAt(0) === '#') ? _waveSw : (body.getAttribute('data-page-bg-color') || '#ffffff');
                if (window._impulseWave.updateBgColor) window._impulseWave.updateBgColor(_waveBgAttr);
            }

            // ── Impulse 3D effects (Three.js) ─────────────────────────────────────
            if (_3dEffects.indexOf(runCloud) !== -1 && window.Impulse3D) {
                var i3dCanvas = document.createElement('canvas');
                i3dCanvas.id = 'impulse-3d-canvas';
                var _3dZp = (runCloud==='sphere') ? '1' : '0';
                i3dCanvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:'+_3dZp+';pointer-events:none;';
                document.body.insertBefore(i3dCanvas, document.body.firstChild);
                wrapper.style.position = 'relative';
                wrapper.style.zIndex = '2';
                var _pageBgAttr = body.getAttribute('data-page-bg-color') || '#f5f5f5';
                if (runCloud === 'sphere') { body.style.cssText += 'background:#000000!important;'; }
                else { body.style.cssText += 'background:' + _pageBgAttr + '!important;'; }
                var _3dC1 = body.getAttribute('data-screen-3d-color1') || '#333333';
                var _3dC2 = body.getAttribute('data-screen-3d-color2') || '#999999';
                var _3dC3 = body.getAttribute('data-screen-3d-color3') || '#7038ff';
                var _3dC4 = body.getAttribute('data-screen-3d-color4') || '#ffba27';
                var _3dSpd = (parseInt(body.getAttribute('data-screen-3d-speed')) || 5) / 10;
                var _3dPreset = body.getAttribute('data-screen-3d-preset') || '';
                var _3dIntensity = (parseInt(body.getAttribute('data-screen-3d-intensity')) || 10) / 10;
                var _3dCount = parseInt(body.getAttribute('data-screen-3d-count')) || 7;
                var _3dVarAttr = body.getAttribute('data-screen-3d-variation');
                var _3dVariation = _3dVarAttr !== null && _3dVarAttr !== '' ? parseInt(_3dVarAttr) : 3;
                var _3dDenAttr = body.getAttribute('data-screen-3d-density');
                var _3dDensity = _3dDenAttr !== null && _3dDenAttr !== '' ? parseInt(_3dDenAttr) : 7;
                var _3dHaloAttr = body.getAttribute('data-screen-3d-halo');
                var _3dHalo = _3dHaloAttr !== null && _3dHaloAttr !== '' ? parseInt(_3dHaloAttr) : 4;
                var _3dOpts = {
                    effect: runCloud,
                    colors: [_3dC1, _3dC2, _3dC3, _3dC4],
                    speed: _3dSpd,
                    preset: _3dPreset || undefined,
                    intensity: _3dIntensity,
                    count: _3dCount,
                    variation: _3dVariation,
                    density: _3dDensity,
                    halo: _3dHalo,
                    sensitivity: parseFloat(body.getAttribute('data-screen-mouse-sens-level') || '0.18')
                };
                window._impulse3D = Impulse3D.init(i3dCanvas, _3dOpts);
                // Apply user bg color to 3D clear color for controllable effects
                var _bgControllable3d = ['particles','crystal','blob','plexus','grid','columns','asteroid'];
                if (window._impulse3D && _bgControllable3d.indexOf(runCloud) !== -1 && window._impulse3D.updateBgColor) {
                    // Honor the per-effect Background swatch (screen_{effect}_bg_color),
                    // mirroring the FX path + frontend engine, not just the page bg.
                    var _per3dBg = body.getAttribute('data-screen-3d-bg-color');
                    // Force-black effects (asteroid, columns) default to BLACK, not the
                    // white page bg, when the swatch is unset/non-hex (e.g. "transparent")
                    // — matches the frontend, which forces black for these. Without this
                    // the editor showed asteroid on white while the published page was black.
                    var _3dBgFallback = (typeof _forceBlack !== 'undefined' && _forceBlack.indexOf(runCloud) !== -1) ? '#000000' : _pageBgAttr;
                    window._impulse3D.updateBgColor((_per3dBg && _per3dBg.charAt(0) === '#') ? _per3dBg : _3dBgFallback);
                }
                // Bubbles always use black background
                if (window._impulse3D && runCloud === 'bubbles' && window._impulse3D.updateBgColor) {
                    window._impulse3D.updateBgColor('#000000');
                }
                // Particles: switch blending mode based on bg color
                if (runCloud === 'particles' && window._impulse3D && window._impulse3D.updateBlendMode) {
                    var _pBgDark = !_pageBgAttr || _pageBgAttr === '#000000' || _pageBgAttr === '#f5f5f5';
                    window._impulse3D.updateBlendMode(_pBgDark);
                }
            }

            // ── FX Engine effects (raw WebGL) ─────────────────────────────────────
            // Must mirror SHADERS registry in impulse-fx-engine.js + screens.js _fxEffects
            var _fxEffects = ['fractal','caustics','glass','bubble','flow','aurora','clouds','aura','topography','neon','noise','dots','ripple','swirl','kaleidoscope','membrane','nebula','agate','storm','lens'];
            if (_fxEffects.indexOf(runCloud) !== -1 && window.ImpulseFX) {
                var fxCanvas = document.createElement('canvas');
                fxCanvas.id = 'impulse-fx-canvas';
                fxCanvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none;';
                document.body.insertBefore(fxCanvas, document.body.firstChild);
                wrapper.style.position = 'relative';
                wrapper.style.zIndex = '1';
                var _fxC1 = body.getAttribute('data-screen-3d-color1') || '#000000';
                var _fxC2 = body.getAttribute('data-screen-3d-color2') || '#333333';
                var _fxC3 = body.getAttribute('data-screen-3d-color3') || '#666666';
                // Glass: speed hardcoded to 0.5 (frees panel slot for Overlay).
                var _fxSpd = runCloud === 'glass'
                    ? 0.5
                    : (parseInt(body.getAttribute('data-screen-3d-speed')) || 10) / 10;
                var _fxInt = (parseInt(body.getAttribute('data-screen-3d-intensity')) || 10) / 10;
                var _fxOuterRaw = body.getAttribute('data-outer-opacity');
                var _fxOuterOp = (_fxOuterRaw != null && _fxOuterRaw !== '')
                    ? Math.max(0, Math.min(100, parseInt(_fxOuterRaw))) / 100
                    : 1.0;
                // Nebula has its own u_color3 in the shader (hot-spot /
                // starlight). Route C3 (data-screen-3d-color3) there and
                // pull bgColor from data-page-bg-color so the B swatch
                // drives it. Other 3-colour FX keep the legacy C3→bgColor
                // mapping to stay backwards-compatible.
                // Effects with an unlocked B swatch (lens, nebula, agate, storm) read
                // bgColor from the per-effect bg-color data attribute when set.
                var _nebulaMode = (runCloud === 'nebula');
                var _fxBgFromSwatch = body.getAttribute('data-screen-3d-bg-color');
                var _usesPageBg = ['nebula','agate','storm'].indexOf(runCloud) !== -1;
                var _fxBg = (_fxBgFromSwatch && _fxBgFromSwatch.charAt(0) === '#')
                    ? _fxBgFromSwatch
                    : _usesPageBg
                        ? (body.getAttribute('data-page-bg-color') || '#000000')
                        : _fxC3;
                var _fxInitOpts = {
                    effect: runCloud,
                    speed: _fxSpd,
                    intensity: _fxInt,
                    opacity: 1.0,
                    outerOpacity: _fxOuterOp,
                    color1: _fxC1,
                    color2: _fxC2,
                    bgColor: _fxBg,
                    mouse: parseFloat(body.getAttribute('data-screen-mouse-sens-level') || '0.18'),
                    bgImage: body.getAttribute('data-depth-image') || body.getAttribute('data-screen-prism-bg') || '',
                    shape: parseInt(body.getAttribute('data-screen-prism-shape') || '0')
                };
                if (_nebulaMode) _fxInitOpts.color3 = _fxC3;
                window._impulseFX = ImpulseFX.init(fxCanvas, _fxInitOpts);
                // The legacy tunnel-canvas-preview also lives at z-index:0 and
                // is inserted AFTER fx-canvas in body, so it paints on top of
                // any FX shader. On a fresh load the tunnel canvas is empty
                // and FX shows through; but the frame() loop accumulates star
                // pinpoints on it over time, and after the first screen
                // transition those start covering the FX nebula. Push it
                // behind the FX canvas whenever an FX engine is running.
                var _legacyTunnelCv = document.getElementById('impulse-tunnel-canvas-preview');
                if (_legacyTunnelCv) _legacyTunnelCv.style.zIndex = '-1';
            }

            // ── Rain init ─────────────────────────────────────────────────────────────
            // ── Rain/Confetti removed (v3.23.071) ──────────────────────────────

            // ── Mountains init ────────────────────────────────────────────────────────

            function frame() {
                if (myToken.dead) return;
                requestAnimationFrame(frame);
                frameCount++;
                neonHueClock++;
                neonHueManual = parseInt(body.dataset.screenNeonHue || '-1');
                
                var _sl = parseFloat(body.dataset.screenMouseSensLevel || '0');
                if(_sl > 0){neonMouseX+=(_neonTargX*_sl-neonMouseX)*0.06;neonMouseY+=(_neonTargY*_sl-neonMouseY)*0.06;}else{neonMouseX=0;neonMouseY=0;}
                /* Camera rotation angles from smoothed mouse — used by rp() */
                _camYaw=neonMouseX*0.42; _camPitch=neonMouseY*0.30;
                _cosYaw=Math.cos(_camYaw); _sinYaw=Math.sin(_camYaw);
                _cosPitch=Math.cos(_camPitch); _sinPitch=Math.sin(_camPitch);
                // Universe — update space image perspective warp
                if (spaceSceneEl) spaceSceneEl.style.transform = 'rotateY(' + (neonMouseX * 8).toFixed(2) + 'deg)';
                var SPD = 1.2 * tunnelSpeed;
                var CLOUD_SPD = tunnelSpeed * 8.0;
                ctx.clearRect(0,0,W,H); // body CSS handles background gradient



                if (cvFar.width > 0 && cvFar.height > 0) {

                    ctx.filter = 'blur(6px)';  ctx.globalAlpha = 1;    ctx.drawImage(cvFar,  0, 0);

                    ctx.filter = 'none';          ctx.globalAlpha = 1;    ctx.drawImage(cvMid,  0, 0);
                    ctx.drawImage(cvNear, 0, 0);
                    ctxFar.clearRect(0,0,W,H);
                    ctxMid.clearRect(0,0,W,H);
                    ctxNear.clearRect(0,0,W,H);


                // ── Hyperspace ────────────────────────────────────────────


                // ── Filaments ─────────────────────────────────────────────
                // ── Neon Rings ─────────────────────────────────────────────

                // ── Neon Grid ──────────────────────────────────────────────
                // ── Neon Vortex ────────────────────────────────────────────
                // ── Neon Helix ─────────────────────────────────────────────

                // ── Neon Sparks ────────────────────────────────────────────
                // ── Neon Flame ─────────────────────────────────────────────
                // ── Neon Aurora ─────────────────────────────────────────────
                // ── Neon Electric ───────────────────────────────────────────
                // ── Neon Plasma ─────────────────────────────────────────────
                // ── Neon Lava ───────────────────────────────────────────────

                // ── Depth Shards 3D — irregular tumbling triangles ─────────────────────
                // ── Meteor ───────────────────────────────────────────────────────────────
                // ── BW Parallax Effects ─────────────────────────────────────
                var _isBW2 = ['bw_matrix','bw_grid3d','bw_blueprint','bw_marble','bw_diamonds'].indexOf(cloudEffect) !== -1;
                if (_isBW2) {
                    var bwFG2 = _bgDark2 ? 'rgba(255,255,255,' : 'rgba(0,0,0,';
                    var _bwAccent2 = (body.dataset.screenBwAccentColor || '').trim();
                    if (_bwAccent2 && _bwAccent2.charAt(0) === '#' && _bwAccent2.length >= 7) {
                        var _ar2 = parseInt(_bwAccent2.slice(1,3),16), _ag2 = parseInt(_bwAccent2.slice(3,5),16), _ab2 = parseInt(_bwAccent2.slice(5,7),16);
                        bwFG2 = 'rgba(' + _ar2 + ',' + _ag2 + ',' + _ab2 + ',';
                    }
                    var bwMX2 = neonMouseX * 180, bwMY2 = neonMouseY * 120;
                    var bwT2 = frameCount;
                    var bwSizePct2 = parseFloat(body.dataset.screenBwElementSize || '100') / 100;
                    var bwCountPct2 = parseFloat(body.dataset.screenBwElementCount || '50') / 50;
                    ctx.save();

                    if (cloudEffect === 'bw_matrix') {
                        var DM_L2=3,DM_SP2=[16,28,44],DM_MR2=[6,4.5,3],DM_AL2=[0.65,0.35,0.15];
                        for(var dmL2=0;dmL2<DM_L2;dmL2++){var dmDep2=dmL2/(DM_L2-1);var dmPlxX2=bwMX2*(0.3+dmDep2*2.0),dmPlxY2=bwMY2*(0.3+dmDep2*2.0);var dmAlpha2=DM_AL2[dmL2];var dmSpacing2=DM_SP2[dmL2]*bwSizePct2;var dmMaxR2=DM_MR2[dmL2]*bwSizePct2*(1+Math.sin(bwT2*0.012+dmL2*1.5)*0.15);if(dmSpacing2<4)dmSpacing2=4;var dmCX2=Math.floor((-dmPlxX2-W*0.1)/dmSpacing2),dmCE2=Math.ceil((W-dmPlxX2+W*0.1)/dmSpacing2);var dmRY2=Math.floor((-dmPlxY2-H*0.1)/dmSpacing2),dmRE2=Math.ceil((H-dmPlxY2+H*0.1)/dmSpacing2);ctx.fillStyle=bwFG2+dmAlpha2.toFixed(3)+')';
                        for(var dmC2=dmCX2;dmC2<=dmCE2;dmC2++){for(var dmR2=dmRY2;dmR2<=dmRE2;dmR2++){var dmSX2=dmC2*dmSpacing2+dmPlxX2,dmSY2=dmR2*dmSpacing2+dmPlxY2;if(dmSX2<-10||dmSX2>W+10||dmSY2<-10||dmSY2>H+10)continue;var nz2=Math.sin(dmC2*0.43+dmR2*0.67+dmL2*2.3+bwT2*0.008)*Math.cos(dmC2*0.29+dmR2*0.51+dmL2*1.1);var r2=dmMaxR2*(0.15+0.85*Math.max(0,(nz2+1)*0.5));if(r2<0.5)continue;ctx.beginPath();ctx.arc(dmSX2,dmSY2,r2,0,Math.PI*2);ctx.fill();}}}
                    }

                    else if (cloudEffect === 'bw_grid3d') {
                        var GD_L2=3,GD_SPS2=[55,80,120];for(var gdL2=0;gdL2<GD_L2;gdL2++){var gdDep2=gdL2/(GD_L2-1);var gdPlxX2=bwMX2*(0.4+gdDep2*1.8),gdPlxY2=bwMY2*(0.4+gdDep2*1.8);var gdAlpha2=0.08+(1-gdDep2)*0.32;var gdTB2=(22+(1-gdDep2)*20)*bwSizePct2;if(gdTB2<6)gdTB2=6;var gdSp2=GD_SPS2[gdL2];var gdC2=Math.ceil(W/gdSp2)+4,gdR2=Math.ceil(H/gdSp2)+4;var gdOX2=gdPlxX2-gdSp2*2,gdOY2=gdPlxY2-gdSp2*2;
                        for(var gc2=0;gc2<gdC2;gc2++){for(var gr2=0;gr2<gdR2;gr2++){var gx2=gc2*gdSp2+gdOX2,gy2=gr2*gdSp2+gdOY2;if(gx2<-gdTB2||gx2>W+gdTB2||gy2<-gdTB2||gy2>H+gdTB2)continue;var gn2=Math.sin(gc2*0.53+gr2*0.71+gdL2*2.3);if(gn2<0.0)continue;var gts2=gdTB2*(0.6+0.4*gn2);var ga2=gdAlpha2*(0.3+0.7*gn2);if(ga2<0.02)continue;ctx.fillStyle=bwFG2+ga2.toFixed(3)+')';ctx.fillRect(Math.round(gx2),Math.round(gy2),Math.round(gts2),Math.round(gts2));}}}
                    }

                    else if (cloudEffect === 'bw_blueprint') {
                        var BP_L2=3,BP_GR2=[40,70,110],BP_AL2=[0.45,0.22,0.08];
                        for(var bpL2=0;bpL2<BP_L2;bpL2++){var bpDep2=bpL2/(BP_L2-1);var bpPX2=bwMX2*(0.3+bpDep2*1.8),bpPY2=bwMY2*(0.3+bpDep2*1.8);var bpA2=BP_AL2[bpL2];var bpG2=BP_GR2[bpL2]*bwSizePct2;if(bpG2<12)bpG2=12;var bpDr2=bwT2*(0.04+(1-bpDep2)*0.08)*tunnelSpeed;var bpOX2=(bpDr2*0.7)%bpG2+bpPX2,bpOY2=(bpDr2*0.5)%bpG2+bpPY2;var bpLW2=Math.max(0.5,1.5-bpDep2*0.6);
                        ctx.strokeStyle=bwFG2+(bpA2*0.4).toFixed(3)+')';ctx.lineWidth=bpLW2*0.5;ctx.beginPath();for(var bgx2=Math.floor(-bpOX2/bpG2)*bpG2+bpOX2;bgx2<W+bpG2;bgx2+=bpG2){ctx.moveTo(Math.round(bgx2)+0.5,0);ctx.lineTo(Math.round(bgx2)+0.5,H);}for(var bgy2=Math.floor(-bpOY2/bpG2)*bpG2+bpOY2;bgy2<H+bpG2;bgy2+=bpG2){ctx.moveTo(0,Math.round(bgy2)+0.5);ctx.lineTo(W,Math.round(bgy2)+0.5);}ctx.stroke();
                        ctx.strokeStyle=bwFG2+bpA2.toFixed(3)+')';ctx.lineWidth=bpLW2;var bpC2=Math.ceil(W/bpG2)+2,bpR2=Math.ceil(H/bpG2)+2;for(var bc2=-1;bc2<bpC2;bc2++){for(var br2=-1;br2<bpR2;br2++){var bx2=bc2*bpG2+bpOX2,by2=br2*bpG2+bpOY2;if(bx2<-bpG2||bx2>W+bpG2||by2<-bpG2||by2>H+bpG2)continue;var bn2=Math.sin(bc2*0.73+br2*0.47+bpL2*2.1+bpDr2*0.005);if(bn2>0.3){var brd2=bpG2*(0.15+Math.abs(bn2)*0.3);ctx.beginPath();ctx.arc(bx2,by2,brd2,0,Math.PI*2);ctx.stroke();var bch2=brd2*0.4;ctx.beginPath();ctx.moveTo(bx2-bch2,by2);ctx.lineTo(bx2+bch2,by2);ctx.moveTo(bx2,by2-bch2);ctx.lineTo(bx2,by2+bch2);ctx.stroke();}else if(bn2>0.0){var bdw2=bpG2*(0.4+Math.abs(bn2)*0.5),btk2=bpG2*0.08;ctx.beginPath();ctx.moveTo(bx2-bdw2,by2);ctx.lineTo(bx2+bdw2,by2);ctx.moveTo(bx2-bdw2,by2-btk2);ctx.lineTo(bx2-bdw2,by2+btk2);ctx.moveTo(bx2+bdw2,by2-btk2);ctx.lineTo(bx2+bdw2,by2+btk2);ctx.stroke();}}}}
                    }

                    else if (cloudEffect === 'bw_marble') {
                        if(!window._marbleCanvas2){window._marbleCanvas2=document.createElement('canvas');window._marbleCtx2=window._marbleCanvas2.getContext('2d');window._marbleFrame2=-1;window._mR2=0;window._mG2=0;window._mB2=0;}
                        var mbPd2=140,mbSc2=3,mbW2=Math.ceil((W+mbPd2*2)/mbSc2),mbH2=Math.ceil((H+mbPd2*2)/mbSc2);if(window._marbleCanvas2.width!==mbW2||window._marbleCanvas2.height!==mbH2){window._marbleCanvas2.width=mbW2;window._marbleCanvas2.height=mbH2;window._marbleFrame2=-1;}
                        var mR2,mG2,mB2;var _mA2=(body.dataset.screenBwAccentColor||'').trim();
                        if(_mA2&&_mA2.charAt(0)==='#'&&_mA2.length>=7){mR2=parseInt(_mA2.slice(1,3),16);mG2=parseInt(_mA2.slice(3,5),16);mB2=parseInt(_mA2.slice(5,7),16);}else if(_bgDark2){mR2=255;mG2=255;mB2=255;}else{mR2=0;mG2=0;mB2=0;}
                        if(mR2!==window._mR2||mG2!==window._mG2||mB2!==window._mB2){window._marbleFrame2=-1;window._mR2=mR2;window._mG2=mG2;window._mB2=mB2;}
                        if(frameCount-window._marbleFrame2>=4){window._marbleFrame2=frameCount;var mbCtx2=window._marbleCtx2,mbT2v=bwT2*0.0012*tunnelSpeed;var mbImg2=mbCtx2.createImageData(mbW2,mbH2),mbD2=mbImg2.data;var mbFr2=0.025/bwSizePct2;for(var my2=0;my2<mbH2;my2++){for(var mx2=0;mx2<mbW2;mx2++){var nx2=mx2*mbFr2,ny2=my2*mbFr2;var v2=Math.sin(nx2*1.2+mbT2v)+Math.sin(ny2*0.9+mbT2v*0.7)+Math.sin((nx2+ny2)*0.7+mbT2v*1.3)+Math.sin(Math.sqrt(nx2*nx2+ny2*ny2)*0.8+mbT2v*0.5)+Math.sin((nx2*0.6-ny2*1.1)+mbT2v*0.9)*0.7;var rb2=Math.sin(v2*2.5);var idx2=(my2*mbW2+mx2)*4;if(rb2>0.25){mbD2[idx2]=mR2;mbD2[idx2+1]=mG2;mbD2[idx2+2]=mB2;mbD2[idx2+3]=255;}else if(rb2>0.05){mbD2[idx2]=mR2;mbD2[idx2+1]=mG2;mbD2[idx2+2]=mB2;mbD2[idx2+3]=Math.round(((rb2-0.05)/0.20)*255);}else{mbD2[idx2+3]=0;}}}mbCtx2.putImageData(mbImg2,0,0);}
                        ctx.imageSmoothingEnabled=true;ctx.imageSmoothingQuality='high';var mbLy2=[{a:0.18,p:0.15,s:0.94},{a:0.35,p:0.50,s:1.00},{a:0.55,p:0.90,s:1.07}];for(var mli=0;mli<3;mli++){var ml=mbLy2[mli];ctx.globalAlpha=ml.a;var mdW=(W+mbPd2*2)*ml.s,mdH=(H+mbPd2*2)*ml.s;var mdX=-mbPd2-(mdW-W-mbPd2*2)*0.5+bwMX2*ml.p;var mdY=-mbPd2-(mdH-H-mbPd2*2)*0.5+bwMY2*ml.p*0.7;ctx.drawImage(window._marbleCanvas2,mdX,mdY,mdW,mdH);}ctx.globalAlpha=1;
                    }

                    else if (cloudEffect === 'bw_diamonds') {
                        var DI_L2=4,DI_BC2=[6,12,20,30],DI_S2=[120,70,40,20],DI_A2=[0.25,0.15,0.08,0.04];var diH2=getNeonHue(0);
                        for(var diL2=0;diL2<DI_L2;diL2++){var diDep2=diL2/(DI_L2-1);var diPX2=bwMX2*(0.3+diDep2*2.0),diPY2=bwMY2*(0.3+diDep2*2.0);var diN2=Math.max(1,Math.round(DI_BC2[diL2]*bwCountPct2));var diMS2=DI_S2[diL2]*bwSizePct2;var diAl2=DI_A2[diL2];var diSp2=(0.04+(1-diDep2)*0.08)*tunnelSpeed;
                        var diGC2=Math.max(1,Math.round(Math.sqrt(diN2*W/H))),diGR2=Math.max(1,Math.ceil(diN2/diGC2));var diCW2=W/diGC2,diCH2=H/diGR2;var diIdx2=0;
                        for(var dgr2=0;dgr2<diGR2&&diIdx2<diN2;dgr2++){for(var dgc2=0;dgc2<diGC2&&diIdx2<diN2;dgc2++){var ds1=Math.sin((dgc2+1)*127.1+(dgr2+1)*269.5+diL2*311.7)*43758.5453;var djx2=(ds1-Math.floor(ds1))-0.5;var ds2=Math.sin((dgc2+1)*419.2+(dgr2+1)*137.3+diL2*183.3)*43758.5453;var djy2=(ds2-Math.floor(ds2))-0.5;var ds3=Math.sin((dgc2+1)*389.9+(dgr2+1)*251.7+diL2*71.9)*43758.5453;var drs2=(ds3-Math.floor(ds3));var dX2=(dgc2+0.5+djx2*0.7)*diCW2+diPX2+Math.sin(bwT2*diSp2*0.003+diIdx2*0.8)*20;var dY2=(dgr2+0.5+djy2*0.7)*diCH2+diPY2+Math.cos(bwT2*diSp2*0.002+diIdx2*1.1)*15;var dS2=diMS2*(0.4+drs2*0.6);var dMDX2=dX2-(W*0.5+bwMX2*W*0.3),dMDY2=dY2-(H*0.5+bwMY2*H*0.3);var dMD2=Math.sqrt(dMDX2*dMDX2+dMDY2*dMDY2);var dMP2=Math.max(0,1-dMD2/(Math.max(W,H)*0.4));dS2*=(1+dMP2*1.2);if(dX2<-dS2||dX2>W+dS2||dY2<-dS2||dY2>H+dS2){diIdx2++;continue;}var dHu2=(diH2+diIdx2*15+diL2*40)%360;var dRGB2=hslToRgb(dHu2,50,_bgDark2?70:35);var dA2=diAl2*(0.5+drs2*0.5);ctx.save();ctx.translate(dX2,dY2);ctx.rotate(Math.PI/4);ctx.fillStyle='rgba('+dRGB2[0]+','+dRGB2[1]+','+dRGB2[2]+','+dA2.toFixed(3)+')';ctx.fillRect(-dS2*0.5,-dS2*0.5,dS2,dS2);ctx.restore();diIdx2++;}}}
                    }

                    ctx.restore();
                }

                    ctx.filter = 'none';        ctx.globalAlpha = 1;
                }
            }
            frame();
        } // end scope block
        } // end initTunnel


        // Initial call
        var _initCloudEff = tunnelOn ? 'none' : (body.dataset.screenCloudEffect || 'none');
        var _tunnelFill = body.dataset.screenTunnelFill === '1';
        /* seed wrapper.dataset.mouseSensLevel so the frame loop can read it live */
        var _sw = document.querySelector('.impulse-screen-wrapper');
        if (_sw) _sw.dataset.mouseSensLevel = body.dataset.screenMouseSensLevel || '0.5';
        if (tunnelOn || _initCloudEff !== 'none') {
            initTunnel(tunnelOn, _initCloudEff, tunnelSpeed, false, intensity, _tunnelFill);
        }

        // Expose
        window.impulseScreenMode = {
            goToScreen: goToScreen,
            jumpToScreen: function(idx) {
                if (idx < 0 || idx >= screens.length) return;
                screens.forEach(function(el, i) {
                    el.style.transition = 'none';
                    if (i === idx) applyFocusState(el);
                    else if (i < idx) applyExitState(el);
                    else applyEnterState(el);
                });
                currentScreen = idx;
                updateDots();
                updateScreenNav();
                updateThumbStrip();
            },
            getCurrentScreen: function() { return currentScreen; },
            getScreenCount: function() { return screens.length; },
            reinitTunnel: function(newTunnel, newCloud, newStars, newSpeed) {
                var tInt = parseInt(body.getAttribute('data-screen-intensity') || intensity) || 50;
                var tFill = body.dataset.screenTunnelFill === '1';
                var tMSens = body.dataset.screenMouseSens !== '0';
                initTunnel(!!newTunnel, newCloud || 'none', parseFloat(newSpeed) || tunnelSpeed, false, tInt, tFill, tMSens);
            },
            updateSettings: function(t, c, d, dots, intens, inf, ts, stars) {
                transition = t || transition;
                cooldown   = c || cooldown;
                duration   = d || duration;
                if (intens !== undefined) intensity   = intens;
                if (inf   !== undefined) infiniteLoop = !!inf;
                if (ts    !== undefined) tunnelSpeed  = parseFloat(ts) || 1;
                screens.forEach(function(el, i) {
                    if (i === currentScreen) applyFocusState(el);
                    else if (i < currentScreen) applyExitState(el);
                    else applyEnterState(el);
                });
                if (dots && !dotsContainer && screens.length > 1) createDots();
                else if (!dots && dotsContainer) { dotsContainer.remove(); dotsContainer = null; }
            },
            updateNames: function(names) {
                screenNames = names;
                // Update panel links
                var links = document.querySelectorAll('.impulse-snav-link');
                links.forEach(function(l, i) { if (names[i]) l.textContent = names[i]; });
                // Update current screen name
                updateScreenNav();
            },
            refreshAfterDelete: function() { this.refresh(); },
            /**
             * Re-query the screens NodeList from the live DOM and re-apply
             * focus/exit/enter states. Called after any operation that
             * mutates the screens list — section delete, section reorder,
             * AND element-edit (where a section's HTML gets replaced via
             * replaceChild). Without this the screens array stayed pinned
             * to the original (now-detached) section nodes after an edit,
             * so .impulse-screen-active never updated and the edited screen
             * stayed visible across the rest of the scene.
             */
            refresh: function() {
                // querySelectorAll returns a static NodeList — childList
                // mutations don't refresh it, so we requery each time the
                // section set might have changed.
                screens = wrapper.querySelectorAll(_screenSel);
                if (!screens.length) return;
                currentScreen = Math.min(currentScreen, screens.length - 1);
                screens.forEach(function(el, i) {
                    if (i === currentScreen) applyFocusState(el);
                    else if (i < currentScreen) applyExitState(el);
                    else applyEnterState(el);
                });
                updateDots();
                updateScreenNav();
                updateThumbStrip();
                // Pass isInit=true — refresh() runs after element edits
                // (the parent re-renders an element, then calls this to
                // re-sync the screens list). It is NOT a user-driven
                // screen transition, so the parent must skip its
                // release-selection logic. Without this flag, every
                // settings edit dropped the user's selection and snapped
                // the panel back to Page Settings.
                notifyParent(true);
            },
            /**
             * Re-sync the screens list after a sidebar drag-reorder
             * moved DOM nodes around. Different from refresh() in one
             * critical way: it tracks the active DOM element by identity
             * across the move, so the user's view stays in viewport
             * (currentScreen updates to the active element's NEW index)
             * instead of snapping to whichever screen happens to land
             * at the same index slot.
             *
             * Sergei reported v2.1.787 still felt "broken" because
             * scrolling DOWN after reorder didn't show the moved screen
             * — turned out the screens NodeList was stale: the cached
             * navigation order still pointed to the pre-reorder layout.
             * This refresh re-collects the NodeList AND preserves the
             * active screen's identity, so the user sees no jump but
             * subsequent wheel/key navigation honors the new order.
             */
            refreshOrder: function() {
                var activeEl = screens[currentScreen] || null;
                screens = wrapper.querySelectorAll(_screenSel);
                if (!screens.length) return;
                // Re-bind currentScreen to the SAME DOM element. If it
                // moved (e.g. Chat dragged from idx 7 to idx 6), the new
                // index tracks the move so the user stays looking at the
                // screen they were already on.
                if (activeEl) {
                    for (var i = 0; i < screens.length; i++) {
                        if (screens[i] === activeEl) { currentScreen = i; break; }
                    }
                }
                currentScreen = Math.min(currentScreen, screens.length - 1);
                // Re-apply focus/exit/enter so the new indices' visual
                // state matches the active screen — without this, the
                // screen previously at currentScreen index might still
                // carry the focused style while the new active one
                // shows the entering style.
                screens.forEach(function(el, i) {
                    if (i === currentScreen) applyFocusState(el);
                    else if (i < currentScreen) applyExitState(el);
                    else applyEnterState(el);
                });
                updateDots();
                updateScreenNav();
                updateThumbStrip();
                notifyParent(true);
            }
        };
        // Alias — editor calls ImpulseScreens.reinitTunnel
        window.ImpulseScreens = window.impulseScreenMode;

        // Listen for editor messages
        window.addEventListener('message', function(e) {
            if (e.data && e.data.type === 'impulse-reinit-effect' && window.impulseScreenMode && window.impulseScreenMode.reinitTunnel) {
                var _cEff = body.getAttribute('data-screen-cloud-effect') || 'none';
                var _tOn = body.getAttribute('data-screen-tunnel') === '1';
                var _tSpd = parseFloat(body.getAttribute('data-screen-tunnel-speed')) || 1;
                window.impulseScreenMode.reinitTunnel(_tOn, _tOn ? 'none' : _cEff, _tOn, _tSpd);
            }
            if (e.data && e.data.type === 'impulse-snav-names' && window.impulseScreenMode) {
                window.impulseScreenMode.updateNames(e.data.names || []);
            }
            if (e.data && e.data.type === 'impulse-space-variant') {
                var _swapPals = {
                    '1': {
                        far:   'radial-gradient(ellipse at 80% 85%, rgba(160,50,100,0.22) 0%, transparent 50%), radial-gradient(ellipse at 15% 20%, rgba(70,50,160,0.12) 0%, transparent 45%)',
                        mid:   'radial-gradient(ellipse at 70% 75%, rgba(180,60,120,0.15) 0%, transparent 40%), radial-gradient(ellipse at 25% 35%, rgba(80,50,160,0.1) 0%, transparent 40%)',
                        near:  'radial-gradient(ellipse at 50% 95%, rgba(140,40,90,0.08) 0%, transparent 35%)',
                        flare: 'radial-gradient(circle at 72% 82%, rgba(255,180,200,0.25) 0%, transparent 14%)'
                    },
                    '2': {
                        far:   'radial-gradient(ellipse at 35% 50%, rgba(200,120,40,0.22) 0%, transparent 45%), radial-gradient(ellipse at 72% 25%, rgba(40,160,180,0.18) 0%, transparent 45%), radial-gradient(ellipse at 20% 75%, rgba(50,80,180,0.14) 0%, transparent 40%)',
                        mid:   'radial-gradient(ellipse at 40% 45%, rgba(220,150,50,0.16) 0%, transparent 35%), radial-gradient(ellipse at 65% 35%, rgba(60,180,170,0.12) 0%, transparent 35%)',
                        near:  'radial-gradient(ellipse at 45% 55%, rgba(200,130,50,0.06) 0%, transparent 30%)',
                        flare: 'radial-gradient(circle at 38% 48%, rgba(255,220,150,0.35) 0%, rgba(255,180,100,0.12) 8%, transparent 20%)'
                    },
                    '3': {
                        far:   'radial-gradient(ellipse at 40% 35%, rgba(180,120,80,0.2) 0%, transparent 40%), radial-gradient(ellipse at 55% 55%, rgba(100,80,160,0.12) 0%, transparent 50%), radial-gradient(ellipse at 70% 60%, rgba(50,80,140,0.1) 0%, transparent 40%)',
                        mid:   'radial-gradient(ellipse at 42% 38%, rgba(200,140,100,0.14) 0%, transparent 30%), radial-gradient(ellipse at 60% 50%, rgba(140,100,180,0.08) 0%, transparent 35%)',
                        near:  'radial-gradient(ellipse at 45% 40%, rgba(180,130,90,0.06) 0%, transparent 25%)',
                        flare: 'radial-gradient(circle at 42% 36%, rgba(255,200,160,0.3) 0%, rgba(255,170,130,0.1) 10%, transparent 22%)'
                    }
                };
                var _isOff = e.data.variant === '0';
                var _sp = _isOff ? null : (_swapPals[e.data.variant] || _swapPals['1']);
                var _af = document.querySelector('.impulse-atmo-far');    if (_af) { if (_isOff) _af.remove(); else _af.style.background = _sp.far; }
                var _am = document.querySelector('.impulse-atmo-mid');    if (_am) { if (_isOff) _am.remove(); else _am.style.background = _sp.mid; }
                var _an = document.querySelector('.impulse-atmo-near');   if (_an) { if (_isOff) _an.remove(); else _an.style.background = _sp.near; }
                var _afl = document.querySelector('.impulse-atmo-flare'); if (_afl) { if (_isOff) _afl.remove(); else _afl.style.background = _sp.flare; }
                // If turning back on from '0', need elements — reinit via full tunnel reinit
                if (!_isOff && !_af) {
                    body.dataset.screenSpaceVariant = e.data.variant;
                    if (window.impulseScreenMode) window.impulseScreenMode.reinitTunnel(false, false);
                }
            }
        });
    })();

    // ========================================
    // BACKGROUND SHAPES - DRAG TO REPOSITION
    // ========================================
    // Drag updates --ox / --oy in cqw units (= % of section width). Both X
    // and Y deltas are normalised against container WIDTH so vertical drag
    // stays stable when section height changes due to content reflow.
    (function() {
        var dragState = null;

        function readCqw(el, name) {
            var raw = el.style.getPropertyValue(name) || '0';
            return parseFloat(raw) || 0;
        }

        document.addEventListener('mousedown', function(e) {
            var shapeEl = e.target.closest('.impulse-bg-shape-el');
            if (!shapeEl) return;
            var layer = shapeEl.closest('.impulse-bg-shape-layer');
            if (!layer) return;

            e.preventDefault();
            e.stopPropagation();

            var rect = layer.getBoundingClientRect();
            dragState = {
                el: shapeEl,
                idx: parseInt(shapeEl.dataset.shapeIdx),
                startX: e.clientX,
                startY: e.clientY,
                startOx: readCqw(shapeEl, '--ox'),
                startOy: readCqw(shapeEl, '--oy'),
                W: rect.width || 1
            };

            shapeEl.style.cursor = 'grabbing';
        });

        document.addEventListener('mousemove', function(e) {
            if (!dragState) return;

            var dx = e.clientX - dragState.startX;
            var dy = e.clientY - dragState.startY;

            // Both axes in cqw (width-relative). 1cqw = W/100 px. Clamp at
            // -50/+50 to match the slider range — beyond that, the user
            // should pick a different anchor instead of accumulating offset.
            var newOx = dragState.startOx + (dx / dragState.W) * 100;
            var newOy = dragState.startOy + (dy / dragState.W) * 100;
            if (newOx >  50) newOx =  50;
            if (newOx < -50) newOx = -50;
            if (newOy >  50) newOy =  50;
            if (newOy < -50) newOy = -50;
            newOx = Math.round(newOx * 10) / 10;
            newOy = Math.round(newOy * 10) / 10;

            dragState.el.style.setProperty('--ox', newOx + 'cqw');
            dragState.el.style.setProperty('--oy', newOy + 'cqw');
        });

        document.addEventListener('mouseup', function(e) {
            if (!dragState) return;

            var newOx = readCqw(dragState.el, '--ox');
            var newOy = readCqw(dragState.el, '--oy');

            window.parent.postMessage({
                type: 'impulse-bg-shape-drag',
                idx: dragState.idx,
                ox: newOx,
                oy: newOy
            }, '*');

            dragState.el.style.cursor = 'move';
            dragState = null;
        });
    })();
    
    // ========================================
    // OFF-CANVAS IN EDITOR
    // ========================================
    (function() {
        // Create overlay element (shared by all off-canvas panels)
        var ocOverlay = document.createElement('div');
        ocOverlay.className = 'impulse-oc-editor-overlay';
        ocOverlay.style.cssText = 'display:none;position:fixed;top:0;left:0;width:100%;height:100%;z-index:99998;opacity:0;transition:opacity 0.3s';
        document.body.appendChild(ocOverlay);
        
        var activeOC = null;
        
        function openOC(ocId) {
            var preview = document.querySelector('.impulse-modal-drawer-preview[data-oc-id="' + ocId + '"]');
            if (!preview) return;
            
            var pos = preview.dataset.ocPosition || 'right';
            var width = parseInt(preview.dataset.ocWidth) || 360;
            var bg = preview.dataset.ocBg || '#ffffff';
            var padding = parseInt(preview.dataset.ocPadding) || 24;
            var showClose = preview.dataset.ocClose === '1';
            var showOverlay = preview.dataset.ocOverlay === '1';
            var overlayClose = preview.dataset.ocOverlayClose === '1';
            var overlayColor = preview.dataset.ocOverlayColor || 'rgba(0,0,0,0.4)';
            
            // Create sliding panel
            var panel = document.createElement('div');
            panel.className = 'impulse-oc-editor-panel';
            panel.style.cssText = 'position:fixed;top:0;' + pos + ':0;width:' + width + 'px;max-width:90vw;height:100vh;background:' + bg + ';padding:' + padding + 'px;box-sizing:border-box;overflow-y:auto;z-index:99999;transform:translateX(' + (pos === 'left' ? '-100%' : '100%') + ');transition:transform 0.3s cubic-bezier(0.4,0,0.2,1)';
            
            // Clone content from editing box
            var content = preview.querySelector('.impulse-modal-drawer-content');
            if (content) {
                panel.innerHTML = content.innerHTML;
                // Remove dropzones from clone
                panel.querySelectorAll('.impulse-dropzone').forEach(function(dz) { dz.remove(); });
            }
            
            if (showClose) {
                var closeBtn = document.createElement('button');
                closeBtn.style.cssText = 'position:absolute;top:12px;right:12px;width:32px;height:32px;border:none;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#666;z-index:1';
                closeBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
                closeBtn.addEventListener('click', function() { closeActiveOC(); });
                panel.insertBefore(closeBtn, panel.firstChild);
            }
            
            document.body.appendChild(panel);
            
            // Show overlay
            if (showOverlay) {
                ocOverlay.style.background = overlayColor;
                ocOverlay.style.display = 'block';
                requestAnimationFrame(function() { ocOverlay.style.opacity = '1'; });
                if (overlayClose) {
                    ocOverlay.onclick = function() { closeActiveOC(); };
                }
            }
            
            // Slide in
            requestAnimationFrame(function() {
                requestAnimationFrame(function() {
                    panel.style.transform = 'translateX(0)';
                });
            });
            
            activeOC = { panel: panel, position: pos };
        }
        
        function closeActiveOC() {
            if (!activeOC) return;
            var oc = activeOC;
            activeOC = null;
            oc.panel.style.transform = 'translateX(' + (oc.position === 'left' ? '-100%' : '100%') + ')';
            ocOverlay.style.opacity = '0';
            setTimeout(function() {
                oc.panel.remove();
                ocOverlay.style.display = 'none';
                ocOverlay.onclick = null;
            }, 300);
        }
        
        // Intercept clicks on trigger links: href="#offcanvas-id" or data-link="#offcanvas-id"
        document.addEventListener('click', function(e) {
            var target = e.target.closest('a[href^="#offcanvas-"], a[href^="#oc-"], button[data-link^="#offcanvas-"], button[data-link^="#oc-"]');
            if (!target) return;
            e.preventDefault();
            e.stopPropagation();
            var href = target.getAttribute('href') || target.dataset.link || '';
            var ocId = href.replace('#', '');
            if (ocId) openOC(ocId);
        }, true);
        
        // ESC to close
        document.addEventListener('keydown', function(e) {
            if (e.key === 'Escape' && activeOC) closeActiveOC();
        });
    })();
    
    // ========================================
    // INITIALIZATION
    // ========================================
    
    var elements = document.querySelectorAll('.impulse-el');
})();
</script>
<?php
// Late emit of the frontend effects engine — deferred from the iframe-handle flush near the
// top of the body so the per-element inline scripts registered DURING the body render (neon
// divider, hero sandbox, charts, booking, grid-fit) are included instead of being dropped. v2.1.1992.
if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
    if (function_exists('_instinctor_emit_script')) {
        _instinctor_emit_script('instinctor-frontend-js', false);
        _instinctor_emit_script('instinctor-frontend-js', true);
    }
} elseif (function_exists('wp_print_scripts')) {
    wp_print_scripts(['instinctor-frontend-js']);
}
wp_footer();
?>
<?php if ($isThumbnail): ?>
<script>
// Thumbnail mode: remove any fixed positioned elements injected by plugins/themes
(function() {
    function hideFixedElements() {
        // Specifically remove Instinctor chat widget
        var chatWidget = document.getElementById('instinctor-chat-widget');
        if (chatWidget) chatWidget.remove();
        
        // Remove any fixed positioned elements
        document.querySelectorAll('*').forEach(function(el) {
            var style = window.getComputedStyle(el);
            if (style.position === 'fixed' && !el.closest('.impulse-el')) {
                el.style.display = 'none';
            }
        });
    }
    hideFixedElements();
    setTimeout(hideFixedElements, 500);
    setTimeout(hideFixedElements, 1500);
})();
</script>
<?php endif; ?>
<script>
// Signal parent that preview content is fully rendered
(function() {
    var pageId = (new URLSearchParams(location.search).get('impulse_preview') || (location.pathname.match(/\/admin\/preview\/(\d+)/) || [])[1] || '');
    function signalReady() {
        if (window.parent && window.parent !== window) {
            window.parent.postMessage({ type: 'impulse-preview-ready', pageId: pageId }, '*');
        }
    }
    // Wait for all images to load before signaling ready
    function waitForImages(cb) {
        var imgs = document.querySelectorAll('img');
        var pending = 0;
        imgs.forEach(function(img) {
            if (!img.complete) {
                pending++;
                img.onload = img.onerror = function() { if (--pending <= 0) cb(); };
            }
        });
        if (pending === 0) cb();
    }
    if (document.readyState === 'complete') {
        waitForImages(signalReady);
    } else {
        window.addEventListener('load', function() { waitForImages(signalReady); });
    }
    // Fallback: signal ready after 3s regardless
    setTimeout(signalReady, 3000);
})();
</script>
<script>
// Display Text alignment — receives postMessage from parent editor.
//
// Mirrors the PHP renderer model exactly: text-align lands on the dtEl
// wrap; max-width + margin:auto land on the inner tag (h1/h2/etc.). The
// previous version put max-width + auto margins on the dtEl wrap itself,
// which shrunk dtEl on every settings change. Because the 3D-text engine
// reads dtEl.offsetWidth to position its canvas, the rebuild then landed
// at a different absolute offset from where the previous canvas sat,
// producing the visible "jumps left" the user reported on every slider
// move. Keeping dtEl at width:100% means engine width is stable across
// rebuilds and only the inner <h1> reflows.
window.addEventListener('message', function(e) {
    if (!e.data || e.data.type !== 'impulse-dt-align') return;
    var el = document.querySelector('.impulse-el[data-id="' + e.data.elementId + '"]');
    if (!el) return;
    var wrap = el.querySelector('.impulse-display-text');
    var tag = el.querySelector('h1,h2,h3,h4,h5,h6,p');
    var align = e.data.align || 'center';
    var maxW = e.data.maxWidth || 0;
    var ls = e.data.letterSpacing || 0;
    if (wrap) {
        wrap.style.textAlign = align;
        // Defensive cleanup — earlier versions of this handler stamped
        // max-width / margin on the wrap. Strip them so old saved
        // sessions don't carry forward a sticky shrunk dtEl. Also
        // re-assert width:100% + max-width:none so any drift from a
        // previous bug iteration is wiped — the wrap MUST fill its
        // parent .impulse-el so h1's margin:auto has room to center.
        wrap.style.maxWidth = 'none';
        wrap.style.width = '100%';
        wrap.style.marginLeft = '';
        wrap.style.marginRight = '';
    }
    if (tag) {
        tag.style.textAlign = align;
        tag.style.letterSpacing = ls + 'px';
        // Mirror PHP exactly: plain `Npx`, not `min(Npx, 100%)`.
        //
        // BUG (long-standing "text jumps left on color change"):
        // `min(1000px, 100%)` containing 100% makes the browser factor
        // h1's max-width into its parent's intrinsic-size calculation,
        // shrinking .impulse-display-text from 100% of .impulse-el down
        // to h1's max-width. With the parent shrunk to 1000px and h1's
        // own max-width also 1000px, there's no room for `margin:0 auto`
        // to center anything — h1 collapses to the left edge.
        //
        // PHP renders plain `max-width:1000px` which doesn't reference
        // the parent, so the parent stays at .impulse-el's full width
        // and centering works. Mirror that exactly. Visual is identical
        // because at parent.width >= maxW the two expressions evaluate
        // to the same value; the difference only mattered for the
        // intrinsic-sizing side-effect, which we want gone.
        if (maxW > 0) {
            tag.style.maxWidth = maxW + 'px';
            tag.style.margin = (align === 'center')
                ? '0 auto'
                : (align === 'right' ? '0 0 0 auto' : '0');
        } else {
            tag.style.maxWidth = '';
            tag.style.margin = '0';
        }
    }
});
</script>
<?php
// Chat widget — inject into the editor iframe on standalone.
// ChatService::renderWidget() short-circuits when is_admin() is true (which it is
// in /admin/preview/X context), so we temporarily clear INSTINCTOR_ADMIN_CONTEXT's
// effect by calling the widget directly with $chat_hidden=false. The per-page
// chat_visible gate still applies via the renderShortcode path.
if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone'
    && class_exists('\\Instinctor\\Services\\ChatService')
    && !empty($GLOBALS['impulse_preview_page_id'])) {
    try {
        // Bypass ChatService::renderShortcode()'s is_admin() gate by setting
        // INSTINCTOR_CHAT_PREVIEW — it's the documented signal that the admin-side
        // preview iframe is rendering the widget deliberately. Without it the
        // shortcode returns an empty string on every admin route (including the
        // editor iframe at /admin/preview/X), so the widget never appears in the
        // editor even though it renders fine on the public Preview button URL.
        if (!defined('INSTINCTOR_CHAT_PREVIEW')) define('INSTINCTOR_CHAT_PREVIEW', true);
        global $post;
        $_savedPost = $post;
        $post = (object) ['ID' => intval($GLOBALS['impulse_preview_page_id'])];
        $GLOBALS['post'] = $post;
        // renderShortcode enqueues chat-widget.css, but the iframe's style
        // print pass already ran. Force-emit it so the <link> tag actually
        // reaches the iframe — without it, the widget's collapsed state
        // styles never apply and the bar reads as expanded by default.
        ob_start();
        echo do_shortcode('[instinctor_chat]');
        $_chatPreviewHtml = ob_get_clean();
        if (function_exists('_instinctor_emit_style')) {
            _instinctor_emit_style('instinctor-chat-widget');
        }
        // Shortcode output: the chat-widget renderer composes and escapes this
        // markup itself. A whole-fragment re-escape (wp_kses_post) would strip
        // its inline styles/SVG and break the widget, so it is echoed raw.
        // phpcs:disable WordPress.Security.EscapeOutput -- buffered do_shortcode('[instinctor_chat]'); the chat renderer escapes its own output
        echo $_chatPreviewHtml;
        // phpcs:enable WordPress.Security.EscapeOutput
        $post = $_savedPost;
        $GLOBALS['post'] = $_savedPost;
    } catch (\Throwable $_e) {
        // Swallow — editor shouldn't crash on chat errors.
    }
}
?>
<script>
/* Section bg-gradient mouse rotation (editor preview). Always emitted so the
   live-update path can tag sections on the fly when the user enables
   mouse-sens — without an unconditional loop, activating the feature
   required a hard refresh before the rAF loop existed at all. Re-queries
   tagged nodes every frame (cheap; sections are typically a handful) so
   newly-tagged sections take effect immediately. */
/* Per-element angle smoothing with circular shortest-arc lerp.
   The naive position-damping approach DOES NOT smooth the rendered angle:
   atan2(y,x) preserves the y/x ratio when both axes damp at the same rate,
   so the angle snaps to the target direction in one frame. Fix is to damp
   the ANGLE itself (with shortest-arc circular lerp) and the cursor MAGNITUDE
   separately so the ring's pull fades in/out as cursor enters/leaves.
   Mirror of the renderer in impulse-page.php — keep them in sync. */
(function(){
    // Direct cursor read + per-element 0.12 lerp. Mirror of impulse-page.php.
    var globalMx = 0, globalMy = 0;
    document.addEventListener('mousemove', function(e) {
        globalMx = e.clientX;
        globalMy = e.clientY;
    }, { passive: true });

    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;
        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). Cursor pull only — no spin.
        var bg = document.querySelectorAll('[data-bg-grad-mouse]');
        for (var i = 0; i < bg.length; i++) {
            var el = bg[i];
            smoothCursor(el);
            var sens = parseFloat(el.dataset.bgGradMouse) || 0;
            var base = parseFloat(el.dataset.bgGradBase)  || 135;
            var cAng = el._curAng * Math.min(1, el._curMag);
            var ang = (((base + cAng * (sens / 100)) % 360) + 360) % 360;
            el.style.setProperty('--imp-bg-grad-angle', ang.toFixed(1) + 'deg');
        }

        // Text-fill gradient (Text block) — paints glyphs via
        // background-clip:text. Spin + cursor pull both supported; spin
        // integrated over dt so slider drags stay smooth.
        var tf = document.querySelectorAll('[data-text-grad-mouse]');
        for (var ti = 0; ti < tf.length; ti++) {
            var elT = tf[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: single formula pull = cAng × sens/100. The slider
        // IS the cap. Mirror of impulse-page.php.
        //
        // Spin integration: previously `nowSec * spin` (recomputed every frame
        // from absolute time). When the user dragged the spin slider mid-
        // rotation, `spin` changed but `nowSec` had already advanced — so
        // `nowSec * spin` jumped by `nowSec * (newSpin - oldSpin)` in a single
        // frame, which looked like "one side at a time" stepping. The fix is
        // to integrate spin over time: accumulate `dt * spin` per frame so
        // when spin changes the rotation continues from where it was, not
        // from a freshly-computed absolute angle.
        var br = document.querySelectorAll('[data-border-grad-mouse]');
        for (var j = 0; j < br.length; j++) {
            var el2 = br[j];
            smoothCursor(el2);
            if (el2._phase === undefined) el2._phase = Math.random() * 6.283;
            var cs = getComputedStyle(el2);
            var sensV = parseFloat(cs.getPropertyValue('--impulse-border-mouse-sens'));
            var sens2 = !isNaN(sensV) ? sensV : (parseFloat(el2.dataset.borderGradMouse) || 0);
            var baseV = parseFloat(cs.getPropertyValue('--impulse-border-gradient-angle-base'));
            var base2 = !isNaN(baseV) ? baseV : (parseFloat(el2.dataset.borderGradBase) || 135);
            var spinV = parseFloat(cs.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 cAng2 = el2._curAng * Math.min(1, el2._curMag);
            var breath = Math.sin(nowSec * 0.7 + el2._phase) * 4;
            var pull = cAng2 * (sens2 / 100);
            var ang2 = (((base2 + el2._spinAccum + breath + pull) % 360) + 360) % 360;
            var angStr = ang2.toFixed(1) + 'deg';
            el2.style.setProperty('--imp-bgrad-angle', angStr);
            el2.style.setProperty('--impulse-border-gradient-angle', angStr);
        }
        requestAnimationFrame(tick);
    }
    requestAnimationFrame(tick);
})();
</script>
<style>
/* Gradient border ring — mask-based so border-radius is honored.
   border-image silently drops border-radius (CSS spec), so when the
   gradient ring (or its spin animation) kicked in, cells snapped from
   rounded to square. The two-mask recipe paints the gradient on a
   ::before pseudo and masks out the interior, leaving a uniform-width
   ring that inherits the cell's border-radius. Sized by --imp-bgrad-w
   (defaults to 1px). Frontend mirror in templates/impulse-page.php. */
.impulse-cell-grad-border {
    position: relative;
    background-clip: padding-box;
    box-sizing: border-box;
}
/* Specificity bumped (.impulse-grid-cell.impulse-cell-grad-border::before
   is two classes vs the editor's bracket-decorator rule which is one).
   Plus explicit overrides for opacity / width / height / transform /
   top / left / background because the editor's bracket rule (around
   L4869 of this file) sets those for ALL .impulse-grid-cell::before
   and cascade-merges with our rule, cropping our gradient ring to a
   tiny 14×14 corner and forcing it to opacity:0. The ::after bracket
   is suppressed entirely for grad-border cells — otherwise a gray
   bottom-right L-shape appears on hover and looks like a phantom
   "hover color" the user never configured. */
.impulse-grid-cell.impulse-cell-grad-border::after,
.impulse-rows-cell.impulse-cell-grad-border::after,
.impulse-layout-cell.impulse-cell-grad-border::after {
    display: none !important;
    content: none !important;
}
.impulse-grid-cell.impulse-cell-grad-border::before,
.impulse-rows-cell.impulse-cell-grad-border::before,
.impulse-layout-cell.impulse-cell-grad-border::before {
    content: '';
    position: absolute;
    inset: 0 !important;
    top: 0 !important;
    left: 0 !important;
    right: 0 !important;
    bottom: 0 !important;
    width: auto !important;
    height: auto !important;
    transform: none !important;
    opacity: 1 !important;
    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: none !important;
    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;
}

/* Gradient ring on buttons — rendered as a ::before pseudo masked with
   composite=exclude so the gradient shows ONLY in the border zone. The
   button keeps its own background (which can be transparent). The pseudo
   sits behind the button content via z-index:-1 + isolation:isolate on
   the host (set inline by the renderer). pointer-events:none keeps the
   click target on the <a>.
   Gradient itself uses conic-gradient (not linear). Linear paints
   perpendicular stripes across the button, so on a thin ring you see
   bands of colour on each side and the spin sweeps diagonally — the
   "one side at a time" snapping that prompted this change. Conic
   places colours as slices radiating from the centre, so the spin
   rotates the highlight around the perimeter naturally. The first and
   last stops repeat the same colour so the loop closes seamlessly when
   the runtime angle wraps from 360° back to 0°. */
.impulse-grad-border::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    box-sizing: border-box;
    padding: var(--impulse-border-width, 2px);
    border-radius: inherit;
    background: conic-gradient(
        from var(--impulse-border-gradient-angle, 135deg),
        var(--impulse-border-color, #1d2327),
        var(--impulse-border-gradient-to, transparent),
        var(--impulse-border-color, #1d2327)
    );
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
            mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
            mask-composite: exclude;
    pointer-events: none;
}
/* Mirror of frontend.css defensive rules — buttons in the editor preview
   iframe see the same DOM/CSS shape as the public renderer, but the
   admin chrome wraps them in extra contexts (entrance animations,
   live-preview transforms). Lock the press states so the button never
   visibly shifts dimensions on click. Also lock white-space so the
   label stays on a single line regardless of width pin — the "Order
   Starter" wrap that surfaced when btn_min_width clamped below the
   label's intrinsic width. */
.impulse-button{outline:none;-webkit-tap-highlight-color:transparent;white-space:nowrap}
.impulse-button:active,
.impulse-button:focus,
.impulse-button:focus-visible{
    transform:none!important;
    animation:none!important;
    outline:none!important;
    box-shadow:none!important;
}
.impulse-frosted{
    box-shadow:
        inset 0 1px 0 rgba(255,255,255,0.35),
        inset 0 -1px 0 rgba(0,0,0,0.12),
        0 4px 14px rgba(0,0,0,0.18);
}
.impulse-frosted.impulse-grad-border::before{
    filter:saturate(160%);
}
/* v2.1.1366 — mirror of the frontend.css frosted-section overrides into
   the editor preview's injected CSS. Sergei was testing inside the editor
   preview iframe (admin?p=rates), where this stylesheet rules — frontend.css
   does NOT cascade in. Without these copies, the editor preview kept hitting
   the same backdrop-root traps the frontend ones cleared.

   The trap chain we kill on screens that host a frosted section:
   - `.impulse-screen-wrapper { perspective: 1200px }` (set inline by
     impulse-screens.js / injected here)
   - `.impulse-screen { backface-visibility: hidden; contain: paint }`
     (same injected runtime CSS)
   - any local-context creators on the section itself or its `.impulse-el`
     wrapper.

   These mirror the frontend.css block exactly. */
/* 2026-07-18: every frosted flattening rule below yields WHILE A SCREEN
   TRANSITION IS IN FLIGHT (body carries an .impulse-screen-animating screen).
   These rules kill perspective / force transform:none so backdrop-filter can
   sample the env canvas AT REST - but the depth-family reveals (z_depth,
   depth_spring/vortex/zoom, rise, fall) animate the screen and its decompose
   children through transforms + a container perspective. With the rules
   unconditional, any screen hosting a frosted section lost ALL transform
   motion: depth reveals degraded to a bare opacity fade while the outgoing
   screen still zoomed ("reveals from the camera, buttons change nothing").
   Iris/split (clip-path) were unaffected - the tell that pinned this. */
body:not(:has(.impulse-screen-animating)) .impulse-screen-wrapper:has(.impulse-frosted) { perspective: none !important; }
.impulse-screen:has(.impulse-frosted) {
    backface-visibility: visible !important;
    -webkit-backface-visibility: visible !important;
    contain: none !important;
    will-change: auto !important;
}
body:not(:has(.impulse-screen-animating)) .impulse-el:has(> section.impulse-frosted) {
    isolation: auto !important;
    contain: none !important;
    will-change: auto !important;
    transform: none !important;
    filter: none !important;
    overflow: visible !important;
    /* v2.1.1370: also strip `transform-style: preserve-3d` that the decompose
       transition leaves leaked on this wrapper (impulse-preview.php sets it
       inline while walking UP from the container to the screen element; its
       cleanup only restores elements whose `overflow` was modified, so the
       inline preserve-3d sticks here forever). preserve-3d promotes the
       element to its own GPU layer, which interferes with bf sampling on
       descendants in unpredictable ways. */
    transform-style: flat !important;
}
section.impulse-frosted {
    isolation: auto !important;
    contain: none !important;
    will-change: auto !important;
    filter: none !important;
}
/* v2.1.1368 — kill the perspective that decompose transitions leak onto
   `.impulse-container` when frosted lives inside. impulse-preview.php
   sets `container.style.perspective = '500px'` inline during the
   decompose-children transition (~line 14259 / 14462). Cleanup at the
   end of the animation clears it, but on forward+back navigation a
   superseded transition can leave the inline `perspective` set —
   creating a backdrop root that severs descendant `backdrop-filter`
   from the body-level env canvas. This rule guarantees the perspective
   never sticks on containers that host a frosted section. */
body:not(:has(.impulse-screen-animating)) .impulse-container:has(section.impulse-frosted) {
    perspective: none !important;
    transform-style: flat !important;
}
</style>
</html>
<?php endif; // end skip HTML for render-element ?>
<?php
        // ── Render-element dispatch ──────────────────────────────────────────
        if (!empty($GLOBALS['impulse_render_element_id'])) {
            $elId2 = intval($GLOBALS['impulse_render_element_id']);
            $found2 = null;
            foreach ($elements as $el2) {
                if (intval($el2->id) === $elId2) { $found2 = $el2; break; }
            }

            ob_clean();
            header('Content-Type: application/json');
            header('Cache-Control: no-store');
            if (!$found2) { echo wp_json_encode(['error' => 'Element not found']); exit; }
            ob_start();
            impulse_render_element($found2);
            $html2 = ob_get_clean();
            // Per-element CSS (divider keyframes like electric/neon, hover rules) registered via
            // wp_add_inline_style during the render lives on the page-inline handle, which this
            // single-element JSON response would otherwise drop — so the AJAX-replaced node would
            // lose its animation and go static. Re-attach it inline (restores pre-enqueue-conversion
            // behaviour, when this CSS was echoed inside the element HTML). v2.1.1992.
            $_perElCss = '';
            if (isset($GLOBALS['__instinctor_styles']['instinctor-page-inline']['inline'])) {
                $_perElCss = (string) $GLOBALS['__instinctor_styles']['instinctor-page-inline']['inline'];
            } elseif (function_exists('wp_styles')) {
                $_after = wp_styles()->get_data('instinctor-page-inline', 'after');
                if (is_array($_after)) { $_perElCss = implode("\n", array_filter($_after)); }
            }
            if ($_perElCss !== '') { $html2 .= '<style>' . $_perElCss . '</style>'; }
            echo wp_json_encode(['html' => $html2, 'id' => $found2->id, 'type' => $found2->section_type, 'parentId' => $found2->parent_id]);
            while (ob_get_level() > 0) { ob_end_flush(); }
            exit;
        }
        // WP.org: never leave the page output buffer (opened at the render
        // endpoint ~line 4417) open when the request ends — flush it explicitly
        // rather than relying on PHP's shutdown flush, so no buffer can cross
        // into another component's output flow.
        while (ob_get_level() > 0) { ob_end_flush(); }
        exit;
    };
    if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
        $impulse_preview_callback();
    } else {
        add_action('parse_request', $impulse_preview_callback, 1);
    }
}
