<?php
declare(strict_types=1);

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

/**
 * Text element renderer (shared between frontend and editor).
 *
 * Phase 1, step 2 of the renderer-unification refactor. The two
 * pre-extraction renderers diverged on several structural points:
 *
 *   - ID format: frontend `ct-<id>` / editor `impulse-text-<id>`
 *     → unified to `ct-<id>` (frontend wins; only the editor's CSS
 *     selectors changed and they're internal).
 *   - Style: frontend used inline literals; editor used CSS variables
 *     for live-update binding (--impulse-color, --impulse-size, etc.).
 *     The editor pattern is preserved in editor context; the frontend
 *     keeps inline literals (no live-update needed once shipped).
 *   - DOM shape: editor wraps content in an inner
 *     <div contenteditable="true" data-field="content"> for inline
 *     editing; frontend emits content directly inside .impulse-text.
 *     Gradient text-fill is applied on the inner div in editor (because
 *     background-clip:text doesn't propagate to nested elements) and on
 *     the outer .impulse-text in frontend.
 *   - Frontend-only features: multi-column flex split for scene-mode
 *     decompose, text-reveal animations (words/lines/chars/sweep),
 *     impulse_responsive_css emission, data-content-tablet/_mobile
 *     attrs for the runtime device-swapper.
 *   - Per-device content: editor pre-resolves the active device's
 *     variant server-side ($GLOBALS['impulse_preview_device']);
 *     frontend renders desktop + emits other variants for the swapper.
 *
 * The desktop-content fallback (rendered <div> uses tablet/mobile
 * content when desktop is empty) is preserved — fix from v2.1.220.
 *
 * @param array|object $el       Element node. Frontend passes array;
 *                               editor passes object. Normalized at entry.
 * @param array        $settings Resolved settings (editor) or raw (frontend).
 * @param string       $context  'frontend' or 'editor'.
 *
 * @return string Inner HTML (no .impulse-el wrapper).
 *
 * @sideeffect In frontend context, calls wp_add_inline_style() to emit
 *             responsive CSS, text-reveal keyframes, and drop-cap CSS.
 *             Frontend may emit a one-time <script> tag for text-reveal
 *             intersection-observer triggers.
 */
