<?php
declare(strict_types=1);

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

/**
 * Section element renderer (shared between frontend and editor).
 *
 * Phase 1, step 10 — final and LARGEST structural-container extraction.
 * Pre-extraction: 688 frontend lines + 448 editor lines = the biggest
 * renderer divergence in the codebase.
 *
 * Section is the page's top-level container. It renders:
 *   - Solid bg_color + optional mesh gradient (1–4 colour swatches,
 *     animated drift on coprime periods, optional SVG-noise grain)
 *   - bg_image / bg_video (autoplay-mobile-fallback)
 *   - 4-sided padding (with auto-scale device defaults on frontend)
 *   - 4-sided border + box-shadow + border-radius
 *   - Section-level FX: tilt + ambient motion (Pro-gated on frontend)
 *   - Overlay (solid alpha) + gradient overlay (vignette/glow/linear/frame)
 *   - Animation layers + bg shapes
 *   - Inner flex .impulse-container (direction, valign, content_align,
 *     content_width, item_space, full_width)
 *   - Recursive children render
 *
 * Pre-extraction differences and resolutions:
 *
 *   - **Editor empty-section tint**: editor renders a barely-visible
 *     tint on truly-empty sections so the user can find them. The
 *     "isTrulyEmpty" check is set up in the outer render function.
 *     → Preserved $isEditor branch.
 *
 *   - **Auto-scale padding**: frontend halves desktop padding values
 *     for tablet/mobile when no per-device override exists (so a
 *     130px desktop padding shrinks to ~80px tablet / ~65px mobile).
 *     Editor reads actual saved values directly per device tab.
 *     → Preserved frontend-only emission via @media.
 *
 *   - **Per-screen environment data attrs**: frontend emits
 *     `data-env-*` for all the screen_3d / screen_lava / screen_wave
 *     overrides so the live scene-mode runtime can pick them up.
 *     Editor pre-extraction did NOT emit these — meaning the editor
 *     preview of a custom-environment scene didn't match what the
 *     live page would show. This was a real bug.
 *     → Unified to ALWAYS emit. Editor preview now matches frontend.
 *
 *   - **Gradient overlay emit-once**: both renderers maintain a
 *     singleton flag (impulse_grad_overlay_emitted vs
 *     impulse_grad_overlay_emitted_preview) so only the first
 *     section in a scene-mode depth_image page emits the overlay.
 *     → Unified flag name to impulse_grad_overlay_emitted; editor
 *     uses the same flag (it never collides with frontend because
 *     the two renderers don't run in the same request).
 *
 *   - **bg_video**: frontend gates the autoplay + fallback elements
 *     to within `overlayOpacity > 0` branch (legacy quirk —
 *     pre-extraction it was nested there); now they emit any time
 *     bg_video is set, regardless of overlay state. Editor was
 *     already emitting unconditionally. Unified.
 *
 *   - **screen-mode root special handling**: section is the scene
 *     root when `parentId === 0` AND `impulse_screen_mode === true`.
 *     Both renderers honour the same flag and apply justify-content
 *     on the section itself (so 9-dot alignment works even when the
 *     inner container flex:1's). The frontend additionally uses
 *     `safe ` prefix to prevent overflow leak; editor matches.
 *     → Preserved.
 *
 *   - **min_height**: stored as vh (0..100). height_mode='vh' (or
 *     legacy empty + min_height > 0) emits min-height:Nvh. Editor
 *     was emitting min_height as px in some branches — fixed in
 *     2.x. Both now match.
 *
 *   - **INSTINCTOR_PREVIEW_CONTEXT wrap**: frontend wraps the section
 *     in `<div class="impulse-el" data-id="...">` when running in
 *     the editor iframe context (preview mode). Editor's preview
 *     already runs inside that wrap. Preserved frontend behaviour.
 *
 *   - **Editor visibility badge**: editor renders impulse_visibility_badge
 *     (a small overlay showing hide-mobile/hide-tablet status).
 *     → Gated on $isEditor.
 *
 *   - **Hidden dropzone**: editor emits a drag target div at the
 *     end so empty sections show a dropzone during drag-active.
 *     → Editor-only.
 *
 *   - **Visibility classes**: frontend emits hide-* classes; editor
 *     doesn't (responsive preview already filters).
 *     → Frontend-only.
 *
 *   - **CSS variables for live preview**: editor wraps padding +
 *     bg-color in var() for slider live-update. Frontend emits
 *     literals.
 *     → Preserved per-context.
 *
 *   - **section_fx gate** (instinctor_locked): frontend Pro-gates section
 *     tilt/ambient; editor always emits (preview is for the author,
 *     not a visitor, so it's fine to show what the upgraded tier
 *     would look like).
 *     → Preserved per-context.
 *
 *   - **Mesh BG flag**: priming `impulse_needs_mesh` causes the page
 *     loader to enqueue `assets/css/impulse-mesh.css`. Wrapper +
 *     blob divs are emitted inline; per-instance colour/speed/grain
 *     ride on CSS variables on the wrapper.
 *     → Single-context (renderer is unified).
 *
 *   - **Children traversal**: caller passes the pre-collected
 *     children array (mixed array/object shape, normalised at call
 *     site by each renderer) along with a $renderChild callable for
 *     recursion. Same pattern as rows/grid/columns.
 *
 * @param array|object $el           Element node.
 * @param array        $settings     Raw or pre-resolved settings.
 * @param array        $children     Pre-collected children array.
 * @param string       $context      'frontend' or 'editor'.
 * @param callable     $renderChild  Recursion callable.
 *
 * @return string Inner HTML.
 *
 * @sideeffect Primes a half-dozen GLOBALS (impulse_grad_overlay_emitted,
 *             impulse_current_section_bg, impulse_parent_bg,
 *             impulse_needs_animations, impulse_needs_section_fx,
 *             impulse_needs_bg_grad_mouse / preview variant); calls
 *             wp_add_inline_style for responsive @media; in frontend
 *             context may break early (return '') if visibility check
 *             fails or section is truly empty.
 */
