/*!
 * ZipWP MCP - Combined JavaScript
 * Version: 0.0.7
 * Build: 2026-07-13 13:50:01
 */

/**
 * Gutenberg Editor Plugin for ZipWP MCP iframe Assistant
 * Adds pinned toolbar button to trigger the iframe
 */

(function() {
    'use strict';

    const { registerPlugin } = wp.plugins;
    const { createElement: el, useEffect } = wp.element;
    const { __ } = wp.i18n;
    const { useDispatch, useSelect } = wp.data;

    // Import from correct package based on WP version
    const PluginSidebar = wp.editor?.PluginSidebar || wp.editPost?.PluginSidebar;

    // ZIP AI brand icon — knockout-white mascot on ink tile (brand-sheet reverse).
    // Matches src/components/shared/ZipBrand.jsx (ZipIcon tone="ink").
    const ZIP_GLYPH = 'M79.6553 65.5468C116.225 59.6587 177.537 60.8497 187.298 61.16C187.884 61.1145 188.383 61.8381 187.979 62.327C185.891 65.8447 178.334 76.8029 157.839 92.12C157.276 92.4687 157.517 93.6701 158.25 93.6132C165.353 93.9763 189.09 94.2657 226.78 90.2802C227.488 90.25 227.95 90.8106 227.709 91.4266C225.224 97.4154 212.408 122.356 168.147 143.178C167.291 143.549 167.544 144.902 168.424 144.834L205.611 144.999C206.356 145.094 206.867 145.969 206.315 146.47C198.2 155.946 149.033 208.567 76.6358 191.917C48.0924 185.285 28.3414 158.906 28.2266 127.038C28.1119 95.1698 47.3028 70.6501 79.6553 65.5468ZM132.944 101.761C132.15 101.761 131.426 102.041 130.772 102.601C130.165 103.115 129.792 103.746 129.651 104.493L121.804 151.725C121.664 152.473 121.804 153.127 122.225 153.687C122.692 154.201 123.299 154.459 124.046 154.459H133.295C134.089 154.459 134.79 154.201 135.397 153.687C136.051 153.127 136.448 152.473 136.588 151.725L144.437 104.493C144.577 103.746 144.412 103.115 143.945 102.601C143.525 102.041 142.941 101.761 142.194 101.761H132.944ZM90.461 101.648V101.719H90.3906V101.648C89.0361 101.695 87.8913 101.906 86.9571 102.279C86.0696 102.653 85.2985 103.283 84.6446 104.171L53.2774 150.113C52.717 150.907 52.6 151.677 52.9268 152.425C53.2538 153.172 53.8616 153.546 54.749 153.546H66.5899C67.5239 153.546 68.2952 153.126 68.9024 152.285C68.9193 152.264 71.5226 149.08 73.1758 147.023C73.7139 146.354 78.1697 141.688 78.2041 141.652H98.2383L102.419 153.015C102.699 153.855 103.26 154.276 104.101 154.276H115.241C116.035 154.276 116.689 153.902 117.203 153.155C117.717 152.408 117.858 151.637 117.624 150.843L100.69 104.171C100.504 103.704 100.247 103.306 99.9199 102.979C99.6398 102.606 99.3593 102.349 99.0791 102.209C98.8456 102.022 98.5651 101.905 98.2383 101.858C97.9115 101.765 97.6773 101.719 97.5371 101.719H95.5059C94.7587 101.672 94.2206 101.648 93.8936 101.648H90.461ZM88.71 119.881C88.8883 119.394 89.5777 119.394 89.7559 119.881L90.2881 121.335C90.7055 122.475 91.3662 123.511 92.2246 124.369C93.0833 125.228 94.1195 125.889 95.2598 126.307L96.7129 126.839C97.2003 127.017 97.2003 127.706 96.7129 127.885L95.2598 128.417C94.1195 128.834 93.0833 129.495 92.2246 130.353C91.366 131.212 90.7056 132.248 90.2881 133.389L89.7559 134.842C89.5777 135.329 88.8883 135.329 88.71 134.842L88.1778 133.389C87.7603 132.248 87.0989 131.212 86.2403 130.353C85.3818 129.495 84.3461 128.834 83.2061 128.417L81.752 127.885C81.2652 127.706 81.2653 127.017 81.752 126.839L83.2061 126.307C84.3463 125.889 85.3817 125.228 86.2403 124.369C87.0988 123.51 87.7603 122.475 88.1778 121.335L88.71 119.881ZM96.3614 115.599C96.4506 115.347 96.7947 115.347 96.8838 115.599L97.1494 116.35C97.3581 116.94 97.6891 117.477 98.1182 117.921C98.5475 118.365 99.0657 118.708 99.6358 118.924L100.362 119.199C100.606 119.291 100.606 119.648 100.362 119.74L99.6358 120.016C99.0657 120.232 98.5475 120.573 98.1182 121.017C97.6889 121.462 97.3582 121.998 97.1494 122.588L96.8838 123.341C96.7946 123.593 96.4507 123.592 96.3614 123.341L96.0947 122.588C95.886 121.998 95.5552 121.462 95.126 121.017C94.6969 120.573 94.1792 120.231 93.6094 120.016L92.8819 119.74C92.6387 119.648 92.6387 119.291 92.8819 119.199L93.6094 118.924C94.1794 118.708 94.6968 118.365 95.126 117.921C95.5553 117.476 95.886 116.94 96.0947 116.35L96.3614 115.599ZM226.78 90.2802C226.765 90.2808 226.75 90.2809 226.734 90.2821L226.881 90.2704C226.847 90.2739 226.814 90.2766 226.78 90.2802Z';
    const ChatBubbleIcon = () => el('svg', {
        width: 24,
        height: 24,
        viewBox: '0 0 256 256',
        fill: 'none',
        'aria-hidden': 'true',
        focusable: 'false'
    },
        el('rect', { width: 256, height: 256, rx: 64, fill: '#0A0A0C' }),
        // Glyph centred at 64% scale (matches ZipIcon GLYPH_SCALE)
        el('g', { transform: 'translate(46.08 46.08) scale(0.64)' },
            el('path', { d: ZIP_GLYPH, fill: '#FFFFFF' })
        )
    );

    // Plugin component - uses PluginSidebar to get pinned button
    const ZipWPMCPPlugin = () => {
        const { closeGeneralSidebar } = useDispatch('core/edit-post') || useDispatch('core/editor') || {};

        // Check if our sidebar is open
        const isOpen = useSelect((select) => {
            const editorSelect = select('core/edit-post') || select('core/editor');
            if (editorSelect && editorSelect.getActiveGeneralSidebarName) {
                return editorSelect.getActiveGeneralSidebarName() === 'zip-ai-iframe-assistant/zip-ai-iframe-panel';
            }
            return false;
        }, []);

        // When sidebar opens, toggle the assistant panel and close the sidebar
        useEffect(() => {
            if (isOpen) {
                if (window.zipwpMcpBridge) {
                    window.zipwpMcpBridge.togglePanel();
                }

                // Close the sidebar immediately
                if (closeGeneralSidebar) {
                    closeGeneralSidebar();
                }
            }
        }, [isOpen, closeGeneralSidebar]);

        // Return a PluginSidebar with isPinnable to get the button in toolbar
        // The content is empty since we use iframe instead
        return el(PluginSidebar, {
            isPinnable: true,
            icon: ChatBubbleIcon(),
            name: 'zip-ai-iframe-panel',
            title: __('ZIP AI Assistant', 'zip-ai'),
            className: 'zip-ai-iframe-sidebar'
        }, null); // Empty content - we use iframe
    };

    // Register the plugin
    registerPlugin('zip-ai-iframe-assistant', {
        render: ZipWPMCPPlugin,
        icon: ChatBubbleIcon()
    });

    // (The per-block "Edit with AI" chat shortcut was removed — the "Ask ZIP AI"
    // quick-edit button is now the single block-toolbar ZIP AI entry point; it
    // lives in the sibling quickedit.js. Chat stays reachable via the pinned
    // ZIP AI Assistant sidebar button above.)

})();


/**
 * ZIP AI Quick Edit — block-toolbar popover for fast, one-shot inline edits.
 *
 * SEPARATE from the chat agent loop: streams from /api/agent/inline-edit/*
 * (brain runs a single LLM call, no runTurn) and applies straight to the
 * block via setAttributes (native Gutenberg undo). Block-aware: each block
 * type exposes only the ops that fit it. UI mirrors the ZIP AI design spec.
 *
 * Self-contained IIFE — self-registers via addFilter('editor.BlockEdit', …)
 * and a load-time style inject; nothing calls into it. Loaded alongside
 * editor-plugin.js (prod: concatenated by Gruntfile's editor/**\/*.js glob;
 * dev: enqueued explicitly in react-manager.php). The per-block "Ask ZIP AI"
 * button is the single block-toolbar ZIP AI entry point; chat stays reachable
 * via the pinned ZIP AI Assistant sidebar button (editor-plugin.js).
 */

