PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.14
ZIP AI – AI Website Builder & AI Agent v0.0.14
0.0.14 0.0.13 0.0.12 0.0.11 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 / apply-change / handler.js

handler.js in ZIP AI – AI Website Builder & AI Agent 0.0.14, at assets/js/tools/editor/apply-change/handler.js

2,744 lines 135.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * ZipWP MCP — Vibe Editing v2: editor/apply-change handler.
3 *
4 * The WRITE sibling of editor/get-context. The brain's AgentBrowserLoop dispatches
5 * a `js_rpc` envelope carrying a serialized Gutenberg EXECUTION PLAN; this handler
6 * runs each operation against the LIVE Gutenberg tree via wp.data dispatch, then
7 * returns a structured reply that the bridge POSTs to /agent/rpc-reply so the loop
8 * folds it IN THE SAME TURN.
9 *
10 * Contract:
11 * args = { version, post_id, operations:[ { function, ...named args } ] }
12 * Each operation maps to ONE-OR-MORE real wp.data.dispatch('core/block-editor')[function](...)
13 * calls — one per op, except moveBlocksToPosition's reorder ('order') and
14 * multi-parent forms, which fan out to N native moveBlocksToPosition dispatches.
15 * Allowed functions: updateBlockAttributes | insertBlocks | removeBlocks |
16 * moveBlocksToPosition | replaceBlocks | replaceInnerBlocks | duplicateBlocks |
17 * selectBlock. The brain drives them with its native Gutenberg API knowledge.
18 * - updateBlockAttributes PRESERVES clientId (read-merge-dispatch; className
19 * verbatim — the GBS JIT renders it; NO style translation).
20 * - insertBlocks / replace* MINT clientIds (createBlock stamps them synchronously)
21 * → returned in applied[].new_client_ids so the brain can target them next turn.
22 * - moveBlocksToPosition PRESERVES clientIds (a splice). insert/move accept a
23 * before|after anchor that resolves to root+index here.
24 * post_id two-tab guard: abort ALL on mismatch (returned as a `refused` reply,
25 * ok:true, so the LLM sees the real reason — not "browser unreachable").
26 * Partial-apply: a per-op failure (stale clientId) records into failed[] by index
27 * and continues; earlier ops stay applied. Targeting is liveness-checked here;
28 * the brain holds no durable handle.
29 *
30 * @package
31 */
32 ( function () {
33 'use strict';
34
35 function isPlainObject( v ) {
36 return v !== null && typeof v === 'object' && ! Array.isArray( v );
37 }
38
39 // L3 boundary — per-block visual styling lives in `className` ONLY (the
40 // Spectra GBS JIT grammar); these flat visual attrs are NEVER allowed on a
41 // block. The brain's zod denylist is the contract owner; we re-enforce
42 // client-side (defense in depth) so a stale/forked brain bundle can't paint
43 // banned attrs into the live tree. The 8-prop list is the SHARED SSOT
44 // (editor-shared-utils.isBannedVisualAttr) so this + get-context can never
45 // drift. If the shared module isn't resolved we don't strip — the brain's zod
46 // already rejected the 8 upstream, so we degrade consistently (never a 3rd copy).
47 function isBannedVisualAttr( key ) {
48 const u = sharedEditorUtils();
49 return !! ( u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr( key ) );
50 }
51 function stripBannedAttrs( attrs ) {
52 if ( ! isPlainObject( attrs ) ) {
53 return attrs;
54 }
55 const clean = {};
56 Object.keys( attrs ).forEach( function ( k ) {
57 if ( ! isBannedVisualAttr( k ) ) {
58 clean[ k ] = attrs[ k ];
59 } else {
60 console.warn( '[ZIP AI:apply-change] dropped banned visual attr "%s" — styling belongs in className', k );
61 }
62 } );
63 return clean;
64 }
65
66 // C5 — AI-authored block content never carries executable markup: no
67 // core/html block, no <script>, no inline on* handler, no script URL.
68 // Page JS goes through set_scripts. core/html is kept only as the
69 // converter's embed: exactly one bare https <iframe> (maps, video).
70 //
71 // Markup is read the way the browser will read it: parsed in an inert
72 // document (no browsing context, so nothing runs or loads), never matched
73 // with a regex. Rich-text strings are HTML fragments and parse as such.
74 function inertBody( html ) {
75 const doc = document.implementation.createHTMLDocument( '' );
76 doc.body.innerHTML = html;
77 return doc.body;
78 }
79 function urlProtocol( value ) {
80 try {
81 return new URL( String( value ), 'https://x.invalid' ).protocol;
82 } catch ( e ) {
83 return '';
84 }
85 }
86 const MARKUP_URL_ATTRS = [ 'href', 'src', 'action', 'formaction', 'xlink:href', 'to', 'from', 'values' ];
87 // noscript parses as markup here but as raw text on a live page, so its
88 // contents cannot be read the way the browser will read them.
89 const UNSAFE_TAGS = {
90 script: true, object: true, embed: true, base: true, meta: true,
91 noscript: true, template: true, style: true, link: true, form: true, xmp: true,
92 };
93 function unsafeUrlAttr( tag, name, value ) {
94 const proto = urlProtocol( value );
95 if ( proto === 'javascript:' || proto === 'vbscript:' ) {
96 return true;
97 }
98 // data: only as an image source on an element that cannot run it.
99 return proto === 'data:' && ! ( name === 'src' && tag !== 'iframe' && tag !== 'frame' &&
100 /^\s*data:image\//i.test( value ) );
101 }
102 // An <iframe> is allowed only as the core/html embed (allowIframe).
103 function unsafeElements( root, allowIframe ) {
104 const els = root.querySelectorAll( '*' );
105 for ( let i = 0; i < els.length; i++ ) {
106 const el = els[ i ];
107 const tag = el.tagName.toLowerCase();
108 if ( UNSAFE_TAGS[ tag ] || ( tag === 'iframe' && ! allowIframe ) ) {
109 return true;
110 }
111 for ( let j = 0; j < el.attributes.length; j++ ) {
112 const n = el.attributes[ j ].name.toLowerCase();
113 if ( /^on/.test( n ) || n === 'srcdoc' ||
114 ( MARKUP_URL_ATTRS.indexOf( n ) !== -1 && unsafeUrlAttr( tag, n, el.attributes[ j ].value ) ) ) {
115 return true;
116 }
117 }
118 }
119 return false;
120 }
121 function markupIsUnsafe( html, allowIframe ) {
122 return typeof html === 'string' && html.indexOf( '<' ) !== -1 && unsafeElements( inertBody( html ), allowIframe );
123 }
124 function isEventHandlerKey( k ) {
125 return /^on[a-z]+$/i.test( k ) && typeof window !== 'undefined' && !! window.HTMLElement &&
126 k.toLowerCase() in window.HTMLElement.prototype;
127 }
128 function unsafeValue( v, depth, allowIframe ) {
129 if ( typeof v === 'string' ) {
130 return markupIsUnsafe( v, allowIframe );
131 }
132 if ( ! v || typeof v !== 'object' || depth > 8 ) {
133 return false;
134 }
135 if ( typeof v.toHTMLString === 'function' ) {
136 return markupIsUnsafe( v.toHTMLString(), allowIframe );
137 }
138 return Object.keys( v ).some( function ( k ) {
139 // A URL-valued attr (linkURL, href, url, src…) holding a script URL.
140 if ( typeof v[ k ] === 'string' && /url|href|link|src/i.test( k ) ) {
141 const proto = urlProtocol( v[ k ] );
142 if ( proto === 'javascript:' || proto === 'vbscript:' ) {
143 return true;
144 }
145 }
146 return isEventHandlerKey( k ) || unsafeValue( v[ k ], depth + 1, allowIframe );
147 } );
148 }
149 // Exactly one bare https <iframe>: no other node beside it but whitespace,
150 // no srcdoc / on* attribute, and only EMBED_ALLOW features in `allow`.
151 const EMBED_ALLOW = {
152 autoplay: true, 'encrypted-media': true, 'picture-in-picture': true, fullscreen: true,
153 accelerometer: true, gyroscope: true, 'clipboard-write': true, 'web-share': true,
154 };
155 function embedAllowOk( allow ) {
156 return String( allow || '' ).split( ';' ).every( function ( d ) {
157 const feature = d.trim().split( /\s+/ )[ 0 ].toLowerCase();
158 return feature === '' || EMBED_ALLOW[ feature ] === true;
159 } );
160 }
161 function isIframeEmbed( html ) {
162 const body = inertBody( html );
163 const el = body.firstElementChild;
164 if ( body.childElementCount !== 1 || el.tagName !== 'IFRAME' || el.childElementCount !== 0 ) {
165 return false;
166 }
167 for ( let i = 0; i < body.childNodes.length; i++ ) {
168 const n = body.childNodes[ i ];
169 if ( n !== el && ! ( n.nodeType === 3 && n.textContent.trim() === '' ) ) {
170 return false;
171 }
172 }
173 for ( let j = 0; j < el.attributes.length; j++ ) {
174 const n = el.attributes[ j ].name.toLowerCase();
175 if ( /^on/.test( n ) || n === 'srcdoc' ) {
176 return false;
177 }
178 }
179 let proto = '';
180 try {
181 proto = new URL( el.getAttribute( 'src' ) || '' ).protocol;
182 } catch ( e ) {
183 return false;
184 }
185 return proto === 'https:' && embedAllowOk( el.getAttribute( 'allow' ) );
186 }
187 function assertSafeContent( name, attributes ) {
188 const html = attributes && attributes.content;
189 // eslint-disable-next-line eqeqeq
190 if ( name === 'core/html' && html != null && html !== '' && ! isIframeEmbed( String( html ) ) ) {
191 throw new Error( 'unsafe_content: core/html is not allowed. Build the content from registered blocks.' );
192 }
193 if ( unsafeValue( attributes, 0, name === 'core/html' ) ) {
194 throw new Error( 'unsafe_content: ' + name + ' carries a <script>, <style>, <link>, <form>, <noscript>, <template> or <iframe>, an inline on* handler or a javascript: URL. Page JS goes through set_scripts.' );
195 }
196 }
197 function assertSafeParsedTree( block ) {
198 assertSafeContent( block.name, block.attributes );
199 if ( markupIsUnsafe( block.originalContent, block.name === 'core/html' ) ) {
200 throw new Error( 'unsafe_content: ' + block.name + ' carries a <script>, <style>, <link>, <form>, <noscript>, <template> or <iframe>, an inline on* handler or a javascript: URL.' );
201 }
202 ( block.innerBlocks || [] ).forEach( assertSafeParsedTree );
203 }
204
205 // Recursively strip banned visual attrs from an already-PARSED block tree
206 // (wp.blocks.parse output). The section_markup insert path bypasses toBlock,
207 // so this is where the block path's per-block stripBannedAttrs is reapplied —
208 // and the ONLY guard on that path (the vibe-editor door never reaches
209 // @bsf/wp-importer's fail-closed validator). Mutates each block's attributes.
210 function stripBannedAttrsDeep( block ) {
211 if ( ! block || typeof block !== 'object' ) {
212 return;
213 }
214 if ( block.attributes ) {
215 block.attributes = stripBannedAttrs( block.attributes );
216 }
217 if ( Array.isArray( block.innerBlocks ) ) {
218 block.innerBlocks.forEach( stripBannedAttrsDeep );
219 }
220 }
221
222 // Shared editor utilities — resolved at call time from the ONE source
223 // (editor/shared/editor-shared-utils.js): window in the browser, require()
224 // under jest. currentPostId (post_id guard) lives there so this handler and
225 // get-context can never drift. A missing module degrades consistently (null)
226 // — never a spurious post_id refusal.
227 function sharedEditorUtils() {
228 if ( typeof window !== 'undefined' && window.zipwpEditorShared ) {
229 return window.zipwpEditorShared;
230 }
231 if ( typeof require === 'function' ) {
232 try {
233 return require( '../shared/editor-shared-utils.js' );
234 } catch ( e ) {
235 return null;
236 }
237 }
238 return null;
239 }
240 function currentPostId() {
241 const u = sharedEditorUtils();
242 return u && u.currentPostId ? u.currentPostId() : null;
243 }
244
245 // Liveness (GAP-C / G34): the clientId must resolve against the live tree.
246 // Throws a typed miss so the caller records it in failed[] and never mutates
247 // the wrong block (a stale id must NEVER default to "top").
248 function liveness( sel, id ) {
249 const block = sel.getBlock( id );
250 if ( ! block ) {
251 throw new Error( 'stale_client_id:' + id );
252 }
253 return block;
254 }
255
256 // Block-safety guard (SCENARIO-003): a MUTATING op must not touch a block the
257 // editor has LOCKED, nor escape THIS page by editing synced-pattern
258 // (core/block) inner content or template-locked content. Gutenberg's own lock
259 // selectors are authoritative — canEditBlock / canRemoveBlock / canMoveBlock
260 // already fold in template lock, content lock, and synced-pattern instance
261 // locks (the instance's inner blocks are content-locked). A blocked op throws
262 // a typed miss → failed[], so the brain surfaces it (and can tell the user)
263 // instead of silently clobbering shared/locked content. Degrades to ALLOW when
264 // a selector is absent (older Gutenberg) — never a false block.
265 const MUTATION_KIND = {
266 updateBlockAttributes: 'edit',
267 removeBlocks: 'remove',
268 replaceBlocks: 'remove',
269 replaceInnerBlocks: 'edit',
270 moveBlocksToPosition: 'move',
271 };
272 function assertMutable( sel, id, kind, structural ) {
273 if ( ! id || ! sel.getBlock( id ) ) {
274 return;
275 } // stale / missing — liveness owns that miss
276 const can = kind === 'remove' ? sel.canRemoveBlock
277 : kind === 'move' ? sel.canMoveBlock
278 : sel.canEditBlock;
279 if ( typeof can === 'function' && can.call( sel, id ) === false ) {
280 throw new Error( 'locked_block:' + id );
281 }
282 if ( ! isAiEditable( sel, id, structural ) ) {
283 throw new Error( 'locked_block:' + id );
284 }
285 }
286
287 // Shared-content / editing-mode guard (editor-shared-utils, also read by
288 // set-scripts and Quick Edit). Missing module → allow, like the lock guards.
289 function isAiEditable( sel, id, structural ) {
290 const u = sharedEditorUtils();
291 return u && u.isAiEditable ? u.isAiEditable( sel, id, structural ) : true;
292 }
293 function isAiInsertableRoot( sel, root ) {
294 const u = sharedEditorUtils();
295 return u && u.isAiInsertableRoot ? u.isAiInsertableRoot( sel, root ) : true;
296 }
297
298 // Insert-time lock guard (GBR-2): insertBlocks / duplicateBlocks ADD a block to
299 // a CONTAINER — a case canEdit/Remove/Move (assertMutable) does NOT cover, so
300 // those two ops previously bypassed the lock check. getTemplateLock on the
301 // destination root is authoritative: 'all' (fully locked) and 'insert' (no
302 // add/remove/move) both forbid the insertion. Throws a typed miss → failed[] so
303 // the brain surfaces it instead of relying on the dispatch to reject. Degrades
304 // to ALLOW when the selector is absent (older Gutenberg) — never a false block.
305 function assertInsertable( sel, root ) {
306 // eslint-disable-next-line eqeqeq
307 const lock = typeof sel.getTemplateLock === 'function' ? sel.getTemplateLock( root == null ? '' : root ) : false;
308 if ( lock === 'all' || lock === 'insert' || lock === 'contentOnly' || ! isAiInsertableRoot( sel, root ) ) {
309 throw new Error( 'locked_block:' + ( root || 'root' ) );
310 }
311 }
312
313 // ALLOWED-CHILD guard. A parent may restrict which block types it accepts
314 // (`allowedBlocks` in block.json — spectra/buttons takes ONLY spectra/button).
315 // Gutenberg does not throw on a rejected child: `insertBlocks` mints a clientId
316 // and silently drops the block, so the reply carried `new_client_ids` and the
317 // structural check then reported `landed:false` with no reason attached. Turn
318 // 469d8aa1: "add a paragraph below for disclaimer" with a Button selected
319 // inserted a spectra/content NEXT TO that button — inside spectra/buttons — so
320 // it could never land. It was retried once, failed identically, and the turn
321 // told the user "The disclaimer paragraph has been added below the buttons."
322 //
323 // Fail LOUD instead, and hand back the fact the model needs to place it well:
324 // which type was refused, by which parent, and the nearest ancestor that WOULD
325 // take it. The model decides where it belongs — or asks the user — rather than
326 // guessing again into the same wall. Degrades to ALLOW when the editor doesn't
327 // expose canInsertBlockType, matching assertInsertable's posture.
328 function acceptingAncestor( sel, root, name ) {
329 if ( typeof sel.getBlockParents !== 'function' ) {
330 return null;
331 }
332 // Nearest ancestor first (getBlockParents returns top-first), then the
333 // page root — one selector call, no arbitrary hop cap.
334 const chain = root ? sel.getBlockParents( root ).slice().reverse() : [];
335 chain.push( '' ); // page root
336 for ( let i = 0; i < chain.length; i++ ) {
337 const parent = chain[ i ];
338 if ( sel.canInsertBlockType( name, parent || undefined ) ) {
339 return parent || 'root';
340 }
341 }
342 return null;
343 }
344 function assertAllowedChildren( sel, root, blocks ) {
345 if ( typeof sel.canInsertBlockType !== 'function' ) {
346 return;
347 }
348 // eslint-disable-next-line eqeqeq
349 const target = root == null ? undefined : root;
350 for ( let i = 0; i < blocks.length; i++ ) {
351 const name = blocks[ i ] && blocks[ i ].name;
352 if ( ! name || sel.canInsertBlockType( name, target ) ) {
353 continue;
354 }
355 const parentBlock = target ? sel.getBlock( target ) : null;
356 const parentName = parentBlock ? parentBlock.name : 'the page root';
357 const where = acceptingAncestor( sel, target, name );
358 throw new Error(
359 'child_not_allowed:' + name + ' cannot go inside ' + parentName +
360 ( where ? ' — the nearest container that accepts it is ' + where : '' ) +
361 '. Place it there, or ask the user where they want it.'
362 );
363 }
364 }
365
366 // M2 — move-DESTINATION lock guard. canMoveBlock (assertMutable 'move')
367 // folds in the SOURCE parent's lock only; moving INTO a fully-locked
368 // container bypassed every check. templateLock semantics: 'all' forbids any
369 // structural change inside the container; 'insert' forbids add/remove but
370 // PERMITS moving existing children, so only 'all' blocks a move-in here.
371 // Same degrade-to-ALLOW posture as assertInsertable.
372 function assertMoveDestination( sel, root ) {
373 // eslint-disable-next-line eqeqeq
374 const lock = typeof sel.getTemplateLock === 'function' ? sel.getTemplateLock( root == null ? '' : root ) : false;
375 if ( lock === 'all' || lock === 'contentOnly' || ! isAiInsertableRoot( sel, root ) ) {
376 throw new Error( 'locked_block:' + ( root || 'root' ) );
377 }
378 }
379
380 // ── Subtree scope-lock (selection confinement — SaaS remediation ①) ───────
381 // The brain stamps `scope_lock` (the selected container's clientId) on the
382 // envelope ONLY when the turn bound a SUBTREE scope (a CONTAINER is selected
383 // and the request is NOT page-wide). Every MUTATING op must then act on the
384 // locked container or a DESCENDANT of it — editing / inserting / moving into
385 // a DIFFERENT section is refused (→ failed[], so the brain surfaces it and
386 // can tell the user). This is the tree-aware AIRTIGHT half of "stay inside
387 // the selected element"; the brain gate is the soft (outline-only) half.
388 // selectBlock is exempt (navigation, not a mutation). Degrades to ALLOW when
389 // getBlockParents is absent (older Gutenberg) — never a false block; the
390 // caller also drops a stale (non-live) lock id before enforcing.
391 const SCOPE_EXEMPT_FUNCTIONS = { selectBlock: true };
392 // `id` is undefined/null when a field simply wasn't provided (no constraint
393 // from it); '' is the PAGE ROOT (Gutenberg's own rootClientId convention)
394 // and must NOT short-circuit true — the page root is never inside a
395 // subtree lock.
396 function isWithinScope( sel, id, lockId ) {
397 if ( id === undefined || id === null || id === lockId ) {
398 return true;
399 }
400 if ( id === '' ) {
401 return false;
402 }
403 if ( typeof sel.getBlockParents !== 'function' ) {
404 return true;
405 } // can't tell → allow
406 const parents = sel.getBlockParents( id ) || [];
407 return parents.indexOf( lockId ) !== -1;
408 }
409 // Every clientId a mutating op acts ON or places relative to (the surface a
410 // "roam" would land on): clientIds[], order[], rootClientId, toRootClientId,
411 // and the before/after placement anchors.
412 function scopeGoverningIds( op ) {
413 let ids = [];
414 if ( Array.isArray( op.clientIds ) ) {
415 ids = ids.concat( op.clientIds );
416 }
417 if ( Array.isArray( op.order ) ) {
418 ids = ids.concat( op.order );
419 }
420 [ 'rootClientId', 'toRootClientId', 'before', 'after', 'clientId' ].forEach( function ( k ) {
421 // eslint-disable-next-line eqeqeq
422 if ( op[ k ] != null ) {
423 ids.push( op[ k ] );
424 }
425 } );
426 return ids.filter( function ( x ) {
427 return typeof x === 'string' && x !== '';
428 } );
429 }
430 // The destination container insertBlocks/moveBlocksToPosition will ACTUALLY
431 // land in — mirrors runOp's own resolution exactly (an anchor resolves to
432 // its PARENT via resolveAnchor; an omitted INSERT root defaults to the PAGE
433 // ROOT, ''; an omitted MOVE destination keeps the target's current parent,
434 // so it imposes no destination constraint) — so the scope check can never
435 // see a different landing spot
436 // than the one that really applies. scopeGoverningIds() above checks the
437 // RAW anchor/root id (is the id itself in scope); this checks where that
438 // id/omission actually RESOLVES to, which is a different question for two
439 // reasons: an anchor's parent can be outside scope even when the anchor
440 // itself is in scope (before/after the locked container = its parent, one
441 // level UP), and an OMITTED destination has no raw field for
442 // scopeGoverningIds() to flag at all. What an omission resolves to now
443 // depends on the op: an omitted INSERT root is the page root (never in
444 // scope); an omitted MOVE keeps each target's current parent, so it adds no
445 // destination constraint; an omitted `order` parent is the one those blocks
446 // share, which IS resolved here and scope-checked for real.
447 function resolvedDestinationOf( sel, op ) {
448 const anchor = anchorOf( op );
449 if ( anchor ) {
450 if ( ! anchor.id || ! sel.getBlock( anchor.id ) ) {
451 return undefined;
452 } // stale anchor — liveness() surfaces this separately
453 return resolveAnchor( sel, anchor.id, anchor.position ).parent;
454 }
455 if ( op.function === 'insertBlocks' ) {
456 // eslint-disable-next-line eqeqeq
457 return op.rootClientId == null ? '' : op.rootClientId;
458 }
459 if ( op.function === 'moveBlocksToPosition' ) {
460 // Mirrors runOp's destination resolution EXACTLY — the invariant this
461 // helper exists for. Explicit null = page root. Omitted:
462 // • the `order` form re-orders inside the parent those blocks SHARE, so
463 // resolve it (orderParentOf) and scope-check it for real. Returning
464 // undefined here would skip the check while runOp still landed
465 // somewhere concrete — the exact drift this helper must not have.
466 // • the targeted form keeps each target in its OWN parent
467 // (KEEP_CURRENT_PARENT), which imposes no single destination, and the
468 // targets themselves are already checked by scopeGoverningIds.
469 if ( op.toRootClientId === null ) {
470 return '';
471 }
472 if ( op.toRootClientId === undefined ) {
473 return op.order ? orderParentOf( sel, op.order ) : undefined;
474 }
475 return op.toRootClientId;
476 }
477 if ( op.function === 'duplicateBlocks' ) {
478 // A duplicate lands as a SIBLING of the original, so its destination is
479 // the original's PARENT. Without this, duplicating the locked container
480 // itself passed the raw-id check (id === lockId) but the copy escaped to
481 // the lock's parent, outside scope.
482 const first = op.clientIds && op.clientIds[ 0 ];
483 return first ? ( sel.getBlockRootClientId( first ) || '' ) : undefined;
484 }
485 return undefined; // op has no destination-shaped field
486 }
487 // `lockId` is one container clientId, or the members of a multi-selection:
488 // an id is in scope when it sits in ANY member's subtree.
489 function assertWithinScope( sel, op, lockId ) {
490 // Applying a PICKED section — the brain stamps `section_add` on every op
491 // whose section_ref it resolved, whether that lands as a page-level ADD
492 // (insertBlocks) or as a redesign of an existing one (replaceBlocks /
493 // replaceInnerBlocks). Exempt from the subtree lock, exactly as the brain's
494 // selection-binding guard does: the picker PINNED its target when it opened,
495 // so a click elsewhere while the previews rendered must not redirect the
496 // replace. Narrow by design — the marker rides only a brain-resolved ref;
497 // free-hand edits, moves, and inline-`blocks` inserts stay confined.
498 const locks = Array.isArray( lockId ) ? lockId : ( lockId ? [ lockId ] : [] );
499 if ( locks.length === 0 || SCOPE_EXEMPT_FUNCTIONS[ op.function ] || op.section_add === true ) {
500 return;
501 }
502 const inScope = function ( id ) {
503 return locks.some( function ( l ) {
504 return isWithinScope( sel, id, l );
505 } );
506 };
507 const label = locks.join( ', ' );
508 const ids = scopeGoverningIds( op );
509 if ( ids.length === 0 ) {
510 // A mutating op with no resolved target = a page-root op (e.g. insert
511 // at root). That lands OUTSIDE the selected container.
512 throw new Error( 'out_of_scope: this edit has no in-section target (it would land at the page root), but you are scoped to the selected container ' + label + ' — insert/edit INSIDE it, or tell the user if a different section is intended.' );
513 }
514 for ( let i = 0; i < ids.length; i++ ) {
515 if ( ! inScope( ids[ i ] ) ) {
516 throw new Error( 'out_of_scope: target ' + ids[ i ] + ' is outside the selected container ' + label + ' (and its children). You are scoped to that section — edit inside it, or tell the user if a different section is intended.' );
517 }
518 }
519 const dest = resolvedDestinationOf( sel, op );
520 // Duplicating the SELECTED container itself lands the copy as its sibling
521 // (in the parent, one level outside the lock) — that IS the user's intent
522 // when they select a section and say "duplicate this". Exempt ONLY that
523 // exact case (the first duplicated id is the lock root, matching how
524 // resolvedDestinationOf derives `dest`); every other out-of-scope
525 // destination — a DIFFERENT block copied outside — still throws.
526 const duplicatingLockRoot = op.function === 'duplicateBlocks' &&
527 Array.isArray( op.clientIds ) && locks.indexOf( op.clientIds[ 0 ] ) !== -1;
528 if ( dest !== undefined && ! duplicatingLockRoot && ! inScope( dest ) ) {
529 throw new Error( 'out_of_scope: this would land at ' + ( dest === '' ? 'the page root' : dest ) + ', outside the selected container ' + label + ' — insert/move INSIDE it, or tell the user if a different section is intended.' );
530 }
531 }
532
533 // The block's editable-content attribute(s), from the block REGISTRY — no
534 // hardcoded per-type table. Core blocks declare content via a `source` of
535 // 'rich-text'/'html' (core/heading→content, core/button→text). Spectra blocks
536 // store content in a CUSTOM attribute with no standard source, so we fall
537 // back to a declared `content`/`text` attribute (the Spectra convention,
538 // e.g. spectra/content→text). Block types with neither (image, spacer) get
539 // an empty list and are left untouched.
540 function richTextAttrsOf( name ) {
541 const t = window.wp.blocks && window.wp.blocks.getBlockType
542 ? window.wp.blocks.getBlockType( name )
543 : null;
544 const attrs = t && t.attributes ? t.attributes : {};
545 const out = [];
546 for ( const k in attrs ) {
547 if ( ! Object.prototype.hasOwnProperty.call( attrs, k ) ) {
548 continue;
549 }
550 const src = attrs[ k ] && attrs[ k ].source;
551 if ( src === 'rich-text' || src === 'html' ) {
552 out.push( k );
553 }
554 }
555 if ( out.length === 0 ) {
556 [ 'content', 'text' ].forEach( function ( k ) {
557 if ( Object.prototype.hasOwnProperty.call( attrs, k ) ) {
558 out.push( k );
559 }
560 } );
561 }
562 return out;
563 }
564
565 // The block's REGISTRY attribute schema — the SSOT for which attrs are valid
566 // on a given block type. getBlockType(name).attributes is the exact set
567 // Gutenberg itself validates against; an attr absent from this set is one
568 // updateBlockAttributes would silently ignore. Returns null when the type
569 // isn't registered (a forked/unknown block) — caller then can't partition, so
570 // it applies everything (degrade open, never block a write on a missing type).
571 function registeredAttrKeysOf( name ) {
572 const t = window.wp.blocks && window.wp.blocks.getBlockType
573 ? window.wp.blocks.getBlockType( name )
574 : null;
575 if ( ! t || ! t.attributes ) {
576 return null;
577 }
578 return Object.keys( t.attributes );
579 }
580
581 // Partition an incoming attrs object against the block's REGISTRY schema:
582 // { valid, unknown }. `valid` is the subset whose keys exist in
583 // getBlockType(name).attributes (the keys Gutenberg will actually accept);
584 // `unknown` is the keys with no schema entry (Gutenberg would silently drop
585 // them). `className` is always valid (Gutenberg's universal block-support
586 // attr, not always re-declared per type). When the type isn't registered we
587 // can't partition — return everything as valid (degrade open). Surfacing
588 // `unknown` in the reply lets the brain LEARN that attr X isn't valid for
589 // block Y, instead of inferring success from a silent ignore.
590 function partitionAttrs( name, attrs ) {
591 const valid = {};
592 const unknown = [];
593 if ( ! isPlainObject( attrs ) ) {
594 return { valid, unknown };
595 }
596 const registered = registeredAttrKeysOf( name );
597 Object.keys( attrs ).forEach( function ( k ) {
598 if ( registered === null || k === 'className' || registered.indexOf( k ) !== -1 ) {
599 valid[ k ] = attrs[ k ];
600 } else {
601 unknown.push( k );
602 }
603 } );
604 return { valid, unknown };
605 }
606
607 // Route a generic content/text value onto the block's real rich-text attr(s).
608 // `content`/`text` are universal authoring ALIASES — the model writes one and
609 // we copy it onto whatever rich-text attr the block actually declares (e.g.
610 // spectra/content -> `text`, core/heading -> `content`). After routing, DROP
611 // any consumed alias the block does NOT itself declare, so a `content` the
612 // model sent for a `text`-block isn't later flagged as an unknown attr (it was
613 // applied, just under the real key) — a false-positive that would mis-fire the
614 // unknown_attrs nudge.
615 // Rich-text attrs that hold an ATTRIBUTION rather than the block's body copy, so
616 // a generic `content`/`text` rewrite must never be routed into them (core/quote
617 // and core/pullquote declare `citation` alongside `value`).
618 const ATTRIBUTION_RICH_TEXT_ATTRS = [ 'citation' ];
619
620 function placeContent( name, attributes ) {
621 const a = Object.assign( {}, attributes || {} );
622 const body = typeof a.content === 'string' && a.content !== '' ? a.content
623 : typeof a.text === 'string' && a.text !== '' ? a.text : undefined;
624 if ( body === undefined ) {
625 return a;
626 }
627 const targets = richTextAttrsOf( name );
628 // Route to ONE attr, never every rich-text attr the block declares. A block
629 // with two (core/quote and core/pullquote both declare `value` AND `citation`
630 // as source:html) got the SAME body copied into both, so "rewrite this quote"
631 // silently overwrote the attribution with the quote text — a content loss that
632 // reports clean, on both the insert and update paths.
633 // Prefer the alias the MODEL actually wrote when the block declares it, else
634 // the block's first NON-ATTRIBUTION rich-text attr. For every
635 // single-rich-text-attr block this resolves to exactly what the fan-out
636 // produced, so nothing else moves.
637 const usedAlias = typeof a.content === 'string' && a.content !== '' ? 'content' : 'text';
638 let primary;
639 if ( targets.indexOf( usedAlias ) !== -1 ) {
640 primary = usedAlias;
641 } else {
642 // Taking targets[0] alone would trust REGISTRY ENUMERATION ORDER to put the
643 // body attr ahead of the attribution attr. core/quote and core/pullquote
644 // happen to declare `value` first, so it is right for them — but a block
645 // (third-party, or a future core revision) declaring the attribution first
646 // would route the rewrite body straight into it: the same silent
647 // content-swap this routing exists to prevent, one block type over. Skip
648 // the known attribution attrs explicitly instead of relying on position.
649 for ( let ti = 0; ti < targets.length; ti++ ) {
650 if ( ATTRIBUTION_RICH_TEXT_ATTRS.indexOf( targets[ ti ] ) === -1 ) {
651 primary = targets[ ti ];
652 break;
653 }
654 }
655 // Every rich-text attr IS an attribution attr (no body attr on this block)
656 // — fall back to the first so the write still lands somewhere rather than
657 // being silently dropped.
658 if ( primary === undefined ) {
659 primary = targets[ 0 ];
660 }
661 }
662 if ( primary !== undefined && ( a[ primary ] === undefined || a[ primary ] === '' ) ) {
663 a[ primary ] = body;
664 }
665 [ 'content', 'text' ].forEach( function ( alias ) {
666 if ( targets.indexOf( alias ) === -1 ) {
667 delete a[ alias ];
668 }
669 } );
670 return a;
671 }
672
673 // Plain text the block renders — its rich-text attrs concatenated, tags
674 // stripped. Used for the effectiveness check + the result_text the reply
675 // surfaces so the brain SEES what actually landed.
676 function renderedTextOf( block ) {
677 const attrs = ( block && block.attributes ) || {};
678 let s = '';
679 richTextAttrsOf( block && block.name ).forEach( function ( k ) {
680 // Core rich-text attrs become RichTextData OBJECTS after createBlock
681 // (not strings); String() yields their text. Spectra stores plain
682 // strings. A `typeof === "string"` check misses the core case and
683 // falsely fires assertEffectiveContent's empty_content (live-found).
684 const v = attrs[ k ];
685 if ( v !== null && v !== undefined && v !== '' ) {
686 s += ' ' + String( v );
687 }
688 } );
689 return s.replace( /<[^>]*>/g, '' ).trim();
690 }
691
692 function intendedContent( spec ) {
693 const a = ( spec && spec.attributes ) || {};
694 return ( typeof a.content === 'string' && a.content !== '' ) ||
695 ( typeof a.text === 'string' && a.text !== '' );
696 }
697
698 // A CONTENT rewrite that would DROP inline markup — the "make it 4/5" flatten.
699 // True when the block's CURRENT value carries real inline markup (a tag, not a
700 // bare entity) and the incoming value is PLAIN text. get-context surfaces the
701 // rich `html` precisely so a rewrite re-authors the SAME inline structure; a
702 // plain string instead silently strips the design pattern (the large number's
703 // styling, an emphasis span, per-word colours). PURE so a unit test locks it.
704 const CONTENT_TAG_RE = /<[a-z!/][^>]*>/i;
705 function contentWouldFlatten( oldVal, newVal ) {
706 // Only a PLAIN-text overwrite can flatten. A core RichTextData old value is
707 // an object whose String() yields its HTML — coerce so both block families
708 // are covered; the incoming model value is always a plain JSON string.
709 if ( typeof newVal !== 'string' ) {
710 return false;
711 }
712 const oldStr = ( oldVal === null || oldVal === undefined ) ? '' : String( oldVal );
713 return CONTENT_TAG_RE.test( oldStr ) && ! CONTENT_TAG_RE.test( newVal );
714 }
715
716 // Fail CLOSED before dispatch when an updateBlockAttributes content write would
717 // flatten the block's existing inline markup. The message steers recovery to
718 // the correct path (re-author the rich `html` from get_context, keeping the
719 // inline structure) rather than letting a lossy plain-text write report a false
720 // success. Symmetric with assertEffectiveContent's empty_content guard.
721 function assertNoContentFlatten( block, normAttrs ) {
722 if ( ! block || ! normAttrs ) {
723 return;
724 }
725 const contentKeys = richTextAttrsOf( block.name );
726 const current = block.attributes || {};
727 contentKeys.forEach( function ( key ) {
728 if ( ! Object.prototype.hasOwnProperty.call( normAttrs, key ) ) {
729 return;
730 }
731 if ( contentWouldFlatten( current[ key ], normAttrs[ key ] ) ) {
732 throw new Error(
733 'content_flatten:' + block.name + ':' + key +
734 '. This block\'s content carries inline formatting that a plain-text value would DROP' +
735 ' (the design pattern: styled spans / emphasis). Re-read the block with editor__get_context' +
736 ' and rewrite its `html` (the rich value) with your new words INSIDE the same inline structure,' +
737 ' not a flat string.',
738 );
739 }
740 } );
741 }
742
743 // A spec that ASKED for text content but produced an EMPTY block is a silent
744 // content loss — fail it LOUD (before dispatch) so the brain never reports a
745 // false success on an empty insert/replace. Generalized: it compares INTENT
746 // (the spec carried content) against RESULT (the built block renders no text
747 // and has no children) — never a per-block-type rule.
748 function assertEffectiveContent( specs, blocks ) {
749 ( specs || [] ).forEach( function ( spec, i ) {
750 const blk = blocks[ i ];
751 if ( ! intendedContent( spec ) || ! blk ) {
752 return;
753 }
754 const hasChildren = blk.innerBlocks && blk.innerBlocks.length > 0;
755 // An iframe embed (core/html) carries no text but is the content.
756 const isEmbed = blk.name === 'core/html' && isIframeEmbed( String( ( blk.attributes || {} ).content || '' ) );
757 if ( renderedTextOf( blk ) === '' && ! hasChildren && ! isEmbed ) {
758 throw new Error( 'empty_content:' + ( spec.name || 'block' ) );
759 }
760 } );
761 }
762
763 // JSON block-spec → Gutenberg block (recursive). NOT the markup-string path.
764 // Applies ONLY the attrs the block's registry declares (partitionAttrs, the
765 // getBlockType SSOT) and pushes the keys it doesn't into `unknownSink` (when
766 // provided) — recursively across innerBlocks. The brain folds every attr
767 // through with no allowlist; this is where insert/replace decide validity and
768 // report back what they dropped, exactly like the setAttributes path (real
769 // Gutenberg's createBlock already sanitizes undeclared attrs away — silently;
770 // collecting them here is what makes the drop visible to the brain).
771 function toBlock( spec, unknownSink, iconSink ) {
772 // Insert-time block-name gate (SaaS remediation plan E2E-F1,
773 // live-observed 2026-06-11): an UNREGISTERED name must fail the op
774 // typed-and-loud — createBlock would otherwise mint a missing-block
775 // placeholder ("Unsupported block") the user has to delete by hand
776 // (the model invented `core/input`/`core/textarea` and the page
777 // collected junk until read-back). Same feedback philosophy as
778 // unknown attrs: structured, model-actionable, never silent.
779 if ( ! ( window.wp.blocks &&
780 window.wp.blocks.getBlockType &&
781 window.wp.blocks.getBlockType( spec.name ) ) ) {
782 throw new Error(
783 'unregistered_block_type: "' + spec.name + '" is not a registered block on this site — ' +
784 'never invent block names. Use registered blocks (spectra/container, spectra/content, ' +
785 'core/paragraph, …) or embed a real form via { "name": "srfm/form", "attrs": { "id": <formId> } }.'
786 );
787 }
788 assertSafeContent( spec.name, spec.attributes );
789 const inner = ( spec.innerBlocks || [] ).map( function ( s ) {
790 return toBlock( s, unknownSink, iconSink );
791 } );
792 // S4 (PR #282): strip the 8 GBS-banned visual attrs FIRST, exactly like the
793 // updateBlockAttributes path (normalizePlanAttrs) — so insert/replace can't
794 // paint a banned visual attr into a freshly-minted block even if a stale/
795 // forked brain bundle folds one through. Defense-in-depth behind the brain
796 // blockSpec zod; symmetric with the update path.
797 const parts = partitionAttrs( spec.name, placeContent( spec.name, stripBannedAttrs( spec.attributes ) ) );
798 if ( unknownSink && parts.unknown.length ) {
799 Array.prototype.push.apply( unknownSink, parts.unknown );
800 }
801 // Icon-name resolution — same boundary rule as the update path, so a
802 // freshly-minted block can't be born with an inert icon value either.
803 resolveIconAttrs( parts.valid, spec.name, iconSink );
804 return window.wp.blocks.createBlock( spec.name, parts.valid, inner );
805 }
806
807 // updateBlockAttributes shallow-merges at the TOP key only, so deep-merge
808 // plain-object values (layout, responsiveControls) to keep sibling nested keys.
809 function deepMerge( cur, partial ) {
810 const out = Object.assign( {}, cur );
811 for ( const k in partial ) {
812 if ( ! Object.prototype.hasOwnProperty.call( partial, k ) ) {
813 continue;
814 }
815 const pv = partial[ k ];
816 const cv = cur ? cur[ k ] : undefined;
817 out[ k ] = isPlainObject( pv ) && isPlainObject( cv ) ? deepMerge( cv, pv ) : pv;
818 }
819 return out;
820 }
821
822 // INVARIANT — gs-* identity is immutable on a className write. updateBlockAttributes
823 // replaces className wholesale, so an incoming string keeps the block's CURRENT gs-*
824 // tokens verbatim and contributes only its NON-gs (utility) tokens; foreign gs-* are
825 // dropped. Prevents the Edit-5 clobber (a paragraph's `gs-0a6168-text` overwritten by
826 // a heading's `gs-ae2ba6-h1`) — the executor owns identity, the model owns utilities.
827 function reconcileClassName( currentClassName, incomingClassName ) {
828 const isGs = function ( t ) {
829 return t.indexOf( 'gs-' ) === 0;
830 };
831 const toTokens = function ( s ) {
832 // eslint-disable-next-line eqeqeq
833 return String( s == null ? '' : s ).trim().split( /\s+/ ).filter( Boolean );
834 };
835 const curGs = toTokens( currentClassName ).filter( isGs );
836 const incUtil = toTokens( incomingClassName ).filter( function ( t ) {
837 return ! isGs( t );
838 } );
839 const seen = Object.create( null );
840 return curGs.concat( incUtil ).filter( function ( t ) {
841 if ( seen[ t ] ) {
842 return false;
843 }
844 seen[ t ] = true;
845 return true;
846 } ).join( ' ' );
847 }
848
849 // INVARIANT, INSERT SIDE — the same rule as reconcileClassName above, applied to
850 // blocks being CREATED. "The executor owns identity, the model owns utilities"
851 // held only for updateBlockAttributes, because that path has a CURRENT className
852 // to preserve. An insert has none, so identity was whatever the model typed — and
853 // a `gs-…` token is an opaque hash it has to hand-copy from a get_context read
854 // several steps earlier. Turn f5999cca: both existing buttons were
855 // `pep-button gs-238780-pep-button`; the authored one was `pep-button`. It landed
856 // near-black and pill-shaped beside its navy siblings, because the gs- token IS
857 // the styling and the readable half paints nothing.
858 //
859 // So: adopt identity from the nearest sibling of the SAME block name, walking the
860 // two subtrees in parallel. The model's non-gs (utility) tokens ride along exactly
861 // as they do on an update. No block-type knowledge, no prompt instruction to
862 // remember — a new peer simply cannot land without the design identity its
863 // neighbours carry. Degrades silently: no sibling, no match, or a divergent shape
864 // leaves the block exactly as authored.
865 // (Posture note: this executor-owned identity rewrite is the opposite of
866 // unknown_icons' apply-and-report — deliberate: gs- identity is the
867 // update-path invariant the executor owns; icon values are model content.)
868 function adoptSiblingIdentity( sel, root, blocks ) {
869 if (
870 typeof sel.getBlockOrder !== 'function' ||
871 typeof sel.getBlockName !== 'function' ||
872 typeof sel.getBlock !== 'function'
873 ) {
874 return;
875 }
876 // getBlockOrder + per-id name probe is O(siblings); getBlocks(root)
877 // materialized the ENTIRE destination tree (114-section pages) just to
878 // take the first same-name sibling.
879 // eslint-disable-next-line eqeqeq
880 const order = sel.getBlockOrder( root == null || root === '' ? undefined : root ) || [];
881 blocks.forEach( function ( b ) {
882 const matchId = order.find( function ( id ) {
883 return sel.getBlockName( id ) === b.name;
884 } );
885 if ( matchId ) {
886 adoptFrom( sel.getBlock( matchId ), b );
887 }
888 } );
889 }
890 function hasGsIdentity( block ) {
891 const cls = block && block.attributes && block.attributes.className;
892 return typeof cls === 'string' && /(^|\s)gs-/.test( cls );
893 }
894 function adoptFrom( from, to ) {
895 // Adoption supplies identity a block LACKS. A block that already carries
896 // its own `gs-` token is a DESIGNED artifact — a whole section off the
897 // resolved-markup path, whose persisted GBS rules are keyed to exactly
898 // that token. Overwriting it with a neighbour's (reconcileClassName keeps
899 // the CURRENT gs- and drops the incoming one) made the placed section and
900 // its whole subtree wear the neighbour's styling while its own rules
901 // matched nothing: add a designed section beside an existing
902 // same-type one and it rendered as the neighbour, not as the design the
903 // user chose. Returning here also stops the positional subtree walk,
904 // which is what carried the corruption down into the inner blocks.
905 if ( hasGsIdentity( to ) ) {
906 return;
907 }
908 const fromCls = from.attributes && from.attributes.className;
909 if ( typeof fromCls === 'string' && fromCls.indexOf( 'gs-' ) !== -1 ) {
910 to.attributes = to.attributes || {};
911 to.attributes.className = reconcileClassName( fromCls, to.attributes.className );
912 }
913 const fromKids = from.innerBlocks || [];
914 const toKids = to.innerBlocks || [];
915 for ( let i = 0; i < toKids.length; i++ ) {
916 // Pair by position, then by name — a divergent shape stops the walk.
917 const peer = fromKids[ i ] && fromKids[ i ].name === toKids[ i ].name
918 ? fromKids[ i ]
919 : fromKids.filter( function ( k ) {
920 return k && k.name === toKids[ i ].name;
921 } )[ 0 ];
922 if ( peer ) {
923 adoptFrom( peer, toKids[ i ] );
924 }
925 }
926 }
927
928 // Every listed block must actually be a child of `parent`. A re-order is only
929 // meaningful among SIBLINGS, and Gutenberg does NOT refuse a non-child: with
930 // from === to it takes its same-parent branch and computes
931 // `subState.indexOf(clientIds[0])`, which is -1 for a stranger. The resulting
932 // moveTo(order, -1, i) splices from the END, so the container's LAST child is
933 // displaced while the block the plan actually named never moves — and the op
934 // reports success. Refuse instead, on BOTH order paths: the derived parent
935 // (below) and the caller-supplied one, which had no check at all.
936 function assertOrderSiblings( sel, order, parent ) {
937 ( order || [] ).forEach( function ( id ) {
938 if ( ( sel.getBlockRootClientId( id ) || '' ) !== parent ) {
939 throw new Error(
940 'order_parent_mismatch: block ' + id + ' is not a child of the container being ' +
941 're-ordered, so there is no single container to re-order these in. Re-order only ' +
942 'siblings, or move them one at a time.'
943 );
944 }
945 } );
946 }
947
948 // The parent a bare `order` re-order should happen INSIDE — the parent the listed
949 // blocks already share. '' (the page root) is a legitimate answer for a top-level
950 // re-order. Falls back to '' only if the first id can't be resolved, which
951 // liveness() surfaces separately.
952 function orderParentOf( sel, order ) {
953 const ids = order || [];
954 const first = ids[ 0 ];
955 if ( ! first ) {
956 return '';
957 }
958 // Adopting the first id's parent when the others don't share it would
959 // RELOCATE them into it — the very re-parenting this resolution exists to
960 // prevent. (The brain now requires an explicit parent, so the derived path
961 // is only reachable from an older or hand-built envelope.)
962 const parent = sel.getBlockRootClientId( first ) || '';
963 assertOrderSiblings( sel, ids, parent );
964 return parent;
965 }
966
967 // Realize an explicit child order within `parent` (reorder). Place each id at
968 // its target index in sequence — earlier indices are already correct, so each
969 // move lands the next block at position i.
970 function reorderChildren( sel, dis, parent, order ) {
971 // M2 — the order form previously bypassed EVERY lock check (its targets
972 // ride `order`, not `clientIds`, so the runOp pre-check resolved no
973 // targets). canMoveBlock per child folds in the parent's template/content
974 // locks — a locked container's reorder now fails typed instead of
975 // silently applying.
976 order.forEach( function ( id ) {
977 liveness( sel, id );
978 assertMutable( sel, id, 'move', true );
979 } );
980 // Same omitted-vs-null distinction the move path makes (KEEP_CURRENT_PARENT).
981 // Collapsing both to '' re-parented EVERY listed block to the page root, so a
982 // plain "re-order these cards" un-nested the whole row. Explicit null still
983 // means the page root; omitted means "the parent these blocks already share".
984 let root;
985 if ( parent === null ) {
986 root = '';
987 assertOrderSiblings( sel, order, root );
988 } else if ( parent === undefined ) {
989 root = orderParentOf( sel, order );
990 } else {
991 root = parent;
992 // An EXPLICIT parent was trusted blindly, so a plan naming a container
993 // the blocks don't belong to silently mangled that container instead of
994 // failing (see assertOrderSiblings). Same check, same refusal.
995 assertOrderSiblings( sel, order, root );
996 }
997 for ( let i = 0; i < order.length; i++ ) {
998 // Undo coalescing: each move after the first merges into the same
999 // undo level so the whole reorder reverts in one ⌘Z. (The caller
1000 // already marked the first dispatch when this isn't the batch's
1001 // first change.)
1002 if ( i > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) {
1003 dis.__unstableMarkNextChangeAsNotPersistent();
1004 }
1005 dis.moveBlocksToPosition( [ order[ i ] ], root, root, i );
1006 }
1007 }
1008
1009 // L5 — THE single documented use of a private Gutenberg API in this
1010 // codebase. `__unstableMarkNextChangeAsNotPersistent` coalesces undo levels
1011 // (one ⌘Z per plan) and keeps typewriter ticks out of the undo stack.
1012 // DEGRADATION CONTRACT: it is feature-checked everywhere; if a future
1013 // Gutenberg removes/renames it, every call becomes a no-op and behaviour
1014 // degrades to MORE undo levels (one per dispatch) — correct, just noisier.
1015 // Nothing may ever depend on it for correctness, only for undo ergonomics.
1016 function markNonPersistent( dis ) {
1017 if ( dis && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) {
1018 dis.__unstableMarkNextChangeAsNotPersistent();
1019 }
1020 }
1021
1022 // `dis` whose first block-changing call merges into the previous undo level.
1023 const COALESCED_ACTIONS = [ 'updateBlockAttributes', 'insertBlocks', 'removeBlocks', 'moveBlocksToPosition',
1024 'replaceBlocks', 'replaceInnerBlocks', 'duplicateBlocks' ];
1025 function coalescingDispatch( dis ) {
1026 let armed = false;
1027 const out = Object.create( dis );
1028 COALESCED_ACTIONS.forEach( function ( fn ) {
1029 if ( typeof dis[ fn ] !== 'function' ) {
1030 return;
1031 }
1032 out[ fn ] = function () {
1033 if ( ! armed ) {
1034 armed = true;
1035 markNonPersistent( dis );
1036 }
1037 return dis[ fn ].apply( dis, arguments );
1038 };
1039 } );
1040 return out;
1041 }
1042
1043 // H4 — saving is LOCKED while a typewriter stream is live. The persistent
1044 // full text is applied by runOp BEFORE the stream starts, but the stream
1045 // then clears the attr and re-types it word-by-word — so for words×28ms the
1046 // LIVE edited attribute is partial. A manual Ctrl+S or the 60s autosave in
1047 // that window serializes the truncated text into the post (non-persistent
1048 // suppresses the undo level, NOT what getEditedPostContent reads). Lock
1049 // via the core/editor store while ANY stream is active; unlock on drain.
1050 // Feature-checked: an editor without lockPostSaving keeps today's window
1051 // (no false lock, no throw).
1052 const TYPEWRITER_SAVE_LOCK = 'zipwp-vibe-typewriter';
1053 function lockSavingForTypewriter() {
1054 try {
1055 const ed = window.wp && window.wp.data && window.wp.data.dispatch( 'core/editor' );
1056 if ( ed && typeof ed.lockPostSaving === 'function' ) {
1057 ed.lockPostSaving( TYPEWRITER_SAVE_LOCK );
1058 }
1059 } catch ( e ) { /* never break the apply on a lock failure */ }
1060 }
1061 function unlockSavingForTypewriter() {
1062 try {
1063 const ed = window.wp && window.wp.data && window.wp.data.dispatch( 'core/editor' );
1064 if ( ed && typeof ed.unlockPostSaving === 'function' ) {
1065 ed.unlockPostSaving( TYPEWRITER_SAVE_LOCK );
1066 }
1067 } catch ( e ) { /* unlock is best-effort; the lock name is idempotent */ }
1068 }
1069
1070 // --- Typewriter (streaming text effect) -------------------------------
1071 // ANY text the agent writes — a rewrite (updateBlockAttributes) OR new copy in
1072 // an inserted/replaced block — STREAMS in word-by-word instead of snapping.
1073 // Ms between words; named single cadence source.
1074 const TYPEWRITER_WORD_MS = 28;
1075
1076 // Animation off when the host can't animate (no matchMedia — e.g. jsdom under
1077 // jest) or the user prefers reduced motion. Keeps the persistent full content.
1078 function typewriterDisabled() {
1079 try {
1080 return ! window.matchMedia ||
1081 window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
1082 } catch ( e ) {
1083 return true;
1084 }
1085 }
1086
1087 // Current rich-text content of a block (the first content attr that HAS text),
1088 // '' if none. The op-agnostic text identity we diff before/after an op.
1089 function contentOf( blk ) {
1090 const cattr = firstRichTextWithContent( blk );
1091 return cattr ? String( blk.attributes[ cattr ] ) : '';
1092 }
1093
1094 // Every clientId an op NAMES as an operand — generic id-bearing fields ONLY, no
1095 // switch on op.function. The before/after text diff over these (+ newly minted
1096 // ids) is what decides what streams, so no operation is ever special-cased.
1097 function opOperandIds( op ) {
1098 let ids = [];
1099 if ( op ) {
1100 if ( Array.isArray( op.clientIds ) ) {
1101 ids = ids.concat( op.clientIds );
1102 }
1103 // eslint-disable-next-line eqeqeq
1104 if ( op.rootClientId != null ) {
1105 ids.push( op.rootClientId );
1106 }
1107 // eslint-disable-next-line eqeqeq
1108 if ( op.clientId != null ) {
1109 ids.push( op.clientId );
1110 }
1111 }
1112 return ids;
1113 }
1114
1115 // First rich-text content attr of a block that actually HAS text — used to find
1116 // what to stream in a freshly inserted/replaced block.
1117 function firstRichTextWithContent( blk ) {
1118 const cattrs = richTextAttrsOf( blk.name );
1119 for ( let i = 0; i < cattrs.length; i++ ) {
1120 const k = cattrs[ i ];
1121 const v = ( blk.attributes || {} )[ k ];
1122 if ( typeof v === 'string' && v.trim() ) {
1123 return k;
1124 }
1125 }
1126 return null;
1127 }
1128
1129 // Active streams + the SINGLE shared ticker that advances them all together, so
1130 // a BULK operation (many blocks at once) types CONCURRENTLY off one timer.
1131 let _twStreams = [];
1132 let _twTimer = null;
1133 let _twIdle = [];
1134 // A stream only writes over its OWN last write: once the user types into the
1135 // block or undoes the change mid-stream, the live text is theirs.
1136 function liveAttrText( sel, id, attr ) {
1137 const b = sel.getBlock( id );
1138 const v = b && b.attributes ? b.attributes[ attr ] : undefined;
1139 // eslint-disable-next-line eqeqeq
1140 return v && typeof v.toHTMLString === 'function' ? v.toHTMLString() : String( v == null ? '' : v );
1141 }
1142 // Compared with what the store HOLDS after the stream's last write, not the
1143 // raw text it sent: the store may normalize a partial (an unclosed <strong>).
1144 function streamOwnsBlock( st ) {
1145 return ! st.sel || ( !! st.sel.getBlock( st.id ) && liveAttrText( st.sel, st.id, st.contentAttr ) === st.held );
1146 }
1147 function heldText( st ) {
1148 return st.sel ? liveAttrText( st.sel, st.id, st.contentAttr ) : st.acc;
1149 }
1150 function drainTypewriter() {
1151 _twStreams = [];
1152 _twTimer = null;
1153 // H4 — every stream drained: the live attrs hold full text again, so
1154 // saving is safe.
1155 unlockSavingForTypewriter();
1156 const waiters = _twIdle;
1157 _twIdle = [];
1158 waiters.forEach( function ( f ) {
1159 f();
1160 } );
1161 }
1162 // Land every live stream's full text at once (non-persistent, like a tick).
1163 function flushTypewriter() {
1164 if ( _twTimer ) {
1165 clearTimeout( _twTimer );
1166 }
1167 _twStreams.forEach( function ( st ) {
1168 if ( st.i >= st.tokens.length ) {
1169 return;
1170 }
1171 st.i = st.tokens.length;
1172 if ( ! streamOwnsBlock( st ) ) {
1173 return;
1174 }
1175 try {
1176 markNonPersistent( st.dis );
1177 const a = {};
1178 a[ st.contentAttr ] = st.tokens.join( '' );
1179 st.dis.updateBlockAttributes( st.id, a );
1180 } catch ( e ) { /* the persistent full text is already applied */ }
1181 } );
1182 drainTypewriter();
1183 }
1184 // The reply waits (bounded) for the type-in, so the brain's next read sees
1185 // the full text, never a half-typed prefix. Past the bound the rest lands
1186 // at once.
1187 function whenTypewriterIdle( maxMs ) {
1188 if ( ! _twTimer ) {
1189 return Promise.resolve();
1190 }
1191 return new Promise( function ( resolve ) {
1192 const bound = setTimeout( flushTypewriter, maxMs );
1193 _twIdle.push( function () {
1194 clearTimeout( bound );
1195 resolve();
1196 } );
1197 } );
1198 }
1199 function _twTick() {
1200 let typing = false;
1201 for ( let s = 0; s < _twStreams.length; s++ ) {
1202 const st = _twStreams[ s ];
1203 if ( st.i >= st.tokens.length ) {
1204 continue;
1205 }
1206 // Liveness re-check: the block can vanish mid-stream (a later op in the
1207 // same plan removed/replaced it, or the user deleted it). Writing to a
1208 // gone block is a wasted dispatch that can warn — drop the stream.
1209 if ( ! streamOwnsBlock( st ) ) {
1210 st.i = st.tokens.length; continue;
1211 }
1212 // H4 — a throw from updateBlockAttributes must NOT escape the tick:
1213 // it would skip the drain/unlock below and leave post-saving locked
1214 // for the rest of the session (the whole page becomes unsavable).
1215 // The persistent full content is already applied by the caller, so a
1216 // failed cosmetic type-in is safe to abandon — drop just that stream.
1217 try {
1218 st.acc += st.tokens[ st.i ];
1219 st.i++;
1220 markNonPersistent( st.dis ); // VISUAL only — never an undo level
1221 const a = {};
1222 a[ st.contentAttr ] = st.acc;
1223 st.dis.updateBlockAttributes( st.id, a );
1224 st.held = heldText( st );
1225 } catch ( e ) {
1226 console.warn( '[ZIP AI] typewriter stream aborted for block', st.id, e ); // eslint-disable-line no-console -- intentional error surfacing
1227 st.i = st.tokens.length; // mark done so the drain path runs
1228 continue;
1229 }
1230 if ( st.i < st.tokens.length ) {
1231 typing = true;
1232 }
1233 }
1234 if ( typing ) {
1235 _twTimer = setTimeout( _twTick, TYPEWRITER_WORD_MS ); return;
1236 }
1237 drainTypewriter();
1238 }
1239
1240 // VISUAL typewriter overlay for block `id`'s content attr. The REAL change (the
1241 // full `target` content) is ALREADY applied PERSISTENTLY by the caller; this
1242 // only adds a non-persistent type-in, so it NEVER touches undo and works the
1243 // same for a rewrite or a freshly-inserted block. Clears the content
1244 // SYNCHRONOUSLY (same frame as the op → no flash of the full text) then types
1245 // it back word-by-word off the shared ticker. No-op when animation is disabled
1246 // or the text is too short (leaves the persistent full content).
1247 function typewriterStream( dis, sel, id, contentAttr, target ) {
1248 if ( typewriterDisabled() ) {
1249 return;
1250 }
1251 // eslint-disable-next-line eqeqeq
1252 const tokens = String( target != null ? target : '' ).split( /(\s+)/ );
1253 if ( tokens.length <= 2 ) {
1254 return;
1255 }
1256 // Same-block dedup: if a stream for this block+attr is already active (an
1257 // earlier op in the plan touched the same block), DROP it — two tickers
1258 // writing one block's content fight and garble. Last write wins.
1259 _twStreams = _twStreams.filter( function ( st ) {
1260 return ! ( st.id === id && st.contentAttr === contentAttr );
1261 } );
1262 // H4 — clear FIRST, then take the save-lock. If markNonPersistent or the
1263 // clear dispatch throws we have NOT locked yet, so the exception
1264 // propagates cleanly with no lock to leak (the earlier version locked
1265 // first, so a throwing clear left post-saving locked for the whole
1266 // session — the exact bug this guards). Once the clear succeeds the live
1267 // attribute is blank; lock on the very next statement (no async gap, so
1268 // no save can interleave) and hold it until _twTick drains + unlocks.
1269 // Idempotent per lock name, so a bulk plan's many streams lock once — and
1270 // if a later stream's clear throws, an already-running ticker still owns
1271 // the lock and releases it on drain.
1272 markNonPersistent( dis );
1273 const clear = {};
1274 clear[ contentAttr ] = '';
1275 dis.updateBlockAttributes( id, clear );
1276 lockSavingForTypewriter();
1277 const st = { dis, sel, id, contentAttr, tokens, i: 0, acc: '' };
1278 st.held = heldText( st );
1279 _twStreams.push( st );
1280 if ( ! _twTimer ) {
1281 _twTimer = setTimeout( _twTick, TYPEWRITER_WORD_MS );
1282 }
1283 }
1284
1285 // Snapshot the rich-text content of block subtrees by clientId (BEFORE an op),
1286 // into `out`. Recurses innerBlocks so a container op also captures its children.
1287 function snapshotContent( sel, ids, out ) {
1288 ids.forEach( function ( cid ) {
1289 const root = sel.getBlock( cid );
1290 if ( ! root ) {
1291 return;
1292 }
1293 ( function walk( b ) {
1294 out[ b.clientId ] = contentOf( b );
1295 if ( b.innerBlocks ) {
1296 b.innerBlocks.forEach( walk );
1297 }
1298 }( root ) );
1299 } );
1300 }
1301
1302 // SSOT — the ONLY place streaming is decided, AFTER an op: stream every
1303 // rich-text block (within the op's operand subtrees OR newly minted) whose text
1304 // CHANGED vs the pre-op snapshot, or is NEW (no snapshot). It's a pure text
1305 // diff — op.function is NEVER inspected, so a rewrite, an insert, a replace, or
1306 // any future operation all get the effect identically; a move / remove / style-
1307 // only change leaves the text equal and streams nothing.
1308 function streamChangedText( dis, sel, ids, before ) {
1309 if ( typewriterDisabled() ) {
1310 return;
1311 }
1312 const seen = {};
1313 ids.forEach( function ( cid ) {
1314 const root = sel.getBlock( cid );
1315 if ( ! root ) {
1316 return;
1317 }
1318 ( function walk( b ) {
1319 if ( seen[ b.clientId ] ) {
1320 return;
1321 }
1322 seen[ b.clientId ] = true;
1323 const cattr = firstRichTextWithContent( b );
1324 if ( cattr ) {
1325 const now = String( b.attributes[ cattr ] );
1326 if ( now && now !== ( before[ b.clientId ] || '' ) ) {
1327 typewriterStream( dis, sel, b.clientId, cattr, now );
1328 }
1329 }
1330 if ( b.innerBlocks ) {
1331 b.innerBlocks.forEach( walk );
1332 }
1333 }( root ) );
1334 } );
1335 }
1336
1337 // Apply ONE change. Returns { new_client_ids?, result_text?, deferRebase? }
1338 // or throws a typed Error. `isAnchor` (batch's first applied change) controls
1339 // the streaming-text undo anchor; it's irrelevant to the non-animated kinds.
1340 // Resolve an anchor-relative placement (insert/move "before"/"after" a
1341 // sibling block) into the {parent, index} the dispatch API needs — using the
1342 // LIVE store (getBlockRootClientId + getBlockIndex). This is why the MODEL no
1343 // longer computes parent/index: it just names the neighbour and the layer that
1344 // OWNS the tree resolves the coordinates (closing the parent:null move-to-page-
1345 // root failure). A stale anchor throws the typed stale_client_id miss via
1346 // liveness → failed[]. parent is '' for page root (matches getBlockRootClientId
1347 // + the move toRoot convention). `position` is 'before' | 'after'.
1348 function resolveAnchor( sel, anchorId, position ) {
1349 liveness( sel, anchorId );
1350 return {
1351 parent: sel.getBlockRootClientId( anchorId ) || '',
1352 index: sel.getBlockIndex( anchorId ) + ( position === 'after' ? 1 : 0 ),
1353 };
1354 }
1355
1356 // Pull the anchor (before/after) off a change, if present. before/after are
1357 // mutually exclusive (the brain superRefine enforces it); after wins only if
1358 // both somehow arrive.
1359 function anchorOf( c ) {
1360 if ( c.after !== undefined ) {
1361 return { id: c.after, position: 'after' };
1362 }
1363 if ( c.before !== undefined ) {
1364 return { id: c.before, position: 'before' };
1365 }
1366 return null;
1367 }
1368
1369 // Blast radius of a DESTRUCTIVE op — the reverse edge the editor otherwise
1370 // never computes. Returns the JS on OTHER blocks that targets an anchor inside
1371 // the removed subtree (their `getElementById(deletedAnchor)` goes null and
1372 // THROWS, killing that block's whole spectraCustomJS IIFE — not a clean no-op).
1373 // SURFACED, not blocked: the model relays it to the user, and native undo /
1374 // "don't save" restores the deleted block. Computed BEFORE the dispatch (while
1375 // the blocks still exist) via the shared dependents SSOT.
1376 // The removed blocks' OWN subtree (ids + all descendants), walked via
1377 // getBlockOrder. NOT sel.getClientIdsWithDescendants( clientIds ) — that WP
1378 // selector ignores its argument and returns EVERY page id, so `subtree`
1379 // covered the whole page, anchorDependents excluded everything, and
1380 // orphaned_js was ALWAYS []; the impact warning never fired in production.
1381 function subtreeOf( sel, clientIds ) {
1382 const out = [];
1383 const stack = ( clientIds || [] ).slice();
1384 while ( stack.length ) {
1385 const id = stack.pop();
1386 out.push( id );
1387 const kids = sel.getBlockOrder ? sel.getBlockOrder( id ) : [];
1388 for ( let i = 0; i < kids.length; i++ ) {
1389 stack.push( kids[ i ] );
1390 }
1391 }
1392 return out;
1393 }
1394 function removalImpact( sel, clientIds ) {
1395 const u = sharedEditorUtils();
1396 if ( ! u || ! u.anchorDependents || ! clientIds || ! clientIds.length ) {
1397 return undefined;
1398 }
1399 const subtree = subtreeOf( sel, clientIds );
1400 const anchors = [];
1401 subtree.forEach( function ( id ) {
1402 const a = sel.getBlockAttributes ? sel.getBlockAttributes( id ) : null;
1403 if ( a && typeof a.anchor === 'string' && a.anchor !== '' ) {
1404 anchors.push( a.anchor );
1405 }
1406 } );
1407 if ( ! anchors.length ) {
1408 return undefined;
1409 }
1410 const orphaned = u.anchorDependents( sel, anchors, subtree );
1411 return orphaned.length ? { orphaned_js: orphaned } : undefined;
1412 }
1413
1414 // --- Computed-effect verification: DELETED 2026-08-21 ------------------
1415 // It asked "did the PAINTED style move?" and treated the answer as a verdict
1416 // on whether the user's request was met. Those are different questions, and
1417 // they diverge exactly when the property ALREADY held the requested value —
1418 // "make this bold" on font-weight:700 text measures as unchanged and was
1419 // reported as a failure. See the note at the former phase-1/2 site below.
1420 // canvasCtx survives: phantom-animation detection still reads the canvas.
1421 function canvasCtx() {
1422 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' );
1423 return iframe && iframe.contentDocument
1424 ? { doc: iframe.contentDocument, win: iframe.contentWindow }
1425 : { doc: document, win: window };
1426 }
1427 // ── Phantom-animation detection (the live-found `animate-bounce` no-op) ─────
1428 // An `animate-<ident>` utility compiles to a `.animate-<ident>{ animation:
1429 // <ident> … }` RULE even when NO `@keyframes <ident>` is registered on the
1430 // site (the JIT's animate-<ident> fallback + a Tailwind default like `bounce`
1431 // that the GBS system never defines). So the class "applies" and
1432 // classNameHasLiveUtility sees a rule — but with the keyframes missing the
1433 // browser paints NOTHING. That is why a bundled `border-red-500 animate-bounce`
1434 // reported applied (the border moved computed style) while the bounce silently
1435 // did nothing. We detect it from the RESOLVED cascade, not the token: read the
1436 // element's computed `animation-name` and flag any name (≠ `none`) that has no
1437 // matching `@keyframes` rule. Resolved-name based, so it's blind to how the
1438 // class maps to a keyframe (preset vs fallback) and never false-flags a
1439 // preset whose keyframes DO exist.
1440
1441 // Does the canvas define `@keyframes <ident>`? Read via the CSSOM
1442 // (CSSKeyframesRule — rule type 7 — carries a `.name`), walking @media groups,
1443 // and skipping unreadable (cross-origin) sheets. Pure over (doc, ident).
1444 function hasKeyframesRule( doc, ident ) {
1445 if ( ! doc || ! ident ) {
1446 return false;
1447 }
1448 function walk( rules ) {
1449 for ( let i = 0; i < rules.length; i++ ) {
1450 const r = rules[ i ];
1451 // CSSRule.KEYFRAMES_RULE === 7.
1452 if ( r && r.type === 7 && r.name === ident ) {
1453 return true;
1454 }
1455 // Recurse into ANY grouping rule that carries child rules — @media
1456 // (4) and @supports (12) plus @layer / @container, whose legacy
1457 // numeric `type` is 0 and would otherwise be skipped, hiding
1458 // keyframes nested inside them. (Skip type 7 — its children are the
1459 // keyframe steps, already handled by the name check above.)
1460 if ( r && r.type !== 7 && r.cssRules && walk( r.cssRules ) ) {
1461 return true;
1462 }
1463 }
1464 return false;
1465 }
1466 // Scan both regular sheets and constructable adoptedStyleSheets (Gutenberg
1467 // / Spectra inject some canvas CSS this way; doc.styleSheets omits them).
1468 const styleSheets = ( doc && doc.styleSheets ) || [];
1469 const adopted = ( doc && doc.adoptedStyleSheets ) || [];
1470 const sheets = [];
1471 for ( let i = 0; i < styleSheets.length; i++ ) {
1472 sheets.push( styleSheets[ i ] );
1473 }
1474 for ( let i = 0; i < adopted.length; i++ ) {
1475 sheets.push( adopted[ i ] );
1476 }
1477 for ( let s = 0; s < sheets.length; s++ ) {
1478 let rules;
1479 try {
1480 rules = sheets[ s ].cssRules || sheets[ s ].rules;
1481 } catch ( e ) {
1482 continue;
1483 } // cross-origin
1484 if ( rules && walk( rules ) ) {
1485 return true;
1486 }
1487 }
1488 return false;
1489 }
1490
1491 // Names in a computed `animation-name` value that have NO `@keyframes` rule →
1492 // phantom motion (paints nothing). Pure over (animationNameValue, doc) so a
1493 // unit test locks it. `none` and empty are ignored; duplicates collapse.
1494 function missingKeyframeNames( animationNameValue, doc ) {
1495 if ( typeof animationNameValue !== 'string' ) {
1496 return [];
1497 }
1498 const names = animationNameValue.split( ',' )
1499 .map( function ( n ) {
1500 return n.trim();
1501 } )
1502 .filter( function ( n ) {
1503 return n && n !== 'none';
1504 } );
1505 const missing = [];
1506 for ( let i = 0; i < names.length; i++ ) {
1507 if ( missing.indexOf( names[ i ] ) === -1 && ! hasKeyframesRule( doc, names[ i ] ) ) {
1508 missing.push( names[ i ] );
1509 }
1510 }
1511 return missing;
1512 }
1513
1514 // The phantom animation names actually resolved on a block (its computed
1515 // animation-name minus any that have keyframes). '' / unreadable node → [].
1516 function phantomAnimationsOnBlock( clientId ) {
1517 const ctx = canvasCtx();
1518 const el = ctx.doc.querySelector( '[data-block="' + clientId + '"]' );
1519 if ( ! el ) {
1520 return [];
1521 }
1522 const cs = ctx.win.getComputedStyle( el );
1523 return missingKeyframeNames( cs.getPropertyValue( 'animation-name' ), ctx.doc );
1524 }
1525
1526 // boostUtilitySpecificity + utilityRuleBody REMOVED — their own stated
1527 // REMOVAL CONDITION ("once editor-side utilities reach parity with the
1528 // server JIT") is met by ensureLiveUtilityCss below, which injects the
1529 // server compiler's OWN output (×5 base + media-wrapped responsive
1530 // variants) for every token a plan touches. The boost was also the root
1531 // cause of the stacked-grid-until-reload bug: it re-emitted BASE tokens
1532 // (e.g. grid-cols-1) at ×5 specificity WITHOUT their media context, so a
1533 // boosted base beat its md:/lg: variant siblings at every viewport width
1534 // until a reload dropped the session-scoped boost element. Any lingering
1535 // `zipwp-gbs-editor-boost` element from an older session dies on reload.
1536
1537 // ── LIVE-JIT for tokens the canvas has never seen ─────────────────────────
1538 // ROOT CAUSE this closes: the per-post JIT stylesheet is compiled from SAVED
1539 // content at editor load (spectra-blocks Engine::enqueue_jit_for_current_post
1540 // → JitCache::get_for_post). Blocks inserted programmatically AFTER load
1541 // (Vibe Editing section inserts) can carry utility tokens with NO rule
1542 // anywhere in the canvas — they render unstyled (grids stack) until the next
1543 // save + reload. Fix at the source of truth: ask Spectra's OWN compiler
1544 // (REST /spectra-blocks/v1/global-styles/jit-compile — the same JitCompiler
1545 // that runs at save) for the missing tokens' CSS and inject it into the
1546 // canvas BEFORE the settle/no-op check reads computed styles, so the live
1547 // preview and the verification both converge to saved-page truth instantly.
1548 // Session-deduped per token; network-tolerant (the rpc reply must never
1549 // hang on this — hard 1500ms cap, failure = canvas converges on save).
1550 const _liveJitRequested = {}; // token → 1 (session-scoped request dedupe)
1551 function collectPlanClassNames( ops ) {
1552 const out = [];
1553 function fromBlocks( blocks ) {
1554 if ( ! blocks ) {
1555 return;
1556 }
1557 for ( let b = 0; b < blocks.length; b++ ) {
1558 const blk = blocks[ b ];
1559 if ( ! blk || typeof blk !== 'object' ) {
1560 continue;
1561 }
1562 const cn = blk.attributes && blk.attributes.className;
1563 if ( typeof cn === 'string' && cn.trim() !== '' ) {
1564 out.push( cn );
1565 }
1566 fromBlocks( blk.innerBlocks );
1567 }
1568 }
1569 for ( let i = 0; i < ops.length; i++ ) {
1570 const op = ops[ i ] || {};
1571 if ( op.attributes && typeof op.attributes.className === 'string' ) {
1572 out.push( op.attributes.className );
1573 }
1574 fromBlocks( op.blocks );
1575 }
1576 return out;
1577 }
1578 function ensureLiveUtilityCss( classNames ) {
1579 try {
1580 const ctx = canvasCtx();
1581 // Compile EVERY touched token (session-deduped) — not only the ones
1582 // with no live rule. The live/static sheets carry tokens at MIXED
1583 // specificities (static 0,2,0; persisted dynamic ×5), and partial
1584 // injection breaks the responsive cascade: a base token already
1585 // present at ×5 (e.g. grid-cols-1) beats a freshly-injected
1586 // md:/lg: variant unless the variant arrives at the SAME server
1587 // parity alongside it. Server output is the SSOT cascade — inject
1588 // it wholesale.
1589 const missing = [];
1590 for ( let c = 0; c < classNames.length; c++ ) {
1591 const toks = String( classNames[ c ] ).trim().split( /\s+/ );
1592 for ( let t = 0; t < toks.length; t++ ) {
1593 const tok = toks[ t ];
1594 if ( ! tok || tok.indexOf( 'gs-' ) === 0 || _liveJitRequested[ tok ] ) {
1595 continue;
1596 }
1597 _liveJitRequested[ tok ] = 1;
1598 missing.push( tok );
1599 }
1600 }
1601 if ( ! missing.length ) {
1602 return Promise.resolve();
1603 }
1604 const apiFetch = window.wp && window.wp.apiFetch;
1605 if ( ! apiFetch ) {
1606 return Promise.resolve();
1607 }
1608 const fetchP = apiFetch( {
1609 path: '/spectra-blocks/v1/global-styles/jit-compile',
1610 method: 'POST',
1611 data: { class_strings: [ missing.join( ' ' ) ] },
1612 } ).then( function ( res ) {
1613 const css = res && typeof res.css === 'string' ? res.css : '';
1614 if ( ! css ) {
1615 return;
1616 }
1617 let styleEl = ctx.doc.getElementById( 'zipwp-gbs-live-jit' );
1618 if ( ! styleEl ) {
1619 styleEl = ctx.doc.createElement( 'style' );
1620 styleEl.id = 'zipwp-gbs-live-jit';
1621 ( ctx.doc.head || ctx.doc.documentElement ).appendChild( styleEl );
1622 }
1623 styleEl.textContent = ( styleEl.textContent || '' ) + '\n' + css;
1624 } ).catch( function () {
1625 // Un-mark on FAILURE so a later plan retries. Marking at collection
1626 // time is what dedupes a burst inside one plan, but keeping the mark
1627 // after a failed request meant one transient REST error (or a site
1628 // whose spectra-blocks predates the jit-compile route, where EVERY
1629 // request 404s) left those tokens uncompiled for the whole editor
1630 // session. Two user-visible consequences, not one: the section renders
1631 // unstyled until save+reload, AND classNameHasLiveUtility then reads a
1632 // correctly-applied className as a no-op — so the paint check reports a
1633 // false no-op and the brain nudges the model to "fix" working styling.
1634 // Not un-marked on the 1500ms cap: that request is still in flight and
1635 // will inject when it lands, so a retry there would just duplicate it.
1636 for ( let m = 0; m < missing.length; m++ ) {
1637 delete _liveJitRequested[ missing[ m ] ];
1638 }
1639 } );
1640 const capP = new Promise( function ( resolve ) {
1641 setTimeout( resolve, 1500 );
1642 } );
1643 return Promise.race( [ fetchP, capP ] );
1644 } catch ( e ) {
1645 return Promise.resolve();
1646 }
1647 }
1648
1649 // ── Persist a generated section's GBS class bodies (DEFERRED to Save) ─────
1650 // A generate_section insert carries `args.styles` (section-scoped schema-v1
1651 // buckets: classes/wrapperStyles/mediaQuery) authored with the site's SEMANTIC
1652 // tokens (var(--primary)/var(--heading)/…). ensureLiveUtilityCss above cannot
1653 // help — it SKIPS gs-* tokens because the JIT only compiles the utility ramp.
1654 // These custom class bodies do NOT ride the block className, so they need the
1655 // page GBS store — but writing it NOW would persist to the DB before the user
1656 // Saves (breaking the "nothing hits the DB until Save" contract and orphaning
1657 // CSS if they discard). So we QUEUE it (shared.queueSectionGbsForSave): it
1658 // RENDERS + injects the CSS for a live PREVIEW immediately (a pure compile — no
1659 // DB write) and flushes the payload to the store only when the editor completes
1660 // a real Save. Best-effort: a preview failure logs and the section converges on
1661 // Save; it NEVER fails the insert.
1662 function persistSectionStyles( styles, postId ) {
1663 try {
1664 const apiFetch = window.wp && window.wp.apiFetch;
1665 const u = sharedEditorUtils();
1666 if ( ! apiFetch || ! u || ! u.queueSectionGbsForSave ) {
1667 return Promise.resolve();
1668 }
1669 const persistP = u.queueSectionGbsForSave( apiFetch, styles, postId ).catch( function ( e ) {
1670 // eslint-disable-next-line no-console -- developer signal; the section still inserts + persists on Save
1671 console.warn( '[ZIP AI:apply-change] section GBS preview failed — section may render unstyled until Save', e );
1672 } );
1673 // The preview render folds into the reply-blocking liveCssReady below. A
1674 // REST *rejection* is caught above, but a stalled/hung socket never
1675 // settles, which would hold the apply_change reply until the brain's RPC
1676 // timeout (the false-timeout -> duplicate-content failure the settle
1677 // tuning avoids). Cap it the same way ensureLiveUtilityCss caps its fetch
1678 // so the reply always fires; the paint still converges on Save.
1679 const capP = new Promise( function ( resolve ) {
1680 setTimeout( resolve, 1500 );
1681 } );
1682 return Promise.race( [ persistP, capP ] );
1683 } catch ( e ) {
1684 return Promise.resolve();
1685 }
1686 }
1687
1688 // ── Serialized Gutenberg execution plan ({ version, operations[] }). One
1689 // operation = ONE real wp.data.dispatch('core/block-editor') function + named
1690 // args, keyed on op.function. Each op reuses the shared guards
1691 // (createBlock/placeContent/partitionAttrs/stripBannedAttrs/resolveAnchor/
1692 // liveness/deepMerge). The brain zod already coerced grid + ran the GBS
1693 // denylist; we re-run them (defense-in-depth) so a direct/forked caller can't
1694 // crash the canvas or paint banned props.
1695
1696 function coerceGridLayoutLengthAttrs( attrs ) {
1697 if ( ! isPlainObject( attrs ) ) {
1698 return attrs;
1699 }
1700 function coerce( layout ) {
1701 if ( ! isPlainObject( layout ) || typeof layout.minimumColumnWidth !== 'number' ) {
1702 return layout;
1703 }
1704 const c = {};
1705 for ( const k in layout ) {
1706 if ( Object.prototype.hasOwnProperty.call( layout, k ) ) {
1707 c[ k ] = layout[ k ];
1708 }
1709 }
1710 c.minimumColumnWidth = layout.minimumColumnWidth + 'px';
1711 return c;
1712 }
1713 const out = {};
1714 for ( const k in attrs ) {
1715 if ( Object.prototype.hasOwnProperty.call( attrs, k ) ) {
1716 out[ k ] = attrs[ k ];
1717 }
1718 }
1719 if ( 'layout' in out ) {
1720 out.layout = coerce( out.layout );
1721 }
1722 if ( isPlainObject( out.responsiveControls ) ) {
1723 const rc = {};
1724 for ( const bp in out.responsiveControls ) {
1725 if ( ! Object.prototype.hasOwnProperty.call( out.responsiveControls, bp ) ) {
1726 continue;
1727 }
1728 const v = out.responsiveControls[ bp ];
1729 rc[ bp ] = ( isPlainObject( v ) && 'layout' in v )
1730 ? Object.assign( {}, v, { layout: coerce( v.layout ) } )
1731 : v;
1732 }
1733 out.responsiveControls = rc;
1734 }
1735 return out;
1736 }
1737
1738 // Image-attribute routing: a source tool (find-assets / import-media /
1739 // ai_generate_image) yields { id, url } and the agent sets a UNIFORM
1740 // { url, id, alt } on the target block. Map that to the block's REAL image
1741 // attribute so it lands regardless of block type — most importantly a Spectra
1742 // container BACKGROUND, which lives in `background:{ type:'image', media:{
1743 // id, url, type:'image' } }` (NOT a bare `url`, and NOT the `overlay*` keys —
1744 // those are a separate tint ON TOP of the background). This mirrors exactly
1745 // what Spectra's own Background control writes (components/background onSelect).
1746 // Keyed off the block's REGISTERED schema (not a hardcoded name): a native
1747 // image block (has `url`) keeps {url,id,alt}; a block exposing a `background`
1748 // object gets the background-media shape. deepMerge at apply time preserves
1749 // the container's other background keys (position/size/repeat). Conservative:
1750 // only fires on a bare image url/id with no agent-supplied `background` object.
1751 // Pull an { id, url } image ref out of a candidate object (any of the
1752 // url/id-bearing shapes weak models emit).
1753 function pickImageRef( o ) {
1754 if ( ! isPlainObject( o ) ) {
1755 return null;
1756 }
1757 const url = typeof o.url === 'string' && o.url !== '' ? o.url : undefined;
1758 const id = typeof o.id === 'number'
1759 ? o.id
1760 : ( typeof o.id === 'string' && o.id !== '' ? o.id : undefined );
1761 return url !== undefined || id !== undefined ? { url, id } : null;
1762 }
1763
1764 // Find the image the agent intends, from EITHER a bare { url, id } (the
1765 // contract) OR a mis-built `background` object — models routinely invent the
1766 // old UAGB shape (`background.backgroundImageDesktop`) or guess `media`/`image`
1767 // sub-keys. We extract the ref from any of them so the SHAPE the model used
1768 // can't break the write. Overlay keys are intentionally NOT consulted — an
1769 // overlay is a separate tint, not the background.
1770 function extractImageRef( attrs ) {
1771 const bare = pickImageRef( attrs );
1772 if ( bare ) {
1773 return bare;
1774 }
1775 const bg = attrs.background;
1776 if ( isPlainObject( bg ) ) {
1777 const keys = [ 'media', 'backgroundImageDesktop', 'image', 'imageDesktop', 'backgroundImage' ];
1778 for ( let i = 0; i < keys.length; i++ ) {
1779 const ref = pickImageRef( bg[ keys[ i ] ] );
1780 if ( ref ) {
1781 return ref;
1782 }
1783 }
1784 }
1785 return null;
1786 }
1787
1788 function normalizeImageAttrs( name, attrs, currentAttrs ) {
1789 if ( ! isPlainObject( attrs ) ) {
1790 return attrs;
1791 }
1792 const registered = registeredAttrKeysOf( name );
1793 if ( registered === null ) {
1794 return attrs;
1795 } // unknown block — degrade open
1796 // Native image block (core/image, spectra/image) takes {url,id,alt} as-is.
1797 if ( registered.indexOf( 'url' ) !== -1 ) {
1798 return attrs;
1799 }
1800 // Background-capable container (Spectra): the bg image lives in
1801 // background.{type:'image', media:{id,url,type:'image'}} — mirror exactly
1802 // what Spectra's Background control writes. We REBUILD it from the
1803 // extracted ref (ignoring the model's invented sub-keys), so any input
1804 // shape lands. deepMerge at apply preserves the block's other background
1805 // keys (position/size/repeat).
1806 if ( registered.indexOf( 'background' ) === -1 ) {
1807 return attrs;
1808 }
1809 const ref = extractImageRef( attrs );
1810 if ( ref === null ) {
1811 return attrs;
1812 } // no image intent (pure style/overlay edit)
1813 const out = {};
1814 Object.keys( attrs ).forEach( function ( k ) {
1815 if ( k !== 'url' && k !== 'id' && k !== 'alt' && k !== 'background' ) {
1816 out[ k ] = attrs[ k ];
1817 }
1818 } );
1819 function freshMedia() {
1820 const m = { type: 'image' };
1821 if ( ref.id !== undefined ) {
1822 m.id = ref.id;
1823 }
1824 if ( ref.url !== undefined ) {
1825 m.url = ref.url;
1826 }
1827 return m;
1828 }
1829 out.background = { type: 'image', media: freshMedia() };
1830 // A Spectra container ALSO holds per-breakpoint background overrides under
1831 // responsiveControls.{lg,md,sm}.background. The renderer reads the active
1832 // device's override FIRST, so a base-only update leaves the old image
1833 // showing at any breakpoint that has its own image background. Mirror the
1834 // new image into every responsive breakpoint that CURRENTLY has an image
1835 // background (don't create one where there isn't, and don't touch a
1836 // color/gradient/video override). deepMerge at apply preserves each
1837 // breakpoint's other keys (layout/height).
1838 const rc = currentAttrs && currentAttrs.responsiveControls;
1839 if ( isPlainObject( rc ) ) {
1840 // Merge the mirror ON TOP of any responsiveControls the agent set in
1841 // THIS op (out already carries it from the copy above) — replacing it
1842 // wholesale would drop a same-op RC edit (e.g. responsiveControls.md.layout).
1843 const rcOut = isPlainObject( out.responsiveControls ) ? Object.assign( {}, out.responsiveControls ) : {};
1844 let mirrored = false;
1845 Object.keys( rc ).forEach( function ( device ) {
1846 const dbg = rc[ device ] && rc[ device ].background;
1847 if ( isPlainObject( dbg ) && dbg.type === 'image' ) {
1848 // Per-device shallow merge: keep the agent's other per-device
1849 // keys (layout/height) for this breakpoint, swap the background.
1850 rcOut[ device ] = Object.assign( {}, rcOut[ device ], {
1851 background: { type: 'image', media: freshMedia() },
1852 } );
1853 mirrored = true;
1854 }
1855 } );
1856 if ( mirrored ) {
1857 out.responsiveControls = rcOut;
1858 }
1859 }
1860 return out;
1861 }
1862
1863 // ── Icon-name resolution (the write boundary) ────────────────────────
1864 // Icon-valued attributes (`icon`, `hoverIcon`, any block) render via a
1865 // DICTIONARY lookup (spectra_blocks_info.uagb_svg_icons — the Font Awesome
1866 // free set keyed by bare slug) but the attribute TYPE is an open string,
1867 // so ANY value "applies". A FontAwesome CLASS string
1868 // ('fa-solid fa-arrow-right') — the grammar the authored-HTML section path
1869 // legitimately uses — therefore applies cleanly and renders NOTHING
1870 // (RenderSVG's lookup misses and returns null). Live false positive, turn
1871 // a226671f: "Added an arrow icon…", canvas unchanged.
1872 // Resolve at the ONE attrs boundary, symmetric with reconcileClassName:
1873 // exact library key → FA5 polyfill → the class string's last token minus
1874 // its fa- prefix. Resolvable → the value is REWRITTEN to the library key
1875 // (the write then paints). Unresolvable → the write still applies and the
1876 // name is reported as `unknown_icons` — a registry FACT, same family as
1877 // unknown_attrs; the model decides. Raw SVG strings ('<svg…'), uploaded-
1878 // icon OBJECTS, and empty strings (icon clear) pass through untouched; a
1879 // missing icon registry (non-editor context, jest) skips resolution
1880 // entirely — this never blocks or fails a write.
1881 // Icon-valued attrs are read off the block REGISTRY, never a hand list:
1882 // every icon attr in the library declares type ["string","object"] with an
1883 // icon-ish key (18 attrs / 14 blocks). No registry → no resolution.
1884 function iconAttrKeysOf( blockName ) {
1885 const bt = window.wp && window.wp.blocks && window.wp.blocks.getBlockType &&
1886 window.wp.blocks.getBlockType( blockName );
1887 const attrs = bt && bt.attributes;
1888 if ( ! attrs ) {
1889 return [];
1890 }
1891 return Object.keys( attrs ).filter( function ( k ) {
1892 const t = attrs[ k ] && attrs[ k ].type;
1893 return Array.isArray( t ) && t.indexOf( 'string' ) !== -1 &&
1894 t.indexOf( 'object' ) !== -1 && /icon/i.test( k );
1895 } );
1896 }
1897 function resolveIconName( value ) {
1898 const info = typeof window !== 'undefined' ? window.spectra_blocks_info : null;
1899 const lib = info && info.uagb_svg_icons;
1900 if ( ! lib || typeof value !== 'string' || ! value || value.indexOf( '<svg' ) !== -1 ) {
1901 return null; // not a resolvable name write — leave untouched
1902 }
1903 if ( lib[ value ] ) {
1904 return value;
1905 }
1906 const poly = info.font_awesome_5_polyfill || {};
1907 if ( poly[ value ] && lib[ poly[ value ] ] ) {
1908 return poly[ value ];
1909 }
1910 const tokens = value.trim().split( /\s+/ );
1911 const last = tokens[ tokens.length - 1 ].replace( /^fa-/, '' );
1912 if ( last !== value ) {
1913 if ( lib[ last ] ) {
1914 return last;
1915 }
1916 if ( poly[ last ] && lib[ poly[ last ] ] ) {
1917 return poly[ last ];
1918 }
1919 }
1920 return ''; // a name the library does not contain
1921 }
1922 function resolveIconAttrs( attrs, blockName, iconSink ) {
1923 const iconKeys = iconAttrKeysOf( blockName );
1924 for ( let i = 0; i < iconKeys.length; i++ ) {
1925 const key = iconKeys[ i ];
1926 if ( ! Object.prototype.hasOwnProperty.call( attrs, key ) ) {
1927 continue;
1928 }
1929 const resolved = resolveIconName( attrs[ key ] );
1930 if ( resolved === null ) {
1931 continue;
1932 }
1933 if ( resolved === '' ) {
1934 if ( iconSink ) {
1935 iconSink.push( { block: blockName, attr: key, value: attrs[ key ] } );
1936 }
1937 } else if ( resolved !== attrs[ key ] ) {
1938 attrs[ key ] = resolved;
1939 }
1940 }
1941 return attrs;
1942 }
1943
1944 // Normalize a bare attributes object for updateBlockAttributes: GBS strip +
1945 // grid coerce + content routing + image routing + registry partition
1946 // (collects unknown_attrs) + icon-name resolution (collects unknown_icons).
1947 // `currentAttrs` (the live block's attributes) lets image routing mirror a
1948 // container background into its responsive overrides.
1949 function normalizePlanAttrs( name, attributes, sink, currentAttrs, iconSink ) {
1950 let a = stripBannedAttrs( attributes || {} );
1951 a = coerceGridLayoutLengthAttrs( a );
1952 a = placeContent( name, a );
1953 a = normalizeImageAttrs( name, a, currentAttrs );
1954 const parts = partitionAttrs( name, a );
1955 if ( sink ) {
1956 Array.prototype.push.apply( sink, parts.unknown );
1957 }
1958 return resolveIconAttrs( parts.valid, name, iconSink );
1959 }
1960
1961 // Sentinel destination meaning "keep each target in its OWN current parent"
1962 // (a pure re-order). Used when the plan carried an `index` but no explicit
1963 // `toRootClientId`: '' is the PAGE ROOT in Gutenberg, so defaulting an omitted
1964 // destination to '' silently RELOCATED a nested block out to the top level.
1965 // The brain now rejects that plan shape outright; this keeps an older or
1966 // hand-built envelope from doing damage here.
1967 const KEEP_CURRENT_PARENT = { __keepCurrentParent: true };
1968
1969 // Move targets grouped by source root (multi-parent safe; preserves input order
1970 // at the destination by advancing insertAt per group). Shared shape with the
1971 // 5-kind move case. `toRoot` may be KEEP_CURRENT_PARENT, in which case each
1972 // group moves within its own parent.
1973 function moveTargetsGrouped( sel, dis, targets, toRoot, insertAt ) {
1974 const groups = {};
1975 const groupOrder = [];
1976 ( targets || [] ).forEach( function ( id ) {
1977 const fromRoot = sel.getBlockRootClientId( id ) || '';
1978 if ( ! Object.prototype.hasOwnProperty.call( groups, fromRoot ) ) {
1979 groups[ fromRoot ] = [];
1980 groupOrder.push( fromRoot );
1981 }
1982 groups[ fromRoot ].push( id );
1983 } );
1984 let moveIdx = 0;
1985 groupOrder.forEach( function ( fromRoot ) {
1986 if ( moveIdx > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) {
1987 dis.__unstableMarkNextChangeAsNotPersistent();
1988 }
1989 moveIdx++;
1990 const grp = groups[ fromRoot ];
1991 const dest = toRoot === KEEP_CURRENT_PARENT ? fromRoot : toRoot;
1992 dis.moveBlocksToPosition( grp, fromRoot, dest, insertAt );
1993 // Advance ONLY when every group lands in the same container. The
1994 // accumulator exists to preserve input order at a SHARED destination;
1995 // under KEEP_CURRENT_PARENT each group lands in its own parent, so
1996 // advancing measures group 2's index against a container group 1 never
1997 // touched ({clientIds:['a','b'], index:0} across two parents put `b` at
1998 // index 1 of its own parent instead of the requested 0). An OMITTED
1999 // index must also stay omitted — `undefined + n` is NaN, which
2000 // dispatches a garbage position for every group after the first (that
2001 // half predates the sentinel and hit any multi-parent move that carried
2002 // no explicit index).
2003 if ( toRoot !== KEEP_CURRENT_PARENT && insertAt !== undefined ) {
2004 insertAt += grp.length;
2005 }
2006 } );
2007 }
2008
2009 // Parse an op's AUTHORITATIVE resolved section markup, if it carries any.
2010 //
2011 // `section_markup` is present when the brain materialized the section against
2012 // the live site — real SureForms/SureDonation ids and re-hosted image urls. The
2013 // `op.blocks` array is the PRE-resolution snapshot (formId:0, remote urls) and
2014 // is stale once that happened, so whenever markup is present it WINS.
2015 //
2016 // Shared by insertBlocks, replaceBlocks and replaceInnerBlocks. It used to be
2017 // inline in the insert case only, which is why placing a form-bearing section
2018 // as a REVAMP shipped an unconfigured placeholder form while the identical
2019 // section ADDED came out working.
2020 //
2021 // Returns an array of blocks, or undefined to fall back to the op.blocks path.
2022 // Throws only for an unregistered block type — a real, typed op failure.
2023 function parseResolvedSectionMarkup( op ) {
2024 const markup = typeof op.section_markup === 'string' ? op.section_markup.trim() : '';
2025 if ( markup === '' || ! window.wp.blocks || typeof window.wp.blocks.parse !== 'function' ) {
2026 return undefined;
2027 }
2028 let blocks;
2029 // A parse throw is very unlikely (wp.blocks.parse is tolerant), but if it
2030 // does, DON'T fail the op — fall back to the op.blocks path.
2031 try {
2032 blocks = window.wp.blocks.parse( markup ).filter( function ( b ) {
2033 return b && b.name;
2034 } );
2035 } catch ( e ) {
2036 return undefined;
2037 }
2038 if ( ! Array.isArray( blocks ) || ! blocks.length ) {
2039 return undefined;
2040 }
2041 // F1: wp.blocks.parse maps an UNREGISTERED name to core/missing (which HAS a
2042 // name, so it survived the filter) — the unregistered_block_type gate toBlock()
2043 // enforces on the block path is bypassed here. Fail typed-and-loud instead of
2044 // planting an "Unsupported block" placeholder. Outside the try so it is a real
2045 // op failure, not swallowed into the fallback.
2046 const missing = blocks.filter( function ( b ) {
2047 return b.name === 'core/missing';
2048 } );
2049 if ( missing.length ) {
2050 const orig = missing[ 0 ].attributes && missing[ 0 ].attributes.originalName;
2051 throw new Error(
2052 'unregistered_block_type: "' + ( orig || 'unknown' ) +
2053 '" is not registered on this site — the section cannot be placed.'
2054 );
2055 }
2056 // F2: the markup path bypasses toBlock → stripBannedAttrs. Reapply it here
2057 // (symmetric with the block path, S4/PR#282) so a spectra/* block in resolved
2058 // markup can't paint a GBS-banned visual attr onto the page + GBS store.
2059 blocks.forEach( stripBannedAttrsDeep );
2060 blocks.forEach( assertSafeParsedTree );
2061 return blocks;
2062 }
2063
2064 // Apply ONE operation. Returns { new_client_ids?, result_text?, unknown_attrs? }
2065 // or throws a typed Error (→ failed[]). The allowlist gate (defense-in-depth;
2066 // the brain zod already gated) is the default case.
2067 function runOp( sel, dis, op ) {
2068 // Block-safety pre-check for mutating ops (locked / synced / template).
2069 const mutKind = MUTATION_KIND[ op.function ];
2070 if ( mutKind ) {
2071 // eslint-disable-next-line eqeqeq
2072 const mutTargets = op.clientIds || ( op.rootClientId != null ? [ op.rootClientId ] : [] );
2073 mutTargets.forEach( function ( id ) {
2074 assertMutable( sel, id, mutKind, op.function !== 'updateBlockAttributes' );
2075 } );
2076 }
2077 switch ( op.function ) {
2078 case 'updateBlockAttributes': {
2079 const ids = op.clientIds || [];
2080 ids.forEach( function ( id ) {
2081 liveness( sel, id );
2082 } );
2083 const sinkU = [];
2084 const iconSinkU = [];
2085 // Normalize + fail-closed pre-checks for ALL targets BEFORE any
2086 // dispatch, so a rejected write (e.g. a content flatten) fails the
2087 // whole op with NO partial apply — the model gets a clean typed
2088 // error, not a half-applied bulk edit.
2089 const planned = ids.map( function ( id ) {
2090 const blk = sel.getBlock( id );
2091 assertSafeContent( blk.name, op.attributes );
2092 const norm = normalizePlanAttrs( blk.name, op.attributes, sinkU, blk.attributes, iconSinkU );
2093 // FAIL CLOSED — a plain-text content write must not silently
2094 // strip the block's inline formatting (the "make it 4/5" defect).
2095 assertNoContentFlatten( blk, norm );
2096 // gs-* identity is immutable on a className write (anti-clobber):
2097 // keep the block's own gs- tokens, apply only the model's utilities.
2098 if ( typeof norm.className === 'string' ) {
2099 norm.className = reconcileClassName(
2100 blk.attributes && blk.attributes.className,
2101 norm.className,
2102 );
2103 }
2104 return { id, blk, norm };
2105 } );
2106 // Apply the final attrs (persistent). The typewriter flourish is
2107 // added uniformly afterwards by the runPlan before/after text diff
2108 // (streamChangedText, SSOT) — never per-op here.
2109 planned.forEach( function ( p, j ) {
2110 // L2 — coalesce a BULK restyle into one undo level: only the
2111 // first target's dispatch is persistent (whether THAT one is
2112 // persistent is the caller's op-level decision); the rest
2113 // merge into it, matching reorderChildren/moveTargetsGrouped.
2114 if ( j > 0 ) {
2115 markNonPersistent( dis );
2116 }
2117 dis.updateBlockAttributes( p.id, deepMerge( p.blk.attributes || {}, p.norm ) );
2118 } );
2119 return {
2120 unknown_attrs: sinkU.length ? sinkU : undefined,
2121 unknown_icons: iconSinkU.length ? iconSinkU : undefined,
2122 };
2123 }
2124 case 'insertBlocks': {
2125 const insAnchor = anchorOf( op );
2126 let insRoot = op.rootClientId;
2127 let insIndex = op.index;
2128 if ( insAnchor ) {
2129 const ra = resolveAnchor( sel, insAnchor.id, insAnchor.position );
2130 insRoot = ra.parent === '' ? null : ra.parent;
2131 insIndex = ra.index;
2132 // eslint-disable-next-line eqeqeq
2133 } else if ( op.rootClientId != null ) {
2134 liveness( sel, op.rootClientId );
2135 }
2136 assertInsertable( sel, insRoot ); // GBR-2 — destination container lock
2137 const sinkI = [];
2138 const iconSinkI = [];
2139 // Resolved markup WINS when present (see parseResolvedSectionMarkup).
2140 // assertEffectiveContent is skipped on that path — it aligns specs to
2141 // blocks by index, and the parsed blocks don't index-align with the
2142 // stale op.blocks specs.
2143 let insBlocks = parseResolvedSectionMarkup( op );
2144 if ( ! insBlocks || insBlocks.length === 0 ) {
2145 // No resolved markup (plain section, no bound site, or it parsed
2146 // to nothing) → the proven block-object path, unchanged.
2147 insBlocks = ( op.blocks || [] ).map( function ( s ) {
2148 return toBlock( s, sinkI, iconSinkI );
2149 } );
2150 assertEffectiveContent( op.blocks, insBlocks );
2151 }
2152 adoptSiblingIdentity( sel, insRoot, insBlocks );
2153 // Refuse a child the destination will not accept, BEFORE dispatch —
2154 // Gutenberg would mint ids and drop the block silently.
2155 assertAllowedChildren( sel, insRoot, insBlocks );
2156 // eslint-disable-next-line eqeqeq
2157 dis.insertBlocks( insBlocks, insIndex, insRoot == null ? undefined : insRoot );
2158 return {
2159 new_client_ids: insBlocks.map( function ( b ) {
2160 return b.clientId;
2161 } ),
2162 result_text: insBlocks.map( renderedTextOf ),
2163 unknown_attrs: sinkI.length ? sinkI : undefined,
2164 unknown_icons: iconSinkI.length ? iconSinkI : undefined,
2165 };
2166 }
2167 case 'removeBlocks': {
2168 ( op.clientIds || [] ).forEach( function ( id ) {
2169 liveness( sel, id );
2170 } );
2171 const rmImpact = removalImpact( sel, op.clientIds );
2172 dis.removeBlocks( op.clientIds );
2173 return rmImpact ? { impact: rmImpact } : {};
2174 }
2175 case 'moveBlocksToPosition': {
2176 if ( op.order ) {
2177 reorderChildren( sel, dis, op.toRootClientId, op.order ); return {};
2178 }
2179 ( op.clientIds || [] ).forEach( function ( id ) {
2180 liveness( sel, id );
2181 } );
2182 const mvAnchor = anchorOf( op );
2183 let toRoot, insertAt;
2184 if ( mvAnchor ) {
2185 const rm = resolveAnchor( sel, mvAnchor.id, mvAnchor.position );
2186 toRoot = rm.parent;
2187 insertAt = rm.index;
2188 } else {
2189 // Distinguish an EXPLICIT page-root destination (null) from an
2190 // OMITTED one (undefined). Collapsing both to '' meant a plan
2191 // carrying only `{clientIds, index}` relocated nested blocks to
2192 // the PAGE ROOT — a silent, destructive un-nesting. An omitted
2193 // destination now means "re-order within the current parent".
2194 if ( op.toRootClientId === null ) {
2195 toRoot = '';
2196 } else if ( op.toRootClientId === undefined ) {
2197 toRoot = KEEP_CURRENT_PARENT;
2198 } else {
2199 toRoot = op.toRootClientId;
2200 }
2201 insertAt = op.index;
2202 }
2203 // M2 — the destination container must accept a move-in
2204 // (canMoveBlock above only folds in the SOURCE parent's lock).
2205 // A keep-current-parent move never crosses a container boundary, so
2206 // there is no new destination lock to check.
2207 if ( toRoot !== KEEP_CURRENT_PARENT ) {
2208 assertMoveDestination( sel, toRoot );
2209 }
2210 moveTargetsGrouped( sel, dis, op.clientIds, toRoot, insertAt );
2211 return {};
2212 }
2213 case 'replaceBlocks': {
2214 ( op.clientIds || [] ).forEach( function ( id ) {
2215 liveness( sel, id );
2216 } );
2217 const sinkR = [];
2218 const iconSinkR = [];
2219 // Resolved markup WINS (same as replaceInnerBlocks): an inner-improve
2220 // section_ref ships real SureForms ids + re-hosted image urls in
2221 // section_markup; parse that so the swapped-in block matches what the
2222 // add path produces, falling back to the block objects otherwise.
2223 let repBlocks = parseResolvedSectionMarkup( op );
2224 if ( ! repBlocks || repBlocks.length === 0 ) {
2225 repBlocks = ( op.blocks || [] ).map( function ( s ) {
2226 return toBlock( s, sinkR, iconSinkR );
2227 } );
2228 assertEffectiveContent( op.blocks, repBlocks );
2229 }
2230 const repImpact = removalImpact( sel, op.clientIds );
2231 dis.replaceBlocks( op.clientIds, repBlocks );
2232 return {
2233 new_client_ids: repBlocks.map( function ( b ) {
2234 return b.clientId;
2235 } ),
2236 result_text: repBlocks.map( renderedTextOf ),
2237 unknown_attrs: sinkR.length ? sinkR : undefined,
2238 unknown_icons: iconSinkR.length ? iconSinkR : undefined,
2239 impact: repImpact,
2240 };
2241 }
2242 case 'replaceInnerBlocks': {
2243 liveness( sel, op.rootClientId );
2244 // Structurally this ADDS/REMOVES the container's children — the
2245 // same class of change insertBlocks/duplicateBlocks need GBR-2
2246 // for (assertMutable's 'edit' kind above only covers editing the
2247 // container's OWN attributes, not manipulating its children).
2248 // Previously missing here, so a templateLock:'all'/'insert'
2249 // container's content could be wiped via this op alone.
2250 assertInsertable( sel, op.rootClientId );
2251 // The OLD children are removed by this op — surface any JS elsewhere
2252 // that targets an anchor inside them (computed before the swap).
2253 const innerRemovedImpact = removalImpact( sel, sel.getBlockOrder ? sel.getBlockOrder( op.rootClientId ) : [] );
2254 const sinkN = [];
2255 const iconSinkN = [];
2256 // Resolved markup WINS here too. Without this, placing a section as a
2257 // REVAMP shipped an unconfigured placeholder form while the identical
2258 // section ADDED came out working — the same content, two outcomes,
2259 // decided only by which op the model happened to pick.
2260 let innerBlocks = parseResolvedSectionMarkup( op );
2261 if ( ! innerBlocks || innerBlocks.length === 0 ) {
2262 innerBlocks = ( op.blocks || [] ).map( function ( s ) {
2263 return toBlock( s, sinkN, iconSinkN );
2264 } );
2265 // Same empty-content guard insertBlocks/replaceBlocks apply — a
2266 // spec that asked for text but built an empty block must fail
2267 // loud, not silently succeed. No-ops when op.blocks is legitimately
2268 // [] (clearing the container is the intentional, allowed case).
2269 // Skipped on the markup path for the same index-alignment reason
2270 // as insertBlocks.
2271 assertEffectiveContent( op.blocks, innerBlocks );
2272 }
2273 assertAllowedChildren( sel, op.rootClientId, innerBlocks );
2274 dis.replaceInnerBlocks( op.rootClientId, innerBlocks );
2275 return {
2276 new_client_ids: innerBlocks.map( function ( b ) {
2277 return b.clientId;
2278 } ),
2279 result_text: innerBlocks.map( renderedTextOf ),
2280 unknown_attrs: sinkN.length ? sinkN : undefined,
2281 unknown_icons: iconSinkN.length ? iconSinkN : undefined,
2282 impact: innerRemovedImpact,
2283 };
2284 }
2285 case 'duplicateBlocks': {
2286 // Group by ORIGINAL parent, like moveTargetsGrouped already does for
2287 // moveBlocksToPosition — native duplicateBlocks assumes a single-
2288 // parent selection (mirrors real UI multi-select, which can only
2289 // span one parent), so cross-parent clientIds in ONE call misplace
2290 // later duplicates into the FIRST group's parent (found live
2291 // 2026-07-14: duplicating a tab's trigger+panel together landed the
2292 // panel copy inside the trigger row, not beside its sibling panels).
2293 // Dispatching per root — one native call per parent — keeps each
2294 // duplicate next to its own original.
2295 //
2296 // Snapshotting each root's child order BEFORE its dispatch also
2297 // recovers the NEW clientIds (Gutenberg mints them internally — we
2298 // never see them from the dispatch call itself) by diffing after.
2299 // Without this, a "duplicate this, then customize the copy" plan
2300 // (e.g. add a tab) has no way to target the copy in a follow-up op.
2301 const groups = {};
2302 const groupOrder = [];
2303 // Drop a selected block whose ANCESTOR is also selected — duplicating
2304 // the ancestor already copies it, so keeping it mints a redundant copy.
2305 const selectedIds = op.clientIds || [];
2306 const topLevelIds = selectedIds.filter( function ( id ) {
2307 const parents = typeof sel.getBlockParents === 'function' ? sel.getBlockParents( id ) || [] : [];
2308 return ! parents.some( function ( p ) {
2309 return selectedIds.indexOf( p ) !== -1;
2310 } );
2311 } );
2312 topLevelIds.forEach( function ( id ) {
2313 liveness( sel, id );
2314 const root = sel.getBlockRootClientId( id ) || '';
2315 // GBR-2 — a duplicate lands as a sibling, so the PARENT container
2316 // must allow insertion (template-locked parent → refuse).
2317 assertInsertable( sel, root );
2318 if ( ! Object.prototype.hasOwnProperty.call( groups, root ) ) {
2319 groups[ root ] = { ids: [], before: sel.getBlockOrder( root ).slice() };
2320 groupOrder.push( root );
2321 }
2322 groups[ root ].ids.push( id );
2323 } );
2324 const newIds = [];
2325 groupOrder.forEach( function ( root, g ) {
2326 if ( g > 0 ) {
2327 markNonPersistent( dis );
2328 } // one ⌘Z for the whole plan
2329 dis.duplicateBlocks( groups[ root ].ids );
2330 const before = groups[ root ].before;
2331 sel.getBlockOrder( root ).forEach( function ( id ) {
2332 if ( before.indexOf( id ) === -1 ) {
2333 newIds.push( id );
2334 }
2335 } );
2336 } );
2337 return { new_client_ids: newIds };
2338 }
2339 case 'selectBlock': {
2340 liveness( sel, op.clientId );
2341 dis.selectBlock( op.clientId );
2342 return {};
2343 }
2344 default:
2345 throw new Error( 'forbidden_function:' + op.function );
2346 }
2347 }
2348
2349 // [RTRACE] browser-side round-trip tracer. Always pushes structured entries
2350 // into window.__zipwpTrace (read via agent-browser eval); the console mirror is
2351 // GATED behind window.ZIPAI_CONFIG.debug so it doesn't spam every shopper's
2352 // devtools in production (PR #282 S5). Diagnostic only — wrapped so it can
2353 // never break an apply.
2354 function rtrace( hop, data ) {
2355 try {
2356 const entry = Object.assign( { hop, ts: Date.now() }, data || {} );
2357 ( window.__zipwpTrace = window.__zipwpTrace || [] ).push( entry );
2358 if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) {
2359 console.log( '[RTRACE] gutenberg ' + hop, entry );
2360 }
2361 } catch ( e ) { /* never break the apply on a trace failure */ }
2362 }
2363
2364 // F4 — STRUCTURAL-effect verdict for a minting op. insertBlocks /
2365 // duplicateBlocks / replaceInnerBlocks all MINT clientIds; the op only
2366 // truly LANDED if those minted blocks are actually in the tree now. A
2367 // dispatch that produced an applied entry but whose minted ids never
2368 // appeared (target detached, coalesced away, silent drop) changed nothing —
2369 // the "Added a sixth card" false success. Non-minting ops
2370 // (updateBlockAttributes / move / remove) are not this verdict's concern and
2371 // return true (they carry their own noop/unverifiable verdicts). `getBlock`
2372 // is the live tree probe, bound by the caller. Pure + exported so a unit
2373 // test locks the REAL logic against a mock sel.
2374 function structurallyLanded( op, entry, getBlock ) {
2375 const fn = op && op.function;
2376 if ( fn !== 'insertBlocks' && fn !== 'duplicateBlocks' && fn !== 'replaceInnerBlocks' ) {
2377 return true;
2378 }
2379 const ids = ( entry && entry.new_client_ids ) || [];
2380 // Every id the op DID mint must be live in the tree — a minted-but-absent
2381 // id is the silent drop we're guarding against (the "sixth card" bug).
2382 if ( ! ids.every( function ( id ) {
2383 return !! getBlock( id );
2384 } ) ) {
2385 return false;
2386 }
2387 // insertBlocks / duplicateBlocks ALWAYS add ≥1 block — zero minted means
2388 // nothing was added. replaceInnerBlocks may legitimately mint zero
2389 // (clearing a container's children is a valid structural change; a
2390 // non-empty blocks[] always mints, so empty here ⇒ intended clear), so
2391 // it is NOT required to mint.
2392 if ( ( fn === 'insertBlocks' || fn === 'duplicateBlocks' ) && ids.length === 0 ) {
2393 return false;
2394 }
2395 return true;
2396 }
2397
2398 function runPlan( args ) {
2399 if ( ! window.wp || ! window.wp.data || ! window.wp.blocks ) {
2400 return { success: false, error: 'block_editor_unavailable' };
2401 }
2402 const sel = window.wp.data.select( 'core/block-editor' );
2403 const dis = window.wp.data.dispatch( 'core/block-editor' );
2404 if ( ! sel || ! dis ) {
2405 return { success: false, error: 'block_editor_unavailable' };
2406 }
2407
2408 // SEC-2 — FAIL CLOSED: this guard is the SOLE protection against applying
2409 // a plan to the WRONG open post (a second editor tab). Shared with
2410 // set-scripts; a missing shared module cannot verify, so it refuses too.
2411 const expectedPostId = args && args.post_id;
2412 const u = sharedEditorUtils();
2413 // eslint-disable-next-line eqeqeq
2414 const refusal = u && u.postIdRefusal ? u.postIdRefusal( expectedPostId ) : ( expectedPostId != null
2415 ? { reason: 'post_id_unresolvable', detail: 'expected=' + expectedPostId + ',live=null', live: null }
2416 : null );
2417 if ( refusal ) {
2418 return {
2419 success: true,
2420 data: {
2421 post_id: refusal.live, applied: [], failed: [],
2422 refused: { reason: refusal.reason, detail: refusal.detail },
2423 },
2424 };
2425 }
2426 const livePostId = currentPostId();
2427
2428 // Subtree scope-lock — enforce ONLY when the brain bound one AND the
2429 // locked container is still live this turn. A stale lock (selection
2430 // changed / block gone) degrades OPEN — never a false block.
2431 let scopeLock = args && typeof args.scope_lock === 'string' && args.scope_lock !== ''
2432 ? args.scope_lock
2433 : null;
2434 if ( scopeLock && ! sel.getBlock( scopeLock ) ) {
2435 scopeLock = null;
2436 }
2437 // A multi-selection locks to its members (the live ones; none live = open).
2438 if ( args && Array.isArray( args.scope_lock_ids ) ) {
2439 const live = args.scope_lock_ids.filter( function ( id ) {
2440 return typeof id === 'string' && id !== '' && !! sel.getBlock( id );
2441 } );
2442 if ( live.length > 0 ) {
2443 scopeLock = live;
2444 }
2445 }
2446
2447 const ops = ( args && args.operations ) || [];
2448 const applied = [];
2449 const failed = [];
2450
2451 // [RTRACE] EXEC #1 — the whole plan the brain handed to live Gutenberg.
2452 rtrace( 'runPlan:entry', {
2453 post_id: args && args.post_id, version: args && args.version,
2454 livePostId, op_count: ops.length,
2455 functions: ops.map( function ( o ) {
2456 return o && o.function;
2457 } ),
2458 } );
2459
2460 for ( let i = 0; i < ops.length; i++ ) {
2461 try {
2462 const op = ops[ i ] || {};
2463 // Subtree confinement: refuse a mutating op that reaches OUTSIDE
2464 // the selected container (→ failed[]); in-scope ops still apply.
2465 if ( scopeLock ) {
2466 assertWithinScope( sel, op, scopeLock );
2467 }
2468 // [RTRACE] EXEC #2 — the exact wp.data.dispatch('core/block-editor') call about
2469 // to fire, with its resolved args + the LIVE target block(s) it will hit.
2470 // eslint-disable-next-line eqeqeq
2471 const __tids = op.clientIds || ( op.clientId != null ? [ op.clientId ] : ( op.rootClientId != null ? [ op.rootClientId ] : [] ) );
2472 rtrace( 'runOp:dispatch', {
2473 index: i, fn: op.function,
2474 dispatch: "wp.data.dispatch('core/block-editor')." + op.function + '(...)',
2475 clientIds: op.clientIds, clientId: op.clientId,
2476 rootClientId: op.rootClientId, toRootClientId: op.toRootClientId,
2477 index_arg: op.index, order: op.order, before: op.before, after: op.after,
2478 blocks: ( op.blocks || [] ).map( function ( s ) {
2479 return s && s.name;
2480 } ),
2481 attr_keys: op.attributes ? Object.keys( op.attributes ) : undefined,
2482 className: op.attributes && op.attributes.className,
2483 targets: __tids.map( function ( id ) {
2484 const blk = sel.getBlock( id );
2485 return { clientId: id, live: !! blk, name: blk && blk.name };
2486 } ),
2487 } );
2488 // SSOT streaming: snapshot operand text BEFORE the op, then stream
2489 // whatever text CHANGED or APPEARED after — a pure diff, no check on
2490 // op.function. Works for any operation, present or future.
2491 const _before = {};
2492 snapshotContent( sel, opOperandIds( op ), _before );
2493 // Undo coalescing → one ⌘Z: arm it at the op's first dispatch, so
2494 // an op that throws first never leaves it armed for the user's
2495 // next edit.
2496 const res = runOp( sel, applied.length > 0 ? coalescingDispatch( dis ) : dis, op );
2497 streamChangedText(
2498 dis, sel,
2499 opOperandIds( op ).concat( ( res && res.new_client_ids ) || [] ),
2500 _before
2501 );
2502 const entry = { index: i };
2503 if ( res && res.new_client_ids && res.new_client_ids.length ) {
2504 entry.new_client_ids = res.new_client_ids;
2505 }
2506 if ( res && res.result_text ) {
2507 entry.result_text = res.result_text;
2508 }
2509 if ( res && res.unknown_attrs && res.unknown_attrs.length ) {
2510 entry.unknown_attrs = res.unknown_attrs;
2511 }
2512 if ( res && res.unknown_icons && res.unknown_icons.length ) {
2513 entry.unknown_icons = res.unknown_icons;
2514 }
2515 if ( res && res.impact ) {
2516 entry.impact = res.impact;
2517 }
2518 applied.push( entry );
2519 // [RTRACE] EXEC #3 — what the dispatch returned (minted clientIds / rendered text / dropped attrs).
2520 rtrace( 'runOp:result', {
2521 index: i, fn: op.function,
2522 new_client_ids: res && res.new_client_ids, result_text: res && res.result_text,
2523 unknown_attrs: res && res.unknown_attrs, unknown_icons: res && res.unknown_icons,
2524 } );
2525 } catch ( e ) {
2526 failed.push( { index: i, error: ( e && e.message ) || 'apply_failed' } );
2527 // [RTRACE] EXEC #3b — typed failure (stale_client_id / empty_content / forbidden_function).
2528 rtrace( 'runOp:failed', { index: i, fn: ( ops[ i ] || {} ).function, error: ( e && e.message ) || 'apply_failed' } );
2529 }
2530 }
2531
2532 // LIVE-JIT: compile + inject CSS for utility tokens this plan introduced
2533 // (inserted blocks included — the className collector below only covers
2534 // updateBlockAttributes). Kicked off NOW so the network round-trip runs
2535 // in parallel with the 120ms paint settle; the settle body awaits it so
2536 // the no-op check reads computed styles WITH the new rules present.
2537 const utilCssReady = ensureLiveUtilityCss( collectPlanClassNames( ops ) );
2538
2539 // Auto-focus: honour an explicit trailing selectBlock (model-driven), else leave selection.
2540 // eslint-disable-next-line eqeqeq
2541 const postId = livePostId != null ? livePostId : expectedPostId;
2542 // Fold the generated-section GBS persist into the readiness promise so the
2543 // paint settle below waits for the class bodies to land before it reads
2544 // computed styles. Only when the brain attached section styles AND a valid
2545 // post id resolved (a real post id is a positive int → truthy).
2546 const sectionStyles = args && args.styles;
2547 const gbsReady = sectionStyles && postId
2548 ? persistSectionStyles( sectionStyles, postId )
2549 : Promise.resolve();
2550 const liveCssReady = Promise.all( [ utilCssReady, gbsReady ] );
2551 return new Promise( function ( resolve ) {
2552 // M5 — TWO-PHASE settle. The 120ms snapshot is a heuristic: a slow
2553 // re-render (large page, busy main thread) can leave the computed
2554 // style untouched at 120ms and repaint later — a FALSE noop that
2555 // nudges the brain into a pointless restyle retry. So a noop is only
2556 // CONFIRMED by a second unchanged reading 160ms later; ops whose
2557 // style already moved at phase 1 (the common case) pay nothing extra
2558 // and the reply leaves at 120ms exactly as before.
2559 function finishReply( classNames ) {
2560 // F4 — stamp landed:false on any minting op whose blocks aren't in
2561 // the tree now (see structurallyLanded). Runs in BOTH settle paths
2562 // (fast + phase-2) because it lives here. Only sets the flag when
2563 // NOT landed — an absent flag means landed (brain degrades-open).
2564 for ( let s = 0; s < applied.length; s++ ) {
2565 const landed = structurallyLanded(
2566 ops[ applied[ s ].index ],
2567 applied[ s ],
2568 function ( id ) {
2569 return sel.getBlock( id );
2570 }
2571 );
2572 if ( ! landed ) {
2573 applied[ s ].landed = false;
2574 }
2575 // Phantom motion: a className op whose block resolves an
2576 // `animation-name` with NO `@keyframes` (e.g. `animate-bounce`
2577 // with no `bounce` keyframes). The class applied but paints no
2578 // motion — surface it so the brain never reports the animation
2579 // as working (it stays applied; this is advisory, not a failure).
2580 // Gate on the op actually INTRODUCING an `animate-*` token so a
2581 // plain edit (e.g. `border-red-500`) on a block already carrying
2582 // a theme/AOS animation can't mis-attribute that pre-existing
2583 // animation as this op's phantom.
2584 const op = ops[ applied[ s ].index ];
2585 if ( op && op.function === 'updateBlockAttributes' &&
2586 op.attributes && typeof op.attributes.className === 'string' &&
2587 /(^|\s)animate-/.test( op.attributes.className ) &&
2588 Array.isArray( op.clientIds ) ) {
2589 const phantoms = [];
2590 for ( let t = 0; t < op.clientIds.length; t++ ) {
2591 phantomAnimationsOnBlock( op.clientIds[ t ] ).forEach( function ( name ) {
2592 if ( phantoms.indexOf( name ) === -1 ) {
2593 phantoms.push( name );
2594 }
2595 } );
2596 }
2597 if ( phantoms.length ) {
2598 applied[ s ].phantom_animations = phantoms;
2599 }
2600 }
2601 }
2602 // [RTRACE] EXEC #4 — the reply Gutenberg sends back (after the
2603 // paint-settle no-op check — 120ms, +160ms confirm when needed).
2604 // This is what the bridge POSTs to /agent/rpc-reply → brain folds it.
2605 rtrace( 'runPlan:reply', {
2606 post_id: postId, applied_count: applied.length, failed_count: failed.length,
2607 noop_indices: applied.filter( function ( a ) {
2608 return a.noop;
2609 } ).map( function ( a ) {
2610 return a.index;
2611 } ),
2612 boosted_classNames: classNames,
2613 applied: JSON.parse( JSON.stringify( applied ) ),
2614 failed: JSON.parse( JSON.stringify( failed ) ),
2615 } );
2616 resolve( { success: true, data: { post_id: postId, applied, failed } } );
2617 }
2618 setTimeout( function () {
2619 liveCssReady.then( function () {
2620 return whenTypewriterIdle( 3000 );
2621 } ).then( function () {
2622 const classNames = [];
2623 for ( let a = 0; a < applied.length; a++ ) {
2624 const o = ops[ applied[ a ].index ];
2625 if ( o && o.function === 'updateBlockAttributes' && o.attributes && typeof o.attributes.className === 'string' ) {
2626 classNames.push( o.attributes.className );
2627 }
2628 }
2629 // (COMPUTED-EFFECT CHECK DELETED 2026-08-21 — the verdict layer.)
2630 //
2631 // Phase 1 snapshotted every touched block's full computed style,
2632 // phase 2 re-read it 160ms later, and an unchanged string was
2633 // stamped `noop`. That is a CHANGE detector answering a
2634 // SATISFACTION question, and the two only diverge when the answer
2635 // was already correct — the case where it did the most damage.
2636 //
2637 // 50 scenarios through this handler (docs/checker-dryrun-2026-08-21.md
2638 // in the saas repo): "already bold at 700", "blocked by !important",
2639 // "blocked by inline style" and "genuinely changed" all produced the
2640 // SAME verdict — the only thing it truly discriminated was a real
2641 // token from a typo, which is a spelling check. It also needed four
2642 // correction layers (classNameHasLiveUtility for the wrong surface,
2643 // the two-phase settle for timing, renders_on for variants,
2644 // unverifiable for unreadable nodes) — the signal that the instrument
2645 // measured the wrong quantity.
2646 //
2647 // The reply still carries the FACTS the model cannot infer: `landed`
2648 // (structurallyLanded, in finishReply), `unknown_attrs`, `failed[]`.
2649 // Facts go back; the model holds the intent and decides.
2650 //
2651 // The 120ms wait STAYS: ensureLiveUtilityCss injects server-parity
2652 // CSS and the tree must settle before structurallyLanded reads it.
2653 finishReply( classNames );
2654 } );
2655 }, 120 );
2656 } );
2657 }
2658
2659 function handleApplyChange( args, signal ) {
2660 // Vibe Editing v2: every apply_change is a serialized execution plan
2661 // ({ post_id?, operations[] }). runPlan validates the live editor, enforces
2662 // the post_id two-tab guard, applies each operation in order, and returns
2663 // per-index applied[] / failed[] with computed-effect no-op detection. There
2664 // is no other shape — the brain zod emits operations[] only.
2665 // The ops dispatch synchronously; what can still write after the
2666 // bridge's budget reply is the type-in, so an abort lands it at once.
2667 if ( signal && typeof signal.addEventListener === 'function' ) {
2668 signal.addEventListener( 'abort', flushTypewriter, { once: true } );
2669 }
2670 return runPlan( args );
2671 }
2672
2673 // Register once the bridge is ready (same retry pattern as get-context).
2674 function initHandler() {
2675 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
2676 window.zipwpMcp.registerTool(
2677 'editor/apply-change',
2678 async function ( args, context ) {
2679 return handleApplyChange( args, context && context.signal );
2680 },
2681 { previewMode: 'client' }
2682 );
2683 } else {
2684 setTimeout( initHandler, 100 );
2685 }
2686 }
2687
2688 initHandler();
2689
2690 // Test-only surface (Node/CommonJS). The browser bundle has no `module`, so
2691 // this block is inert there and the IIFE registration path above is untouched.
2692 // Exposes the PURE functions so unit tests exercise the REAL logic (no
2693 // reimplementation) against a mocked window.wp.data / window.wp.blocks.
2694 if ( typeof module !== 'undefined' && module.exports ) {
2695 module.exports = {
2696 handleApplyChange,
2697 runPlan,
2698 runOp,
2699 normalizePlanAttrs,
2700 resolveIconName,
2701 resolveIconAttrs,
2702 coerceGridLayoutLengthAttrs,
2703 placeContent,
2704 renderedTextOf,
2705 assertEffectiveContent,
2706 // Fail-closed content-flatten invariant — a plain-text write must not
2707 // silently strip a block's inline formatting. Exported PURE so a unit
2708 // test locks the predicate, not a reimplementation.
2709 contentWouldFlatten,
2710 deepMerge,
2711 // gs-* identity is immutable on a className write (anti-clobber).
2712 // Exported PURE so a unit test locks the reconcile rule.
2713 reconcileClassName,
2714 toBlock,
2715 // Phantom-animation detection (`animate-bounce` with no keyframes).
2716 // Exported PURE so unit tests lock the resolved-name → missing-keyframes
2717 // logic without a live canvas.
2718 hasKeyframesRule,
2719 missingKeyframeNames,
2720 stripBannedAttrs,
2721 // Registry-driven attr validation — getBlockType(name).attributes is
2722 // the SSOT for attr validity (mirrors what Gutenberg accepts). Exported
2723 // PURE so unit tests lock the partition, not a reimplementation.
2724 partitionAttrs,
2725 registeredAttrKeysOf,
2726 // Image-attribute routing — maps the agent's uniform {url,id,alt} to a
2727 // block's real image attr (incl. container background overlay). Exported
2728 // PURE so unit tests lock the routing per block type.
2729 normalizeImageAttrs,
2730 // Subtree scope-lock (selection confinement) — the tree-aware
2731 // airtight half of "stay inside the selected element". Exported PURE
2732 // so unit tests lock the in/out-of-scope decision against a mock sel.
2733 isWithinScope,
2734 scopeGoverningIds,
2735 resolvedDestinationOf,
2736 assertWithinScope,
2737 // F4 structural-effect verdict — a minting op only landed if its
2738 // minted clientIds are in the tree. Exported PURE so a unit test
2739 // locks the real "did it actually add a block?" logic.
2740 structurallyLanded,
2741 };
2742 }
2743 }() );
2744