PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / assets / js / tools / editor / styles / handler.js

handler.js in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at assets/js/tools/editor/styles/handler.js

630 lines 30.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * editor/get-styles + editor/set-styles — the GBS CSS store, symmetric with
3 * editor/get-context (HTML) and editor/get-scripts (JS).
4 *
5 * READ (get-styles) has two scopes: 'page' (this page's payload + the SELECTED
6 * block's ownership styleContext) and 'global' (the site-wide option). Pure reads.
7 *
8 * WRITE (set-styles) is GLOBAL-scope ONLY — the header/footer / site-wide CHROME
9 * WP OPTION (read via GET /global-styles/user-css, written via the shared
10 * /global-styles/sitewide merge route — IMMEDIATE + site-wide, not reversible by
11 * discard). The former scope:'page' WRITE (an IMMEDIATE `/global-styles/save`
12 * write to this post's `spectra_blocks_pro_gs_user_css` meta) was REMOVED: it
13 * persisted a per-block style edit to the DB before the user Saved (no undo) and,
14 * on a SHARED gs- class body, silently restyled every section carrying it. All
15 * per-block styling is now a utility className via editor/apply-change (editor
16 * state, committed on Save). scope:'page' here is rejected, defensively.
17 *
18 * The global write does READ → MERGE only the touched buckets → WRITE (never
19 * full-replace, so importer chrome + user classes survive), then RENDERs the
20 * merged payload via the SSOT GenCssRenderer (REST /global-styles/render) and
21 * injects it into the canvas iframe for live paint.
22 *
23 * @package
24 */
25 ( function () {
26 // eslint-disable-next-line no-unused-vars
27 const META_KEY = 'spectra_blocks_pro_gs_user_css'; // page post-meta AND global option key
28 const NS = '/spectra-blocks/v1/global-styles';
29
30 // Editor-store access (select/dispatch core/editor + session meta) comes from
31 // the ONE shared source (editor/shared/editor-shared-utils.js): window in the
32 // browser, require() under jest — so the editor handlers can't drift.
33 function sharedEditorUtils() {
34 if ( typeof window !== 'undefined' && window.zipwpEditorShared ) {
35 return window.zipwpEditorShared;
36 }
37 if ( typeof require === 'function' ) {
38 try {
39 return require( '../shared/editor-shared-utils.js' );
40 } catch ( e ) {
41 return null;
42 }
43 }
44 return null;
45 }
46 function editorSelect() {
47 const u = sharedEditorUtils(); return u && u.editorSelect ? u.editorSelect() : null;
48 }
49 function blockEditorSelect() {
50 const u = sharedEditorUtils(); return u && u.blockEditorSelect ? u.blockEditorSelect() : null;
51 }
52 // How many blocks on the page carry a gs- token (the reverse `class -> blocks`
53 // edge). >1 means editing this class body restyles other sections too. Shared SSOT.
54 function classUsage( token ) {
55 const u = sharedEditorUtils();
56 const besel = blockEditorSelect();
57 if ( ! u || ! u.classDependents || ! besel ) {
58 return 1;
59 }
60 const n = u.classDependents( besel, token, [] ).length;
61 return n > 0 ? n : 1;
62 }
63 function apiFetch( opts ) {
64 if ( ! ( window.wp && window.wp.apiFetch ) ) {
65 return Promise.reject( new Error( 'wp.apiFetch unavailable' ) );
66 }
67 return window.wp.apiFetch( opts );
68 }
69
70 // GBS page-store logic (merge / iframe-inject / the read→merge→save→render→inject
71 // persist) lives in ONE place — editor/shared/editor-shared-utils.js — so this
72 // styling tool and editor/apply-change can't drift. These are thin forwarders,
73 // the same pattern as editorSelect above.
74 function mergePayload( existing, incoming ) {
75 const u = sharedEditorUtils();
76 return u && u.mergePayload ? u.mergePayload( existing, incoming ) : Object.assign( {}, existing, incoming );
77 }
78 function canvasDoc() {
79 const u = sharedEditorUtils();
80 return u && u.canvasDoc ? u.canvasDoc() : document;
81 }
82 function injectCss( elementId, css ) {
83 const u = sharedEditorUtils();
84 if ( u && u.injectCss ) {
85 u.injectCss( elementId, css );
86 }
87 }
88
89 function bucketsOf( payload ) {
90 return Object.keys( payload || {} ).filter( function ( k ) {
91 return k !== 'v';
92 } );
93 }
94
95 // ── STYLE CONTEXT (ownership) ──────────────────────────────────────────────
96 // The OWNERSHIP MODEL: a visual property is set by ONE of three layers, in
97 // descending CSS specificity — a block ATTRIBUTE, a GBS CLASS body, or the
98 // block DEFAULT (DEVELOPER-INSTRUCTIONS §5.1). To change a property you edit
99 // its current OWNER (update the existing class, don't stack a new one; clear a
100 // block attr that pins it). This resolver answers "who owns each property" so
101 // the agent never hand-resolves specificity. It does NOT compute the exact
102 // frontend winner (the editor canvas inverts utility-vs-gsClass specificity,
103 // and utilities are JIT-compiled, not in the GBS payload) — `effective` is the
104 // rendered truth and the agent's verify-iterate loop corrects any mis-guess.
105 // The property map is small and explicit ON PURPOSE: it is this tool's job.
106 // `prop` is the kebab CSS property — it doubles as the GBS-body key (bodies are
107 // kebab, e.g. `font-size`) and the getComputedStyle key. Only `attrs` (the
108 // block-attribute path(s) for that property) is non-derivable.
109 // NOTE: ownership matches the EXACT property name — the shorthands `padding` /
110 // `margin` resolve against a rule's `padding` / `margin` declaration, not a
111 // longhand-only rule (`padding-top`). This system's utilities + gs- bodies write
112 // the shorthand, so that is the intended scope; widen this list if a longhand-
113 // only owner ever needs to be surfaced.
114 const STYLE_PROPS = [
115 { prop: 'color', attrs: [ 'style.color.text' ] },
116 { prop: 'background-color', attrs: [ 'style.color.background', 'background.color' ] },
117 { prop: 'padding', attrs: [ 'style.spacing.padding' ] },
118 { prop: 'margin', attrs: [ 'style.spacing.margin' ] },
119 { prop: 'font-size', attrs: [ 'style.typography.fontSize' ] },
120 { prop: 'font-weight', attrs: [ 'style.typography.fontWeight' ] },
121 { prop: 'text-align', attrs: [ 'align' ] },
122 { prop: 'max-width', attrs: [ 'maxWidth' ] },
123 // Border / shadow / motion — className-utility-owned (the visual block
124 // attributes for these are BANNED, so there is no block-attribute owner
125 // path: `attrs` is empty and ownership resolves purely from the live
126 // cascade). Tracked so a `border-2 border-red-500`, `rounded-lg`,
127 // `shadow-md`, `animate-*`, or a transform utility applied via apply_change
128 // has an `effective`/`owner` the agent can VERIFY — without these the
129 // resolver was blind to them and the agent could never confirm the edit
130 // (the "the border is applied, the bounce could not be confirmed" hedge).
131 // Longhands (not the `border` shorthand): utilities author border-width /
132 // border-color separately, and getComputedStyle returns longhands.
133 { prop: 'border-color', attrs: [] },
134 { prop: 'border-width', attrs: [] },
135 { prop: 'border-radius', attrs: [] },
136 { prop: 'box-shadow', attrs: [] },
137 { prop: 'animation', attrs: [] },
138 { prop: 'transform', attrs: [] },
139 ];
140 function deepGet( obj, path ) {
141 let cur = obj;
142 const parts = path.split( '.' );
143 for ( let i = 0; i < parts.length; i++ ) {
144 if ( cur === null || typeof cur !== 'object' ) {
145 return undefined;
146 }
147 cur = cur[ parts[ i ] ];
148 }
149 return cur;
150 }
151 function gsTokensOf( block ) {
152 const cn = ( block && block.attributes && typeof block.attributes.className === 'string' )
153 ? block.attributes.className : '';
154 return cn.trim().split( /\s+/ ).filter( function ( t ) {
155 return t && t.indexOf( 'gs-' ) === 0;
156 } );
157 }
158 function computedForBlock( clientId ) {
159 try {
160 const doc = canvasDoc();
161 const el = doc.querySelector( '[data-block="' + clientId + '"]' );
162 if ( ! el ) {
163 return null;
164 }
165 return ( doc.defaultView || window ).getComputedStyle( el );
166 } catch ( e ) {
167 return null;
168 }
169 }
170 // ── CSSOM CASCADE RESOLUTION ───────────────────────────────────────────────
171 // The ROOT of ownership: `effective` is read from the rendered cascade, so
172 // `owner` MUST be resolved from that SAME cascade — not an authored guess. The
173 // authored join (attrs + GBS payload) is blind to JIT utility classes (they are
174 // compiled to real CSS rules, never in the GBS payload) and to source-order
175 // ties between two utilities — the exact gap behind "colour set but not
176 // applied" (owner came back `default` while the pixel was clearly painted). We
177 // read the real matched rules from the canvas stylesheets instead.
178
179 // The live element for a block (the [data-block] node in the canvas), or null
180 // when unmounted (off-screen / virtualized) — then we degrade to the authored
181 // join below, flagged as best-effort.
182 function elForBlock( doc, clientId ) {
183 try {
184 return doc && clientId ? doc.querySelector( '[data-block="' + clientId + '"]' ) : null;
185 } catch ( e ) {
186 return null;
187 }
188 }
189 function computedForEl( el, doc ) {
190 try {
191 return el ? ( doc.defaultView || window ).getComputedStyle( el ) : null;
192 } catch ( e ) {
193 return null;
194 }
195 }
196
197 // Approximate CSS specificity [ids, classes/attrs/pseudo-classes, types] for a
198 // single (comma-free) selector. Our selectors are single classes (`.gs-x`,
199 // `.text-primary-600`) → [0,1,0]; the ties they create are broken by SOURCE
200 // ORDER, which is the real decider and is tracked separately.
201 function specificityOf( sel ) {
202 const s = String( sel );
203 const ids = ( s.match( /#[\w-]+/g ) || [] ).length;
204 const classesAttrsPc =
205 ( s.match( /\.[\w-]+/g ) || [] ).length +
206 ( s.match( /\[[^\]]*\]/g ) || [] ).length +
207 ( s.match( /:(?!:)[\w-]+/g ) || [] ).length;
208 const stripped = s
209 .replace( /::?[\w-]+(\([^)]*\))?/g, ' ' )
210 .replace( /[.#][\w-]+/g, ' ' )
211 .replace( /\[[^\]]*\]/g, ' ' );
212 const types = ( stripped.match( /[a-zA-Z][\w-]*/g ) || [] ).length;
213 return [ ids, classesAttrsPc, types ];
214 }
215 function cmpSpec( a, b ) {
216 for ( let i = 0; i < 3; i++ ) {
217 if ( a[ i ] !== b[ i ] ) {
218 return a[ i ] - b[ i ];
219 }
220 }
221 return 0;
222 }
223 // Does a conditional group rule (@media / @supports) currently apply? Permissive
224 // on the way in — a group we can't evaluate is INCLUDED (better to surface a
225 // possibly-inactive source than hide the active one), matching the live paint
226 // which the model verifies against `effective` anyway.
227 function groupRuleApplies( doc, rule ) {
228 try {
229 const win = doc.defaultView || window;
230 if ( rule.type === 4 /* MEDIA */ ) {
231 const mt = rule.media && rule.media.mediaText;
232 return ! mt || ! win.matchMedia ? true : win.matchMedia( mt ).matches;
233 }
234 if ( rule.type === 12 /* SUPPORTS */ ) {
235 const ct = rule.conditionText;
236 return ! ct || ! ( win.CSS && win.CSS.supports ) ? true : win.CSS.supports( ct );
237 }
238 } catch ( e ) { /* permissive */ }
239 return true;
240 }
241
242 // Every stylesheet declaration of `prop` whose selector MATCHES `el`, sorted in
243 // ascending cascade priority (winner LAST): !important tier, then specificity,
244 // then source order. Reads real CSSOM rules from the canvas — the true source
245 // of the painted value.
246 function matchedDeclarations( el, doc, prop ) {
247 const out = [];
248 let order = 0;
249 function walk( rules ) {
250 for ( let i = 0; i < rules.length; i++ ) {
251 const r = rules[ i ];
252 if ( r.type === 1 /* STYLE_RULE */ && r.selectorText && r.style ) {
253 order++;
254 const value = r.style.getPropertyValue( prop );
255 if ( value === '' || value === null || value === undefined ) {
256 continue;
257 }
258 const important = r.style.getPropertyPriority( prop ) === 'important';
259 let best = null;
260 String( r.selectorText ).split( ',' ).forEach( function ( part ) {
261 const p = part.trim();
262 if ( ! p ) {
263 return;
264 }
265 let matches = false;
266 try {
267 matches = el.matches( p );
268 } catch ( e ) {
269 matches = false;
270 }
271 if ( ! matches ) {
272 return;
273 }
274 const spec = specificityOf( p );
275 if ( ! best || cmpSpec( spec, best.specificity ) > 0 ) {
276 best = { part: p, specificity: spec };
277 }
278 } );
279 if ( best ) {
280 out.push( { selectorPart: best.part, value: String( value ).trim(), important, specificity: best.specificity, order } );
281 }
282 } else if ( ( r.type === 4 || r.type === 12 ) && r.cssRules && groupRuleApplies( doc, r ) ) {
283 walk( r.cssRules );
284 }
285 }
286 }
287 const sheets = ( doc && doc.styleSheets ) || [];
288 for ( let s = 0; s < sheets.length; s++ ) {
289 let rules;
290 try {
291 rules = sheets[ s ].cssRules || sheets[ s ].rules;
292 } catch ( e ) {
293 continue;
294 } // cross-origin sheet
295 if ( rules ) {
296 walk( rules );
297 }
298 }
299 out.sort( function ( a, b ) {
300 return ( a.important ? 1 : 0 ) - ( b.important ? 1 : 0 ) ||
301 cmpSpec( a.specificity, b.specificity ) ||
302 a.order - b.order;
303 } );
304 return out;
305 }
306
307 // Map a single-selector matched rule to an EDITABLE source ON this block: a
308 // `.gs-*` class (edit its body via set_styles) or a utility class (replace it
309 // in className via apply_change). A compound selector (`.parent .child`) isn't
310 // a class the model owns here → surfaced as `selector` so it edits the real
311 // owner (or an ancestor) rather than stacking.
312 function classifySelectorPart( part, el ) {
313 const sel = String( part ).trim();
314 if ( ! el || ! el.classList ) {
315 return { type: 'selector', selector: sel };
316 }
317 // Class tokens named anywhere in the selector (a `[class]` attribute selector
318 // or `:root` scoping carries no dot, so it is ignored here). The GBS JIT
319 // boosts per-block rules to `[class].gs-x.gs-x` (attribute + DOUBLED class)
320 // to outrank utilities — the same class repeated is ONE owner, editable via
321 // set_styles. We must see through that, and through `:root .gs-x` scoping.
322 const classTokens = ( sel.match( /\.[\w-]+/g ) || [] ).map( function ( c ) {
323 return c.slice( 1 );
324 } );
325 if ( classTokens.length === 0 ) {
326 return { type: 'selector', selector: sel };
327 }
328 // A SELF-target: every class the selector names is on THIS element (so it is
329 // repetition / attribute-boosting / a structural `:root` scope, not a real
330 // ancestor). A descendant selector like `.wrap .child` names an ancestor
331 // class that ISN'T on el → it stays a contextual `selector:` the model can't
332 // edit here. Exactly one DISTINCT on-el class keeps the owner unambiguous.
333 const distinct = [];
334 let allOnEl = true;
335 classTokens.forEach( function ( c ) {
336 if ( ! el.classList.contains( c ) ) {
337 allOnEl = false;
338 }
339 if ( distinct.indexOf( c ) === -1 ) {
340 distinct.push( c );
341 }
342 } );
343 if ( ! allOnEl || distinct.length !== 1 ) {
344 return { type: 'selector', selector: sel };
345 }
346 const cls = distinct[ 0 ];
347 return cls.indexOf( 'gs-' ) === 0
348 ? { type: 'gbs', class: cls }
349 : { type: 'utility', class: cls };
350 }
351 function ownerLabel( src ) {
352 if ( ! src ) {
353 return 'default';
354 }
355 if ( src.type === 'block_attribute' ) {
356 return 'block_attribute:' + src.path;
357 }
358 if ( src.type === 'gbs' ) {
359 return 'gbs:' + src.class;
360 }
361 if ( src.type === 'utility' ) {
362 return 'utility:' + src.class;
363 }
364 if ( src.type === 'selector' ) {
365 return 'selector:' + src.selector;
366 }
367 return 'default';
368 }
369
370 // Cascade-ordered editable sources for ONE property on a MOUNTED block, winner
371 // first. Merges the authored inline attribute (Spectra renders style.* inline,
372 // a normal declaration above class selectors) with the real matched rules, then
373 // sorts by the CSS cascade: !important tier > inline > selectors, each broken by
374 // specificity then source order.
375 function resolveCascadeSources( def, attrs, el, doc, usageByToken ) {
376 const candidates = [];
377 def.attrs.forEach( function ( path ) {
378 const v = deepGet( attrs, path );
379 if ( v !== undefined && v !== null && v !== '' ) {
380 // level 2 = normal inline; specificity above any class selector.
381 candidates.push( { src: { type: 'block_attribute', path, value: v }, level: 2, specificity: [ 1, 0, 0 ], order: Number.MAX_SAFE_INTEGER } );
382 }
383 } );
384 matchedDeclarations( el, doc, def.prop ).forEach( function ( d ) {
385 const cls = classifySelectorPart( d.selectorPart, el );
386 let src;
387 if ( cls.type === 'gbs' ) {
388 src = { type: 'gbs', class: cls.class, value: d.value };
389 if ( usageByToken && usageByToken[ cls.class ] !== undefined ) {
390 src.usage = usageByToken[ cls.class ];
391 }
392 } else if ( cls.type === 'utility' ) {
393 src = { type: 'utility', class: cls.class, value: d.value };
394 } else {
395 src = { type: 'selector', selector: cls.selector, value: d.value };
396 }
397 candidates.push( { src, level: d.important ? 3 : 1, specificity: d.specificity, order: d.order } );
398 } );
399 candidates.sort( function ( a, b ) {
400 return a.level - b.level || cmpSpec( a.specificity, b.specificity ) || a.order - b.order;
401 } );
402 return candidates.reverse().map( function ( c ) {
403 return c.src;
404 } ); // winner first
405 }
406
407 // Per-property { effective, owner, availableSources[] } for ONE block. When the
408 // block is MOUNTED (the real case: the selected block is on-screen), ownership
409 // is resolved from the live CSS cascade (CSSOM) so `owner` can never disagree
410 // with the painted `effective`. When unmounted (off-screen / no canvas — e.g.
411 // jsdom), we degrade to the authored join (attrs + GBS payload), which is
412 // best-effort. `canvas` ({ doc, el }) is an injectable seam for tests.
413 function buildStyleContext( block, pagePayload, usageByToken, canvas ) {
414 if ( ! block ) {
415 return null;
416 }
417 const classes = ( pagePayload && pagePayload.classes && typeof pagePayload.classes === 'object' )
418 ? pagePayload.classes : {};
419 const gsTokens = gsTokensOf( block );
420 const attrs = block.attributes || {};
421 const doc = ( canvas && canvas.doc ) || canvasDoc();
422 const el = ( canvas && canvas.el !== undefined ) ? canvas.el : elForBlock( doc, block.clientId );
423 const cs = el ? computedForEl( el, doc ) : computedForBlock( block.clientId );
424 const properties = {};
425 if ( el ) {
426 // ROOT PATH — resolve ownership from the live cascade (CSSOM), so a
427 // colour painted by a JIT utility (or the winner of two competing
428 // utilities) is named correctly instead of collapsing to `default`.
429 STYLE_PROPS.forEach( function ( def ) {
430 const sources = resolveCascadeSources( def, attrs, el, doc, usageByToken );
431 properties[ def.prop ] = {
432 effective: cs ? cs.getPropertyValue( def.prop ) : null,
433 owner: sources.length ? ownerLabel( sources[ 0 ] ) : 'default',
434 availableSources: sources,
435 };
436 } );
437 } else {
438 // FALLBACK — block not mounted (no canvas node): the authored join
439 // (attrs + GBS payload) is the best we can do and is flagged best-effort
440 // by the absence of a rendered `effective`. It cannot see utilities.
441 STYLE_PROPS.forEach( function ( def ) {
442 const sources = [];
443 // block_attribute tier (highest specificity)
444 def.attrs.forEach( function ( path ) {
445 const v = deepGet( attrs, path );
446 if ( v !== undefined && v !== null && v !== '' ) {
447 sources.push( { type: 'block_attribute', path, value: v } );
448 }
449 } );
450 // gbs class tier — only classes ACTUALLY on this block, only if they declare it.
451 // `usage` = how many blocks carry this class: >1 means editing its body
452 // restyles those other sections too (the reverse-dependency signal).
453 gsTokens.forEach( function ( token ) {
454 const body = classes[ token ] && classes[ token ].default;
455 if ( ! body || typeof body !== 'object' ) {
456 return;
457 }
458 const v = body[ def.prop ];
459 if ( v !== undefined && v !== null && v !== '' ) {
460 sources.push( { type: 'gbs', class: token, value: v, usage: usageByToken ? usageByToken[ token ] : undefined } );
461 }
462 } );
463 const owner = sources.length === 0
464 ? 'default'
465 : ( sources[ 0 ].type === 'block_attribute'
466 ? 'block_attribute:' + sources[ 0 ].path
467 : 'gbs:' + sources[ 0 ].class );
468 properties[ def.prop ] = {
469 effective: null,
470 owner,
471 availableSources: sources,
472 };
473 } );
474 }
475 // Classes on THIS block that also style other sections — edit their body
476 // and every listed block moves with it. The model surfaces this to the user
477 // (or restyles this block with utilities instead — see the doctrine).
478 const sharedClasses = usageByToken
479 ? gsTokens.filter( function ( t ) {
480 return ( usageByToken[ t ] || 1 ) > 1;
481 } )
482 .map( function ( t ) {
483 return { class: t, used_by: usageByToken[ t ] };
484 } )
485 : [];
486 return {
487 client_id: block.clientId,
488 gbs_classes: gsTokens,
489 properties,
490 shared_gbs_classes: sharedClasses,
491 };
492 }
493
494 // ── get-styles ───────────────────────────────────────────────────────────
495 async function handleGetStyles( args ) {
496 const scope = args && args.scope === 'global' ? 'global' : 'page';
497 if ( scope === 'page' ) {
498 const sel = editorSelect();
499 const postId = sel && sel.getCurrentPostId ? sel.getCurrentPostId() : 0;
500 if ( ! postId ) {
501 return { success: false, error: 'editor_unavailable: no current post id (is the block editor open?)' };
502 }
503 try {
504 const pres = await apiFetch( { path: NS + '/save?scope=page&post_id=' + postId } );
505 const pp = ( pres && pres.payload && typeof pres.payload === 'object' ) ? pres.payload : {};
506 const out = { scope: 'page', post_id: postId, buckets: bucketsOf( pp ), payload: pp };
507 // STYLE CONTEXT (ownership) for a target block — the agent reads
508 // this BEFORE a styling edit so it updates the existing owner
509 // instead of guessing/stacking. Best-effort: a missing block /
510 // unmounted node simply omits styleContext (never blocks the read).
511 const clientId = args && typeof args.client_id === 'string' ? args.client_id : null;
512 if ( clientId && sel && sel.getBlock ) {
513 const blk = sel.getBlock( clientId );
514 // Reverse-dependency counts for the block's gs- tokens, so the
515 // model sees "shared by N" BEFORE it picks an owner to edit.
516 const usageByToken = {};
517 ( blk && blk.attributes && typeof blk.attributes.className === 'string'
518 ? blk.attributes.className.trim().split( /\s+/ ) : [] )
519 .filter( function ( t ) {
520 return t.indexOf( 'gs-' ) === 0;
521 } )
522 .forEach( function ( t ) {
523 usageByToken[ t ] = classUsage( t );
524 } );
525 const ctx = buildStyleContext( blk, pp, usageByToken );
526 if ( ctx ) {
527 out.styleContext = ctx;
528 }
529 }
530 return { success: true, data: out };
531 } catch ( e ) {
532 return { success: false, error: 'page_read_failed: ' + String( e && e.message ? e.message : e ) };
533 }
534 }
535 try {
536 const res = await apiFetch( { path: NS + '/user-css' } );
537 const gp = ( res && res.payload && typeof res.payload === 'object' ) ? res.payload : {};
538 return { success: true, data: { scope: 'global', buckets: bucketsOf( gp ), payload: gp } };
539 } catch ( e ) {
540 return { success: false, error: 'global_read_failed: ' + String( e && e.message ? e.message : e ) };
541 }
542 }
543
544 // ── set-styles ───────────────────────────────────────────────────────────
545 // GLOBAL (chrome) scope ONLY. The former scope:'page' write — an IMMEDIATE,
546 // irreversible REST write to this post's `spectra_blocks_pro_gs_user_css` meta
547 // — was REMOVED: it persisted a per-block style edit to the DB even if the user
548 // never Saved (no undo), and editing a SHARED gs- class body silently restyled
549 // every section carrying it. All per-block styling now goes through
550 // editor/apply-change utility classNames (editor state, committed on Save). This
551 // handler still rejects scope:'page' defensively so a stale/forked brain bundle
552 // can never resurrect the silent DB write.
553 async function handleSetStyles( args ) {
554 const scope = args && args.scope === 'global' ? 'global' : null;
555 const incoming = args && args.payload;
556 if ( args && args.scope === 'page' ) {
557 return { success: false, error: 'unsupported_scope: per-page style writes were removed (they wrote the DB before Save and restyled shared classes). Change per-block styling with a utility className via apply_change instead. set_styles is header/footer chrome (scope:"global") only.' };
558 }
559 if ( scope === null ) {
560 return { success: false, error: 'invalid_input: scope must be "global" (per-block styling uses apply_change utilities)' };
561 }
562 if ( ! incoming || typeof incoming !== 'object' || Array.isArray( incoming ) ) {
563 return { success: false, error: 'invalid_input: payload must be a schema-v1 object of style buckets' };
564 }
565 if ( ! bucketsOf( incoming ).length ) {
566 return { success: false, error: 'invalid_input: payload has no style buckets (classes / wrapperStyles / rootStyles / …)' };
567 }
568
569 // scope === 'global' — read-modify-write the option (immediate, site-wide).
570 let existingGlobal;
571 try {
572 // eslint-disable-next-line no-redeclare, no-var
573 var g = await apiFetch( { path: NS + '/user-css' } );
574 existingGlobal = ( g && g.payload && typeof g.payload === 'object' ) ? g.payload : {};
575 } catch ( e ) {
576 return { success: false, error: 'global_read_failed: ' + String( e && e.message ? e.message : e ) };
577 }
578 const mergedGlobal = mergePayload( existingGlobal, incoming );
579 try {
580 // /sitewide replaces non-class buckets wholesale → send the FULL merged
581 // payload so the write equals the merged state (chrome/user classes kept).
582 await apiFetch( { path: NS + '/sitewide', method: 'POST', data: { payload: mergedGlobal } } );
583 } catch ( e ) {
584 return { success: false, error: 'global_write_failed: ' + String( e && e.message ? e.message : e ) };
585 }
586 try {
587 const rg = await apiFetch( { path: NS + '/render', method: 'POST', data: { payload: mergedGlobal, post_id: 0, scope: 'global' } } );
588 // Append-last override so the live global paint wins source-order ties
589 // (deletions converge on reload, consistent with the live-JIT model).
590 injectCss( 'zipwp-gbs-live-global', rg && rg.css );
591 } catch ( e ) {
592 return { success: false, error: 'render_failed: ' + String( e && e.message ? e.message : e ) };
593 }
594 return {
595 success: true,
596 data: {
597 scope: 'global',
598 buckets: bucketsOf( incoming ),
599 note: 'Site-wide + IMMEDIATE: applied to every page now (not reversible by discarding the editor).',
600 },
601 };
602 }
603
604 function initHandler() {
605 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
606 window.zipwpMcp.registerTool( 'editor/get-styles', async function ( args ) {
607 return handleGetStyles( args );
608 }, { previewMode: 'client' } );
609 window.zipwpMcp.registerTool( 'editor/set-styles', async function ( args ) {
610 return handleSetStyles( args );
611 }, { previewMode: 'client' } );
612 } else {
613 setTimeout( initHandler, 100 );
614 }
615 }
616 initHandler();
617
618 // Test-only surface (Node/CommonJS) — inert in the browser bundle.
619 if ( typeof module !== 'undefined' && module.exports ) {
620 module.exports = {
621 handleGetStyles,
622 handleSetStyles,
623 mergePayload,
624 // Ownership resolver (per-property effective/owner/availableSources).
625 // Exported PURE so a unit test locks the join, not a reimplementation.
626 buildStyleContext,
627 };
628 }
629 }() );
630