<?php
declare(strict_types=1);

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

/**
 * Grid (CSS Grid / Masonry / AutoFit Flex) element renderer (shared).
 *
 * Phase 1, step 9 of the renderer-unification refactor — third
 * structural-container extraction.
 *
 * Grid supports three layout modes:
 *
 *   - **grid**     (default) — CSS Grid with explicit cols × rows,
 *                  per-track sizing via `col_width_N` (fr) and
 *                  `row_height_N` (px). Per-cell wrappers carry the
 *                  per-cell bg, border, gradient ring, rotate, scale,
 *                  valign/halign. This is the load-bearing mode.
 *
 *   - **auto_fit** — Flexbox with `flex-wrap`, no fixed cell count.
 *                  Children render directly, no cell wrappers.
 *
 *   - **masonry**  — CSS `column-count` multi-column packing. Children
 *                  break across columns at natural heights.
 *
 * Pre-extraction differences and resolutions:
 *
 *   - **Mobile column count**: editor pre-resolves at preview-device
 *     time (walks children for the actual cell count and rebuilds
 *     $columns/$rows). Frontend emits an `@media` rule keyed to
 *     `columns_mobile` / `mobile_layout`.
 *     → Preserved per-context.
 *
 *   - **Gap CSS vars**: both renderers emit
 *     `var(--impulse-gap-horizontal,Npx)` / `var(--impulse-gap-vertical,Npx)`
 *     since 2.x — was a previous editor-vs-frontend drift that's
 *     already fixed.
 *
 *   - **Per-cell loop**: identical fields read in both — bg, gradient,
 *     border (solid OR gradient ring), rotate, scale, valign, halign,
 *     padding, content_gap, depth_row/col data attrs.
 *
 *   - **Per-cell padding default**: editor uses CSS vars
 *     `var(--impulse-cell-padding-v/h,Npx)` for live updates; frontend
 *     emits literal `padding:VpxHpx`. Preserved per-context.
 *
 *   - **Auto-fit mobile row→column**: frontend emits @media that
 *     flips flex-direction to column on mobile (and a tablet override
 *     if `direction_tablet` is set). Editor doesn't need this — it
 *     resolves direction per device tab server-side.
 *
 *   - **Editor-only extras**: `data-parent` on every grid cell,
 *     `impulse-grid-cell-empty` class on empty cells, plus indicator
 *     `<span>` in empty cells (gated on impulse_is_edit_mode).
 *
 *   - **Per-cell border-gradient flag globals**: editor primes
 *     `impulse_preview_border_gradient` / `impulse_preview_border_grad_mouse`;
 *     frontend primes the non-`preview_` variants. Both kept as-was.
 *
 *   - **Grid-level bg_color**: removed 2026-05-04 in both renderers
 *     (per-cell bg only); kept here for the legacy `bg_image` field
 *     which is still supported.
 *
 *   - **Grid-level overlay**: retired ($overlayColor/Image/Opacity
 *     forced to 0/empty). Legacy `overlay_color` writes silently
 *     stop painting. Mirror of frontend (2.0.854).
 *
 * @param array|object $el           Element node.
 * @param array        $settings     Raw or pre-resolved settings.
 * @param array        $children     Pre-collected child elements.
 * @param string       $context      'frontend' or 'editor'.
 * @param callable     $renderChild  Recursion callable.
 *
 * @return string Inner HTML.
 *
 * @sideeffect Primes $GLOBALS['impulse_needs_cell_depth'] in both;
 *             frontend primes impulse_needs_border_gradient +
 *             impulse_needs_border_grad_mouse; editor primes the
 *             preview_ variants. Calls wp_add_inline_style for
 *             responsive @media rules.
 */