function impulse_element_render_text($el, array $settings, string $context): string {
    // Normalize array/object element shape.
    if (is_object($el)) $el = (array) $el;
    $elId = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($el['id'] ?? '0'));
    $isEditor = ($context === 'editor');

    // ── Content + per-device resolution ────────────────────────────
    $content = (string) ($settings['content'] ?? '');
    if ($isEditor) {
        // Editor renders only the active device's variant; reloads on tab change.
        $_txtDev = $GLOBALS['impulse_preview_device'] ?? 'desktop';
        if ($_txtDev !== 'desktop') {
            $_ov = (string) ($settings['content_' . $_txtDev] ?? '');
            if ($_ov !== '') {
                $content = $_ov;
            } elseif ($_txtDev === 'mobile') {
                $_ovT = (string) ($settings['content_tablet'] ?? '');
                if ($_ovT !== '') $content = $_ovT;
            }
        }
    } else {
        // Frontend desktop-content fallback: if desktop is empty but
        // tablet/mobile is set, use that as the rendered HTML so the
        // visible block is never blank. JS swapper still picks the
        // matching variant per device width. (v2.1.220 fix)
        if ($content === '') {
            $content = (string) ($settings['content_tablet'] ?? '');
            if ($content === '') $content = (string) ($settings['content_mobile'] ?? '');
        }
    }

    // ── Settings (shared) ──────────────────────────────────────────
    // In editor mode, read per-device values for the properties that
    // get emitted inline below. Frontend bakes desktop inline and
    // relies on @media to override at viewport widths — but @media
    // is suppressed in editor (the iframe reloads per device tab),
    // so the inline emission must already match the active device.
    // Without this, editor mobile preview rendered desktop sizes /
    // spacing / padding while font-size flipped to mobile, producing
    // a layout that didn't match real-device output. Locked 2026-05-27.
    $device  = $isEditor ? ($GLOBALS['impulse_preview_device'] ?? 'desktop') : 'desktop';
    $readDev = function ($key, $default) use ($settings, $isEditor, $device) {
        if ($isEditor && function_exists('impulse_device_val')) {
            return impulse_device_val($settings, $key, $device, $default);
        }
        return $settings[$key] ?? $default;
    };

    $align         = $readDev('align', 'center');
    $color         = $settings['color'] ?? '#666666';
    $bgColor       = $settings['bg_color'] ?? '';
    $size          = $readDev('size', 16);
    // Mobile rule (Sergei 2026-07-02): 14px is only the DEFAULT for text whose size
    // was never set. The moment a size is chosen manually — on ANY device, including
    // desktop (e.g. the 72px "$99") — that value is the size everywhere until a
    // per-device override changes it, and it must never snap back to 14px. So the
    // default applies ONLY when the base size AND both device overrides are unset.
    // When it does apply it is emitted as a HARD px (not the var(--impulse-size)
    // fallback) so the editor's live-preview can't restore a value on element select
    // (the "click makes it big, reload shrinks it" report). Editor previews here; the
    // frontend @media below is gated the same way.
    $_textMobDefault = (($settings['size'] ?? '') === '')
        && (($settings['size_mobile'] ?? '') === '')
        && (($settings['size_tablet'] ?? '') === '');
    $_textMobHard = false;
    if ($isEditor && $device === 'mobile' && $_textMobDefault) {
        $size = 14;
        $_textMobHard = true;
    }
    // Cast to string — DB can save weight as int (400, 700) or string ("400");
    // esc_attr in PHP 8 requires a string and throws a TypeError on int.
    $weight        = (string) ($readDev('weight', '400'));
    $fontFamily    = $settings['font_family'] ?? '';
    $lineHeight    = $readDev('line_height', 160);
    $letterSpacing = $readDev('letter_spacing', 0);
    $minHeight     = $readDev('min_height', 0);
    $widthPct      = $readDev('width', null);
    $widthPct      = ($widthPct !== null && $widthPct !== '') ? max(10, min(100, intval($widthPct))) : 100;
    $columns       = min(4, max(1, intval($readDev('columns', 1))));
    $columnGap     = $readDev('column_gap', 30);
    $italic        = !empty($settings['italic']);
    $decoration    = $settings['decoration'] ?? 'none';
    $dropCap       = !empty($settings['drop_cap']);
    $link          = $settings['link'] ?? '';
    $paddingTop    = intval($readDev('padding_top',    0));
    $paddingBottom = intval($readDev('padding_bottom', 0));
    $paddingLeft   = intval($readDev('padding_left',   0));
    $paddingRight  = intval($readDev('padding_right',  0));

    $textId = 'ct-' . preg_replace('/[^A-Za-z0-9_-]/', '', (string) $elId);

    // ── Markdown → HTML → kses → nl2br ─────────────────────────────
    $renderContent  = impulse_is_markdown($content) ? impulse_markdown_to_html($content) : $content;
    $displayContent = impulse_kses_richtext($renderContent);
    $hasBlockTags   = preg_match('/<(p|div|h[1-6]|ul|ol|li|blockquote|pre|hr|table)[^>]*>/i', $renderContent);
    if (!$hasBlockTags) $displayContent = nl2br($displayContent);

    // Typographic orphan/widow control — heading-sized text on the FRONTEND
    // only. Glues short words + the last two words with &nbsp; so a lone "a",
    // "of", "the" etc. can never end a line and the last line never carries a
    // single word. Deterministic (unlike CSS text-wrap: pretty/balance) and
    // works in every browser. Editor is skipped: its content lives in a
    // contenteditable and injected nbsp would be saved back + accumulate.
    if (!$isEditor && !$hasBlockTags && intval($size) >= 24 && function_exists('impulse_prevent_orphans')) {
        $displayContent = impulse_prevent_orphans($displayContent);
    }

    // ── Multi-column flex split (frontend only) ────────────────────
    // Scene-mode decompose targets .impulse-text-col; split when the
    // content has at least N <p>/<h> children, otherwise fall back to
    // CSS column-count below.
    $splitCols = (!$isEditor && $columns > 1) ? impulse_split_text_columns($displayContent, $columns) : false;

    // ── Build the container style ──────────────────────────────────
    $lhRatio = intval($lineHeight) / 100;
    if ($isEditor) {
        // Editor: bind each visual prop to a CSS variable so the panel's
        // live-update can update the iframe DOM by setting
        // `--impulse-<prop>` on the .impulse-el wrapper.
        $style  = 'text-align:' . esc_attr($align) . ';';
        $style .= 'color:var(--impulse-color,' . esc_attr($color) . ');';
        $style .= $_textMobHard
            ? 'font-size:14px;'
            : 'font-size:var(--impulse-size,' . intval($size) . 'px);';
        $style .= 'font-weight:' . esc_attr($weight) . ';';
        $style .= 'line-height:var(--impulse-line-height,' . $lhRatio . ');';
        $style .= 'letter-spacing:var(--impulse-letter-spacing,' . intval($letterSpacing) . 'px);';
    } else {
        // Frontend: inline literals (no live-update target needed).
        $style  = 'text-align:' . esc_attr($align) . ';';
        $style .= 'color:' . esc_attr($color) . ';';
        $style .= 'font-size:' . intval($size) . 'px;';
    }

    // Width sizes the CONTAINER (Sergei 2026-06-23). On the FRONTEND there is no
    // .impulse-el wrapper, so .impulse-text IS the box and carries width% +
    // centering + the 1200px cap. In the EDITOR the .impulse-el selection wrapper
    // carries width/max-width/centering (set in impulse-preview.php) so the box +
    // handles match; the text just fills it — otherwise the handles spanned full
    // width while the text floated narrow inside. Hard cap: never exceed 1200px.
    if ($isEditor) {
        $style .= 'width:100%;align-self:stretch;';
    } elseif ($widthPct < 100) {
        // A narrow box (width<100) used to always margin:auto-center, which
        // overrides both the block's own align and the parent cell's halign.
        // So a left-aligned 92%-wide text floated its glyphs left inside a
        // centered box, reading as "not left-aligned" even with the cell set
        // to left. Position the box per the block's align instead. Default
        // 'center' keeps the historical centering, so existing pages are
        // unchanged; left/right hug the matching edge. Sergei 2026-06-25.
        // box_align decouples box POSITION from text alignment (2026-07-01):
        // a centered box can hold left-aligned text (FAQ pattern). Falls back
        // to the text align when unset, so every existing page is unchanged.
        $boxAlign = $readDev('box_align', '');
        if ($boxAlign === '') $boxAlign = $align;
        $boxMargin = ($boxAlign === 'left')  ? 'margin-left:0;margin-right:auto;'
                  : (($boxAlign === 'right') ? 'margin-left:auto;margin-right:0;'
                  : 'margin-left:auto;margin-right:auto;');
        $style .= 'width:' . $widthPct . '%;max-width:1200px;' . $boxMargin;
    } else {
        // width==100: the box fills to a 1200px cap. box_align still governs
        // WHERE that capped box sits when the container is wider than 1200 (or
        // wider than the text). Previously this hardcoded margin:auto (center),
        // silently ignoring box_align at full width, so a left box_align read as
        // centered on wide canvases and would not line up with a left-anchored
        // sibling (button/section). Honor box_align here exactly like width<100.
        // Default (unset → falls back to text align 'center') keeps historical
        // centering, so existing centered heroes are unchanged. Sergei 2026-07-03.
        $boxAlign = $readDev('box_align', '');
        if ($boxAlign === '') $boxAlign = $align;
        $boxMargin = ($boxAlign === 'left')  ? 'margin-left:0;margin-right:auto;'
                  : (($boxAlign === 'right') ? 'margin-left:auto;margin-right:0;'
                  : 'margin-left:auto;margin-right:auto;');
        $style .= 'width:100%;max-width:1200px;align-self:stretch;' . $boxMargin;
    }

    if (!$isEditor) {
        // Frontend: emit literal weight/line-height/letter-spacing here
        // (in editor they were already emitted as CSS vars above).
        $style .= 'font-weight:' . esc_attr($weight) . ';';
        $style .= 'line-height:' . $lhRatio . ';';
        $style .= 'letter-spacing:' . intval($letterSpacing) . 'px;';
    }

    if ($fontFamily) {
        // font_family stores a full system-font stack (e.g. Georgia,"Times New Roman",serif).
        // Emit it RAW — $style is esc_attr()'d at echo (line ~385); inner
        // esc_attr here doubles up the encoding (" → &quot; → &amp;quot;)
        // and the browser ignores the rule. 2026-05-29.
        if ($isEditor) {
            $style .= 'font-family:var(--impulse-font-family,' . $fontFamily . ');';
        } else {
            $style .= 'font-family:' . $fontFamily . ';';
        }
    }

    // Height is a plain min-height in every case. It used to become a
    // FIXED height + overflow:hidden whenever columns > 1, which is what
    // tangled the Columns control together with the Height slider.
    // Columns now stand on their own (even balance, top-aligned).
    if ($minHeight) {
        $style .= 'min-height:' . intval($minHeight) . 'px;';
    }

    // Solid container bg (independent of text-fill gradient).
    if ($bgColor && $bgColor !== 'transparent') {
        $style .= 'background-color:' . esc_attr($bgColor) . ';';
    }

    // Text-fill gradient retired in v2.1.1311. Gradient typography is
    // owned by the Title (animator) block where 3D depth + presets give
    // it weight; on plain body Text it hurt readability and competed
    // with the hero. Existing text_gradient_* settings are ignored —
    // dead weight, harmless. Stubs kept so the inner-element interpolation
    // below stays valid without conditionals.
    $textGradMouseAttrs = '';
    $innerGradStyle     = '';
    $innerGradAttrs     = '';

    if ($italic) $style .= 'font-style:italic;';
    if ($decoration !== 'none') $style .= 'text-decoration:' . esc_attr($decoration) . ';';

    // Cinematic legibility halo — auto-flips dark/light based on text luminance.
    $_haloLevel = max(0, min(100, intval($settings['text_halo'] ?? 0)));
    if ($_haloLevel > 0 && function_exists('impulse_text_halo_css')) {
        $style .= impulse_text_halo_css($_haloLevel, $color);
    }

    // CSS column-count. Works on ANY content — inline runs, <br>-separated
    // text, or block-level markup all flow across columns. Built into a
    // separate var because WHERE it lands differs by context:
    //   - Frontend: on the outer .impulse-text (content is its direct
    //     child, so column-count fragments it).
    //   - Editor: on the INNER contenteditable div. The outer has a
    //     single child (the contenteditable), and a lone child can't be
    //     split across columns — which is why columns silently did
    //     nothing in the editor regardless of the buttons. Putting the
    //     flow on the contenteditable makes its text fragment properly.
    // No $hasBlockTags gate (plain / <br> text columns fine) and no
    // dependency on the Height slider.
    // Plain column-count with the default column-fill:balance — the
    // browser divides the text evenly into N columns, each top-aligned.
    // No column-fill:auto (that fills column 1 first and only looks
    // right with an explicit height — the unpredictable behaviour) and
    // no dependency on the Height slider.
    $colFlowCss = '';
    if ($columns > 1 && !$splitCols) {
        $colFlowCss = 'column-count:' . $columns . ';column-gap:' . intval($columnGap) . 'px;';
    }

    if ($isEditor && $link) {
        $style .= 'cursor:pointer;';
    }

    // Padding: editor uses CSS variables so the panel's live-update can
    // rewrite them by setting --impulse-padding-* on the .impulse-el
    // wrapper (the var on the closest ancestor wins, shadowing the page-
    // level --impulse-padding-top that .impulse-page always carries).
    // Frontend emits hard pixel values — no live-update target needed,
    // and the CSS-var approach would cause the text element to inherit
    // the page-level padding instead of using its own stored value.
    if ($isEditor) {
        $style .= 'padding-top:var(--impulse-padding-top,' . $paddingTop . 'px);';
        $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);';
    } else {
        $style .= 'padding-top:'    . $paddingTop    . 'px;';
        $style .= 'padding-bottom:' . $paddingBottom . 'px;';
        $style .= 'padding-left:'   . $paddingLeft   . 'px;';
        $style .= 'padding-right:'  . $paddingRight  . 'px;';
    }

    // Vertical margins — frontend reads via impulse_get_responsive.
    if (!$isEditor && function_exists('impulse_get_responsive')) {
        $tMt = intval(impulse_get_responsive($settings, 'margin_top', 0));
        $tMb = intval(impulse_get_responsive($settings, 'margin_bottom', 0));
        if ($tMt) $style .= 'margin-top:' . $tMt . 'px;';
        if ($tMb) $style .= 'margin-bottom:' . $tMb . 'px;';
    }

    // Drop cap styles.
    if ($dropCap && function_exists('wp_add_inline_style')) {
        wp_add_inline_style('instinctor-page-inline', '#' . $textId . '::first-letter{float:left;font-size:3em;line-height:1;margin-right:8px;font-weight:bold}');
    }

    // ── Class composition + entrance / text-reveal (frontend only) ─
    $textClass = 'impulse-text';
    $animAttrs = ['class' => '', 'style' => ''];
    if (!$isEditor) {
        if (function_exists('impulse_get_animation_attrs')) {
            $animAttrs = impulse_get_animation_attrs($settings);
        }
        if (function_exists('impulse_get_visibility_classes')) {
            $visClass = impulse_get_visibility_classes($settings);
            if ($visClass) $textClass .= ' ' . $visClass;
        }
        if (!empty($animAttrs['class'])) $textClass .= ' ' . $animAttrs['class'];
        if (!empty($animAttrs['style'])) $style .= $animAttrs['style'];
    }

    // Text-content reveal (frontend only — animates the text itself,
    // independent of container entrance animations).
    $textReveal         = $isEditor ? 'none' : ($settings['text_reveal'] ?? 'none');
    $textRevealStagger  = 0; $textRevealDuration = 0; $textRevealDelay = 0;
    if (!$isEditor && $textReveal !== 'none' && $textReveal !== '') {
        // Per-mode minimum stagger. Words/Lines/Chars only differ visually
        // when there's a per-token delay — at stagger=0 all tokens fire
        // together and the picker becomes a no-op (the user sees a single
        // block fade and reports "preset doesn't work"). Floor to the same
        // values used as defaults for unset settings so a saved 0 still
        // animates with per-token build. To get a truly simultaneous fade,
        // the user should pick mode=Sweep (single-mask reveal, no tokens).
        // Sergei 2026-05-31.
        $minStagger = ($textReveal === 'chars' ? 0.02 : ($textReveal === 'lines' ? 0.08 : 0.04));
        $rawStagger = floatval($settings['text_reveal_stagger'] ?? $minStagger);
        $textRevealStagger = max($minStagger, $rawStagger);
        $textRevealDuration = max(0.1, floatval($settings['text_reveal_duration'] ?? 0.8));
        $textRevealDelay    = max(0, floatval($settings['text_reveal_delay']    ?? 0));
        $textClass .= ' impulse-text-reveal impulse-text-reveal-' . preg_replace('/[^a-z]/', '', $textReveal);
        $style     .= '--ttr-stagger:'  . $textRevealStagger  . 's;'
                    . '--ttr-duration:' . $textRevealDuration . 's;'
                    . '--ttr-delay:'    . $textRevealDelay    . 's;';
        if (!$splitCols && function_exists('impulse_split_text_for_reveal')) {
            $displayContent = impulse_split_text_for_reveal($displayContent, $textReveal);
        } elseif ($splitCols && function_exists('impulse_split_text_for_reveal')) {
            foreach ($splitCols as $i => $colHtml) {
                $splitCols[$i] = impulse_split_text_for_reveal($colHtml, $textReveal);
            }
        }
    }

    // Per-device content overrides — emit base64-encoded HTML for
    // tablet/mobile variants in data-content-* attrs; runtime swapper in
    // impulse-interactions.js picks the right one. Frontend only — the
    // editor renders the active device's variant directly server-side.
    $_contentRespAttrs = '';
    if (!$isEditor) {
        $_buildVariantHtml = function ($raw) {
            if ($raw === '' || $raw === null) return '';
            $r = impulse_is_markdown($raw) ? impulse_markdown_to_html($raw) : $raw;
            $h = impulse_kses_richtext($r);
            if (!preg_match('/<(p|div|h[1-6]|ul|ol|li|blockquote|pre|hr|table)[^>]*>/i', $r)) {
                $h = nl2br($h);
            }
            return $h;
        };
        $_tabContent = trim((string) ($settings['content_tablet'] ?? ''));
        $_mobContent = trim((string) ($settings['content_mobile'] ?? ''));
        if ($_tabContent !== '') $_contentRespAttrs .= ' data-content-tablet="' . esc_attr(base64_encode($_buildVariantHtml($_tabContent))) . '"';
        if ($_mobContent !== '') $_contentRespAttrs .= ' data-content-mobile="' . esc_attr(base64_encode($_buildVariantHtml($_mobContent))) . '"';
    }

    // ── Build HTML ─────────────────────────────────────────────────
    ob_start();
    if ($link) {
        echo '<a href="' . esc_url($link) . '" style="text-decoration:none;color:inherit;display:block">';
    }

    if ($isEditor) {
        // Editor: outer .impulse-text + inner contenteditable wrapper.
        // The contenteditable lets the user edit text inline in the iframe;
        // it also carries the text-fill gradient (since bg-clip:text doesn't
        // propagate to nested elements).
        echo '<div id="' . esc_attr($textId) . '" class="' . esc_attr($textClass) . '" style="' . esc_attr($style) . '"' . wp_kses($textGradMouseAttrs, impulse_svg_allowed()) . '>';
        $isTextEmpty = empty(trim(wp_strip_all_tags($displayContent)));
        // Column flow goes on the contenteditable — the outer's single
        // child can't fragment across columns.
        $innerStyle  = 'position:relative;min-height:1em;outline:none;' . $colFlowCss . $innerGradStyle;
        echo '<div class="' . ($isTextEmpty ? 'is-empty' : '') . '" contenteditable="true" data-field="content" data-placeholder="Click to add text" style="' . esc_attr($innerStyle) . '"' . wp_kses($innerGradAttrs, impulse_svg_allowed()) . '>' . wp_kses_post($displayContent) . '</div>';
        echo '</div>';
    } elseif ($splitCols) {
        // Frontend multi-column flex split — each column is a decompose
        // target for scene-mode screen transitions.
        $outerClass = $textClass . ' impulse-text-cols';
        $outerStyle = $style . 'display:flex;flex-direction:row;gap:' . intval($columnGap) . 'px;';
        echo '<div id="' . esc_attr($textId) . '" class="' . esc_attr($outerClass) . '" style="' . esc_attr($outerStyle) . '"' . wp_kses($textGradMouseAttrs, impulse_svg_allowed()) . wp_kses($_contentRespAttrs, impulse_svg_allowed()) . '>';
        foreach ($splitCols as $colHtml) {
            echo '<div class="impulse-text-col" style="flex:1 1 0;min-width:0">' . wp_kses_post($colHtml) . '</div>';
        }
        echo '</div>';
    } else {
        // Frontend single-column (or CSS column-count) — content direct
        // child so bg-clip:text gradient on outer paints the glyphs.
        // column-count goes on the outer here because the content IS its
        // direct child and fragments correctly.
        echo '<div id="' . esc_attr($textId) . '" class="' . esc_attr($textClass) . '" style="' . esc_attr($style . $colFlowCss) . '"' . wp_kses($textGradMouseAttrs, impulse_svg_allowed()) . wp_kses($_contentRespAttrs, impulse_svg_allowed()) . '>';
        echo wp_kses_post($displayContent);
        echo '</div>';
    }

    if ($link) echo '</a>';

    // Frontend per-device WIDTH override. The desktop width% is inline; the
    // editor applies device-aware width to the .impulse-el wrapper, but the
    // frontend serves ONE html to every device, so width_tablet/width_mobile
    // need a media query — otherwise a narrow desktop column (e.g. 39%) stays
    // narrow on a phone even when width_mobile=100. !important (via
    // impulse_responsive_css) beats the inline width. v2.1.1878.
    if (!$isEditor && function_exists('impulse_responsive_css')) {
        $_wResp = impulse_responsive_css($settings, '#' . $textId, ['width' => ['width', '%']]);
        if ($_wResp !== '') {
            if (function_exists('wp_add_inline_style')) {
                wp_add_inline_style('instinctor-page-inline', $_wResp);
            } else {
                wp_add_inline_style('instinctor-page-inline', $_wResp);
            }
        }
    }

    // One-time CSS + JS for text reveal — emit once per page render.
    if (!$isEditor && !empty($textReveal) && $textReveal !== 'none' && empty($GLOBALS['impulse_text_reveal_css'])) {
        $GLOBALS['impulse_text_reveal_css'] = true;
        /* Per-token spans inherit the parent's gradient/clipped-text fill.
           Without this, the parent's `-webkit-background-clip:text` paints
           only on the parent's OWN text (none — the text now lives in
           children), so on a gradient-fill or stroked text block every
           token is rendered with the transparent text-fill-color and is
           completely invisible. By copying the gradient + clip + fill
           onto each span, the per-token glyphs paint correctly regardless
           of whether the animation keyframes (opacity / transform) put the
           span on its own compositing layer. Sergei 2026-05-31. */
        $css = '.impulse-text-reveal{--ttr-stagger:0.04s;--ttr-duration:0.8s;--ttr-delay:0s}'
             . '.impulse-text-reveal .impulse-tr-word,'
             . '.impulse-text-reveal .impulse-tr-line,'
             . '.impulse-text-reveal .impulse-tr-char{'
             .   'display:inline-block;opacity:0;'
             .   'background-image:inherit;background-clip:inherit;'
             .   '-webkit-background-clip:inherit;'
             .   '-webkit-text-fill-color:inherit;color:inherit'
             . '}'
             . '.impulse-text-reveal-lines .impulse-tr-line{display:block}'
             . '.impulse-text-reveal.is-revealed .impulse-tr-word,'
             . '.impulse-text-reveal.is-revealed .impulse-tr-line,'
             . '.impulse-text-reveal.is-revealed .impulse-tr-char{'
             .   'animation:impulse-tr-fadeup var(--ttr-duration) cubic-bezier(0.4, 0, 0.2, 1) both;'
             .   'animation-delay:calc(var(--ttr-delay) + var(--i,0) * var(--ttr-stagger))}'
             . '@keyframes impulse-tr-fadeup{from{opacity:0;transform:translateY(0.5em)}to{opacity:1;transform:translateY(0)}}'
             /* Chars: distinct per-char pop (scale) so it does not read like the
                word/line rise or the sweep wipe. Same var(--ttr-duration)/stagger.
                Sergei 2026-06-23. */
             . '.impulse-text-reveal-chars.is-revealed .impulse-tr-char{animation-name:impulse-tr-pop}'
             . '@keyframes impulse-tr-pop{from{opacity:0;transform:scale(0.5)}to{opacity:1;transform:scale(1)}}'
             . '.impulse-text-reveal-sweep{'
             .   '-webkit-mask-image:linear-gradient(90deg,transparent 0%,transparent 40%,#000 60%,#000 100%);'
             .   'mask-image:linear-gradient(90deg,transparent 0%,transparent 40%,#000 60%,#000 100%);'
             .   '-webkit-mask-size:250% 100%;mask-size:250% 100%;'
             .   '-webkit-mask-position:0% 0;mask-position:0% 0;'
             .   '-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}'
             . '.impulse-text-reveal-sweep.is-revealed{'
             .   'animation:impulse-tr-sweep var(--ttr-duration) cubic-bezier(0.4, 0, 0.2, 1) both;'
             .   'animation-delay:var(--ttr-delay)}'
             . '@keyframes impulse-tr-sweep{from{-webkit-mask-position:0% 0;mask-position:0% 0}to{-webkit-mask-position:100% 0;mask-position:100% 0}}';
        if (function_exists('wp_add_inline_style')) {
            wp_add_inline_style('instinctor-page-inline', $css);
        } else {
            wp_add_inline_style('instinctor-page-inline', $css);
        }
        /* Screen-active-aware sync. Listens to impulse-screen-changed; when
           a text-reveal's screen becomes active, replays the animation
           (concurrent with the screens engine's decomposeEnter so the
           composite opacity ramps smoothly into view). When the screen
           deactivates, resets so the next visit replays cleanly.
           Pattern: feedback_screen_visibility_gating.md.
           Sergei 2026-05-31. */
        $js = '(function(){if(window.__impulseTextRevealInit)return;window.__impulseTextRevealInit=1;'
            . 'function fire(el){el.classList.add("is-revealed");}'
            . 'function reset(el){el.classList.remove("is-revealed");}'
            . 'function replay(el){reset(el);void el.offsetWidth;fire(el);}'
            . 'function sync(){var nodes=document.querySelectorAll(".impulse-text-reveal");for(var i=0;i<nodes.length;i++){var el=nodes[i];var s=el.closest?el.closest(".impulse-screen"):null;if(!s){fire(el);continue;}if(s.classList.contains("impulse-screen-active"))replay(el);else reset(el);}}'
            . 'document.addEventListener("impulse-screen-changed",sync);'
            // Fallback for any page that has no screens engine running —
            // fire everything after a short delay. If a screen IS active
            // by then, sync() already ran and we leave it alone.
            . 'setTimeout(function(){if(document.querySelector(".impulse-screen-active"))return;document.querySelectorAll(".impulse-text-reveal").forEach(fire);},400);'
            . '})();';
        wp_add_inline_script('instinctor-frontend-js', '' . $js . '', 'after');
    }

    $html = ob_get_clean();
    // (orphan control applied earlier to $displayContent, before ob_start)

    // ── Responsive per-device CSS (frontend only) ──────────────────
    if (!$isEditor && function_exists('impulse_responsive_css') && function_exists('wp_add_inline_style')) {
        $textRespCss = impulse_responsive_css($settings, '#' . $textId, [
            'size'           => ['font-size', 'px'],
            'line_height'    => ['line-height', '%'],
            'letter_spacing' => ['letter-spacing', 'px'],
            'padding_top'    => ['padding-top', 'px'],
            'padding_bottom' => ['padding-bottom', 'px'],
            'padding_left'   => ['padding-left', 'px'],
            'padding_right'  => ['padding-right', 'px'],
            'min_height'     => ['min-height', 'px'],
            'margin_top'     => ['margin-top', 'px'],
            'margin_bottom'  => ['margin-bottom', 'px'],
        ]);
        if ($textRespCss) wp_add_inline_style('instinctor-page-inline', $textRespCss);
        // Mobile rule: default text to 14px on mobile when no explicit mobile/tablet size.
        if ($_textMobDefault) {
            wp_add_inline_style('instinctor-page-inline', '@media(max-width:767px){#' . $textId . '{font-size:14px!important}}');
        }
    }

    return $html;
}

