<?php
declare(strict_types=1);

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

/**
 * Try It Yourself sandbox controls for the hero scene.
 *
 * Renders a fixed overlay over the scene-mode page (frontend or editor)
 * when the page has `hero_sandbox=1`. Visitors use the curated controls to
 * swap scroll transitions, scene environments, and 3D background images
 * live without leaving the hero.
 *
 * Idempotent across the two duplicated render paths in impulse-page.php and
 * the single editor render path in impulse-preview.php — guards on a global
 * flag so only one overlay ships per page request.
 *
 * @param array $page_settings  Page-level settings JSON (provides active state).
 *
 * @return void  Emits HTML + first-call CSS/JS directly.
 */
function impulse_render_hero_sandbox(array $page_settings): void {
    if (empty($page_settings['hero_sandbox'])) return;
    if (!empty($GLOBALS['impulse_hero_sandbox_emitted'])) return;
    $GLOBALS['impulse_hero_sandbox_emitted'] = true;

    // Editor preview context: sandbox renders for visual reference but
    // its rail + dock would otherwise sit on top of block toolboxes
    // (Sergei reported: can't delete a text block on the right side
    // because the right-rail eats clicks). Emit a guard rule that
    // disables pointer-events on the interactive parts in editor
    // context, so clicks fall through to the underlying element
    // toolbar. The sandbox stays visible — it just stops swallowing
    // editor interactions.
    $isEditor = (defined('INSTINCTOR_PREVIEW_CONTEXT') && INSTINCTOR_PREVIEW_CONTEXT);
    if ($isEditor) {
        $_sbEditorCss = '.impulse-hero-sandbox-rail,'
           . '.impulse-hero-sandbox-dock,'
           . '.impulse-hero-sandbox-copy,'
           . '.impulse-hero-sandbox-chevron,'
           . '.impulse-hero-sandbox-hint{pointer-events:none !important}'
           // Dim the editor-preview sandbox so it reads as a "passive
           // layout reference" rather than a live UI — at full opacity
           // designers tried to click it expecting it to work.
           . '.impulse-hero-sandbox{opacity:0.55}';
        if (function_exists('wp_add_inline_style')) wp_add_inline_style('instinctor-page-inline', $_sbEditorCss);
    }

    // The sandbox lets the visitor switch the hero between every one of its
    // environments (cosmos, wave, swirl, glass, blob, wormhole) — which span
    // all render engines. Declare the full engine set so the page enqueues
    // impulse-fx-engine.js (needed by swirl + glass) and the depth-parallax
    // engine (needed by glass), not only the engines the page's own saved
    // environment happens to need. The screen-mode enqueue blocks in
    // templates/impulse-page.php honour these globals.
    $GLOBALS['impulse_needs_fx'] = true;
    $GLOBALS['impulse_needs_depth_parallax'] = true;

    // Curated lists per Sergei's spec. Real depth-image URLs land once the
    // 100 curated 3D backgrounds are uploaded.
    $transitions = [
        ['z_depth','Depth'], ['iris','Iris'], ['dissolve','Dissolve'],
        ['swirl','Spin'], ['depth_zoom','Zoom'],
    ];
    // Each env: [effect, label, preset, intensity, bg_color, depth_image_on].
    // Preset names map to insertion-order entries in each engine's PRESETS
    // dict. intensity/bg/depth_image: null = leave engine default.
    $envs = [
        ['cosmos',     'Cosmos',   'inferno',     15,   null,      false],
        // Lava removed per Sergei — didn't fit alongside the other envs.
        ['wave',       'Wave',     'vortex',      null, null,      false],
        ['swirl',      'Swirl',    '',            null, null,      false],
        ['glass',      'Glass',    '',            null, null,      true ],
        ['blob',       'Blob',     'lava_blob',   null, '#000000', false],
        // Topo removed per Sergei — palette didn't read as premium and the
        // shader's terrain motif overlaps with what Cosmos already offers.
        ['wormhole',   'Wormhole', 'fire_vortex', 8,    null,      false],
    ];
    // Curated 3D images for the sandbox bottom row. Source of truth lives
    // on platform.instinctor.com — admin right-clicks a card in
    // Library > 3D Images and toggles the `sandbox` tag (see
    // platform_v2/views/library.php). Platform exposes the curated set
    // via GET /api/graphics/sandbox-3d which returns up to 8 entries with
    // matching depth maps.
    //
    // Cached for 5 minutes via WP transient so we don\'t hit platform on
    // every hero render. Force-refresh by deleting the transient or
    // appending ?cb= to a hero load.
    $depthImages = [];
    $cacheKey = 'instinctor_sandbox_3d_v1';
    $cached = function_exists('get_transient') ? get_transient($cacheKey) : false;
    if (is_array($cached)) {
        $depthImages = $cached;
    } elseif (!\Instinctor\Core\PlatformConsent::granted()) {
        // WP.org guideline 7/9 — sandbox graphics are fetched from platform.
        // Skip on un-consented WP installs; local renderer falls back to its
        // built-in placeholder set.
        $depthImages = [];
    } else {
        $apiUrl = 'https://platform.instinctor.com/api/graphics/sandbox-3d';
        // WP HTTP API (real on WordPress, curl-backed shim on standalone).
        $resp = wp_remote_get($apiUrl, ['timeout' => 4]);
        $body = is_wp_error($resp) ? false : (string) wp_remote_retrieve_body($resp);
        if ($body !== false && $body !== '') {
            $json = json_decode($body, true);
            if (is_array($json) && !empty($json['success']) && !empty($json['images']) && is_array($json['images'])) {
                foreach ($json['images'] as $img) {
                    $url   = (string) ($img['image_url'] ?? '');
                    $dmUrl = (string) ($img['depth_url'] ?? '');
                    if ($url === '' || $dmUrl === '') continue;
                    $depthImages[] = [
                        'url'       => $url,
                        'depth_url' => $dmUrl,
                        'label'     => (string) ($img['name'] ?? ('Image ' . ($img['id'] ?? '?'))),
                        'thumb'     => (string) ($img['thumb_url'] ?: $url),
                    ];
                }
                if (function_exists('set_transient')) {
                    set_transient($cacheKey, $depthImages, 300); // 5-minute cache
                }
            }
        }
    }
    // Always render exactly 8 slots so the dock has a stable layout.
    // Empty slots become disabled placeholders the admin can fill later.
    while (count($depthImages) < 8) {
        $depthImages[] = ['url' => '', 'depth_url' => '', 'label' => 'Image ' . (count($depthImages) + 1), 'thumb' => ''];
    }

    $activeT   = (string) ($page_settings['screen_transition']   ?? 'z_depth');
    $activeEnv = (string) ($page_settings['screen_cloud_effect'] ?? 'none');
    $activeImg = (string) ($page_settings['screen_depth_image']  ?? '');

    // Singleton CSS — emit once, regardless of how many sandbox instances
    // would otherwise share the styles.
    $css = '.impulse-hero-sandbox{position:fixed;inset:0;z-index:3;pointer-events:none;color:#fff;font-family:inherit}'
         /* Exploring state — the user clicked an env or 3D-image button.
            Older hero-zone sandbox faded the anchor screen's content
            to opacity:0 while exploring an env, so a sparse hero
            could "step aside" and reveal the env unobstructed.
            With the all-screens-equal model and the builder demo
            living on the anchor screen, the demo content IS the
            point: when a visitor clicks Wave the canvas should swap
            behind the demo, never wipe the demo out. The fade is
            disabled but the class still toggles in case other
            handlers want to read sandbox-exploring state. */
         . 'body.impulse-sandbox-exploring [data-sandbox-anchor="1"] .impulse-container{}'
         /* Builder demo open-state hides (REMOVED 2026-06-04). The old
            rules faded the whole sandbox (bd-demo-open) or its rail+
            dock (bd-section-open) whenever the demo panel was active,
            on the assumption the demo would "take over" the screen.
            With the demo's autoplay opening its right panel ~1.5s
            after the visitor lands, the page-level sandbox would
            disappear within a beat of scrolling in — and the
            section-open hide compounded the effect. Sergei's call:
            sandbox is the live, clickable demo, so it stays visible
            the entire time on the anchor screen regardless of which
            block the demo autoplay has selected. The body classes
            still toggle in case future per-block layout code wants
            to read them, but no CSS reads them anymore. */
         . '.impulse-hero-sandbox-rail{position:absolute;right:14px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:5px;pointer-events:auto;background:linear-gradient(180deg,rgba(24,26,32,0.82),rgba(15,17,21,0.9));border:1px solid rgba(255,255,255,0.16);box-shadow:0 8px 30px rgba(0,0,0,0.45),inset 0 1px 0 rgba(255,255,255,0.14);border-radius:8px;padding:5px}'
         . '.impulse-hero-sandbox-dock{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;pointer-events:auto;background:linear-gradient(180deg,rgba(24,26,32,0.82),rgba(15,17,21,0.9));border:1px solid rgba(255,255,255,0.16);box-shadow:0 8px 30px rgba(0,0,0,0.45),inset 0 1px 0 rgba(255,255,255,0.14);border-radius:10px;padding:6px}'
         . '.impulse-hero-sandbox-row{display:flex;gap:5px}'
         // Thumb row stretches across the dock width (whatever the env row
         // happens to be) — each of the 10 thumbs takes an equal slice via
         // flex:1, capped square via aspect-ratio. Without this the 30x30
         // fixed thumbs clustered on the left and looked like an afterthought.
         . '.impulse-hero-sandbox-row.impulse-hero-sandbox-thumbs{flex-wrap:nowrap;width:100%}'
         . '.impulse-hero-sandbox-btn{height:30px;min-width:30px;padding:0 10px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);color:#fff;border-radius:5px;cursor:pointer;font-size:11px;font-weight:500;letter-spacing:0.2px;transition:background 0.15s,border-color 0.15s;display:flex;align-items:center;justify-content:center}'
         . '.impulse-hero-sandbox-btn:hover{background:rgba(255,255,255,0.14);border-color:rgba(255,255,255,0.2)}'
         . '.impulse-hero-sandbox-btn.active{background:#fff;color:#1d2327;border-color:#fff}'
         . '.impulse-hero-sandbox-thumb{flex:1 1 0;height:30px;min-width:30px;width:auto;padding:0;border-radius:5px;background:rgba(255,255,255,0.06)}'
         . '.impulse-hero-sandbox-thumb.is-empty{opacity:0.35;cursor:default;border-style:dashed}'
         . '.impulse-hero-sandbox-thumb.is-empty:hover{background:rgba(255,255,255,0.06);border-color:rgba(255,255,255,0.1)}'
         // Copy line sits 12px above the dock's top edge (dock at bottom:16px
         // with ~78px height → dock top ≈ bottom:94px; copy at bottom:106px).
         . '.impulse-hero-sandbox-copy{position:absolute;left:50%;bottom:106px;transform:translateX(-50%);font-size:11px;color:rgba(255,255,255,0.65);letter-spacing:0.4px;text-align:center;pointer-events:none;white-space:nowrap}'
         // Hint labels — "Scroll" above the right rail, "Switch" above
         // the bottom dock chevron. Same fly-in cascade as the chevrons
         // (see fly-in keyframes in impulse-page.php / impulse-preview.php
         // CSS block). Both initially hidden so they only appear on
         // screen 4 in lockstep with the chevrons.
         //
         // text-align centering used instead of transform: translateX so
         // `transform` is available for the fly-in animation. Width
         // spans 100% of the .impulse-hero-sandbox container; text-align
         // anchors each label to its target horizontally.
         . '.impulse-hero-sandbox-hint{position:absolute;font-size:13px;font-weight:600;letter-spacing:2px;text-transform:uppercase;color:rgba(255,255,255,0.85);pointer-events:none;opacity:0;visibility:hidden;text-shadow:0 1px 3px rgba(0,0,0,0.6)}'
         // Switch hint: above the dock chevron. dock chevron sits at
         // bottom:130-178px; hint at bottom:188px (10px gap above
         // chevron top edge). Full-width container + text-align:center
         // centres the text horizontally without a transform.
         . '.impulse-hero-sandbox-hint-dock{left:0;right:0;bottom:188px;text-align:center}'
         // Scroll hint: nested INSIDE the rail so it inherits the rail's
         // width and sits centred above the buttons with a 12px gap.
         // bottom:calc(100% + 12px) places its baseline 12px above the
         // rail's top edge; left:0/right:0 spans the rail; text-align:
         // center horizontally centres the label.
         . '.impulse-hero-sandbox-hint-rail{position:absolute;bottom:calc(100% + 12px);left:0;right:0;text-align:center}'
         // ── Chevron sizing / position / reveal ───────────────────────
         // Self-contained so the sandbox renders correctly on every path
         // (frontend + editor) without depending on page-level CSS.
         . '.impulse-hero-sandbox-chevron{position:absolute;left:calc(50% - 24px);width:48px;height:48px;color:rgba(255,255,255,0.85);pointer-events:none;opacity:0;visibility:hidden}'
         . '.impulse-hero-sandbox-chevron svg{width:100%;height:100%;display:block;animation:impulse-chevron-pulse 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite}'
         . '.impulse-hero-sandbox-chevron-rail{top:calc(50% - 24px);right:calc(100% + 10px);left:auto}'
         . '.impulse-hero-sandbox-chevron-rail svg{animation:impulse-chevron-pulse-right 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite}'
         . '.impulse-hero-sandbox-chevron-dock{bottom:130px;top:auto}'
         . '@keyframes impulse-chevron-pulse{0%,100%{transform:translateY(0);opacity:0.65}50%{transform:translateY(8px);opacity:1}}'
         . '@keyframes impulse-chevron-pulse-right{0%,100%{transform:rotate(-90deg) translateY(0);opacity:0.65}50%{transform:rotate(-90deg) translateY(8px);opacity:1}}'
         . '@keyframes impulse-chevron-fly-dock{0%{transform:translateY(-32vh) scale(0.35);opacity:0;filter:blur(6px)}40%{opacity:1;filter:blur(2px)}100%{transform:translateY(0) scale(1);opacity:1;filter:blur(0)}}'
         . '@keyframes impulse-chevron-fly-rail{0%{transform:translateX(-35vw) scale(0.35);opacity:0;filter:blur(6px)}40%{opacity:1;filter:blur(2px)}100%{transform:translateX(0) scale(1);opacity:1;filter:blur(0)}}'
         // Last-screen-only reveal. Chevrons + hint labels stay hidden
         // until the final hero screen is active, then fly in.
         // Reveal chevrons + hint labels (SWITCH/SCROLL) whenever the
         // sandbox itself is revealed — same gate as the rail/dock. The
         // old selector chain (body[data-screen-mode="1"].impulse-hero-visible
         // :not(.impulse-hero-exiting):has(.impulse-screen-wrapper > .impulse-screen
         // :last-of-type.impulse-screen-active)) only matched the frontend
         // DOM shape and only the final hero screen; in the merged-mode
         // architecture the chevrons should accompany the sandbox on every
         // non-first hero screen (screens 2-7), not just the dedicated
         // Try-it screen.
         . 'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-chevron,'
         . 'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-hint{visibility:visible}'
         . 'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-chevron-dock,'
         . 'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-hint-dock{animation:impulse-chevron-fly-dock 520ms cubic-bezier(0.4, 0, 0.2, 1) both}'
         . 'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-chevron-rail,'
         . 'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-hint-rail{animation:impulse-chevron-fly-rail 520ms cubic-bezier(0.4, 0, 0.2, 1) both}'
         . 'body[data-screen-mode="1"].impulse-hero-exiting .impulse-hero-sandbox-chevron,'
         . 'body[data-screen-mode="1"].impulse-hero-exiting .impulse-hero-sandbox-hint{opacity:0 !important;visibility:hidden !important;transform:scale(0.7) !important;animation:none !important;transition:opacity 180ms cubic-bezier(0.4, 0, 0.2, 1),transform 180ms cubic-bezier(0.4, 0, 0.2, 1),visibility 0s linear 180ms !important}'
         // ── Mobile (<= 600px) ────────────────────────────────────────
         // Bottom dock: stretch to 100% width (minus 12px gutters) so the
         // env row + 10 thumbs read clearly on a phone.
         // Right rail (reveal effects) + its chevron: hidden — there isn't
         // enough horizontal room beside the dock, and effect-switching
         // isn't a touch-first action anyway.
         . '@media (max-width:600px){'
         .   '.impulse-hero-sandbox-dock{left:12px;right:12px;width:auto;transform:translateY(140%);transition:transform 280ms cubic-bezier(0.4, 0, 0.2, 1)}'
         .   '.impulse-hero-sandbox-rail,'
         .   '.impulse-hero-sandbox-chevron-rail{display:none !important}'
         /* Slide-up from below via the base rule's transition (280ms
            ease-in-out). The resting revealed position is the STATIC
            translateY(0), never a keyframe end-frame. A previous
            decaying-bounce keyframe (impulse-sb-spring-dock-mobile, with
            fill-mode both) silently broke the dock on mobile: when the
            entrance animation was throttled or deferred (a backgrounded
            tab, prefers-reduced-motion, or an element leaving
            visibility:hidden the same frame the animation is assigned,
            which is common on mobile), it never reached 100% and its
            backward-filled 0% frame (translateY 140%) pinned the dock
            off-screen below the fold. A transition has no such failure
            mode: throttled or not, the used transform resolves to
            translateY(0). Sergei 2026-06-24. */
         .   'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-dock{transform:translateY(0)}'
         /* Deterministic CSS-only reveal (Sergei 2026-06-24). The body-class
            path above relies on a JS observer/debounce chain that was
            silently failing to fire on some mobile touch navigations, so the
            dock never appeared on screen 3 even though the screen rendered.
            This reveals the dock whenever the ACTIVE screen contains the
            sandbox anchor — :has() re-evaluates automatically the instant the
            anchor screen gains .impulse-screen-active, with no JS, observer,
            or timing involved. Kept SEPARATE (not comma-merged) so browsers
            without :has() drop only this rule and keep the JS fallback. */
         .   'body:has(.impulse-screen-active [data-sandbox-anchor="1"]) .impulse-hero-sandbox .impulse-hero-sandbox-dock{transform:translateY(0)}'
         . '}'
         // Sandbox controls — hidden on the FIRST screen. On the SECOND
         // screen they fly IN FROM DEPTH-CENTRE: each panel starts small
         // (scale .35) at the centre of the viewport and travels out to
         // its dock, landing with a bounce (overshoot easing). When the
         // reveal state ends — back on screen 1, or the hero exiting past
         // the last screen — the same transition runs in reverse and they
         // retract to depth-centre. The default rules below ARE the
         // depth-centre state; the :has() reveal rules send them home.
         // Depth-centre transform = shift the docked panel to viewport
         // centre (calc against 50vw/50vh) + scale .35.
         // No transition on the hide direction either — when the sandbox-
         // revealed class is removed (user leaves the last hero), the fade
         // out drags the visible state across the screen-change transition.
         // Sergei: "should disappear immediately as last hero screen starts
         // scrolling to body."
         . '.impulse-hero-sandbox{opacity:0;visibility:hidden;transition:none}'
         // !important here is deliberate. The screen-mode baseline rule
         // (body[data-screen-mode="1"] .impulse-hero-sandbox) sets
         // opacity:0 + transform:scale(0.55) without !important. The plain
         // show selector's higher specificity should be enough, but in the
         // builder the values were observed stuck at the baseline — and
         // empirically the fix was to drop the show rule's own `transition`
         // declaration. Some interaction between the show selector's
         // transition shorthand and the baseline's overshoot cubic-bezier
         // appears to keep opacity from settling. With no transition on the
         // show rule, the baseline's transition handles BOTH directions
         // (matching/un-matching of the show selector triggers the same
         // bouncy animation in reverse on exit), and !important opacity:1 +
         // transform:scale(1) now actually take effect.
         . 'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox{opacity:1 !important;visibility:visible !important;transform:scale(1) !important;transition:none !important}'
         // CSS-only reveal twin (see the mobile-dock note): show the sandbox
         // container whenever the active screen hosts the anchor, regardless
         // of whether the JS managed to set body.impulse-sandbox-revealed.
         . 'body:has(.impulse-screen-active [data-sandbox-anchor="1"]) .impulse-hero-sandbox{opacity:1 !important;visibility:visible !important;transform:scale(1) !important;transition:none !important}'
         // Leak fix (Sergei 2026-07-05): body.impulse-sandbox-revealed is
         // flipped the instant a screen nav BEGINS, which re-showed the whole
         // controls overlay (dock + rail) on non-anchor screens — the dock
         // "leaked" onto the screen above the anchor. When the page HAS an
         // anchor but it is NOT on the active screen, force the overlay hidden.
         // Guarded with :has([data-sandbox-anchor="1"]) so anchor-less pages
         // (which rely on impulse-sandbox-revealed alone) are untouched. Higher
         // specificity than the revealed rule, so it wins.
         . 'body[data-screen-mode="1"]:has([data-sandbox-anchor="1"]):not(:has(.impulse-screen-active [data-sandbox-anchor="1"])) .impulse-hero-sandbox{opacity:0 !important;visibility:hidden !important;pointer-events:none !important}'
         // Copy line ("36 motion effects…") does NOT slide with the
         // panels — held hidden through the spring, fades in after they
         // settle (760ms delay > the 640ms spring).
         . '.impulse-hero-sandbox-copy{opacity:0;transition:opacity 200ms ease-in-out}'
         . 'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-copy{opacity:1;transition:opacity 260ms ease-in-out 760ms}'
         // Slide-in — desktop only. The right rail slides in from the
         // right edge, the bottom dock up from below, via the base rule's
         // 300ms ease-in-out transition. The resting revealed transform is
         // the STATIC translateX(0)/translateY(0); see the mobile note
         // above for why the prior decaying-spring keyframes (fill-mode
         // both) were removed — a throttled or deferred entrance left the
         // dock pinned off-screen at its 0% frame. Driven by
         // body.impulse-sandbox-revealed, which JS flips the instant a
         // screen nav BEGINS.
         . '@media (min-width:601px){'
         .   '.impulse-hero-sandbox-rail{transform:translateY(-50%) translateX(160%);transition:transform 300ms cubic-bezier(0.4, 0, 0.2, 1)}'
         .   '.impulse-hero-sandbox-dock{transform:translateX(-50%) translateY(220%);transition:transform 300ms cubic-bezier(0.4, 0, 0.2, 1)}'
         .   'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-rail{transform:translateY(-50%) translateX(0)}'
         .   'body.impulse-sandbox-revealed:not(.impulse-hero-exiting) .impulse-hero-sandbox-dock{transform:translateX(-50%) translateY(0)}'
         /* CSS-only reveal twins (desktop) — see mobile-dock note. */
         .   'body:has(.impulse-screen-active [data-sandbox-anchor="1"]) .impulse-hero-sandbox .impulse-hero-sandbox-rail{transform:translateY(-50%) translateX(0)}'
         .   'body:has(.impulse-screen-active [data-sandbox-anchor="1"]) .impulse-hero-sandbox .impulse-hero-sandbox-dock{transform:translateX(-50%) translateY(0)}'
         . '}';
    wp_add_inline_style('instinctor-page-inline', $css);

    // Lucide chevron-down SVG (inline so it inherits currentColor + animates).
    // stroke-width bumped 2 → 2.5: at 48px the original stroke was visually
    // thin enough that the chevron read as a smudge rather than a clear
    // arrow. 2.5 still looks proportionate but the shape is recognisable
    // at a glance and survives the pulse animation's lower opacity stops.
    $_chevSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';

    // Overlay HTML.
    // Chevrons + SWITCH / SCROLL hint labels removed 2026-05-26 — the
    // sandbox now lives on the third screen (builder demo) where the
    // visitor is already actively exploring the editor; the "here, try
    // it" arrows were redundant signposting. The panels still spring
    // in on screen-3 activation, that motion is the cue.
    echo '<div class="impulse-hero-sandbox" data-sandbox-host="1">';
    // Scroll-transition right rail removed 2026-05-28 — it floated at the
    // viewport's right edge and collided with the builder demo's right
    // panel on the anchor screen (screen 3). A scroll transition also
    // can't be demoed on a single static screen (the effect only shows on
    // the next scroll), so the rail was weak signal at high visual cost.
    // The env/background dock below is the live, self-evident demo. The
    // $transitions array + rail CSS stay dormant in case a future layout
    // wants the control back somewhere that doesn't overlap.
    /* "36 motion effects · 120 presets · 100 curated 3D backgrounds" copy
       line removed — it sat right above the sandbox dock as a fact-strip,
       but on a screen that already includes the live sandbox controls
       (Cosmos / Wave / Swirl / Glass / Blob / Wormhole + 8 depth-image
       thumbs) the visitor can SEE the variety. Stating the count above
       reads like throat-clearing. The CSS for .impulse-hero-sandbox-copy
       stays in case a future iteration wants the slot back. */
    // Greyscale slider styling for the Glass-only Size + Overlay controls.
    $_sbGlassCss = '.impulse-hero-sandbox-glass{display:flex;gap:14px;align-items:center;justify-content:center;padding:6px 4px 2px}'
       . '.impulse-hero-sandbox-glass[hidden]{display:none}'
       /* Wave + Blob bg toggle — two swatch dots (black / white)
          shown only while one of those envs is active. Lets the
          visitor see how the gradient/blob reads on either
          extreme without changing the page palette. */
       . '.impulse-hero-sandbox-bg{display:flex;gap:8px;align-items:center;justify-content:center;padding:6px 4px 2px}'
       . '.impulse-hero-sandbox-bg[hidden]{display:none}'
       . '.impulse-hero-sandbox-bg-label{color:rgba(255,255,255,0.72);font-size:10px;font-weight:600;letter-spacing:0.6px;text-transform:uppercase}'
       . '.impulse-hero-sandbox-bg-btn{width:22px;height:22px;border-radius:50%;border:1px solid rgba(255,255,255,0.32);cursor:pointer;padding:0;transition:border-color 150ms ease-in-out,transform 150ms ease-in-out,box-shadow 150ms ease-in-out;background:#000}'
       . '.impulse-hero-sandbox-bg-btn[data-bg-value="#ffffff"]{background:#fff}'
       . '.impulse-hero-sandbox-bg-btn.active{border-color:#fff;transform:scale(1.18);box-shadow:0 0 0 2px rgba(255,255,255,0.22)}'
       /* Wormhole color swatches — same dot pattern as the BG toggle,
          one per palette. Background-color set inline from the palette
          definition so adding/editing a palette only needs a JS edit. */
       . '.impulse-hero-sandbox-wh{display:flex;gap:8px;align-items:center;justify-content:center;padding:6px 4px 2px}'
       . '.impulse-hero-sandbox-wh[hidden]{display:none}'
       . '.impulse-hero-sandbox-wh-btn{width:22px;height:22px;border-radius:50%;border:1px solid rgba(255,255,255,0.32);cursor:pointer;padding:0;transition:border-color 150ms ease-in-out,transform 150ms ease-in-out,box-shadow 150ms ease-in-out}'
       . '.impulse-hero-sandbox-wh-btn.active{border-color:#fff;transform:scale(1.18);box-shadow:0 0 0 2px rgba(255,255,255,0.22)}'
       /* Glass-only tip: prompts visitors to pick a 3D image first so
          the shader has a meaningful backdrop to refract. Sits just
          above the Size/Overlay sliders, only visible while Glass is
          active AND no thumb has been picked yet.
          Class name MUST stay distinct from .impulse-hero-sandbox-hint
          (a separate orphan class for the legacy SCROLL/SWITCH labels
          that still has opacity:0;visibility:hidden in the singleton
          stylesheet above). Sharing the class name once made my tip
          invisible AND the dock reveal cascade misfired. */
       . '.impulse-hero-sandbox-tip{display:flex;justify-content:center;align-items:center;padding:6px 4px 0;color:rgba(255,255,255,0.78);font-size:10px;font-weight:600;letter-spacing:0.6px;text-transform:uppercase;text-align:center;transition:opacity 280ms ease-in-out}'
       . '.impulse-hero-sandbox-tip[hidden]{display:none}'
       . '.impulse-hero-sandbox-slider{display:flex;align-items:center;gap:7px;flex:1;color:rgba(255,255,255,0.72);font-size:10px;font-weight:600;letter-spacing:0.6px;text-transform:uppercase}'
       . '.impulse-hero-sandbox-slider input[type=range]{flex:1;-webkit-appearance:none;appearance:none;height:3px;border-radius:2px;background:rgba(255,255,255,0.25);outline:none;margin:0}'
       . '.impulse-hero-sandbox-slider input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:13px;height:13px;border-radius:50%;background:#fff;cursor:pointer}'
       . '.impulse-hero-sandbox-slider input[type=range]::-moz-range-thumb{width:13px;height:13px;border:none;border-radius:50%;background:#fff;cursor:pointer}';
    if (function_exists('wp_add_inline_style')) wp_add_inline_style('instinctor-page-inline', $_sbGlassCss);
    echo '<div class="impulse-hero-sandbox-dock">';
    echo '<div class="impulse-hero-sandbox-row" role="group" aria-label="Environments">';
    foreach ($envs as $e) {
        $cls = 'impulse-hero-sandbox-btn' . ($activeEnv === $e[0] ? ' active' : '');
        echo '<button type="button" class="' . esc_attr($cls) . '"';
        echo ' data-action="env" data-value="' . esc_attr($e[0]) . '"';
        echo ' data-preset="' . esc_attr($e[2]) . '"';
        if ($e[3] !== null) echo ' data-intensity="' . intval($e[3]) . '"';
        if ($e[4] !== null) echo ' data-bg="' . esc_attr($e[4]) . '"';
        if ($e[5])          echo ' data-depth-on="1"';
        echo '>' . esc_html($e[1]) . '</button>';
    }
    echo '</div>';
    echo '<div class="impulse-hero-sandbox-row impulse-hero-sandbox-thumbs" role="group" aria-label="3D images">';
    foreach ($depthImages as $img) {
        $isActive = ($activeImg && $activeImg === $img['url']);
        $isEmpty  = empty($img['url']);
        $cls = 'impulse-hero-sandbox-btn impulse-hero-sandbox-thumb' . ($isActive ? ' active' : '') . ($isEmpty ? ' is-empty' : '');
        echo '<button type="button" class="' . esc_attr($cls) . '"'
           . ' data-action="depth_image"'
           . ' data-value="' . esc_attr($img['url']) . '"'
           . ' data-depth="' . esc_attr($img['depth_url']) . '"'
           . ' aria-label="' . esc_attr($img['label']) . '"';
        if (!$isEmpty) echo ' style="background-image:url(' . esc_url($img['thumb']) . ');background-size:cover;background-position:center"';
        if ($isEmpty)  echo ' disabled';
        echo '></button>';
    }
    echo '</div>';
    // Glass-only hint. Shows the first time a visitor picks Glass with no
    // 3D image active — prompts them to pick an image so they choose the
    // backdrop the sphere refracts. Auto-hides once an image is picked
    // (the click handler watches the thumb row).
    echo '<div class="impulse-hero-sandbox-tip" data-glass-hint hidden>';
    echo '<span>Tip: Pick a 3D image, then Glass.</span>';
    echo '</div>';
    // Glass-only live controls (Size + Overlay). Hidden until the Glass env is
    // selected (toggled by _sbShowGlass in the click handler).
    echo '<div class="impulse-hero-sandbox-row impulse-hero-sandbox-glass" data-glass-controls hidden>';
    echo '<label class="impulse-hero-sandbox-slider"><span>Size</span><input type="range" min="0" max="100" value="0" data-glass-size aria-label="Glass sphere size"></label>';
    echo '<label class="impulse-hero-sandbox-slider"><span>Overlay</span><input type="range" min="0" max="100" value="60" data-glass-overlay aria-label="3D image overlay opacity"></label>';
    echo '</div>';
    // Wave-only bg toggle. Blob doesn't get one: its shader paints
    // pure white for every non-blob pixel (see impulse-3d-effects.js
    // BLOB.frag line ~689 `if(!hit){gl_FragColor=vec4(1.0);return;}`),
    // so a "Black" choice would have no visual effect. Wave defaults
    // to black for legibility against white hero text; the toggle lets
    // visitors flip to white to see Wave's lighter palette pop.
    echo '<div class="impulse-hero-sandbox-bg" data-bg-toggle hidden>';
    echo '<span class="impulse-hero-sandbox-bg-label">Background</span>';
    echo '<button type="button" class="impulse-hero-sandbox-bg-btn active" data-bg-value="#000000" aria-label="Black background"></button>';
    echo '<button type="button" class="impulse-hero-sandbox-bg-btn" data-bg-value="#ffffff" aria-label="White background"></button>';
    echo '</div>';
    // Wormhole color picker. Six palettes ranging across the spectrum so
    // visitors can recolour the tunnel in one click. Each entry is
    // [key, label, dot-color, [c1,c2,c3,c4]]. The dot color is the
    // dominant brightest hue so the swatch reads at a glance. data-wh-key
    // ties the button to the matching palette in _sbWormholePalettes (JS).
    $wormholePalettes = [
        ['inferno',  'Inferno',  '#ed5509', ['#423d61','#ed5509','#2b3e40','#000000']],
        ['cyber',    'Cyber',    '#00bcd4', ['#001f3f','#00bcd4','#1a2a3a','#000000']],
        ['toxic',    'Toxic',    '#00ff88', ['#003020','#00ff88','#102015','#000000']],
        ['violet',   'Violet',   '#a020f0', ['#1a0033','#a020f0','#150018','#000000']],
        ['sunset',   'Sunset',   '#ff5773', ['#1a0010','#ff5773','#ff9966','#000000']],
        ['gold',     'Gold',     '#ffc857', ['#1a1004','#ffc857','#3a2410','#000000']],
    ];
    echo '<div class="impulse-hero-sandbox-wh" data-wh-toggle hidden>';
    // "Color" label removed (Sergei 2026-06-24) — the swatch dots read as
    // colour presets on their own; the label was redundant clutter.
    foreach ($wormholePalettes as $i => $p) {
        $cls = 'impulse-hero-sandbox-wh-btn' . ($i === 0 ? ' active' : '');
        echo '<button type="button" class="' . esc_attr($cls)
           . '" data-wh-key="' . esc_attr($p[0]) . '"'
           . ' aria-label="' . esc_attr($p[1]) . ' palette"'
           . ' style="background:' . esc_attr($p[2]) . '"></button>';
    }
    echo '</div>';
    echo '</div>'; // dock
    echo '</div>'; // sandbox

    // Singleton click handler. Mirrors the new value to the engine's source-
    // of-truth attributes (the screen wrapper and body) AND calls the engine
    // API to re-init live — no reload needed. Skips silently
    // if ImpulseScreens isn't on the page yet (sandbox is rendered before
    // engine init in some templates).
    // Preset → engine attrs table. Each engine reads from a different attribute
    // family — see initTunnel() in assets/js/impulse-screens.js:
    //   3D engine (cosmos/blob/wormhole): data-3d-preset + data-3d-colorN.
    //     CRITICAL: initTunnel always passes `colors` to Impulse3D.init from
    //     wrapperEl.dataset, and createInstance() in impulse-3d-effects.js
    //     (line 2248) uses `opts.colors || preset.colors` — meaning passed
    //     colors OVERRIDE preset colors. So we MUST write the preset's color
    //     palette onto wrapperEl too, otherwise stale colours from a previous
    //     env (or page defaults) bleed through. That's why Blob looked blue
    //     before — wrapper still had cosmos's blue palette from earlier.
    //   Wave engine: data-wave-preset + data-wave-colorN overrides
    //   FX engine (swirl/glass/topography): NO preset reading — only colors
    //     read from data-3d-colorN. CRITICAL gotcha — frontend reads
    //     bgColor from body.dataset.pageBgColor, but EDITOR reads it from
    //     `data-screen-3d-color3` (impulse-preview.php line 18789-18791;
    //     defaults to `#666666` when unset). Solution: include the bg as
    //     the 3rd entry in the colors array — covers both paths.
    //   Lava (MotionLava): NO preset reading — only data-lava-colorN colors
    // 3D colour palettes are pulled from the engine's own preset table
    // (impulse-3d-effects.js — BLOB.presets, STARS_3D.presets) for cosmos/
    // blob, and from _3dColors in instinctor-admin-core.js for wormhole
    // (where the engine's preset.colors are just grey placeholders).
    // FX palettes are from _fxColors. `sens` forces a non-zero mouse
    // sensitivity so effects like Wormhole actually react to cursor — page
    // settings often save screen_mouse_sens_level=0, which would otherwise
    // freeze u_mouse to (0.5,0.5) in the render loop.
    $envPresetsJs = '{'
        . 'cosmos:{engine:"3d",preset:"inferno",intensity:15,speed:5,bg:"#000000",'
            . 'colors:["#aa2211","#ff6622","#ffaa44","#ffeebb"],sens:0.5},'
        // Wave — fiery vortex preset, plus a `wavePosX` override applied
        // after the engine inits. On desktop the wave shape was almost
        // centered (preset posX:380 × sf:~1.33 ≈ 505); pushing it to 750
        // anchors it on the right edge so the hero text breathes. On
        // mobile/portrait we leave it alone — the engine\'s _adjustForViewport
        // already scales it correctly there.
        // Wave ships a white backdrop by default — white hero text (the
        // saved page palette) disappears against it. Force a black page
        // bg whenever Wave is selected from the sandbox so white content
        // stays readable. Other envs keep their own bg or the page
        // default (handled by the cfg.bg branch in applyEnv).
        . 'wave:{engine:"wave",preset:"vortex",colors:["#ffdd00","#ff6b35","#d90429","#6a040f"],bg:"#000000",wavePosX:750},'
        . 'swirl:{engine:"fx",preset:"vortex",colors:["#0066ff","#ffffff","#000000"],bg:"#000000",sens:0.2},'
        // Glass — sphere size scales with u_intensity (shader: sz=0.5+u_intensity*1.5;
        // the runtime divides data-3d-intensity by 10). intensity:5 → u_intensity 0.5
        // → sz≈1.25 — a small sphere matching the dock Size slider's minimum (its
        // default). The Size slider maps 0..100 → u_intensity 0.5..0.85 (sz 1.25..1.78),
        // deliberately capped well below the old sz≈8 that overflowed the viewport.
        // Glass renders a full-viewport depth-parallax 3D image BEHIND the sphere
        // (data-outer-opacity / Overlay slider) and refracts the same image inside it.
        . 'glass:{engine:"fx",preset:"diamond",intensity:5,colors:["#1a0a2a","#8844cc","#000000"],bg:"#000000",depthOn:true,sens:0.5},'
        // Blob palette (Sergei 2026-06-04): pure-red gradient instead
        // of the engine\'s `lava_blob` preset which ramps red → orange →
        // yellow. The yellow #ffab40 stop combined with Blob\'s edge-fade-
        // to-white made the whole blob read yellow, not red. Four pure-red
        // stops give a heat-map feel without any warm overshoot.
        . 'blob:{engine:"3d",preset:"lava_blob",bg:"#000000",'
            . 'colors:["#1a0000","#8b1111","#d92929","#ff4d4d"],sens:2.5},'
        // Wormhole — palette per Sergei\'s hand-picked spec.
        //   B (bg) = #000000 black void
        //   1 = #423d61 dusky purple
        //   2 = #ed5509 bright orange
        //   3 = #2b3e40 deep teal-grey
        //   4 = #000000 (matches bg — anchors the palette\'s dark end so
        //                no foreign hue creeps in via wrapperEl fallbacks)
        /* Wormhole + Blob ride hard on cursor interaction — the
           original 0.5 sens left the warp barely reacting to a
           full-window drag. Bumped to 2.5 so even a small mouse
           move arcs the vortex visibly. Gyro auto-scales with
           the same multiplier in the 3D engine render loop, so
           tablet/phone tilt gets the same drama. */
        . 'wormhole:{engine:"3d",preset:"fire_vortex",intensity:18,bg:"#000000",'
            . 'colors:["#423d61","#ed5509","#2b3e40","#000000"],sens:2.5}'
        . '}';

    $_sbJs = '(function(){'
       . 'var P=' . $envPresetsJs . ';'
       /* Dirty bit: set true the first time the visitor clicks an env or
          depth-image button. _sbToggle() reads it on every screen change
          and, if true, calls _sbResetDefault() to snap the canvas back to
          the page default. Cleared after each reset so repeat visits to
          the anchor screen don't double-reinit. */
       . 'var _sbEnvDirty=false;'
       /* Page default env — what _sbResetDefault snaps back to when
          the visitor scrolls away from the sandbox. Pulled from saved
          page settings (screen_cloud_effect), defaulting to cosmos
          when unset. NOT necessarily one of the sandbox preset list:
          page 113 ships with "lava", which has no entry in P and
          relies on its data-lava-* attrs (intact on the wrapper from
          server render) to boot cleanly. */
       . 'var _sbPageDefault=' . wp_json_encode($activeEnv && $activeEnv !== 'none' ? $activeEnv : 'cosmos') . ';'
       // Tracks the env the visitor last picked, so _sbToggle can decide
       // whether it should persist onto neighbour screens (ambient envs) or
       // reset the instant they leave the anchor (image-based: glass / 3D image).
       . 'var _sbCurEnv=_sbPageDefault;'
       . 'function setAttrPair(wrap,body,wAttr,bAttr,val){if(wrap)wrap.setAttribute(wAttr,val);body.setAttribute(bAttr,val);}'
       . 'function applyEnv(cfg,effect,wrap,body){'
         // Always write the cloud-effect itself (so reinit reads the new effect).
         . 'setAttrPair(wrap,body,"data-cloud-effect","data-screen-cloud-effect",effect);'
         // Preset name — routed to the right attr per engine.
         . 'if(cfg.preset){'
           . 'if(cfg.engine==="wave"){setAttrPair(wrap,body,"data-wave-preset","data-screen-wave-preset",cfg.preset);}'
           // FX engines have no preset uniform but preserve attr for round-trip.
           . 'setAttrPair(wrap,body,"data-3d-preset","data-screen-3d-preset",cfg.preset);'
         . '}'
         // Colors — destination attr depends on engine.
         . 'if(cfg.colors){'
           . 'var pref=cfg.engine==="lava"?"lava":cfg.engine==="wave"?"wave":"3d";'
           . 'cfg.colors.forEach(function(c,i){'
             . 'setAttrPair(wrap,body,"data-"+pref+"-color"+(i+1),"data-screen-"+pref+"-color"+(i+1),c);'
           . '});'
         . '}'
         // Intensity (lava/wave/3d each have own intensity attr).
         . 'if(cfg.intensity!=null){'
           . 'var ipref=cfg.engine==="lava"?"lava":cfg.engine==="wave"?"wave":"3d";'
           . 'setAttrPair(wrap,body,"data-"+ipref+"-intensity","data-screen-"+ipref+"-intensity",String(cfg.intensity));'
         . '}'
         // Speed — same per-engine routing as intensity. Cosmos at the saved
         // page default (10) is too fast; the sandbox preset can override
         // per-env so Try It Yourself reads correctly without touching saved
         // page settings.
         . 'if(cfg.speed!=null){'
           . 'var spref=cfg.engine==="lava"?"lava":cfg.engine==="wave"?"wave":"3d";'
           . 'setAttrPair(wrap,body,"data-"+spref+"-speed","data-screen-"+spref+"-speed",String(cfg.speed));'
           . 'setAttrPair(wrap,body,"data-screen-tunnel-speed","data-screen-tunnel-speed",String(cfg.speed));'
         . '}'
         // Background — FX shaders read bgColor from _pageBg, which derives
         // from body.dataset.pageBgColor. 3D engines route via updateBgColor.
         // PLUS we own a dedicated <style> tag that overrides the page's own
         // `html,body{background:...!important}` rule. Injecting our own
         // LATER style wins on cascade order for same-specificity !important
         // rules.
         . 'if(cfg.bg){'
           . 'setAttrPair(wrap,body,"data-page-bg-color","data-page-bg-color",cfg.bg);'
           // Dedicated override targeting the BACKDROP behind the env canvas
           // — `html,body`. It must NOT target `.impulse-screen-wrapper`:
           // the wrapper is z-index:1, the env canvas is z-index:0, so an
           // opaque wrapper bg paints straight over every environment and
           // hides it. html,body sit behind the canvas — exactly the "page
           // backdrop" this override is for.
           . 'var sb=document.getElementById("impulse-sandbox-bg-override");'
           . 'if(!sb){sb=document.createElement("style");sb.id="impulse-sandbox-bg-override";document.head.appendChild(sb);}'
           . 'sb.textContent="html,body{background:"+cfg.bg+"!important}";'
           // Clear any direct inline backgrounds so the stylesheet is the
           // only thing controlling it.
           . 'body.style.removeProperty("background");'
           . 'document.documentElement.style.removeProperty("background");'
         . '}else{'
           // Env doesn\'t force a bg — strip any per-env override.
           . 'var _sb=document.getElementById("impulse-sandbox-bg-override");'
           . 'if(_sb)_sb.remove();'
           . 'body.style.removeProperty("background");'
           . 'document.documentElement.style.removeProperty("background");'
         . '}'
         // Depth image layer. Glass renders its shader over a 3D image
         // backdrop, so when Glass is selected we ensure depth is on AND
         // populate the depth image — falling back to the first tagged
         // sandbox thumb if none has been picked yet, so visiting Glass
         // from a fresh load always has a usable bg. Users can still pick
         // a different image from the thumb row to swap it.
         // For every other env we CLEAR the depth attrs so a previously-
         // clicked thumb doesn\'t bleed through. Without this, the engine\'s
         // reinitTunnel saw data-depth-enabled=1 still set, re-initialised
         // ImpulseDepthParallax on top of the new env, and the still
         // image stayed visible instead of the new animation.
         . 'if(cfg.depthOn){'
           // Resolve the image: reuse whatever was last stamped (body or wrapper),
           // else auto-pick the first non-empty sandbox thumb.
           . 'var curImg=body.getAttribute("data-depth-image")||(wrap&&wrap.getAttribute("data-depth-image"))||"";'
           . 'var curMap=body.getAttribute("data-depth-map")||(wrap&&wrap.getAttribute("data-depth-map"))||"";'
           . 'if(!curImg){'
             . 'var firstThumb=Array.from(document.querySelectorAll(".impulse-hero-sandbox-btn[data-action=\"depth_image\"]")).find(function(b){return b.getAttribute("data-value")&&!b.disabled;});'
             . 'if(firstThumb){'
               . 'curImg=firstThumb.getAttribute("data-value");curMap=firstThumb.getAttribute("data-depth")||"";'
               . 'document.querySelectorAll(".impulse-hero-sandbox-btn[data-action=\"depth_image\"]").forEach(function(b){b.classList.remove("active");});'
               . 'firstThumb.classList.add("active");'
             . '}'
           . '}'
           // The full-viewport 3D image renders via a depth-parallax canvas on
           // document.body (z-index:-1), BEHIND the FX glass canvas (z:0) — see
           // the glass branch of the click handler. We must NOT leave depth attrs
           // on the screen wrapper, or reinitTunnel inits a SECOND parallax there
           // at z-index:2 that paints OVER the sphere (the "sphere is gone, only
           // image" bug). Strip the wrapper; stamp the body.
           // The FX glass shader refracts wrapperEl.dataset.depthImage INSIDE the
           // sphere, so give the wrapper the IMAGE. But it must NOT carry a depth
           // MAP — reinitTunnel only spawns the covering wrapper-parallax when BOTH
           // image AND map are present, so image-only refracts without covering the
           // sphere. The full-viewport image (with depth motion) renders separately
           // on document.body below.
           . 'if(wrap){wrap.setAttribute("data-depth-image",curImg);wrap.removeAttribute("data-depth-map");wrap.removeAttribute("data-depth-enabled");}'
           . 'body.setAttribute("data-depth-image",curImg);'
           . 'body.setAttribute("data-depth-map",curMap);'
           . 'body.setAttribute("data-depth-enabled","1");'
           . 'if(!body.getAttribute("data-depth-strength"))body.setAttribute("data-depth-strength","60");'
           // Outer-overlay opacity — the FX glass shader reads this from the
           // wrapper at init and paints everything OUTSIDE the sphere at this
           // opacity. Low → transparent → the body image shows around the sphere.
           . 'setAttrPair(wrap,body,"data-outer-opacity","data-outer-opacity","60");'
         . '}else{'
           . 'var stage=wrap;'
           . 'if(stage){'
             . 'stage.removeAttribute("data-depth-enabled");'
             . 'stage.removeAttribute("data-depth-image");'
             . 'stage.removeAttribute("data-depth-map");'
           . '}'
           . 'body.removeAttribute("data-depth-enabled");'
           . 'body.removeAttribute("data-depth-image");'
           . 'body.removeAttribute("data-depth-map");'
           // Also clear depth_image highlight on thumb buttons so they
           // don\'t look "active" when the env switched away.
           . 'document.querySelectorAll(".impulse-hero-sandbox-btn[data-action=\"depth_image\"]").forEach(function(b){b.classList.remove("active");});'
           . 'if(window.ImpulseDepthParallax){try{window.ImpulseDepthParallax.destroyAll();}catch(_e){}}'
         . '}'
         // Mouse sensitivity — read by initTunnel as
         //   wrapperEl.dataset.mouseSensLevel (frontend)
         //   body.dataset.screenMouseSensLevel (editor)
         // Pages save this as screen_mouse_sens_level which defaults 5 →
         // 0.5 on the wrapper, but Sergei's homepage saved it as 0, freezing
         // Wormhole's u_mouse to (0.5,0.5) and killing all cursor response.
         // Force it back to a sensible value per env.
         . 'if(cfg.sens!=null){'
           . 'setAttrPair(wrap,body,"data-mouse-sens-level","data-screen-mouse-sens-level",String(cfg.sens));'
         . '}'
       . '}'
       // ── Glass dock controls (Size + Overlay) ───────────────────────────
       // Shown only while the Glass env is active. Size scales the sphere;
       // Overlay sets the opacity of everything painted OUTSIDE the sphere
       // (higher = the full-viewport 3D image behind it is more covered).
       . 'function _sbGlassEl(){return document.querySelector("[data-glass-controls]");}'
       . 'function _sbBgEl(){return document.querySelector("[data-bg-toggle]");}'
       . 'function _sbWhEl(){return document.querySelector("[data-wh-toggle]");}'
       /* Per-env bg preference. Currently Wave only — Blob renders
          its non-blob pixels as pure white in the shader and
          ignores any backdrop choice (see Blob bg-toggle comment
          above). Visitor picks white → it sticks for Wave across
          re-selections. */
       . 'var _sbBgPref={wave:"#000000"};'
       /* Wormhole palette table — keyed to the data-wh-key buttons.
          Each is the 4-color array fed into the 3D engine\'s
          updateColors() + the data-3d-color1..4 attrs. Defaults to
          inferno (matches the wormhole entry in P at the top). */
       . 'var _sbWormholePalettes={'
         . 'inferno:["#423d61","#ed5509","#2b3e40","#000000"],'
         . 'cyber:["#001f3f","#00bcd4","#1a2a3a","#000000"],'
         . 'toxic:["#003020","#00ff88","#102015","#000000"],'
         . 'violet:["#1a0033","#a020f0","#150018","#000000"],'
         . 'sunset:["#1a0010","#ff5773","#ff9966","#000000"],'
         . 'gold:["#1a1004","#ffc857","#3a2410","#000000"]'
       . '};'
       . 'var _sbWormholePref="inferno";'
       . 'function _sbShowBgToggle(env){'
         . 'var bg=_sbBgEl();if(!bg)return;'
         . 'var on=(env==="wave");'
         . 'if(on){'
           . 'bg.removeAttribute("hidden");'
           // Light up the dot matching the saved pref for this env.
           . 'var cur=_sbBgPref[env]||"#000000";'
           . 'bg.querySelectorAll(".impulse-hero-sandbox-bg-btn").forEach(function(b){b.classList.toggle("active",b.getAttribute("data-bg-value").toLowerCase()===cur.toLowerCase());});'
           // Sync body class so the demo columns can paint a dark
           // underlay when the visitor is on white (their translucent
           // glass surfaces are designed for dark backdrops and read
           // as muddy panels on white otherwise).
           . 'document.body.classList.toggle("impulse-sandbox-bg-white",(cur||"").toLowerCase()==="#ffffff");'
         . '}else{'
           . 'bg.setAttribute("hidden","");'
           // Leaving Wave: clear the white-bg flag so the demo columns
           // drop their dark underlay.
           . 'document.body.classList.remove("impulse-sandbox-bg-white");'
         . '}'
       . '}'
       /* Wormhole color-picker visibility. Shown only when Wormhole
          is the active env. On show, lights up the dot matching the
          last picked palette so the row reflects current state. */
       . 'function _sbShowWormholeColors(env){'
         . 'var wh=_sbWhEl();if(!wh)return;'
         . 'if(env==="wormhole"){'
           . 'wh.removeAttribute("hidden");'
           . 'wh.querySelectorAll(".impulse-hero-sandbox-wh-btn").forEach(function(b){b.classList.toggle("active",b.getAttribute("data-wh-key")===_sbWormholePref);});'
         . '}else{'
           . 'wh.setAttribute("hidden","");'
         . '}'
       . '}'
       . 'function _sbHintEl(){return document.querySelector("[data-glass-hint]");}'
       /* Hint visibility rule: shown when Glass is active AND no 3D image
          thumb is currently active. Once the visitor picks any thumb the
          hint never returns this session, because the click sets
          _sbHintConsumed and _sbShowGlassHint short-circuits. */
       . 'var _sbHintConsumed=false;'
       . 'function _sbShowGlassHint(on){var h=_sbHintEl();if(!h)return;if(_sbHintConsumed)on=false;if(on){h.removeAttribute("hidden");}else{h.setAttribute("hidden","");}}'
       . 'function _sbShowGlass(on){var g=_sbGlassEl();if(g){if(on){g.removeAttribute("hidden");}else{g.setAttribute("hidden","");}}_sbShowGlassHint(on);}'
       . 'function applyGlassParams(size,overlay){'
         . 'var wrap=document.querySelector(".impulse-screen-wrapper"),body=document.body;'
         . 'var uInt=Math.max(0.5,Math.min(0.85,0.5+(parseFloat(size)||0)/100*0.35));'
         . 'var outerOp=Math.max(0,Math.min(1,(parseFloat(overlay)||0)/100));'
         . 'var ov=String(Math.round(parseFloat(overlay)||0));'
         . 'if(wrap){wrap.setAttribute("data-3d-intensity",String(Math.round(uInt*10)));wrap.setAttribute("data-outer-opacity",ov);}'
         . 'body.setAttribute("data-outer-opacity",ov);'
         . 'if(window._impulseFX&&typeof window._impulseFX.update==="function"){window._impulseFX.update({intensity:uInt,outerOpacity:outerOp});}'
       . '}'
       . 'document.addEventListener("click",function(e){'
         . 'var btn=e.target.closest&&e.target.closest(".impulse-hero-sandbox-btn");if(!btn)return;'
         . 'e.preventDefault();e.stopPropagation();'
         . 'var act=btn.getAttribute("data-action"),val=btn.getAttribute("data-value");'
         . 'var wrap=document.querySelector(".impulse-screen-wrapper");'
         . 'var body=document.body;'
         . 'var group=btn.parentElement;if(group){group.querySelectorAll(".impulse-hero-sandbox-btn").forEach(function(b){b.classList.remove("active")});btn.classList.add("active");}'
         . 'var API=window.ImpulseScreens||null;'
         . 'if(act==="transition"){'
           // The screen engine resolves reveal effect per zone. _zoneReveal()
           // in impulse-screens.js reads wrapperEl.dataset.heroTransition for
           // hero screens and .dataset.transition for body screens, each
           // falling back to config.transition. The sandbox lives in the hero
           // zone and Sergei wants its choices to affect the hero only — the
           // body zone keeps its own saved reveal. So we update ONLY the hero
           // attrs (+ their body-element twin used by the editor) and leave
           // data-transition and the global config.transition fallback alone.
           . 'if(wrap)wrap.setAttribute("data-hero-transition",val);'
           . 'body.setAttribute("data-screen-hero-transition",val);'
         . '}else if(act==="env"){'
           /* Page-default click = no dirty state, no scroll-away reset
              (visitor is already on the page default; nothing to revert
              to). Any other env flips dirty ON so scroll-away triggers
              _sbResetDefault and restores the page's saved env.
              Previously hardcoded `val==="cosmos"`, which assumed cosmos
              was always the page default. On page 124 (default=lava)
              that meant clicking Cosmos was silently treated as "back
              to default" and the dock left it persistent across every
              screen. Fixed 2026-06-04 to read _sbPageDefault dynamically. */
           . 'if(val===_sbPageDefault){_sbEnvDirty=false;document.body.classList.remove("impulse-sandbox-exploring");}else{_sbEnvDirty=true;document.body.classList.add("impulse-sandbox-exploring");}_sbCurEnv=val;'
           // Wave + Blob expose a bg toggle (black/white). Show/hide it
           // alongside the env, and inject any saved pref into the preset
           // BEFORE applyEnv runs so the engine boots on the right bg.
           . '_sbShowBgToggle(val);'
           . 'if(_sbBgPref[val])P[val]=Object.assign({},P[val]||{},{bg:_sbBgPref[val]});'
           // Show wormhole color picker when wormhole is the active env.
           // Apply saved palette pref to P.wormhole BEFORE applyEnv so
           // the engine boots on the right colors.
           . '_sbShowWormholeColors(val);'
           . 'if(val==="wormhole"&&_sbWormholePalettes[_sbWormholePref]){'
             . 'P.wormhole=Object.assign({},P.wormhole||{},{colors:_sbWormholePalettes[_sbWormholePref].slice()});'
           . '}'
           /* Swirl wraps the viewport in fast-moving high-contrast
              patterns. Transparent / dark-tinted children of the demo
              (Grid + Rows + Columns cells) blend into the moving
              shader and become unreadable. The body class lets the
              demo CSS swap those cells to opaque dark so the
              autoplay frames still read while Swirl is active. */
           . 'document.body.classList.toggle("impulse-sandbox-swirl",val==="swirl");'
           /* Blob's shader paints every non-blob pixel pure white
              (can't be overridden, see BLOB.frag line ~689) so when
              it's active the demo columns sit on a bright backdrop
              and their dark translucent glass reads as muddy gray.
              Class flag mirrors what bg-white does for Wave so the
              column-opacification CSS can fire for both. */
           . 'document.body.classList.toggle("impulse-sandbox-blob",val==="blob");'
           /* Glass paints a refractive sphere on top of a full-
              viewport 3D image. Both layers are busy / high-detail,
              so demo Grid/Rows/Columns cells with translucent rgba
              backgrounds disappear into the imagery. Force the cells
              opaque while Glass is the active env. */
           . 'document.body.classList.toggle("impulse-sandbox-glass",val==="glass");'
           /* Wormhole's tunnel pattern is high-contrast and full-
              viewport — translucent demo chrome reads as smudge over
              it. Same opacification rule as swirl/glass/depth-image. */
           . 'document.body.classList.toggle("impulse-sandbox-wormhole",val==="wormhole");'
           // Picking any non-depth-image env clears the depth-image
           // flag too (handler below sets it when a thumb fires).
           . 'document.body.classList.remove("impulse-sandbox-depth-image");'
           . 'var cfg=P[val]||{engine:"3d"};'
           . 'applyEnv(cfg,val,wrap,body);'
           . 'if(API&&API.reinitTunnel)try{API.reinitTunnel(false,val,false,parseFloat(body.getAttribute("data-screen-tunnel-speed"))||1);}catch(_e){}'
           // Wave gets a post-init position nudge on desktop. The engine
           // ships its preset posX baked into PRESETS — we can\'t pass an
           // override at construction, but _basePosX is read by
           // _adjustForViewport on every resize, so mutating it after the
           // engine spawns is enough. Landscape only — on portrait the
           // engine\'s own 1.4× sf already pushes the mesh into the visible
           // region, so don\'t double-shift.
           . 'if(val==="wave"){'
             // Wave paints its own canvas backdrop via WebGL clearColor,
             // initialised to white at construct time (impulse-wave.js
             // line ~373). The html/body bg we set above sits BEHIND the
             // wave canvas and never shows. Push the desired bg into the
             // engine instance via updateBgColor() once it spawns.
             . 'setTimeout(function(){'
               . 'var W=window._impulseWave;if(!W)return;'
               . 'if(cfg.bg&&W.updateBgColor)try{W.updateBgColor(cfg.bg);}catch(_e){}'
               . 'if(cfg.wavePosX){'
                 . 'var asp=(W.width||window.innerWidth)/(W.height||window.innerHeight);'
                 . 'if(asp>1){W._basePosX=cfg.wavePosX;try{W._adjustForViewport();}catch(_e){}}'
               . '}'
             . '},80);'
           . '}'
           // Glass composes a full-viewport depth-parallax 3D image (rendered by
           // reinitTunnel's _dGlass branch) BEHIND the FX glass sphere, and the
           // shader refracts that same image INSIDE the sphere. We no longer
           // destroy the depth parallax here — that was an old workaround for a
           // z-order bug the engine has since fixed, and destroying it was exactly
           // what left the page black outside the sphere. Reveal the Size +
           // Overlay sliders and re-apply WHATEVER values they already hold so
           // the user's tuning survives a Glass → thumb → Glass cycle. The HTML
           // value="0" / value="60" attributes only seed the very first Glass
           // selection; after that, the DOM input retains the last user position.
           . 'if(cfg.depthOn){'
             . '_sbShowGlass(true);'
             . 'var _gsz=document.querySelector("[data-glass-size]"),_gov=document.querySelector("[data-glass-overlay]");'
             // Render the full-viewport 3D image on document.body (z-index:-1),
             // BEHIND the FX glass canvas (z:0). The wrapper carries no depth
             // attrs (stripped in applyEnv), so reinitTunnel made no covering
             // parallax — this body canvas is the only one and the sphere stays
             // visible on top, its transparent outer area revealing the image.
             . 'setTimeout(function(){try{if(window.ImpulseDepthParallax){window.ImpulseDepthParallax.destroyAll();document.body.dataset.id="impulse-glass-bg";window.ImpulseDepthParallax.init(document.body);}}catch(_e){}},90);'
             . 'setTimeout(function(){applyGlassParams(_gsz?_gsz.value:0,_gov?_gov.value:60);},220);'
           . '}else{_sbShowGlass(false);}'
           // Reinit cycles can leave duplicate canvases with the same id
           // (engine destroy removes only the first match found via
           // getElementById); the orphans paint stale frames over the
           // live engine surface. Sweep dupes so only the last (live)
           // copy of each id survives. Cheap, safe — no-op when no dupes.
           . 'setTimeout(function(){'
             . '["impulse-fx-canvas","impulse-3d-canvas","impulse-tunnel-canvas","impulse-motion-lava-canvas","impulse-wave-canvas"].forEach(function(id){'
               . 'var nodes=document.querySelectorAll("#"+id);'
               . 'for(var i=0;i<nodes.length-1;i++){try{nodes[i].remove();}catch(_e){}}'
             . '});'
           . '},150);'
         . '}else if(act==="depth_image"){'
           . '_sbEnvDirty=true;'  /* mark for cosmos-restore on scroll-away */
           /* Visitor followed the Glass hint (or just picked any image) —
              suppress the hint forever this session. Even if they
              re-select Glass later, the prompt would now be stale. */
           . '_sbHintConsumed=true;_sbShowGlassHint(false);'
           // Leaving Swirl / Blob (or any env) for a thumb — drop the
           // body flags so the cells return to their styled opacity
           // and the columns regain their translucent glass.
           . 'document.body.classList.remove("impulse-sandbox-swirl");'
           . 'document.body.classList.remove("impulse-sandbox-blob");'
           /* Set the depth-image body flag (Sergei 2026-06-04): same
              column + cell opacification as Glass / Swirl / Blob,
              since a 3D photo backdrop is just as busy. If Glass is
              ALSO active (Glass-preserving thumb swap), the
              `impulse-sandbox-glass` flag was set by the env branch
              and stays — that's fine, the CSS treats either flag
              the same. The standalone depth-image case (env strip
              deselects below) leaves only this flag set. */
           . 'document.body.classList.add("impulse-sandbox-depth-image");'
           . 'document.body.classList.add("impulse-sandbox-exploring");'  /* same fade as env */
           . 'var dm=btn.getAttribute("data-depth")||"";'
           . 'if(!val)return;'  /* empty slot: ignore click */
           /* Glass-preserving thumb swap (Sergei 2026-06-04): when Glass
              is the current env, clicking a thumb should JUST swap the
              backdrop image the sphere refracts — NOT drop back to a
              plain depth_image with no sphere. Keeps Glass active,
              keeps the Size + Overlay slider values, keeps the dock
              visible. Otherwise (Cosmos/Wave/etc. or no env), treat
              the click as before: switch to plain depth_image. */
           . 'var _dipGlass=(_sbCurEnv==="glass");'
           . '_sbCurEnv=_dipGlass?"glass":"depth_image";'
           // The screen wrapper carries the engine's source-of-truth attrs.
           . 'var stage=wrap||body;'
           . 'stage.setAttribute("data-depth-image",val);'
           . 'stage.setAttribute("data-depth-map",dm);'
           . 'stage.setAttribute("data-depth-enabled","1");'
           . 'if(!stage.getAttribute("data-depth-strength"))stage.setAttribute("data-depth-strength","60");'
           // Mirror on body too — editor CSS/JS reads body.dataset.
           . 'body.setAttribute("data-depth-image",val);body.setAttribute("data-depth-map",dm);body.setAttribute("data-depth-enabled","1");'
           . 'if(_dipGlass){'
             // Keep Glass selected in the env strip + keep the dock + sliders.
             // reinit as "glass" so the FX shader runs over the new backdrop,
             // then re-apply current slider values once the engine is up.
             . 'if(API&&API.reinitTunnel)try{API.reinitTunnel(false,"glass",false,1);}catch(_e){}'
             . 'setTimeout(function(){try{if(window.ImpulseDepthParallax){window.ImpulseDepthParallax.destroyAll();document.body.dataset.id="impulse-glass-bg";window.ImpulseDepthParallax.init(document.body);}}catch(_e){}},90);'
             . 'setTimeout(function(){var _gs=document.querySelector("[data-glass-size]"),_go=document.querySelector("[data-glass-overlay]");applyGlassParams(_gs?_gs.value:0,_go?_go.value:60);},220);'
           . '}else{'
             // 3D image only (no sphere). Deselect env, hide Glass dock.
             . 'document.querySelectorAll(".impulse-hero-sandbox-btn[data-action=\"env\"]").forEach(function(b){b.classList.remove("active");});'
             . '_sbShowGlass(false);'
             . 'if(API&&API.reinitTunnel)try{API.reinitTunnel(false,"depth_image",false,1);}catch(_e){}'
             /* Editor-context bridge (Sergei 2026-06-04). The frontend's
                reinitTunnel mounts ImpulseDepthParallax inside its own
                depth-parallax branch — the editor's reinitTunnel doesn't
                (the editor has its own env pipeline). Both contexts ship
                the engine class, so doing the mount explicitly here works
                in both: frontend just re-establishes the same instance
                reinitTunnel created (destroyAll wipes it, init re-creates
                on stage = identical end state); editor gets the mount it
                was missing. Stage is wrap when present, body otherwise —
                matches the chip handler's earlier setAttribute target. */
             . 'setTimeout(function(){try{if(window.ImpulseDepthParallax){window.ImpulseDepthParallax.destroyAll();window.ImpulseDepthParallax.init(stage);}}catch(_e){}},80);'
           . '}'
         . '}'
         . 'window.dispatchEvent(new CustomEvent("impulse-scene-config-changed",{detail:{action:act,value:val}}));'
       . '});'
       // Wire the Glass dock sliders (live Size + Overlay → FX shader).
       . 'var _gSizeEl=document.querySelector("[data-glass-size]"),_gOverEl=document.querySelector("[data-glass-overlay]");'
       . 'if(_gSizeEl)_gSizeEl.addEventListener("input",function(){applyGlassParams(_gSizeEl.value,_gOverEl?_gOverEl.value:60);});'
       . 'if(_gOverEl)_gOverEl.addEventListener("input",function(){applyGlassParams(_gSizeEl?_gSizeEl.value:60,_gOverEl.value);});'
       /* Bg-toggle click handler. Lives outside the singleton env/thumb
          handler because its buttons carry a distinct class
          (impulse-hero-sandbox-bg-btn). Swaps bg in three places:
            (1) the in-memory preset so re-selecting the env keeps it,
            (2) the engine instance via updateBgColor() — Wave + Blob
                both expose this, so no reinit needed and there's no flash,
            (3) the html/body override style + data-page-bg-color attr
                so backdrop-filter children sample the new bg. */
       . 'document.addEventListener("click",function(e){'
         . 'var bg=e.target.closest&&e.target.closest(".impulse-hero-sandbox-bg-btn");if(!bg)return;'
         . 'e.preventDefault();e.stopPropagation();'
         . 'var val=bg.getAttribute("data-bg-value")||"#000000";'
         . 'var env=_sbCurEnv;'
         . 'if(env!=="wave")return;'
         . 'var row=bg.parentElement;'
         . 'if(row){row.querySelectorAll(".impulse-hero-sandbox-bg-btn").forEach(function(b){b.classList.remove("active")});bg.classList.add("active");}'
         . '_sbBgPref[env]=val;'
         . 'P[env]=Object.assign({},P[env]||{},{bg:val});'
         . 'var wrap=document.querySelector(".impulse-screen-wrapper"),body=document.body;'
         // Stamp the new bg on attrs (so anything that re-reads them gets it).
         . 'setAttrPair(wrap,body,"data-page-bg-color","data-page-bg-color",val);'
         // Push the override style — same code path applyEnv uses.
         . 'var sb=document.getElementById("impulse-sandbox-bg-override");'
         . 'if(!sb){sb=document.createElement("style");sb.id="impulse-sandbox-bg-override";document.head.appendChild(sb);}'
         . 'sb.textContent="html,body{background:"+val+"!important}";'
         . 'body.style.removeProperty("background");document.documentElement.style.removeProperty("background");'
         // Body class flag — the demo columns watch this to paint a
         // dark backdrop on white (their glass styling looks muddy
         // on a bright page).
         . 'body.classList.toggle("impulse-sandbox-bg-white",val.toLowerCase()==="#ffffff");'
         // Live-update the engine instance — no reinit, no flash.
         . 'var W=window._impulseWave;if(W&&W.updateBgColor)try{W.updateBgColor(val);}catch(_e){}'
       . '});'
       /* Wormhole palette-pick click handler. Distinct selector from
          the bg-btn handler so they don\'t collide. Updates:
            (1) in-memory pref + P.wormhole.colors so re-selecting
                wormhole later (after Cosmos / scroll-away / etc.)
                restores this palette
            (2) wrapper + body data-3d-color1..4 attrs so reinitTunnel
                reads the new palette on its next pass
            (3) live engine instance via updateColors() — palette
                texture rebuilt in-place, no canvas reinit, no flash */
       . 'document.addEventListener("click",function(e){'
         . 'var wh=e.target.closest&&e.target.closest(".impulse-hero-sandbox-wh-btn");if(!wh)return;'
         . 'e.preventDefault();e.stopPropagation();'
         . 'if(_sbCurEnv!=="wormhole")return;'
         . 'var key=wh.getAttribute("data-wh-key");'
         . 'var pal=_sbWormholePalettes[key];if(!pal)return;'
         . 'var row=wh.parentElement;'
         . 'if(row){row.querySelectorAll(".impulse-hero-sandbox-wh-btn").forEach(function(b){b.classList.remove("active")});wh.classList.add("active");}'
         . '_sbWormholePref=key;'
         . 'P.wormhole=Object.assign({},P.wormhole||{},{colors:pal.slice()});'
         . 'var wrap=document.querySelector(".impulse-screen-wrapper"),body=document.body;'
         . 'pal.forEach(function(c,i){setAttrPair(wrap,body,"data-3d-color"+(i+1),"data-screen-3d-color"+(i+1),c);});'
         . 'var I=window._impulse3D;if(I&&I.updateColors)try{I.updateColors(pal.slice());}catch(_e){}'
       . '});'
       // ── Load-time auto-apply ────────────────────────────────────────
       // The engine reads its colour/intensity attrs from wrapperEl on
       // initTunnel, which fires once at page-load using whatever attrs
       // the server emitted from saved page settings. Those saved attrs
       // are usually defaults/greys — they don\'t carry the sandbox\'s
       // curated palettes. Result: cosmos boots in muted
       // grey, and user has to click another env then click cosmos again
       // to get the inferno palette to fire. Fix: as soon as the engine
       // API is ready, look up the currently-active env button, write
       // its preset attrs, and call reinitTunnel — so the engine\'s very
       // first visible frame is already on the sandbox\'s preset.
       . 'function applyActiveOnLoad(){'
         . 'var btn=document.querySelector(".impulse-hero-sandbox-btn[data-action=\"env\"].active");'
         . 'if(!btn)return;'
         . 'var val=btn.getAttribute("data-value");var cfg=P[val];if(!cfg)return;'
         . 'var wrap=document.querySelector(".impulse-screen-wrapper");var body=document.body;'
         . 'applyEnv(cfg,val,wrap,body);'
         . 'var API=window.ImpulseScreens;'
         . 'if(API&&API.reinitTunnel)try{API.reinitTunnel(false,val,false,parseFloat(body.getAttribute("data-screen-tunnel-speed"))||1);}catch(_e){}'
       . '}'
       // ImpulseScreens may not be ready yet on DOMContentLoaded — poll
       // briefly until reinitTunnel exists, then apply once. Bail after
       // ~3s so we never spin.
       . 'var _readyTries=0;'
       . 'function _waitReady(){'
         . 'if(window.ImpulseScreens&&window.ImpulseScreens.reinitTunnel){applyActiveOnLoad();return;}'
         . 'if(++_readyTries>60)return;'
         . 'setTimeout(_waitReady,50);'
       . '}'
       . 'if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",_waitReady);}else{_waitReady();}'
       // ── Reveal driver — bounce-in starts on SCROLL ──────────────────
       // The engine calls impulsePreWarmScreen() at the very START of
       // goTo() with the target screen; .impulse-screen-active only lands
       // when the transition finishes — too late. Wrap preWarm so
       // body.impulse-sandbox-revealed flips the instant navigation
       // begins; the sandbox CSS reveal keys off that class.
       . 'var _sbPwTries=0;'
       // _sbToggle locates the screen list two ways: frontend ships
       // .impulse-screen-wrapper > .impulse-screen; the editor (in the
       // merged-mode architecture) ships .impulse-content-wrapper >
       // .impulse-el[data-type="section"]. Falling back to the editor
       // DOM is what keeps the sandbox reveal class flipping on editor
       // screen navigation — without it the class stays off and the
       // sandbox is permanently hidden in the builder.
       // Reveal rule (count-independent):
       //   1. The target must be a HERO screen — section.php emits
       //      data-hero-marker when settings.hero_marker is set; the marker
       //      may sit on the section itself or on an editor wrapper child.
       //   2. The target must not be the FIRST hero screen — the very first
       //      one is the brand-reveal entry, the user hasn't engaged with
       //      the scene yet, sandbox stays hidden.
       // Identifying "first" via the marker list (document order) rather
       // than an idx>=1 test against the screen list makes this robust to
       // any number of hero screens, vertical sections interleaved, or
       // navigation reordering — the only thing that matters is whether
       // the target is the FIRST [data-hero-marker] in document order.
       // Two classes are toggled together:
       //   impulse-sandbox-revealed — true on any non-first hero screen
       //   impulse-on-last-hero    — true only when the active section is
       //                              the LAST [data-hero-marker] in document
       //                              order. The chevrons + hint labels
       //                              (SWITCH / SCROLL) gate off this class
       //                              so they only show on the dedicated
       //                              "Try it yourself" screen.
       // Reveal sandbox + chevrons + SWITCH/SCROLL hints on the dedicated
       // "Try it yourself" hero screen. Sergei's homepage convention places
       // the sandbox screen as the SECOND-to-last hero, with the final
       // hero reserved for a Download/CTA. So we target index [length-2],
       // falling back to the last marker when there's only one hero (single-
       // screen sites). Locked 2026-05-25.
       // _sbToggle reveals the sandbox on EXACTLY ONE screen — the one
       // flagged data-sandbox-anchor (set per-section via the
       // sandbox_anchor setting in section.php). If no section in the
       // document carries the anchor attribute, fall back to the legacy
       // "second-to-last hero" rule so existing pages built before the
       // anchor existed keep working.
       /* Sandbox env scope: cosmos is the page default for every screen.
          The sandbox panel lets the visitor swap env on the anchor screen
          only — and once they scroll away from that anchor, cosmos snaps
          back. _sbEnvDirty tracks whether the user has clicked an env in
          this session; we use it to skip an unnecessary reinitTunnel when
          they never touched the sandbox.

          On leaving the sandbox screen WHILE dirty:
            1. Rewrite wrapper + body attrs back to the cosmos preset
               (engine, preset name, colors, speed, intensity, mouse sens,
               page bg) via applyEnv — same path the click handler uses,
               so the engine re-reads consistent state.
            2. Strip any depth-image override left over from a Glass /
               thumb click (cosmos doesn't use depth_image).
            3. Reinit the engine with cloudEffect=cosmos.
            4. Light up the Cosmos button in the panel so the sandbox UI
               reflects current state.
            5. Clear _sbEnvDirty so a no-touch leave-and-return is cheap. */
       /* _sbResetDefault (formerly _sbResetCosmos). Restores the
          page's SAVED env (screen_cloud_effect) when the visitor
          scrolls away from the sandbox. Two paths:
            (a) page default IS in the sandbox preset list (cosmos,
                wave, swirl, blob, wormhole) — apply that preset via
                applyEnv, same code path as a chip click.
            (b) page default ISN'T in the list (lava, aurora, depth_image,
                etc.) — just rewrite data-cloud-effect and reinit;
                the engine-specific attrs (data-lava-color1…4 etc.)
                are still intact on the wrapper from server render
                because applyEnv only writes attrs for the CURRENTLY-
                selected sandbox engine, never the others. */
       . 'function _sbResetDefault(){'
         . 'try{'
           . 'var w=document.querySelector(".impulse-screen-wrapper");'
           . 'var b=document.body;'
           . 'var def=_sbPageDefault||"cosmos";'
           . 'var cfg=P&&P[def];'
           . 'if(cfg){applyEnv(cfg,def,w,b);}'
           . 'else{setAttrPair(w,b,"data-cloud-effect","data-screen-cloud-effect",def);}'
           . '_sbCurEnv=def;'
           // Clear sandbox bg override style + any depth-image attrs left
           // by a Glass / thumb click — page default doesn't use them.
           . 'var sb=document.getElementById("impulse-sandbox-bg-override");if(sb)sb.remove();'
           . 'if(w){w.removeAttribute("data-depth-image");w.removeAttribute("data-depth-map");}'
           . 'b.removeAttribute("data-depth-image");b.removeAttribute("data-depth-map");'
           . 'if(window.ImpulseDepthParallax){try{window.ImpulseDepthParallax.destroyAll();}catch(_e){}}'
           // Strip wave-bg-white body flag too (left over from a Wave-white pick).
           . 'b.classList.remove("impulse-sandbox-bg-white");'
           // Same for the swirl + blob + glass + wormhole + depth-image
           // flags (cells / columns return to their styled translucency
           // on scroll-away).
           . 'b.classList.remove("impulse-sandbox-swirl");'
           . 'b.classList.remove("impulse-sandbox-blob");'
           . 'b.classList.remove("impulse-sandbox-glass");'
           . 'b.classList.remove("impulse-sandbox-wormhole");'
           . 'b.classList.remove("impulse-sandbox-depth-image");'
           . 'var IS=window.ImpulseScreens;'
           . 'if(IS&&IS.reinitTunnel){'
             . 'var spd=cfg?cfg.speed:parseFloat(b.getAttribute("data-screen-tunnel-speed")||w.getAttribute("data-screen-tunnel-speed")||"5");'
             . 'try{IS.reinitTunnel(false,def,false,parseFloat(spd)||5);}catch(_e){}'
           . '}'
           // Reflect in sandbox panel buttons (only highlights if def
           // matches one of the chip values; lava etc. simply clears them).
           . 'document.querySelectorAll(".impulse-hero-sandbox-btn[data-action=\\"env\\"]").forEach(function(b2){b2.classList.toggle("active",b2.getAttribute("data-value")===def);});'
           . 'document.querySelectorAll(".impulse-hero-sandbox-btn[data-action=\\"depth_image\\"]").forEach(function(b2){b2.classList.remove("active");});'
         . '}catch(_e){}'
       . '}'
       /* _sbToggle reveal rules:
            1. impulse-sandbox-revealed — true ONLY on the anchor screen
               itself. The dock UI is anchored visually to one screen so
               visitors know where to interact.
            2. Env carry-over: the sandbox env (cosmos/wave/blob/etc)
               persists across the anchor screen and its IMMEDIATE
               NEIGHBOURS — one screen up and one down. Visitors picking
               Wave on the anchor get to see how Wave reads on the
               surrounding context too. Cosmos auto-restores only when
               the active screen is 2+ hops away from the anchor.
            3. impulse-sandbox-exploring — true when _sbEnvDirty is set
               (user has touched an env or 3D-image button) AND we are
               in the anchor neighbourhood. CSS uses this to fade out
               the anchor screen's content so the env shows clean. */
       . 'function _sbToggle(el){try{'
         . 'if(!el)return;'
         /* Explicit anchor short-circuit (Sergei 2026-06-04 — added so
            sandbox works on pages without hero zones, after the hero/
            body merge). If ANY section on the page has
            data-sandbox-anchor="1", that section IS the sandbox screen
            — no need for data-hero-marker. */
         . 'var _scAnchor=document.querySelector("[data-sandbox-anchor=\\"1\\"]");'
         . 'if(_scAnchor){'
           . 'var _scIsAnchor=(el===_scAnchor||(el.contains&&el.contains(_scAnchor))||(_scAnchor.contains&&_scAnchor.contains(el)));'
           . 'document.body.classList.toggle("impulse-sandbox-revealed",_scIsAnchor);'
           . 'document.body.classList.toggle("impulse-on-last-hero",_scIsAnchor);'
           . 'var _scImgEnv=(_sbCurEnv==="glass"||_sbCurEnv==="depth_image");'
           . 'var _scShouldReset=_sbEnvDirty&&(!_scIsAnchor||(_scImgEnv&&!_scIsAnchor));'
           . 'document.body.classList.toggle("impulse-sandbox-exploring",_sbEnvDirty&&_scIsAnchor&&!_scShouldReset);'
           . 'if(_scShouldReset){_sbResetDefault();_sbEnvDirty=false;document.body.classList.remove("impulse-sandbox-exploring");}'
           . 'return;'
         . '}'
         /* No explicit anchor: legacy hero_marker positional rule. */
         . 'var marker=el.hasAttribute("data-hero-marker")?el:el.querySelector("[data-hero-marker]");'
         . 'if(!marker){'
           . 'document.body.classList.remove("impulse-sandbox-revealed");'
           . 'document.body.classList.remove("impulse-on-last-hero");'
           . 'document.body.classList.remove("impulse-sandbox-exploring");'
           . 'if(_sbEnvDirty){_sbResetDefault();_sbEnvDirty=false;}'
           . 'return;'
         . '}'
         . 'var all=document.querySelectorAll("[data-hero-marker]");'
         . 'var anchorEl=document.querySelector("[data-sandbox-anchor=\\"1\\"]");'
         /* Resolve anchor and current screen indices in the hero-marker
            list. If no explicit anchor exists, fall back to legacy
            "second-to-last hero" rule for back-compat. */
         . 'var anchorIdx=-1,currentIdx=-1;'
         . 'for(var i=0;i<all.length;i++){'
           . 'if(all[i]===marker)currentIdx=i;'
           . 'if(anchorEl&&(all[i]===anchorEl||all[i].contains&&all[i].contains(anchorEl))){anchorIdx=i;}'
         . '}'
         . 'if(!anchorEl&&all.length){anchorIdx=all.length>=2?all.length-2:all.length-1;}'
         . 'var isSandboxScreen=(anchorIdx>=0&&currentIdx===anchorIdx);'
         . 'var withinNeighbourhood=(anchorIdx>=0&&currentIdx>=0&&Math.abs(currentIdx-anchorIdx)<=1);'
         . 'document.body.classList.toggle("impulse-sandbox-revealed",isSandboxScreen);'
         . 'document.body.classList.toggle("impulse-on-last-hero",isSandboxScreen);'
         /* Exploring class lifts content only when the user has touched
            something AND we are still in the neighbourhood — leaving
            the neighbourhood resets cosmos and clears the flag. */
         /* Image-based envs (glass sphere, picked 3D image) are discrete
            overlays — they look broken floating over a neighbour screen's own
            content, so they reset the instant the active screen is no longer the
            anchor. Ambient WebGL envs (cosmos/wave/swirl/blob/wormhole) keep the
            ±1 neighbourhood persistence so visitors see them carry across. */
         . 'var _sbImageEnv=(_sbCurEnv==="glass"||_sbCurEnv==="depth_image");'
         . 'var _sbShouldReset=_sbEnvDirty&&(!withinNeighbourhood||(_sbImageEnv&&!isSandboxScreen));'
         . 'document.body.classList.toggle("impulse-sandbox-exploring",_sbEnvDirty&&withinNeighbourhood&&!_sbShouldReset);'
         . 'if(_sbShouldReset){_sbResetDefault();_sbEnvDirty=false;document.body.classList.remove("impulse-sandbox-exploring");}'
       . '}catch(_e){}}'
       // Debounced reveal driver. A screen transition fires a burst of
       // intersection/active-class/screen-changed events while BOTH screens
       // overlap mid-animation; acting on each one toggles the reveal on/off
       // (the dock flashing across screens 2 + 3 before it settles). Collapse
       // the burst: every trigger reschedules a single _sbToggle for the
       // SETTLED screen, so the dock reveals exactly once, after the
       // transition lands. The initial load-time pass calls _sbToggle directly
       // (no transition to wait out).
       /* Debounce delay (ms): must be LONGER than a single screen transition
          so the burst of intersection / active-class / screen-changed events
          fired during scroll collapses into ONE toggle that reads the final,
          settled active-screen index. 200ms was shorter than the engine's
          ~480ms transition, so the toggle fired mid-transition, read a
          transient class state (the engine flips active between screens
          during cleanup), and produced an appear-disappear-reappear on the
          anchor + a brief flash on the neighbour. 420ms rides past the
          transition into the cooldown window. 2026-05-30. */
       . 'var _sbToggleTO=null;function _sbToggleSoon(el){clearTimeout(_sbToggleTO);_sbToggleTO=setTimeout(function(){_sbToggle(el);},420);}'
       /* Route through the 420ms debounce — preWarm fires DURING the
          transition while screens overlap, so a direct _sbToggle reads
          the still-active OLD screen and briefly shows the sandbox on
          the wrong neighbour. _sbToggleSoon collapses every transition's
          burst of preWarm + observer + screen-changed events into ONE
          toggle that reads the settled active screen. Sergei 2026-06-02. */
       . 'function _sbWrapPreWarm(){var o=window.impulsePreWarmScreen;if(typeof o!=="function"){if(++_sbPwTries<=60)setTimeout(_sbWrapPreWarm,50);return;}if(o._sbWrapped)return;var wrapped=function(el){_sbToggleSoon(el);return o.apply(this,arguments);};wrapped._sbWrapped=true;window.impulsePreWarmScreen=wrapped;window.impulsePreTriggerScreen=wrapped;}'
       // ImpulseScreens fallback wrap. impulse-animator.js (where
       // impulsePreWarmScreen lives) is only enqueued when a page has
       // layered-animation content — pages with plain headings never load
       // it, so the wrap above silently times out. ImpulseScreens IS
       // loaded on every screen-mode page (the editor screen engine), so
       // wrapping its navigation methods guarantees _sbToggle fires on
       // every screen change regardless of animator presence.
       /* Same debounce routing as preWarm wrap — goToScreen/jumpToScreen
          fire at the START of a transition, before the active class
          settles on the destination. Sergei 2026-06-02. */
       . 'function _sbWrapImpulseScreens(){var IS=window.ImpulseScreens;if(!IS){if(++_sbPwTries<=60)setTimeout(_sbWrapImpulseScreens,50);return;}if(IS._sbWrapped)return;["goToScreen","jumpToScreen"].forEach(function(name){var o=IS[name];if(typeof o!=="function")return;IS[name]=function(idx){try{var w=document.querySelector(".impulse-screen-wrapper");var sel=":scope > .impulse-screen";if(!w){w=document.querySelector(".impulse-content-wrapper");sel=":scope > .impulse-el[data-type=\\"section\\"]";}if(w){var sc=w.querySelectorAll(sel);if(sc[idx])_sbToggleSoon(sc[idx]);}}catch(_e){}return o.apply(this,arguments);};});IS._sbWrapped=true;}'
       // MutationObserver on .impulse-screen-active is the load-bearing
       // path. The engine has multiple internal entry points for changing
       // the active screen (wheel events, keyboard, drag-scroll, internal
       // refresh) that DON'T go through goToScreen / jumpToScreen — wrapping
       // only the public API silently misses those navigations and the
       // sandbox-revealed class never lands. The active class itself is the
       // one thing every code path eventually mutates, so watching it is the
       // only catch-all. Observes the per-section class attribute; when a
       // section GAINS .impulse-screen-active, route it through _sbToggle.
       /* Observe class mutations on every section. Adds of
          .impulse-screen-active route through _sbToggleSoon (debounced
          reveal). Removes from a section that holds data-sandbox-anchor
          immediately strip impulse-sandbox-revealed — the moment
          scroll starts dragging the anchor out of view, the sandbox
          disappears (synchronous, no 420ms debounce on the hide
          path). Reveal still rides the debounce so a fast scroll-past
          the anchor doesn't flash the dock on for one frame; only
          the disappear-on-leave is instant. */
       . 'function _sbObserveActive(){try{if(window._sbObserverInstalled)return;var w=document.querySelector(".impulse-screen-wrapper");var sel=":scope > .impulse-screen";if(!w){w=document.querySelector(".impulse-content-wrapper");sel=":scope > .impulse-el[data-type=\\"section\\"]";}if(!w){if(++_sbPwTries<=60)setTimeout(_sbObserveActive,50);return;}var sections=w.querySelectorAll(sel);if(!sections.length){if(++_sbPwTries<=60)setTimeout(_sbObserveActive,50);return;}var mo=new MutationObserver(function(muts){for(var i=0;i<muts.length;i++){var m=muts[i];if(m.attributeName!=="class")continue;var hasActive=m.target.classList.contains("impulse-screen-active");var hostsAnchor=(m.target.getAttribute("data-sandbox-anchor")==="1")||!!m.target.querySelector("[data-sandbox-anchor=\\"1\\"]");if(hasActive){_sbToggleSoon(m.target);}else if(hostsAnchor){clearTimeout(_sbToggleTO);document.body.classList.remove("impulse-sandbox-revealed");document.body.classList.remove("impulse-on-last-hero");document.body.classList.remove("impulse-sandbox-exploring");}}});sections.forEach(function(s){mo.observe(s,{attributes:true,attributeFilter:["class"]});});window._sbObserverInstalled=true;'
       // Also seed an initial pass — if the page already has an active
       // section at observer-install time (which is the common case in the
       // editor where the iframe pre-positions on screen 1), fire toggle
       // once so the class is set without requiring the user to navigate.
       .   'var existing=w.querySelector(".impulse-screen-active");if(existing)_sbToggle(existing);'
       . '}catch(_e){}}'
       /* Activation pipeline: ONLY the MutationObserver path stays
          active. The two early-fire wraps (_sbWrapPreWarm wraps
          impulsePreWarmScreen, _sbWrapImpulseScreens wraps the
          public navigation methods) BOTH fire at the START of a
          transition — well before the destination screen carries
          .impulse-screen-active. That made the sandbox dock slide in
          while still on the outgoing screen, visible across the
          screen-to-screen transition. The MutationObserver fires
          when .impulse-screen-active actually lands at the END of
          the transition, which is when the sandbox should appear.
          The two wrap functions remain defined above for historical
          reference but are no longer invoked at boot. */
       /* Belt-and-suspenders: also listen for the impulse-screen-changed
          event the screens engine dispatches on every transition + on
          init. The MutationObserver above is the canonical path, but in
          the editor the section-activation flow doesn't always mutate
          .impulse-screen-active on a known target (depending on the
          editor's screen-list click handler vs. natural scroll nav). The
          event fires regardless, so re-toggling on each fire gives the
          sandbox a second chance to find the right active screen. */
       . 'document.addEventListener("impulse-screen-changed",function(){try{var w=document.querySelector(".impulse-screen-wrapper");var sel=":scope > .impulse-screen";if(!w){w=document.querySelector(".impulse-content-wrapper");sel=":scope > .impulse-el[data-type=\\"section\\"]";}if(!w)return;var active=w.querySelector(".impulse-screen-active");if(active)_sbToggleSoon(active);}catch(_e){}});'
       /* Visibility-based reveal (load-bearing on MOBILE). The
          MutationObserver + impulse-screen-changed paths both hinge on
          .impulse-screen-active being stamped on a section. On the mobile
          editor tab (and any context that doesn't snap-activate screens),
          that class never lands, so the dock never revealed — confirmed
          2026-05-28: the @media(≤600px) dock CSS is intact in the CSSOM,
          but body.impulse-sandbox-revealed was simply never set. An
          IntersectionObserver fires purely on what's actually on screen,
          independent of the engine, so it catches the anchor screen on
          mobile. We route the MOST-visible section through the existing
          _sbToggle (which decides reveal vs un-reveal by comparing it to
          the anchor), so behaviour matches the desktop path exactly. */
       /* IntersectionObserver tightened (Sergei 2026-06-04). Old config
          fired callbacks at 6 thresholds (0/0.1/0.25/0.5/0.75/1) and
          acted on any non-zero "best" ratio — during scroll between
          screens, the anchor screen briefly held the highest ratio
          at ~30% visibility, debounced toggle fired → sandbox flashed
          on. New rule: only act when one section is CLEARLY winning
          at >85% visibility (a settled snap position). Mid-scroll
          ratios never exceed 0.85, so the IO stays silent during
          transitions and only fires after snap settles. */
       . 'function _sbObserveVisible(){try{if(window._sbVisObserverInstalled)return;if(typeof IntersectionObserver==="undefined")return;var w=document.querySelector(".impulse-screen-wrapper");var sel=":scope > .impulse-screen";if(!w){w=document.querySelector(".impulse-content-wrapper");sel=":scope > .impulse-el[data-type=\\"section\\"]";}if(!w){if(++_sbPwTries<=60)setTimeout(_sbObserveVisible,50);return;}var sections=w.querySelectorAll(sel);if(!sections.length){if(++_sbPwTries<=60)setTimeout(_sbObserveVisible,50);return;}var ratios={};var io=new IntersectionObserver(function(entries){for(var j=0;j<entries.length;j++){var ix=Array.prototype.indexOf.call(sections,entries[j].target);if(ix>=0)ratios[ix]=entries[j].intersectionRatio;}var best=-1,bestR=-1;for(var k=0;k<sections.length;k++){var rv=ratios[k]||0;if(rv>bestR){bestR=rv;best=k;}}if(best>=0&&bestR>=0.85)_sbToggleSoon(sections[best]);},{threshold:[0.85,0.99]});for(var s2=0;s2<sections.length;s2++)io.observe(sections[s2]);window._sbVisObserverInstalled=true;}catch(_e){}}'
       . 'if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",function(){_sbObserveActive();_sbObserveVisible();});}else{_sbObserveActive();_sbObserveVisible();}'
       . '})();';
    if (function_exists('wp_add_inline_script')) wp_add_inline_script('instinctor-frontend-js', $_sbJs, 'after');
}