function impulse_element_render_grid($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'));
    $isEditor = ($context === 'editor');
    $device   = $isEditor ? ($GLOBALS['impulse_preview_device'] ?? 'desktop') : 'desktop';

    $columns        = intval($settings['columns'] ?? 2);
    $rows           = intval($settings['rows']    ?? 1);
    $gapH           = $settings['gap_horizontal'] ?? ($settings['gap'] ?? 20);
    $gapV           = $settings['gap_vertical']   ?? ($settings['gap'] ?? 20);
    // Mobile rule (Sergei 2026-07-02): grid gaps default to 10px on mobile,
    // overridable by an explicit gap_*_mobile (default via gating). Editor mobile
    // preview here; frontend sets the gap CSS vars via @media after $gridId below.
    $_gridGapMobDefault = (($settings['gap_horizontal_mobile'] ?? '') === '')
        && (($settings['gap_vertical_mobile'] ?? '') === '')
        && (($settings['gap_mobile'] ?? '') === '');
    if ($isEditor && $device === 'mobile' && $_gridGapMobDefault) { $gapH = 10; $gapV = 10; }
    $padding        = intval($settings['padding'] ?? 0);
    $borderRadius   = intval($settings['border_radius']  ?? 0);
    $marginTop      = intval($settings['margin_top']     ?? 0);
    $marginBottom   = intval($settings['margin_bottom']  ?? 0);
    $minHeight      = intval($settings['min_height']     ?? 0);
    $autoFit        = !empty($settings['auto_fit']);
    $layoutMode     = (string) ($settings['layout_mode']   ?? ($autoFit ? 'auto_fit' : 'grid'));
    $mobileLayout   = (string) ($settings['mobile_layout'] ?? 'stack');
    if ($mobileLayout === 'grid') $mobileLayout = '2col'; // legacy back-compat
    // Does this grid render as a SINGLE column on phones? (columns_mobile=1, or the
    // default 'stack' layout — anything but '2col'/'keep'.) A lone stacked column has
    // no use for a width_pct constraint: an 88%-wide single column just insets itself
    // and wastes edge space (the recurring "huge mobile padding"). When true we force
    // full mobile width below, overriding any stale desktop-cloned width_pct_mobile.
    // Sergei 2026-07-02.
    $_mcRaw0 = $settings['columns_mobile'] ?? null;
    if ($_mcRaw0 !== null && $_mcRaw0 !== '') {
        $_mobileSingleCol = (intval($_mcRaw0) === 1);
    } else {
        $_mobileSingleCol = ($mobileLayout !== '2col' && $mobileLayout !== 'keep');
    }

    // Grid-level bg/overlay retired 2026-05-04 / 2.0.854 — kept here so
    // legacy values can't bleed through. bg_image still supported.
    $bgColor    = 'transparent';
    $bgImage    = (string) ($settings['bg_image'] ?? '');
    $bgSize     = (string) ($settings['bg_size']     ?? 'cover');
    $bgPosition = (string) ($settings['bg_position'] ?? 'center');
    $bgRepeat   = (string) ($settings['bg_repeat']   ?? 'no-repeat');
    $overlayColor   = '';
    $overlayImage   = '';
    $overlaySize    = 'cover';
    $overlayOpacity = 0;

    // 'between' = space-between: edge children flush, middle children spread.
    // Useful for multi-child cells where the user wants top + bottom items
    // anchored to opposite edges with the gap absorbed by the middle.
    $valignMap = ['top' => 'flex-start', 'center' => 'center', 'bottom' => 'flex-end', 'between' => 'space-between'];
    $halignMap = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'];

    $gridId = 'impulse-grid-' . $elId;

    // Mobile rule: default grid gaps to 10px on mobile (sets the gap CSS vars the
    // gap declarations read) when no explicit gap_*_mobile is set.
    if (!$isEditor && $_gridGapMobDefault && function_exists('wp_add_inline_style')) {
        wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . $gridId . '{--impulse-gap-horizontal:10px;--impulse-gap-vertical:10px}}');
    }
    // ...and when the designer DID set per-device gaps, emit those. Only the
    // no-explicit-value default above was ever written, so an explicit mobile or
    // tablet gap silently did nothing on the frontend and the desktop fallback
    // kept applying: setting the mobile gap to 0 left the buttons 14px apart
    // (Sergei 2026-07-31, home screen 2). The editor was fine because it resolves
    // per-device server-side, which is why this only showed on a real phone.
    // 0 is a legitimate value, so test for '' rather than emptiness.
    if (!$isEditor && !$_gridGapMobDefault && function_exists('wp_add_inline_style')) {
        $_gapDevCss = function (array $keys, string $mq) use ($settings, $gridId) {
            $decl = '';
            foreach ($keys as $var => $cands) {
                foreach ($cands as $k) {
                    if (isset($settings[$k]) && $settings[$k] !== '') {
                        $decl .= '--impulse-gap-' . $var . ':' . intval($settings[$k]) . 'px;';
                        break;
                    }
                }
            }
            return $decl === '' ? '' : $mq . '{#' . $gridId . '{' . $decl . '}}';
        };
        $_css = $_gapDevCss(
            ['horizontal' => ['gap_horizontal_mobile', 'gap_mobile'],
             'vertical'   => ['gap_vertical_mobile',   'gap_mobile']],
            '@media(max-width:767px)'
        ) . $_gapDevCss(
            ['horizontal' => ['gap_horizontal_tablet', 'gap_tablet'],
             'vertical'   => ['gap_vertical_tablet',   'gap_tablet']],
            '@media(max-width:1024px)and(min-width:768px)'
        );
        if ($_css !== '') wp_add_inline_style('instinctor-page-inline', $_css);
    }

    // ── Editor: walk children to compute mobile cell count ───────
    // The desktop-resolved $columns/$rows isn't reliable on mobile
    // because impulse_resolve_responsive_settings may have clamped
    // $columns to columns_mobile already; counting children gives
    // the true cell count.
    $origColumns = $columns;
    $origRows    = $rows;
    if ($isEditor && $device === 'mobile' && !$autoFit) {
        $rawMC = $settings['columns_mobile'] ?? null;
        if ($rawMC !== null && $rawMC !== '') {
            $mobCols = max(1, intval($rawMC));
        } elseif ($mobileLayout === '2col') {
            $mobCols = 2;
        } elseif ($mobileLayout === 'keep') {
            $mobCols = $origColumns;
        } else {
            $mobCols = 1;
        }
        $cellCount = max(1, $origColumns * $origRows);
        $maxIdx = -1;
        foreach ($children as $_chk) {
            $so = is_object($_chk) ? intval($_chk->sort_order ?? 0) : intval($_chk['sort_order'] ?? 0);
            if ($so > $maxIdx) $maxIdx = $so;
        }
        if ($maxIdx >= 0) $cellCount = max($cellCount, $maxIdx + 1);
        $columns = max(1, $mobCols);
        // Explicit rows_mobile wins when it can hold every cell; otherwise
        // the implicit reflow (ceil) keeps all cells visible. v2.1.1428.
        $rawMR = $settings['rows_mobile'] ?? null;
        $rows  = max(
            ($rawMR !== null && $rawMR !== '') ? max(1, intval($rawMR)) : 1,
            (int) ceil($cellCount / $columns)
        );
    } elseif ($isEditor && $device === 'tablet' && !$autoFit) {
        // Tablet tab mirrors the frontend's <=1024px behaviour: explicit
        // columns_tablet wins, else >2-col desktop grids collapse to 2.
        // Previously the editor only resolved the MOBILE tab, so tablet
        // showed desktop columns and per-device edits looked unsaved.
        // v2.1.1428.
        $rawTC = $settings['columns_tablet'] ?? null;
        if ($rawTC !== null && $rawTC !== '') {
            $columns = max(1, intval($rawTC));
        } elseif ($origColumns > 2) {
            $columns = 2;
        }
        $cellCount = max(1, $origColumns * $origRows);
        $maxIdx = -1;
        foreach ($children as $_chk) {
            $so = is_object($_chk) ? intval($_chk->sort_order ?? 0) : intval($_chk['sort_order'] ?? 0);
            if ($so > $maxIdx) $maxIdx = $so;
        }
        if ($maxIdx >= 0) $cellCount = max($cellCount, $maxIdx + 1);
        $rawTR = $settings['rows_tablet'] ?? null;
        $rows  = max(
            ($rawTR !== null && $rawTR !== '') ? max(1, intval($rawTR)) : 1,
            (int) ceil($cellCount / $columns)
        );
    }

    // ── Animation + visibility (frontend only) ────────────────────
    $animAttrs = (!$isEditor && function_exists('impulse_get_animation_attrs'))
        ? impulse_get_animation_attrs($settings)
        : ['class' => '', 'style' => ''];

    // ── Frontend: emit per-grid @media for mobile/tablet column count ──
    //
    // cell_sizing controls how column tracks are sized:
    //   'stretch' (default): repeat(N, 1fr) — tracks fill the container, cards
    //                        with a section_max_width cap sit centered in wide
    //                        empty cells.
    //   'fit'              : repeat(N, auto) + justify-content:center — tracks
    //                        size to their natural content width and the whole
    //                        grid centers in its container. Cards hug each
    //                        other regardless of available width.
    // Per-device variants (cell_sizing_tablet, cell_sizing_mobile) override
    // the desktop value inside the corresponding media query. Locked 2026-05-26.
    $cellSizingDesk = (string) ($settings['cell_sizing']        ?? 'stretch');
    $cellSizingTab  = (string) ($settings['cell_sizing_tablet'] ?? $cellSizingDesk);
    $cellSizingMob  = (string) ($settings['cell_sizing_mobile'] ?? $cellSizingTab);

    // Fit-mode group alignment (Sergei 2026-06-25). Fit historically forced
    // `justify-content:center` on the content-width track group; now it honours
    // the grid's existing `justify` control so a row of content-sized cells
    // (e.g. two buttons) can be anchored left/right with a STATIC gap instead of
    // only centered. Backward-compat: grids are created with justify:'center'
    // (admin-core defaultSettings), so unset/center → 'center' = no change to
    // any existing Fit grid; only an explicit start/end shifts it. Applied as
    // ONE highest-precedence override appended to $gridFitCss below, so none of
    // the fragile per-device Fit rules need touching.
    $fitJustMap = ['start' => 'flex-start', 'left' => 'flex-start', 'center' => 'center', 'end' => 'flex-end', 'right' => 'flex-end', 'between' => 'space-between'];
    // Fit-mode column alignment follows the EXPOSED "Align" control
    // (grid_halign: Left/Center/Right), NOT the legacy `justify` value.
    // In strict-grid mode the panel never shows Justify (it lives only in
    // the auto-fit sub-panel), so a Fit grid's content-width columns were
    // pinned by whatever stale `justify` happened to be stored — commonly
    // 'start' from an auto-fit past — with no visible control to center
    // them (the "Align" the user does see writes grid_halign, which is an
    // inert margin on a full-width Fit grid). grid_halign defaults to
    // 'center', so every Fit grid without an explicit Left/Right now
    // centers, and Left/Right anchor the columns with a static gap.
    $fitJustVal = $fitJustMap[(string) ($settings['grid_halign'] ?? 'center')] ?? 'center';
    // In Fit mode the grid tracks are sized `max-content`, but the
    // measurement collapses to ~0 unless the cells themselves have a
    // concrete intrinsic width. The cells default to `display:flex;
    // flex-direction:column` with `width:100%` children inside (the
    // flip-box outer wrapper), which propagates as "size to parent" all
    // the way up — circular when the parent track is also sizing to
    // content. Force each cell to `width:max-content` in Fit mode so
    // the cell's own intrinsic width resolves first, then the track
    // wraps around it. Same scope rule lifts the inner flex-row wrapper
    // (a direct div child of the cell) out of `width:100%` so its
    // children's max-content (capped by section_max_width on the
    // flip-box, e.g. 200px) propagates up cleanly. Locked 2026-05-27.
    // The cell-width helper sizes each cell to `width:max-content` so that
    // a `repeat(N, max-content)` track template can measure something
    // concrete (otherwise the cells' flex chain — display:flex > div
    // width:100% > flip-box — collapses to ~0). MUST be scoped per
    // device because if cell_sizing is 'stretch' on a given device, the
    // tracks are `1fr` and cells should fill — forcing max-content there
    // shrinks the cells to their content width even inside stretch tracks.
    // Sergei: "On mobile, cells are squeezed to nearly zero width" — that
    // was this exact mismatch (desktop fit / mobile stretch, but cell
    // helper applied to both). Locked 2026-05-27.
    // Build the per-grid Fit/Stretch CSS string. We echo it directly inside
    // a <style> tag adjacent to the grid (below) instead of relying on
    // wp_add_inline_style — the editor preview path doesn't reliably print
    // accumulated inline styles for every render cycle, which is why the
    // editor desktop view was reading raw `repeat(N, max-content)` without
    // the cell-width helper and collapsing the cards to zero. Direct echo
    // works in BOTH editor and frontend. Locked 2026-05-27.
    $gridFitCss = '';
    if (!$autoFit) {
        // Fit mode uses FLEXBOX (not CSS Grid) because the flip-box block
        // has absolute-positioned faces and zero intrinsic width — CSS
        // Grid's `max-content` track measurement walks the cell's child
        // chain looking for intrinsic content size and finds 0, so every
        // track collapses. Flexbox uses different intrinsic-sizing rules:
        // a flex item with `width:auto` and child `width:100%;max-width:Npx`
        // resolves to Npx via shrink-to-fit, NOT to 0. This makes the
        // cards land at their section_max_width naturally, wrap into the
        // user's requested column count via flex-basis math, and stay
        // centered. Locked 2026-05-27.
        //
        // The `> .impulse-grid-cell > div` width:auto override breaks the
        // flex-row wrapper's `width:100%` inline so the flip-box max-width
        // wins. Without this the flex item would still try to fill via
        // 100% chain.
        // Track sizing for Fit mode depends on the children's nature:
        //   - flip_box has absolute-positioned faces ⇒ zero intrinsic
        //     width ⇒ `max-content` collapses to 0. Must use an
        //     explicit pixel width sourced from section_max_width.
        //   - everything else (button, icon, image, text, etc.) has a
        //     real intrinsic content width ⇒ `max-content` measures
        //     correctly and gives each track its natural content size,
        //     so the visible gap equals exactly the gap_horizontal value.
        // Walk children once to decide: any flip_box ⇒ pixel mode with
        // $fitFlipW from the first valid section_max_width.
        // Editor and frontend disagree on the field name carrying element
        // type: frontend uses `type`, but the editor preview's stdClass build
        // (impulse-preview.php:4276) maps the DB's `type` into `section_type`
        // and leaves `type` undefined on each child object. Reading only
        // `['type']` therefore missed every flip_box on the editor side, so
        // detection silently fell through to the max-content branch and
        // columns collapsed to 0px (the "screen 2 squeezed to zero width"
        // regression on page 113). Check BOTH keys so the same renderer is
        // correct in either context. Locked 2026-05-27.
        $fitFlipW = 200;
        $fitHasFlipBox = false;
        if (is_array($children)) {
            foreach ($children as $_ch) {
                $_chArr = is_object($_ch) ? (array) $_ch : $_ch;
                $_chType = $_chArr['type'] ?? ($_chArr['section_type'] ?? '');
                $_chSet  = $_chArr['settings'] ?? [];
                if (is_string($_chSet)) { $_d = json_decode($_chSet, true); $_chSet = is_array($_d) ? $_d : []; }
                if ($_chType === 'flip_box') {
                    $fitHasFlipBox = true;
                    if (!empty($_chSet['section_max_width'])) {
                        $fitFlipW = max(60, intval($_chSet['section_max_width']));
                        break;
                    }
                }
            }
        }
        // CSS Grid with explicit pixel tracks — now that the renderer knows
        // the child's section_max_width ($fitFlipW), tracks can be sized
        // deterministically per the user's column count. Avoids both the
        // max-content collapse (CSS Grid can't measure zero-intrinsic
        // flip-box content) AND the flex-wrap problem (where all N cards
        // sit on one row if the container is wide enough, ignoring the
        // designer's chosen column count).
        // Two flavours of the Fit track template:
        //   - flip-box children: explicit pixel tracks (max-content collapses).
        //   - everything else: `max-content` tracks (track sizes to natural
        //     content width — buttons/icons/images have real intrinsic widths
        //     so each track shrinks to exactly the child's render width).
        //     The cell-width helpers stay the same: cell width auto with
        //     max-width 100%, inner wrapper width auto.
        if ($fitHasFlipBox) {
            $fitTrackTpl = 'repeat(' . $columns . ',' . $fitFlipW . 'px)';
            $fitCellMaxW = $fitFlipW . 'px';
        } else {
            $fitTrackTpl = 'repeat(' . $columns . ',max-content)';
            // CSS Grid quirk: with `max-content` tracks, a percentage
            // max-width on the grid item creates a circular dependency.
            // The browser resolves it by treating the item's intrinsic
            // size as 0 → tracks collapse → cells squeezed to near zero.
            // `none` removes the cap entirely; the max-content track
            // measurement uses the item's natural content width
            // (button text + padding, icon size, etc.). 2.1.869.
            $fitCellMaxW = 'none';
        }
        // Inner-div max-width has to follow the same rule as the cell:
        // for max-content tracks (non-flip_box), a percentage max-width on
        // the inner triggers the same circular dependency that the cell's
        // max-width does — track sizing recurses through the inner, hits
        // the % cap, resolves to 0, cells collapse. `none` lets the
        // inner take its content's natural width (button text + padding,
        // etc.) and the track sizes to that. For flip_box tracks the
        // inner stays at 100% so it fills the pixel-sized cell.
        $fitInnerMaxW = $fitHasFlipBox ? '100%' : 'none';
        // :not([style*="max-width:"]) — a PX cap the author set on the inner
        // element is a definite size, so it creates no circular dependency and
        // must survive. Without this exclusion the blanket max-width:none wiped
        // the image block's Max W, and an SVG with no intrinsic size rendered at
        // its 300px default: three award badges came out huge on the live home
        // page while the editor (which does not emit this rule) showed them
        // correctly (Sergei 2026-07-28).
        $fitDeskRule = '#' . $gridId . '.impulse-grid-fit{grid-template-columns:' . $fitTrackTpl . '!important;justify-content:center!important}'
                     . '#' . $gridId . '.impulse-grid-fit > .impulse-grid-cell{width:auto!important;max-width:' . $fitCellMaxW . '!important}'
                     . '#' . $gridId . '.impulse-grid-fit > .impulse-grid-cell > div:not([style*="max-width:"]){width:auto!important;max-width:' . $fitInnerMaxW . '!important}';
        if ($fitHasFlipBox) {
            $fitDeskRule .= '#' . $gridId . '.impulse-grid-fit .impulse-flip-box{width:' . $fitFlipW . 'px!important;max-width:100%!important}';
        }
        if ($cellSizingDesk === 'fit') {
            $gridFitCss .= $fitDeskRule;
        }

        $tabColsForOverride = $settings['columns_tablet'] ?? null;
        $tabColsForOverride = ($tabColsForOverride !== null && $tabColsForOverride !== '') ? max(1, intval($tabColsForOverride)) : $columns;
        $mobColsForOverride = $settings['columns_mobile'] ?? null;
        $mobColsForOverride = ($mobColsForOverride !== null && $mobColsForOverride !== '') ? max(1, intval($mobColsForOverride)) : $columns;

        // Per-device @media overrides MUST match the desktop selector's
        // specificity — desktop fitDeskRule uses `#gridId.impulse-grid-fit`
        // (id + class), so a mobile @media rule targeting just `#gridId`
        // (id only) loses the specificity battle and the desktop's
        // `repeat(5, 200px)` leaks into mobile. Use the same `.impulse-grid-fit`
        // class qualifier inside @media. Also reset flip-box width back to
        // 100% inside stretch overrides so the explicit-pixel desktop rule
        // doesn't overflow narrow viewports. Locked 2026-05-27.
        if ($cellSizingTab === 'fit' && $cellSizingDesk !== 'fit') {
            $gridFitCss .= '@media(max-width:1024px)and(min-width:768px){'
                 . '#' . $gridId . '.impulse-grid-fit{grid-template-columns:repeat(' . $tabColsForOverride . ',' . $fitFlipW . 'px)!important;justify-content:center!important}'
                 . '#' . $gridId . '.impulse-grid-fit > .impulse-grid-cell{width:auto!important;max-width:' . $fitFlipW . 'px!important}'
                 . '#' . $gridId . '.impulse-grid-fit .impulse-flip-box{width:' . $fitFlipW . 'px!important;max-width:100%!important}}';
        } elseif ($cellSizingTab !== 'fit' && $cellSizingDesk === 'fit') {
            $gridFitCss .= '@media(max-width:1024px)and(min-width:768px){'
                 . '#' . $gridId . '.impulse-grid-fit{grid-template-columns:repeat(' . $tabColsForOverride . ',1fr)!important;justify-content:initial!important}'
                 . '#' . $gridId . '.impulse-grid-fit > .impulse-grid-cell{width:auto!important;max-width:100%!important}'
                 . '#' . $gridId . '.impulse-grid-fit > .impulse-grid-cell > div{width:100%!important;max-width:100%!important}'
                 . '#' . $gridId . '.impulse-grid-fit .impulse-flip-box{width:100%!important;max-width:' . $fitFlipW . 'px!important}}';
        }
        if ($cellSizingMob === 'fit' && ($cellSizingDesk !== 'fit' || $cellSizingTab !== 'fit')) {
            $gridFitCss .= '@media(max-width:767px){'
                 . '#' . $gridId . '.impulse-grid-fit{grid-template-columns:repeat(' . $mobColsForOverride . ',' . $fitFlipW . 'px)!important;justify-content:center!important}'
                 . '#' . $gridId . '.impulse-grid-fit > .impulse-grid-cell{width:auto!important;max-width:' . $fitFlipW . 'px!important}'
                 . '#' . $gridId . '.impulse-grid-fit .impulse-flip-box{width:' . $fitFlipW . 'px!important;max-width:100%!important}}';
        } elseif ($cellSizingMob !== 'fit' && ($cellSizingDesk === 'fit' || $cellSizingTab === 'fit')) {
            $gridFitCss .= '@media(max-width:767px){'
                 . '#' . $gridId . '.impulse-grid-fit{grid-template-columns:repeat(' . $mobColsForOverride . ',1fr)!important;justify-content:initial!important}'
                 . '#' . $gridId . '.impulse-grid-fit > .impulse-grid-cell{width:auto!important;max-width:100%!important}'
                 . '#' . $gridId . '.impulse-grid-fit > .impulse-grid-cell > div{width:100%!important;max-width:100%!important}'
                 . '#' . $gridId . '.impulse-grid-fit .impulse-flip-box{width:100%!important;max-width:' . $fitFlipW . 'px!important}}';
        }

        // Group alignment override. A single id+class rule appended LAST wins
        // over every per-device Fit rule above (equal specificity, later in
        // source) AND over the head's id-only @media rules (higher specificity),
        // at all widths — so the content-width track group anchors per `justify`.
        // Emitted only when non-default AND a device is actually Fit, so every
        // existing Fit grid (and all non-Fit grids) stay byte-identical.
        if ($fitJustVal !== 'center' && ($cellSizingDesk === 'fit' || $cellSizingTab === 'fit' || $cellSizingMob === 'fit')) {
            $gridFitCss .= '#' . $gridId . '.impulse-grid-fit{justify-content:' . $fitJustVal . '!important}';
        }
    }
    if (!$isEditor && !$autoFit && function_exists('wp_add_inline_style')) {
        // Mobile.
        $mobColsRaw = $settings['columns_mobile'] ?? null;
        $mobCols = null;
        if ($mobColsRaw !== null && $mobColsRaw !== '') $mobCols = max(1, intval($mobColsRaw));
        elseif ($mobileLayout === '2col') $mobCols = 2;
        elseif ($mobileLayout === 'keep') $mobCols = $columns;
        /* Always emit the mobile rule when a value is set — the old
           `&& $mobCols !== 1` short-circuit skipped emission when
           designers explicitly chose 1 column, assuming 1 was the
           default. It isn't: the default falls back to the desktop
           `columns` value at narrow widths, so a 5-col desktop grid
           stayed 5-col on mobile and the cells crushed to ~70px wide
           with content unreadable. Now: any explicit mobile value
           emits the rule. */
        if ($mobCols !== null) {
            $mobTrack = $cellSizingMob === 'fit' ? 'max-content' : '1fr';
            $mobJust  = $cellSizingMob === 'fit' ? ';justify-content:center' : '';
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . $gridId . '{grid-template-columns:repeat(' . $mobCols . ',' . $mobTrack . ')!important' . $mobJust . '}}');
        } elseif ($cellSizingMob === 'fit') {
            // Mobile inherits column count from desktop but still wants fit
            // sizing — emit just the override.
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . $gridId . '{grid-template-columns:repeat(' . $columns . ',max-content)!important;justify-content:center}}');
        }
        // Per-device max-width override (width_pct_mobile / width_pct_tablet).
        // The shared resolver impulse_resolve_content_width() reads only
        // `width_pct`, so a desktop 67% constraint applied to mobile too,
        // squeezing content into a narrow column on phones even after
        // columns_mobile collapsed the grid. Emit an explicit per-device
        // max-width rule when an override exists. 2026-05-30.
        $mobWpctRaw = $settings['width_pct_mobile'] ?? null;
        if ($_mobileSingleCol) {
            // Single stacked column — width_pct is meaningless, use the full width.
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . $gridId . '{max-width:100%!important}}');
        } elseif ($mobWpctRaw !== null && $mobWpctRaw !== '') {
            $mobWpct = max(30, min(100, intval($mobWpctRaw)));
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . $gridId . '{max-width:' . $mobWpct . '%!important}}');
        } else {
            // No explicit mobile width: a desktop width_pct < 100 (a centered narrow
            // grid) otherwise leaks onto phones and wastes edge space. Default mobile
            // grids to full width so stacked content uses the screen. Overridable via
            // width_pct_mobile. Sergei 2026-07-02.
            $_deskWpct = intval($settings['width_pct'] ?? 100);
            if ($_deskWpct > 0 && $_deskWpct < 100) {
                wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . $gridId . '{max-width:100%!important}}');
            }
        }
        $tabWpctRaw = $settings['width_pct_tablet'] ?? null;
        if ($tabWpctRaw !== null && $tabWpctRaw !== '') {
            $tabWpct = max(30, min(100, intval($tabWpctRaw)));
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:1024px)and(min-width:768px){#' . $gridId . '{max-width:' . $tabWpct . '%!important}}');
        }
        // Tablet.
        // Explicit per-device row counts - emitted only when the designer
        // set them (rows joined RESPONSIVE_SETTINGS in v2.1.1428); implicit
        // auto-flow rows keep covering the unset case.
        $mobRowsRaw = $settings['rows_mobile'] ?? null;
        if ($mobRowsRaw !== null && $mobRowsRaw !== '') {
            $mobRows = max(1, intval($mobRowsRaw));
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . $gridId . '{grid-template-rows:repeat(' . $mobRows . ',minmax(80px,auto))!important}}');
        }
        $tabRowsRaw = $settings['rows_tablet'] ?? null;
        if ($tabRowsRaw !== null && $tabRowsRaw !== '') {
            $tabRows = max(1, intval($tabRowsRaw));
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:1024px)and(min-width:768px){#' . $gridId . '{grid-template-rows:repeat(' . $tabRows . ',minmax(80px,auto))!important}}');
        }
        $tabColsRaw = $settings['columns_tablet'] ?? null;
        $tabFitOnly = $cellSizingTab === 'fit'; // need an override even if column count matches desktop
        if ($tabColsRaw !== null && $tabColsRaw !== '') {
            $tabCols = max(1, intval($tabColsRaw));
            if ($tabCols !== $columns || $tabFitOnly) {
                $tabTrack = $cellSizingTab === 'fit' ? 'max-content' : '1fr';
                $tabJust  = $cellSizingTab === 'fit' ? ';justify-content:center' : '';
                wp_add_inline_style('instinctor-page-inline', '@media(max-width:1024px)and(min-width:768px){#' . $gridId . '{grid-template-columns:repeat(' . $tabCols . ',' . $tabTrack . ')!important' . $tabJust . '}}');
            }
        } elseif ($columns > 2) {
            $tabTrack = $cellSizingTab === 'fit' ? 'max-content' : '1fr';
            $tabJust  = $cellSizingTab === 'fit' ? ';justify-content:center' : '';
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:1024px)and(min-width:768px){#' . $gridId . '{grid-template-columns:repeat(2,' . $tabTrack . ')!important' . $tabJust . '}}');
        } elseif ($tabFitOnly) {
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:1024px)and(min-width:768px){#' . $gridId . '{grid-template-columns:repeat(' . $columns . ',max-content)!important;justify-content:center}}');
        }
    }

    // ── Wrapper (when bg/overlay) ─────────────────────────────────
    $hasBackground = $bgImage || ($bgColor && $bgColor !== 'transparent');
    $hasOverlay    = $overlayColor || $overlayImage;
    $needsWrapper  = $isEditor || $hasBackground || $hasOverlay; // editor ALWAYS wraps for badge/dropzone scaffolding
    // Device-aware width_pct overlay: the shared resolver reads only
    // `width_pct`, so in editor preview at a mobile/tablet device tab the
    // desktop value was leaking through (e.g. 67% on a phone). Overlay the
    // per-device override into the local $settings copy before calling the
    // resolver. Frontend still relies on the per-device @media rules emitted
    // further down. Same idea as the device-aware reads for depth_intensity
    // / cell_padding below. 2026-05-30.
    if ($isEditor && $device !== 'desktop') {
        if ($device === 'mobile' && $_mobileSingleCol) {
            // Single stacked column on mobile: full width regardless of any stale
            // width_pct_mobile (matches the frontend @media rule below).
            $settings['width_pct'] = 100;
        } else {
            $_dwRaw = $settings['width_pct_' . $device] ?? '';
            if ($_dwRaw !== '') {
                $settings['width_pct'] = max(30, min(100, intval($_dwRaw)));
            } elseif ($device === 'mobile') {
                // No explicit mobile width: phones use full width by default so a
                // desktop narrow width_pct (a centered multi-column grid) doesn't leak
                // onto the stacked mobile layout and waste edge space. Overridable via
                // width_pct_mobile. Sergei 2026-07-02.
                $_dw = intval($settings['width_pct'] ?? 100);
                if ($_dw > 0 && $_dw < 100) $settings['width_pct'] = 100;
            }
        }
    }
    $contentWidth  = function_exists('impulse_resolve_content_width')
        ? impulse_resolve_content_width($settings)
        : '100%';

    // Grid horizontal alignment (left/center/right, default center). Left/right
    // use !important so they beat the blanket `.impulse-el[data-type="grid"] …
    // {margin-left:auto!important}` centering rule in frontend.css — inline
    // !important wins over stylesheet !important. Center keeps the legacy
    // auto-margins, so every existing grid is byte-for-byte unchanged and only
    // grids the user explicitly sets to Left/Right move. (Sergei 2026-06-29)
    $gridHalign = (string) ($settings['grid_halign'] ?? 'center');
    if (!in_array($gridHalign, ['left', 'center', 'right'], true)) $gridHalign = 'center';
    $halignMargin = $gridHalign === 'left'  ? 'margin-left:0 !important;margin-right:auto !important;'
                  : ($gridHalign === 'right' ? 'margin-left:auto !important;margin-right:0 !important;'
                  : 'margin-left:auto;margin-right:auto;');
    // When the grid is a flex item inside a parent cell/section whose align-items is
    // center (a common hero layout), auto margins alone don't left/right-anchor it —
    // the cross-axis alignment wins. Emit align-self for explicit left/right so a grid
    // set to Left actually hugs the left edge even in a centered parent. Center is left
    // untouched so full-width stretch grids are byte-for-byte unchanged. Sergei 2026-07-02.
    if ($gridHalign === 'left')  $halignMargin .= 'align-self:flex-start !important;';
    elseif ($gridHalign === 'right') $halignMargin .= 'align-self:flex-end !important;';
    // Center must ALSO pin align-self, or a centered grid inside a section whose
    // content_align is left/right inherits that cross-axis alignment (flex-start/
    // -end) and gets pushed to the edge instead of centering. align-self:center on
    // a width:100% grid is a visual no-op (it already fills), so full-width stretch
    // grids stay byte-for-byte unchanged; it only bites when a max-width/width_pct
    // makes the grid narrower than a non-centered parent. Sergei 2026-07-03.
    else $halignMargin .= 'align-self:center !important;';

    $wrapperStyle = '';
    if ($needsWrapper) {
        $wrapperStyle = 'position:relative;width:100%;' . $halignMargin;
        if ($borderRadius > 0) $wrapperStyle .= 'border-radius:' . $borderRadius . 'px;overflow:hidden;';
        if ($marginTop > 0)    $wrapperStyle .= 'margin-top:'    . $marginTop    . 'px;';
        if ($marginBottom > 0) $wrapperStyle .= 'margin-bottom:' . $marginBottom . 'px;';
        $wrapperStyle .= 'max-width:' . $contentWidth . ';';
        if ($minHeight > 0)    $wrapperStyle .= 'min-height:' . $minHeight . 'px;';
        if ($bgColor && $bgColor !== 'transparent') $wrapperStyle .= 'background-color:' . esc_attr($bgColor) . ';';
        if ($bgImage !== '') {
            $wrapperStyle .= 'background-image:url(' . esc_url($bgImage) . ');';
            $wrapperStyle .= 'background-size:' . esc_attr($bgSize) . ';';
            $wrapperStyle .= 'background-position:' . esc_attr($bgPosition) . ';';
            $wrapperStyle .= 'background-repeat:' . esc_attr($bgRepeat) . ';';
        }
        if (!empty($animAttrs['style'])) $wrapperStyle .= $animAttrs['style'];
    }

    // ── Inner grid style ─────────────────────────────────────────
    $gapHvar = 'var(--impulse-gap-horizontal,' . intval($gapH) . 'px)';
    $gapVvar = 'var(--impulse-gap-vertical,'   . intval($gapV) . 'px)';
    if ($layoutMode === 'auto_fit') {
        $direction = (string) ($settings['direction'] ?? 'row');
        $justify   = (string) ($settings['justify']   ?? 'start');
        $align     = (string) ($settings['align']     ?? 'stretch');
        $wrap      = (string) ($settings['wrap']      ?? 'wrap');
        $justifyM  = ['start' => 'flex-start', 'center' => 'center', 'end' => 'flex-end', 'between' => 'space-between'];
        $alignM    = ['start' => 'flex-start', 'center' => 'center', 'end' => 'flex-end', 'stretch' => 'stretch'];
        $style  = 'width:100%;display:flex;flex-direction:' . esc_attr($direction) . ';';
        $style .= 'justify-content:' . ($justifyM[$justify] ?? 'flex-start') . ';';
        $style .= 'align-items:' . ($alignM[$align] ?? 'stretch') . ';';
        $style .= 'flex-wrap:' . esc_attr($wrap) . ';';
        $style .= 'gap:' . $gapVvar . ' ' . $gapHvar . ';';
    } elseif ($layoutMode === 'masonry') {
        $style = 'width:100%;column-count:' . $columns . ';column-gap:' . $gapHvar . ';--impulse-masonry-gap:' . intval($gapV) . 'px;';
    } else {
        // Per-track sizing.
        $colTpl = [];
        $firstW = null;
        $uniform = true;
        for ($i = 0; $i < $columns; $i++) {
            $w = (int) ($settings['col_width_' . $i] ?? 10);
            if ($w <= 0) $w = 10;
            if ($firstW === null) $firstW = $w;
            elseif ($w !== $firstW) $uniform = false;
            $colTpl[] = $w . 'fr';
        }
        // cell_sizing 'fit' on desktop: tracks size to natural content width
        // (repeat(N, max-content)) so cards with a section_max_width cap
        // don't sit marooned in wide 1fr cells. The grid itself then
        // centers in its container via justify-content:center below.
        //
        // In editor mode the inline template needs to follow the CURRENT
        // device tab, not just desktop — otherwise switching to Tablet /
        // Mobile in the editor shows desktop's track sizing instead of
        // the per-device cell_sizing the designer chose. (Frontend does
        // this via the @media-query block below, which is editor-skipped.)
        $cellSizingActive = $cellSizingDesk;
        if ($isEditor) {
            if      ($device === 'mobile') $cellSizingActive = $cellSizingMob;
            elseif  ($device === 'tablet') $cellSizingActive = $cellSizingTab;
        }
        if ($cellSizingActive === 'fit') {
            $colTemplate = 'repeat(' . $columns . ',max-content)';
        } else {
            $colTemplate = $uniform ? 'repeat(' . $columns . ',1fr)' : implode(' ', $colTpl);
        }

        // Which rows hold something. The editor's 80px row floor exists so an
        // EMPTY cell is still a drop target; applied to every row it inflated
        // rows that already had content, so a stack of 44px buttons showed 80px
        // rows in the canvas and the gap slider looked broken (Sergei 2026-07-31,
        // home screen 2). Occupied rows size to content, exactly like the
        // frontend; empty rows keep the floor.
        $_rowOccupied = [];
        if ($isEditor) {
            $_cellsTotal = max(1, $columns * $rows);
            foreach ($children as $_rc) {
                $_so = is_object($_rc) ? intval($_rc->sort_order ?? 0) : intval($_rc['sort_order'] ?? 0);
                if ($_so < 0) $_so = 0;
                if ($_so >= $_cellsTotal) $_so = $_cellsTotal - 1;
                $_rowOccupied[intdiv($_so, max(1, $columns))] = true;
            }
        }

        $rowTpl = [];
        $hasCustomRowHeight = false;
        for ($i = 0; $i < $rows; $i++) {
            $h = (int) ($settings['row_height_' . $i] ?? 0);
            if ($h > 0) { $hasCustomRowHeight = true; $rowTpl[] = $h . 'px'; }
            else        { $rowTpl[] = ($isEditor && empty($_rowOccupied[$i])) ? 'minmax(80px,auto)' : 'auto'; }
        }
        $rowTemplate = $isEditor
            ? implode(' ', $rowTpl)
            : ($hasCustomRowHeight ? implode(' ', $rowTpl) : null);

        $style  = 'width:100%;display:grid;grid-template-columns:' . $colTemplate . ';';
        $style .= 'column-gap:' . $gapHvar . ';row-gap:' . $gapVvar . ';';
        if ($cellSizingActive === 'fit') $style .= 'justify-content:center;';
        if ($rowTemplate !== null) $style .= 'grid-template-rows:' . $rowTemplate . ';';
    }

    if ($padding > 0) {
        $style .= 'padding:var(--impulse-padding,' . $padding . 'px);';
    }
    if ($minHeight > 0) $style .= 'min-height:' . $minHeight . 'px;';
    if (!$needsWrapper) {
        if ($bgColor && $bgColor !== 'transparent') $style .= 'background:' . esc_attr($bgColor) . ';';
        if ($borderRadius > 0)                       $style .= 'border-radius:' . $borderRadius . 'px;';
        if ($marginTop > 0)                          $style .= 'margin-top:' . $marginTop . 'px;';
        if ($marginBottom > 0)                       $style .= 'margin-bottom:' . $marginBottom . 'px;';
        if (!empty($animAttrs['style']))             $style .= $animAttrs['style'];
    }
    // Inner grid horizontal alignment (mirrors the wrapper) + max-width.
    $style .= $halignMargin . 'max-width:' . $contentWidth . ';';
    if ($hasOverlay) $style .= 'position:relative;z-index:1;';

    $class = 'impulse-grid';
    if ($layoutMode === 'masonry') $class .= ' impulse-grid-masonry';
    // Tag fit-mode grids so the per-grid CSS injection (above) can target
    // their cells and force `width:max-content` — without the class, the
    // grid-template-columns:repeat(N,max-content) measures the cell's
    // flex chain to ~0 and all 10 cards collapse to a single column.
    if ($cellSizingDesk === 'fit' || $cellSizingTab === 'fit' || $cellSizingMob === 'fit') {
        $class .= ' impulse-grid-fit';
    }
    if (!$isEditor && function_exists('impulse_get_visibility_classes')) {
        $vc = impulse_get_visibility_classes($settings);
        if ($vc !== '') $class .= ' ' . $vc;
    }
    if (!$needsWrapper && !empty($animAttrs['class'])) $class .= ' ' . $animAttrs['class'];

    $wrapperClass = 'impulse-grid-wrapper';
    if ($needsWrapper && !empty($animAttrs['class'])) $wrapperClass .= ' ' . $animAttrs['class'];

    if (!$isEditor && !empty($GLOBALS['impulse_use_fill_wrapper'])) $style .= 'height:100%;';

    // 3D depth attrs.
    $depthIntensity = max(0, min(100, intval($read_resp = ($isEditor && function_exists('impulse_get_responsive') ? impulse_get_responsive($settings, 'depth_intensity', 0, $device) : ($settings['depth_intensity'] ?? 0)))));
    $tiltIntensity  = max(0, min(100, intval($isEditor && function_exists('impulse_get_responsive') ? impulse_get_responsive($settings, 'tilt_intensity', 0, $device) : ($settings['tilt_intensity'] ?? 0))));
    if (!$autoFit) $GLOBALS['impulse_needs_cell_depth'] = true;

    // Cell padding (editor pre-resolves via device_val; frontend reads desktop).
    $cellPadH = $isEditor && function_exists('impulse_device_val')
        ? intval(impulse_device_val($settings, 'cell_padding_h', $device, 0))
        : intval($settings['cell_padding_h'] ?? 0);
    $cellPadV = $isEditor && function_exists('impulse_device_val')
        ? intval(impulse_device_val($settings, 'cell_padding_v', $device, 0))
        : intval($settings['cell_padding_v'] ?? 0);

    // Mobile rule (Sergei 2026-07-02): grid cells collapse to a compact 14px padding
    // on phones so feature-card stacks stay short (long pages lose readers).
    // It is a CAP, not a value (Sergei 2026-07-31): a cell padded 10px on desktop
    // used to grow to 14px on phones, which read as "the top padding control does
    // nothing on mobile". min() keeps the original intent (collapse large desktop
    // paddings) and never inflates a smaller one. An explicit
    // cell_padding_v/h_mobile still wins outright. The frontend emits the capped
    // value per cell in the responsive block below (per-cell overrides need the
    // higher [data-cell] specificity to survive their own inline padding).
    $_cellPadMobCap     = 14;
    $_cellPadMobDefault = (($settings['cell_padding_v_mobile'] ?? '') === '')
        && (($settings['cell_padding_h_mobile'] ?? '') === '');
    $_cellPadMobHard = $isEditor && $device === 'mobile' && $_cellPadMobDefault;

    // ── Compose HTML ─────────────────────────────────────────────
    ob_start();
    // Emit the per-grid Fit/Stretch CSS as a <style> tag adjacent to the
    // grid. Direct echo, no wp_add_inline_style — works reliably across
    // editor preview + frontend render paths.
    if ($gridFitCss !== '') {
        wp_add_inline_style('instinctor-page-inline', $gridFitCss);
    }
    if ($needsWrapper) {
        $hasShapes = !empty($settings['bg_shapes']) && $settings['bg_shapes'] !== '[]';
        echo '<div class="' . esc_attr($wrapperClass) . '" style="' . esc_attr($wrapperStyle) . ($hasShapes ? 'overflow:hidden;' : '') . '">';
        if (function_exists('impulse_render_bg_shapes')) {
            impulse_render_bg_shapes($settings, $device, $isEditor);
        }
        if ($isEditor && function_exists('impulse_visibility_badge')) {
            echo wp_kses(impulse_visibility_badge($settings), impulse_svg_allowed());
        }
        if ($hasOverlay) {
            $overlayStyle = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;';
            if ($overlayColor) $overlayStyle .= 'background-color:' . esc_attr($overlayColor) . ';';
            if ($overlayImage) {
                $overlayStyle .= 'background-image:url(' . esc_url($overlayImage) . ');';
                $overlayStyle .= 'background-size:' . esc_attr($overlaySize) . ';';
                $overlayStyle .= 'background-position:center;';
            }
            $overlayStyle .= 'opacity:' . $overlayOpacity . ';';
            echo '<div class="impulse-grid-overlay" style="' . esc_attr($overlayStyle) . '"></div>';
        }
    }

    // Grid-as-gallery cinema-zoom lightbox (2.1.963). When `images_lightbox`
    // is on, the wrapper emits `data-images-lightbox="1"` and a singleton
    // runtime wires every <img> inside to a FLIP-animated zoom:
    //   - Click image → it physically travels from its grid cell position to
    //     the viewport center, scaling up to its natural size (capped to fit).
    //     The page behind dims AND backdrop-blurs (not solid black) so the
    //     user can still feel the page is there, just out of focus.
    //   - Click backdrop or press ESC → the image animates back to its cell
    //     and unmounts. The original <img> stays hidden during the trip so
    //     there's no flicker.
    // Replaces the deprecated Gallery block's lightbox toggle.
    $imagesLightbox = !empty($settings['images_lightbox']) && $settings['images_lightbox'] !== '0';
    if ($imagesLightbox && empty($GLOBALS['impulse_grid_lightbox_emitted'])) {
        $GLOBALS['impulse_grid_lightbox_emitted'] = true;
        // CSS — backdrop blurs the page underneath; image floats with a soft
        // drop-shadow; both transition ease-in-out per CLAUDE.md rule. No
        // close button, no border, no chrome — only the image is visible.
        $lbCss  = '.impulse-grid[data-images-lightbox] img{cursor:zoom-in;transition:opacity 0.22s ease-in-out}';
        $lbCss .= '.impulse-grid[data-images-lightbox] img.impulse-grid-lb-source-hidden{opacity:0}';
        $lbCss .= '.impulse-grid-lightbox-backdrop{position:fixed;inset:0;z-index:2147483600;background:rgba(0,0,0,0.55);backdrop-filter:blur(24px) brightness(0.55);-webkit-backdrop-filter:blur(24px) brightness(0.55);opacity:0;pointer-events:none;transition:opacity 0.32s ease-in-out;cursor:zoom-out}';
        $lbCss .= '.impulse-grid-lightbox-backdrop.active{opacity:1;pointer-events:auto}';
        $lbCss .= '.impulse-grid-lightbox-img{position:fixed;z-index:2147483601;pointer-events:none;object-fit:cover;display:block;border-radius:inherit;box-shadow:0 30px 80px rgba(0,0,0,0.45);transition:top 0.42s cubic-bezier(0.4,0,0.2,1),left 0.42s cubic-bezier(0.4,0,0.2,1),width 0.42s cubic-bezier(0.4,0,0.2,1),height 0.42s cubic-bezier(0.4,0,0.2,1),border-radius 0.42s cubic-bezier(0.4,0,0.2,1)}';
        $lbCss .= '@media (prefers-reduced-motion:reduce){.impulse-grid-lightbox-backdrop,.impulse-grid-lightbox-img{transition:none}}';
        if (function_exists('wp_add_inline_style')) {
            wp_add_inline_style('instinctor-page-inline', $lbCss);
        } else {
            wp_add_inline_style('instinctor-page-inline', $lbCss);
        }
        // FLIP-animated runtime. The travelling image is a fixed-position
        // clone seeded at the source cell's bounding rect, then transitioned
        // to a centered target rect. Reversing the transition returns it.
        $lbJs  = '(function(){if(window.__impGridLightbox)return;window.__impGridLightbox=1;';
        $lbJs .= 'var state=null;'; // {sourceImg, clone, backdrop, sourceRect}
        $lbJs .= 'function compTarget(natW,natH){var vw=window.innerWidth,vh=window.innerHeight,maxW=vw*0.9,maxH=vh*0.9;var w=natW||maxW,h=natH||maxH;var s=Math.min(maxW/w,maxH/h,1);w=w*s;h=h*s;return{x:(vw-w)/2,y:(vh-h)/2,w:w,h:h};}';
        $lbJs .= 'function open(img){if(state)return;var r=img.getBoundingClientRect();var clone=document.createElement("img");clone.className="impulse-grid-lightbox-img";clone.src=img.currentSrc||img.src;clone.alt=img.alt||"";';
        // Seed clone at source rect (FIRST state)
        $lbJs .= 'clone.style.top=r.top+"px";clone.style.left=r.left+"px";clone.style.width=r.width+"px";clone.style.height=r.height+"px";';
        // Match the source cell's border-radius so the trip starts visually identical
        $lbJs .= 'var cs=window.getComputedStyle(img);clone.style.borderRadius=cs.borderRadius||"0";';
        $lbJs .= 'var bd=document.createElement("div");bd.className="impulse-grid-lightbox-backdrop";document.body.appendChild(bd);document.body.appendChild(clone);';
        $lbJs .= 'img.classList.add("impulse-grid-lb-source-hidden");';
        // Use natural dimensions if loaded, else fall back to rendered size scaled up
        $lbJs .= 'var natW=img.naturalWidth||r.width*2,natH=img.naturalHeight||r.height*2;';
        $lbJs .= 'state={img:img,clone:clone,bd:bd,r:r};';
        $lbJs .= 'bd.addEventListener("click",close);';
        // Animate to LAST state (target rect, centered)
        $lbJs .= 'requestAnimationFrame(function(){requestAnimationFrame(function(){bd.classList.add("active");var t=compTarget(natW,natH);clone.style.top=t.y+"px";clone.style.left=t.x+"px";clone.style.width=t.w+"px";clone.style.height=t.h+"px";clone.style.borderRadius="4px";});});';
        $lbJs .= '}';
        $lbJs .= 'function close(){if(!state)return;var s=state;state=null;s.bd.classList.remove("active");';
        // Reverse — return clone to source rect, then unmount
        $lbJs .= 's.clone.style.top=s.r.top+"px";s.clone.style.left=s.r.left+"px";s.clone.style.width=s.r.width+"px";s.clone.style.height=s.r.height+"px";';
        $lbJs .= 'var cs=window.getComputedStyle(s.img);s.clone.style.borderRadius=cs.borderRadius||"0";';
        $lbJs .= 'setTimeout(function(){if(s.bd.parentNode)s.bd.parentNode.removeChild(s.bd);if(s.clone.parentNode)s.clone.parentNode.removeChild(s.clone);s.img.classList.remove("impulse-grid-lb-source-hidden");},430);';
        $lbJs .= '}';
        // Re-seed the SOURCE rect on resize/scroll while open so the return
        // trip lands on the cell's new position (page may have scrolled).
        $lbJs .= 'function refresh(){if(!state)return;state.r=state.img.getBoundingClientRect();}';
        $lbJs .= 'window.addEventListener("resize",refresh);window.addEventListener("scroll",refresh,true);';
        // Delegated click — every image inside a lightbox-grid opens.
        $lbJs .= 'document.addEventListener("click",function(e){var g=e.target&&e.target.closest&&e.target.closest(".impulse-grid[data-images-lightbox]");if(!g)return;var img=e.target.closest("img");if(!img||!img.src)return;e.preventDefault();e.stopPropagation();if(state)close();else open(img);},true);';
        $lbJs .= 'document.addEventListener("keydown",function(e){if(e.key==="Escape")close();});';
        $lbJs .= '})();';
        wp_add_inline_script('instinctor-frontend-js', '' . $lbJs . '', 'after');
    }

    // Idle wave — periodic diagonal flip-box "peek" plus an arrival flip-teaser
    // each time the grid's screen becomes the one the visitor is looking at, so
    // an otherwise-static flip-box grid feels alive. Frontend only (skipped in
    // editor). Gated to non-touch pointers (touch grids already get the built-in
    // one-shot tease from flip_box.php). Singleton CSS + runtime, once per page.
    //
    // GATING — keyed on what the visitor actually SEES, not IntersectionObserver
    // alone. Every Impulse page is a sequence of full-viewport screens; inactive
    // ones are scaled down (~0.375) but stay inside the viewport box, so IO calls
    // them "visible" even though they're tucked away. We instead go live only
    // when the grid is rendered at ~full scale (rect.width / offsetWidth ≈ 1) AND
    // intersects the viewport. That single rule is correct for hero-zone screens
    // (scale toggles 0.375↔1) and body-zone screens (always full-scale, scroll)
    // without caring HOW a screen is hidden. evaluate() is idempotent and is
    // driven by IO (scroll), the screen's transform transitionend (hero settle),
    // a MutationObserver on the screen, resize, and one initial call.
    $liveWave = !$isEditor && !empty($settings['live_wave']) && $settings['live_wave'] !== '0';
    if ($liveWave && empty($GLOBALS['impulse_grid_wave_emitted'])) {
        $GLOBALS['impulse_grid_wave_emitted'] = true;
        $waveJs  = '(function(){if(window.__impGridWave)return;window.__impGridWave=1;';
        $waveJs .= 'var mm=window.matchMedia;if(mm&&(mm("(prefers-reduced-motion:reduce)").matches||mm("(pointer:coarse)").matches))return;';
        $waveJs .= 'var PEEK=15,PEEK_MS=700,CYCLE_MS=9000,SPREAD_MS=900,FIRST_MS=2800;';
        $waveJs .= 'var st=document.createElement("style");st.textContent="@keyframes _igw{0%,100%{transform:perspective(700px) rotateY(0)}45%{transform:perspective(700px) rotateY("+PEEK+"deg)}}.impulse-grid[data-live-wave] .impulse-flip-inner.imp-wave-peek{animation:_igw "+(PEEK_MS/1000)+"s cubic-bezier(0.4,0,0.2,1) 1 both}";document.head.appendChild(st);';
        $waveJs .= 'function phase(grid,box){var g=grid.getBoundingClientRect(),b=box.getBoundingClientRect();var px=g.width>0?(b.left+b.width/2-g.left)/g.width:0,py=g.height>0?(b.top+b.height/2-g.top)/g.height:0;return Math.max(0,Math.min(1,(px+py)/2));}';
        $waveJs .= 'function wire(grid){if(grid._waveWired)return;grid._waveWired=true;var boxes=[].slice.call(grid.querySelectorAll(".impulse-flip-box"));if(!boxes.length)return;var hovering=false,live=false,timer=null,firstT=null;';
        $waveJs .= 'grid.addEventListener("mouseenter",function(){hovering=true;});grid.addEventListener("mouseleave",function(){hovering=false;});';
        $waveJs .= 'function peekOnce(){if(hovering||!live||document.hidden)return;boxes.forEach(function(b){if(b._touchFlipped)return;var fi=b.querySelector(".impulse-flip-inner");if(!fi)return;setTimeout(function(){if(hovering||!live||b._touchFlipped)return;if(fi.style.transform&&fi.style.transform.indexOf("180")>-1)return;fi.classList.remove("imp-wave-peek");void fi.offsetWidth;fi.classList.add("imp-wave-peek");var ae=function(){fi.classList.remove("imp-wave-peek");fi.removeEventListener("animationend",ae);};fi.addEventListener("animationend",ae);},phase(grid,b)*SPREAD_MS);});}';
        $waveJs .= 'function tease(){boxes.forEach(function(b){setTimeout(function(){if(hovering||!live||b._touchFlipped)return;b.dispatchEvent(new MouseEvent("mouseenter",{bubbles:true}));setTimeout(function(){b.dispatchEvent(new MouseEvent("mouseleave",{bubbles:true}));},1500);},phase(grid,b)*SPREAD_MS);});}';
        $waveJs .= 'function start(){if(timer)return;firstT=setTimeout(peekOnce,FIRST_MS);timer=setInterval(peekOnce,CYCLE_MS);}';
        $waveJs .= 'function stop(){if(timer){clearInterval(timer);timer=null;}if(firstT){clearTimeout(firstT);firstT=null;}}';
        $waveJs .= 'function evaluate(){var r=grid.getBoundingClientRect();var scale=r.width/(grid.offsetWidth||1);var inView=r.width>0&&r.bottom>0&&r.top<window.innerHeight&&r.right>0&&r.left<window.innerWidth;var now=inView&&scale>0.9;if(now===live)return;live=now;if(live){setTimeout(tease,350);start();}else{stop();}}';
        $waveJs .= 'if(typeof IntersectionObserver!=="undefined"){new IntersectionObserver(function(){evaluate();},{threshold:[0,0.2,0.5,0.8,1]}).observe(grid);}';
        $waveJs .= 'var sc=grid.closest&&grid.closest(".impulse-screen");if(sc){sc.addEventListener("transitionend",function(e){if(e.propertyName==="transform")evaluate();});if(typeof MutationObserver!=="undefined"){new MutationObserver(function(){setTimeout(evaluate,60);}).observe(sc,{attributes:true,attributeFilter:["class","style"]});}}';
        $waveJs .= 'window.addEventListener("resize",evaluate);evaluate();}';
        $waveJs .= 'function init(){document.querySelectorAll(".impulse-grid[data-live-wave]").forEach(wire);}';
        $waveJs .= 'if(document.readyState!=="loading")init();else document.addEventListener("DOMContentLoaded",init);';
        $waveJs .= '})();';
        wp_add_inline_script('instinctor-frontend-js', '' . $waveJs . '', 'after');
    }

    echo '<div id="' . esc_attr($gridId) . '" class="' . esc_attr($class) . '" style="' . esc_attr($style) . '"';
    if (!$autoFit) {
        echo ' data-depth-intensity="' . (int) $depthIntensity . '" data-tilt-intensity="' . (int) $tiltIntensity . '" data-depth-cols="' . (int) $columns . '"';
    }
    if ($imagesLightbox) echo ' data-images-lightbox="1"';
    if ($liveWave)       echo ' data-live-wave="1"';
    echo '>';

    $gridBgCellIndices = []; // mobile min-height tracker

    if ($autoFit) {
        foreach ($children as $child) {
            $renderChild($child);
        }
        if ($isEditor) {
            echo '<div class="impulse-dropzone" data-parent="' . esc_attr((string) $elId) . '"></div>';
        }
    } else {
        // Group children by sort_order = cell position.
        $totalCells = $columns * $rows;
        $childrenByCell = [];
        foreach ($children as $child) {
            $so = is_object($child) ? intval($child->sort_order ?? 0) : intval($child['sort_order'] ?? 0);
            // Containment guard: cell membership in a basic grid is sort_order
            // only. A child whose sort_order drifted outside [0,totalCells)
            // (e.g. a 2nd block dropped next to an existing one resolved to a
            // sibling insert with no cell index, so the server fell back to
            // maxSort+1) must NEVER be silently dropped — that is the 'text
            // disappeared, then rendered outside the cells' bug. Clamp it into
            // a valid cell so a grid child always stays inside the grid.
            // Sergei 2026-06-05.
            if ($so < 0) { $so = 0; }
            if ($so >= $totalCells) { $so = $totalCells - 1; }
            if (!isset($childrenByCell[$so])) $childrenByCell[$so] = [];
            $childrenByCell[$so][] = $child;
        }

        $gridContentGap = intval($settings['content_gap'] ?? 0);
        // Inherit the parent section's Content Gap when the grid's own is
        // UNTOUCHED (key absent/empty). An explicit value - including an
        // explicit 0 - always wins. The inherited value is emitted through
        // the section's --impulse-item-space var in the editor so the
        // section slider live-updates cell gaps too.
        $_cgExplicit  = isset($settings['content_gap']) && $settings['content_gap'] !== '';
        $_cgInherited = 0;
        if (!$_cgExplicit) {
            $_cgInherited = intval($GLOBALS['impulse_section_item_space'] ?? 0);
            if ($_cgInherited > 0) $gridContentGap = $_cgInherited;
        }
        // Editor previews the CURRENT device: resolve the grid's own per-device
        // Content Gap, then the section's, so the canvas matches the @media rules
        // the frontend emits below (Sergei 2026-07-31).
        if ($isEditor && $device !== 'desktop') {
            if (isset($settings['content_gap_' . $device]) && $settings['content_gap_' . $device] !== '') {
                $gridContentGap = intval($settings['content_gap_' . $device]);
                $_cgExplicit    = true;
                $_cgInherited   = 0;
            } elseif (!$_cgExplicit) {
                $_secDevGap = $GLOBALS['impulse_section_item_space_' . $device] ?? null;
                if ($_secDevGap !== null) {
                    $gridContentGap = intval($_secDevGap);
                    $_cgInherited   = $gridContentGap;
                }
            }
        }

        for ($i = 0; $i < $totalCells; $i++) {
            $hasContent = !empty($childrenByCell[$i]);
            $cellBg = (string) ($settings['cell_bg_' . $i] ?? '');

            // Unset valign defaults to TOP (Sergei 2026-07-04) — content in a
            // cell taller than its content now sits at the top instead of
            // centering. Matches the editor default below (admin-core.js). No
            // child-count heuristic (it broke WYSIWYG). Explicit center/bottom
            // is honoured.
            $cellValignDefault = 'top';
            if ($isEditor && function_exists('impulse_device_val')) {
                $cellVAlign = impulse_device_val($settings, 'cell_valign_' . $i, $device, $cellValignDefault);
                $cellHAlign = impulse_device_val($settings, 'cell_halign_' . $i, $device, 'center');
            } else {
                $cellVAlign = array_key_exists('cell_valign_' . $i, $settings) ? $settings['cell_valign_' . $i] : $cellValignDefault;
                $cellHAlign = $settings['cell_halign_' . $i] ?? 'center';
            }
            // Cell content flow. A cell is a flex COLUMN, so two blocks dropped in
            // one cell could only ever stack: laying them out inline meant giving
            // each its own cell and counting columns. 'row' lays a cell's children
            // out side by side instead. Sergei 2026-07-16.
            //
            // Flipping the main axis swaps what the flex properties mean, so the
            // halign/valign the user already picked must swap with it or the two
            // align controls silently invert. align-items has no `space-between`,
            // so a valign of 'between' (only reachable on the vertical control)
            // degrades to stretch rather than emitting an invalid declaration.
            $cellFlow = (($settings['cell_flow_' . $i] ?? 'column') === 'row') ? 'row' : 'column';
            if ($cellFlow === 'row') {
                $cellJustify    = $halignMap[$cellHAlign] ?? 'center';
                $cellAlignItems = ($cellVAlign === 'between') ? 'stretch' : ($valignMap[$cellVAlign] ?? 'flex-start');
            } else {
                $cellJustify    = $valignMap[$cellVAlign] ?? 'flex-start';
                $cellAlignItems = $halignMap[$cellHAlign] ?? 'center';
            }
            // Cell halign controls block POSITIONING only (via align-items
            // + the data-halign CSS rule below that content-sizes children
            // for left/right). Internal text alignment is owned by each
            // block's own settings — we deliberately don't set text-align
            // on the cell, because text-align inherits all the way down
            // into block content (e.g. product card title/price would
            // right-align with cell halign=right, which is not what users
            // expect).

            // The editor's 80px cell floor is a DROP TARGET for empty cells, and it
            // was applied to every cell, so a 44px button sat in an 80px box: the
            // canvas showed a 46px gap between stacked buttons where the published
            // page has the gap you actually set (Sergei 2026-07-31, home screen 2 -
            // "I set the gap to 10 and nothing changed"). Cells WITH content size to
            // their content, exactly like the frontend. Empty cells keep the floor.
            $cellStyle = ($isEditor && !$hasContent) ? 'min-height:80px;' : '';
            // GALLERY CELLS ARE A POSITIONING CONTEXT. An image with
            // fill_container renders position:absolute/100%x100%, which without a
            // positioned cell escapes to the grid wrapper: every image in the
            // gallery then stacked over the whole grid and only the last one was
            // visible, full width (Sergei 2026-07-29, 'single full width image
            // instead of a gallery'). Scoped to lightbox galleries so no other
            // grid's stacking behaviour moves.
            if ($imagesLightbox) $cellStyle .= 'position:relative;overflow:hidden;';
            $cellStyle .= 'display:flex;flex-direction:' . $cellFlow . ';justify-content:' . $cellJustify . ';align-items:' . $cellAlignItems;
            // Row cells wrap: a narrow cell (or a phone) must fold the children
            // onto a second line rather than overflow the track.
            if ($cellFlow === 'row') $cellStyle .= ';flex-wrap:wrap';
            // Cell isolation removed: it created an isolated stacking
            // context that prevented backdrop-filter on any descendant
            // (e.g. frosted-glass flipboxes) from sampling the page
            // environment behind the grid. The gradient-ring pseudo's
            // z-index still works fine without isolation — z-index is
            // relative to the parent's stacking context, not the cell's
            // own.

            // Per-cell padding/gap overrides.
            $cphOverride = (isset($settings['cell_pad_h_' . $i]) && $settings['cell_pad_h_' . $i] !== '') ? intval($settings['cell_pad_h_' . $i]) : null;
            $cpvOverride = (isset($settings['cell_pad_v_' . $i]) && $settings['cell_pad_v_' . $i] !== '') ? intval($settings['cell_pad_v_' . $i]) : null;
            $_cellGapExplicit = isset($settings['cell_gap_' . $i]) && $settings['cell_gap_' . $i] !== '';
            $cgapOverride = $_cellGapExplicit ? intval($settings['cell_gap_' . $i]) : $gridContentGap;
            $_cgapInherits = !$_cellGapExplicit && !$_cgExplicit && $_cgInherited > 0;

            if ($isEditor) {
                if ($_cellPadMobHard) {
                    // Compact mobile default — caps both per-cell and grid-level padding.
                    $cellStyle .= ';padding:' . min($cpvOverride !== null ? $cpvOverride : $cellPadV, $_cellPadMobCap)
                        . 'px ' . min($cphOverride !== null ? $cphOverride : $cellPadH, $_cellPadMobCap) . 'px';
                } elseif ($cphOverride !== null || $cpvOverride !== null) {
                    $padV = $cpvOverride !== null ? $cpvOverride : $cellPadV;
                    $padH = $cphOverride !== null ? $cphOverride : $cellPadH;
                    $cellStyle .= ';padding:' . $padV . 'px ' . $padH . 'px';
                } else {
                    $cellStyle .= ';padding:var(--impulse-cell-padding-v,' . $cellPadV . 'px) var(--impulse-cell-padding-h,' . $cellPadH . 'px)';
                }
            } else {
                $effPadH = $cphOverride !== null ? $cphOverride : $cellPadH;
                $effPadV = $cpvOverride !== null ? $cpvOverride : $cellPadV;
                if ($effPadH > 0 || $effPadV > 0) {
                    $cellStyle .= ';padding:' . $effPadV . 'px ' . $effPadH . 'px';
                }
            }
            if ($cgapOverride > 0) {
                $cellStyle .= ($isEditor && $_cgapInherits)
                    ? ';gap:var(--impulse-item-space,' . $cgapOverride . 'px)'
                    : ';gap:' . $cgapOverride . 'px';
            }

            // Per-cell gradient bg.
            $pcgTo  = (string) ($settings['cell_bg_gradient_to_' . $i] ?? '');
            $pcgHas = $pcgTo !== '' && $pcgTo !== 'transparent' && strpos($pcgTo, 'rgba(0,0,0,0)') !== 0;
            $cellGradMouseAttrs = [];
            if ($pcgHas) {
                $solidBg  = ($cellBg && $cellBg !== 'transparent') ? $cellBg : 'transparent';
                $pcgAngle = isset($settings['cell_bg_gradient_angle_' . $i]) ? intval($settings['cell_bg_gradient_angle_' . $i]) : 135;
                $pcgMouse = isset($settings['cell_bg_mouse_sens_'      . $i]) ? intval($settings['cell_bg_mouse_sens_'      . $i]) : 0;
                $cellStyle .= ';background:linear-gradient(var(--imp-bg-grad-angle, ' . $pcgAngle . 'deg), ' . esc_attr($solidBg) . ', ' . esc_attr($pcgTo) . ')';
                if ($pcgMouse > 0) {
                    $cellGradMouseAttrs['data-bg-grad-mouse'] = (int) $pcgMouse;
                    $cellGradMouseAttrs['data-bg-grad-base']  = (int) $pcgAngle;
                }
            } elseif ($cellBg && $cellBg !== 'transparent') {
                $cellStyle .= ';background-color:' . esc_attr($cellBg);
            }

            // Per-cell border (solid or gradient ring).
            $cellExtraClass = '';
            $pcbW = intval($settings['cell_border_width_' . $i] ?? 0);
            if ($pcbW > 0) {
                $pcbC  = (string) ($settings['cell_border_color_' . $i] ?? '#e0e0e0');
                $pcbgT = (string) ($settings['cell_border_gradient_to_'    . $i] ?? '');
                $pcbgA = isset($settings['cell_border_gradient_angle_' . $i]) ? intval($settings['cell_border_gradient_angle_' . $i]) : 135;
                $pcbgM = isset($settings['cell_border_mouse_sens_'      . $i]) ? intval($settings['cell_border_mouse_sens_'      . $i]) : 0;
                $pcbgSpin = isset($settings['cell_border_spin_speed_'   . $i]) ? intval($settings['cell_border_spin_speed_'   . $i]) : 0;
                // Any non-empty gradient_to means the user has configured
                // a gradient — including literal 'transparent' / rgba(0,0,0,0).
                // Fading to transparent is a legitimate gradient (e.g.
                // black → transparent for a vignette ring); excluding it
                // forced the renderer into the solid-border branch and
                // triggered editor's hover border-color override.
                $hasBgrad = $pcbgT !== '';
                $T = !(($settings['cell_border_top_'    . $i] ?? null) === '0' || ($settings['cell_border_top_'    . $i] ?? null) === 0 || ($settings['cell_border_top_'    . $i] ?? null) === false);
                $R = !(($settings['cell_border_right_'  . $i] ?? null) === '0' || ($settings['cell_border_right_'  . $i] ?? null) === 0 || ($settings['cell_border_right_'  . $i] ?? null) === false);
                $B = !(($settings['cell_border_bottom_' . $i] ?? null) === '0' || ($settings['cell_border_bottom_' . $i] ?? null) === 0 || ($settings['cell_border_bottom_' . $i] ?? null) === false);
                $L = !(($settings['cell_border_left_'   . $i] ?? null) === '0' || ($settings['cell_border_left_'   . $i] ?? null) === 0 || ($settings['cell_border_left_'   . $i] ?? null) === false);
                $bT = $T ? ($pcbW . 'px') : '0';
                $bR = $R ? ($pcbW . 'px') : '0';
                $bB = $B ? ($pcbW . 'px') : '0';
                $bL = $L ? ($pcbW . 'px') : '0';

                if ($hasBgrad) {
                    // Gradient ring is painted by the
                    // .impulse-cell-grad-border::before pseudo (CSS lives in
                    // templates/impulse-page.php + admin/views/impulse-preview.php).
                    // The old recipe used `border-image` which silently
                    // ignored border-radius — cells snapped to square as
                    // soon as the gradient kicked in. The pseudo uses
                    // inset:0 + padding:var(--imp-bgrad-w) + mask:exclude
                    // to paint a uniform-width ring AT the cell's outer
                    // edge with border-radius:inherit, so the spin animation
                    // also respects the rounded corners.
                    // Per-side toggling (T/R/B/L) is uniform with this
                    // approach; if a side is off we still ring all four.
                    // Revisit when a real customer needs per-side gradient.
                    $cellStyle .= ';--imp-bgrad-w:' . intval($pcbW) . 'px';
                    $cellStyle .= ';--imp-bgrad-angle:' . $pcbgA . 'deg';
                    $cellStyle .= ';--imp-bgrad-c1:' . esc_attr($pcbC);
                    $cellStyle .= ';--imp-bgrad-c2:' . esc_attr($pcbgT);
                    $cellExtraClass = ' impulse-cell-grad-border';
                    // Editor / frontend use different global flag names —
                    // both ultimately enqueue the gradient-border asset.
                    if ($isEditor) {
                        $GLOBALS['impulse_preview_border_gradient'] = true;
                    } else {
                        $GLOBALS['impulse_needs_border_gradient'] = true;
                    }
                    if ($pcbgM > 0 || $pcbgSpin > 0) {
                        // The JS border-gradient handler iterates
                        // [data-border-grad-mouse]; emit the attr even at
                        // sens=0 so spin alone still animates the angle.
                        $cellGradMouseAttrs['data-border-grad-mouse'] = (int) $pcbgM;
                        $cellGradMouseAttrs['data-border-grad-base']  = (int) $pcbgA;
                        if ($pcbgSpin > 0) {
                            $cellGradMouseAttrs['data-border-grad-spin'] = (int) $pcbgSpin;
                            $cellStyle .= ';--impulse-border-spin-speed:' . $pcbgSpin;
                        }
                        if ($isEditor) {
                            $GLOBALS['impulse_preview_border_grad_mouse'] = true;
                        } else {
                            $GLOBALS['impulse_needs_border_grad_mouse'] = true;
                        }
                    }
                } else {
                    $cellStyle .= ';border-width:' . $bT . ' ' . $bR . ' ' . $bB . ' ' . $bL
                                . ';border-style:solid;border-color:' . esc_attr($pcbC);
                }
            }

            // Per-cell border-radius. Clamped 0-100 so a fat-finger drag
            // can't blow out the cell corners.
            $cellRadiusN = isset($settings['cell_radius_' . $i]) ? intval($settings['cell_radius_' . $i]) : 0;
            if ($cellRadiusN > 0) {
                $cellStyle .= ';border-radius:' . max(0, min(100, $cellRadiusN)) . 'px';
            }

            // Container-level frosted glass (moved up from per-cell
            // cell_frosted_<i>, Sergei 2026-06-10): one checkbox on the Grid
            // frosts every cell. Translucent white + backdrop blur; overrides
            // any solid cell_bg, a gradient cell_bg still shows through the
            // translucent layer for a tinted frost effect.
            if (!empty($settings['frosted_glass']) && $settings['frosted_glass'] !== '0') {
                $cellStyle .= ';background-color:rgba(255,255,255,0.18)';
                $cellStyle .= ';backdrop-filter:blur(20px) saturate(180%) brightness(1.15)';
                $cellStyle .= ';-webkit-backdrop-filter:blur(20px) saturate(180%) brightness(1.15)';
            }

            // Per-cell rotate + scale.
            $cellRotate = isset($settings['cell_rotate_' . $i]) ? intval($settings['cell_rotate_' . $i]) : 0;
            $cellScale  = isset($settings['cell_scale_'  . $i]) ? intval($settings['cell_scale_'  . $i]) : 100;
            $cellRotate = max(-45, min(45, $cellRotate));
            $cellScale  = max(70, min(130, $cellScale));
            $cellStaticAttrs = [];
            if ($cellRotate !== 0 || $cellScale !== 100) {
                $tParts = [];
                if ($cellRotate !== 0) $tParts[] = 'rotate(' . $cellRotate . 'deg)';
                if ($cellScale !== 100) $tParts[] = 'scale(' . ($cellScale / 100) . ')';
                $cellStyle .= ';transform:' . implode(' ', $tParts) . ';transform-origin:center';
            }
            if ($cellRotate !== 0) $cellStaticAttrs['data-cell-rotate'] = (int) $cellRotate;
            if ($cellScale !== 100) $cellStaticAttrs['data-cell-scale']  = (int) $cellScale;

            $cellDepthAttrs = [];
            if ($depthIntensity > 0) {
                $cellDepthAttrs['data-depth-row'] = (int) floor($i / max(1, $columns));
                $cellDepthAttrs['data-depth-col'] = (int) ($i % max(1, $columns));
            }

            // Editor-only: empty class + parent attr + truly-empty tracking.
            $isTrulyEmpty = !$hasContent && (!$cellBg || $cellBg === 'transparent');
            if ($isEditor && $isTrulyEmpty && !empty($GLOBALS['impulse_is_edit_mode'])) {
                $cellExtraClass .= ' impulse-grid-cell-empty';
            }
            // Static marker for cells with a USER background: the editor's
            // empty-cell chrome is now :has()-driven (content truth from the
            // live DOM, so a cell emptied client-side gets its chrome back
            // without a re-render) and must skip these cells - the color fill
            // is the user's styling, never to be tinted over. Unlike the
            // -empty class this does not depend on content, so it can't go
            // stale (Sergei 2026-07-20).
            if ($isEditor && $cellBg && $cellBg !== 'transparent') {
                $cellExtraClass .= ' impulse-cell-user-bg';
            }
            if ($cellBg && $cellBg !== 'transparent' && !$hasContent) $gridBgCellIndices[] = $i;

            echo '<div class="impulse-grid-cell' . esc_attr($cellExtraClass) . '" data-cell="' . esc_attr((string) $i) . '" data-halign="' . esc_attr($cellHAlign) . '" data-flow="' . esc_attr($cellFlow) . '"';
            if ($isEditor) echo ' data-parent="' . esc_attr((string) $elId) . '"';
            echo ' style="' . esc_attr($cellStyle) . '"';
            foreach ($cellDepthAttrs + $cellStaticAttrs + $cellGradMouseAttrs as $_an => $_av) {
                echo ' ' . esc_attr($_an) . '="' . esc_attr((string) $_av) . '"';
            }
            echo '>';
            if ($hasContent) {
                foreach ($childrenByCell[$i] as $cellChild) {
                    $renderChild($cellChild);
                }
            }
            // The + placeholder is emitted for EVERY plain cell, filled or not:
            // the :has(.impulse-el) CSS rule hides it while content exists, so
            // a cell emptied by deleting its block gets its + back instantly
            // instead of only after a full re-render (Sergei 2026-07-20).
            if ($isEditor && !empty($GLOBALS['impulse_is_edit_mode']) && (!$cellBg || $cellBg === 'transparent')) {
                echo '<span class="impulse-cell-empty-indicator" style="font-size:20px;color:' . esc_attr($GLOBALS['impulse_cell_empty_fg'] ?? 'rgba(128,128,128,0.15)') . '">+</span>';
            }
            echo '</div>';
        }
    }

    echo '</div>'; // .impulse-grid

    if ($needsWrapper) echo '</div>'; // .impulse-grid-wrapper

    // ── Frontend-only responsive emits ────────────────────────────
    if (!$isEditor && function_exists('wp_add_inline_style')) {
        $gridResp = '';
        foreach (['tablet', 'mobile'] as $dv) {
            $bpx = ($dv === 'tablet') ? '1024' : '767';
            $tph = $settings['cell_padding_h_' . $dv] ?? null;
            $tpv = $settings['cell_padding_v_' . $dv] ?? null;
            $tph = ($tph === '') ? null : $tph;
            $tpv = ($tpv === '') ? null : $tpv;
            if ($tph !== null || $tpv !== null) {
                $ph = intval($tph ?? $cellPadH);
                $pv = intval($tpv ?? $cellPadV);
                $gridResp .= '@media(max-width:' . $bpx . 'px){#' . $gridId . '>.impulse-grid-cell{padding:' . $pv . 'px ' . $ph . 'px!important}}';
            } elseif ($dv === 'mobile' && $_cellPadMobDefault) {
                // Compact-phone CAP over the desktop padding, per cell so a cell
                // with its own override is capped from ITS value, not the grid's.
                $_padCells = [];
                $_totalC   = $autoFit ? 0 : ($columns * $rows);
                for ($ci = 0; $ci < $_totalC; $ci++) {
                    $_ch = (isset($settings['cell_pad_h_' . $ci]) && $settings['cell_pad_h_' . $ci] !== '') ? intval($settings['cell_pad_h_' . $ci]) : null;
                    $_cv = (isset($settings['cell_pad_v_' . $ci]) && $settings['cell_pad_v_' . $ci] !== '') ? intval($settings['cell_pad_v_' . $ci]) : null;
                    if ($_ch === null && $_cv === null) continue;
                    $_padCells[$ci] = min($_cv ?? $cellPadV, $_cellPadMobCap) . 'px ' . min($_ch ?? $cellPadH, $_cellPadMobCap) . 'px';
                }
                $gridResp .= '@media(max-width:767px){#' . $gridId . '>.impulse-grid-cell{padding:'
                    . min($cellPadV, $_cellPadMobCap) . 'px ' . min($cellPadH, $_cellPadMobCap) . 'px!important}';
                foreach ($_padCells as $_ci => $_pd) {
                    $gridResp .= '#' . $gridId . '>.impulse-grid-cell[data-cell="' . $_ci . '"]{padding:' . $_pd . '!important}';
                }
                $gridResp .= '}';
            }

            // Cell CONTENT gap per device. Cell gaps are inline (per cell), so
            // neither the grid's own content_gap_<dv> nor the parent section's
            // item_space_<dv> reached phones before: home screen 4 kept its 28px
            // desktop gap between each title and text (Sergei 2026-07-31). Explicit
            // grid value wins; otherwise the section's per-device Content Gap is
            // inherited, exactly as the desktop value is.
            $_cgDev = null;
            if (isset($settings['content_gap_' . $dv]) && $settings['content_gap_' . $dv] !== '') {
                $_cgDev = intval($settings['content_gap_' . $dv]);
            } elseif (!$_cgExplicit) {
                $_secDev = $GLOBALS['impulse_section_item_space_' . $dv] ?? null;
                if ($_secDev !== null) $_cgDev = intval($_secDev);
            }
            if ($_cgDev !== null) {
                $_gapCells = '';
                $_totalG   = $autoFit ? 0 : ($columns * $rows);
                for ($ci = 0; $ci < $_totalG; $ci++) {
                    $_cgOwn = $settings['cell_gap_' . $ci . '_' . $dv] ?? (($settings['cell_gap_' . $ci] ?? '') !== '' ? $settings['cell_gap_' . $ci] : null);
                    if ($_cgOwn === null || $_cgOwn === '') continue;
                    $_gapCells .= '#' . $gridId . '>.impulse-grid-cell[data-cell="' . $ci . '"]{gap:' . intval($_cgOwn) . 'px!important}';
                }
                $gridResp .= '@media(max-width:' . $bpx . 'px){#' . $gridId . '>.impulse-grid-cell{gap:' . $_cgDev . 'px!important}' . $_gapCells . '}';
            }
            if (!$autoFit) {
                $totalCells = $columns * $rows;
                for ($ci = 0; $ci < $totalCells; $ci++) {
                    $vk = 'cell_valign_' . $ci . '_' . $dv;
                    $hk = 'cell_halign_' . $ci . '_' . $dv;
                    $hasV = isset($settings[$vk]) && $settings[$vk] !== '' && $settings[$vk] !== null;
                    $hasH = isset($settings[$hk]) && $settings[$hk] !== '' && $settings[$hk] !== null;
                    if (!$hasV && !$hasH) continue;
                    $decl = '';
                    // Row cells swap the flex axes (see cell_flow above), so the
                    // per-device override has to swap with them or a tablet/mobile
                    // align would land on the wrong axis and invert.
                    $ciRow = (($settings['cell_flow_' . $ci] ?? 'column') === 'row');
                    if ($ciRow) {
                        if ($hasH) { $jv = $halignMap[$settings[$hk]] ?? null; if ($jv) $decl .= 'justify-content:' . $jv . '!important;'; }
                        if ($hasV) { $ai = ($settings[$vk] === 'between') ? 'stretch' : ($valignMap[$settings[$vk]] ?? null); if ($ai) $decl .= 'align-items:' . $ai . '!important;'; }
                    } else {
                        if ($hasV) { $jv = $valignMap[$settings[$vk]] ?? null; if ($jv) $decl .= 'justify-content:' . $jv . '!important;'; }
                        if ($hasH) { $ai = $halignMap[$settings[$hk]] ?? null; if ($ai) $decl .= 'align-items:' . $ai . '!important;'; }
                    }
                    if ($decl) $gridResp .= '@media(max-width:' . $bpx . 'px){#' . $gridId . '>.impulse-grid-cell[data-cell="' . $ci . '"]{' . $decl . '}}';
                }
            }
        }
        if ($gridResp) wp_add_inline_style('instinctor-page-inline', $gridResp);

        // Auto-fit row→column mobile fallback.
        if ($autoFit && ($settings['direction'] ?? 'row') === 'row') {
            $mobileDir = $settings['direction_mobile'] ?? 'column';
            $tabletDir = $settings['direction_tablet'] ?? null;
            $autoFitResp = '';
            if ($tabletDir !== null && $tabletDir !== ($settings['direction'] ?? 'row')) {
                $autoFitResp .= '@media(max-width:1024px){#' . $gridId . '{flex-direction:' . esc_attr($tabletDir) . '!important}}';
            }
            $autoFitResp .= '@media(max-width:767px){#' . $gridId . '{flex-direction:' . esc_attr($mobileDir) . '!important}}';
            wp_add_inline_style('instinctor-page-inline', $autoFitResp);
        }
    }

    // Editor-only mobile media (matches mobile device tab override).
    if ($isEditor && empty($GLOBALS['impulse_is_edit_mode']) && !$autoFit && $mobileLayout !== 'keep' && function_exists('wp_add_inline_style')) {
        $sel = '.impulse-el[data-id="' . esc_attr((string) $elId) . '"] .impulse-grid';
        $mobileCols = $mobileLayout === '2col' ? 2 : 1;
        $css = '@media(max-width:767px){' . $sel . '{grid-template-columns:repeat(' . $mobileCols . ',1fr)!important}}';
        if (!empty($gridBgCellIndices)) {
            $bgSelectors = implode(',', array_map(function ($idx) use ($sel) { return $sel . ' .impulse-grid-cell[data-cell="' . $idx . '"]'; }, $gridBgCellIndices));
            $css .= '@media(max-width:767px){' . $bgSelectors . '{min-height:80px!important}}';
        }
        wp_add_inline_style('instinctor-page-inline', $css);
    }
    // Editor mobile-tab column override (server-side, fires when device tab = mobile).
    if ($isEditor && $device === 'mobile' && !$autoFit && $columns > 1 && $layoutMode !== 'auto_fit' && function_exists('wp_add_inline_style')) {
        $sel = '.impulse-el[data-id="' . esc_attr((string) $elId) . '"] > .impulse-grid-wrapper > .impulse-grid';
        wp_add_inline_style('instinctor-page-inline', $sel . '{grid-template-columns:repeat(' . $columns . ',1fr)!important}');
    }

    return ob_get_clean();
}