if (!function_exists('impulse_prevent_orphans')) {
    /**
     * Prevent typographic orphans/widows in short inline text (headings).
     *
     * Glues short/preposition words AND the final two words together with
     * non-breaking spaces, so a lone "a", "of", "the", "&" etc. can never end
     * a line and the last line never carries a single word. Deterministic
     * (unlike CSS text-wrap: pretty/balance, which is browser-inconsistent and
     * only targets the last line) and works everywhere.
     *
     * Idempotent: only rewrites real ASCII spaces, never existing nbsp. Bails
     * on anything containing a tag so inline markup is never broken.
     */
    function impulse_prevent_orphans(string $text): string {
        if ($text === '' || strpos($text, '<') !== false) return $text;
        $nb = "\xC2\xA0"; // UTF-8 non-breaking space
        $short = '(?:a|an|and|as|at|be|but|by|do|for|if|in|is|it|its|my|no|nor|of|off|on|or|our|per|so|the|to|up|via|vs|we|your)';
        // 1. Short / preposition words glue to the following word.
        $text = preg_replace('/(?<![\p{L}\p{N}])(' . $short . ')\x20+(?=[\p{L}\p{N}])/iu', '$1' . $nb, $text);
        // 2. Any remaining 1-2 character word glues to the following word.
        $text = preg_replace('/(?<![\p{L}\p{N}])([\p{L}\p{N}]{1,2})\x20+(?=[\p{L}\p{N}])/u', '$1' . $nb, $text);
        // 3. Widont: glue the final two words (anchored to end → last space only).
        $text = preg_replace('/\x20+([^\s]+)\s*$/u', $nb . '$1', (string) $text, 1);
        return (string) $text;
    }
}
