<?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);
    // Classic page + an EXPLICIT per-section vh: honour that vh as the floor.
    // Before this, a classic section with screen_min_height_vh set failed the
    // classic-free test above and fell into the SCREEN branch's 100dvh floor,
    // so the requested value never applied - and since new sections default
    // to 40vh, EVERY new classic section ballooned to full viewport on the
    // frontend ("I set 38vh but on preview it's huge", Sergei 2026-07-22).
    $_secClassicVh = $isRootScreen
        && (($GLOBALS['impulse_body_scroll'] ?? '') === 'classic')
        && (int) ($settings['screen_min_height_vh'] ?? 0) > 0;

    // 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 padding (Sergei 2026-07-13): horizontal keeps a 20px page gutter,
    // but VERTICAL padding keeps proportional air from the desktop value. The old
    // flat 10px on every side made sections feel cramped with no separation between
    // them on a phone. Explicit _mobile always wins; an explicit desktop 0 stays
    // flush (respect intentional edge-to-edge sections). Used by BOTH the editor
    // mobile preview (here) and the frontend @media block below, so they match.
    $impMobPad = function (string $k) use ($settings): int {
        if (isset($settings[$k . '_mobile']) && $settings[$k . '_mobile'] !== '') {
            return max(0, intval($settings[$k . '_mobile']));
        }
        if ($k === 'padding_left' || $k === 'padding_right') return 20;
        if (isset($settings[$k]) && $settings[$k] !== '' && intval($settings[$k]) === 0) return 0;
        $desk = (isset($settings[$k]) && $settings[$k] !== '') ? intval($settings[$k]) : 20;
        return max(44, min(72, (int) round($desk * 0.5)));
    };
    if ($isEditor && ($GLOBALS['impulse_preview_device'] ?? 'desktop') === 'mobile') {
        $paddingTop    = $impMobPad('padding_top');
        $paddingBottom = $impMobPad('padding_bottom');
        $paddingLeft   = $impMobPad('padding_left');
        $paddingRight  = $impMobPad('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 === '') {
        // Default the 9-dot alignment picker to its CENTRE position everywhere:
        // valign 'center' pairs with the already-centred content_align default so a
        // section with no explicit alignment sits dead-centre. On a content-height
        // section (a document body, e.g. terms/privacy) centre reads the same as top
        // (the content fills its own height), so this only re-centres sections that
        // actually have spare vertical room (full-viewport screens, tall heroes).
        // Reverses the 2026-07-08 classic->top default. Sergei 2026-07-15.
        $valign = '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'] ?? '');
    // The editor pre-resolves the active device, so the same 'none' sentinel the
    // frontend honours below has to read as "no image" here or the canvas would
    // keep showing the desktop photo on the tab where the user removed it.
    if ($bgImage === 'none') $bgImage = '';
    $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.
    // ripple/static stay accepted here: saved pages may still carry them and
    // the JS engine maps them to their replacements (ink / glitch).
    $ovfxValid = ['noise', 'ink', 'ripple', 'liquid', 'shatter', 'glitch', 'static', 'fold', 'melt', 'mosaic', 'blinds', 'orbit', 'stamp', 'vortex',
                  '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']   ?? '');
    // Editor device tabs: the JS engines pick the per-device pair by VIEWPORT
    // width, but the editor's mobile canvas is not guaranteed to be a <=767px
    // viewport, so pick server-side off the active tab, exactly like bg_image
    // above. Pair rule matches the engines: both image AND map or no override.
    if ($isEditor && $device !== 'desktop') {
        $_dImgDev = (string) ($settings['bg_depth_image_' . $device] ?? '');
        $_dMapDev = (string) ($settings['bg_depth_map_' . $device]   ?? '');
        if ($_dImgDev !== '' && $_dMapDev !== '') {
            $bgDepthImage = $_dImgDev;
            $bgDepthMap   = $_dMapDev;
        }
    }
    $bgDepthStrength = isset($settings['bg_depth_strength']) ? max(0, min(100, intval($settings['bg_depth_strength']))) : 50;
    // 3D-relief depth mode (a second option beside the flat parallax). "3D depth"
    // (bg_depth_strength) is the single motion dial (the dolly is merged in); these
    // tune the relief. Caps match the panel sliders (Lighting 0-70, Pivot 0-100,
    // Zoom 100-140).
    $bgDepthMode  = (($settings['bg_depth_mode'] ?? 'parallax') === 'relief') ? 'relief' : 'parallax';
    $bgDepthLight = isset($settings['bg_depth_light']) ? max(0, min(70,  intval($settings['bg_depth_light']))) : 50;
    $bgDepthPivot = isset($settings['bg_depth_pivot']) ? max(0, min(100, intval($settings['bg_depth_pivot']))) : 50;
    $bgDepthZoom  = isset($settings['bg_depth_zoom'])  ? max(100, min(140, intval($settings['bg_depth_zoom']))) : 100;
    // Depth engages only at strength > 0. At 0 the section renders its flat
    // bg_image as plain CSS instead of mounting a WebGL canvas that shows the
    // same image with zero parallax (the unified Image picker writes both the
    // flat base and the depth pair; the slider decides flat vs 3D). Absent
    // strength defaults to 50 above, so existing depth sections stay 3D.
    // 2026-07-19.
    // Contain = flat: the depth engines are cover-only by construction (the
    // relief mesh oversizes the image so its edges stay outside the frame; a
    // contained image shows all four edges, which the dolly would visibly
    // tear). Picking Contain therefore renders the plain CSS background and
    // skips the WebGL mount entirely, same as strength 0. Sergei 2026-07-24.
    // impulse_can_render_depth(): free tenants on the shared host publish flat.
    // 3D is Pro, and Pro means their own standalone install. See free-tier.php
    // for why this is scoped to the apex host only (Sergei 2026-07-25).
    // Owner kill-switch for the depth motion (Settings: 3D motion on photos).
    // Klevez read the hero drift as a glitch and wrote in (2026-08-04):
    // non-technical owners need their photos to be able to stand still. When
    // off, the depth image demotes to the plain background via the existing
    // fallback below, so nothing goes dark. Vendor pages and demos have no
    // owner context and keep their motion.
    $_owner3d   = !$isEditor && class_exists('\Instinctor\Services\BusinessProfile')
        ? \Instinctor\Core\Tenant::currentOwnerId() : null;
    $_hero3dOff = $_owner3d !== null
        && (string) \Instinctor\Services\BusinessProfile::get('hero_3d', $_owner3d) === '0';
    $bgHasDepth      = ($bgDepthImage !== '' && $bgDepthMap !== '' && $bgDepthStrength > 0 && $bgSize !== 'contain')
                       && !$_hero3dOff
                       && (!function_exists('impulse_can_render_depth') || impulse_can_render_depth($bgDepthImage));
    // Legacy depth-only sections (old 3D flow wrote no flat bg_image): when
    // the slider sits at 0, fall back flat to the depth image itself so the
    // background never goes empty.
    if (!$bgHasDepth && $bgDepthImage !== '' && $bgImage === '') {
        $bgImage    = $bgDepthImage;
        $hasBgImage = true;
    }
    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);
    // Cinematic/screen mode included since 2026-07-22 (Sergei: "should
    // definitely work") - a full-viewport screen tiles its wall just like a
    // classic section; the JS ResizeObserver re-tiles when an inactive
    // screen gains real dimensions on activation.
    $hasTextWall = $twOn
        && trim((string) ($settings['tw_text'] ?? '')) !== '';

    $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);
    // IMAGE WINS over the gradient (Sergei 2026-07-25). The mesh renders as a
    // positioned layer while a flat bg image rides the section's own CSS
    // background, so DOM order painted the gradient ON TOP and the image was
    // invisible - and the panel hides the mesh swatches once an image exists,
    // so there was no way to take the gradient back off. A section with a
    // background image simply does not emit mesh. Depth/parallax images have
    // their own layers (also above the mesh) and count the same way.
    $hasMesh = ($meshCount >= 2) && ($bgImage === '');
    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.
        // A classic section with an explicit vh height IS overlaid by the header
        // (headers float over the top of the page in both modes), so it needs the
        // offset just like a snap screen: the SECTION keeps its full height and
        // starts at y=0, only its CONTENT clears the header. Excluding it put the
        // hero copy under the header, which read as the padding being ignored
        // (Sergei 2026-08-03, academy). Classic-FREE sections stay excluded: they
        // flow normally and would just gain a phantom gap.
        $_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.
        // A classic section with an explicit vh height IS overlaid by the header
        // (headers float over the top of the page in both modes), so it needs the
        // offset just like a snap screen: the SECTION keeps its full height and
        // starts at y=0, only its CONTENT clears the header. Excluding it put the
        // hero copy under the header, which read as the padding being ignored
        // (Sergei 2026-08-03, academy). Classic-FREE sections stay excluded: they
        // flow normally and would just gain a phantom gap.
        $_hdrOffset = ($isRootScreen && !$_secClassicFree) ? ' + var(--impulse-header-height,0px)' : '';
        $style .= 'padding-top:calc(' . $paddingTop . 'px' . $_hdrOffset . ');';
        // Dock inset rides the PADDING, not the height: the section still fills the
        // screen (so a hero photo covers it edge to edge) while its content sits clear
        // of the demo dock. Subtracting it from min-height instead left a dark bar under
        // the hero. Root screens only: a body section is not overlaid by the dock, and
        // the var is 0 everywhere except a demo, so nothing else moves. Sergei 2026-07-16.
        $_dockPad = $isRootScreen ? ' + var(--impulse-screen-bottom-inset, 0px)' : '';
        $style .= 'padding-bottom:calc(' . $paddingBottom . 'px' . $_dockPad . ');';
        $style .= 'padding-left:'   . $paddingLeft   . 'px;';
        $style .= 'padding-right:'  . $paddingRight  . 'px;';
    }

    // ── Frontend: build responsive @media for section + container ──
    // Same gate as the inline padding-top below: only sections the header floats
    // OVER reserve room for it.
    // Only the FIRST root section sits under the header. Applying the offset to
    // every root section added the header height to each one's padding-top, so a
    // section set to 7px measured ~50px and the setting looked broken (Sergei
    // 2026-08-07, academy home section 3). The header overlaps exactly one
    // section: the one at the top of the page.
    if (!isset($GLOBALS['impulse_root_section_seen'])) $GLOBALS['impulse_root_section_seen'] = 0;
    $_isFirstRoot = false;
    if ($isRootScreen) {
        $_isFirstRoot = ($GLOBALS['impulse_root_section_seen'] === 0);
        $GLOBALS['impulse_root_section_seen']++;
    }
    $_hdrOffsetPk = ($_isFirstRoot && !$_secClassicFree) ? ' + var(--impulse-header-height,0px)' : '';
    $tabletStyles = []; $mobileStyles = [];
    $containerTablet = []; $containerMobile = [];
    if (!$isEditor) {
        foreach (['padding_top', 'padding_bottom', 'padding_left', 'padding_right'] as $pk) {
            $cssK = str_replace('_', '-', $pk);
            // padding-top carries the floating-header reservation on the same
            // sections whose inline style does ($_hdrOffset below). These rules
            // are !important, so without it the per-device value silently DROPPED
            // the offset and the hero copy slid back under the header on phones
            // and tablets, while desktop was fine (Sergei 2026-08-03, academy).
            $_pkOffset = ($pk === 'padding_top') ? $_hdrOffsetPk : '';
            if (isset($settings[$pk . '_tablet'])) {
                $tabletStyles[] = $_pkOffset !== ''
                    ? $cssK . ':calc(' . intval($settings[$pk . '_tablet']) . 'px' . $_pkOffset . ')'
                    : $cssK . ':' . intval($settings[$pk . '_tablet']) . 'px';
            }
            // Mobile: 20px horizontal page gutter, proportional vertical air (see
            // $impMobPad above). Overridable by an explicit _mobile value.
            $mobileStyles[] = $_pkOffset !== ''
                ? $cssK . ':calc(' . $impMobPad($pk) . 'px' . $_pkOffset . ')'
                : $cssK . ':' . $impMobPad($pk) . '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'])) {
                $_atOffset = ($pk === 'padding_top') ? $_hdrOffsetPk : '';
                $tabletStyles[] = $_atOffset !== ''
                    ? $pCss . ':calc(' . (int) round($pBase * $pft) . 'px' . $_atOffset . ')'
                    : $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 ($_secClassicVh) {
        // Explicit vh floor on a classic page: viewport-relative via the same
        // --imp-vh var the screen floor uses; grows past it with content.
        $_scvh = max(1, min(200, (int) $settings['screen_min_height_vh']));
        $style .= 'min-height:calc(var(--imp-vh, 100dvh) * ' . ($_scvh / 100) . ');';
    } elseif ($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).
            // The dock inset is NOT subtracted here any more. Subtracting it shrank the
            // whole section, so a hero's background image only covered (100vh - dock) and
            // the page colour showed as a dark bar under it (Sergei 2026-07-16). The
            // section must still FILL the screen; only its CONTENT needs to clear the
            // dock, which the padding-bottom below handles. The wrapper stopped
            // subtracting it too, so this floor still matches the wrapper's content box
            // and cannot re-introduce the phantom scroll described above.
            $style .= 'flex:1;height:100%;min-height:calc(var(--imp-vh, 100dvh) - 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) . ');';
            // Size + position are user controls (the Fill/Fit picker and the focal
            // drag), so in the editor they read from vars the panel can set. As
            // literals they only changed after a re-render, which is the same
            // "control looks dead" class of bug as the Alignment button and the
            // section alignment dot (audit 2026-08-03). background-image itself
            // stays literal: swapping the image is a structural change with its own
            // refresh path, not something a var can carry.
            $style .= $isEditor
                ? 'background-size:var(--impulse-bg-size,' . esc_attr($bgSize) . ');'
                  . 'background-position:var(--impulse-bg-position,' . esc_attr($bgPosition) . ');'
                : 'background-size:' . esc_attr($bgSize) . ';'
                  . '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 = '';
        // 'none' is an explicit "no background on this device", distinct from an
        // empty value, which means "inherit the desktop image". Without the
        // distinction there was no way to have a photo on desktop and none on
        // phones: clearing the field just fell back to desktop (Sergei
        // 2026-08-03).
        if ($bgImageTablet === 'none') {
            $bgImgResp .= '@media(max-width:1024px){#' . $sectionId . '{background-image:none!important;}}';
        } elseif ($bgImageTablet !== '') {
            $bgImgResp .= '@media(max-width:1024px){#' . $sectionId . '{background-image:url(' . esc_url($bgImageTablet) . ')!important;}}';
        }
        if ($bgImageMobile === 'none') {
            $bgImgResp .= '@media(max-width:767px){#' . $sectionId . '{background-image:none!important;}}';
        } elseif ($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;
        // Relief mode: impulse-depth-mesh.js claims elements with mode="relief"
        // (the parallax engine skips them). Params are read off these attrs.
        if ($bgDepthMode === 'relief') {
            $bgDepthAttrs['data-depth-mode']  = 'relief';
            $bgDepthAttrs['data-depth-light'] = $bgDepthLight;
            $bgDepthAttrs['data-depth-pivot'] = $bgDepthPivot;
            $bgDepthAttrs['data-depth-zoom']  = $bgDepthZoom;
        }
        // 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];
        }

        /* MESH MODE: where the four colours sit, not which colours they are.
         *
         * 'full' is the historic behaviour and the default, so a section without
         * the key renders byte-identical: the author's own saved positions, wide
         * 80% spread, colour across the whole surface.
         *
         * The other modes push the same slots to the frame and pull the spread in,
         * which is what keeps the CENTRE clear. That is the whole point: authors
         * pick four strong colours, the full-surface wash eats their text, and they
         * retreat to something safe. Edge placement lets the colour stay.
         * Sergei 2026-08-10.
         */
        $meshMode = (string) ($settings['bg_mesh_mode'] ?? 'full');
        $_meshPos = [
            'edges'   => [[50, 100], [0, 50], [100, 50], [50, 0]],
            'corners' => [[0, 0], [100, 0], [0, 100], [100, 100]],
            'top'     => [[12, 0], [38, -6], [64, -6], [90, 0]],
            'bottom'  => [[12, 100], [38, 106], [64, 106], [90, 100]],
        ];
        $meshSpread = 80;
        if (isset($_meshPos[$meshMode])) {
            foreach ($_meshPos[$meshMode] as $_i => $_xy) {
                if (!isset($paddedSlots[$_i])) continue;
                $paddedSlots[$_i]['bx'] = $_xy[0];
                $paddedSlots[$_i]['by'] = $_xy[1];
            }
            // Tighter reach, or a perimeter blob still washes the middle.
            $meshSpread = ($meshMode === 'corners') ? 42 : 46;
        }
        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 . ';';
        $meshVars .= '--imp-mesh-sp:' . (int) $meshSpread . '%;';

        /* 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 var(--imp-mesh-sp,80%)),'
                 . 'radial-gradient(at var(--imp-mesh-x2) var(--imp-mesh-y2),var(--imp-mesh-c2) 0%,transparent var(--imp-mesh-sp,80%)),'
                 . 'radial-gradient(at var(--imp-mesh-x3) var(--imp-mesh-y3),var(--imp-mesh-c3) 0%,transparent var(--imp-mesh-sp,80%)),'
                 . 'radial-gradient(at var(--imp-mesh-x4) var(--imp-mesh-y4),var(--imp-mesh-c4) 0%,transparent var(--imp-mesh-sp,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.
            // 100% = fade completes at the far edge. Was $goD / 2, which finished
            // the blend at the mid-line and left a flat band beyond it.
            // Linear ignores Diameter entirely: the fade always spans the section
            // and Softness owns the blend width. The control is hidden in this mode,
            // so honouring a stored value would be a setting nothing can reach.
            $reach = 100.0;
            $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) ?: [];
            // "Grid" is the WebGL Impulse3D effect delegated from the section
            // animation engine (impulse-animations.js), which itself pulls in no
            // THREE. Enqueue the 3D engine + THREE when any device layer uses it.
            $_hasGridAnim = static function ($layers): bool {
                if (!is_array($layers)) return false;
                foreach ($layers as $l) { if (is_array($l) && (($l['effect'] ?? '') === 'grid')) return true; }
                return false;
            };
            if (class_exists('\\Instinctor\\Core\\Assets')
                && ($_hasGridAnim($animLayersDesktop) || $_hasGridAnim($animLayersTablet) || $_hasGridAnim($animLayersMobile))) {
                \Instinctor\Core\Assets::enqueue('instinctor-three');
                \Instinctor\Core\Assets::enqueue('instinctor-3d-effects');
            }
            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.
    $twEmit = $isRootScreen; // root sections, ANY scroll mode (classic + cinematic)
    // Free WP.org build: the wall is an EDITOR demo. It renders in full in the
    // builder and is simply absent from the published page, matching how 3D and
    // the environments already behave. No watermark, no branding on the
    // visitor's site, so Guideline 10 stays clear. Sergei 2026-08-07.
    if ($twEmit && function_exists('instinctor_motion_demo_locked')
        && instinctor_motion_demo_locked((bool) $isEditor)) {
        $twEmit = false;
    }
    if ($twEmit) {
        $twText = trim((string) ($settings['tw_text'] ?? ''));
        if (($twText !== '' || ((string) ($settings['tw_content'] ?? '') === 'images' && !empty($settings['tw_images']))) && $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 WHEN THE SPOTLIGHT IS OFF:
            // 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), and tw_on already exists for deliberately hiding it. 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.
            //
            // WITH THE SPOTLIGHT ON, alpha-0 is the opposite of accidental: the wall
            // renders opaque and the alpha becomes the radial mask's FLOOR (see
            // impulse-textwall.js), i.e. how visible the wall is OUTSIDE the spotlight
            // circle. Alpha 0 therefore means "show nothing but the spotlight", which
            // is a real design. Rescuing it to 0.14 pinned the wall at 14% everywhere
            // and made the swatch's leftmost position unreachable. Reported by Sergei
            // 2026-07-16: "I am setting transparency to the most left position. The
            // text wall remains visible." Only rescue when there is no spotlight.
            $_twSpotOn = (!empty($settings['tw_spotlight']) && (string) $settings['tw_spotlight'] !== '0');
            if (!$_twSpotOn
                && ($twColor === '' || $twColor === 'transparent'
                    || preg_match('/rgba?\(\s*[\d.]+[\s,]+[\d.]+[\s,]+[\d.]+[\s,\/]+0(?:\.0+)?\s*\)/', $twColor))) {
                $twColor = 'rgba(255,255,255,0.14)';
            }
            // Spotlight on with no colour at all still needs a base to tint from.
            if ($twColor === '' || $twColor === 'transparent') {
                $twColor = 'rgba(255,255,255,0)';
            }
            $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)
            // Peak opacity INSIDE the spotlight circle (%). The wall renders opaque and
            // the radial mask does the fading: the swatch's alpha is the mask FLOOR
            // (outside the circle) and this is its CENTRE. 100 = the old hardcoded
            // `#000`, so existing walls are unchanged. Sergei 2026-07-16.
            $twSpotOpacity = max(0, min(100, (int) ($settings['tw_spot_opacity'] ?? 100)));
            $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', 'lanes', 'stack', 'zoom', 'rings', 'wavelanes', 'stickers', 'wavestack', 'drum', 'weightwalk', 'cross', 'banner', 'overprint', 'snake', 'funnel', 'tilt', 'vortex', 'bricks', 'coils'], true) ? $twMotionRaw : 'none';
            // Lanes rotation, degrees. Emitted for every motion (harmless), read only by lanes.
            $twAngle = is_numeric($settings['tw_angle'] ?? null) ? (float) $settings['tw_angle'] : -30;
            $twAngle = max(-60, min(60, $twAngle));
            // Camera drift over any motion; empty = engine default (room dollies, flat holds).
            $twCameraRaw = strtolower((string) ($settings['tw_camera'] ?? ''));
            $twCamera = in_array($twCameraRaw, ['none', 'breathe', 'pan', 'dolly', 'orbit'], true) ? $twCameraRaw : '';
            // 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;';
            // Per-device: hide the wall on mobile (<=767px). It is a JS canvas layer,
            // so per-device follows the frontend's CSS-media-query pattern, not a
            // re-render. Toggle = tw_hide_mobile (panel checkbox). 2026-07-22.
            $twHideMobile = !empty($settings['tw_hide_mobile']);
            if ($twHideMobile && !$isEditor && function_exists('wp_add_inline_style')) {
                static $_twHmEmitted = false;
                if (!$_twHmEmitted) {
                    $_twHmEmitted = true;
                    wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){.impulse-textwall-hm{display:none!important}}');
                }
            }
            echo '<div class="impulse-textwall' . ($twHideMobile ? ' impulse-textwall-hm' : '') . '" 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-opacity="' . esc_attr((string) $twSpotOpacity) . '"'
               . ' data-tw-spot-size="' . esc_attr((string) $twSpotSize) . '"'
               . ' data-tw-parallax="' . esc_attr($twParallax) . '"'
               . ' data-tw-space="' . esc_attr((string) $twSpace) . '"'
               . ' data-tw-angle="' . esc_attr((string) $twAngle) . '"'
               . ' data-tw-camera="' . esc_attr($twCamera) . '"'
               . ' data-tw-vcount="' . esc_attr((string) max(0, min(40, (int) ($settings['tw_vcount'] ?? 0)))) . '"'
               . ' data-tw-cam-strength="' . esc_attr((string) max(0, min(3, (float) ($settings['tw_cam_strength'] ?? 1)))) . '"'
               . ' data-tw-color2="' . esc_attr((string) ($settings['tw_color2'] ?? '')) . '"'
               . ' data-tw-ticker="' . ((!empty($settings['tw_ticker']) && (string) $settings['tw_ticker'] !== '0') ? '1' : '0') . '"'
               . ' data-tw-font="' . esc_attr(in_array((string) ($settings['tw_font'] ?? ''), ['grotesque', 'mono', 'serif', 'condensed'], true) ? (string) $settings['tw_font'] : '') . '"'
               . ' data-tw-content="' . esc_attr(((string) ($settings['tw_content'] ?? '') === 'images') ? 'images' : 'text') . '"'
               . ' data-tw-images="' . esc_attr(rawurlencode((string) ($settings['tw_images'] ?? ''))) . '"'
               . ' data-tw-dim="' . esc_attr((string) max(0, min(100, (int) ($settings['tw_dim'] ?? 100)))) . '"'
               . ' data-tw-gray="' . ((!empty($settings['tw_gray']) && (string) $settings['tw_gray'] !== '0') ? '1' : '0') . '"'
               . ' 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;
    // Carry this section's Content Gap to descendants: a grid whose own
    // content_gap is untouched inherits it for the spacing INSIDE its cells,
    // so the section slider "just works" everywhere (Sergei 2026-07-24).
    // Explicit grid/cell values always win; explicit 0 means none.
    $prevItemSpace = $GLOBALS['impulse_section_item_space'] ?? null;
    $GLOBALS['impulse_section_item_space'] = $itemSpace;
    // Per-device siblings of the same value. The section emits its own tablet/
    // mobile gap through impulse_responsive_css, but a grid's CELL gaps are
    // inline per cell, so the grid has to emit its own @media rules and needs
    // the per-device numbers here (Sergei 2026-07-31: home screen 4 kept the
    // 28px desktop gap on phones even with item_space_mobile:12 set).
    $prevItemSpaceT = $GLOBALS['impulse_section_item_space_tablet'] ?? null;
    $prevItemSpaceM = $GLOBALS['impulse_section_item_space_mobile'] ?? null;
    $GLOBALS['impulse_section_item_space_tablet'] = (isset($settings['item_space_tablet']) && $settings['item_space_tablet'] !== '') ? intval($settings['item_space_tablet']) : null;
    $GLOBALS['impulse_section_item_space_mobile'] = (isset($settings['item_space_mobile']) && $settings['item_space_mobile'] !== '') ? intval($settings['item_space_mobile']) : null;

    // impulse-valign-center marks the container so the push-to-edge CSS can
    // add a counterweight spacer: a child's margin-top:auto (push_bottom)
    // absorbs ALL free space, slamming the REST of a centred section to the
    // top. With the class, :has() rules split the space so the other content
    // stays centred (Sergei 2026-07-29, home hero badges). Per-device valign
    // overrides don't move this class; the desktop value governs.
    echo '<div class="impulse-container' . ($contentWidth >= 100 ? ' impulse-container-full' : '') . ($valign === 'center' ? ' impulse-valign-center' : '') . '" style="' . esc_attr($containerStyle) . '">';
    foreach ($children as $child) {
        $renderChild($child);
    }
    echo '</div>';

    $GLOBALS['impulse_parent_bg'] = $prevParentBg;
    $GLOBALS['impulse_section_item_space'] = $prevItemSpace;
    $GLOBALS['impulse_section_item_space_tablet'] = $prevItemSpaceT;
    $GLOBALS['impulse_section_item_space_mobile'] = $prevItemSpaceM;

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