function impulse_element_render_section($el, array $settings, array $children, string $context, callable $renderChild): string {
    if (is_object($el)) $el = (array) $el;
    $elId     = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
    $parentId = intval($el['parent_id'] ?? 0);
    $isEditor = ($context === 'editor');
    $device   = $isEditor ? ($GLOBALS['impulse_preview_device'] ?? 'desktop') : 'desktop';

    // ── Visibility check (frontend only) ─────────────────────────
    if (!$isEditor && function_exists('impulse_check_visibility') && !impulse_check_visibility($settings)) {
        return '';
    }

    // Cinematic "bare" mode (classic<->cinematic toggle): in cinematic mode every
    // section floats transparent over the cinematic backdrop. Render-time ONLY —
    // strips the visible bg layers (colour / mesh / image / video / shapes /
    // animation) from a LOCAL copy of $settings, never the stored data, so a switch
    // back to classic restores everything for free. The 3D depth bg is kept: the
    // hero's imagery lives there. Gated by an explicit page flag so no other
    // cinematic page (the marketing site) is affected.
    if ((($GLOBALS['impulse_body_scroll'] ?? '') !== 'classic')
        && !empty($GLOBALS['impulse_page_settings']['cinematic_bare'])) {
        foreach (['bg_color', 'bg_color_2', 'bg_color_3', 'bg_color_4',
                  'mesh_base_color', 'mesh_base_color_tablet', 'mesh_base_color_mobile',
                  'bg_image', 'bg_image_tablet', 'bg_image_mobile',
                  'bg_video', 'bg_shapes',
                  'bg_animation_enabled', 'bg_animation_enabled_tablet', 'bg_animation_enabled_mobile'] as $__bareKey) {
            unset($settings[$__bareKey]);
        }
        $settings['bg_transparent'] = 1;
    }

    // Truly empty detection — used by editor for tint, by frontend
    // to skip render when section has zero content + no background.
    $sectionBgColorRaw = (string) ($settings['bg_color'] ?? '');
    if ($sectionBgColorRaw === '#f5f5f5') $sectionBgColorRaw = ''; // editor placeholder default
    $hasBgColor   = $sectionBgColorRaw !== '' && $sectionBgColorRaw !== 'transparent';
    $hasBgImage   = !empty($settings['bg_image']);
    $hasAnimation = !empty($settings['bg_animation_enabled'])
                    || !empty($settings['bg_animation_enabled_tablet'])
                    || !empty($settings['bg_animation_enabled_mobile']);
    if ($hasAnimation) $GLOBALS['impulse_needs_animations'] = true;
    $isTrulyEmpty = empty($children) && !$hasBgColor && !$hasBgImage && !$hasAnimation;
    // A "root screen" is a top-level section. Every page is a sequence of
    // screens, so any parentless section IS a screen (the legacy non-screen
    // page mode no longer exists).
    $isRootScreen = !$parentId;

    // Classic pages give top-level sections FREE (content) height: only cinematic /
    // snap screens are forced to a centered 100dvh. A classic section with no manual
    // height must sit at its natural top so padding_top actually controls the top
    // offset (otherwise the section is a centered 100dvh screen and padding_top=0
    // still floats the content in the middle). Completes the wrapper-level classic-
    // free fix in impulse-page.php; the body_scroll mode is published to
    // $GLOBALS['impulse_body_scroll'] by both the frontend template and the editor
    // preview before elements render. Sergei 2026-07-02.
    $_secClassicFree = $isRootScreen
        && (($GLOBALS['impulse_body_scroll'] ?? '') === 'classic')
        && (int) ($settings['screen_min_height_vh'] ?? 0) <= 0
        && (int) ($settings['min_height'] ?? 0) <= 0
        && in_array((string) ($settings['height_mode'] ?? 'auto'), ['', 'auto'], true);

    // Frontend: skip truly-empty sections (don't paint anything).
    if (!$isEditor && $isTrulyEmpty && !defined('INSTINCTOR_PREVIEW_CONTEXT')) {
        return '';
    }

    // ── Section id + base settings ────────────────────────────────
    $customIdClasses = function_exists('impulse_get_custom_id_classes')
        ? impulse_get_custom_id_classes($settings)
        : ['id' => '', 'classes' => ''];
    $sectionId = !empty($customIdClasses['id']) ? $customIdClasses['id'] : ('cs-' . $elId);

    $bgColor    = (string) ($settings['bg_color'] ?? 'transparent');
    if ($bgColor === '#f5f5f5') $bgColor = 'transparent';

    // bg_transparent toggle (editor-canonical setting): user's explicit
    // "no background" choice — overrides everything.
    if (!empty($settings['bg_transparent'])) $bgColor = 'transparent';

    // Effective bg for child auto-contrast machinery.
    $effectiveSectionBg = ($bgColor === 'transparent' || $bgColor === '')
        ? ($GLOBALS['impulse_page_bg_color'] ?? '#ffffff')
        : $bgColor;
    $GLOBALS['impulse_current_section_bg'] = $effectiveSectionBg;

    // Editor-only: barely-visible tint on truly-empty sections so the
    // user can locate them in the builder. Adaptive light/dark.
    $editorBgColor = $bgColor;
    if ($isEditor && $isTrulyEmpty && !$isRootScreen) {
        $pgBg = (string) ($GLOBALS['impulse_page_bg_color'] ?? '#ffffff');
        $pgR = hexdec(substr($pgBg, 1, 2)); $pgG = hexdec(substr($pgBg, 3, 2)); $pgB = hexdec(substr($pgBg, 5, 2));
        $pgLum = ($pgR * 0.299 + $pgG * 0.587 + $pgB * 0.114);
        $editorBgColor = ($pgLum < 128) ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.025)';
    }

    // Padding defaults: 20 on frontend; editor uses a $rootPadDefault
    // passed in via globals (or 20 fallback).
    $rootPadDefault = $isEditor ? ($GLOBALS['impulse_root_pad_default'] ?? 20) : 20;
    $paddingTop    = intval($settings['padding_top']    ?? $rootPadDefault);
    $paddingBottom = intval($settings['padding_bottom'] ?? $rootPadDefault);
    $paddingLeft   = intval($settings['padding_left']   ?? $rootPadDefault);
    $paddingRight  = intval($settings['padding_right']  ?? $rootPadDefault);
    // Mobile rule (Sergei 2026-07-02): sections default to 10px padding on all
    // sides on mobile, still overridable by an explicit _mobile/_tablet value
    // (default, not !important). Editor mobile preview handled here; the
    // frontend applies the same via the @media block below.
    if ($isEditor && ($GLOBALS['impulse_preview_device'] ?? 'desktop') === 'mobile') {
        $_mobPad = function ($k) use ($settings) {
            foreach ([$k . '_mobile', $k . '_tablet'] as $ov) {
                if (isset($settings[$ov]) && $settings[$ov] !== '') return intval($settings[$ov]);
            }
            return 10;
        };
        $paddingTop    = $_mobPad('padding_top');
        $paddingBottom = $_mobPad('padding_bottom');
        $paddingLeft   = $_mobPad('padding_left');
        $paddingRight  = $_mobPad('padding_right');
    }
    $contentWidth  = max(1, intval($settings['content_width'] ?? 100));
    $minHeight     = intval($settings['min_height'] ?? ($isEditor && !$parentId ? 400 : 0));
    $valign        = (string) ($settings['valign'] ?? '');
    if ($valign === '') {
        // Vertical-alignment default is MODE-AWARE. On classic (content-flow)
        // pages content sits at the TOP like a normal document; on cinematic
        // full-viewport screens it centers. It used to default to 'center'
        // everywhere, which vertically centered content on scrolling classic
        // pages -- wrong, and compounded by the panel leaking 'center' into
        // every section's saved data. Sergei 2026-07-08.
        $valign = (($GLOBALS['impulse_body_scroll'] ?? '') === 'classic') ? 'top' : 'center';
    }
    $direction     = (string) ($settings['direction'] ?? 'column');
    $borderRadius  = intval($settings['border_radius'] ?? 0);
    /* Per-device bg_image. Editor preview reads via impulse_device_val()
       so the inline style reflects the active device tab (the iframe
       reloads with ?device=mobile / ?device=tablet, $device global is
       seeded from $_GET[device]). Frontend keeps emitting the desktop
       URL inline and adds @media-wrapped overrides below — see the
       "Per-device bg_image @media" block further down. Mobile fallback
       chain: mobile → tablet → desktop (impulse_device_val's logic).
       Locked 2026-05-27. */
    $bgImage = $isEditor && function_exists('impulse_device_val')
        ? (string) impulse_device_val($settings, 'bg_image', $device, '')
        : (string) ($settings['bg_image'] ?? '');
    $bgImageTablet = (string) ($settings['bg_image_tablet'] ?? '');
    $bgImageMobile = (string) ($settings['bg_image_mobile'] ?? '');
    $bgSize        = (string) ($settings['bg_size'] ?? 'cover');
    $bgFixed       = !empty($settings['bg_fixed']);
    $bgPosition    = $isEditor
        ? (string) impulse_device_val($settings, 'bg_position', $device, '50% 50%')
        : (string) ($settings['bg_position'] ?? '50% 50%');
    $borderWidth   = intval($settings['border_width'] ?? 0);
    $shadow        = (string) ($settings['shadow'] ?? 'none');

    // Overlay / shapes / gradient overlay.
    $overlayColor   = (string) ($settings['overlay_color'] ?? '#000000');
    $overlayOpacity = ($settings['overlay_opacity'] ?? 0) / 100;
    $hasOverlay     = $overlayOpacity > 0;
    $hasShapes      = !empty($settings['bg_shapes']) && $settings['bg_shapes'] !== '[]';

    $pageSettings = $GLOBALS['impulse_page_settings'] ?? [];
    $gradOverlayPageActive = in_array(($pageSettings['screen_cloud_effect'] ?? ''), ['depth_image', 'glass'], true)
        && !empty($pageSettings['screen_gradient_shape']);
    // A colour is "alive" for the per-section gradient overlay (slider model,
    // v2.1.272) when it isn't empty/transparent/zero-alpha. Shared by the
    // layer-detection below AND the emission further down so the two never
    // disagree — see the bug this fixes: when only the overlay COLOURS are set
    // (no legacy `gradient_overlay_shape`), $hasGradientOverlay used to stay
    // false, $needsLayers stayed false, the container never got z-index:4 +
    // isolation, and the emitted z:2 overlay painted OVER a static container,
    // tinting the section's own content. (v2.1.1561)
    $goColorAlive = static function ($c): bool {
        $c = (string) $c;
        if ($c === '' || $c === 'transparent') return false;
        if (strpos($c, 'rgba(0,0,0,0)') === 0) return false;
        if (preg_match('/rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*([\d.]+))?\s*\)/i', $c, $m)) {
            if (isset($m[1]) && $m[1] !== '') return ((float) $m[1]) > 0.02;
        }
        return true;
    };
    $hasGradientOverlayColors = false;
    foreach (['gradient_overlay_color1', 'gradient_overlay_color2',
              'gradient_overlay_color1_tablet', 'gradient_overlay_color2_tablet',
              'gradient_overlay_color1_mobile', 'gradient_overlay_color2_mobile'] as $_gck) {
        if (isset($settings[$_gck]) && $goColorAlive($settings[$_gck])) { $hasGradientOverlayColors = true; break; }
    }
    $hasGradientOverlay = $gradOverlayPageActive || !empty($settings['gradient_overlay_shape']) || $hasGradientOverlayColors;
    // Overlay reveal FX (3D Reveal) — EVERY section now (Sergei 2026-06-25), same as
    // Tilt/Ambient above. On a cinematic snap root there's no scroll travel to drive
    // it (so it won't fire there); on classic/native scroll it fires normally.
    // Un-gated from !$isRootScreen so top-level sections get the reveal back.
    $ovfxValid = ['noise', 'ripple', 'liquid', 'shatter', 'static', 'melt',
                  'focus', 'tumble', 'tunnel', 'wave', 'bars', 'cards',
                  'pop', 'fly', 'rise', 'converge'];
    $hasOverlayFx = (in_array((string) ($settings['ovfx'] ?? ''), $ovfxValid, true)
            || in_array((string) ($settings['ovfx_tablet'] ?? ''), $ovfxValid, true)
            || in_array((string) ($settings['ovfx_mobile'] ?? ''), $ovfxValid, true));

    // ── Cursor parallax tilt (3D feel without depth map) ──
    // Per-device sensitivity 0..50. When > 0 AND a bg image is set, the
    // image is lifted out of the section's CSS background into its own
    // .impulse-section-bg-image-parallax layer so impulse-bg-parallax.js
    // can rotateX/Y the layer on mousemove without tilting the section
    // content. Mutually exclusive with the depth-map engine — if the
    // user picks a 3D image (depth pair), the depth shader takes over
    // and parallax tilt is skipped on the JS side.
    $bgParallaxSens         = intval($settings['bg_parallax_sens'] ?? 0);
    $bgParallaxSensTablet   = isset($settings['bg_parallax_sens_tablet']) && $settings['bg_parallax_sens_tablet'] !== ''
        ? intval($settings['bg_parallax_sens_tablet'])
        : null;
    $bgParallaxSensMobile   = isset($settings['bg_parallax_sens_mobile']) && $settings['bg_parallax_sens_mobile'] !== ''
        ? intval($settings['bg_parallax_sens_mobile'])
        : null;
    // Effective sensitivity on the active device (used by editor preview).
    $bgParallaxSensActive = $bgParallaxSens;
    if ($isEditor && $device === 'tablet' && $bgParallaxSensTablet !== null) $bgParallaxSensActive = $bgParallaxSensTablet;
    if ($isEditor && $device === 'mobile') {
        if ($bgParallaxSensMobile !== null)       $bgParallaxSensActive = $bgParallaxSensMobile;
        elseif ($bgParallaxSensTablet !== null)   $bgParallaxSensActive = $bgParallaxSensTablet;
    }
    // ── 3D depth-mapped bg (cinematic; expensive — one WebGL context
    //    per section). When both depth image + depth map are set, the
    //    section becomes a depth-parallax host: ImpulseDepthParallax
    //    loads both textures, mounts a canvas inside the section, and
    //    drives per-pixel UV shift on mousemove. The regular bg_image
    //    and parallax tilt are suppressed — depth shader is the only
    //    bg layer.
    $bgDepthImage    = (string) ($settings['bg_depth_image'] ?? '');
    $bgDepthMap      = (string) ($settings['bg_depth_map']   ?? '');
    $bgDepthStrength = isset($settings['bg_depth_strength']) ? max(0, min(100, intval($settings['bg_depth_strength']))) : 50;
    $bgHasDepth      = ($bgDepthImage !== '' && $bgDepthMap !== '');
    if ($bgHasDepth) {
        $GLOBALS['impulse_needs_depth_parallax'] = true;
    }
    // The CSS cursor-tilt parallax is retired (Sergei 2026-06-25). The only
    // cinematic motion on a section/screen bg image is the depth shader (the
    // 3D-depth slider). A bg image is FLAT until that engine engages — it must
    // never tilt, regardless of any legacy bg_parallax_sens left in the DB.
    $bgIsParallax = false;
    if ($bgIsParallax) $GLOBALS['impulse_needs_bg_parallax'] = true;

    // A rendered text wall (root + classic scroll, non-empty line) needs the
    // content container to own a stacking context so the wall's absolute layer
    // sits BEHIND content -- force $needsLayers so the container gets z-index:4
    // + isolation. Without this a wall-only section left the container
    // un-positioned and the z-indexed wall painted OVER the content. v2.1.2167.
    // tw_on = explicit show/hide switch. Absent (older saves) defaults ON so a
    // wall with text keeps showing; only an explicit false hides it.
    $twOn = !array_key_exists('tw_on', $settings) || filter_var($settings['tw_on'], FILTER_VALIDATE_BOOLEAN);
    $hasTextWall = $twOn
        && trim((string) ($settings['tw_text'] ?? '')) !== ''
        && (($GLOBALS['impulse_body_scroll'] ?? '') === 'classic');

    $needsLayers = $hasBgImage || $hasOverlay || $hasAnimation || $hasShapes || $hasGradientOverlay || $hasOverlayFx || $bgIsParallax || $bgHasDepth || $hasTextWall;

    // ── Mesh BG (multi-color gradient, GPU-cheap) ────────────────────
    // Replaces the old `bg_gradient_to` / `bg_gradient_angle` /
    // `bg_mouse_sens` linear-gradient (ripped 2026-05-17, v2.1.270).
    // Activation: any of `bg_color_2/3/4` (per-device-resolved) is
    // non-empty → mesh emission kicks in via the layer DOM below.
    // The section root only carries `bg_color` here; mesh blobs paint
    // on top of it via absolutely-positioned layer divs.
    $dvRead = function (string $key, $default) use ($settings, $device) {
        if (function_exists('impulse_device_val')) {
            return impulse_device_val($settings, $key, $device, $default);
        }
        return $settings[$key] ?? $default;
    };
    $meshClean = static function (string $c): string {
        $c = trim($c);
        if ($c === '' || $c === 'transparent' || strpos($c, 'rgba(0,0,0,0)') === 0) return '';
        return $c;
    };
    // Per-slot defaults for blob position (% of mesh box). When the user
    // hasn't dragged a handle for a slot, the blob lands close to one
    // of the four corner anchors below — slot 1 top-left, slot 2
    // top-right, slot 3 bottom-left, slot 4 bottom-right. 10/90
    // instead of 25/75 puts the handle visibly near the section edge
    // (with a small margin so the handle dot isn't clipped). When
    // outer padding is active, the handles container is also inset
    // by the same padding (CSS), so handle positions remain a % of
    // the mesh box — visually the handles move inward as the user
    // grows the padding.
    $slotKeys = ['bg_color', 'bg_color_2', 'bg_color_3', 'bg_color_4'];
    $slotDefaults = [
        ['x' => 10, 'y' => 10],
        ['x' => 90, 'y' => 10],
        ['x' => 10, 'y' => 90],
        ['x' => 90, 'y' => 90],
    ];
    $slots = [];
    foreach ($slotKeys as $idx => $sk) {
        $raw = (string) $dvRead($sk, '');
        // slot 1 also accepts the canonical $bgColor fallback (used
        // pre-mesh; preserves continuity for old data without a real
        // bg_color save).
        if ($idx === 0 && $raw === '') $raw = $bgColor;
        $clean = $meshClean($raw);
        if ($clean === '') continue;
        $bx = max(0, min(100, intval($dvRead($sk . '_x', $slotDefaults[$idx]['x']))));
        $by = max(0, min(100, intval($dvRead($sk . '_y', $slotDefaults[$idx]['y']))));
        $slots[] = ['color' => $clean, 'slot' => $idx + 1, 'bx' => $bx, 'by' => $by];
    }
    $meshCount  = count($slots);
    $meshColors = array_map(static function ($s) { return $s['color']; }, $slots);
    $hasMesh = ($meshCount >= 2);
    if ($hasMesh) $GLOBALS['impulse_needs_mesh'] = true;
    // Speed is an abstract 0-10 scale where 10 = fastest motion and 0 =
    // static. The stored value maps to a seconds period via the formula
    // below — chosen so 30s (visibly brisk drift on a section-sized
    // canvas) lands at speed=10, ~75s at speed=7 (default, matches the
    // legacy 70s feel), and 165s at speed=1 (barely-perceptible drift).
    // Speed -> drift-cycle seconds. The old 30 + (10-N)*15 map (30-165s) was
    // imperceptibly slow: a ~16% background-position shift spread over 30-165s
    // reads as no motion at all, so the slider felt dead. The drift magnitude
    // is gentle (not the large sweeps that once "flickered"), so a much shorter
    // cycle glides smoothly rather than strobing. New map 4 + (10-N)*2 -> 4s
    // (brisk) .. 22s (subtle), default N=7 -> 10s. N=0 stays fully static.
    // v2.1.1956.
    $meshSpeedLevel = max(0, min(10, intval($dvRead('bg_mesh_speed', 7))));
    $meshSpeed   = $meshSpeedLevel > 0 ? (4 + (10 - $meshSpeedLevel) * 2) : 0;
    /* Mesh alpha (0-100, default 100). Below 100 lets the env effect
       behind the section (cosmos / lava / aurora / etc. on hero
       screens) bleed through. The base colour fades with the gradients
       since it's the bottom layer of the same composited background.
       Named `_alpha` not `_opacity` so the value isn't accidentally
       dropped by collectSettingsFromPanel's color-picker alpha skip
       rule (admin-core.js line 8988 — that rule's `.closest()` check
       can match in unexpected layout contexts). */
    $meshOpacity = max(0, min(100, intval($dvRead('bg_mesh_alpha', 100))));
    $meshGrain   = max(0, min(10,  intval($dvRead('bg_mesh_grain',    5))));
    /* Designer-controlled base colour under the four radial-gradient
       slots. Defaults to a deep neutral so arbitrary 4-color palettes
       have somewhere flattering to settle. The previous version painted
       on a transparent base; with thin alpha-fade gradients, that left
       large washed-out areas dominated by the page background. The
       dark base ensures every slot reads as a focal "mood light"
       against a consistent canvas. */
    $meshBaseColor = trim((string) $dvRead('mesh_base_color', ''));
    if ($meshBaseColor === '' || $meshBaseColor === 'transparent') {
        // No explicit Base colour. If swatch 1 (bg_color) is an OPAQUE solid the
        // designer clearly wants it as the background -- use it as the mesh base
        // (WYSIWYG) instead of the near-black cinematic default. A transparent /
        // empty / low-alpha first swatch keeps the flattering dark default so
        // translucent multi-colour palettes still settle on a dark canvas. Fixes
        // the "set base white + transparent gradients = obvious black underneath"
        // trap (the first swatch is blob-1, not the base). Sergei 2026-07-08, v2.1.2169.
        $_isOpaque = static function (string $c): bool {
            $c = trim($c);
            if ($c === '' || $c === 'transparent') return false;
            if (preg_match('/^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/', $c, $m)) {
                return !isset($m[2]) || $m[2] === '' || hexdec($m[2]) >= 242; // 242/255 ~ 0.95
            }
            if (preg_match('/^#[0-9a-fA-F]{3}$/', $c)) return true;
            if (preg_match('/rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*([\d.]+))?\s*\)/i', $c, $m)) {
                return !isset($m[1]) || $m[1] === '' || ((float) $m[1]) >= 0.95;
            }
            return true; // named colours etc. -> treat as opaque
        };
        $_slot1 = trim((string) $dvRead('bg_color', ''));
        $meshBaseColor = $_isOpaque($_slot1) ? $_slot1 : '#0a0c12';
    }
    /* Legacy distortion / ragged / mouse-sens / outer-pad settings
       are intentionally NOT read any more. The SVG feDisplacementMap
       path retired with the v2.2 mesh rewrite — saved values stay in
       the DB but are silently ignored, so existing pages just look
       cleaner without breaking. The settings are removed from the
       admin panel in the same release. */
    $sectionExtraVars = '';

    // ── Section style ────────────────────────────────────────────
    // Section bg policy:
    //   - Mesh active → transparent. The mesh's base colour (designer-
    //     controlled, defaults #0a0c12) handles the fill via the
    //     bottom layer of its background stack, so the section itself
    //     doesn't need to paint a swatch.
    //   - No mesh, mesh_base_color set → use mesh_base_color as the
    //     section's solid background. The base swatch in the panel
    //     becomes a universal "section colour" setting — designers
    //     pick it once, it kicks in whether mesh is active or not.
    //   - No mesh, no mesh_base_color → swatch 1 (--impulse-bg-color)
    //     as the legacy fallback. Keeps existing pages unchanged.
    $bgTransparentForRagged = $hasMesh;
    /* Base-colour fallback. When the gradient mesh is NOT active and
       swatch 1 (bg_color) is empty/transparent, but the designer set an
       explicit Base colour, paint the section with that base colour. So
       "Base colour" doubles as the section's solid background whenever
       there's no gradient — which is what designers expect when they set
       it. Applied to BOTH the editor preview AND the live page (the old
       code only touched the editor, and was gated backwards on
       NON-transparent bg_color, so it never fired for the common
       transparent-swatch case — that's why screen backgrounds looked
       ignored). An empty/transparent base leaves the section transparent,
       so page-level env (cosmos/lava/etc.) still shows through. */
    $rawBaseColor = (string) $dvRead('mesh_base_color', '');
    $baseIsSet = ($rawBaseColor !== '' && $rawBaseColor !== 'transparent' && strpos($rawBaseColor, 'rgba(0,0,0,0)') !== 0);
    $bgIsTransparent = ($bgColor === 'transparent' || $bgColor === 'rgba(0,0,0,0)' || $bgColor === '');
    // Base colour is the AUTHORITATIVE solid fill: when the designer sets it, it wins
    // over swatch 1 (gradient-blob-1, only meaningful for a mesh). Swatch 1 stays the
    // fallback ONLY when no Base is set, so old pages keep their bg (no migration).
    // Was gated on $bgIsTransparent -- so any colour in swatch 1 beat the Base, which
    // is exactly "first gradient colour is the solid bg". Sergei 2026-07-09.
    if (!$hasMesh && $baseIsSet) {
        $bgColor       = $rawBaseColor; /* frontend branch reads $bgColor */
        $editorBgColor = $rawBaseColor; /* editor branch reads $editorBgColor */
        $GLOBALS['impulse_current_section_bg'] = $rawBaseColor; /* child auto-contrast */
    }
    if ($isEditor) {
        // CSS var bg-color for live preview + literal fallback.
        $style  = '--impulse-bg-color:' . esc_attr($editorBgColor) . ';';
        $style .= $sectionExtraVars;
        $style .= $bgTransparentForRagged
            ? 'background-color:transparent;'
            : 'background-color:var(--impulse-bg-color,' . esc_attr($editorBgColor) . ');';
        // Padding-top reserves space for the floating header on root
        // SCREENS so content never disappears under it. The JS that
        // measures the rendered header height publishes
        // `--impulse-header-height` on <html>; when no header exists or
        // chrome is hidden on this screen, the var is 0 and the calc
        // collapses to the panel's own padding. v2.1.1332.
        // Gated to root screens in v2.1.1336 — nested sections (e.g.
        // Wrap-in-Section containers) inherited the offset and ended up
        // with ~96px of top padding (header ~76px + their own 20px)
        // out of nowhere.
        // The header-height offset keeps a screen's content clear of the fixed header
        // that floats over it. That applies to snap screens (each is a 100dvh panel the
        // header overlays) and to the hero. A classic-free body section flows in a
        // normal document and is NOT a fixed-header panel, so adding the header height
        // just bakes an ~80px gap above its content that padding_top=0 can't remove.
        // Skip the offset for classic-free sections. Sergei 2026-07-02.
        $_hdrOffset = ($isRootScreen && !$_secClassicFree) ? ' + var(--impulse-header-height,0px)' : '';
        $style .= 'padding-top:calc(var(--impulse-padding-top,' . $paddingTop . 'px)' . $_hdrOffset . ');';
        $style .= 'padding-bottom:var(--impulse-padding-bottom,' . $paddingBottom . 'px);';
        $style .= 'padding-left:var(--impulse-padding-left,'     . $paddingLeft   . 'px);';
        $style .= 'padding-right:var(--impulse-padding-right,'   . $paddingRight  . 'px);';
        // Empty-section layout collapse. The frontend already skips
        // truly-empty sections (line 158), but the editor renders them
        // with a faint tint so they're selectable. Without this collapse
        // the placeholder takes real flex-layout space between siblings,
        // breaking the parent screen's item_space gap (the user reports
        // "moving Content gap only pushes one item"). Override min-height
        // / padding / flex so the empty section has effectively zero
        // layout footprint — still in the DOM, still selectable via the
        // layers panel, but no longer eats space the gap should fill.
        // Sergei 2026-06-01.
        if ($isTrulyEmpty && !$isRootScreen) {
            $style .= 'min-height:0;padding:0;flex:0 0 0;';
        }
    } else {
        $style = $bgTransparentForRagged
            ? 'background-color:transparent;'
            : 'background-color:' . esc_attr($bgColor) . ';';
        $style .= $sectionExtraVars;
        // Header offset via calc — see editor branch above. v2.1.1332,
        // root-only since v2.1.1336.
        // The header-height offset keeps a screen's content clear of the fixed header
        // that floats over it. That applies to snap screens (each is a 100dvh panel the
        // header overlays) and to the hero. A classic-free body section flows in a
        // normal document and is NOT a fixed-header panel, so adding the header height
        // just bakes an ~80px gap above its content that padding_top=0 can't remove.
        // Skip the offset for classic-free sections. Sergei 2026-07-02.
        $_hdrOffset = ($isRootScreen && !$_secClassicFree) ? ' + var(--impulse-header-height,0px)' : '';
        $style .= 'padding-top:calc(' . $paddingTop . 'px' . $_hdrOffset . ');';
        $style .= 'padding-bottom:' . $paddingBottom . 'px;';
        $style .= 'padding-left:'   . $paddingLeft   . 'px;';
        $style .= 'padding-right:'  . $paddingRight  . 'px;';
    }

    // ── Frontend: build responsive @media for section + container ──
    $tabletStyles = []; $mobileStyles = [];
    $containerTablet = []; $containerMobile = [];
    if (!$isEditor) {
        foreach (['padding_top', 'padding_bottom', 'padding_left', 'padding_right'] as $pk) {
            $cssK = str_replace('_', '-', $pk);
            if (isset($settings[$pk . '_tablet'])) $tabletStyles[] = $cssK . ':' . intval($settings[$pk . '_tablet']) . 'px';
            // Mobile rule (Sergei): sections default to 10px padding on all sides,
            // still overridable by an explicit _mobile value (default, not !important).
            $mobileStyles[] = $cssK . ':' . (isset($settings[$pk . '_mobile']) && $settings[$pk . '_mobile'] !== '' ? intval($settings[$pk . '_mobile']) : 10) . 'px';
        }
        // Tablet auto-scale when no explicit tablet override (mobile is a flat 10px above).
        $secAutoT = ['padding_top' => 0.80, 'padding_bottom' => 0.80, 'padding_left' => 0.70, 'padding_right' => 0.70];
        foreach ($secAutoT as $pk => $pft) {
            $pBase = isset($settings[$pk]) ? (int) $settings[$pk] : 0;
            if ($pBase <= 0) continue;
            $pCss = str_replace('_', '-', $pk);
            if (!isset($settings[$pk . '_tablet'])) $tabletStyles[] = $pCss . ':' . (int) round($pBase * $pft) . 'px';
        }
        if (isset($settings['min_height_tablet'])) $tabletStyles[] = 'min-height:' . max(0, min(100, intval($settings['min_height_tablet']))) . 'vh';
        if (isset($settings['min_height_mobile'])) $mobileStyles[] = 'min-height:' . max(0, min(100, intval($settings['min_height_mobile']))) . 'vh';
        if (isset($settings['content_width_tablet'])) $containerTablet[] = 'width:' . intval($settings['content_width_tablet']) . '%';
        if (isset($settings['content_width_mobile'])) $containerMobile[] = 'width:' . intval($settings['content_width_mobile']) . '%';
        // All sections collapse to flex-direction:column on mobile.
        $containerMobile[] = 'flex-direction:column';
    }

    // ── Section: flex column + height mode + border + shadow + bg img ──
    $style .= 'display:flex;flex-direction:column;';

    if ($isRootScreen && !$_secClassicFree) {
        $sectJC = [
            'top'     => 'flex-start',
            'center'  => 'safe center',
            'bottom'  => 'safe flex-end',
            'between' => 'space-between',
            'around'  => 'space-around',
            'evenly'  => 'space-evenly',
        ];
        $secJCval = $sectJC[$valign] ?? 'safe center';
        $style .= 'justify-content:' . $secJCval . ' !important;';
    }

    // Height mode (auto / vh / aspect / px). A classic-free section falls through to
    // the content-height branch below (no forced 100dvh), so padding controls the top.
    if ($isRootScreen && !$_secClassicFree) {
        // The screen itself is 100dvh (the `.impulse-screen-wrapper >
        // .impulse-screen` CSS rule). The root section is a flex item of
        // the flex-column .impulse-content-wrapper.
        //
        // `flex:1` GROWS it to fill — but that is only reliable when every
        // link in the parent chain (.impulse-screen → .impulse-page →
        // .impulse-content-wrapper) has a resolved height. A single
        // unresolved link (a race during load, an iframe sized a frame
        // late in an embedded scene, etc.) silently collapses a section
        // whose only floor is `min-height:0` down to content height — the
        // recurring "screen N is a narrow bar" bug.
        //
        // `min-height:100dvh` is the reliable guarantee: it is viewport-
        // relative and parent-independent, so the section is ALWAYS at
        // least one full viewport no matter what the flex chain does.
        // `flex:1` is kept so the section still grows past 100dvh when a
        // screen carries taller-than-viewport content.
        //
        // BUT the section lives inside `.impulse-content-wrapper`, which is
        // `box-sizing:border-box; min-height:calc(100dvh - inset)` PLUS this
        // same section's vertical padding (impulse-page.php applies
        // padding_top/padding_bottom to the wrapper). A flat `100dvh` floor
        // therefore overshoots the wrapper's CONTENT box by exactly that
        // padding, so the wrapper grows past the screen and the
        // internal-overflow-scroll guard (task #235, overflow-y:auto on
        // `.impulse-screen`) exposes the padding as a phantom scrollbar — any
        // screen with non-zero vertical padding scrolls by the padding amount.
        // Subtract the wrapper's padding + bottom inset so the floor equals the
        // wrapper's content box: section fills the screen exactly, never past
        // it. Still viewport-relative (no %-collapse), still grows for
        // genuinely tall content via flex:1. Sergei 2026-06-03.
        if (!$isEditor) {
            // v2.1.1425: subtract the WRAPPER's actual vertical padding via
            // the --impulse-wrapper-pad-y var the wrapper now publishes,
            // instead of guessing from this section's own padding settings.
            // The single-screen render path pads the wrapper from PAGE-level
            // values, so the old guess only matched when section padding
            // happened to equal page padding - a section with padding 0 in a
            // page-padded wrapper kept a full-100dvh floor, overflowed the
            // screen by the wrapper padding (+ header height when a header
            // renders), and task #235's overflow-y:auto exposed it as a
            // phantom up/down scroll on the last screen (aokarate home).
            $style .= 'flex:1;height:100%;min-height:calc(100dvh - var(--impulse-screen-bottom-inset, 0px) - var(--impulse-wrapper-pad-y, 0px));';
        }
    } else {
        $heightMode = (string) ($settings['height_mode'] ?? '');
        if ($heightMode === '' && $minHeight > 0) $heightMode = 'vh';
        if ($heightMode === 'aspect') {
            $ar = (string) ($settings['aspect_ratio'] ?? '16:9');
            if (!preg_match('/^\d+:\d+$/', $ar)) $ar = '16:9';
            $style .= 'aspect-ratio:' . str_replace(':', '/', $ar) . ';';
        } elseif ($heightMode === 'px') {
            $px = max(0, min(2000, intval($settings['height_px'] ?? 0)));
            if ($px > 0) {
                $style .= $isEditor
                    ? 'min-height:var(--impulse-min-height,' . $px . 'px);'
                    : 'min-height:' . $px . 'px;';
            }
        } elseif ($heightMode === 'vh' && $minHeight > 0) {
            $vh = max(0, min(100, intval($minHeight)));
            $style .= $isEditor
                ? 'min-height:var(--impulse-min-height,' . $vh . 'vh);'
                : 'min-height:' . $vh . 'vh;';
        } elseif ($isEditor && $minHeight > 0) {
            // Editor legacy fallback: when no height_mode set + min_height > 0, use px.
            $style .= 'min-height:var(--impulse-min-height,' . $minHeight . 'px);';
        }
    }

    if ($needsLayers || $isEditor) $style .= 'position:relative;';

    // ── Stacking context isolation (Sergei 2026-06-08, v2.1.1313) ──
    // When the section paints a coloured overlay (or any backdrop layer)
    // its z-indexes need to compete inside the section, NOT bubble up to
    // a parent stacking context where some unrelated transform/filter
    // ancestor can flip the order and push the overlay above content.
    //
    // `position:relative` alone is NOT a stacking context — it just
    // establishes a containing block. So .impulse-section-overlay
    // (z:0) and .impulse-container (z:4) sometimes compared at a higher
    // ancestor's context, where unrelated z-indexes on siblings could
    // sort the overlay above the container — visually painting the
    // tint OVER the section's heading/text/blocks.
    //
    // `isolation:isolate` forces the section into its own stacking
    // context with zero side effects on layout, rendering, or pointer
    // behaviour. Overlay vs. container now sort *inside* the section,
    // unambiguously: 4 > 0, content wins. Defensive against future
    // ancestors that introduce filters/transforms.
    if ($needsLayers) $style .= 'isolation:isolate;';

    if ($borderRadius > 0) $style .= 'border-radius:' . $borderRadius . 'px;';

    if ($borderWidth > 0) {
        $borderColor = (string) ($settings['border_color'] ?? '#e0e0e0');
        $btVal = $settings['border_top']    ?? null;
        $brVal = $settings['border_right']  ?? null;
        $bbVal = $settings['border_bottom'] ?? null;
        $blVal = $settings['border_left']   ?? null;
        $bTop    = ($btVal === '0' || $btVal === 0 || $btVal === false) ? '0' : $borderWidth . 'px';
        $bRight  = ($brVal === '0' || $brVal === 0 || $brVal === false) ? '0' : $borderWidth . 'px';
        $bBottom = ($bbVal === '0' || $bbVal === 0 || $bbVal === false) ? '0' : $borderWidth . 'px';
        $bLeft   = ($blVal === '0' || $blVal === 0 || $blVal === false) ? '0' : $borderWidth . 'px';
        $style  .= 'border-style:solid;border-color:' . esc_attr($borderColor) . ';';
        $style  .= 'border-width:' . $bTop . ' ' . $bRight . ' ' . $bBottom . ' ' . $bLeft . ';';
    }

    $shadowStyles = [
        'sm' => '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
        'md' => '0 4px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06)',
        'lg' => '0 10px 15px rgba(0,0,0,0.1), 0 4px 6px rgba(0,0,0,0.05)',
        'xl' => '0 20px 25px rgba(0,0,0,0.1), 0 8px 10px rgba(0,0,0,0.04)',
    ];
    if ($shadow !== 'none' && isset($shadowStyles[$shadow])) $style .= 'box-shadow:' . $shadowStyles[$shadow] . ';';

    // Depth-mapped sections: paint the still color image as the section's
    // CSS background BEFORE the depth canvas mounts. impulse-depth-parallax
    // uses an IntersectionObserver to lazy-init / destroy the WebGL canvas
    // as the user scrolls, so a section can be off-canvas one moment and
    // re-entering the next. The ~150ms texture+GL warm-up shows as a black
    // hole without this fallback. Same image as the depth color source, so
    // when the canvas takes over the visual barely shifts — just becomes
    // reactive. 2.1.864.
    if ($bgHasDepth) {
        $style .= 'background-image:url(' . esc_url($bgDepthImage) . ');';
        $style .= 'background-size:cover;background-position:50% 50%;background-repeat:no-repeat;';
    }

    // When a frame-loop background (bg_frames) is ALSO set, the bg_image is the
    // owner's transparent OVERLAY over the animation (the flame shows through it) —
    // it is emitted as a foreground <img> layer ABOVE the frame loop below, NOT as
    // the section's CSS background (which sits beneath the loop AND is stripped to
    // transparent in screen mode). So skip the CSS-background path here.
    if ($bgImage && !$bgHasDepth && empty($settings['bg_frames'])) {
        if ($bgIsParallax) {
            // Parallax-active: the image is rendered as a child layer
            // (.impulse-section-bg-image-parallax) so impulse-bg-parallax.js
            // can transform it independently of the section content. The
            // section root carries `perspective` so the layer's rotateX/Y
            // reads as real 3D depth instead of a flat skew. Skip the
            // CSS background-image here — emitting both would paint the
            // image twice (the inner layer tilts, the root sits flat
            // underneath).
            //
            // overflow:hidden clips the scaled-up child to section bounds.
            // The engine grows the parallax layer to scale(1.04–1.08) so
            // the corners stay covered during tilt; without the clip the
            // image visibly extends beyond the section (and beyond the
            // overlay, which still sits at scale 1). 2.1.862.
            $style .= 'perspective:1500px;overflow:hidden;';
        } else {
            $style .= 'background-image:url(' . esc_url($bgImage) . ');';
            $style .= 'background-size:' . esc_attr($bgSize) . ';';
            $style .= 'background-position:' . esc_attr($bgPosition) . ';';
            $style .= 'background-repeat:no-repeat;';
            // Fixed background only meaningful on frontend (editor iframe breaks it).
            if (!$isEditor && $bgFixed) $style .= 'background-attachment:fixed;';
        }
    }

    /* Per-device bg_image @media — frontend only. Editor sees the active
       device's image baked into the inline style above (because $bgImage
       was already resolved through impulse_device_val()). Frontend keeps
       the desktop URL inline so first-paint matches the designer intent
       on desktop visitors; tablet and mobile viewports get their own
       @media-wrapped override using the section's stable id.

       Mobile rule emitted AFTER tablet so it wins on the cascade at
       overlapping widths (≤767 satisfies both). When a mobile URL is
       missing, the tablet override still applies on mobile viewports
       (which is the correct fallback for the desktop→tablet→mobile
       chain). When tablet is missing but mobile is set, mobile applies
       on phones and desktop image stays on tablet — also correct.
       Locked 2026-05-27. */
    if (!$isEditor && function_exists('wp_add_inline_style') && ($bgImageTablet !== '' || $bgImageMobile !== '')) {
        $bgImgResp = '';
        if ($bgImageTablet !== '') {
            $bgImgResp .= '@media(max-width:1024px){#' . $sectionId . '{background-image:url(' . esc_url($bgImageTablet) . ')!important;}}';
        }
        if ($bgImageMobile !== '') {
            $bgImgResp .= '@media(max-width:767px){#' . $sectionId . '{background-image:url(' . esc_url($bgImageMobile) . ')!important;}}';
        }
        wp_add_inline_style('instinctor-page-inline', $bgImgResp);
    }

    /* Per-device background-position (section-bg focal) @media — frontend only.
       Editor resolves bg_position per device above (impulse_device_val); the live
       site keeps the desktop focal inline and overrides tablet/mobile here, scoped
       to the section's stable id. Emitted only when a per-device focal is set, so
       existing sections are unchanged. v2.1.1784. */
    if (!$isEditor && function_exists('wp_add_inline_style') && $hasBgImage) {
        $bgPosT = preg_replace('/[^0-9%. ]/', '', (string) ($settings['bg_position_tablet'] ?? ''));
        $bgPosM = preg_replace('/[^0-9%. ]/', '', (string) ($settings['bg_position_mobile'] ?? ''));
        $bgPosResp = '';
        if ($bgPosT !== '') $bgPosResp .= '@media(max-width:1024px){#' . $sectionId . '{background-position:' . $bgPosT . '!important;}}';
        if ($bgPosM !== '') $bgPosResp .= '@media(max-width:767px){#' . $sectionId . '{background-position:' . $bgPosM . '!important;}}';
        if ($bgPosResp !== '') wp_add_inline_style('instinctor-page-inline', $bgPosResp);
    }

    // Content alignment CSS var (inherited by children).
    $contentAlign = (string) ($settings['content_align'] ?? 'center');
    if ($contentAlign === 'flex-start') $contentAlign = 'left';
    if ($contentAlign === 'flex-end')   $contentAlign = 'right';
    if ($contentAlign === '')           $contentAlign = 'center';
    $caJustifyMap = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'];
    $style .= '--impulse-ca:' . ($caJustifyMap[$contentAlign] ?? 'center') . ';';

    // ── Section classes ──────────────────────────────────────────
    $class = 'impulse-section impulse-stretch';
    // Compact mode (v2.1.1326, Sergei 2026-06-08): section bg hugs the
    // wrapped content (card shape). v2.1.1327: emit `align-self` inline
    // from the SECTION'S OWN content_align (not the parent's). When
    // designers turn Compact on, they expect the 9-dot picker in the
    // section's panel to center / left / right the section itself, not
    // inherit from the parent (which usually defaults to left and
    // affects every sibling). The CSS rule's `align-self: var(...)`
    // fallback chain reads --impulse-ca-self first, then the parent's
    // --impulse-ca, then center.
    // Compact hugs content on ANY section, incl. top-level cinematic screens
    // (opt-in via the panel checkbox; off by default so no existing page changes).
    // Sergei 2026-07-03 — a wrapped group of blocks should hug anywhere, not only nested.
    $_isCompact = !empty($settings['compact'])
        && $settings['compact'] !== '0' && $settings['compact'] !== 0;
    if ($_isCompact) {
        $class .= ' is-compact';
        // v2.1.1327: position the compact section via auto-margins from
        // the SECTION'S OWN content_align (not the parent's). The outer
        // .impulse-el wrapper is display:block, so the inner section's
        // align-self has no flex parent to honor — auto-margin centering
        // is the reliable cross-browser block-level pattern. Also keeps
        // 1:1 with the existing margin-left/right logic the renderer
        // already uses on .impulse-container at line 1137-1140.
        // !important required: `.impulse-content-wrapper .impulse-stretch`
        // sets `margin-left/right: 0 !important` (cancels the full-bleed
        // negative margins on .impulse-stretch when the section is in a
        // content-wrapper context). That selector-!important beats plain
        // inline. Inline-!important wins on top. v2.1.1328.
        $_compactMargin = [
            'left'   => 'margin-left:0 !important;margin-right:auto !important;',
            'center' => 'margin-left:auto !important;margin-right:auto !important;',
            'right'  => 'margin-left:auto !important;margin-right:0 !important;',
        ];
        $style .= $_compactMargin[$contentAlign] ?? 'margin-left:auto !important;margin-right:auto !important;';
        // Compact Width (v2.1.1331): explicit pixel width on the card,
        // not a cap. v2.1.1330 used max-width which was invisible until
        // content exceeded it — designer expectation for "make this
        // card 600px wide" is `width:Xpx`, so the slider directly drives
        // the visible card size. Key stays `compact_max_width` for
        // back-compat. !important required because the .is-compact CSS
        // rule sets `width: max-content !important`. max-width:100%
        // !important on the same rule keeps the card responsive — when
        // parent is narrower than the chosen width, parent wins.
        $_cmw = isset($settings['compact_max_width']) ? intval($settings['compact_max_width']) : 0;
        if ($_cmw > 0) {
            $style .= 'width:' . $_cmw . 'px !important;';
        }
        // Frosted glass — translucent bg + backdrop blur. Compact-only:
        // backdrop-filter on a full-bleed section blurs the whole viewport
        // and is expensive on mobile GPUs. We mirror the Button block's
        // sealed-preset look (blur 20px, saturate 1.8, brightness 1.15)
        // but respect the user's bg color (just force its alpha to 0.18).
        // Locked v2.1.1342. Blur amount user-adjustable (4-40px) since
        // v2.1.1350 — clamped server-side so a stale/forged setting can't
        // emit absurd values.
        $_isFrosted = !empty($settings['frosted_glass'])
            && $settings['frosted_glass'] !== '0' && $settings['frosted_glass'] !== 0;
        if ($_isFrosted) {
            $class .= ' impulse-frosted';
            $_frBlurRaw = isset($settings['frosted_blur']) && $settings['frosted_blur'] !== ''
                        ? intval($settings['frosted_blur']) : 20;
            $_frBlur = max(4, min(40, $_frBlurRaw));
            $style .= 'backdrop-filter:blur(' . $_frBlur . 'px) saturate(180%) brightness(1.15) !important;';
            $style .= '-webkit-backdrop-filter:blur(' . $_frBlur . 'px) saturate(180%) brightness(1.15) !important;';
            // Translucentize the bg. Hex → rgba(_,_,_,0.18); rgba/rgb
            // passed through as-is (user's own alpha wins); empty or
            // 'transparent' falls back to a neutral white tint.
            $_bgRaw = trim((string) ($settings['bg_color'] ?? ''));
            if ($_bgRaw === '' || $_bgRaw === 'transparent') {
                $_bgRgba = 'rgba(255,255,255,0.18)';
            } elseif (preg_match('/^#([0-9a-fA-F]{6})$/', $_bgRaw, $_m)) {
                $r = hexdec(substr($_m[1], 0, 2));
                $g = hexdec(substr($_m[1], 2, 2));
                $b = hexdec(substr($_m[1], 4, 2));
                $_bgRgba = 'rgba(' . $r . ',' . $g . ',' . $b . ',0.18)';
            } elseif (preg_match('/^#([0-9a-fA-F]{3})$/', $_bgRaw, $_m)) {
                $r = hexdec(str_repeat(substr($_m[1], 0, 1), 2));
                $g = hexdec(str_repeat(substr($_m[1], 1, 1), 2));
                $b = hexdec(str_repeat(substr($_m[1], 2, 1), 2));
                $_bgRgba = 'rgba(' . $r . ',' . $g . ',' . $b . ',0.18)';
            } elseif (preg_match('/^rgba?\(/i', $_bgRaw)) {
                $_bgRgba = $_bgRaw;
            } else {
                $_bgRgba = 'rgba(255,255,255,0.18)';
            }
            $style .= 'background-color:' . esc_attr($_bgRgba) . ' !important;';
        }
    }
    if (!$isEditor && function_exists('impulse_get_visibility_classes')) {
        $vc = impulse_get_visibility_classes($settings);
        if ($vc !== '') $class .= ' ' . $vc;
    }
    if (!empty($customIdClasses['classes'])) $class .= ' ' . $customIdClasses['classes'];
    // Ragged mesh distortion spills past section bounds (the outer
    // .impulse-mesh switches to overflow:visible in that mode). Without
    // a stacking-context escape, the NEXT section in DOM order paints
    // over the spillover at the bottom edge. Tagging the section with
    // .has-mesh-ragged lifts its z-index above the default stacking
    // order so the distortion stays visible across the boundary.
    /* Ragged-mesh class retired with the v2.2 mesh rewrite (no more
       SVG distortion = no more spillover that needed special z-index
       handling). Saved bg_mesh_distort_edges values are silently
       ignored. */

    // ── Data attrs (FX, structure, environment, mouse-grad, keyframes) ──
    // Accumulated as ordered [name => rawValue] pairs and emitted with
    // per-item esc_attr() at the section echo below (so the output goes
    // through a real escaping function, not a phpcs:ignore).
    $dataAttrs = [];

    // Section FX (Tilt / Ambient) — EVERY section now (Sergei 2026-06-25). These
    // are section-level effects unrelated to root/screen status. The old
    // !$isRootScreen gate silently dropped them on every TOP-LEVEL section once
    // the page-model refactor made every top-level section a "root screen" —
    // exactly the regression Sergei flagged (ambient/FX vanished from sections).
    // The stale comment here even claimed $isRootScreen was always false; it is
    // `!$parentId` (true for top-level), which is why FX disappeared. Frontend
    // still Pro-checks the feature.
    $canSectionFx = $isEditor || !instinctor_locked('section_fx');
    if ($canSectionFx) {
        $fxDev   = $device;
        $fxTilt  = function_exists('impulse_device_val') ? !empty(impulse_device_val($settings, 'section_tilt', $fxDev, '')) : !empty($settings['section_tilt']);
        $fxAmb   = function_exists('impulse_device_val') ? impulse_device_val($settings, 'section_ambient', $fxDev, 'none') : ($settings['section_ambient'] ?? 'none');
        if ($fxAmb === 'none' || $fxAmb === '') $fxAmb = '';
        if ($fxTilt || $fxAmb) {
            if (!$isEditor) $GLOBALS['impulse_needs_section_fx'] = true;
            if ($fxTilt) {
                $dataAttrs['data-section-tilt'] = '1';
                $dataAttrs['data-section-tilt-intensity'] = intval(function_exists('impulse_device_val') ? impulse_device_val($settings, 'section_tilt_intensity', $fxDev, 50) : ($settings['section_tilt_intensity'] ?? 50));
            }
            if ($fxAmb) {
                $dataAttrs['data-section-ambient'] = $fxAmb;
                $dataAttrs['data-section-ambient-intensity'] = intval(function_exists('impulse_device_val') ? impulse_device_val($settings, 'section_ambient_intensity', $fxDev, 50) : ($settings['section_ambient_intensity'] ?? 50));
                $dataAttrs['data-section-ambient-hue'] = intval(function_exists('impulse_device_val') ? impulse_device_val($settings, 'section_ambient_hue', $fxDev, 200) : ($settings['section_ambient_hue'] ?? 200));
                $ambOver = function_exists('impulse_device_val') ? impulse_device_val($settings, 'section_ambient_over_content', $fxDev, false) : !empty($settings['section_ambient_over_content']);
                if ($ambOver) $dataAttrs['data-section-ambient-over'] = '1';
            }
        }
    }

    // Structure panel metadata.
    if (!empty($settings['struct_label'])) $dataAttrs['data-struct-label'] = (string) $settings['struct_label'];
    if (!empty($settings['struct_color'])) $dataAttrs['data-struct-color'] = (string) $settings['struct_color'];
    if (!empty($settings['css_id']))       $dataAttrs['data-anchor']       = (string) $settings['css_id'];

    // Per-screen environment overrides — emit in BOTH contexts (was a
    // pre-extraction bug where editor preview missed these).
    foreach ([
        'screen_3d_color1'         => 'data-env-c1',
        'screen_3d_color2'         => 'data-env-c2',
        'screen_3d_color3'         => 'data-env-c3',
        'screen_3d_color4'         => 'data-env-c4',
        'screen_lava_color1'       => 'data-env-lava-c1',
        'screen_lava_color2'       => 'data-env-lava-c2',
        'screen_lava_color3'       => 'data-env-lava-c3',
        'screen_lava_color4'       => 'data-env-lava-c4',
        'screen_wave_color1'       => 'data-env-wave-c1',
        'screen_wave_color2'       => 'data-env-wave-c2',
        'screen_wave_color3'       => 'data-env-wave-c3',
        'screen_wave_color4'       => 'data-env-wave-c4',
        'screen_wave_preset'       => 'data-env-wave-preset',
        'screen_mouse_sens_level'  => 'data-env-sns',
    ] as $sk => $da) {
        if (!empty($settings[$sk])) $dataAttrs[$da] = (string) $settings[$sk];
    }
    foreach ([
        'screen_3d_speed'         => 'data-env-spd',
        'screen_3d_intensity'     => 'data-env-int',
        'screen_lava_speed'       => 'data-env-lava-spd',
        'screen_lava_intensity'   => 'data-env-lava-int',
        'screen_wave_speed'       => 'data-env-wave-spd',
        'screen_wave_intensity'   => 'data-env-wave-int',
    ] as $sk => $da) {
        if (isset($settings[$sk])) $dataAttrs[$da] = intval($settings[$sk]);
    }
    // Timeline keyframes. Store the raw JSON (or null) and emit inline at
    // the tag echoes below with esc_attr(), so output goes through a real
    // escaping function.
    $kfJson = (!empty($settings['keyframes']) && is_array($settings['keyframes']))
        ? wp_json_encode($settings['keyframes'])
        : null;

    // ── Compose section open ──────────────────────────────────────
    ob_start();

    // Emit an ordered [name => rawValue] attribute map with every name and
    // value run through esc_attr() at output. Names here are static
    // literals and values were already sanitised at build, so esc_attr()
    // is a byte-identical pass — it just satisfies EscapeOutput without a
    // phpcs:ignore.
    $emitAttrs = static function (array $pairs): void {
        foreach ($pairs as $name => $value) {
            echo ' ' . esc_attr((string) $name) . '="' . esc_attr((string) $value) . '"';
        }
    };

    // Frontend preview wrapper (impulse-el div).
    // The marker data attrs (emitted inline below on the wrapper div and the
    // <section>) are static "1" flags:
    //   data-hero-marker    — hero-sandbox JS (_sbToggle) tells hero screens
    //                         apart from the vertical-scroll sections after them.
    //   data-chat-marker    — chat-pointer overlay draws a curved arrow from
    //                         this screen toward the floating chat trigger.
    //   data-sandbox-anchor — pins the Try-It-Yourself sandbox reveal to this
    //                         exact section (falls back to length-2 when unset).
    $wrapForPreview = (!$isEditor && defined('INSTINCTOR_PREVIEW_CONTEXT') && INSTINCTOR_PREVIEW_CONTEXT);
    if ($wrapForPreview) {
        echo '<div class="impulse-el" data-id="' . intval($elId) . '" data-type="section"'
            . (!empty($settings['hero_marker'])    ? ' data-hero-marker="1"'    : '')
            . (!empty($settings['chat_marker'])    ? ' data-chat-marker="1"'    : '')
            . (!empty($settings['sandbox_anchor']) ? ' data-sandbox-anchor="1"' : '')
            . (($kfJson !== null) ? ' data-keyframes=\'' . esc_attr($kfJson) . '\'' : '')
            . '>';
    }

    // Parallax tilt data attrs — read by impulse-bg-parallax.js. Only
    // emit when parallax is genuinely active so the engine's selector
    // (`[data-bg-parallax-sens]`) doesn't pick up zero-sens sections.
    $bgParallaxAttrs = [];
    if ($bgIsParallax) {
        $bgParallaxAttrs['data-bg-parallax-sens'] = $bgParallaxSens;
        if ($bgParallaxSensTablet !== null) $bgParallaxAttrs['data-bg-parallax-sens-tablet'] = $bgParallaxSensTablet;
        if ($bgParallaxSensMobile !== null) $bgParallaxAttrs['data-bg-parallax-sens-mobile'] = $bgParallaxSensMobile;
    }

    // 3D depth attrs — read by impulse-depth-parallax.js's initAll()
    // selector `[data-depth-image][data-depth-map]`. The engine mounts
    // its WebGL canvas as the section's first child and handles
    // mouse / scroll response internally.
    $bgDepthAttrs = [];
    if ($bgHasDepth) {
        $bgDepthAttrs['data-depth-image'] = $bgDepthImage;
        $bgDepthAttrs['data-depth-map']   = $bgDepthMap;
        $bgDepthAttrs['data-depth-strength'] = $bgDepthStrength;
        // Per-device depth image + map: a landscape hero crops poorly into a
        // tall phone viewport even with a focal point, so a portrait image can
        // be supplied for mobile / tablet. The engine picks the pair by viewport
        // width at mount (mobile<=767, tablet<=1024). Both image AND map must be
        // present for a breakpoint to override; otherwise it falls back to desktop.
        $_dImgM = (string) ($settings['bg_depth_image_mobile'] ?? '');
        $_dMapM = (string) ($settings['bg_depth_map_mobile']   ?? '');
        if ($_dImgM !== '' && $_dMapM !== '') {
            $bgDepthAttrs['data-depth-image-mobile'] = $_dImgM;
            $bgDepthAttrs['data-depth-map-mobile']   = $_dMapM;
        }
        $_dImgT = (string) ($settings['bg_depth_image_tablet'] ?? '');
        $_dMapT = (string) ($settings['bg_depth_map_tablet']   ?? '');
        if ($_dImgT !== '' && $_dMapT !== '') {
            $bgDepthAttrs['data-depth-image-tablet'] = $_dImgT;
            $bgDepthAttrs['data-depth-map-tablet']   = $_dMapT;
        }
        // Focal point per device (same intent as the flat-image
        // background-position @media above): the depth shader crops toward this
        // point instead of always centring, so a narrow viewport shows the
        // subject side of a wide image. "50% 25%" -> "0.5,0.25". The engine
        // picks desktop/tablet/mobile by viewport width. v2.1.1918.
        $_depthFocal = static function ($pos): string {
            $pos = trim((string) $pos);
            if ($pos === '' || !preg_match('/(-?[\d.]+)%?\s+(-?[\d.]+)%?/', $pos, $m)) return '';
            $x = max(0.0, min(1.0, ((float) $m[1]) / 100.0));
            $y = max(0.0, min(1.0, ((float) $m[2]) / 100.0));
            return round($x, 4) . ',' . round($y, 4);
        };
        $_fD = $_depthFocal($settings['bg_position']        ?? '');
        $_fT = $_depthFocal($settings['bg_position_tablet'] ?? '');
        $_fM = $_depthFocal($settings['bg_position_mobile'] ?? '');
        if ($_fD !== '') $bgDepthAttrs['data-depth-focal']        = $_fD;
        if ($_fT !== '') $bgDepthAttrs['data-depth-focal-tablet'] = $_fT;
        if ($_fM !== '') $bgDepthAttrs['data-depth-focal-mobile'] = $_fM;
    }

    echo '<section id="' . esc_attr($sectionId) . '" class="' . esc_attr($class) . '" style="' . esc_attr($style) . '"'
        . (!empty($settings['hero_marker'])    ? ' data-hero-marker="1"'    : '')
        . (!empty($settings['chat_marker'])    ? ' data-chat-marker="1"'    : '')
        . (!empty($settings['sandbox_anchor']) ? ' data-sandbox-anchor="1"' : '');
    $emitAttrs($dataAttrs);
    $emitAttrs($bgParallaxAttrs);
    $emitAttrs($bgDepthAttrs);
    echo (($kfJson !== null) ? ' data-keyframes=\'' . esc_attr($kfJson) . '\'' : '')
        . '>';

    // Parallax bg image layer — emitted FIRST so it paints behind every
    // overlay (overlay, gradient overlay, animation layers, content).
    // Same z-index 0 as the other bg-tier layers; DOM order decides the
    // paint stack. background-size / position mirror what the section
    // would have used on its own CSS background, so swapping parallax
    // on/off doesn't change the framing.
    if ($bgIsParallax) {
        $pxStyle = 'position:absolute;top:0;left:0;right:0;bottom:0;'
                 . 'background-image:url(' . esc_url($bgImage) . ');'
                 . 'background-size:'     . esc_attr($bgSize)     . ';'
                 . 'background-position:' . esc_attr($bgPosition) . ';'
                 . 'background-repeat:no-repeat;'
                 . 'pointer-events:none;z-index:0;'
                 . 'transform-origin:center center;';
        echo '<div class="impulse-section-bg-image-parallax" style="' . esc_attr($pxStyle) . '" aria-hidden="true"></div>';
    }

    // ── Mesh BG (v2.2: radial-on-base, single element) ───────────
    // Each filled colour slot becomes a `radial-gradient(at X% Y%,
    // COLOR 0%, transparent 50%)` composited on a designer-controlled
    // base colour. Background-position drifts over the speed period
    // so the composition feels alive without any DOM-node motion.
    // No blob elements, no blur filter, no SVG distortion machinery,
    // no cursor-lerped feOffset script — the entire mesh paints in
    // one CSS background declaration.
    //
    // Grain overlay still emits via .impulse-mesh::after (CSS-only,
    // unchanged). Saved bg_mesh_distort / bg_mesh_distort_edges /
    // bg_mesh_outer_padding / bg_mesh_mouse_sens settings are
    // ignored — existing pages just render with the cleaner look.
    if ($hasMesh) {
        // Singleton-enqueue the mesh stylesheet on first mesh per page.
        // Inline <link> instead of registered handle so the asset ships
        // with the block markup whether or not wp_head() has fired yet
        // (AJAX-loaded fragments don't get wp_head). Same trick used by
        // unfold / animator / chart for their per-block stylesheets.
        if (empty($GLOBALS['impulse_mesh_css_emitted'])) {
            $GLOBALS['impulse_mesh_css_emitted'] = true;
            \Instinctor\Core\Assets::enqueue('instinctor-mesh');
        }

        $meshVars  = '--imp-mesh-spd:' . (max(1, $meshSpeed)) . 's;';
        /* Opacity bound as a CSS var so the element-config live-update
           path can adjust it in real time without re-rendering. The
           .impulse-mesh wrapper consumes it as `opacity:var(--imp-mesh-op)`
           via the CSS rule in impulse-mesh.css. */
        $meshVars .= '--imp-mesh-op:' . ($meshOpacity / 100) . ';';
        $meshClasses = 'impulse-mesh';
        if ($meshSpeedLevel === 0) $meshClasses .= ' impulse-mesh-static';
        /* Grain class retired with v2.2.001 mesh rewrite. */

        /* Compose per-slot radial gradients via CSS variables so the
           editor's live-update path can swap colors / positions in
           real time without re-rendering the section. The .impulse-
           mesh wrapper carries one --imp-mesh-c{N} per slot color,
           --imp-mesh-x{N} / --imp-mesh-y{N} per slot position, and
           --imp-mesh-base for the bottom base. element-config.js
           binds the bg_color / bg_color_N / mesh_base_color settings
           to these properties so a panel change updates the iframe
           DOM in-place (no PHP roundtrip, no full section rebuild). */
        $paddedSlots = $slots;
        while (count($paddedSlots) < 4) {
            $paddedSlots[] = ['color' => 'rgba(0,0,0,0)', 'bx' => 50, 'by' => 50, 'slot' => -1];
        }
        foreach ($paddedSlots as $i => $slot) {
            $n = $i + 1;
            $meshVars .= '--imp-mesh-c' . $n . ':' . $slot['color'] . ';';
            $meshVars .= '--imp-mesh-x' . $n . ':' . floatval($slot['bx']) . '%;';
            $meshVars .= '--imp-mesh-y' . $n . ':' . floatval($slot['by']) . '%;';
        }
        $meshVars .= '--imp-mesh-base:' . $meshBaseColor . ';';

        /* Background uses var() so it re-resolves whenever the parent
           wrapper's CSS vars update.

           Gradient stop at 80% (was 50%) — each color reaches its
           transparent edge much further from its center, so adjacent
           gradients overlap heavily. At any drift position, every part
           of the visible canvas is inside the spread of at least one
           gradient. No more "all-base-visible" moments. */
        $animDur = max(1, $meshSpeed);
        $fxStyle = 'background:'
                 . 'radial-gradient(at var(--imp-mesh-x1) var(--imp-mesh-y1),var(--imp-mesh-c1) 0%,transparent 80%),'
                 . 'radial-gradient(at var(--imp-mesh-x2) var(--imp-mesh-y2),var(--imp-mesh-c2) 0%,transparent 80%),'
                 . 'radial-gradient(at var(--imp-mesh-x3) var(--imp-mesh-y3),var(--imp-mesh-c3) 0%,transparent 80%),'
                 . 'radial-gradient(at var(--imp-mesh-x4) var(--imp-mesh-y4),var(--imp-mesh-c4) 0%,transparent 80%),'
                 . 'var(--imp-mesh-base);'
                 . 'background-size:140% 140%;'
                 . 'animation:imp-mesh-drift ' . $animDur . 's ease-in-out infinite alternate;';

        /* Emit the drift keyframes once per page. Five background-
           position values match the five-layer background (4 gradients
           + base). Each layer drifts between opposite corners so the
           composition feels asymmetric without any one direction
           dominating. The base stays at 0% 0% — no animation needed
           on a flat fill. */
        /* Drift range 12%-88% (v2.1.1957). It was tightened to 30%-70%
           which, with background-size:140%, is only ~16% of real travel —
           symmetric across all four corners, so even at a fast period it
           read as no motion at all ("the mesh doesn't move"). 12%-88% is
           ~30% real travel, clearly visible. The gradient-stop at 80% +
           four overlapping blobs keep heavy color coverage at the extremes,
           so the base never becomes the only visible layer; the base layer
           itself stays at 50% 50% (a flat fill needs no drift). */
        if (empty($GLOBALS['impulse_mesh_keyframes_emitted'])) {
            $GLOBALS['impulse_mesh_keyframes_emitted'] = true;
            wp_add_inline_style('instinctor-page-inline', '@keyframes imp-mesh-drift{'
               . 'from{background-position:12% 12%,88% 12%,12% 88%,88% 88%,50% 50%}'
               . 'to{background-position:88% 88%,12% 88%,88% 12%,12% 12%,50% 50%}'
               . '}');
        }

        echo '<div class="' . esc_attr($meshClasses) . '" data-count="' . intval($meshCount) . '" style="' . esc_attr($meshVars) . '" aria-hidden="true">';
        echo '<div class="impulse-mesh-fx" style="' . esc_attr($fxStyle) . '"></div>';
        echo '</div>';
    }

    // ── Layer DOM (overlay / bg_video / gradient overlay / animation / shapes) ──
    // bg_video: emit any time set (was nested inside overlay branch
    // pre-extraction on frontend — silent bug fixed).
    $bgVideo   = (string) ($settings['bg_video']          ?? '');
    $bgVideoFb = (string) ($settings['bg_video_fallback'] ?? '');
    if ($bgVideo !== '') {
        $videoStyle = 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0;pointer-events:none;display:block';
        echo '<video class="impulse-section-bg-video" autoplay muted loop playsinline style="' . esc_attr($videoStyle) . '"'
            . ($bgVideoFb !== '' ? ' poster="' . esc_url($bgVideoFb) . '"' : '')
            . '>';
        echo '<source src="' . esc_url($bgVideo) . '">';
        echo '</video>';
        if ($bgVideoFb !== '') {
            $fbStyle = $isEditor
                ? 'position:absolute;top:0;left:0;width:100%;height:100%;background-image:url(' . esc_url($bgVideoFb) . ');background-size:cover;background-position:center;z-index:0;pointer-events:none;display:none'
                : 'background-image:url(' . esc_url($bgVideoFb) . ')';
            echo '<div class="impulse-section-bg-video-fallback" style="' . esc_attr($fbStyle) . '"></div>';
        }
    }

    // Animated frame-loop background: a lightweight numbered WebP sequence
    // (<base>1.webp … <base>N.webp) looped by impulse-frameloop.js. Same z-layer
    // as bg_video (behind the overlay + content). Far lighter than a video.
    // EDITOR: render a STATIC first frame (no data-frameloop). The editor's
    // live-preview re-renders + screen-reveal animations make an in-canvas frame
    // loop strobe; the animation plays on the published page. The owner still sees
    // the fire as the background while editing, just not cycling.
    $bgFrames = (string) ($settings['bg_frames'] ?? '');
    if ($bgFrames !== '') {
        $bgFramesN   = max(1, intval($settings['bg_frames_count'] ?? 0));
        $bgFramesFps = max(1, intval($settings['bg_frames_fps'] ?? 12));
        $bgFramesVer = (string) ($settings['bg_frames_ver'] ?? '');
        // Dark backing on the loop <img>: if a frame swap momentarily clears the
        // element, show the section's own dark colour (or black) — NOT the page
        // environment behind it (that flash-through was the visible part of the strobe).
        $flBg    = $sectionBgColorRaw !== '' ? $sectionBgColorRaw : '#000';
        $flStyle = 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0;pointer-events:none;display:block;background-color:' . $flBg;
        // Frameloop attrs (frontend only). Emitted inline so each value goes
        // through esc_attr() at output; editor gets a static first frame.
        echo '<img class="impulse-section-bg-frameloop" alt="" aria-hidden="true" decoding="async"'
            . ' src="' . esc_url($bgFrames . '1.webp' . $bgFramesVer) . '"'
            . ($isEditor ? '' :
                ' data-frameloop data-frameloop-base="' . esc_attr($bgFrames) . '"'
                . ' data-frameloop-frames="' . esc_attr((string) $bgFramesN) . '"'
                . ' data-frameloop-fps="' . esc_attr((string) $bgFramesFps) . '"'
                . ' data-frameloop-ver="' . esc_attr($bgFramesVer) . '"')
            . ' style="' . esc_attr($flStyle) . '">';
        if (!$isEditor) $GLOBALS['impulse_needs_frameloop'] = true;

        // Foreground overlay image OVER the frame loop: when a bg_image is ALSO set
        // it is the owner's transparent image riding ABOVE the animation, so the
        // flame shows through its transparent areas. Rendered as an <img> (not CSS
        // bg) so it survives screen-mode transparency, at z-index:0 but AFTER the
        // loop in DOM order → paints above it, and below the overlay wash + content.
        if ($bgImage !== '' && !$bgHasDepth) {
            $ovFit   = in_array($bgSize, ['cover', 'contain'], true) ? $bgSize : 'cover';
            // Normal compositing: the image's real alpha decides what shows — solid
            // where opaque, the animation behind shows through its transparent areas.
            // (No blend mode: a 'screen' blend lightens the opaque parts against the
            // animation, which reads as the whole image going semi-transparent.)
            $ovStyle = 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:' . $ovFit
                . ';object-position:' . esc_attr($bgPosition !== '' ? $bgPosition : 'center')
                . ';z-index:0;pointer-events:none;display:block';
            echo '<img class="impulse-section-bg-overlay-img" alt="" aria-hidden="true" decoding="async"'
                . ' src="' . esc_url($bgImage) . '" style="' . esc_attr($ovStyle) . '">';
        }
    }

    // Procedural smoke puffs (bg_smoke): a localized cluster of rising, screen-blended
    // white puffs (steam/smoke off a subject, e.g. a pizza). Positioned with
    // bg_smoke_cx (centre x %), bg_smoke_y (bottom %), bg_smoke_w (cluster width %).
    // Pure CSS + an SVG turbulence filter for wispy edges — no JS. Frontend only
    // (editor re-renders would keep restarting the animation). z:3 = above the bg
    // layers, below the content container (z:4) so headings stay on top.
    if (!empty($settings['bg_smoke']) && $settings['bg_smoke'] !== '0' && !$isEditor) {
        $smCx = (float) ($settings['bg_smoke_cx'] ?? 50);
        $smY  = (float) ($settings['bg_smoke_y']  ?? 12);
        $smW  = (float) ($settings['bg_smoke_w']  ?? 38);
        if (empty($GLOBALS['impulse_smoke_assets_emitted'])) {
            $GLOBALS['impulse_smoke_assets_emitted'] = true;
            // NOTE: no SVG feTurbulence/feDisplacementMap filter — it is far too
            // expensive on large screen-blended puffs that re-composite every frame
            // over the animated flame (it starved the flame's rAF). A soft radial
            // gradient + blur reads as smoke for a fraction of the cost.
            wp_add_inline_style('instinctor-page-inline', ''
                . '.impulse-smoke-layer{position:absolute;pointer-events:none;z-index:3}'
                . '.impulse-smoke{position:absolute;bottom:0;border-radius:50%;background:radial-gradient(circle at 50% 55%,rgba(255,255,255,.5),rgba(228,228,228,.2) 45%,rgba(215,215,215,0) 70%);filter:blur(7px);mix-blend-mode:screen;transform:translate(-50%,0) scale(.5);opacity:0;animation:impulse-smoke-rise var(--dur,3s) ease-out infinite;will-change:transform,opacity}'
                . '@keyframes impulse-smoke-rise{0%{opacity:0;transform:translate(-50%,0) scale(.45)}18%{opacity:.5}55%{opacity:.32}100%{opacity:0;transform:translate(-50%,-300%) scale(calc(var(--sc,1)*1.7))}}'
                . '@media (prefers-reduced-motion:reduce){.impulse-smoke{display:none}}'
                . '');
        }
        $layerStyle = 'left:' . round($smCx - $smW / 2, 2) . '%;width:' . round($smW, 2) . '%;bottom:' . round($smY, 2) . '%;height:' . round(max(45, $smW * 1.8), 2) . '%';
        echo '<div class="impulse-smoke-layer" style="' . esc_attr($layerStyle) . '">';
        $seed  = 7;
        $rnd   = function () use (&$seed) { $seed = ($seed * 1103515245 + 12345) & 0x7fffffff; return ($seed % 1000) / 1000; };
        $count = max(1, min(12, (int) ($settings['bg_smoke_count'] ?? 6)));
        for ($p = 0; $p < $count; $p++) {
            $px = 24 + $rnd() * 52; $pw = 22 + $rnd() * 18; $dur = 2.8 + $rnd() * 1.7; $sc = 0.9 + $rnd() * 0.8; $del = -$rnd() * 3.5;
            echo '<div class="impulse-smoke" style="' . esc_attr('left:' . round($px, 1) . '%;width:' . round($pw, 1) . '%;aspect-ratio:1/1;--dur:' . round($dur, 2) . 's;--sc:' . round($sc, 2) . ';animation-delay:' . round($del, 2) . 's') . '"></div>';
        }
        echo '</div>';
    }

    // Solid overlay (alpha > 0).
    // 2026-05-23: z-index:0 (not 1) so the overlay sits in the same layer
    // as the bg video/image but BELOW .impulse-container (z-index:4). When
    // both bg-video and overlay are at z-index:0, DOM order paints overlay
    // above bg-video, which is correct. Previously z-index:1 was supposed to
    // be below container (z-index:4), but in some stacking contexts the
    // container's inline z-index didn't take effect and the overlay ended
    // up painting on top of section CONTENT (heading/text/blocks). z-index:0
    // is defensive against that whole class of issue.
    if ($hasOverlay) {
        echo '<div class="impulse-section-overlay" style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:' . esc_attr($overlayColor) . ';opacity:' . esc_attr((string) $overlayOpacity) . ';pointer-events:none;z-index:0"></div>';
    }

    // Page-level gradient overlay (singleton).
    $emitFlag = 'impulse_grad_overlay_emitted';
    if (empty($GLOBALS[$emitFlag])) {
        $goShape  = $pageSettings['screen_gradient_shape'] ?? '';
        $goD  = isset($pageSettings['screen_gradient_diameter'])
            ? intval($pageSettings['screen_gradient_diameter'])
            : ($goShape === 'glow' ? 80 : ($goShape === 'linear' ? 200 : ($goShape === 'frame' ? 110 : 100)));
        $goR  = isset($pageSettings['screen_gradient_roundness'])
            ? intval($pageSettings['screen_gradient_roundness'])
            : ($goShape === 'linear' ? 0 : ($goShape === 'frame' ? 80 : 100));
        $goAg = intval($pageSettings['screen_gradient_angle']    ?? 0);
        $goSf = isset($pageSettings['screen_gradient_softness'])
            ? intval($pageSettings['screen_gradient_softness'])
            : ($goShape === 'frame' ? 60 : 100);
        $goC1 = (string) ($pageSettings['screen_gradient_color1'] ?? 'rgba(0,0,0,0)');
        $goC2 = (string) ($pageSettings['screen_gradient_color2'] ?? 'rgba(0,0,0,0)');
        $goOff = 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 (!($goOff($goC1) && $goOff($goC2))) {
            $goD  = max(10, min(200, $goD ?: 100));
            $goR  = max(0,  min(100, $goR));
            $goAg = (($goAg % 360) + 360) % 360;
            $goSf = max(0,  min(100, $goSf));
            $goRX = $goD / 2;
            $goRY = max(0.5, $goRX * ($goR / 100));
            $goInner = max(0,   (100 - $goSf) / 2);
            $goOuter = min(100, 100 - $goInner);
            $css =
                  'position:fixed;top:50%;left:50%;width:200%;height:200%;'
                . 'pointer-events:none;z-index:1;'
                . 'background:radial-gradient(ellipse '
                . number_format($goRX, 2, '.', '') . '% '
                . number_format($goRY, 2, '.', '') . '% at center,'
                . $goC1 . ' 0%,'
                . $goC1 . ' ' . number_format($goInner, 2, '.', '') . '%,'
                . $goC2 . ' ' . number_format($goOuter, 2, '.', '') . '%,'
                . $goC2 . ' 100%);'
                . 'transform:translate(-50%,-50%) rotate(' . $goAg . 'deg);';
            echo '<div class="impulse-gradient-overlay" style="' . esc_attr($css) . '"></div>';
            $GLOBALS[$emitFlag] = true;
        }
    }

    // Per-section gradient overlay (slider-driven, v2.1.272 — replaces
    // the legacy 5-button shape preset that was only available on scene
    // roots). Sits between the mesh layer and the section content
    // (z-index 2). Four parameters drive the shape:
    //
    //   diameter  10–200 %  — radius along the long axis. <100 = focal,
    //                         >100 = lets the gradient bleed past edges.
    //   roundness 0–100  %  — Y-axis radius as % of X. 0 = flat band
    //                         (top/bottom fade), 100 = perfect circle
    //                         (true vignette/glow).
    //   angle     0–360 °   — rotation; pair with roundness 0 to make
    //                         a side fade or a diagonal band.
    //   softness  0–100 %   — distance between solid c1 and solid c2.
    //                         100 = perfectly smooth, 0 = hard edge
    //                         (sharp circular spotlight).
    //
    // Swap the two colours via the panel's swap button to flip
    // vignette (dark edges, transparent centre) ↔ glow (bright centre,
    // transparent edges). Same formula the page-level overlay uses;
    // section variant just bounds the ellipse to the section box
    // instead of position:fixed at viewport scale.
    $goC1 = (string) $dvRead('gradient_overlay_color1', 'rgba(0,0,0,0)');
    $goC2 = (string) $dvRead('gradient_overlay_color2', 'rgba(0,0,0,0)');
    // $goColorAlive defined near the top (shared with the $hasGradientOverlay
    // layer-detection so emission + scaffolding never disagree).
    $goType = strtolower(trim((string) $dvRead('gradient_overlay_type', 'radial')));
    if ($goColorAlive($goC1) || $goColorAlive($goC2)) {
        $goD  = max(10, min(200, intval($dvRead('gradient_overlay_diameter',  100))));
        $goAg = ((intval($dvRead('gradient_overlay_angle', 0)) % 360) + 360) % 360;
        $goSf = max(0,  min(100, intval($dvRead('gradient_overlay_softness',  100))));
        if ($goType === 'linear') {
            // Directional linear fade (bottom scrim, top scrim, side/diagonal
            // sweep). Angle is the CSS gradient direction in degrees — 0 anchors
            // the Start colour at the BOTTOM (a bottom scrim), 180 = top, 90 =
            // left, 270 = right. Diameter is the reach: 100% completes the fade
            // at the section mid-line (center), 200% carries it to the far edge.
            // Softness widens the transition band out from the anchored edge.
            // Roundness is a radial-only concept and is ignored here. The inner
            // simply fills the clip wrapper; the gradient's own angle orients it,
            // so no rotate()/200% oversize is needed.
            $reach = max(5.0, min(100.0, $goD / 2));
            $c1End = max(0.0, $reach * (1 - $goSf / 100));
            $goInnerCss =
                  'position:absolute;inset:0;'
                . 'background:linear-gradient(' . $goAg . 'deg,'
                . $goC1 . ' 0%,'
                . $goC1 . ' ' . number_format($c1End, 2, '.', '') . '%,'
                . $goC2 . ' ' . number_format($reach, 2, '.', '') . '%,'
                . $goC2 . ' 100%);';
        } else {
            $goR  = max(0,  min(100, intval($dvRead('gradient_overlay_roundness', 100))));
            $goRX = $goD / 2;
            $goRY = max(0.5, $goRX * ($goR / 100));
            $goInner = max(0,   (100 - $goSf) / 2);
            $goOuter = min(100, 100 - $goInner);
            // Two-div construction so the rotated 200%×200% inner can be
            // clipped to the section's border-radius via the outer wrapper's
            // overflow:hidden — without forcing the section itself to clip
            // (which would chop other absolute-positioned children like the
            // editor toolbar).
            $goInnerCss =
                  'position:absolute;top:50%;left:50%;width:200%;height:200%;'
                . 'background:radial-gradient(ellipse '
                . number_format($goRX, 2, '.', '') . '% '
                . number_format($goRY, 2, '.', '') . '% at center,'
                . $goC1 . ' 0%,'
                . $goC1 . ' ' . number_format($goInner, 2, '.', '') . '%,'
                . $goC2 . ' ' . number_format($goOuter, 2, '.', '') . '%,'
                . $goC2 . ' 100%);'
                . 'transform:translate(-50%,-50%) rotate(' . $goAg . 'deg);';
        }
        echo '<div class="impulse-gradient-overlay" style="position:absolute;inset:0;pointer-events:none;z-index:2;overflow:hidden;border-radius:inherit">';
        echo '<div class="impulse-gradient-overlay-inner" style="' . esc_attr($goInnerCss) . '"></div>';
        echo '</div>';
    }

    // Animation layers.
    if ($hasAnimation) {
        $animId = 'section-anim-' . $elId;
        $animLayers = function_exists('impulse_device_val')
            ? impulse_device_val($settings, 'animation_layers', $device, [])
            : ($settings['animation_layers'] ?? []);
        if (is_string($animLayers)) $animLayers = json_decode($animLayers, true) ?: [];

        if (!$isEditor) {
            // Frontend per-device device-aware layers + window data side-channel.
            $animLayersDesktop = $settings['animation_layers'] ?? [];
            if (is_string($animLayersDesktop)) $animLayersDesktop = json_decode($animLayersDesktop, true) ?: [];
            $animLayersTablet = $settings['animation_layers_tablet'] ?? null;
            if (is_string($animLayersTablet)) $animLayersTablet = json_decode($animLayersTablet, true) ?: [];
            $animLayersMobile = $settings['animation_layers_mobile'] ?? null;
            if (is_string($animLayersMobile)) $animLayersMobile = json_decode($animLayersMobile, true) ?: [];
            if (!empty($animLayersTablet) || !empty($animLayersMobile)) {
                ob_start();
                echo 'window["impulse_anim_resp_' . esc_attr($animId) . '"]=' . wp_json_encode([
                    'enabled' => [!empty($settings['bg_animation_enabled']), $settings['bg_animation_enabled_tablet'] ?? null, $settings['bg_animation_enabled_mobile'] ?? null],
                    'tablet'  => $animLayersTablet,
                    'mobile'  => $animLayersMobile,
                ]) . ';';
                $_inlineJs = ob_get_clean();
                wp_add_inline_script('instinctor-frontend-js', $_inlineJs, 'after');
            }
            if (!empty($settings['bg_animation_enabled']) && !empty($animLayersDesktop) && function_exists('impulse_render_animation_layers')) {
                impulse_render_animation_layers($animLayersDesktop, $animId, false, false);
            }
        } elseif (!empty($animLayers) && function_exists('impulse_render_animation_layers')) {
            impulse_render_animation_layers($animLayers, $animId, false, true);
        }
    }

    // Overlay reveal FX (3D Reveal): emit the carrier the impulse-overlay-fx.js
    // engine scans for. The carrier's parent (this <section>) is what the engine
    // warps/transforms; it sits before .impulse-container so collectBlocks finds
    // the real content blocks. The frontend loads the engine only when a section
    // needs it (flag below); the editor preview always enqueues it.
    if ($hasOverlayFx) {
        if (!$isEditor) $GLOBALS['impulse_needs_overlay_fx'] = true;
        $ovCfg = [
            'fx'    => (string) ($settings['ovfx'] ?? ''),
            'ease'  => (string) ($settings['ovfx_ease'] ?? 'spring'),
            'speed' => (float) ($settings['ovfx_speed'] ?? 5),
            'p1'    => (float) ($settings['ovfx_p1'] ?? 50),
            'p2'    => (float) ($settings['ovfx_p2'] ?? 50),
            'p3'    => (float) ($settings['ovfx_p3'] ?? 50),
            'c1'    => (string) ($settings['ovfx_c1'] ?? ''),
            'c2'    => (string) ($settings['ovfx_c2'] ?? ''),
        ];
        $ovT = (string) ($settings['ovfx_tablet'] ?? '');
        $ovM = (string) ($settings['ovfx_mobile'] ?? '');
        if ($ovT !== '') $ovCfg['fxT'] = $ovT;
        if ($ovM !== '') $ovCfg['fxM'] = $ovM;
        if ($isEditor) $ovCfg['editor'] = true;
        echo '<div class="impulse-overlay-fx" data-ovfx=\'' . esc_attr((string) wp_json_encode($ovCfg)) . '\' aria-hidden="true" style="display:none"></div>';
    }

    // Background shapes.
    if (function_exists('impulse_render_bg_shapes')) {
        impulse_render_bg_shapes($settings, $device, $isEditor);
    }

    // Text-wall background (Sergei 2026-07): one line cloned to fill the section,
    // rendered as a back layer BEHIND .impulse-container content. ROOT + CLASSIC
    // sections only (useless in screen mode) — a full-viewport snap screen owns
    // its own bg. A JS pass (impulse-textwall.js) measures the section and tiles
    // the line to fill width x height; 'scroll' motion drifts the rows sideways
    // in alternating directions, 'static' just fills. Enqueued conditionally.
    // Text presence IS the on-switch (the redundant Enable checkbox was dropped
    // v2.1.2171): a non-empty line on a classic-scroll root section = wall on.
    $twClassic = $isRootScreen && (($GLOBALS['impulse_body_scroll'] ?? '') === 'classic');
    if ($twClassic) {
        $twText = trim((string) ($settings['tw_text'] ?? ''));
        if ($twText !== '' && $twOn) {
            if (!$isEditor) $GLOBALS['impulse_needs_textwall'] = true;
            $twSize    = max(8, (int) ($settings['tw_size'] ?? 72));
            $twWeight  = preg_replace('/[^0-9a-z]/i', '', (string) ($settings['tw_weight'] ?? '800')) ?: '800';
            // Color carries the transparency (alpha) — the standalone opacity
            // control was dropped as redundant with the picker's alpha channel.
            $twColor   = (string) ($settings['tw_color'] ?? 'rgba(255,255,255,0.14)');
            // A fully-transparent wall colour is a no-op: enabling the wall then
            // leaving it invisible is never intentional (an accidental alpha-0
            // drag on the swatch, or a stale 'transparent' left in the DB). Fall
            // back to the subtle default so the wall actually shows. This ONLY
            // rescues alpha==0 -- any deliberate visible colour (alpha > 0) is
            // left EXACTLY as set, so the picker stays in full control. v2.1.2167.
            if ($twColor === '' || $twColor === 'transparent'
                || preg_match('/rgba?\(\s*[\d.]+[\s,]+[\d.]+[\s,]+[\d.]+[\s,\/]+0(?:\.0+)?\s*\)/', $twColor)) {
                $twColor = 'rgba(255,255,255,0.14)';
            }
            $twGap     = max(0, (int) ($settings['tw_gap'] ?? 0));
            $twSpace   = max(0, (int) ($settings['tw_space'] ?? 24));
            $twSpotSize = max(50, (int) ($settings['tw_spot_size'] ?? 600)); // spotlight diameter (px)
            $twSpeed   = max(0.1, (float) ($settings['tw_speed'] ?? 1));
            // Motion = movement (none/scroll/drift). Spotlight = cursor-glow
            // modifier. Legacy: 'wave' (stepped shimmer) and 'perspective' (real 3D
            // CSS that fought foreground content — vanishing tiles, flashing blocks)
            // were dropped; both map to 'none' so old saves render a static wall.
            $twMotionRaw = strtolower((string) ($settings['tw_motion'] ?? 'none'));
            $twSpotlight = (!empty($settings['tw_spotlight']) && (string) $settings['tw_spotlight'] !== '0') ? '1' : '0';
            $twParallax = (!empty($settings['tw_parallax']) && (string) $settings['tw_parallax'] !== '0') ? '1' : '0';
            if ($twMotionRaw === 'wave' || $twMotionRaw === 'perspective') { $twMotionRaw = 'none'; }
            $twMotion = in_array($twMotionRaw, ['scroll', 'drift'], true) ? $twMotionRaw : 'none';
            // z-index:3 -- above every background decoration (bg image/video 0,
            // section overlay 0, animation 1, gradient overlay 2) but below the
            // content container (z-index:4). A section's gradient overlay used to
            // paint OVER the wall at z-index:1 ("background is covering it"). v2.1.2167.
            $twStyle = 'position:absolute;inset:0;z-index:3;overflow:hidden;pointer-events:none;'
                     . 'display:flex;flex-direction:column;justify-content:center;align-items:stretch;'
                     . 'font-family:inherit;font-weight:' . $twWeight . ';font-size:' . $twSize . 'px;'
                     . 'line-height:1.05;color:' . $twColor . ';'
                     . 'white-space:nowrap;text-transform:uppercase;letter-spacing:0.01em;'
                     . 'row-gap:' . $twGap . 'px;';
            echo '<div class="impulse-textwall" aria-hidden="true"'
               . ' data-tw-text="' . esc_attr(rawurlencode($twText)) . '"'
               . ' data-tw-motion="' . esc_attr($twMotion) . '"'
               . ' data-tw-spotlight="' . esc_attr($twSpotlight) . '"'
               . ' data-tw-spot-size="' . esc_attr((string) $twSpotSize) . '"'
               . ' data-tw-parallax="' . esc_attr($twParallax) . '"'
               . ' data-tw-space="' . esc_attr((string) $twSpace) . '"'
               . ' data-tw-speed="' . esc_attr((string) $twSpeed) . '"'
               . ' style="' . esc_attr($twStyle) . '"></div>';
        }
    }

    // Editor: visibility badge.
    if ($isEditor && function_exists('impulse_visibility_badge')) {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- impulse_visibility_badge() composes its markup from esc_attr()'d values internally (see element-renderers/helpers.php).
        echo wp_kses(impulse_visibility_badge($settings), impulse_svg_allowed());
    }

    // ── Inner container ──────────────────────────────────────────
    $containerStyle = $needsLayers ? 'position:relative;z-index:4;' : '';
    if ($isEditor) {
        $containerStyle .= 'width:var(--impulse-content-width,' . $contentWidth . '%);';
    } else {
        $containerStyle .= 'width:' . $contentWidth . '%;';
    }

    $pageMaxCW = $GLOBALS['impulse_page_max_content_width'] ?? 1440;
    $fullWidth = !empty($settings['full_width']);
    if ($isRootScreen) {
        // Root screen — no max-width clamp (content fills viewport).
    } elseif ($fullWidth) {
        $containerStyle .= 'max-width:none;';
    } elseif (!$isEditor && $contentWidth >= 100) {
        $containerStyle .= 'max-width:var(--impulse-content-max-width, none);';
    } elseif ($pageMaxCW > 0) {
        $containerStyle .= 'max-width:' . $pageMaxCW . 'px;';
    } else {
        $containerStyle .= 'max-width:none;';
    }

    // Horizontal alignment.
    switch ($contentAlign) {
        case 'center': $containerStyle .= 'margin-left:auto;margin-right:auto;'; break;
        case 'right':  $containerStyle .= 'margin-left:auto;margin-right:0;';    break;
        default:       $containerStyle .= 'margin-left:0;margin-right:auto;';
    }

    // Flex column (or row).
    $containerStyle .= 'display:flex;flex-direction:' . esc_attr($direction) . ';align-items:stretch;';
    if ($direction === 'row') {
        $wrapEnabled = ($settings['wrap'] ?? true) !== false && ($settings['wrap'] ?? '1') !== '0';
        if ($wrapEnabled) $containerStyle .= 'flex-wrap:wrap;';
    }

    // Fill section height when section has explicit height OR is screen root.
    if ($minHeight > 0 || $isRootScreen) {
        $containerStyle .= 'flex:1;height:100%;min-height:0;';
    }

    // Vertical distribution.
    $vImp     = $isRootScreen && !$isEditor ? ' !important' : '';
    $safePfx  = $isRootScreen ? 'safe ' : '';
    /* Distribution values (space-between/around/evenly) have no `safe`
       fallback in the CSS spec — when content height exceeds the
       screen, the first child pushes above the visible area and the
       last child pushes below (the dreaded "Spread spreads off-
       screen" bug on mobile). To allow a media-query override at
       narrow widths, distribution values are routed through the
       --impulse-justify custom property, which the mobile stylesheet
       can rewrite to flex-start. The !important on the property
       declaration is preserved by the var() indirection. */
    switch ($valign) {
        case 'center':  $containerStyle .= 'justify-content:' . $safePfx . 'center' . $vImp . ';';   break;
        case 'bottom':  $containerStyle .= 'justify-content:' . $safePfx . 'flex-end' . $vImp . ';'; break;
        case 'between': $containerStyle .= '--impulse-justify:space-between;justify-content:var(--impulse-justify,space-between)' . $vImp . ';'; break;
        case 'around':  $containerStyle .= '--impulse-justify:space-around;justify-content:var(--impulse-justify,space-around)'   . $vImp . ';'; break;
        case 'evenly':  $containerStyle .= '--impulse-justify:space-evenly;justify-content:var(--impulse-justify,space-evenly)'   . $vImp . ';'; break;
        default:        $containerStyle .= 'justify-content:flex-start' . $vImp . ';';
    }

    $itemSpace = intval($settings['item_space'] ?? 0);
    if ($itemSpace > 0) {
        $containerStyle .= $isEditor
            ? 'gap:var(--impulse-item-space,' . $itemSpace . 'px);'
            : 'gap:' . $itemSpace . 'px;';
    }

    // Compose container HTML — and track effective bg for child auto-contrast.
    $prevParentBg = $GLOBALS['impulse_parent_bg'] ?? null;
    $effectiveBg = ($bgColor !== 'transparent' && $bgColor !== '')
        ? $bgColor
        : ($GLOBALS['impulse_parent_bg'] ?? ($GLOBALS['impulse_page_bg_color'] ?? '#ffffff'));
    $GLOBALS['impulse_parent_bg'] = $effectiveBg;

    echo '<div class="impulse-container' . ($contentWidth >= 100 ? ' impulse-container-full' : '') . '" style="' . esc_attr($containerStyle) . '">';
    foreach ($children as $child) {
        $renderChild($child);
    }
    echo '</div>';

    $GLOBALS['impulse_parent_bg'] = $prevParentBg;

    // Editor: hidden dropzone (shows during drag-active).
    if ($isEditor) {
        $dzCls = 'impulse-dropzone' . (empty($children) ? ' impulse-section-empty-dropzone' : '');
        echo '<div class="' . esc_attr($dzCls) . '" data-parent="' . esc_attr((string) $elId) . '"></div>';
    }

    echo '</section>';
    if ($wrapForPreview) echo '</div>';

    // ── Responsive @media emits (frontend only) ───────────────────
    if (!$isEditor && function_exists('wp_add_inline_style')) {
        $secSel  = '#' . $sectionId;
        $contSel = $secSel . '>.impulse-container';
        if (!empty($tabletStyles) || !empty($mobileStyles) || !empty($containerTablet) || !empty($containerMobile)) {
            $css = '';
            if (!empty($tabletStyles) || !empty($containerTablet)) {
                $c = '';
                if (!empty($tabletStyles))   $c .= $secSel  . '{' . implode('!important;', $tabletStyles)   . '!important}';
                if (!empty($containerTablet))$c .= $contSel . '{' . implode('!important;', $containerTablet) . '!important}';
                $css .= '@media(max-width:1024px){' . $c . '}';
            }
            if (!empty($mobileStyles) || !empty($containerMobile)) {
                $c = '';
                if (!empty($mobileStyles))   $c .= $secSel  . '{' . implode('!important;', $mobileStyles)   . '!important}';
                if (!empty($containerMobile))$c .= $contSel . '{' . implode('!important;', $containerMobile) . '!important}';
                $css .= '@media(max-width:767px){' . $c . '}';
            }
            wp_add_inline_style('instinctor-page-inline', $css);
        }

        // Mesh BG per-device overrides: emit @media blocks for any mesh
        // setting that diverges from desktop. Speed/grain ride on the
        // wrapper; per-blob colours are pushed into the blobs' `--c`
        // via slot-position selectors. Caveat — count comes from
        // desktop fills, so a tablet/mobile override that clears a
        // colour just makes that blob's `--c` transparent (the blob
        // div stays in the layout). This is an intentional simplification:
        // per-device LAYOUT swap (e.g. desktop=4 → mobile=2) would
        // require re-rendering the DOM, which doesn't fit a static
        // page render. Designer who needs that should compress the
        // visual at the source (fewer desktop colours).
        if ($hasMesh) {
            $meshSel = $secSel . '>.impulse-mesh';
            $meshCss = '';
            // Desktop->slot map: the renderer compacted empties out
            // when building $meshColors. Re-derive the same mapping so
            // a tablet override of `bg_color_3` lands on the same blob
            // index the desktop renderer assigned it to.
            $deskKeys = [];
            foreach (['bg_color' => true, 'bg_color_2' => true, 'bg_color_3' => true, 'bg_color_4' => true] as $sk => $_) {
                $raw = (string) ($settings[$sk] ?? '');
                if ($raw !== '' && $raw !== 'transparent' && strpos($raw, 'rgba(0,0,0,0)') !== 0) {
                    $deskKeys[] = $sk;
                }
            }
            foreach ([['tablet', 1024], ['mobile', 767]] as $bp) {
                [$dv, $px] = $bp;
                $rules = [];
                $wrapParts = [];
                foreach ($deskKeys as $slotIdx => $sk) {
                    $blobN = $slotIdx + 1;
                    $key = $sk . '_' . $dv;
                    if (array_key_exists($key, $settings)) {
                        $val = (string) $settings[$key];
                        if ($val === '' || $val === 'transparent' || strpos($val, 'rgba(0,0,0,0)') === 0) {
                            $val = 'transparent';
                        }
                        $rules[] = $meshSel . '>.impulse-mesh-blob-' . $blobN . '{--c:' . esc_attr($val) . '}';
                    }
                }
                if (isset($settings['bg_mesh_speed_' . $dv])) {
                    $wrapParts[] = '--imp-mesh-spd:' . max(0, intval($settings['bg_mesh_speed_' . $dv])) . 's';
                }
                if (isset($settings['bg_mesh_grain_' . $dv])) {
                    $g = max(0, min(10, intval($settings['bg_mesh_grain_' . $dv])));
                    $wrapParts[] = '--imp-mesh-grain:' . ($g / 100);
                }
                if ($wrapParts) {
                    $rules[] = $meshSel . '{' . implode(';', $wrapParts) . '}';
                }
                if ($rules) {
                    $meshCss .= '@media(max-width:' . $px . 'px){' . implode('', $rules) . '}';
                }
            }
            if ($meshCss !== '') wp_add_inline_style('instinctor-page-inline', $meshCss);
        }

        // item_space responsive.
        if (function_exists('impulse_responsive_css')) {
            $secResp = impulse_responsive_css($settings, $contSel, ['item_space' => ['gap', 'px', true]]);
            if ($secResp) wp_add_inline_style('instinctor-page-inline', $secResp);
        }
        // valign / content_align per-device overrides.
        $valignJC = [
            'top' => 'flex-start', 'center' => 'center', 'bottom' => 'flex-end',
            'between' => 'space-between', 'around' => 'space-around', 'evenly' => 'space-evenly',
        ];
        $caJC = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'];
        foreach (['tablet', 'mobile'] as $dv) {
            $bpx   = ($dv === 'tablet') ? '1024' : '767';
            $vaDev = $settings['valign_'        . $dv] ?? null;
            $caDev = $settings['content_align_' . $dv] ?? null;
            $rules = '';
            if ($vaDev !== null && $vaDev !== '' && $vaDev !== $valign) {
                $jc    = $valignJC[$vaDev] ?? 'center';
                $safe  = ($isRootScreen && ($vaDev === 'center' || $vaDev === 'bottom')) ? 'safe ' : '';
                $rules .= $contSel . '{justify-content:' . $safe . $jc . '!important}';
                if ($isRootScreen) $rules .= $secSel . '{justify-content:' . $safe . $jc . '!important}';
            }
            if ($caDev !== null && $caDev !== '' && $caDev !== $contentAlign) {
                $caNorm = $caDev === 'flex-start' ? 'left' : ($caDev === 'flex-end' ? 'right' : $caDev);
                $caVal  = $caJC[$caNorm] ?? 'center';
                $rules .= $secSel . '{--impulse-ca:' . $caVal . '!important}';
            }
            if ($rules) wp_add_inline_style('instinctor-page-inline', '@media(max-width:' . $bpx . 'px){' . $rules . '}');
        }
    }

    return ob_get_clean();
}