(function() {
    'use strict';

    const { createElement: el, useEffect, useState, useRef, useCallback, useMemo } = wp.element;
    const { __, sprintf } = wp.i18n;
    const { createHigherOrderComponent } = wp.compose;
    const { addFilter } = wp.hooks;
    const BlockControls = wp.blockEditor?.BlockControls || wp.editor?.BlockControls;
    const { ToolbarGroup, ToolbarButton, Dropdown } = wp.components;

    // ZIP AI brand icon — knockout-white mascot on ink tile (brand-sheet reverse).
    // Matches src/components/shared/ZipBrand.jsx (ZipIcon tone="ink").
    // Local copy so this file has no runtime dependency on editor-plugin.js.
    const ZIP_GLYPH = 'M79.6553 65.5468C116.225 59.6587 177.537 60.8497 187.298 61.16C187.884 61.1145 188.383 61.8381 187.979 62.327C185.891 65.8447 178.334 76.8029 157.839 92.12C157.276 92.4687 157.517 93.6701 158.25 93.6132C165.353 93.9763 189.09 94.2657 226.78 90.2802C227.488 90.25 227.95 90.8106 227.709 91.4266C225.224 97.4154 212.408 122.356 168.147 143.178C167.291 143.549 167.544 144.902 168.424 144.834L205.611 144.999C206.356 145.094 206.867 145.969 206.315 146.47C198.2 155.946 149.033 208.567 76.6358 191.917C48.0924 185.285 28.3414 158.906 28.2266 127.038C28.1119 95.1698 47.3028 70.6501 79.6553 65.5468ZM132.944 101.761C132.15 101.761 131.426 102.041 130.772 102.601C130.165 103.115 129.792 103.746 129.651 104.493L121.804 151.725C121.664 152.473 121.804 153.127 122.225 153.687C122.692 154.201 123.299 154.459 124.046 154.459H133.295C134.089 154.459 134.79 154.201 135.397 153.687C136.051 153.127 136.448 152.473 136.588 151.725L144.437 104.493C144.577 103.746 144.412 103.115 143.945 102.601C143.525 102.041 142.941 101.761 142.194 101.761H132.944ZM90.461 101.648V101.719H90.3906V101.648C89.0361 101.695 87.8913 101.906 86.9571 102.279C86.0696 102.653 85.2985 103.283 84.6446 104.171L53.2774 150.113C52.717 150.907 52.6 151.677 52.9268 152.425C53.2538 153.172 53.8616 153.546 54.749 153.546H66.5899C67.5239 153.546 68.2952 153.126 68.9024 152.285C68.9193 152.264 71.5226 149.08 73.1758 147.023C73.7139 146.354 78.1697 141.688 78.2041 141.652H98.2383L102.419 153.015C102.699 153.855 103.26 154.276 104.101 154.276H115.241C116.035 154.276 116.689 153.902 117.203 153.155C117.717 152.408 117.858 151.637 117.624 150.843L100.69 104.171C100.504 103.704 100.247 103.306 99.9199 102.979C99.6398 102.606 99.3593 102.349 99.0791 102.209C98.8456 102.022 98.5651 101.905 98.2383 101.858C97.9115 101.765 97.6773 101.719 97.5371 101.719H95.5059C94.7587 101.672 94.2206 101.648 93.8936 101.648H90.461ZM88.71 119.881C88.8883 119.394 89.5777 119.394 89.7559 119.881L90.2881 121.335C90.7055 122.475 91.3662 123.511 92.2246 124.369C93.0833 125.228 94.1195 125.889 95.2598 126.307L96.7129 126.839C97.2003 127.017 97.2003 127.706 96.7129 127.885L95.2598 128.417C94.1195 128.834 93.0833 129.495 92.2246 130.353C91.366 131.212 90.7056 132.248 90.2881 133.389L89.7559 134.842C89.5777 135.329 88.8883 135.329 88.71 134.842L88.1778 133.389C87.7603 132.248 87.0989 131.212 86.2403 130.353C85.3818 129.495 84.3461 128.834 83.2061 128.417L81.752 127.885C81.2652 127.706 81.2653 127.017 81.752 126.839L83.2061 126.307C84.3463 125.889 85.3817 125.228 86.2403 124.369C87.0988 123.51 87.7603 122.475 88.1778 121.335L88.71 119.881ZM96.3614 115.599C96.4506 115.347 96.7947 115.347 96.8838 115.599L97.1494 116.35C97.3581 116.94 97.6891 117.477 98.1182 117.921C98.5475 118.365 99.0657 118.708 99.6358 118.924L100.362 119.199C100.606 119.291 100.606 119.648 100.362 119.74L99.6358 120.016C99.0657 120.232 98.5475 120.573 98.1182 121.017C97.6889 121.462 97.3582 121.998 97.1494 122.588L96.8838 123.341C96.7946 123.593 96.4507 123.592 96.3614 123.341L96.0947 122.588C95.886 121.998 95.5552 121.462 95.126 121.017C94.6969 120.573 94.1792 120.231 93.6094 120.016L92.8819 119.74C92.6387 119.648 92.6387 119.291 92.8819 119.199L93.6094 118.924C94.1794 118.708 94.6968 118.365 95.126 117.921C95.5553 117.476 95.886 116.94 96.0947 116.35L96.3614 115.599ZM226.78 90.2802C226.765 90.2808 226.75 90.2809 226.734 90.2821L226.881 90.2704C226.847 90.2739 226.814 90.2766 226.78 90.2802Z';
    const ChatBubbleIcon = () => el('svg', {
        width: 24,
        height: 24,
        viewBox: '0 0 256 256',
        fill: 'none',
        'aria-hidden': 'true',
        focusable: 'false'
    },
        el('rect', { width: 256, height: 256, rx: 64, fill: '#0A0A0C' }),
        // Glyph centred at 64% scale (matches ZipIcon GLYPH_SCALE)
        el('g', { transform: 'translate(46.08 46.08) scale(0.64)' },
            el('path', { d: ZIP_GLYPH, fill: '#FFFFFF' })
        )
    );

    const ic = (children, vb) => el('svg', {
        width: 16, height: 16, viewBox: vb || '0 0 24 24', fill: 'none',
        stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
        'aria-hidden': 'true',
    }, children);

    // ZIP AI brand mark — the quick-edit button is "Ask ZIP AI", not a generic sparkle.
    const EraMark = () => ChatBubbleIcon();

    // Animated loader — the "Snake border" variant from the ZIP AI Loaders spec: a
    // static ink mark with a comet segment gliding around its border (conic
    // gradient swept via the animated --eqe-a angle) over a faint base track.
    // Replaces the generic wp Spinner while a quick edit streams. Honors
    // prefers-reduced-motion (CSS).
    const EraLoader = () => el('span', { className: 'eqe-loader' },
        el('span', { className: 'eqe-snake-track', 'aria-hidden': 'true' }),
        el('span', { className: 'eqe-snake-ring', 'aria-hidden': 'true' }),
        el('svg', { className: 'eqe-snake-glyph', width: 14, height: 14, viewBox: '0 0 256 256', fill: 'none', 'aria-hidden': 'true' },
            el('path', { d: ZIP_GLYPH, fill: '#FFFFFF' })
        )
    );

    // Ink spinner ring — replaces the generic blue wp Spinner so every loading
    // state stays in the ZIP AI ink palette. `on-dark` variant for dark (ink-fill)
    // buttons where a light ring reads better.
    const spinner = (mod) => el('span', { className: 'eqe-spin' + (mod ? ' ' + mod : ''), 'aria-hidden': 'true' });

    // Lucide icon path builder (MIT). p(d) → keyed <path>.
    const p = (d, k) => el('path', { key: k || d.slice(0, 6), d });

    // Per-intent menu icon (Lucide) + label.
    const INTENT_META = {
        rewrite: { label: 'Rewrite', icon: () => ic([p('M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'a'), p('M3 3v5h5', 'b')]) }, // rotate-ccw
        improve: { label: 'Improve', icon: () => ic([p('m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z', 'a'), p('M5 3v4', 'b'), p('M19 17v4', 'c'), p('M3 5h4', 'd'), p('M17 19h4', 'e')]) }, // sparkles
        shorten: { label: 'Shorten', icon: () => ic([p('M21 6H3', 'a'), p('M15 12H3', 'b'), p('M17 18H3', 'c')]) }, // align-left
        expand: { label: 'Expand', icon: () => ic([p('M3 6h18', 'a'), p('M3 12h18', 'b'), p('M3 18h18', 'c')]) }, // align-justify
        punchier: { label: 'Make punchier', icon: () => ic(p('M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z')) }, // zap
        simplify: { label: 'Simplify', icon: () => ic([p('m21.64 3.64-1.28-1.28a1.2 1.2 0 0 0-1.72 0L2.36 18.64a1.2 1.2 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72', 'a'), p('m14 7 3 3', 'b'), p('M5 6v4', 'c'), p('M7 8H3', 'd')]) }, // wand-sparkles
        professional: { label: 'Professional', icon: () => ic([p('M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16', 'a'), el('rect', { key: 'r', width: 20, height: 14, x: 2, y: 6, rx: 2 })]) }, // briefcase
        friendly: { label: 'Friendly', icon: () => ic([el('circle', { key: 'c', cx: 12, cy: 12, r: 10 }), p('M8 14s1.5 2 4 2 4-2 4-2', 'm'), p('M9 9h.01', 'l'), p('M15 9h.01', 'r')]) }, // smile
        grammar: { label: 'Grammar', icon: () => ic(p('M20 6 9 17l-5-5')) }, // check
        humanize: { label: 'Humanize', icon: () => ic([p('M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2', 'a'), el('circle', { key: 'c', cx: 12, cy: 7, r: 4 })]) }, // user
    };

    // Common edits shown up front; the rest fold behind "+ More tones & styles".
    // Both filter against caps.text.ops per block type.
    const PRIMARY_INTENTS = ['rewrite', 'improve', 'shorten', 'expand', 'grammar'];

    const MORE_INTENTS = ['simplify', 'professional', 'friendly', 'humanize', 'punchier'];
    const TEXT_FULL = ['rewrite', 'improve', 'shorten', 'expand', 'punchier', 'simplify', 'professional', 'friendly', 'grammar', 'humanize'];

    // Last-resort overrides: ONLY blocks whose editable text is a plain string
    // attribute that ISN'T named `text` and has no `source` — so neither
    // auto-rule in textFieldsFor() can find it. Everything else is discovered:
    //   • source: html/rich-text/text attrs → core/*, uagb/*, Kadence, most builders
    //   • a plain string attr named `text`   → Spectra's convention (content,
    //     button, all the loop/pagination buttons, countdown-label, form-button…)
    // Map value = list of extra field names on that block.
    const TEXT_MAP = {
        'spectra/counter': ['prefix'],
        'spectra-pro/mega-menu': ['label', 'description', 'title'],
        'uagb/buttons-child': ['label'],
    };
    // Image-block apply overrides: blocks that store the image in PLAIN attrs
    // (CSS background / custom save, no <img> source) so it can't be auto-found.
    // { url, id, alt } = the attribute names the apply writes into.
    const IMAGE_MAP = {
        'core/cover': { url: 'url', id: 'id', alt: 'alt' },
        'uagb/image': { url: 'url', id: 'id', alt: 'alt' },
    };
    const IMG_DESC_CACHE = {};
    // Describe how to set an image on a block: { url, id, alt } attribute names,
    // or null if it isn't an image block. Auto-detects any block that renders an
    // <img> (an attr with source:attribute, attribute:'src', selector incl. img)
    // — which safely EXCLUDES video/audio/embed (their src selector isn't img) —
    // then falls back to IMAGE_MAP for plain-attr blocks (cover, uagb/image).
    function imageDescriptorFor(name) {
        if (name in IMG_DESC_CACHE) return IMG_DESC_CACHE[name];
        let desc = IMAGE_MAP[name] || null;
        const type = wp.blocks && wp.blocks.getBlockType ? wp.blocks.getBlockType(name) : null;
        if (!desc && type && type.attributes) {
            const attrs = type.attributes;
            const imgSel = (d) => d && d.source === 'attribute' && typeof d.selector === 'string' && /\bimg\b/.test(d.selector);
            let urlAttr = null, altAttr = null;
            Object.keys(attrs).forEach((k) => {
                if (imgSel(attrs[k]) && attrs[k].attribute === 'src' && !urlAttr) urlAttr = k;
                if (imgSel(attrs[k]) && attrs[k].attribute === 'alt' && !altAttr) altAttr = k;
            });
            if (urlAttr) {
                const numId = (k) => attrs[k] && attrs[k].type === 'number';
                const idAttr = numId('id') ? 'id' : numId('mediaId') ? 'mediaId'
                    : Object.keys(attrs).find((k) => numId(k) && /id$/i.test(k)) || 'id';
                desc = { url: urlAttr, id: idAttr, alt: altAttr || 'alt' };
            }
        }
        if (type) IMG_DESC_CACHE[name] = desc; // cache once the type has registered
        return desc;
    }

    const RATIOS = [
        { id: '1024x1024', label: '1:1', w: 20, h: 20 },
        { id: '1536x1024', label: '16:9', w: 26, h: 16 },
        { id: '1024x1536', label: '4:5', w: 16, h: 20 },
    ];
    // Camera mark — the image-generation action reads as "photo", not a sparkle.
    const cameraIcon = () => ic([
        p('M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z', 'a'),
        el('circle', { key: 'c', cx: 12, cy: 13, r: 3 }),
    ]);
    const searchIcon = () => ic(el('path', { d: 'M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.3-4.3' }));
    const checkIcon = () => ic(el('path', { d: 'M20 6L9 17l-5-5' }));

    // Style presets — gradient-thumbnail cards (per the ZIP AI image-tool spec).
    const STYLE_PRESETS = [
        { id: 'Photographic', label: 'Photographic', grad: 'linear-gradient(135deg,#8FAE91,#5F726B)', icon: () => ic([
            el('circle', { key: 'c', cx: 12, cy: 12, r: 10 }),
            p('M14.31 8 20.05 17.94', 'a'), p('M9.69 8 21.17 8', 'b'), p('M7.38 12 13.12 2.06', 'c'),
            p('M9.69 16 3.95 6.06', 'd'), p('M14.31 16 2.83 16', 'e'), p('M16.62 12 10.88 21.94', 'f')]) },
        { id: 'Illustration', label: 'Illustration', grad: 'linear-gradient(135deg,#E7C98C,#CFA257)', icon: () => ic([
            p('M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.3a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z', 'a'),
            el('circle', { key: 'c1', cx: 13.5, cy: 6.5, r: 1, fill: 'currentColor', stroke: 'none' }),
            el('circle', { key: 'c2', cx: 17.5, cy: 10.5, r: 1, fill: 'currentColor', stroke: 'none' }),
            el('circle', { key: 'c3', cx: 6.5, cy: 12.5, r: 1, fill: 'currentColor', stroke: 'none' }),
            el('circle', { key: 'c4', cx: 8.5, cy: 7.5, r: 1, fill: 'currentColor', stroke: 'none' })]) },
        { id: '3D render', label: '3D render', grad: 'linear-gradient(135deg,#B6A6E6,#8E72D2)', icon: () => ic([
            p('M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z', 'a'),
            p('m3.3 7 8.7 5 8.7-5', 'b'), p('M12 22V12', 'c')]) },
        { id: 'Minimal', label: 'Minimal', grad: 'linear-gradient(135deg,#E6E7EA,#C2C5CA)', icon: () => ic(el('circle', { cx: 12, cy: 12, r: 9 })) },
        { id: 'Vibrant', label: 'Vibrant', grad: 'linear-gradient(135deg,#F09B91,#DD5F77)', icon: () => ic([
            el('circle', { key: 'c', cx: 12, cy: 12, r: 4 }),
            p('M12 2v2', 'a'), p('M12 20v2', 'b'), p('m4.93 4.93 1.41 1.41', 'c'), p('m17.66 17.66 1.41 1.41', 'd'),
            p('M2 12h2', 'e'), p('M20 12h2', 'f'), p('m6.34 17.66-1.41 1.41', 'g'), p('m19.07 4.93-1.41 1.41', 'h')]) },
    ];

    // Editable text fields for a block type, discovered generically. Three rules,
    // widest-first, so almost no block needs a hardcoded entry:
    //   1. attributes with source: html | rich-text | text → RichText prose
    //      (core/*, legacy uagb/* like info-box, Kadence, most third-party).
    //   2. a plain string attr literally named `text` → Spectra's editable-content
    //      convention (content, button, loop/pagination buttons, form-button…).
    //   3. TEXT_MAP → the few odd-named plain attrs rules 1–2 can't see.
    // Returns [{ field }], deduped. Memoized by block name.
    const TEXT_FIELDS_CACHE = {}; // by block name — schemas are static at runtime
    function textFieldsFor(name) {
        if (TEXT_FIELDS_CACHE[name]) return TEXT_FIELDS_CACHE[name];
        const type = wp.blocks && wp.blocks.getBlockType ? wp.blocks.getBlockType(name) : null;
        const seen = {};
        const fields = [];
        const add = (f) => { if (f && !seen[f]) { seen[f] = true; fields.push(f); } };
        if (type && type.attributes) {
            const attrs = type.attributes;
            Object.keys(attrs).forEach((key) => {
                const def = attrs[key];
                if (!def) return;
                const src = def.source;
                // (1) sourced RichText — core/*, uagb/*, Kadence, most builders.
                if (src === 'html' || src === 'rich-text' || src === 'text') add(key);
                // (2) a plain string attr literally named `text` — Spectra's
                //     editable-content convention (no source). isProse() guards it.
                else if (key === 'text' && !src && def.type === 'string') add(key);
            });
        }
        (TEXT_MAP[name] || []).forEach(add); // (3) odd-named plain-attr extras
        const out = fields.map((f) => ({ field: f }));
        if (type) TEXT_FIELDS_CACHE[name] = out; // don't cache before the type registers
        return out;
    }
    // Guard against editing non-prose source:html attrs (icon SVG, raw markup):
    // keep only values that have visible word characters after stripping tags.
    function isProse(v) {
        if (typeof v !== 'string') return false;
        const plain = v.replace(/<[^>]*>/g, '').trim();
        return plain !== '' && /[\p{L}\p{N}]/u.test(plain);
    }

    // Visual state on a block's OWN editor node (Gutenberg renders
    // `[data-block="<clientId>"]`; no wrapper around BlockEdit — that breaks
    // blocks). 'pending' — shimmer · 'active' — glow ring + scrolled into view ·
    // 'done' — brief success flash · null — clear. Best-effort.
    // The Gutenberg canvas may render inside a same-origin iframe
    // (name="editor-canvas"); blocks live in ITS document, so a main-document
    // query misses them — which is why scroll-into-view never reached the block.
    // Look in the canvas iframe first, fall back to the main document (classic).
    function findBlockEl(clientId) {
        const sel = '[data-block="' + clientId + '"]';
        const frame = document.querySelector('iframe[name="editor-canvas"]');
        const doc = frame && frame.contentDocument;
        return (doc && doc.querySelector(sel)) || document.querySelector(sel);
    }
    function setBlockState(clientId, state) {
        try {
            const n = findBlockEl(clientId);
            if (!n) return;
            n.classList.toggle('eqe-skeleton-on', state === 'pending');
            n.classList.toggle('eqe-block-active', state === 'active');
            if (state === 'active') n.scrollIntoView({ behavior: 'smooth', block: 'center' });
            if (state === 'done') { n.classList.add('eqe-block-done'); window.setTimeout(function () { n.classList.remove('eqe-block-done'); }, 900); }
            if (state === null) n.classList.remove('eqe-skeleton-on', 'eqe-block-active', 'eqe-block-done');
        } catch (e) {}
    }

    // A "section" = any block with editable text descendants (detected via the
    // store, no hardcoded container list — so custom containers work too). We
    // show "Ask ZIP AI" on it and fan the chosen op out to each descendant text
    // block. A big section is split into batches (see partitionTargets) and
    // edited over sequential calls, so even a text-heavy container works.
    // MAX_SECTION_TARGETS caps the WHOLE section (runaway guard — a whole-page
    // selection stays per-block); MAX_BATCH_TARGETS + SECTION_COST_CEILING bound
    // each single call.
    const MAX_SECTION_TARGETS = 80; // total fan-out cap across all batches
    const MAX_BATCH_TARGETS = 40; // count cap per single call
    // Per-batch est-token ceiling. MUST equal the brain's EST_CEILING
    // (workers/brain/src/http/routes/inlineEdit.ts) — same value + same formula
    // means a batch the client builds is exactly one the server accepts (no drift).
    const SECTION_COST_CEILING = 24000;
    const BATCH_GAP_MS = 300; // pause between sequential batches to ease provider rate limits
    // Bin-pack targets (in document order) into batches that each fit ONE call:
    // ≤ SECTION_COST_CEILING est tokens AND ≤ MAX_BATCH_TARGETS blocks. The first
    // target is admitted unconditionally, so callers must pre-drop any single
    // target that alone exceeds the ceiling (run() filters them) — then every
    // target is guaranteed to fit its batch.
    // Estimate mirrors the brain's estOutputTokens (chars + block count).
    function partitionTargets(targets) {
        const batches = [];
        let cur = [], curChars = 0;
        targets.forEach((t) => {
            const chars = curChars + t.text.length;
            const count = cur.length + 1;
            const est = Math.ceil(chars / 2) + count * 64 + 4096;
            if (cur.length && (est > SECTION_COST_CEILING || count > MAX_BATCH_TARGETS)) {
                batches.push(cur); cur = []; curChars = 0;
            }
            cur.push(t); curChars += t.text.length;
        });
        if (cur.length) batches.push(cur);
        return batches;
    }
    // Collect editable text descendants of a container (fresh from the store):
    // [{ id, name, field, text }] for every non-empty text block under clientId.
    // Every editable text target in a block's scope: the block's OWN text fields
    // PLUS those of every descendant — so a leaf (1 field), a multi-field block
    // (uagb/info-box → prefix + title + desc), and a container (all descendants)
    // are one uniform list. `key` = clientId + field: a block can contribute
    // several fields, but the wire keys edits by id, so each field rides its own
    // composite id (the brain treats it as opaque and echoes it back).
    function collectTargets(clientId) {
        const be = wp.data.select('core/block-editor');
        if (!be || !clientId) return [];
        const out = [];
        const visit = (id) => {
            const name = be.getBlockName(id);
            const attrs = be.getBlockAttributes(id) || {};
            textFieldsFor(name).forEach((f) => {
                const raw = attrs[f.field];
                // WP 6.5+ rich-text source attrs are RichTextData objects, not
                // strings — coerce here so the whole pipeline stays string-typed
                // (wire payload, unchanged-diff, revert).
                const v = (raw && typeof raw === 'object' && typeof raw.toHTMLString === 'function')
                    ? raw.toHTMLString() : raw;
                if (typeof v === 'string' && isProse(v)) {
                    out.push({ id: id, key: id + '::' + f.field, name: name, field: f.field, text: v });
                }
            });
        };
        visit(clientId);
        if (typeof be.getClientIdsOfDescendants === 'function') {
            be.getClientIdsOfDescendants([clientId]).forEach(visit);
        }
        return out;
    }

    function apiBase() {
        const cfg = window.ZIPAI_CONFIG || {};
        return { url: (cfg.apiUrl || '/api').replace(/\/$/, ''), token: cfg.token || '' };
    }

    // Stream an inline edit (one structured call, SSE) over /inline-edit/stream.
    // Sends blocks:[{id,block_type,text}] — a single block is a list of one. The
    // brain emits `edit {id,text}` events then `complete`; onEdit(id,text) fires
    // per event. Throws on error.
    //
    // Goes DIRECT to the brain (verifies the same Sanctum token and bills via
    // Laravel). The Laravel relay fallback was removed — brainUrl is required.
    async function streamEdit(payload, onEdit, signal) {
        const { token } = apiBase();
        const base = (window.ZIPAI_CONFIG || {}).brainUrl;
        if (!base) throw new Error('Inline edit unavailable — the import server URL is not configured.');
        const endpoint = base.replace(/\/$/, '') + '/inline-edit/stream';
        const res = await fetch(endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', Authorization: 'Bearer ' + token },
            body: JSON.stringify(payload),
            signal,
        });
        if (!res.ok || !res.body) throw new Error('Inline edit failed (' + res.status + ')');
        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';
        let errored = null;
        for (;;) {
            const { done, value } = await reader.read();
            if (done) break;
            buffer += decoder.decode(value, { stream: true });
            let idx;
            while ((idx = buffer.indexOf('\n\n')) !== -1) {
                const blk = buffer.slice(0, idx);
                buffer = buffer.slice(idx + 2);
                let event = null;
                let data = null;
                blk.split('\n').forEach((line) => {
                    if (line.indexOf('event:') === 0) event = line.slice(6).trim();
                    else if (line.indexOf('data:') === 0) data = line.slice(5).trim();
                });
                if (!event || data === null) continue;
                let parsed = {};
                try { parsed = JSON.parse(data); } catch (e) { parsed = {}; }
                if (event === 'edit' && typeof parsed.id === 'string' && typeof parsed.text === 'string') onEdit(parsed.id, parsed.text);
                else if (event === 'error') errored = parsed.error || 'Inline edit failed';
            }
        }
        if (errored) throw new Error(errored);
    }

    // Generate an image (base64 only — preview before committing to media).
    // DIRECT to the brain; the Laravel relay was removed (brainUrl required).
    async function generateImage(prompt, size) {
        const { token } = apiBase();
        const base = (window.ZIPAI_CONFIG || {}).brainUrl;
        if (!base) throw new Error('Image generation unavailable — the import server URL is not configured.');
        const endpoint = base.replace(/\/$/, '') + '/inline-edit/image';
        const res = await fetch(endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer ' + token },
            body: JSON.stringify({ prompt: prompt, size: size }),
        });
        const data = await res.json();
        // Surface the human-readable `message` (e.g. the AI-image plan-limit
        // notice) — `error` is a machine code like "plan_limit_exceeded".
        if (!res.ok || !data.success || !data.base64) throw new Error(data.message || data.error || 'Image generation failed');
        return { dataUrl: 'data:' + (data.mime_type || 'image/png') + ';base64,' + data.base64 };
    }

    // Stock-image search via the SaaS proxy (Pexels/Unsplash aggregator).
    async function searchStock(query) {
        const { url, token } = apiBase();
        const res = await fetch(url + '/unsplash/search?query=' + encodeURIComponent(query) + '&per_page=12', {
            headers: { Accept: 'application/json', Authorization: 'Bearer ' + token },
        });
        const data = await res.json();
        return (data && data.data && data.data.results) ? data.data.results : [];
    }


    // Upload an image (blob) into the WP media library; returns { url, id }.
    async function uploadBlobToMedia(blob, filename) {
        const form = new FormData();
        form.append('file', blob, filename || 'zip-ai-image.png');
        const media = await wp.apiFetch({ path: '/wp/v2/media', method: 'POST', body: form });
        return { url: media.source_url, id: media.id };
    }
    async function uploadFromDataUrl(dataUrl) {
        const res = await fetch(dataUrl);
        return uploadBlobToMedia(await res.blob(), 'zip-ai-image.png');
    }
    async function uploadFromUrl(srcUrl) {
        const res = await fetch(srcUrl);
        if (!res.ok) throw new Error('Could not fetch image');
        return uploadBlobToMedia(await res.blob(), 'zip-ai-stock.jpg');
    }

    // ── Text popover ──
    function TextPanel(props) {
        const { caps, onClose, onRun } = props;
        const [prompt, setPrompt] = useState('');
        const [moreOpen, setMoreOpen] = useState(false);

        // The popover only TRIGGERS a run, then closes. Streaming + the toolbar
        // animation are owned by the block wrapper (EraBlockEdit) so they survive
        // the popover closing.
        const trigger = (intent, instruction) => { onRun(intent, instruction); onClose(); };
        const submitPrompt = () => { if (prompt.trim()) trigger('custom', prompt.trim()); };

        if (!caps.text) return el('div', { className: 'eqe-menu' });

        const ops = caps.text.ops;
        const primary = PRIMARY_INTENTS.filter((id) => ops.indexOf(id) !== -1);
        const more = MORE_INTENTS.filter((id) => ops.indexOf(id) !== -1);

        // Primary actions — staged horizontal toolbar-style buttons (unchanged).
        const actBtn = (id) => el('button', { key: id, className: 'eqe-act', onClick: () => trigger(id) },
            el('span', { className: 'eqe-ai' }, INTENT_META[id].icon()),
            INTENT_META[id].label,
        );
        // "More tones & styles" reveal — same horizontal icon+label structure as
        // the primary row, just under a MORE label + a Show less link.
        const linkRow = (open) => el('button', {
            key: open ? 'more' : 'less', className: 'eqe-morelink', onClick: () => setMoreOpen(open), 'aria-expanded': open,
        },
            ic(open ? [p('M12 5v14', 'a'), p('M5 12h14', 'b')] : [p('M18 6 6 18', 'a'), p('m6 6 12 12', 'b')]),
            open ? __('More tones & styles', 'zip-ai') : __('Show less', 'zip-ai'),
        );

        let moreSection = null;
        if (more.length) {
            moreSection = moreOpen
                ? el('div', { className: 'eqe-moreopen' },
                    el('div', { className: 'eqe-morelabel' }, __('More', 'zip-ai')),
                    el('div', { className: 'eqe-actions-row' }, more.map(actBtn)),
                    linkRow(false))
                : linkRow(true);
        }

        return el('div', { className: 'eqe-menu' },
            el('div', { className: 'eqe-promptRow' },
                el('div', { className: 'eqe-promptField' },
                    el('span', { className: 'eqe-spark' }, EraMark()),
                    el('input', {
                        className: 'eqe-promptInput', type: 'text',
                        placeholder: __('Tell ZIP AI what to change…', 'zip-ai'),
                        value: prompt, onChange: (e) => setPrompt(e.target.value),
                        onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); submitPrompt(); } },
                    }),
                    el('button', { className: 'eqe-send' + (prompt.trim() ? ' ready' : ''), onClick: submitPrompt, 'aria-label': __('Send', 'zip-ai') },
                        ic([p('m5 12 7-7 7 7', 'a'), p('M12 19V5', 'b')])), // arrow-up
                )),
            el('div', { className: 'eqe-divider' }),
            el('div', { className: 'eqe-actions-row' }, primary.map(actBtn)),
            moreSection,
        );
    }

    // ── Image popover (Generate / Search) ──
    function ImagePanel(props) {
        const { setAttributes, onClose } = props;
        // Per-block image attribute names (descriptor from imageDescriptorFor).
        const d = (props.caps && props.caps.image) || { url: 'url', id: 'id', alt: 'alt' };
        const applyImage = (url, id, alt) => {
            const a = {}; a[d.url] = url; a[d.id] = id; if (d.alt) a[d.alt] = alt;
            setAttributes(a); onClose();
        };
        const [mode, setMode] = useState('generate'); // generate | search
        const [imgPrompt, setImgPrompt] = useState('');
        const [ratio, setRatio] = useState('1536x1024');
        const [styleChip, setStyleChip] = useState(null);
        const [busy, setBusy] = useState(false);
        const [error, setError] = useState('');
        const [genPreview, setGenPreview] = useState(null); // dataUrl
        const [query, setQuery] = useState('');
        const [results, setResults] = useState([]);
        const [searching, setSearching] = useState(false);

        const doGenerate = useCallback(() => {
            if (!imgPrompt.trim()) return;
            const full = styleChip ? (imgPrompt.trim() + ', ' + styleChip + ' style') : imgPrompt.trim();
            setBusy(true); setError(''); setGenPreview(null);
            generateImage(full, ratio)
                .then(({ dataUrl }) => { setGenPreview(dataUrl); setBusy(false); })
                .catch((e) => { setError(e.message); setBusy(false); });
        }, [imgPrompt, styleChip, ratio]);

        const useGenerated = useCallback(() => {
            if (!genPreview) return;
            setBusy(true);
            uploadFromDataUrl(genPreview)
                .then(({ url, id }) => { applyImage(url, id, imgPrompt.trim()); })
                .catch((e) => { setError(e.message); setBusy(false); });
        }, [genPreview, imgPrompt, setAttributes, onClose]);

        const doSearch = useCallback(() => {
            if (!query.trim()) return;
            setSearching(true); setError(''); setResults([]);
            searchStock(query.trim())
                .then((r) => { setResults(r); setSearching(false); })
                .catch((e) => { setError(e.message); setSearching(false); });
        }, [query]);

        const pickStock = useCallback((item) => {
            const src = item.urls && (item.urls.regular || item.urls.small || item.urls.thumb);
            if (!src) return;
            setBusy(true);
            uploadFromUrl(src)
                .then(({ url, id }) => { applyImage(url, id, item.alt_description || query); })
                .catch((e) => { setError(e.message); setBusy(false); });
        }, [query, setAttributes, onClose]);

        const seg = el('div', { className: 'eqe-seg' },
            el('button', { className: 'eqe-segBtn' + (mode === 'generate' ? ' on' : ''), onClick: () => setMode('generate') }, cameraIcon(), __('Generate', 'zip-ai')),
            el('button', { className: 'eqe-segBtn' + (mode === 'search' ? ' on' : ''), onClick: () => setMode('search') }, searchIcon(), __('Search', 'zip-ai')),
        );

        let body;
        if (mode === 'generate') {
            body = el('div', { className: 'eqe-imgBody' },
                el('textarea', {
                    className: 'eqe-ta', rows: 3, placeholder: __('Describe the image you want…', 'zip-ai'),
                    value: imgPrompt, onChange: (e) => setImgPrompt(e.target.value),
                }),
                el('div', { className: 'eqe-fieldLabel' }, __('Style', 'zip-ai')),
                el('div', { className: 'eqe-swatchRow' }, STYLE_PRESETS.map((s) =>
                    el('button', {
                        key: s.id, className: 'eqe-swatch' + (styleChip === s.id ? ' on' : ''),
                        'aria-pressed': styleChip === s.id, onClick: () => setStyleChip(styleChip === s.id ? null : s.id),
                    },
                        el('span', { className: 'eqe-swatchImg', style: { backgroundImage: s.grad } }, s.icon()),
                        el('span', { className: 'eqe-swatchLbl' }, s.label)))),
                el('div', { className: 'eqe-fieldLabel' }, __('Aspect ratio', 'zip-ai')),
                el('div', { className: 'eqe-ratioRow' }, RATIOS.map((r) =>
                    el('button', { key: r.id, className: 'eqe-ratio' + (ratio === r.id ? ' on' : ''), onClick: () => setRatio(r.id) },
                        el('span', { className: 'eqe-box', style: { width: r.w, height: r.h } }),
                        el('span', { className: 'eqe-rl' }, r.label)))),
                el('button', { className: 'eqe-primary', disabled: busy || !imgPrompt.trim(), onClick: doGenerate },
                    (!genPreview && busy) ? spinner('on-dark') : cameraIcon(),
                    (!genPreview && busy) ? __('Generating…', 'zip-ai') : genPreview ? __('Regenerate', 'zip-ai') : __('Generate image', 'zip-ai')),
                genPreview ? el('div', { className: 'eqe-genResult' + (busy ? ' busy' : '') },
                    el('img', { src: genPreview, alt: '', style: { width: '100%', display: 'block' } }),
                    el('button', { className: 'eqe-useOverlay', disabled: busy, onClick: useGenerated },
                        busy ? spinner('on-dark') : checkIcon(),
                        busy ? __('Replacing…', 'zip-ai') : __('Replace', 'zip-ai'))) : null,
                error ? el('p', { className: 'eqe-errline' }, error) : null,
            );
        } else {
            body = el('div', { className: 'eqe-imgBody' },
                el('div', { className: 'eqe-searchField' + (searching ? ' busy' : '') },
                    // The search icon itself becomes the loader while a query runs —
                    // no separate spinner row, so the field stays the focal point.
                    searching ? spinner() : searchIcon(),
                    el('input', {
                        type: 'text', placeholder: __('Search free stock photos…', 'zip-ai'),
                        value: query, onChange: (e) => setQuery(e.target.value),
                        onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); doSearch(); } },
                    })),
                searching ? el('div', { className: 'eqe-grid eqe-skel' },
                    [0, 1, 2, 3, 4, 5].map((i) => el('span', { key: i, className: 'eqe-skelcell' }))) : null,
                results.length ? el('div', { className: 'eqe-grid' }, results.map((item, i) =>
                    el('button', { key: item.id || i, className: 'eqe-thumb', onClick: () => pickStock(item), disabled: busy },
                        el('img', { src: (item.urls && (item.urls.thumb || item.urls.small)) || '', alt: item.alt_description || '', loading: 'lazy' })))) : null,
                results.length ? el('p', { className: 'eqe-credit' }, __('Photos via Pexels / Unsplash', 'zip-ai')) : null,
                error ? el('p', { className: 'eqe-errline' }, error) : null,
            );
        }
        return el('div', { className: 'eqe-imgPanel' }, seg, body);
    }

    function QuickEditPanel(props) {
        if (props.caps.image) return el(ImagePanel, props);
        return el(TextPanel, props);
    }

    // Per-editable-block wrapper. OWNS the streaming text run so it outlives the
    // popover: the popover only triggers run(); the stream, the typewriter
    // reveal, the skeleton (until the first token) and the toolbar loading state
    // all live here. Errors surface as a Gutenberg snackbar notice — no popover.
    function EraBlockEdit(opts) {
        const BlockEdit = opts.BlockEdit, props = opts.props, caps = opts.caps;
        const { name, attributes, setAttributes } = props;
        const [running, setRunning] = useState(false);
        const [progress, setProgress] = useState(null); // section edit: { cur, total }
        const abortRef = useRef(null);
        const timerRef = useRef(null);

        const markTransient = useCallback(() => {
            try { wp.data.dispatch('core/block-editor').__unstableMarkNextChangeAsNotPersistent(); } catch (e) {}
        }, []);
        const notifyError = useCallback((msg) => {
            try { wp.data.dispatch('core/notices').createErrorNotice(msg, { type: 'snackbar', isDismissible: true }); } catch (e) {}
        }, []);

        // Abort + clear on unmount.
        useEffect(() => () => {
            if (abortRef.current) abortRef.current.abort();
            if (timerRef.current) clearInterval(timerRef.current);
        }, []);

        const cancel = useCallback(() => { if (abortRef.current) abortRef.current.abort(); }, []);

        // Edit a block or a whole section (a single block is a list of one). A
        // large section is bin-packed into batches that each fit one call, fetched
        // SEQUENTIALLY (one connection at a time), and revealed as each arrives: a
        // choreographed SPOTLIGHT pass types each changed block in turn (shimmer →
        // glow + scroll → typewriter → done). Each block has a unique id and lives
        // in exactly one batch, so the per-id buffer never conflicts. One persistent
        // write per block; cancel reverts everything, a mid-way failure keeps the
        // batches already applied. Toolbar shows "Editing N of M" across the section.
        const run = useCallback((intent, instruction) => {
            if (abortRef.current) return;
            // Every editable text target in scope (own fields + descendants, any
            // block type). A target = one (block, field): a block with several
            // text fields contributes several targets, each keyed by `key`
            // (clientId::field) since the wire keys edits by id.
            // Drop any single field whose own est-cost already exceeds the
            // per-call ceiling — it can't fit a batch and the brain would reject
            // it, failing the whole pass; skipping it lets the rest still edit.
            const all = collectTargets(props.clientId);
            const targets = all.filter(
                (t) => Math.ceil(t.text.length / 2) + 64 + 4096 <= SECTION_COST_CEILING);
            if (!targets.length) {
                // Don't fail silently: if there WAS text but every field is over
                // the ceiling, tell the user instead of a no-op.
                if (all.length) notifyError(__('This text is too long for ZIP AI to edit in one pass.', 'zip-ai'));
                return;
            }
            const byKey = {}; targets.forEach((t) => { byKey[t.key] = t; });
            const batches = partitionTargets(targets); // each ≤ cost ceiling & ≤ count cap

            const ac = new AbortController(); abortRef.current = ac;
            setRunning(true);
            const dispatch = wp.data.dispatch('core/block-editor');
            const setText = (id, field, value) => { const a = {}; a[field] = value; dispatch.updateBlockAttributes(id, a); };
            const latest = {}; // key -> final text, buffered per batch
            targets.forEach((t) => setBlockState(t.id, 'pending')); // all waiting → shimmer
            setProgress({ cur: 0, total: targets.length });

            // Reveal one target: spotlight + scroll its block, typewriter at a
            // steady cadence into its field, final persistent write, done flash.
            const reveal = (t) => new Promise((resolve) => {
                const full = latest[t.key];
                setBlockState(t.id, 'active');
                // A value containing HTML tags CANNOT be char-sliced: an
                // intermediate slice lands mid-tag (e.g. `…></span><span>`), which
                // is invalid markup — and if the reveal is interrupted there, the
                // block freezes in that broken state and loses content. Write rich
                // values in one shot; only plain text gets the typewriter.
                if (/<[a-z!/][^>]*>/i.test(full)) {
                    setText(t.id, t.field, full);
                    setBlockState(t.id, 'done');
                    resolve();
                    return;
                }
                let shown = 0;
                timerRef.current = setInterval(() => {
                    if (ac.signal.aborted) { clearInterval(timerRef.current); timerRef.current = null; resolve(); return; }
                    shown = Math.min(full.length, shown + Math.max(2, Math.ceil((full.length - shown) / 8)));
                    markTransient(); setText(t.id, t.field, full.slice(0, shown));
                    if (shown >= full.length) {
                        clearInterval(timerRef.current); timerRef.current = null;
                        setText(t.id, t.field, full); // persist final
                        setBlockState(t.id, 'done');
                        resolve();
                    }
                }, 30);
            });
            const revertAll = () => {
                targets.forEach((t) => setBlockState(t.id, null));
                Object.keys(latest).forEach((key) => { const t = byKey[key]; if (t) { markTransient(); setText(t.id, t.field, t.text); } });
            };

            (async () => {
                let revealed = 0; // targets the brain actually changed
                let processed = 0; // all targets handled — drives the N-of-M counter
                try {
                    for (let b = 0; b < batches.length; b += 1) {
                        if (ac.signal.aborted) break;
                        const batch = batches[b];
                        const payload = { intent: intent, blocks: batch.map((t) => ({ id: t.key, block_type: t.name, text: t.text })) };
                        if (intent === 'custom') payload.instruction = instruction;
                        // Fetch this batch (fills the buffer), then reveal its targets.
                        await streamEdit(payload, (key, text) => { if (byKey[key]) latest[key] = text; }, ac.signal); // eslint-disable-line no-await-in-loop
                        for (let i = 0; i < batch.length; i += 1) {
                            if (ac.signal.aborted) break;
                            const t = batch[i];
                            // Count every target (changed or not) so the counter
                            // reaches M even when some blocks come back unchanged.
                            processed += 1; setProgress({ cur: processed, total: targets.length });
                            if (latest[t.key] != null && latest[t.key] !== t.text) {
                                revealed += 1;
                                await reveal(t); // eslint-disable-line no-await-in-loop
                            } else {
                                setBlockState(t.id, null); // unchanged → drop shimmer
                            }
                        }
                        // Brief gap before the next batch so a multi-batch section
                        // doesn't machine-gun the provider into a rate limit.
                        if (b < batches.length - 1 && !ac.signal.aborted) {
                            await new Promise((r) => setTimeout(r, BATCH_GAP_MS)); // eslint-disable-line no-await-in-loop
                        }
                    }
                    abortRef.current = null; setRunning(false); setProgress(null);
                    if (ac.signal.aborted) { revertAll(); return; }
                    if (revealed === 0) notifyError(__('No changes were needed.', 'zip-ai'));
                } catch (e) {
                    abortRef.current = null; setRunning(false); setProgress(null);
                    if (ac.signal.aborted) { revertAll(); return; }
                    // Partial failure: keep batches already applied; just clear the
                    // shimmer on targets we never reached.
                    targets.forEach((t) => { if (latest[t.key] == null) setBlockState(t.id, null); });
                    notifyError(__('ZIP AI couldn’t finish this section: ', 'zip-ai') + ((e && e.message) || 'failed'));
                }
            })();
        }, [props.clientId, markTransient, notifyError]);

        // caps null = no ZIP AI affordance for this block (yet) — render the
        // block untouched. Hooks above still run so the type stays mounted.
        const toolbar = caps && el(BlockControls, { group: 'other' },
            el(ToolbarGroup, null,
                running
                    ? el(ToolbarButton, {
                        label: __('Stop', 'zip-ai'), showTooltip: true,
                        onClick: cancel, className: 'eqe-toggle eqe-running',
                    },
                        // Loader + explicit "editing" copy + animated dots so it
                        // reads unmistakably as work-in-progress, not a static icon.
                        el('span', { className: 'eqe-workwrap' },
                            EraLoader(),
                            el('span', { className: 'eqe-worktext' },
                                (progress && progress.total > 1)
                                    ? sprintf(__('Editing %1$d of %2$d', 'zip-ai'), progress.cur || 1, progress.total)
                                    : __('ZIP AI is editing', 'zip-ai')),
                            el('span', { className: 'eqe-dots', 'aria-hidden': 'true' },
                                el('i', null), el('i', null), el('i', null))))
                    : el(Dropdown, {
                        // Let Gutenberg flip to whichever side has room (the
                        // toolbar floats high on some blocks, low on others — no
                        // fixed side works). The panel keeps a stable compact
                        // height (eqe-imgBody min-height + grid cap below) so flip
                        // doesn't ping-pong between tabs, and the content scrolls
                        // rather than clipping when space is tight (resize+overflow).
                        popoverProps: { placement: 'bottom-start' },
                        className: 'eqe-dropdown', contentClassName: 'eqe-popover',
                        renderToggle: ({ isOpen, onToggle }) => el(ToolbarButton, {
                            icon: EraMark(), text: __('Ask ZIP AI', 'zip-ai'), label: __('Ask ZIP AI', 'zip-ai'),
                            showTooltip: true, onClick: onToggle, 'aria-expanded': isOpen, className: 'eqe-toggle',
                        }),
                        renderContent: ({ onClose }) => el(QuickEditPanel, {
                            name: name, attributes: attributes, setAttributes: setAttributes, caps: caps, onClose: onClose, onRun: run,
                        }),
                    })
            )
        );

        // No wrapper around BlockEdit — Gutenberg blocks break when their edit
        // output is nested in an extra element. Run state lives in this
        // component (survives the popover closing); the block streams in place.
        return el(wp.element.Fragment, null,
            el(BlockEdit, props),
            toolbar,
        );
    }

    const withEraQuickEdit = createHigherOrderComponent((BlockEdit) => {
        return (props) => {
            // One uniform decision: collect every editable text target in this
            // block's scope (own fields + descendants, any block type). Show
            // "Ask ZIP AI" if there's text within the cap, or it's an image block.
            // REACTIVE gate, cheap steady-state: subscribe to the deep block
            // tree ONLY while this block has no quick-edit affordance yet
            // (fresh empty block), so the button appears the moment text lands
            // — typed or streamed — without a reload. Once targets exist the
            // mapSelect returns a constant and the subscription goes quiet:
            // during a streaming quick edit (~30 descendant attr updates/s)
            // ancestor wrappers do NOT re-walk their subtree per tick.
            // Structural changes still refresh via descIds (render-computed);
            // the block's own fields via props.attributes.
            const hasTargetsRef = useRef(false);
            const emptyTree = wp.data.useSelect(
                (select) => hasTargetsRef.current ? null : select('core/block-editor').getBlocks(props.clientId),
                [props.clientId]
            );
            const descIds = (() => {
                const be = wp.data.select('core/block-editor');
                return be && typeof be.getClientIdsOfDescendants === 'function'
                    ? be.getClientIdsOfDescendants([props.clientId]).join('|') : '';
            })();
            const targets = useMemo(() => collectTargets(props.clientId), [props.clientId, descIds, emptyTree, props.attributes]);
            hasTargetsRef.current = targets.length > 0;
            const hasText = targets.length > 0 && targets.length <= MAX_SECTION_TARGETS;
            // Image descriptor (or null). Skip a media block currently in video
            // mode (e.g. core/media-text with mediaType:'video').
            const imageDesc = props.attributes && props.attributes.mediaType === 'video'
                ? null : imageDescriptorFor(props.name);
            // The image panel is shown ONLY for a leaf image block (core/image,
            // even one with a caption). A section CONTAINER that also carries an
            // image (core/cover, core/media-text) has editable text in its
            // descendants — there the section text-rewrite is the point, so the
            // image panel must not shadow it.
            const hasDescendantText = targets.some((t) => t.id !== props.clientId);
            const imagePrimary = !!imageDesc && !hasDescendantText;
            // ALWAYS render EraBlockEdit (caps:null → plain BlockEdit, no
            // toolbar). The component type at this slot must be stable: now
            // that the gate is reactive it flips on the first typed char, and
            // swapping bare BlockEdit ↔ wrapper on a flip remounts the block's
            // edit tree mid-typing (caret/IME loss). Conditional lives inside.
            return el(EraBlockEdit, {
                BlockEdit: BlockEdit, props: props,
                caps: (hasText || imagePrimary) ? {
                    text: (hasText && !imagePrimary) ? { ops: TEXT_FULL } : null,
                    image: imagePrimary ? imageDesc : false,
                } : null,
            });
        };
    }, 'withEraQuickEdit');

    addFilter('editor.BlockEdit', 'zip-ai/quick-edit', withEraQuickEdit);

    // One-time scoped styles for the quick-edit popover (ZIP AI design tokens).
    (function injectQuickEditStyles() {
        if (document.getElementById('eqe-styles')) return;
        const s = document.createElement('style');
        s.id = 'eqe-styles';
        s.textContent =
            // Accent maps to the ZIP AI ink system (no blue): primary/active = ink-1,
            // soft ring = translucent ink, soft fill = sunken.
            '.eqe-popover{--ai:#1A1D21;--ai-50:#F6F7F8;--ink0:#0A0A0C;--ink1:#1A1D21;--ink3:#6B7178;--ink4:#9AA0A6;--ink5:#BCC0C5;--line:#ECECEE;--line2:#E2E2E5;--line3:#D3D4D7;--sunken:#F6F7F8}' +
            // overflow:auto (not hidden) so when flip/resize constrains the popover
            // near a viewport edge the panel scrolls instead of clipping its top.
            '.eqe-popover .components-popover__content{border-radius:14px!important;border:1px solid var(--line2)!important;box-shadow:0 18px 44px -16px rgba(16,18,20,.26),0 4px 12px -6px rgba(16,18,20,.12)!important;overflow:auto}' +
            // Toolbar-width panel: prompt on top, one wrapping action row.
            '.eqe-menu{width:344px;max-width:92vw;padding:0 0 7px}' +
            '.eqe-promptRow{padding:11px 11px 9px}' +
            '.eqe-promptField{display:flex;align-items:center;gap:9px;border:1.5px solid var(--line2);border-radius:11px;padding:0 6px 0 12px;height:44px;transition:border-color .14s,box-shadow .14s}' +
            '.eqe-promptField:focus-within{border-color:var(--ai);box-shadow:0 0 0 3px var(--ai-50)}' +
            // Kill wp-admin borders/rings on the bare inputs (focused or not) so
            // only the wrapper shows a single border — no nested box.
            '.eqe-popover .eqe-promptField input,.eqe-popover .eqe-searchField input{border:none!important;box-shadow:none!important;outline:none!important;background:transparent!important}' +
            '.eqe-spark{color:var(--ai);display:flex;flex-shrink:0}.eqe-spark svg{width:18px;height:18px}' +
            '.eqe-promptInput{flex:1;border:none;outline:none;background:transparent;font-size:13.5px;color:var(--ink1);min-width:0}' +
            // wp-admin slaps a blue focus ring on every input/textarea — kill it
            // inside the popover so only OUR ink ring (on the wrappers) shows.
            '.eqe-popover input:focus,.eqe-popover .eqe-promptInput:focus,.eqe-popover .eqe-searchField input:focus{outline:none!important;box-shadow:none!important;border:none!important;background:transparent!important}' +
            '.eqe-popover .eqe-ta:focus{border-color:var(--ai)!important;box-shadow:0 0 0 3px var(--ai-soft)!important;outline:none!important}' +
            '.eqe-send{width:32px;height:32px;border-radius:9px;border:none;cursor:pointer;display:grid;place-items:center;flex-shrink:0;background:var(--sunken);color:var(--ink5);transition:all .14s}' +
            '.eqe-send svg{width:17px;height:17px}' +
            '.eqe-send.ready{background:var(--ai);color:#fff}' +
            '.eqe-send.ready:hover{transform:translateY(-1px)}' +
            '.eqe-divider{height:1px;background:var(--line);margin:0 11px}' +
            // Primary actions — staged horizontal toolbar-style buttons.
            '.eqe-actions-row{display:flex;flex-wrap:wrap;gap:3px;padding:8px}' +
            '.eqe-act{display:inline-flex;align-items:center;gap:7px;padding:7px 10px;border:none;background:none;border-radius:8px;cursor:pointer;color:var(--ink1);font-size:12.5px;font-weight:500;transition:background .12s}' +
            '.eqe-act:hover{background:var(--sunken)}' +
            '.eqe-act .eqe-ai{width:15px;height:15px;flex-shrink:0;color:var(--ink3);display:flex}.eqe-act:hover .eqe-ai{color:var(--ai)}.eqe-act .eqe-ai svg{width:15px;height:15px}' +
            // "More tones & styles" reveal — same horizontal action row as primary.
            '.eqe-moreopen{border-top:1px solid var(--line);margin-top:2px}' +
            // "+ More tones & styles" / "Show less" accent link row (ZIP AI ink).
            '.eqe-morelabel{font-size:9.5px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink4);padding:7px 9px 3px}' +
            '.eqe-morelink{display:flex;align-items:center;gap:8px;width:100%;padding:6px 7px;border:none;background:none;border-radius:9px;cursor:pointer;text-align:left;color:var(--ai);font-size:12.5px;font-weight:600;transition:background .12s}' +
            '.eqe-morelink:hover{background:var(--sunken)}' +
            '.eqe-morelink svg{width:15px;height:15px;flex-shrink:0;margin:0 6px}' +
            // Ink spinner ring — shared by search field + Generate button.
            '.eqe-spin{width:16px;height:16px;border-radius:50%;border:2px solid var(--line2);border-top-color:var(--ai);box-sizing:border-box;flex-shrink:0;display:inline-block;animation:eqe-rot .7s linear infinite}' +
            '.eqe-spin.on-dark{border-color:rgba(255,255,255,.35);border-top-color:#fff}' +
            '@keyframes eqe-rot{to{transform:rotate(360deg)}}' +
            // "ZIP AI is editing" toolbar state — loader + copy + bouncing dots.
            '.eqe-workwrap{display:inline-flex;align-items:center;gap:8px}' +
            '.eqe-worktext{font-size:12.5px;font-weight:600;color:var(--ai);white-space:nowrap}' +
            '.eqe-dots{display:inline-flex;align-items:flex-end;gap:3px;padding-bottom:2px}' +
            '.eqe-dots i{width:3px;height:3px;border-radius:50%;background:var(--ai);animation:eqe-dot 1.2s ease-in-out infinite}' +
            '.eqe-dots i:nth-child(2){animation-delay:.18s}.eqe-dots i:nth-child(3){animation-delay:.36s}' +
            '@keyframes eqe-dot{0%,70%,100%{opacity:.3;transform:translateY(0)}35%{opacity:1;transform:translateY(-3px)}}' +
            // ZIP AI-mark loader: same ink knockout tile as the Ask button (the
            // mark must not change color while editing) + a comet segment
            // running its border.
            '.eqe-loader{position:relative;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:#0A0A0C}' +
            '.eqe-loader .eqe-snake-glyph{display:block}' +
            // track + ring use the border-mask trick (content-box xor) so only the
            // 1.6px frame paints; the ring sweeps via the animated --eqe-a angle.
            '.eqe-snake-track,.eqe-snake-ring{position:absolute;inset:0;border-radius:inherit;padding:1.6px;pointer-events:none;-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude}' +
            '.eqe-snake-track{background:rgba(255,255,255,.08)}' +
            '.eqe-snake-ring{background:conic-gradient(from var(--eqe-a,0deg),#E9E0FF 0deg,#B49DFF 14deg,#5B3DF5 30deg,rgba(91,61,245,.35) 70deg,rgba(91,61,245,0) 110deg,transparent 360deg);filter:drop-shadow(0 0 3px rgba(125,95,255,.7));animation:eqe-snake 1.5s cubic-bezier(.45,.05,.55,.95) infinite}' +
            '@property --eqe-a{syntax:"<angle>";inherits:false;initial-value:0deg}' +
            '@keyframes eqe-snake{to{--eqe-a:360deg}}' +
            '@media (prefers-reduced-motion:reduce){.eqe-snake-ring,.eqe-spin,.eqe-skelcell,.eqe-dots i{animation:none}}' +
            '.eqe-primary{flex:1;min-width:120px;padding:11px;border:none;border-radius:11px;background:var(--ai);color:#fff;font-size:13.5px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px;box-shadow:inset 0 1px 0 rgba(255,255,255,.18);transition:filter .12s}' +
            '.eqe-primary svg{width:16px;height:16px}' +
            '.eqe-primary:hover:not(:disabled){filter:brightness(1.06)}' +
            // Block skeleton — a shimmer overlay on the editing block(s) until
            // the first streamed token. Targets the block\'s own [data-block]
            // node (no wrapper around BlockEdit). Global, not popover-scoped.
            '[data-block].eqe-skeleton-on{position:relative}' +
            '[data-block].eqe-skeleton-on::after{content:"";position:absolute;inset:0;z-index:6;border-radius:6px;pointer-events:none;background:#F2F3F5;background-image:linear-gradient(90deg,#F2F3F5 0,#E6E8EB 80px,#F2F3F5 160px);background-size:300px 100%;background-repeat:no-repeat;animation:eqe-shimmer 1.1s linear infinite}' +
            '@keyframes eqe-shimmer{0%{background-position:-160px 0}100%{background-position:300px 0}}' +
            '@media (prefers-reduced-motion:reduce){[data-block].eqe-skeleton-on::after{animation:none}}' +
            // Sequential spotlight — the block being written gets a pulsing accent
            // ring; on hand-off it flashes a green "done" ring that fades. Rings
            // paint on ::before (skeleton owns ::after) so the two never clash.
            '[data-block].eqe-block-active{position:relative}' +
            '[data-block].eqe-block-active::before{content:"";position:absolute;inset:-4px;z-index:7;border-radius:9px;pointer-events:none;box-shadow:0 0 0 2px #5B3DF5,0 0 0 7px rgba(91,61,245,.16);animation:eqe-activepulse 1.4s ease-in-out infinite}' +
            '@keyframes eqe-activepulse{0%,100%{opacity:.6}50%{opacity:1}}' +
            '[data-block].eqe-block-done::before{content:"";position:absolute;inset:-4px;z-index:7;border-radius:9px;pointer-events:none;box-shadow:0 0 0 2px #2BA24C,0 0 0 7px rgba(43,162,76,.14);animation:eqe-doneflash .9s ease-out forwards}' +
            '@keyframes eqe-doneflash{0%{opacity:1}100%{opacity:0}}' +
            '@media (prefers-reduced-motion:reduce){[data-block].eqe-block-active::before,[data-block].eqe-block-done::before{animation:none}}' +
            '.eqe-imgBody>.eqe-primary{width:100%;margin-top:16px}' +
            '.eqe-primary:disabled{opacity:.6;cursor:default}' +
            '.eqe-genResult::after{content:"";position:absolute;inset:0;background:rgba(0,0,0,.14);opacity:0;transition:opacity .15s;pointer-events:none}' +
            '.eqe-genResult:hover::after,.eqe-genResult.busy::after{opacity:1}' +
            '.eqe-useOverlay{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:2;display:inline-flex;align-items:center;gap:8px;padding:11px 20px;border:none;border-radius:999px;background:#fff;color:var(--ink0);font-size:13.5px;font-weight:600;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.22);opacity:0;transition:opacity .15s}' +
            '.eqe-genResult:hover .eqe-useOverlay,.eqe-genResult.busy .eqe-useOverlay{opacity:1}' +
            '.eqe-useOverlay svg{width:16px;height:16px}' +
            '.eqe-useOverlay:disabled{cursor:default}' +
            '.eqe-error,.eqe-errline{color:#b32d2e;font-size:13px}.eqe-errline{margin:10px 2px 0}' +
            // Image panel stays on the ZIP AI ink system (no blue): --ai/--ai-50
            // inherit ink-1/sunken from .eqe-popover; only the two image-only
            // tokens are mapped to ink — --ai-ink (selected label) + --ink2.
            '.eqe-imgPanel{--ai-ink:#0A0A0C;--ai-soft:#F6F7F8;--ink2:#3D4248}' +
            '.eqe-seg{display:flex;gap:3px;padding:12px 12px 0}' +
            '.eqe-segBtn{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 0;border:none;background:var(--sunken);color:var(--ink3);font-size:12.5px;font-weight:600;cursor:pointer;border-radius:9px;transition:all .14s}' +
            '.eqe-segBtn svg{width:15px;height:15px}' +
            '.eqe-segBtn.on{background:var(--ink1);color:#fff}' +
            // Floor the body to the Generate-tab height so an empty Search tab
            // doesn't collapse — keeps both tabs the same size so flip picks one
            // side and stays there instead of jumping when you switch tabs.
            '.eqe-imgBody{padding:12px;min-width:300px;max-width:340px;min-height:336px;box-sizing:border-box}' +
            '.eqe-ta{width:100%;border:1.5px solid var(--line2);border-radius:11px;padding:10px 12px;font-size:13.5px;line-height:1.5;color:var(--ink1);resize:none;outline:none;min-height:68px;background:#fff;transition:border-color .14s,box-shadow .14s}' +
            '.eqe-ta:focus{border-color:var(--ai);box-shadow:0 0 0 3px var(--ai-soft)}' +
            '.eqe-fieldLabel{font-size:10.5px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink4);margin:14px 2px 7px}' +
            // Style presets — gradient-thumbnail cards (3-up).
            '.eqe-swatchRow{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}' +
            '.eqe-swatch{display:flex;flex-direction:column;gap:5px;padding:5px;border:1px solid var(--line2);background:#fff;border-radius:10px;cursor:pointer;transition:all .12s}' +
            '.eqe-swatch:hover{border-color:var(--line3)}' +
            '.eqe-swatch.on{border-color:var(--ai);background:var(--ai-50);box-shadow:0 0 0 1px var(--ai)}' +
            '.eqe-swatchImg{height:34px;border-radius:6px;display:grid;place-items:center;color:#fff}' +
            '.eqe-swatchImg svg{width:18px;height:18px}' +
            '.eqe-swatchLbl{font-size:11px;font-weight:500;color:var(--ink2);text-align:center}' +
            '.eqe-swatch.on .eqe-swatchLbl{color:var(--ai-ink)}' +
            '.eqe-ratioRow{display:flex;gap:6px}' +
            '.eqe-ratio{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;padding:9px 0 8px;border:1px solid var(--line2);border-radius:9px;background:#fff;cursor:pointer}' +
            '.eqe-ratio.on{border-color:var(--ai);background:var(--ai-50)}' +
            '.eqe-box{border:1.6px solid var(--ink3);border-radius:2px}.eqe-ratio.on .eqe-box{border-color:var(--ai)}' +
            '.eqe-rl{font-size:11px;font-weight:500;color:var(--ink3)}.eqe-ratio.on .eqe-rl{color:var(--ai)}' +
            '.eqe-genResult{position:relative;margin-top:12px;border-radius:11px;overflow:hidden}' +
            '.eqe-searchField{display:flex;align-items:center;gap:9px;height:42px;padding:0 12px;border:1.5px solid var(--line2);border-radius:11px;color:var(--ink4)}' +
            '.eqe-searchField:focus-within{border-color:var(--ai);box-shadow:0 0 0 3px var(--ai-soft)}' +
            '.eqe-searchField input{flex:1;border:none;outline:none;background:transparent;font-size:13.5px;color:var(--ink1);min-width:0}' +
            // Cap the result grid so the panel stays a fixed compact size: the
            // grid scrolls internally instead of stretching the popover to fill
            // the viewport (which made it resize/jump as the page scrolled).
            '.eqe-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-top:12px;max-height:300px;overflow-y:auto;overflow-x:hidden}' +
            // Skeleton thumbnails while a search runs — ink shimmer, no blue spinner.
            '.eqe-skelcell{aspect-ratio:1;border-radius:9px;background:linear-gradient(90deg,var(--sunken) 25%,var(--line) 37%,var(--sunken) 63%);background-size:400% 100%;animation:eqe-shim 1.3s ease-in-out infinite}' +
            '@keyframes eqe-shim{0%{background-position:100% 0}100%{background-position:-100% 0}}' +
            '.eqe-thumb{position:relative;aspect-ratio:1;border-radius:9px;overflow:hidden;cursor:pointer;border:1px solid rgba(0,0,0,.06);padding:0;background:none}' +
            '.eqe-thumb img{width:100%;height:100%;object-fit:cover;display:block}' +
            '.eqe-thumb:hover{box-shadow:inset 0 0 0 2.5px var(--ai)}' +
            '.eqe-credit{font-size:11px;color:var(--ink4);margin-top:11px}';
        document.head.appendChild(s);
    })();

})();
