# zip-ai/0.0.7/assets/js/tools/editor/styles/handler.js

ZIP AI – AI Website Builder &amp; AI Agent (Beta), version 0.0.7. 328 lines.

- Page: https://pluginprobe.com/plugins/zip-ai/0.0.7/code/assets/js/tools/editor/styles/handler.js
- Raw: https://pluginprobe.com/plugins/zip-ai/0.0.7/raw/assets/js/tools/editor/styles/handler.js
- Modified: 2026-07-07T13:04:36+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/zip-ai/0.0.7/code/assets/js/tools/editor/styles/handler.js#L10-L20`.

```javascript
/**
 * editor/get-styles + editor/set-styles — typed read/patch over the GBS CSS
 * store (the CSS source-of-truth), symmetric with editor/get-context (HTML) and
 * editor/get-scripts (JS). ONE store key, two scopes:
 *
 *   scope:'page'   → post meta `spectra_blocks_pro_gs_user_css` via the GBS
 *                    `/global-styles/save` route (scope:page) — IMMEDIATE write
 *                    (the same SSOT endpoint the importer uses), then live paint.
 *   scope:'global' → the same-named WP OPTION (header/footer/site-wide chrome),
 *                    read via GET /global-styles/user-css and written via the
 *                    shared /global-styles/sitewide merge route — IMMEDIATE +
 *                    site-wide (not reversible by discard).
 *
 * READ → MERGE only the touched buckets → WRITE (never full-replace, so importer
 * chrome + user classes survive), then RENDER the merged payload via the SSOT
 * GenCssRenderer (REST /global-styles/render — no string hacks) and inject it
 * into the canvas iframe for live paint. Cascade tier: GBS classes sit BELOW
 * block attributes and ABOVE block defaults — the renderer emits that specificity.
 *
 * @package zip-ai
 */
(function () {
    var META_KEY = 'spectra_blocks_pro_gs_user_css'; // page post-meta AND global option key
    var NS = '/spectra-blocks/v1/global-styles';

    // Editor-store access (select/dispatch core/editor + session meta) comes from
    // the ONE shared source (editor/shared/editor-shared-utils.js): window in the
    // browser, require() under jest — so the editor handlers can't drift.
    function sharedEditorUtils() {
        if (typeof window !== 'undefined' && window.zipwpEditorShared) return window.zipwpEditorShared;
        if (typeof require === 'function') {
            try { return require('../shared/editor-shared-utils.js'); } catch (e) { return null; }
        }
        return null;
    }
    function editorSelect() { var u = sharedEditorUtils(); return u && u.editorSelect ? u.editorSelect() : null; }
    function apiFetch(opts) {
        if (!(window.wp && window.wp.apiFetch)) {
            return Promise.reject(new Error('wp.apiFetch unavailable'));
        }
        return window.wp.apiFetch(opts);
    }

    // Merge the touched buckets of `incoming` onto `existing` (read-modify-write).
    // Object buckets (classes/wrapperStyles/rootStyles/scopeVars/presetLock/
    // mediaQuery/object-keyframes) merge PER ENTRY — `null` deletes that entry;
    // a `null` whole bucket deletes the bucket. Array buckets (imports / list
    // keyframes) REPLACE. Never wholesale-clobbers a bucket the caller didn't send.
    function mergePayload(existing, incoming) {
        var out = Object.assign({}, existing || {});
        out.v = '1';
        Object.keys(incoming || {}).forEach(function (bucket) {
            if (bucket === 'v') return;
            var inc = incoming[bucket];
            if (inc === null) { delete out[bucket]; return; }
            if (Array.isArray(inc)) { out[bucket] = inc.slice(); return; }
            if (typeof inc !== 'object') return;
            var base = (out[bucket] && typeof out[bucket] === 'object' && !Array.isArray(out[bucket]))
                ? Object.assign({}, out[bucket])
                : {};
            Object.keys(inc).forEach(function (key) {
                if (inc[key] === null) { delete base[key]; } else { base[key] = inc[key]; }
            });
            out[bucket] = base;
        });
        return out;
    }

    // The block-editor canvas runs in an iframe; styles must be injected THERE.
    function canvasDoc() {
        var ifr = document.querySelector('iframe[name="editor-canvas"]');
        return ifr && ifr.contentDocument ? ifr.contentDocument : document;
    }
    function injectCss(elementId, css) {
        var doc = canvasDoc();
        var el = doc.getElementById(elementId);
        if (!el) {
            el = doc.createElement('style');
            el.id = elementId;
            (doc.head || doc.documentElement).appendChild(el);
        }
        el.textContent = css || '';
    }

    function bucketsOf(payload) {
        return Object.keys(payload || {}).filter(function (k) { return k !== 'v'; });
    }

    // ── STYLE CONTEXT (ownership) ──────────────────────────────────────────────
    // The OWNERSHIP MODEL: a visual property is set by ONE of three layers, in
    // descending CSS specificity — a block ATTRIBUTE, a GBS CLASS body, or the
    // block DEFAULT (DEVELOPER-INSTRUCTIONS §5.1). To change a property you edit
    // its current OWNER (update the existing class, don't stack a new one; clear a
    // block attr that pins it). This resolver answers "who owns each property" so
    // the agent never hand-resolves specificity. It does NOT compute the exact
    // frontend winner (the editor canvas inverts utility-vs-gsClass specificity,
    // and utilities are JIT-compiled, not in the GBS payload) — `effective` is the
    // rendered truth and the agent's verify-iterate loop corrects any mis-guess.
    // The property map is small and explicit ON PURPOSE: it is this tool's job.
    // `prop` is the kebab CSS property — it doubles as the GBS-body key (bodies are
    // kebab, e.g. `font-size`) and the getComputedStyle key. Only `attrs` (the
    // block-attribute path(s) for that property) is non-derivable.
    var STYLE_PROPS = [
        { prop: 'color', attrs: ['style.color.text'] },
        { prop: 'background-color', attrs: ['style.color.background', 'background.color'] },
        { prop: 'padding', attrs: ['style.spacing.padding'] },
        { prop: 'margin', attrs: ['style.spacing.margin'] },
        { prop: 'font-size', attrs: ['style.typography.fontSize'] },
        { prop: 'font-weight', attrs: ['style.typography.fontWeight'] },
        { prop: 'text-align', attrs: ['align'] },
        { prop: 'max-width', attrs: ['maxWidth'] },
    ];
    function deepGet(obj, path) {
        var cur = obj;
        var parts = path.split('.');
        for (var i = 0; i < parts.length; i++) {
            if (cur === null || typeof cur !== 'object') return undefined;
            cur = cur[parts[i]];
        }
        return cur;
    }
    function gsTokensOf(block) {
        var cn = (block && block.attributes && typeof block.attributes.className === 'string')
            ? block.attributes.className : '';
        return cn.trim().split(/\s+/).filter(function (t) { return t && t.indexOf('gs-') === 0; });
    }
    function computedForBlock(clientId) {
        try {
            var doc = canvasDoc();
            var el = doc.querySelector('[data-block="' + clientId + '"]');
            if (!el) return null;
            return (doc.defaultView || window).getComputedStyle(el);
        } catch (e) { return null; }
    }
    // Per-property { effective, owner, availableSources[] } for ONE block, joining
    // its gs- class bodies (from the page GBS payload) + its block attributes +
    // the rendered computed value. owner = the highest-specificity layer that
    // declares the property (attr > gbs > default) — a best-guess the verify loop
    // refines; `effective` is always the rendered truth.
    function buildStyleContext(block, pagePayload) {
        if (!block) return null;
        var classes = (pagePayload && pagePayload.classes && typeof pagePayload.classes === 'object')
            ? pagePayload.classes : {};
        var gsTokens = gsTokensOf(block);
        var cs = computedForBlock(block.clientId);
        var attrs = block.attributes || {};
        var properties = {};
        STYLE_PROPS.forEach(function (def) {
            var sources = [];
            // block_attribute tier (highest specificity)
            def.attrs.forEach(function (path) {
                var v = deepGet(attrs, path);
                if (v !== undefined && v !== null && v !== '') {
                    sources.push({ type: 'block_attribute', path: path, value: v });
                }
            });
            // gbs class tier — only classes ACTUALLY on this block, only if they declare it
            gsTokens.forEach(function (token) {
                var body = classes[token] && classes[token].default;
                if (!body || typeof body !== 'object') return;
                var v = body[def.prop];
                if (v !== undefined && v !== null && v !== '') {
                    sources.push({ type: 'gbs', class: token, value: v });
                }
            });
            var owner = sources.length === 0
                ? 'default'
                : (sources[0].type === 'block_attribute'
                    ? 'block_attribute:' + sources[0].path
                    : 'gbs:' + sources[0].class);
            properties[def.prop] = {
                effective: cs ? cs.getPropertyValue(def.prop) : null,
                owner: owner,
                availableSources: sources,
            };
        });
        return { client_id: block.clientId, gbs_classes: gsTokens, properties: properties };
    }

    // ── get-styles ───────────────────────────────────────────────────────────
    async function handleGetStyles(args) {
        var scope = args && args.scope === 'global' ? 'global' : 'page';
        if (scope === 'page') {
            var sel = editorSelect();
            var postId = sel && sel.getCurrentPostId ? sel.getCurrentPostId() : 0;
            if (!postId) {
                return { success: false, error: 'editor_unavailable: no current post id (is the block editor open?)' };
            }
            try {
                var pres = await apiFetch({ path: NS + '/save?scope=page&post_id=' + postId });
                var pp = (pres && pres.payload && typeof pres.payload === 'object') ? pres.payload : {};
                var out = { scope: 'page', post_id: postId, buckets: bucketsOf(pp), payload: pp };
                // STYLE CONTEXT (ownership) for a target block — the agent reads
                // this BEFORE a styling edit so it updates the existing owner
                // instead of guessing/stacking. Best-effort: a missing block /
                // unmounted node simply omits styleContext (never blocks the read).
                var clientId = args && typeof args.client_id === 'string' ? args.client_id : null;
                if (clientId && sel && sel.getBlock) {
                    var blk = sel.getBlock(clientId);
                    var ctx = buildStyleContext(blk, pp);
                    if (ctx) out.styleContext = ctx;
                }
                return { success: true, data: out };
            } catch (e) {
                return { success: false, error: 'page_read_failed: ' + String(e && e.message ? e.message : e) };
            }
        }
        try {
            var res = await apiFetch({ path: NS + '/user-css' });
            var gp = (res && res.payload && typeof res.payload === 'object') ? res.payload : {};
            return { success: true, data: { scope: 'global', buckets: bucketsOf(gp), payload: gp } };
        } catch (e) {
            return { success: false, error: 'global_read_failed: ' + String(e && e.message ? e.message : e) };
        }
    }

    // ── set-styles ───────────────────────────────────────────────────────────
    async function handleSetStyles(args) {
        var scope = args && (args.scope === 'page' || args.scope === 'global') ? args.scope : null;
        var incoming = args && args.payload;
        if (scope === null) {
            return { success: false, error: 'invalid_input: scope must be "page" or "global"' };
        }
        if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
            return { success: false, error: 'invalid_input: payload must be a schema-v1 object of style buckets' };
        }
        if (!bucketsOf(incoming).length) {
            return { success: false, error: 'invalid_input: payload has no style buckets (classes / wrapperStyles / rootStyles / …)' };
        }

        if (scope === 'page') {
            var sel = editorSelect();
            var postId = sel && sel.getCurrentPostId ? sel.getCurrentPostId() : 0;
            if (!postId) {
                return { success: false, error: 'editor_unavailable: no current post id (is the block editor open?)' };
            }

            // Read-modify-write the per-page GBS store through the SSOT /save route
            // (the same endpoint the importer uses) — IMMEDIATE, no editPost session.
            var existing;
            try {
                var g = await apiFetch({ path: NS + '/save?scope=page&post_id=' + postId });
                existing = (g && g.payload && typeof g.payload === 'object') ? g.payload : {};
            } catch (e) {
                return { success: false, error: 'page_read_failed: ' + String(e && e.message ? e.message : e) };
            }
            var merged = mergePayload(existing, incoming);
            try {
                await apiFetch({ path: NS + '/save', method: 'POST', data: { scope: 'page', post_id: postId, payload: merged, replace: true } });
            } catch (e) {
                return { success: false, error: 'page_write_failed: ' + String(e && e.message ? e.message : e) };
            }

            // Live paint: render the merged payload (SSOT) and replace the page CSS element.
            try {
                var r = await apiFetch({ path: NS + '/render', method: 'POST', data: { payload: merged, post_id: postId, scope: 'page' } });
                injectCss('spectra-gen-custom-css-' + postId + '-inline-css', r && r.css);
            } catch (e) {
                return { success: false, error: 'render_failed: ' + String(e && e.message ? e.message : e) };
            }
            return {
                success: true,
                data: {
                    scope: 'page',
                    post_id: postId,
                    buckets: bucketsOf(incoming),
                    note: 'Written immediately via the GBS /save route; painted live.',
                },
            };
        }

        // scope === 'global' — read-modify-write the option (immediate, site-wide).
        var existingGlobal;
        try {
            var g = await apiFetch({ path: NS + '/user-css' });
            existingGlobal = (g && g.payload && typeof g.payload === 'object') ? g.payload : {};
        } catch (e) {
            return { success: false, error: 'global_read_failed: ' + String(e && e.message ? e.message : e) };
        }
        var mergedGlobal = mergePayload(existingGlobal, incoming);
        try {
            // /sitewide replaces non-class buckets wholesale → send the FULL merged
            // payload so the write equals the merged state (chrome/user classes kept).
            await apiFetch({ path: NS + '/sitewide', method: 'POST', data: { payload: mergedGlobal } });
        } catch (e) {
            return { success: false, error: 'global_write_failed: ' + String(e && e.message ? e.message : e) };
        }
        try {
            var rg = await apiFetch({ path: NS + '/render', method: 'POST', data: { payload: mergedGlobal, post_id: 0, scope: 'global' } });
            // Append-last override so the live global paint wins source-order ties
            // (deletions converge on reload, consistent with the live-JIT model).
            injectCss('zipwp-gbs-live-global', rg && rg.css);
        } catch (e) {
            return { success: false, error: 'render_failed: ' + String(e && e.message ? e.message : e) };
        }
        return {
            success: true,
            data: {
                scope: 'global',
                buckets: bucketsOf(incoming),
                note: 'Site-wide + IMMEDIATE: applied to every page now (not reversible by discarding the editor).',
            },
        };
    }

    function initHandler() {
        if (window.zipwpMcp && window.zipwpMcp.registerTool) {
            window.zipwpMcp.registerTool('editor/get-styles', async function (args) { return handleGetStyles(args); }, { previewMode: 'client' });
            window.zipwpMcp.registerTool('editor/set-styles', async function (args) { return handleSetStyles(args); }, { previewMode: 'client' });
        } else {
            setTimeout(initHandler, 100);
        }
    }
    initHandler();

    // Test-only surface (Node/CommonJS) — inert in the browser bundle.
    if (typeof module !== 'undefined' && module.exports) {
        module.exports = {
            handleGetStyles: handleGetStyles,
            handleSetStyles: handleSetStyles,
            mergePayload: mergePayload,
            // Ownership resolver (per-property effective/owner/availableSources).
            // Exported PURE so a unit test locks the join, not a reimplementation.
            buildStyleContext: buildStyleContext,
        };
    }
})();

```
