<?php
declare(strict_types=1);

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

/**
 * Heading element renderer (shared between frontend and editor).
 *
 * Phase 1, step 6 of the renderer-unification refactor. This is the
 * largest renderer in the codebase — 286 lines (frontend) / 255 lines
 * (editor) — and the two implementations had drifted apart in subtle
 * ways that produced visible delta between the editor preview and the
 * deployed page:
 *
 *   - line-height: frontend 1.1 / editor 1.3 (~21px baseline shift)
 *   - tag style overflow-wrap: frontend yes / editor no
 *   - mouse-tilt data-tilt attr: frontend yes / editor no
 *   - opacity:0 hide-during-load: frontend yes / editor no
 *
 * The frontend value is canonical (production reality) so the editor
 * now matches it. Visible-but-tiny diffs are explicitly resolved here.
 *
 * The case-statement has TWO paths:
 *
 *   1. **Animator dispatch**: when `la_preset` is set (or content is
 *      image), the heading is re-rendered via the animator engine. Two
 *      helpers in this file decide and remap the settings:
 *
 *        - `impulse_title_should_use_animated($settings): bool`
 *        - `impulse_title_remap_to_animated(array &$settings): void`
 *
 *      The switch in each renderer becomes (step 28 update — animator is
 *      now its own function, so a direct call replaces the old
 *      `goto title_animated_render` label-jump):
 *
 *      ```
 *      case 'title':
 *          if (impulse_title_should_use_animated($settings)) {
 *              impulse_title_remap_to_animated($settings);
 *              echo impulse_element_render_title_animated($el, $settings, 'frontend');
 *              break;
 *          }
 *          echo impulse_element_render_title($el, $settings, 'frontend');
 *          break;
 *      ```
 *
 *   2. **Pure heading render**: the 3D-text / depth / stroke engine
 *      path. Extracted into `impulse_element_render_title()`.
 *
 * Pre-extraction differences and resolutions:
 *
 *   - **line-height**: frontend `line-height:1.1` / editor 1.3.
 *     → Unified to 1.1 (frontend canonical).
 *
 *   - **tag style overflow-wrap**: frontend emits `overflow-wrap:
 *     anywhere; word-break:break-word;` on the outer <h1>/<h2>/<h3>
 *     tag. Editor only had it on the inner span.
 *     → Unified to emit on both (matches frontend).
 *
 *   - **opacity:0 anti-FOUC**: frontend hides the heading while 3D
 *     engine + Google Fonts load. Editor didn't.
 *     → Unified — but skipped in editor context so the user can see
 *     what they're typing.
 *
 *   - **data-tilt mouse-tilt attribute**: frontend emits when la_tilt
 *     > 0; editor didn't. la_tilt is unified with mouse_sens (single
 *     slider in the panel drives both 3D engine and tilt — see the
 *     remap function below).
 *     → Editor now emits data-tilt too.
 *
 *   - **Per-device text override**: frontend emits base64-encoded
 *     `data-content-tablet` / `data-content-mobile` attrs and a runtime
 *     swapper picks the right one. Editor resolves server-side from
 *     the current preview device (impulse_preview_device global).
 *     → Preserved per-context.
 *
 *   - **Per-device align override**: frontend emits inline `<style>`
 *     block with @media queries. Editor resolves server-side.
 *     → Preserved per-context.
 *
 *   - **Mobile defaults**: frontend calls
 *     `impulse_auto_mobile_defaults($settings, 'heading', 'tablet')`
 *     and again for `'mobile'` to populate tablet/mobile defaults
 *     when the user hasn't overridden. Editor doesn't (it shows
 *     actual saved values).
 *     → Frontend-only call; gated on $isEditor.
 *
 *   - **Animation classes**: frontend emits
 *     `impulse_get_animation_attrs($settings)` for entrance
 *     animations. Editor doesn't (entrance animations would be
 *     distracting while editing).
 *     → Frontend-only; gated on $isEditor.
 *
 *   - **Visibility classes**: frontend emits hide-mobile / hide-
 *     tablet classes via `impulse_get_visibility_classes($settings)`.
 *     Editor doesn't (responsive preview already filters).
 *     → Frontend-only; gated on $isEditor.
 *
 *   - **Responsive font-size @media**: frontend emits via
 *     impulse_responsive_css(). Editor doesn't (per-device server-
 *     side rendering).
 *     → Frontend-only.
 *
 *   - **font-family CSS var**: editor wraps the system-font stack in
 *     `var(--impulse-font-family, <stack>)` for live panel drag.
 *     Frontend emits the stack literal.
 *     → Preserved per-context.
 *
 *   - **Element id `dt-<id>`**: frontend emits it (required by
 *     responsive font-size CSS selector + align @media). Editor was
 *     not emitting it.
 *     → Unified to ALWAYS emit (useful in both contexts; harmless
 *     even when no responsive CSS targets it).
 *
 *   - **Dead multi-line code in editor**: editor's pre-extraction
 *     case had a `for ($lfi=1; $lfi<=3; $lfi++)` loop collecting
 *     fonts from line_2_font / line_3_font, and an oldDepth
 *     migration loop iterating `$dtLines` with backward-compat logic
 *     for legacy multi-line headings. line_2/line_3 are dead
 *     settings since 2.1.x — only line_1 is read by the 3D-text
 *     engine. Removed.
 *
 *   - **Empty heading**: frontend breaks early (silent) when not in
 *     edit mode. Editor emits an `<span class="impulse-dt-line
 *     is-empty" data-placeholder="Title">`.
 *     → Preserved per-context.
 *
 *   - **Stroke + 3D attrs**: both renderers emit identical
 *     `data-dt-*` attrs when 3D engine or stroke is active, but the
 *     gating conditions had drifted (editor checked `mouse_sensitive`
 *     setting which has been dead since 2.0.x; frontend uses
 *     unconditional `data-dt-mouse="1"` when 3D engine is on).
 *     → Unified to frontend's gating.
 *
 * @param array|object $el       Element node.
 * @param array        $settings Raw or pre-resolved settings.
 * @param string       $context  'frontend' or 'editor'.
 *
 * @return string Inner HTML.
 *
 * @sideeffect Primes $GLOBALS['impulse_needs_3d_text'],
 *             impulse_needs_three and impulse_needs_heading_tilt;
 *             emits no wp_add_inline_style (responsive font-size CSS
 *             is inlined directly into the output string via a
 *             leading <style> block).
 */
function impulse_title_should_use_animated(array $settings): bool {
    $presetSet = !empty($settings['la_preset']);
    $isImage   = (($settings['la_content_type'] ?? '') === 'image') && !empty($settings['la_image_src']);
    return $presetSet || $isImage;
}

/**
 * Mutate $settings so the animator render path picks up the right
 * heading-derived values. Used by both renderers when the animator
 * dispatch fires.
 */
function impulse_title_remap_to_animated(array &$settings): void {
    // la_text overwrite policy is different from the numeric fields. The
    // earlier !empty()-guarded version let a stale la_text default (e.g.
    // 'Your text' seeded long ago) leak through whenever line_1 was empty
    // — so a heading with cleared line_1 rendered foreign text in the
    // animator preview. la_text is therefore ALWAYS replaced (including
    // with empty) from the resolved line_1, so clearing line_1 clears
    // la_text too.
    //
    // Numeric / colour fields use !empty() — intval('') is 0 and stamping
    // la_font_size = 0 collapses the canvas vertically (the "font squeezed
    // vertically" regression). Empty stays empty → renderer falls back to
    // its own default (font-size 64, colour #1d2327, weight 700) instead
    // of zero.
    if (!empty($settings['line_1_font']))   $settings['font_family']    = (string) $settings['line_1_font'];
    // Per-device line_1 fallback — frontend stores tablet/mobile overrides
    // under line_1_tablet / line_1_mobile, and the editor preview's
    // current device may be one of those. Resolve before copying so
    // device-only headings don't render as empty.
    $_dev = $GLOBALS['impulse_preview_device'] ?? 'desktop';
    $_line1 = (string) ($settings['line_1'] ?? '');
    if ($_dev !== 'desktop' && trim($_line1) === '') {
        $_ov = (string) ($settings['line_1_' . $_dev] ?? '');
        if (trim($_ov) === '' && $_dev === 'mobile') {
            $_ov = (string) ($settings['line_1_tablet'] ?? '');
        }
        if (trim($_ov) !== '') $_line1 = $_ov;
    }
    $settings['la_text'] = $_line1;
    if (!empty($settings['line_1_size']))   $settings['la_font_size']   = intval($settings['line_1_size']);
    if (!empty($settings['line_1_weight'])) $settings['la_font_weight'] = (string) $settings['line_1_weight'];
    // la_color resolution. The heading panel's colour swatch
    // (settingColorSwatchOnly) defaults its VISUAL to white when no value
    // has been explicitly set (see r=255/g=255/b=255 defaults in the JS),
    // but the hidden input persists as empty string. Older empty saves
    // therefore fell through to the animator renderer's $laColor default
    // (#1d2327, dark grey) — so the user saw a white swatch in the panel
    // but a dark grey title in the canvas. Resolve unset to white so the
    // canvas matches the swatch's visual default. Gradient mode below
    // overrides with line_1_grad_1.
    $settings['la_color'] = !empty($settings['line_1_color'])
        ? (string) $settings['line_1_color']
        : '#ffffff';
    if (($settings['line_1_fill'] ?? '') === 'gradient') {
        if (!empty($settings['line_1_grad_1']))    $settings['la_color']          = (string) $settings['line_1_grad_1'];
        if (!empty($settings['line_1_grad_2']))    $settings['la_gradient_to']    = (string) $settings['line_1_grad_2'];
        if (isset($settings['line_1_grad_angle'])) $settings['la_gradient_angle'] = intval($settings['line_1_grad_angle']);
    } else {
        $settings['la_gradient_to'] = '';
    }
    if (isset($settings['margin_bottom']))  $settings['la_gap_below']      = intval($settings['margin_bottom']);
    if (isset($settings['letter_spacing'])) $settings['la_letter_spacing'] = intval($settings['letter_spacing']);
    // Single Sensitivity slider drives both engines: 3D depth's mouse
    // rotation AND the animator's mouse-tilt amplitude.
    // 2026-05-23: clamp to 50 to match the Title panel slider's max.
    if (isset($settings['mouse_sens'])) $settings['la_tilt'] = min(50, max(0, intval($settings['mouse_sens'])));
}

function impulse_element_render_title($el, array $settings, string $context): 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';

    // Populate tablet/mobile defaults from desktop where the user
    // hasn't set explicit overrides. Runs in BOTH frontend and editor
    // contexts — the editor preview previously skipped this and
    // rendered raw desktop sizes on the mobile tab, which made titles
    // jump 2-3x bigger until the user touched a setting (the JS panel
    // already auto-scaled the slider for display via autoMobileDefaults
    // in admin-core.js, so the panel and preview disagreed). Locked
    // 2026-05-26 (Sergei feedback).
    if (function_exists('impulse_auto_mobile_defaults')) {
        impulse_auto_mobile_defaults($settings, 'heading', 'tablet');
        impulse_auto_mobile_defaults($settings, 'heading', 'mobile');
    }

    $tag = (string) ($settings['tag'] ?? 'h2');

    // Align resolution: editor resolves per-device server-side
    // (preview device global). Frontend resolves to desktop and emits
    // @media overrides at the end.
    if ($isEditor) {
        if ($device === 'mobile' && isset($settings['align_mobile']) && $settings['align_mobile'] !== '') {
            $align = (string) $settings['align_mobile'];
        } elseif ($device === 'tablet' && isset($settings['align_tablet']) && $settings['align_tablet'] !== '') {
            $align = (string) $settings['align_tablet'];
        } else {
            $align = (string) ($settings['align'] ?? 'center');
        }
    } else {
        $align = (string) ($settings['align'] ?? 'center');
    }

    /* Read inline-emitted properties device-aware in the editor. Without
       this, the editor's mobile/tablet preview emitted the desktop value
       inline — and inline styles beat the @media overrides the frontend
       relies on, so designers saw a layout that didn't match real device
       output. Frontend keeps its own inline-desktop + @media-override
       path (it doesn't know the runtime device until the browser checks
       its viewport), so we only swap reads for $isEditor. Locked
       2026-05-27. */
    $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;
    };

    $letterSpacing = intval($readDev('letter_spacing', 0));
    $marginTop     = intval($readDev('margin_top', 0));
    $marginBottom  = intval($readDev('margin_bottom', 0));

    // ── Heading text ─────────────────────────────────────────────
    // Editor: server-side per-device text resolution.
    // Frontend: desktop literal + base64 attrs for tablet/mobile
    // (runtime swapper picks the right one).
    $dtLine1 = trim((string) ($settings['line_1'] ?? ''));
    if ($isEditor && $device !== 'desktop') {
        $ov = trim((string) ($settings['line_1_' . $device] ?? ''));
        if ($ov !== '') {
            $dtLine1 = $ov;
        } elseif ($device === 'mobile') {
            // Fallback chain mobile → tablet → desktop.
            $ovT = trim((string) ($settings['line_1_tablet'] ?? ''));
            if ($ovT !== '') $dtLine1 = $ovT;
        }
    }

    $isEmpty = ($dtLine1 === '');
    if ($isEmpty) {
        if ($isEditor) {
            // Editor placeholder so the block is selectable.
            // No remaining processing needed; emit the wrap + empty span.
            $emptyWrap = 'width:100%;align-self:stretch;text-align:' . esc_attr($align) . ';';
            if ($marginTop)    $emptyWrap .= 'margin-top:' . $marginTop . 'px;';
            if ($marginBottom) $emptyWrap .= 'margin-bottom:' . $marginBottom . 'px;';
            ob_start();
            echo '<div id="dt-' . esc_attr((string) $elId) . '" class="impulse-display-text" style="' . esc_attr($emptyWrap) . '">';
            echo '<' . esc_attr($tag) . ' style="display:block;width:100%;line-height:1.1;overflow-wrap:anywhere;word-break:break-word;margin:0;text-align:' . esc_attr($align) . ';">';
            echo '<span class="impulse-dt-line is-empty" data-line="1" data-placeholder="Title" style="display:block;font-size:48px;font-weight:700;overflow-wrap:anywhere;word-break:break-word;max-width:100%;"></span>';
            echo '</' . esc_attr($tag) . '>';
            echo '</div>';
            return ob_get_clean();
        }
        // Frontend: silently render nothing when not in edit mode.
        if (empty($GLOBALS['impulse_is_edit_mode'])) return '';
        $dtLine1 = 'Title';
    }

    // ── Animation / visibility (frontend only) ───────────────────
    $animData  = (!$isEditor && function_exists('impulse_get_animation_attrs'))
                   ? impulse_get_animation_attrs($settings) : null;
    $animClass = $animData ? ' ' . $animData['class'] : '';
    $animStyle = $animData ? $animData['style']         : '';
    $visClass  = (!$isEditor && function_exists('impulse_get_visibility_classes'))
                   ? impulse_get_visibility_classes($settings) : '';

    // ── Wrap style ───────────────────────────────────────────────
    $wrapStyle = 'width:100%;align-self:stretch;text-align:' . esc_attr($align) . ';';
    if ($marginTop)    $wrapStyle .= 'margin-top:' . $marginTop . 'px;';
    if ($marginBottom) $wrapStyle .= 'margin-bottom:' . $marginBottom . 'px;';

    $_padT = intval($readDev('padding_top',    0));
    $_padR = intval($readDev('padding_right',  0));
    $_padB = intval($readDev('padding_bottom', 0));
    $_padL = intval($readDev('padding_left',   0));
    if ($_padT || $_padR || $_padB || $_padL) {
        $wrapStyle .= 'padding:' . $_padT . 'px ' . $_padR . 'px ' . $_padB . 'px ' . $_padL . 'px;';
    }
    // Halo for the 3D-engine canvas path. See the longer comment further
    // down (next to the text-shadow emit on the span) for why both
    // surfaces need their own halo and why they don't double up. We
    // compute $_haloLvl + $_haloPrimary here because $wrapStyle is
    // assembled before $fill / $settings['line_1_color'] are referenced
    // below — read them defensively at this point.
    $_haloLvl = max(0, min(100, intval($settings['text_halo'] ?? 0)));
    if ($_haloLvl > 0 && function_exists('impulse_drop_shadow_halo_css')) {
        $_haloFill   = (string) ($settings['line_1_fill']  ?? 'solid');
        $_haloColor  = ($_haloFill === 'gradient')
            ? (string) ($settings['line_1_grad_1'] ?? '#1d2327')
            : (string) ($settings['line_1_color']  ?? '#1d2327');
        $wrapStyle  .= impulse_drop_shadow_halo_css($_haloLvl, $_haloColor);
    }
    $wrapStyle .= $animStyle;

    // ── Tag style ────────────────────────────────────────────────
    // line-height 1.1 fixed (frontend canonical; was 1.3 in editor —
    // shifted baselines by ~21px and broke layouts already shipped).
    // overflow-wrap:anywhere on both wrap AND inner span so long
    // unbreakable strings ("$59/month.", URLs, hashtags) wrap inside
    // narrow containers. The properties don't inherit, so they have
    // to be set on the span too.
    $tagStyle = 'display:block;width:100%;line-height:1.1;text-align:' . esc_attr($align) . ';overflow-wrap:anywhere;word-break:break-word;';
    $maxWidth = intval($settings['max_width'] ?? 0);
    if ($maxWidth > 0) {
        $tagStyle .= 'max-width:min(' . $maxWidth . 'px,100%);';
        if ($align === 'center')     $tagStyle .= 'margin:0 auto;';
        elseif ($align === 'right')  $tagStyle .= 'margin:0 0 0 auto;';
        else                         $tagStyle .= 'margin:0;';
    } else {
        $tagStyle .= 'margin:0;';
    }
    if ($letterSpacing) $tagStyle .= 'letter-spacing:' . $letterSpacing . 'px;';

    $dtFontFamily = (string) ($settings['font_family'] ?? '');
    if ($dtFontFamily !== '') {
        // font_family stores a system-font stack with literal " — DO NOT
        // esc_attr() here; $tagStyle is itself esc_attr()'d at echo time
        // (line ~611). Double-escape turns " → &amp;quot; in the rendered
        // CSS, the browser sees literal `&quot;` text in the font-family
        // value, rejects the rule, and falls back to default. 2026-05-29.
        if ($isEditor) {
            $tagStyle .= 'font-family:var(--impulse-font-family,' . $dtFontFamily . ');';
        } else {
            $tagStyle .= 'font-family:' . $dtFontFamily . ';';
        }
    }
    $lineFont = (string) ($settings['line_1_font'] ?? '');

    $wrapClass = 'impulse-display-text' . $animClass;
    if ($visClass !== '') $wrapClass .= ' ' . $visClass;

    // ── Mode-based gating ────────────────────────────────────────
    // Single source of truth: dt_3d_mode ∈ {flat, outline, depth, outline_depth}.
    // The legacy boolean fields (depth_effects, stroke_enabled,
    // depth_shadow, depth_of_field) used to be independent checkboxes, with the
    // 3D engine booting on ANY of them. That created the "stroke checkbox enables
    // what 3D-depth checkbox enables" duality. Now the mode drives everything.
    //
    // Migration: if dt_3d_mode isn't saved (old page), derive it from the
    // legacy fields once. The panel saves dt_3d_mode going forward; old DB
    // values keep working via derivation.
    $dtLineDepth = intval($settings['line_1_depth'] ?? 0);
    $dtLineYRot  = intval($settings['line_1_yrot']  ?? 0);

    $dtMode = (string) ($settings['dt_3d_mode'] ?? '');
    if ($dtMode === '' || !in_array($dtMode, ['flat', 'outline', 'depth', 'outline_depth'], true)) {
        // Derivation chain — old page or unknown value.
        $legacyStroke = !empty($settings['stroke_enabled']);
        $legacyDepth  = !empty($settings['depth_effects'])
                     || !empty($settings['depth_of_field'])
                     || !empty($settings['depth_shadow'])
                     || $dtLineDepth !== 0
                     || $dtLineYRot !== 0;
        if      ($legacyStroke && $legacyDepth) $dtMode = 'outline_depth';
        elseif  ($legacyStroke)                 $dtMode = 'outline';
        elseif  ($legacyDepth)                  $dtMode = 'depth';
        else                                    $dtMode = 'flat';
    }
    $dtNeeds3d      = ($dtMode !== 'flat');
    $dtStrokeActive = ($dtMode === 'outline' || $dtMode === 'outline_depth');
    $dtDepthActive  = ($dtMode === 'depth'   || $dtMode === 'outline_depth');
    // DOF blur / shadow are sub-effects of Depth mode. Authors can
    // still dial intensity to 0 to disable per-effect, but the booleans are
    // derived rather than independently toggled.
    $dofOn   = $dtDepthActive && (!empty($settings['depth_of_field']) || !empty($settings['dof_intensity']));
    $shadOn  = $dtDepthActive && !empty($settings['depth_shadow']);

    // opacity:0 anti-FOUC — only on frontend so the editor user can see
    // what they're typing immediately. Any non-flat mode boots the 3D engine,
    // so the wrapper must start hidden until the engine paints its first frame.
    if (!$isEditor && $dtNeeds3d) {
        $tagStyle .= 'opacity:0;';
    }
    if ($dtNeeds3d) {
        $GLOBALS['impulse_needs_3d_text'] = true;
        $GLOBALS['impulse_needs_three']   = true;
    }

    // ── Data attrs ───────────────────────────────────────────────
    // Mouse-tilt — works regardless of whether the 3D engine, stroke,
    // or animator preset is active. impulse-title-el-tilt.js reads
    // data-tilt and applies a perspective rotate on the wrapper.
    // 2026-05-23: the Title panel writes to `mouse_sens` (its
    // Sensitivity slider, 0..50 range), but this used to read only
    // `la_tilt` — meaning every Title saved through the modern panel
    // had data-tilt=0 and no mouse interaction, ring-arc'd or not.
    // Prefer mouse_sens; fall back to la_tilt for legacy saves.
    $_hdrSensRaw = $settings['mouse_sens'] ?? $settings['la_tilt'] ?? 0;
    // 2026-05-23: clamp to 50 to match the panel slider's max.
    $hdrTilt = max(0, min(50, intval($_hdrSensRaw)));
    if ($hdrTilt > 0) {
        $GLOBALS['impulse_needs_heading_tilt'] = true;
    }
    // Outline spin style — 'sweep' (whole-word conic, legacy) or 'pulse'
    // (per-character spin with staggered phase across the word). Engine
    // reads this attr in inlineSettings.strokeSpinMode + applies it on
    // texture build (creates per-glyph bbox array for the shader).
    $_dtSpinMode = ($settings['stroke_spin_mode'] ?? '') === 'pulse' ? 'pulse' : 'sweep';

    // ── Span style ───────────────────────────────────────────────
    $lSize = $isEditor && function_exists('impulse_device_val')
        ? intval(impulse_device_val($settings, 'line_1_size', $device, 48))
        : intval($settings['line_1_size'] ?? 48);
    $lWeight = (string) ($settings['line_1_weight'] ?? '700');
    $fill    = (string) ($settings['line_1_fill']   ?? 'solid');
    $ls = 'display:block;font-size:' . $lSize . 'px;font-weight:' . esc_attr($lWeight) . ';overflow-wrap:anywhere;word-break:break-word;max-width:100%;';
    if ($fill === 'solid') {
        $ls .= 'color:' . esc_attr($settings['line_1_color'] ?? '#1d2327') . ';';
        // Reserve descender room (y/g/p/j/q). line-height:1.1 leaves the
        // descenders ~0.1em BELOW the line box, so a line background or a
        // section that hugs the title crops them at the baseline ("g/y cut off
        // at the bottom"). 0.14em pads the box past the deepest descender.
        // Mirrors the gradient path, which already does this. (Sergei 2026-06-29)
        $ls .= 'padding-bottom:0.14em;';
    } elseif ($fill === 'gradient') {
        // background-clip:text masks the gradient with the glyph shape,
        // but the gradient itself is sized to the element's box. With
        // display:block the box stretches to the parent's width (typically
        // the section's 1440 px content max), so a centred short line of
        // text only samples the middle slice of the gradient — for a
        // white→red ramp that's a uniform pink. Switch to width:fit-content
        // (with margin-inline:auto so text-align:center still centres the
        // line) so the gradient box hugs the glyph row and the full
        // colour ramp lands within the visible characters.
        //
        // padding-bottom:0.25em extends the element's painting area below
        // the baseline so that glyph descenders ('y', 'g', 'p', 'j', 'q')
        // sit INSIDE the gradient region. Without it, descenders extend
        // below the gradient area and -webkit-background-clip:text paints
        // them transparent — visually the descender is "clipped" at the
        // baseline. line-height:1 normalises the line-box so the descender
        // padding doesn't compound with default line-height leading.
        $ls .= 'width:fit-content;max-width:100%;margin-inline:auto;';
        $ls .= 'padding-bottom:0.25em;line-height:1;';
        $ls .= 'background:linear-gradient(' . intval($settings['line_1_grad_angle'] ?? 135) . 'deg,'
             . esc_attr($settings['line_1_grad_1'] ?? '#1d2327') . ','
             . esc_attr($settings['line_1_grad_2'] ?? '#999999')
             . ');-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;';
    }
    $lbg = (string) ($settings['line_1_bg'] ?? '');
    if ($lbg !== '' && $lbg !== 'transparent') $ls .= 'background-color:' . esc_attr($lbg) . ';';
    // Raw — $ls is esc_attr()'d at echo. Inner esc_attr produced &amp;quot;
    // in the rendered CSS and the font-family value was ignored. 2026-05-29.
    if ($lineFont !== '') $ls .= 'font-family:' . $lineFont . ';';

    // ── Halo (text glow for legibility on busy / same-colour bgs) ────
    // Mirror of the Text block's `text_halo`. The helper auto-flips
    // dark/light based on the text colour's luminance — so this
    // single slider handles both "white text vanishing on a white
    // section" and "black text vanishing on a dark hero".
    //
    // Two emit paths because a heading has TWO surfaces that paint
    // the visible text:
    //   1. .impulse-dt-line  — the CSS text (flat / no-engine mode).
    //                          text-shadow paints around glyphs.
    //   2. <canvas> overlay  — the 3D engine paints to its own
    //                          canvas, which text-shadow can't reach
    //                          (shadows live outside the canvas
    //                          bitmap). filter:drop-shadow on the
    //                          .impulse-display-text wrap traces the
    //                          canvas's alpha pixels and paints the
    //                          halo around them.
    // When the 3D engine is active, the H1/H2 tag gets opacity:0
    // (see $tagStyle above), so text-shadow on the inner span
    // multiplies to invisible alpha — only the drop-shadow on the
    // wrap paints. When the engine is off, text-shadow paints the
    // halo on the visible glyphs and drop-shadow has nothing alpha-
    // significant to trace. They don't double up.
    $hdrHalo = max(0, min(100, intval($settings['text_halo'] ?? 0)));
    $haloPrimary = ($fill === 'gradient')
        ? (string) ($settings['line_1_grad_1'] ?? '#1d2327')
        : (string) ($settings['line_1_color']  ?? '#1d2327');
    if ($hdrHalo > 0 && function_exists('impulse_text_halo_css')) {
        $ls .= impulse_text_halo_css($hdrHalo, $haloPrimary);
    }

    // data-fill + data-grad-* / data-color and the per-device base64
    // text attrs are now emitted inline at the compose site below (each
    // value escaped at the echo sink for WordPress.Security.EscapeOutput).

    // ── Responsive CSS — emitted INLINE BEFORE the heading ───────
    // Mobile @media must reach the parser BEFORE the heading span so
    // the browser paints with the mobile font-size on the first pass.
    // Hoisting fixes the "giant letters flash then snap to mobile
    // size" reflow on first-paint (Refresh gig screen 1 mobile, 2.1.197).
    $dtElId = 'dt-' . $elId;
    $leadingStyles = '';
    if (!$isEditor && function_exists('impulse_responsive_css')) {
        $dtRespCss = impulse_responsive_css($settings, '#' . $dtElId . ' .impulse-dt-line[data-line="1"]', [
            'line_1_size'    => ['font-size', 'px', true],
            'letter_spacing' => ['letter-spacing', 'px', true],
        ]);
        if ($dtRespCss) wp_add_inline_style('instinctor-page-inline', $dtRespCss);
    }

    // ── Trailing responsive align CSS (frontend only) ────────────
    $trailingStyles = '';
    if (!$isEditor) {
        $alignTab = $settings['align_tablet'] ?? null;
        $alignMob = $settings['align_mobile'] ?? null;
        if (($alignTab !== null && $alignTab !== $align)
            || ($alignMob !== null && $alignMob !== $align)) {
            if ($alignTab !== null && $alignTab !== $align) {
                $trailingStyles .= '@media(max-width:1024px){#' . $dtElId . ', #' . $dtElId . ' > *{text-align:' . esc_attr($alignTab) . '!important}}';
            }
            if ($alignMob !== null && $alignMob !== $align) {
                $trailingStyles .= '@media(max-width:767px){#' . $dtElId . ', #' . $dtElId . ' > *{text-align:' . esc_attr($alignMob) . '!important}}';
            }
        }
    }

    // ── Compose HTML ─────────────────────────────────────────────
    $safeText = function_exists('impulse_text_safe_breaks')
        ? impulse_text_safe_breaks($dtLine1)
        : esc_html($dtLine1);

    ob_start();
    echo esc_html($leadingStyles);
    echo '<div id="' . esc_attr($dtElId) . '" class="' . esc_attr($wrapClass) . '" style="' . esc_attr($wrapStyle) . '"';
    // ── data-* attrs emitted inline (each value escaped at the sink) ──
    // data-dt-3d-mode is kept as the engine boot signal. The engine's existing
    // contract (any non-empty value means "3D engine on") is preserved by
    // emitting "depth" for any non-flat mode.
    if ($dtNeeds3d) echo ' data-dt-3d-mode="depth"';
    echo ' data-dt-mode="'                . esc_attr($dtMode) . '"';
    echo ' data-dt-layer-gap="'           . intval($settings['layer_gap']    ?? 0) . '"';
    echo ' data-dt-stroke-width="'        . intval($settings['stroke_width'] ?? 1) . '"';
    echo ' data-dt-line-1-stroke="'       . esc_attr($settings['line_1_stroke']   ?? '') . '"';
    echo ' data-dt-line-1-stroke-2="'     . esc_attr($settings['line_1_stroke_2'] ?? '') . '"';
    echo ' data-dt-stroke-rotate-speed="' . floatval($settings['stroke_rotate_speed'] ?? 0) . '"';
    echo ' data-dt-stroke-bevel="'        . (!empty($settings['stroke_bevel']) ? '1' : '0') . '"';
    echo ' data-dt-stroke-spin-mode="'    . esc_attr($_dtSpinMode) . '"';
    echo ' data-dt-stroke-enabled="'      . ($dtStrokeActive ? '1' : '0') . '"';
    if ($hdrTilt > 0) {
        echo ' data-tilt="' . intval($hdrTilt) . '"';
    }
    // 3D engine attrs — emitted whenever the 3D engine is active (any
    // non-flat mode). DOF / shadow are sub-effects of Depth mode
    // only; Outline mode renders just the stroke layer.
    if ($dtNeeds3d) {
        echo ' data-dt-perspective="' . intval($settings['perspective'] ?? 800) . '"';
        echo ' data-dt-mouse-sens="'  . intval($settings['mouse_sens']  ?? 5)   . '"';
        echo ' data-dt-orbit-speed="' . intval($settings['orbit_speed'] ?? 5)   . '"';
        echo ' data-dt-mouse="1"';
        if (!empty($settings['auto_tilt']) && $settings['auto_tilt'] !== '0') {
            echo ' data-dt-auto-tilt="1"';
        }
        echo ' data-dt-letter-spacing="' . intval($letterSpacing) . '"';
        if ($dofOn)   echo ' data-dt-dof="' . intval($settings['dof_intensity'] ?? 5) . '"';
        if ($shadOn)  echo ' data-dt-shadow="1"';
    }
    echo '>';
    echo '<' . esc_attr($tag) . ' style="' . esc_attr($tagStyle) . '">';
    echo '<span class="impulse-dt-line" data-line="1" data-depth="' . esc_attr($dtLineDepth) . '" data-yrot="' . esc_attr($dtLineYRot) . '"';
    // ── data-fill + data-grad-* / data-color emitted inline ──────────
    echo ' data-fill="' . ($fill === 'gradient' ? 'gradient' : 'solid') . '"';
    if ($fill === 'gradient') {
        echo ' data-grad-1="'     . esc_attr($settings['line_1_grad_1'] ?? '#000000') . '"';
        echo ' data-grad-2="'     . esc_attr($settings['line_1_grad_2'] ?? '#ffffff') . '"';
        echo ' data-grad-angle="' . intval($settings['line_1_grad_angle'] ?? 135) . '"';
    } else {
        echo ' data-color="' . esc_attr($settings['line_1_color'] ?? '#1d2327') . '"';
    }
    // ── Per-device base64 attrs (frontend only) ──────────────────────
    if (!$isEditor) {
        $dtTabRaw = trim((string) ($settings['line_1_tablet'] ?? ''));
        $dtMobRaw = trim((string) ($settings['line_1_mobile'] ?? ''));
        if ($dtTabRaw !== '' && function_exists('impulse_text_safe_breaks')) {
            echo ' data-content-tablet="' . esc_attr(base64_encode(impulse_text_safe_breaks($dtTabRaw))) . '"';
        }
        if ($dtMobRaw !== '' && function_exists('impulse_text_safe_breaks')) {
            echo ' data-content-mobile="' . esc_attr(base64_encode(impulse_text_safe_breaks($dtMobRaw))) . '"';
        }
    }
    // Allowlist mirrors impulse_text_safe_breaks() (br,b,i,u,strong,em,sup,sub,
    // small,mark) so wp_kses is a no-op on its already-safe output. The broader
    // richtext allowlist would strip sup/sub/small/mark.
    echo ' style="' . esc_attr($ls) . '">' . wp_kses($safeText, ['br'=>[],'b'=>[],'i'=>[],'u'=>[],'strong'=>[],'em'=>[],'sup'=>[],'sub'=>[],'small'=>[],'mark'=>[]]) . '</span>';
    echo '</' . esc_attr($tag) . '>';
    echo '</div>';
    if ($trailingStyles !== '') wp_add_inline_style('instinctor-page-inline', $trailingStyles);
    return ob_get_clean();
}
