PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.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 / dist / zip-ai.js

zip-ai.js in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at assets/js/dist/zip-ai.js

8,237 lines 370.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /*!
2 * ZipWP MCP - Combined JavaScript
3 * Version: 0.0.8
4 * Build: 2026-08-17 11:36:27
5 */
6
7 /**
8 * ZIPWP_LAYOUT — single source of truth for the assistant's panel layout and
9 * appearance state: the localStorage keys, the layout/theme values + defaults,
10 * the geometry constants, the context-aware default resolution, and the
11 * "reset to defaults" routine.
12 *
13 * Loaded before wp-bridge-host / popover-drag (and the React app bundle) and
14 * exposed as `window.ZIPWP_LAYOUT`, so the vanilla host scripts and the React
15 * components all read ONE definition. No backward-compat shims — key strings
16 * are the current ones on purpose so live prefs keep working.
17 */
18 ( function() {
19 'use strict';
20
21 const CONFIG = {
22 // localStorage keys.
23 keys: {
24 layout: 'zipwp-panel-layout',
25 open: 'zipwp-panel-open',
26 popoverPosition: 'zipwp-popover-position',
27 popoverSize: 'zipwp-popover-size',
28 fabPosition: 'zip-ai-fab-position',
29 theme: 'zip-ai-theme',
30 },
31 // Panel layout values.
32 layout: { sidebar: 'sidebar', popover: 'popover' },
33 // Appearance values.
34 theme: { light: 'light', dark: 'dark', default: 'light' },
35 // Cross-tree broadcast fired by resetToDefaults().
36 events: { reset: 'zip-ai-reset-layout' },
37 // FAB launcher geometry.
38 fab: { size: 56, margin: 8, dragThreshold: 4 },
39 // Popover card geometry.
40 popover: {
41 defaultWidth: 420,
42 defaultHeight: 620,
43 minWidth: 360,
44 minHeight: 400,
45 maxWidthMargin: 40,
46 maxHeightMargin: 60,
47 },
48
49 isValidLayout( value ) {
50 return value === this.layout.sidebar || value === this.layout.popover;
51 },
52
53 // Block-editor screens dock the sidebar beside the canvas; every other
54 // admin screen defaults to the floating popover. Uses the
55 // PHP-authoritative is_block_editor() flag with a DOM fallback.
56 isBlockEditorContext() {
57 const cfg = ( typeof window !== 'undefined' && window.zipwpIframeConfig ) || {};
58 if ( cfg.isBlockEditor ) {
59 return true;
60 }
61 try {
62 return document.body.classList.contains( 'block-editor-page' ) ||
63 !! document.querySelector( '.block-editor-block-list__layout' );
64 } catch ( e ) {
65 return false;
66 }
67 },
68
69 // Explicit stored choice wins everywhere; otherwise the context default.
70 resolveLayout() {
71 let stored = null;
72 try {
73 stored = localStorage.getItem( this.keys.layout );
74 } catch ( e ) {}
75 if ( this.isValidLayout( stored ) ) {
76 return stored;
77 }
78 return this.isBlockEditorContext() ? this.layout.sidebar : this.layout.popover;
79 },
80
81 // Wipe every persisted layout + appearance preference. Broadcasts the
82 // reset event so components in other React trees (FAB launcher, header
83 // theme) snap back live without a reload. Callers re-resolve the layout
84 // and re-apply classes themselves.
85 resetToDefaults() {
86 try {
87 localStorage.removeItem( this.keys.layout );
88 localStorage.removeItem( this.keys.popoverPosition );
89 localStorage.removeItem( this.keys.popoverSize );
90 localStorage.removeItem( this.keys.fabPosition );
91 localStorage.removeItem( this.keys.theme );
92 } catch ( e ) {}
93 try {
94 window.dispatchEvent( new CustomEvent( this.events.reset ) );
95 } catch ( e ) {}
96 },
97 };
98
99 if ( typeof window !== 'undefined' ) {
100 window.ZIPWP_LAYOUT = CONFIG;
101 }
102 }() );
103
104
105 /**
106 * Tool Hooks Registry
107 *
108 * Minimal tool hooks system for registering and executing JavaScript handlers
109 * for MCP tools. This is used by the WordPress bridge host to execute js_hook tools.
110 */
111
112 ( function() {
113 'use strict';
114
115 class ToolHooksRegistry {
116 constructor() {
117 this.hooks = new Map();
118 this.listeners = new Map();
119 }
120
121 /**
122 * Register a JavaScript handler for a tool
123 *
124 * @param {string} toolName - The tool name (e.g., 'myplugin/my-action')
125 * @param {Function} handler - The handler function
126 * @param {Object} options - Additional options
127 * @return {boolean} True on success
128 */
129 registerHandler( toolName, handler, options = {} ) {
130 if ( ! toolName || typeof handler !== 'function' ) {
131 return false;
132 }
133
134 const config = {
135 handler,
136 previewMode: options.previewMode || 'client',
137 priority: options.priority || 10,
138 ...options,
139 };
140
141 this.hooks.set( toolName, config );
142
143 return true;
144 }
145
146 /**
147 * Check if a tool has a registered handler
148 *
149 * @param {string} toolName - The tool name
150 * @return {boolean} True if handler exists
151 */
152 hasHandler( toolName ) {
153 return this.hooks.has( toolName );
154 }
155
156 /**
157 * Execute a tool using its registered JavaScript handler
158 *
159 * @param {string} toolName - The tool name
160 * @param {Object} args - Tool arguments
161 * @param {Object} context - Execution context
162 * @return {Promise<Object>} Execution result
163 */
164 async executeToolHook( toolName, args, context = {} ) {
165 const config = this.hooks.get( toolName );
166
167 if ( ! config ) {
168 return {
169 success: false,
170 error: `No JavaScript handler registered for tool: ${ toolName }`,
171 };
172 }
173
174 try {
175 // Emit pre-execution event
176 this.emitEvent( 'beforeToolExecution', { toolName, args, context } );
177
178 // Execute the handler
179 const result = await config.handler( args, context );
180
181 // Ensure result has proper structure — pass through operation fields.
182 // The outer `message` reflects actual status so callers don't read
183 // "executed successfully" when the inner operation failed.
184 const operationSucceeded = result?.success !== false;
185 const defaultMessage = operationSucceeded
186 ? `Tool ${ toolName } executed successfully`
187 : ( result?.error || `Tool ${ toolName } failed` );
188 const formattedResult = {
189 success: operationSucceeded,
190 data: result?.data || result,
191 message: result?.message || defaultMessage,
192 user_summary: result?.user_summary || null,
193 operation: result?.operation || null,
194 verification: result?.verification || result?.data?.verification || null,
195 error: result?.error || null,
196 toolName,
197 executionMode: 'js_hook',
198 };
199
200 // Emit post-execution event
201 this.emitEvent( 'afterToolExecution', { toolName, args, context, result: formattedResult } );
202
203 return formattedResult;
204 } catch ( error ) {
205 const errorResult = {
206 success: false,
207 error: error.message || 'Tool execution failed',
208 toolName,
209 executionMode: 'js_hook',
210 };
211
212 // Emit error event
213 this.emitEvent( 'toolExecutionError', { toolName, args, context, error } );
214
215 return errorResult;
216 }
217 }
218
219 /**
220 * Emit an event to all registered listeners
221 *
222 * @param {string} eventName - Event name
223 * @param {any} data - Event data
224 */
225 emitEvent( eventName, data ) {
226 const callbacks = this.listeners.get( eventName ) || [];
227 callbacks.forEach( ( callback ) => {
228 try {
229 callback( data );
230 } catch {
231 // Event listener error - continue with other listeners
232 }
233 } );
234 }
235 }
236
237 // Create singleton instance
238 const toolHooksRegistry = new ToolHooksRegistry();
239
240 // Make available globally
241 window.zipwpMcp = window.zipwpMcp || {};
242 window.zipwpMcp.toolHooks = toolHooksRegistry;
243 window.zipwpMcp.registerTool = function( toolName, handler, options = {} ) {
244 return toolHooksRegistry.registerHandler( toolName, handler, options );
245 };
246 window.zipwpMcpHooks = toolHooksRegistry;
247 }() );
248
249
250 /**
251 * ZipWP MCP - Spectra Shared Utilities
252 *
253 * Shared utility functions used across Spectra tool handlers and context providers.
254 *
255 * @package
256 */
257
258 ( function() {
259 'use strict';
260
261 /**
262 * Text attributes to extract for lightweight serialization.
263 */
264 const TEXT_ATTRIBUTES = [
265 'content', 'text', 'title', 'heading', 'description',
266 'label', 'placeholder', 'caption', 'citation', 'value',
267 'buttonText', 'linkText', 'question', 'answer',
268 ];
269
270 /**
271 * Extract only text attributes from block attributes.
272 * Truncates long text to save tokens.
273 *
274 * @param {Object} attributes - Block attributes
275 * @return {Object} Object with only text attributes
276 */
277 function extractTextAttributes( attributes ) {
278 if ( ! attributes ) {
279 return {};
280 }
281
282 const texts = {};
283 for ( const attr of TEXT_ATTRIBUTES ) {
284 if ( typeof attributes[ attr ] === 'string' && attributes[ attr ].trim() ) {
285 const text = attributes[ attr ].trim();
286 texts[ attr ] = text.length > 200 ? text.substring( 0, 200 ) + '...' : text;
287 }
288 }
289 return texts;
290 }
291
292 /**
293 * Walk a block subtree DFS and return the first non-empty text attribute
294 * encountered (the block itself first, then its descendants in document
295 * order). Used to derive a user-recognisable label for a selected block
296 * whose own attributes may be empty but whose children carry content —
297 * e.g. a Spectra/Container that wraps the heading block of a hero.
298 *
299 * @param {Object} block
300 * @return {string} Plain trimmed text, or '' when nothing found.
301 */
302 function findFirstText( block ) {
303 if ( ! block ) {
304 return '';
305 }
306 const attrs = block.attributes || {};
307 for ( const key of TEXT_ATTRIBUTES ) {
308 const v = attrs[ key ];
309 if ( typeof v === 'string' && v.trim() ) {
310 return v.replace( /<[^>]*>/g, '' ).trim();
311 }
312 }
313 if ( Array.isArray( block.innerBlocks ) ) {
314 for ( const child of block.innerBlocks ) {
315 const nested = findFirstText( child );
316 if ( nested ) {
317 return nested;
318 }
319 }
320 }
321 return '';
322 }
323
324 /**
325 * Serialize a block with only essential data (clientId, name, texts).
326 * Lightweight version for AI operations - minimizes token usage.
327 *
328 * Also emits `primary_text`: a single user-recognisable label derived
329 * from the block's own text attributes (or its subtree when the block
330 * itself carries no text). Consumed by the React chat UI to make the
331 * selected-block badge identifiable on pages with many same-type
332 * containers. Capped at 120 chars.
333 *
334 * @param {Object} block - WordPress block object
335 * @return {object|null} Lightweight serialized block or null
336 */
337 function serializeBlockLight( block ) {
338 if ( ! block ) {
339 return null;
340 }
341
342 const texts = extractTextAttributes( block.attributes );
343 const result = {
344 clientId: block.clientId,
345 name: block.name,
346 texts,
347 };
348
349 const primary = findFirstText( block );
350 if ( primary ) {
351 result.primary_text = primary.length > 120 ? primary.substring( 0, 117 ) + '' : primary;
352 }
353
354 // The HTML tag the user gave this block — Spectra content stores it in
355 // `tagName` (h1/h2/p/span), a container in `htmlTag` (section/div/header).
356 // Surfaced so the selection chip can show the actual tag instead of the
357 // raw block-type slug (falls back to the block name when absent).
358 const attrs = block.attributes || {};
359 const tag = typeof attrs.tagName === 'string' && attrs.tagName !== ''
360 ? attrs.tagName
361 : ( typeof attrs.htmlTag === 'string' && attrs.htmlTag !== '' ? attrs.htmlTag : '' );
362 if ( tag ) {
363 result.tag = tag;
364 }
365
366 if ( block.innerBlocks && block.innerBlocks.length > 0 ) {
367 result.innerBlocks = block.innerBlocks
368 .map( ( innerBlock ) => serializeBlockLight( innerBlock ) )
369 .filter( ( b ) => b !== null );
370 }
371
372 return result;
373 }
374
375 /**
376 * Serialize a block for context/transmission.
377 * Recursively includes innerBlocks and HTML representation.
378 *
379 * @param {Object} block - WordPress block object
380 * @return {object|null} Serialized block or null if invalid
381 */
382 function serializeBlock( block ) {
383 if ( ! block ) {
384 return null;
385 }
386
387 // Use WordPress serializer to get HTML representation
388 let blocksHtml = '';
389 if ( window.wp?.blocks?.serialize ) {
390 try {
391 blocksHtml = wp.blocks.serialize( [ block ] );
392 } catch ( e ) {
393 console.warn( 'Failed to serialize block:', e ); // eslint-disable-line no-console -- intentional error surfacing
394 }
395 }
396
397 return {
398 clientId: block.clientId,
399 name: block.name,
400 blockName: block.name, // Also include as blockName for backend compatibility
401 attributes: block.attributes,
402 attrs: block.attributes, // Also include as attrs for backend compatibility
403 innerBlocks: ( block.innerBlocks || [] ).map( ( innerBlock ) => serializeBlock( innerBlock ) ),
404 blocks_html: blocksHtml, // Pre-serialized HTML for direct use
405 };
406 }
407
408 /**
409 * Screenshot utilities using Screen Capture API
410 * Captures exactly what's rendered on screen with persistent stream (one-time permission)
411 */
412
413 /**
414 * Get block DOM element by clientId.
415 *
416 * WordPress 6.3+ renders the editor canvas inside an internal iframe
417 * (`iframe[name="editor-canvas"]`), so a direct `document.querySelector`
418 * on the top document misses every block. Fall through to each
419 * same-origin iframe's document before returning null.
420 *
421 * @param {string} clientId - Block client ID
422 * @return {HTMLElement|null} Block element or null
423 */
424 function getBlockElement( clientId ) {
425 if ( ! clientId ) {
426 return null;
427 }
428 const selector = '[data-block="' + clientId + '"]';
429 // Top document first (legacy / non-iframe editors).
430 const direct = document.querySelector( selector );
431 if ( direct ) {
432 return direct;
433 }
434 // Then every iframe we can reach without a cross-origin error.
435 const iframes = document.querySelectorAll( 'iframe' );
436 for ( let i = 0; i < iframes.length; i++ ) {
437 let doc = null;
438 try {
439 doc = iframes[ i ].contentDocument || ( iframes[ i ].contentWindow && iframes[ i ].contentWindow.document );
440 } catch ( err ) {
441 doc = null;
442 }
443 if ( ! doc ) {
444 continue;
445 }
446 const nested = doc.querySelector( selector );
447 if ( nested ) {
448 return nested;
449 }
450 }
451 return null;
452 }
453
454 /**
455 * Inject the zip-ai-block-pulse keyframes + class into a document once.
456 * Idempotent per-document. Must be called on the owner-document of the
457 * element we'll animate — injecting into the top document does nothing
458 * when the block lives inside Gutenberg's editor-canvas iframe.
459 *
460 * @param {Document} [doc] - Target document. Defaults to top document.
461 */
462 function ensurePulseStyles( doc ) {
463 const target = doc || document;
464 if ( ! target || ! target.head ) {
465 return;
466 }
467 if ( target.getElementById( 'zip-ai-block-pulse-styles' ) ) {
468 return;
469 }
470 const style = target.createElement( 'style' );
471 style.id = 'zip-ai-block-pulse-styles';
472 style.textContent =
473 '@keyframes zip-ai-block-pulse {' +
474 '0%, 100% { box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.9), 0 0 0 10px rgba(124, 58, 237, 0); }' +
475 '50% { box-shadow: 0 0 0 3px rgba(124, 58, 237, 1), 0 0 0 14px rgba(124, 58, 237, 0.18); }' +
476 '}' +
477 '.zip-ai-block-pulse {' +
478 'animation: zip-ai-block-pulse 0.9s ease-in-out 2;' +
479 'border-radius: 4px;' +
480 'outline: none !important;' +
481 '}';
482 target.head.appendChild( style );
483 }
484
485 /**
486 * Scroll the block identified by clientId into view and play a brief
487 * pulse highlight so the user can visually locate it. Used by the
488 * selected-block badge in the chat UI.
489 *
490 * Silent no-op if the element isn't in the DOM (block may be in an
491 * iframe we can't reach, or was just replaced — don't throw).
492 *
493 * @param {string} clientId
494 * @param {{ durationMs?: number, scroll?: boolean }} [options]
495 * @return {boolean} true when an element was highlighted.
496 */
497 function highlightBlock( clientId, options ) {
498 const opts = options || {};
499 const el = getBlockElement( clientId );
500 if ( ! el ) {
501 return false;
502 }
503 // Inject the animation CSS into the element's owner document so it
504 // works inside Gutenberg's editor-canvas iframe as well as on the
505 // top document.
506 ensurePulseStyles( el.ownerDocument );
507 if ( opts.scroll !== false ) {
508 try {
509 el.scrollIntoView( { behavior: 'smooth', block: 'center' } );
510 } catch ( err ) {
511 // Older browsers without smooth scroll — fall back to default.
512 try {
513 el.scrollIntoView();
514 } catch ( e ) { /* ignore */ }
515 }
516 }
517 el.classList.remove( 'zip-ai-block-pulse' );
518 // Force reflow so re-adding the class restarts the animation.
519 // eslint-disable-next-line no-unused-expressions
520 void el.offsetWidth;
521 el.classList.add( 'zip-ai-block-pulse' );
522 const duration = typeof opts.durationMs === 'number' ? opts.durationMs : 1800;
523 window.setTimeout( () => {
524 el.classList.remove( 'zip-ai-block-pulse' );
525 }, duration );
526 return true;
527 }
528
529 /**
530 * Persistent screen capture stream (reused to avoid repeated permission prompts)
531 */
532 let screenCaptureStream = null;
533 let screenCaptureVideo = null;
534 let screenCapturePermissionDenied = false; // Remember if user cancelled/denied
535
536 /**
537 * Initialize or get existing screen capture stream
538 * Permission is requested only once per session
539 * If user cancels, won't ask again until page reload
540 * @return {Promise<{stream: MediaStream, video: HTMLVideoElement}|null>}
541 */
542 async function getScreenCaptureStream() {
543 // Don't ask again if user already denied/cancelled
544 if ( screenCapturePermissionDenied ) {
545 return null;
546 }
547
548 // Check if existing stream is still active
549 if ( screenCaptureStream && screenCaptureVideo ) {
550 const tracks = screenCaptureStream.getVideoTracks();
551 if ( tracks.length > 0 && tracks[ 0 ].readyState === 'live' ) {
552 return { stream: screenCaptureStream, video: screenCaptureVideo };
553 }
554 // Stream ended, clean up
555 screenCaptureStream = null;
556 screenCaptureVideo = null;
557 }
558
559 try {
560 // Request new stream (prompts user once)
561 screenCaptureStream = await navigator.mediaDevices.getDisplayMedia( {
562 video: {
563 displaySurface: 'browser',
564 preferCurrentTab: true,
565 },
566 preferCurrentTab: true,
567 } );
568
569 // Create persistent video element
570 screenCaptureVideo = document.createElement( 'video' );
571 screenCaptureVideo.srcObject = screenCaptureStream;
572 screenCaptureVideo.muted = true;
573 await screenCaptureVideo.play();
574
575 // Listen for stream end (user stops sharing)
576 screenCaptureStream.getVideoTracks()[ 0 ].addEventListener( 'ended', () => {
577 screenCaptureStream = null;
578 screenCaptureVideo = null;
579 } );
580
581 return { stream: screenCaptureStream, video: screenCaptureVideo };
582 } catch ( error ) {
583 console.warn( 'Screen capture permission denied:', error.message ); // eslint-disable-line no-console -- intentional error surfacing
584 // Remember that user denied/cancelled - don't ask again until page reload
585 screenCapturePermissionDenied = true;
586 return null;
587 }
588 }
589
590 /**
591 * Capture screenshot of an element using the persistent screen capture stream
592 * @param {HTMLElement} element - Element to capture
593 * @param clientId
594 * @param {Object} options - Capture options
595 * @return {Promise<{base64: string, width: number, height: number}|null>}
596 */
597 async function captureBlockScreenshot( clientId, options = {} ) {
598 const { maxWidth = 1200, quality = 0.8 } = options;
599
600 const element = getBlockElement( clientId );
601 if ( ! element ) {
602 console.warn( 'Block element not found for clientId:', clientId ); // eslint-disable-line no-console -- intentional error surfacing
603 return null;
604 }
605
606 // Scroll element into view
607 element.scrollIntoView( { behavior: 'instant', block: 'center' } );
608 await new Promise( ( resolve ) => setTimeout( resolve, 150 ) );
609
610 // Get or initialize screen capture stream
611 const capture = await getScreenCaptureStream();
612 if ( ! capture ) {
613 return null;
614 }
615
616 const { video } = capture;
617
618 try {
619 // Get element position relative to viewport
620 const rect = element.getBoundingClientRect();
621
622 // Draw current video frame to canvas
623 const fullCanvas = document.createElement( 'canvas' );
624 fullCanvas.width = video.videoWidth;
625 fullCanvas.height = video.videoHeight;
626 const fullCtx = fullCanvas.getContext( '2d' );
627 fullCtx.drawImage( video, 0, 0 );
628
629 // Calculate scale factor (screen capture may be at different DPI)
630 const scaleX = video.videoWidth / window.innerWidth;
631 const scaleY = video.videoHeight / window.innerHeight;
632
633 // Crop to element bounds
634 const cropX = Math.max( 0, rect.left * scaleX );
635 const cropY = Math.max( 0, rect.top * scaleY );
636 const cropWidth = Math.min( rect.width * scaleX, fullCanvas.width - cropX );
637 const cropHeight = Math.min( rect.height * scaleY, fullCanvas.height - cropY );
638
639 // Create cropped canvas
640 const croppedCanvas = document.createElement( 'canvas' );
641 croppedCanvas.width = cropWidth;
642 croppedCanvas.height = cropHeight;
643 const croppedCtx = croppedCanvas.getContext( '2d' );
644 croppedCtx.drawImage( fullCanvas, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight );
645
646 // Optimize if needed
647 let finalCanvas = croppedCanvas;
648 if ( croppedCanvas.width > maxWidth ) {
649 finalCanvas = document.createElement( 'canvas' );
650 const ratio = maxWidth / croppedCanvas.width;
651 finalCanvas.width = maxWidth;
652 finalCanvas.height = croppedCanvas.height * ratio;
653 const ctx = finalCanvas.getContext( '2d' );
654 ctx.drawImage( croppedCanvas, 0, 0, finalCanvas.width, finalCanvas.height );
655 }
656
657 return {
658 base64: finalCanvas.toDataURL( 'image/jpeg', quality ),
659 width: finalCanvas.width,
660 height: finalCanvas.height,
661 };
662 } catch ( error ) {
663 console.error( 'Screenshot capture failed:', error ); // eslint-disable-line no-console -- intentional error surfacing
664 return null;
665 }
666 }
667
668 // Export utilities to global scope for use by tool handlers and context providers
669 window.zipwpMcpSpectraUtils = {
670 serializeBlock,
671 serializeBlockLight,
672 extractTextAttributes,
673 // Screenshot utilities
674 getScreenCaptureStream,
675 getBlockElement,
676 highlightBlock,
677 captureBlockScreenshot,
678 };
679 }() );
680
681
682 /**
683 * ZipWP MCP — Vibe Editing v2: shared editor utilities.
684 *
685 * The ONE source of truth for helpers the editor handlers (get-context,
686 * apply-change, get/set-scripts, get/set-styles) need identically — so they can
687 * never drift:
688 * - currentPostId: the live post id from the editor store, with the same
689 * null-guard. apply-change uses it for the two-tab post_id guard;
690 * get-context returns it in the response.
691 * - editorSelect / editorDispatch: the null-guarded `core/editor` store
692 * select/dispatch accessors. styles read the editing session through these
693 * (getEditedPostAttribute / editPost).
694 * - currentMeta: the live post meta object from the session (or {}), the
695 * read side of the session-scoped styles write.
696 * - blockEditorSelect / blockEditorDispatch / rootClientId: the null-guarded
697 * `core/block-editor` store accessors + the page root container's clientId.
698 * get/set-scripts read/patch a block's `spectraCustomJS` attribute through
699 * these (getBlockAttributes / updateBlockAttributes).
700 * - bannedVisualAttrs / isBannedVisualAttr: the 8 GBS-banned per-block visual
701 * styling props (style, *Color(Hover), boxShadow(Hover), styleAttributes) —
702 * styling lives in `className` ONLY (the Spectra GBS JIT grammar). apply-change
703 * strips them from an incoming attrs write; get-context omits them from the
704 * authorable attr-keys it advertises. ONE list so the two can never drift
705 * (mirrors Laravel's StrictAttrValidator). The 8 props are a sanctioned
706 * constant, not an allowlist — every OTHER attr is decided by the registry.
707 * - isJsCapable: is `spectraCustomJS` actually a registered attribute on this
708 * block type? spectra-blocks-pro only registers it on spectra/spectra-pro
709 * blocks + core/image + core/heading (global-styles/helpers.js
710 * SUPPORTED_BLOCKS) — writing it anywhere else paints the session but is
711 * dropped on Save. Read LIVE off `wp.blocks.getBlockType`, not a mirrored
712 * name list, so it can't drift from whatever the plugin actually registered.
713 *
714 * Dual-mode: attaches to window.zipwpEditorShared in the browser; CommonJS
715 * export for jest.
716 *
717 * Load order: enqueued before the editor handlers in source mode
718 * (react-manager.php) and concatenated ahead of handler.js in the production
719 * grunt bundle (matches the `tools/**\/*-utils.js` glob). The handlers also
720 * resolve it lazily at call time, so a missing global degrades consistently
721 * (post id -> null = the two-tab guard no-ops, exactly as a null
722 * getCurrentPostId() would).
723 *
724 * @package
725 */
726 ( function () {
727 'use strict';
728
729 function editorSelect() {
730 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.select )
731 ? window.wp.data.select( 'core/editor' )
732 : null;
733 }
734
735 function editorDispatch() {
736 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.dispatch )
737 ? window.wp.data.dispatch( 'core/editor' )
738 : null;
739 }
740
741 function currentPostId() {
742 const editor = editorSelect();
743 return editor && typeof editor.getCurrentPostId === 'function'
744 ? editor.getCurrentPostId()
745 : null;
746 }
747
748 // The live post meta object from the editing session (or {} when absent) —
749 // the read side of the session-scoped scripts/styles write.
750 function currentMeta( sel ) {
751 const meta = sel && sel.getEditedPostAttribute ? sel.getEditedPostAttribute( 'meta' ) : null;
752 return meta && typeof meta === 'object' ? meta : {};
753 }
754
755 // The null-guarded `core/block-editor` store — the read/write side of a
756 // block's attributes (get/set-scripts operate on `spectraCustomJS`, an attr).
757 // Edits through it are inherently session-scoped: in-memory now, persisted on
758 // Save, discarded with the session (same model apply-change uses).
759 function blockEditorSelect() {
760 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.select )
761 ? window.wp.data.select( 'core/block-editor' )
762 : null;
763 }
764 function blockEditorDispatch() {
765 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.dispatch )
766 ? window.wp.data.dispatch( 'core/block-editor' )
767 : null;
768 }
769
770 // The page root container's clientId — the first top-level block — the
771 // default owner for page-wide JS when no clientId is passed. null when the
772 // tree is empty or the store is absent.
773 function rootClientId( sel ) {
774 const order = sel && sel.getBlockOrder ? sel.getBlockOrder() : null;
775 return Array.isArray( order ) && order.length > 0 ? order[ 0 ] : null;
776 }
777
778 // The 13 GBS-banned per-block visual styling props. Per-block styling lives in
779 // `className` ONLY (the Spectra GBS JIT grammar); these are never authorable
780 // attrs even when a block's registry declares them. A sanctioned constant —
781 // every OTHER attribute's validity is decided by the registry (getBlockType).
782 //
783 // HARDCODED MIRROR of config/spectra-contract.json `banned_attrs.keys` in the
784 // api-scs-credits-system repo (the SSOT the brain zod + Laravel validator read).
785 // Kept in lockstep by hand for now; a generated artifact + cross-repo CI pin is
786 // the sanctioned fix (drift here is otherwise silent — apply_change attrs bypass
787 // Laravel's validator, so this client strip is the editor's only write-point guard
788 // besides the brain denylist). Per the contract's `notes.update_ops`, the full
789 // 13-key ban applies name-agnostically on the clientId-targeted apply_change path
790 // (no block name at validation time); insert/replace BlockSpecs carry a `name` and
791 // are scope-aware (srfm/* exempt) — that scope-awareness is a separate follow-up.
792 const bannedVisualAttrs = [
793 'style', 'styleAttributes',
794 'backgroundColor', 'backgroundColorHover',
795 'boxShadow', 'boxShadowHover',
796 'textColor', 'textColorHover',
797 'numberColor', 'numberColorHover',
798 'borderHover', 'iconColorHover',
799 'backgroundGradientHover',
800 ];
801 function isBannedVisualAttr( key ) {
802 return bannedVisualAttrs.indexOf( key ) !== -1;
803 }
804
805 // True when this block type actually persists `spectraCustomJS` (the
806 // registry, not a mirrored name list — see the file header).
807 function isJsCapable( blockName ) {
808 const blocks = ( typeof window !== 'undefined' && window.wp && window.wp.blocks ) ? window.wp.blocks : null;
809 if ( ! blocks || typeof blocks.getBlockType !== 'function' || typeof blockName !== 'string' ) {
810 return false;
811 }
812 const blockType = blocks.getBlockType( blockName );
813 return !! ( blockType && blockType.attributes &&
814 Object.prototype.hasOwnProperty.call( blockType.attributes, 'spectraCustomJS' ) );
815 }
816
817 // ── Dependents (reverse edges) ──────────────────────────────────────────
818 // The editor resolves OWNERSHIP (which layer owns a property) but never
819 // DEPENDENTS ("who else relies on the resource I'm about to change"). These
820 // pure helpers compute a mutating write's blast radius from the LIVE tree —
821 // no persistent index — so a destructive/shared write can surface its impact
822 // as a structured signal (act-first: the model informs the user, it doesn't
823 // silently break things). SSOT here so every handler reads dependents identically.
824
825 // Every id a block's JS references — `getElementById('x')` or a `#x` inside a
826 // querySelector. Shared by set-scripts' WRITE-time dead-#id gate AND
827 // apply-change's DELETE-time orphan-JS surface, so the two never drift on how
828 // JS is parsed.
829 function referencedIds( code ) {
830 const ids = [];
831 if ( typeof code !== 'string' || code === '' ) {
832 return ids;
833 }
834 let m;
835 const reGid = /getElementById\(\s*['"]([A-Za-z][\w-]*)['"]\s*\)/g;
836 while ( ( m = reGid.exec( code ) ) !== null ) {
837 ids.push( m[ 1 ] );
838 }
839 const reQs = /querySelector(?:All)?\(\s*(['"])([^'"]*)\1/g;
840 while ( ( m = reQs.exec( code ) ) !== null ) {
841 // eslint-disable-next-line no-var
842 var idm,
843 reId = /#([A-Za-z][\w-]*)/g;
844 while ( ( idm = reId.exec( m[ 2 ] ) ) !== null ) {
845 ids.push( idm[ 1 ] );
846 }
847 }
848 return ids;
849 }
850
851 // Every clientId in the live tree (top-level + descendants).
852 function allClientIds( sel ) {
853 return ( sel && sel.getClientIdsWithDescendants ) ? sel.getClientIdsWithDescendants() : [];
854 }
855
856 // Blocks (excluding `excludeIds`) whose `className` carries `gsToken` — the
857 // reverse `class -> blocks` edge. Answers "editing this gs- class body also
858 // restyles N other sections" BEFORE the write.
859 function classDependents( sel, gsToken, excludeIds ) {
860 const out = [];
861 if ( ! sel || ! gsToken || ! sel.getBlockAttributes ) {
862 return out;
863 }
864 const skip = Object.create( null );
865 ( excludeIds || [] ).forEach( function ( id ) {
866 skip[ id ] = true;
867 } );
868 allClientIds( sel ).forEach( function ( cid ) {
869 if ( skip[ cid ] ) {
870 return;
871 }
872 const a = sel.getBlockAttributes( cid );
873 const cn = ( a && typeof a.className === 'string' ) ? a.className : '';
874 if ( cn.split( /\s+/ ).indexOf( gsToken ) !== -1 ) {
875 out.push( { clientId: cid, blockName: sel.getBlockName ? sel.getBlockName( cid ) : null } );
876 }
877 } );
878 return out;
879 }
880
881 // Blocks (excluding `excludeIds`) whose `spectraCustomJS` references any of
882 // `anchors` (by `#id` / getElementById) — the reverse `anchor -> scripts`
883 // edge. Answers "deleting this block orphans the JS on N others" BEFORE the
884 // delete (their getElementById(...) would return null and THROW, killing the
885 // whole per-block IIFE — not a clean no-op).
886 function anchorDependents( sel, anchors, excludeIds ) {
887 const out = [];
888 if ( ! sel || ! anchors || ! anchors.length || ! sel.getBlockAttributes ) {
889 return out;
890 }
891 const want = Object.create( null );
892 anchors.forEach( function ( x ) {
893 if ( x ) {
894 want[ x ] = true;
895 }
896 } );
897 const skip = Object.create( null );
898 ( excludeIds || [] ).forEach( function ( id ) {
899 skip[ id ] = true;
900 } );
901 allClientIds( sel ).forEach( function ( cid ) {
902 if ( skip[ cid ] ) {
903 return;
904 }
905 const a = sel.getBlockAttributes( cid );
906 const js = ( a && typeof a.spectraCustomJS === 'string' ) ? a.spectraCustomJS : '';
907 if ( ! js ) {
908 return;
909 }
910 const hits = referencedIds( js ).filter( function ( id ) {
911 return want[ id ];
912 } );
913 if ( hits.length ) {
914 out.push( { clientId: cid, blockName: sel.getBlockName ? sel.getBlockName( cid ) : null, refs: hits } );
915 }
916 } );
917 return out;
918 }
919
920 // ── GBS page-store persist (SSOT) ──────────────────────────────────────────
921 // ONE read→merge→write→render→inject path over the per-page GBS store, shared
922 // by editor/set-styles (the styling tool) AND editor/apply-change (which uses
923 // it to persist a generated section's semantic-token class bodies — the JIT
924 // cannot synthesize gs-* class bodies, so without a store write the section
925 // renders unstyled). `apiFetch` is injected by the caller (its own wp.apiFetch
926 // wrapper); NS is the GBS route root.
927 const GBS_NS = '/spectra-blocks/v1/global-styles';
928
929 // Deep-merge one incoming schema-v1 payload onto an existing one, bucket by
930 // bucket (null value = delete). Never full-replaces — importer chrome / other
931 // sections' classes in untouched buckets survive.
932 function mergePayload( existing, incoming ) {
933 const out = Object.assign( {}, existing || {} );
934 out.v = '1';
935 Object.keys( incoming || {} ).forEach( function ( bucket ) {
936 if ( bucket === 'v' ) {
937 return;
938 }
939 const inc = incoming[ bucket ];
940 if ( inc === null ) {
941 delete out[ bucket ];
942 return;
943 }
944 if ( Array.isArray( inc ) ) {
945 out[ bucket ] = inc.slice();
946 return;
947 }
948 if ( typeof inc !== 'object' ) {
949 return;
950 }
951 const base = ( out[ bucket ] && typeof out[ bucket ] === 'object' && ! Array.isArray( out[ bucket ] ) )
952 ? Object.assign( {}, out[ bucket ] )
953 : {};
954 Object.keys( inc ).forEach( function ( key ) {
955 if ( inc[ key ] === null ) {
956 delete base[ key ];
957 } else {
958 base[ key ] = inc[ key ];
959 }
960 } );
961 out[ bucket ] = base;
962 } );
963 return out;
964 }
965
966 // The block-editor canvas runs in an iframe; styles must be injected THERE.
967 function canvasDoc() {
968 const ifr = document.querySelector( 'iframe[name="editor-canvas"]' );
969 return ifr && ifr.contentDocument ? ifr.contentDocument : document;
970 }
971 function injectCss( elementId, css ) {
972 const doc = canvasDoc();
973 let el = doc.getElementById( elementId );
974 if ( ! el ) {
975 el = doc.createElement( 'style' );
976 el.id = elementId;
977 ( doc.head || doc.documentElement ).appendChild( el );
978 }
979 el.textContent = css || '';
980 }
981
982 // Tag a REST failure with which of the three GBS steps it came from so a
983 // caller can label the outcome granularly. `step` is 'read' | 'write' |
984 // 'render' — a render failure means the data WAS saved, only the live paint
985 // failed (recoverable by reload), which is a different remediation than a
986 // read/write failure where nothing persisted. The message is preserved.
987 function taggedGbsError( step, e ) {
988 const err = e instanceof Error ? e : new Error( String( e && e.message ? e.message : e ) );
989 err.gbsStep = step;
990 return err;
991 }
992
993 // Read-modify-write the per-page GBS store through the SSOT /save route (the
994 // same endpoint the importer uses) then render + inject the merged CSS so it
995 // paints live. Returns the merged payload; throws on any REST failure (tagged
996 // with `gbsStep`) so the caller can decide whether to surface or swallow it
997 // and, if surfacing, which step failed.
998 async function persistPageGbs( apiFetch, incoming, postId ) {
999 let existing;
1000 try {
1001 existing = await apiFetch( { path: GBS_NS + '/save?scope=page&post_id=' + postId } )
1002 .then( function ( g ) {
1003 return ( g && g.payload && typeof g.payload === 'object' ) ? g.payload : {};
1004 } );
1005 } catch ( e ) {
1006 throw taggedGbsError( 'read', e );
1007 }
1008 const merged = mergePayload( existing, incoming );
1009 try {
1010 await apiFetch( {
1011 path: GBS_NS + '/save',
1012 method: 'POST',
1013 data: { scope: 'page', post_id: postId, payload: merged, replace: true },
1014 } );
1015 } catch ( e ) {
1016 throw taggedGbsError( 'write', e );
1017 }
1018 try {
1019 const r = await apiFetch( {
1020 path: GBS_NS + '/render',
1021 method: 'POST',
1022 data: { payload: merged, post_id: postId, scope: 'page' },
1023 } );
1024 injectCss( 'spectra-gen-custom-css-' + postId + '-inline-css', r && r.css );
1025 } catch ( e ) {
1026 throw taggedGbsError( 'render', e );
1027 }
1028 return merged;
1029 }
1030
1031 // ── Deferred section-GBS persistence (persist on SAVE, never before) ─────────
1032 // A generate_section insert carries custom `gs-` class BODIES — semantic-token
1033 // CSS (var(--primary)/…) the JIT can't compile as utilities, so it can't ride
1034 // the block className the way per-block styling now does. We must NOT write it
1035 // to the DB immediately: that persists before the user Saves, breaking the
1036 // "nothing hits the DB until Save" contract (and orphaning CSS if they discard).
1037 // Instead: RENDER + inject it for a live PREVIEW now (a pure compile — NO /save,
1038 // no DB write), ACCUMULATE the payload, and FLUSH it to the GBS store only when
1039 // the editor completes a real Save. Never Saved → never persisted.
1040
1041 // Preview-only: compile the payload via the SSOT renderer and inject it into the
1042 // canvas. NO /save — this never touches the DB. (The saved-meta render replaces
1043 // this element on the next reload.)
1044 async function previewSectionGbs( apiFetch, incoming, postId ) {
1045 const r = await apiFetch( {
1046 path: GBS_NS + '/render',
1047 method: 'POST',
1048 data: { payload: incoming, post_id: postId, scope: 'page' },
1049 } );
1050 injectCss( 'zipwp-gbs-pending-section-' + postId, r && r.css );
1051 }
1052
1053 // Remove the pending-section preview <style> for a post — after Save flushes,
1054 // the persisted-meta render (spectra-gen-custom-css-<postId>) paints the
1055 // section, so the preview element is stale duplicate CSS.
1056 function removePendingSectionStyle( postId ) {
1057 const el = canvasDoc().getElementById( 'zipwp-gbs-pending-section-' + postId );
1058 if ( el && el.parentNode ) {
1059 el.parentNode.removeChild( el );
1060 }
1061 }
1062
1063 // Build a deferred saver. Deps are injectable (apiFetch, editorStore, subscribe,
1064 // persist, preview, merge) so the accumulate → flush-on-save logic is testable
1065 // without a live wp.data. `queue(incoming, postId)` merges the payload into the
1066 // pending set (per post), paints the preview, and arms a ONE-TIME subscription
1067 // that flushes every pending payload through the real persist the first time a
1068 // NON-autosave Save completes successfully.
1069 function createSectionGbsSaver( deps ) {
1070 const apiFetch = deps.apiFetch;
1071 const editorStore = deps.editorStore; // () => core/editor select, or the select object
1072 const subscribe = deps.subscribe; // (cb) => unsubscribe
1073 const persist = deps.persist || persistPageGbs;
1074 const preview = deps.preview || previewSectionGbs;
1075 const removePreview = deps.removePreview || removePendingSectionStyle;
1076 const merge = deps.merge || mergePayload;
1077 const pending = {}; // postId -> merged payload
1078 let armed = false;
1079 let wasSaving = false;
1080
1081 function resolveStore() {
1082 return typeof editorStore === 'function' ? editorStore() : editorStore;
1083 }
1084 function flush() {
1085 Object.keys( pending ).forEach( function ( postId ) {
1086 const payload = pending[ postId ];
1087 // Optimistically clear so a second Save with nothing new is a no-op…
1088 delete pending[ postId ];
1089 Promise.resolve( persist( apiFetch, payload, Number( postId ) ) ).then( function () {
1090 // …persisted: the saved-meta render now owns the paint, so drop
1091 // the stale preview <style> — but ONLY if nothing was re-queued
1092 // for this post while the persist was in flight. A queue() during
1093 // the flush re-creates the shared per-post preview element for a
1094 // section that hasn't persisted yet; removing it here would leave
1095 // that section unstyled until the next Save.
1096 if ( ! pending[ postId ] ) {
1097 removePreview( Number( postId ) );
1098 }
1099 } ).catch( function ( e ) {
1100 // A 'render'-tagged rejection means the /save DB write SUCCEEDED
1101 // and only the live paint failed (recoverable by reload). The
1102 // payload IS persisted, so treat it like success: drop the stale
1103 // preview and do NOT re-queue (re-queuing would rewrite an
1104 // already-saved body and, via the merge below, could clobber a
1105 // newer queued edit).
1106 if ( e && e.gbsStep === 'render' ) {
1107 if ( ! pending[ postId ] ) {
1108 removePreview( Number( postId ) );
1109 }
1110 return;
1111 }
1112 // A genuine not-persisted failure (read/write): re-queue so the
1113 // NEXT Save retries — a transient REST error must not permanently
1114 // lose the section's styling. `payload` is the OLDER failed batch,
1115 // so merge it UNDER anything queued since (2nd arg wins per key)
1116 // to keep a newer regenerate from being clobbered by the stale body.
1117 pending[ postId ] = merge( payload, pending[ postId ] || {} );
1118 // eslint-disable-next-line no-console -- developer signal; retried on the next Save
1119 console.warn( '[ZIP AI] deferred section-styles flush failed on Save (will retry next Save)', e );
1120 } );
1121 } );
1122 }
1123 function onStoreChange() {
1124 const sel = resolveStore();
1125 if ( ! sel || typeof sel.isSavingPost !== 'function' ) {
1126 return;
1127 }
1128 // A real (non-autosave) save in flight.
1129 const saving = sel.isSavingPost() &&
1130 ! ( typeof sel.isAutosavingPost === 'function' && sel.isAutosavingPost() );
1131 // Transition saving → finished: flush IF the save succeeded (degrade-open
1132 // when the selector is absent — a completed save with no failure signal).
1133 if ( wasSaving && ! saving ) {
1134 const succeeded = typeof sel.didPostSaveRequestSucceed === 'function'
1135 ? sel.didPostSaveRequestSucceed()
1136 : true;
1137 if ( succeeded ) {
1138 flush();
1139 }
1140 }
1141 wasSaving = saving;
1142 }
1143 function arm() {
1144 if ( armed ) {
1145 return;
1146 }
1147 armed = true;
1148 if ( typeof subscribe === 'function' ) {
1149 subscribe( onStoreChange );
1150 }
1151 }
1152 return {
1153 queue ( incoming, postId ) {
1154 if ( ! incoming || ! postId ) {
1155 return Promise.resolve();
1156 }
1157 pending[ postId ] = merge( pending[ postId ] || {}, incoming );
1158 arm();
1159 // Preview the MERGED payload (not just this section) so a second
1160 // generate_section doesn't overwrite the single per-post preview
1161 // <style> with only its own CSS — earlier sections would go unstyled
1162 // until Save otherwise.
1163 return Promise.resolve( preview( apiFetch, pending[ postId ], postId ) ).catch( function () {} );
1164 },
1165 // Test seams — the pending set + the store-change handler.
1166 _pending: pending,
1167 _onStoreChange: onStoreChange,
1168 };
1169 }
1170
1171 // Lazily-built browser singleton (ONE subscription per editor session), wired to
1172 // the live wp.data core/editor store. `queueSectionGbsForSave` is what the
1173 // apply-change section path calls.
1174 let _sectionGbsSaver = null;
1175 function sectionGbsSaver( apiFetch ) {
1176 if ( ! _sectionGbsSaver ) {
1177 _sectionGbsSaver = createSectionGbsSaver( {
1178 apiFetch,
1179 editorStore: editorSelect,
1180 subscribe ( cb ) {
1181 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.subscribe )
1182 ? window.wp.data.subscribe( cb )
1183 : function () {};
1184 },
1185 } );
1186 }
1187 return _sectionGbsSaver;
1188 }
1189 function queueSectionGbsForSave( apiFetch, incoming, postId ) {
1190 return sectionGbsSaver( apiFetch ).queue( incoming, postId );
1191 }
1192
1193 if ( typeof window !== 'undefined' ) {
1194 window.zipwpEditorShared = window.zipwpEditorShared || {};
1195 window.zipwpEditorShared.mergePayload = mergePayload;
1196 window.zipwpEditorShared.canvasDoc = canvasDoc;
1197 window.zipwpEditorShared.injectCss = injectCss;
1198 window.zipwpEditorShared.persistPageGbs = persistPageGbs;
1199 window.zipwpEditorShared.previewSectionGbs = previewSectionGbs;
1200 window.zipwpEditorShared.createSectionGbsSaver = createSectionGbsSaver;
1201 window.zipwpEditorShared.queueSectionGbsForSave = queueSectionGbsForSave;
1202 window.zipwpEditorShared.currentPostId = currentPostId;
1203 window.zipwpEditorShared.editorSelect = editorSelect;
1204 window.zipwpEditorShared.editorDispatch = editorDispatch;
1205 window.zipwpEditorShared.currentMeta = currentMeta;
1206 window.zipwpEditorShared.blockEditorSelect = blockEditorSelect;
1207 window.zipwpEditorShared.blockEditorDispatch = blockEditorDispatch;
1208 window.zipwpEditorShared.rootClientId = rootClientId;
1209 window.zipwpEditorShared.bannedVisualAttrs = bannedVisualAttrs;
1210 window.zipwpEditorShared.isBannedVisualAttr = isBannedVisualAttr;
1211 window.zipwpEditorShared.isJsCapable = isJsCapable;
1212 window.zipwpEditorShared.referencedIds = referencedIds;
1213 window.zipwpEditorShared.classDependents = classDependents;
1214 window.zipwpEditorShared.anchorDependents = anchorDependents;
1215 }
1216 if ( typeof module !== 'undefined' && module.exports ) {
1217 module.exports = {
1218 mergePayload,
1219 canvasDoc,
1220 injectCss,
1221 persistPageGbs,
1222 previewSectionGbs,
1223 createSectionGbsSaver,
1224 queueSectionGbsForSave,
1225 currentPostId,
1226 editorSelect,
1227 editorDispatch,
1228 currentMeta,
1229 blockEditorSelect,
1230 blockEditorDispatch,
1231 isJsCapable,
1232 referencedIds,
1233 classDependents,
1234 anchorDependents,
1235 rootClientId,
1236 bannedVisualAttrs,
1237 isBannedVisualAttr,
1238 };
1239 }
1240 }() );
1241
1242
1243 /**
1244 * ZipWP MCP — Vibe Editing v2: editor/apply-change handler.
1245 *
1246 * The WRITE sibling of editor/get-context. The brain's AgentBrowserLoop dispatches
1247 * a `js_rpc` envelope carrying a serialized Gutenberg EXECUTION PLAN; this handler
1248 * runs each operation against the LIVE Gutenberg tree via wp.data dispatch, then
1249 * returns a structured reply that the bridge POSTs to /agent/rpc-reply so the loop
1250 * folds it IN THE SAME TURN.
1251 *
1252 * Contract:
1253 * args = { version, post_id, operations:[ { function, ...named args } ] }
1254 * Each operation maps to ONE-OR-MORE real wp.data.dispatch('core/block-editor')[function](...)
1255 * calls — one per op, except moveBlocksToPosition's reorder ('order') and
1256 * multi-parent forms, which fan out to N native moveBlocksToPosition dispatches.
1257 * Allowed functions: updateBlockAttributes | insertBlocks | removeBlocks |
1258 * moveBlocksToPosition | replaceBlocks | replaceInnerBlocks | duplicateBlocks |
1259 * selectBlock. The brain drives them with its native Gutenberg API knowledge.
1260 * - updateBlockAttributes PRESERVES clientId (read-merge-dispatch; className
1261 * verbatim — the GBS JIT renders it; NO style translation).
1262 * - insertBlocks / replace* MINT clientIds (createBlock stamps them synchronously)
1263 * → returned in applied[].new_client_ids so the brain can target them next turn.
1264 * - moveBlocksToPosition PRESERVES clientIds (a splice). insert/move accept a
1265 * before|after anchor that resolves to root+index here.
1266 * post_id two-tab guard: abort ALL on mismatch (returned as a `refused` reply,
1267 * ok:true, so the LLM sees the real reason — not "browser unreachable").
1268 * Partial-apply: a per-op failure (stale clientId) records into failed[] by index
1269 * and continues; earlier ops stay applied. Targeting is liveness-checked here;
1270 * the brain holds no durable handle.
1271 *
1272 * @package
1273 */
1274 ( function () {
1275 'use strict';
1276
1277 function isPlainObject( v ) {
1278 return v !== null && typeof v === 'object' && ! Array.isArray( v );
1279 }
1280
1281 // L3 boundary — per-block visual styling lives in `className` ONLY (the
1282 // Spectra GBS JIT grammar); these flat visual attrs are NEVER allowed on a
1283 // block. The brain's zod denylist is the contract owner; we re-enforce
1284 // client-side (defense in depth) so a stale/forked brain bundle can't paint
1285 // banned attrs into the live tree. The 8-prop list is the SHARED SSOT
1286 // (editor-shared-utils.isBannedVisualAttr) so this + get-context can never
1287 // drift. If the shared module isn't resolved we don't strip — the brain's zod
1288 // already rejected the 8 upstream, so we degrade consistently (never a 3rd copy).
1289 function isBannedVisualAttr( key ) {
1290 const u = sharedEditorUtils();
1291 return !! ( u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr( key ) );
1292 }
1293 function stripBannedAttrs( attrs ) {
1294 if ( ! isPlainObject( attrs ) ) {
1295 return attrs;
1296 }
1297 const clean = {};
1298 Object.keys( attrs ).forEach( function ( k ) {
1299 if ( ! isBannedVisualAttr( k ) ) {
1300 clean[ k ] = attrs[ k ];
1301 } else {
1302 console.warn( '[ZIP AI:apply-change] dropped banned visual attr "%s" — styling belongs in className', k );
1303 }
1304 } );
1305 return clean;
1306 }
1307
1308 // Recursively strip banned visual attrs from an already-PARSED block tree
1309 // (wp.blocks.parse output). The section_markup insert path bypasses toBlock,
1310 // so this is where the block path's per-block stripBannedAttrs is reapplied —
1311 // and the ONLY guard on that path (the vibe-editor door never reaches
1312 // @bsf/wp-importer's fail-closed validator). Mutates each block's attributes.
1313 function stripBannedAttrsDeep( block ) {
1314 if ( ! block || typeof block !== 'object' ) {
1315 return;
1316 }
1317 if ( block.attributes ) {
1318 block.attributes = stripBannedAttrs( block.attributes );
1319 }
1320 if ( Array.isArray( block.innerBlocks ) ) {
1321 block.innerBlocks.forEach( stripBannedAttrsDeep );
1322 }
1323 }
1324
1325 // Shared editor utilities — resolved at call time from the ONE source
1326 // (editor/shared/editor-shared-utils.js): window in the browser, require()
1327 // under jest. currentPostId (post_id guard) lives there so this handler and
1328 // get-context can never drift. A missing module degrades consistently (null)
1329 // — never a spurious post_id refusal.
1330 function sharedEditorUtils() {
1331 if ( typeof window !== 'undefined' && window.zipwpEditorShared ) {
1332 return window.zipwpEditorShared;
1333 }
1334 if ( typeof require === 'function' ) {
1335 try {
1336 return require( '../shared/editor-shared-utils.js' );
1337 } catch ( e ) {
1338 return null;
1339 }
1340 }
1341 return null;
1342 }
1343 function currentPostId() {
1344 const u = sharedEditorUtils();
1345 return u && u.currentPostId ? u.currentPostId() : null;
1346 }
1347
1348 // Liveness (GAP-C / G34): the clientId must resolve against the live tree.
1349 // Throws a typed miss so the caller records it in failed[] and never mutates
1350 // the wrong block (a stale id must NEVER default to "top").
1351 function liveness( sel, id ) {
1352 const block = sel.getBlock( id );
1353 if ( ! block ) {
1354 throw new Error( 'stale_client_id:' + id );
1355 }
1356 return block;
1357 }
1358
1359 // Block-safety guard (SCENARIO-003): a MUTATING op must not touch a block the
1360 // editor has LOCKED, nor escape THIS page by editing synced-pattern
1361 // (core/block) inner content or template-locked content. Gutenberg's own lock
1362 // selectors are authoritative — canEditBlock / canRemoveBlock / canMoveBlock
1363 // already fold in template lock, content lock, and synced-pattern instance
1364 // locks (the instance's inner blocks are content-locked). A blocked op throws
1365 // a typed miss → failed[], so the brain surfaces it (and can tell the user)
1366 // instead of silently clobbering shared/locked content. Degrades to ALLOW when
1367 // a selector is absent (older Gutenberg) — never a false block.
1368 const MUTATION_KIND = {
1369 updateBlockAttributes: 'edit',
1370 removeBlocks: 'remove',
1371 replaceBlocks: 'remove',
1372 replaceInnerBlocks: 'edit',
1373 moveBlocksToPosition: 'move',
1374 };
1375 function assertMutable( sel, id, kind ) {
1376 if ( ! id || ! sel.getBlock( id ) ) {
1377 return;
1378 } // stale / missing — liveness owns that miss
1379 const can = kind === 'remove' ? sel.canRemoveBlock
1380 : kind === 'move' ? sel.canMoveBlock
1381 : sel.canEditBlock;
1382 if ( typeof can === 'function' && can.call( sel, id ) === false ) {
1383 throw new Error( 'locked_block:' + id );
1384 }
1385 }
1386
1387 // Insert-time lock guard (GBR-2): insertBlocks / duplicateBlocks ADD a block to
1388 // a CONTAINER — a case canEdit/Remove/Move (assertMutable) does NOT cover, so
1389 // those two ops previously bypassed the lock check. getTemplateLock on the
1390 // destination root is authoritative: 'all' (fully locked) and 'insert' (no
1391 // add/remove/move) both forbid the insertion. Throws a typed miss → failed[] so
1392 // the brain surfaces it instead of relying on the dispatch to reject. Degrades
1393 // to ALLOW when the selector is absent (older Gutenberg) — never a false block.
1394 function assertInsertable( sel, root ) {
1395 if ( typeof sel.getTemplateLock !== 'function' ) {
1396 return;
1397 }
1398 // eslint-disable-next-line eqeqeq
1399 const lock = sel.getTemplateLock( root == null ? '' : root );
1400 if ( lock === 'all' || lock === 'insert' ) {
1401 throw new Error( 'locked_block:' + ( root || 'root' ) );
1402 }
1403 }
1404
1405 // M2 — move-DESTINATION lock guard. canMoveBlock (assertMutable 'move')
1406 // folds in the SOURCE parent's lock only; moving INTO a fully-locked
1407 // container bypassed every check. templateLock semantics: 'all' forbids any
1408 // structural change inside the container; 'insert' forbids add/remove but
1409 // PERMITS moving existing children, so only 'all' blocks a move-in here.
1410 // Same degrade-to-ALLOW posture as assertInsertable.
1411 function assertMoveDestination( sel, root ) {
1412 if ( typeof sel.getTemplateLock !== 'function' ) {
1413 return;
1414 }
1415 // eslint-disable-next-line eqeqeq
1416 if ( sel.getTemplateLock( root == null ? '' : root ) === 'all' ) {
1417 throw new Error( 'locked_block:' + ( root || 'root' ) );
1418 }
1419 }
1420
1421 // ── Subtree scope-lock (selection confinement — SaaS remediation ①) ───────
1422 // The brain stamps `scope_lock` (the selected container's clientId) on the
1423 // envelope ONLY when the turn bound a SUBTREE scope (a CONTAINER is selected
1424 // and the request is NOT page-wide). Every MUTATING op must then act on the
1425 // locked container or a DESCENDANT of it — editing / inserting / moving into
1426 // a DIFFERENT section is refused (→ failed[], so the brain surfaces it and
1427 // can tell the user). This is the tree-aware AIRTIGHT half of "stay inside
1428 // the selected element"; the brain gate is the soft (outline-only) half.
1429 // selectBlock is exempt (navigation, not a mutation). Degrades to ALLOW when
1430 // getBlockParents is absent (older Gutenberg) — never a false block; the
1431 // caller also drops a stale (non-live) lock id before enforcing.
1432 const SCOPE_EXEMPT_FUNCTIONS = { selectBlock: true };
1433 // `id` is undefined/null when a field simply wasn't provided (no constraint
1434 // from it); '' is the PAGE ROOT (Gutenberg's own rootClientId convention)
1435 // and must NOT short-circuit true — the page root is never inside a
1436 // subtree lock.
1437 function isWithinScope( sel, id, lockId ) {
1438 if ( id === undefined || id === null || id === lockId ) {
1439 return true;
1440 }
1441 if ( id === '' ) {
1442 return false;
1443 }
1444 if ( typeof sel.getBlockParents !== 'function' ) {
1445 return true;
1446 } // can't tell → allow
1447 const parents = sel.getBlockParents( id ) || [];
1448 return parents.indexOf( lockId ) !== -1;
1449 }
1450 // Every clientId a mutating op acts ON or places relative to (the surface a
1451 // "roam" would land on): clientIds[], order[], rootClientId, toRootClientId,
1452 // and the before/after placement anchors.
1453 function scopeGoverningIds( op ) {
1454 let ids = [];
1455 if ( Array.isArray( op.clientIds ) ) {
1456 ids = ids.concat( op.clientIds );
1457 }
1458 if ( Array.isArray( op.order ) ) {
1459 ids = ids.concat( op.order );
1460 }
1461 [ 'rootClientId', 'toRootClientId', 'before', 'after', 'clientId' ].forEach( function ( k ) {
1462 // eslint-disable-next-line eqeqeq
1463 if ( op[ k ] != null ) {
1464 ids.push( op[ k ] );
1465 }
1466 } );
1467 return ids.filter( function ( x ) {
1468 return typeof x === 'string' && x !== '';
1469 } );
1470 }
1471 // The destination container insertBlocks/moveBlocksToPosition will ACTUALLY
1472 // land in — mirrors runOp's own resolution exactly (an anchor resolves to
1473 // its PARENT via resolveAnchor; an omitted INSERT root defaults to the PAGE
1474 // ROOT, ''; an omitted MOVE destination keeps the target's current parent,
1475 // so it imposes no destination constraint) — so the scope check can never
1476 // see a different landing spot
1477 // than the one that really applies. scopeGoverningIds() above checks the
1478 // RAW anchor/root id (is the id itself in scope); this checks where that
1479 // id/omission actually RESOLVES to, which is a different question for two
1480 // reasons: an anchor's parent can be outside scope even when the anchor
1481 // itself is in scope (before/after the locked container = its parent, one
1482 // level UP), and an OMITTED destination has no raw field for
1483 // scopeGoverningIds() to flag at all. What an omission resolves to now
1484 // depends on the op: an omitted INSERT root is the page root (never in
1485 // scope); an omitted MOVE keeps each target's current parent, so it adds no
1486 // destination constraint; an omitted `order` parent is the one those blocks
1487 // share, which IS resolved here and scope-checked for real.
1488 function resolvedDestinationOf( sel, op ) {
1489 const anchor = anchorOf( op );
1490 if ( anchor ) {
1491 if ( ! anchor.id || ! sel.getBlock( anchor.id ) ) {
1492 return undefined;
1493 } // stale anchor — liveness() surfaces this separately
1494 return resolveAnchor( sel, anchor.id, anchor.position ).parent;
1495 }
1496 if ( op.function === 'insertBlocks' ) {
1497 // eslint-disable-next-line eqeqeq
1498 return op.rootClientId == null ? '' : op.rootClientId;
1499 }
1500 if ( op.function === 'moveBlocksToPosition' ) {
1501 // Mirrors runOp's destination resolution EXACTLY — the invariant this
1502 // helper exists for. Explicit null = page root. Omitted:
1503 // • the `order` form re-orders inside the parent those blocks SHARE, so
1504 // resolve it (orderParentOf) and scope-check it for real. Returning
1505 // undefined here would skip the check while runOp still landed
1506 // somewhere concrete — the exact drift this helper must not have.
1507 // • the targeted form keeps each target in its OWN parent
1508 // (KEEP_CURRENT_PARENT), which imposes no single destination, and the
1509 // targets themselves are already checked by scopeGoverningIds.
1510 if ( op.toRootClientId === null ) {
1511 return '';
1512 }
1513 if ( op.toRootClientId === undefined ) {
1514 return op.order ? orderParentOf( sel, op.order ) : undefined;
1515 }
1516 return op.toRootClientId;
1517 }
1518 if ( op.function === 'duplicateBlocks' ) {
1519 // A duplicate lands as a SIBLING of the original, so its destination is
1520 // the original's PARENT. Without this, duplicating the locked container
1521 // itself passed the raw-id check (id === lockId) but the copy escaped to
1522 // the lock's parent, outside scope.
1523 const first = op.clientIds && op.clientIds[ 0 ];
1524 return first ? ( sel.getBlockRootClientId( first ) || '' ) : undefined;
1525 }
1526 return undefined; // op has no destination-shaped field
1527 }
1528 function assertWithinScope( sel, op, lockId ) {
1529 // A whole-section ADD (the brain stamps `section_add` on a resolved
1530 // section_ref insert) is a PAGE-LEVEL add, not an edit of the selected
1531 // container — exempt it from the subtree lock, exactly as the brain's
1532 // selection-binding guard does. Narrow by design: only a marked section
1533 // insert; edits, moves, and inline-`blocks` inserts stay confined.
1534 if ( ! lockId || SCOPE_EXEMPT_FUNCTIONS[ op.function ] || op.section_add === true ) {
1535 return;
1536 }
1537 const ids = scopeGoverningIds( op );
1538 if ( ids.length === 0 ) {
1539 // A mutating op with no resolved target = a page-root op (e.g. insert
1540 // at root). That lands OUTSIDE the selected container.
1541 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 ' + lockId + ' — insert/edit INSIDE it, or tell the user if a different section is intended.' );
1542 }
1543 for ( let i = 0; i < ids.length; i++ ) {
1544 if ( ! isWithinScope( sel, ids[ i ], lockId ) ) {
1545 throw new Error( 'out_of_scope: target ' + ids[ i ] + ' is outside the selected container ' + lockId + ' (and its children). You are scoped to that section — edit inside it, or tell the user if a different section is intended.' );
1546 }
1547 }
1548 const dest = resolvedDestinationOf( sel, op );
1549 // Duplicating the SELECTED container itself lands the copy as its sibling
1550 // (in the parent, one level outside the lock) — that IS the user's intent
1551 // when they select a section and say "duplicate this". Exempt ONLY that
1552 // exact case (the first duplicated id is the lock root, matching how
1553 // resolvedDestinationOf derives `dest`); every other out-of-scope
1554 // destination — a DIFFERENT block copied outside — still throws.
1555 const duplicatingLockRoot = op.function === 'duplicateBlocks' &&
1556 Array.isArray( op.clientIds ) && op.clientIds[ 0 ] === lockId;
1557 if ( dest !== undefined && ! duplicatingLockRoot && ! isWithinScope( sel, dest, lockId ) ) {
1558 throw new Error( 'out_of_scope: this would land at ' + ( dest === '' ? 'the page root' : dest ) + ', outside the selected container ' + lockId + ' — insert/move INSIDE it, or tell the user if a different section is intended.' );
1559 }
1560 }
1561
1562 // The block's editable-content attribute(s), from the block REGISTRY — no
1563 // hardcoded per-type table. Core blocks declare content via a `source` of
1564 // 'rich-text'/'html' (core/heading→content, core/button→text). Spectra blocks
1565 // store content in a CUSTOM attribute with no standard source, so we fall
1566 // back to a declared `content`/`text` attribute (the Spectra convention,
1567 // e.g. spectra/content→text). Block types with neither (image, spacer) get
1568 // an empty list and are left untouched.
1569 function richTextAttrsOf( name ) {
1570 const t = window.wp.blocks && window.wp.blocks.getBlockType
1571 ? window.wp.blocks.getBlockType( name )
1572 : null;
1573 const attrs = t && t.attributes ? t.attributes : {};
1574 const out = [];
1575 for ( const k in attrs ) {
1576 if ( ! Object.prototype.hasOwnProperty.call( attrs, k ) ) {
1577 continue;
1578 }
1579 const src = attrs[ k ] && attrs[ k ].source;
1580 if ( src === 'rich-text' || src === 'html' ) {
1581 out.push( k );
1582 }
1583 }
1584 if ( out.length === 0 ) {
1585 [ 'content', 'text' ].forEach( function ( k ) {
1586 if ( Object.prototype.hasOwnProperty.call( attrs, k ) ) {
1587 out.push( k );
1588 }
1589 } );
1590 }
1591 return out;
1592 }
1593
1594 // The block's REGISTRY attribute schema — the SSOT for which attrs are valid
1595 // on a given block type. getBlockType(name).attributes is the exact set
1596 // Gutenberg itself validates against; an attr absent from this set is one
1597 // updateBlockAttributes would silently ignore. Returns null when the type
1598 // isn't registered (a forked/unknown block) — caller then can't partition, so
1599 // it applies everything (degrade open, never block a write on a missing type).
1600 function registeredAttrKeysOf( name ) {
1601 const t = window.wp.blocks && window.wp.blocks.getBlockType
1602 ? window.wp.blocks.getBlockType( name )
1603 : null;
1604 if ( ! t || ! t.attributes ) {
1605 return null;
1606 }
1607 return Object.keys( t.attributes );
1608 }
1609
1610 // Partition an incoming attrs object against the block's REGISTRY schema:
1611 // { valid, unknown }. `valid` is the subset whose keys exist in
1612 // getBlockType(name).attributes (the keys Gutenberg will actually accept);
1613 // `unknown` is the keys with no schema entry (Gutenberg would silently drop
1614 // them). `className` is always valid (Gutenberg's universal block-support
1615 // attr, not always re-declared per type). When the type isn't registered we
1616 // can't partition — return everything as valid (degrade open). Surfacing
1617 // `unknown` in the reply lets the brain LEARN that attr X isn't valid for
1618 // block Y, instead of inferring success from a silent ignore.
1619 function partitionAttrs( name, attrs ) {
1620 const valid = {};
1621 const unknown = [];
1622 if ( ! isPlainObject( attrs ) ) {
1623 return { valid, unknown };
1624 }
1625 const registered = registeredAttrKeysOf( name );
1626 Object.keys( attrs ).forEach( function ( k ) {
1627 if ( registered === null || k === 'className' || registered.indexOf( k ) !== -1 ) {
1628 valid[ k ] = attrs[ k ];
1629 } else {
1630 unknown.push( k );
1631 }
1632 } );
1633 return { valid, unknown };
1634 }
1635
1636 // Route a generic content/text value onto the block's real rich-text attr(s).
1637 // `content`/`text` are universal authoring ALIASES — the model writes one and
1638 // we copy it onto whatever rich-text attr the block actually declares (e.g.
1639 // spectra/content -> `text`, core/heading -> `content`). After routing, DROP
1640 // any consumed alias the block does NOT itself declare, so a `content` the
1641 // model sent for a `text`-block isn't later flagged as an unknown attr (it was
1642 // applied, just under the real key) — a false-positive that would mis-fire the
1643 // unknown_attrs nudge.
1644 // Rich-text attrs that hold an ATTRIBUTION rather than the block's body copy, so
1645 // a generic `content`/`text` rewrite must never be routed into them (core/quote
1646 // and core/pullquote declare `citation` alongside `value`).
1647 const ATTRIBUTION_RICH_TEXT_ATTRS = [ 'citation' ];
1648
1649 function placeContent( name, attributes ) {
1650 const a = Object.assign( {}, attributes || {} );
1651 const body = typeof a.content === 'string' && a.content !== '' ? a.content
1652 : typeof a.text === 'string' && a.text !== '' ? a.text : undefined;
1653 if ( body === undefined ) {
1654 return a;
1655 }
1656 const targets = richTextAttrsOf( name );
1657 // Route to ONE attr, never every rich-text attr the block declares. A block
1658 // with two (core/quote and core/pullquote both declare `value` AND `citation`
1659 // as source:html) got the SAME body copied into both, so "rewrite this quote"
1660 // silently overwrote the attribution with the quote text — a content loss that
1661 // reports clean, on both the insert and update paths.
1662 // Prefer the alias the MODEL actually wrote when the block declares it, else
1663 // the block's first NON-ATTRIBUTION rich-text attr. For every
1664 // single-rich-text-attr block this resolves to exactly what the fan-out
1665 // produced, so nothing else moves.
1666 const usedAlias = typeof a.content === 'string' && a.content !== '' ? 'content' : 'text';
1667 let primary;
1668 if ( targets.indexOf( usedAlias ) !== -1 ) {
1669 primary = usedAlias;
1670 } else {
1671 // Taking targets[0] alone would trust REGISTRY ENUMERATION ORDER to put the
1672 // body attr ahead of the attribution attr. core/quote and core/pullquote
1673 // happen to declare `value` first, so it is right for them — but a block
1674 // (third-party, or a future core revision) declaring the attribution first
1675 // would route the rewrite body straight into it: the same silent
1676 // content-swap this routing exists to prevent, one block type over. Skip
1677 // the known attribution attrs explicitly instead of relying on position.
1678 for ( let ti = 0; ti < targets.length; ti++ ) {
1679 if ( ATTRIBUTION_RICH_TEXT_ATTRS.indexOf( targets[ ti ] ) === -1 ) {
1680 primary = targets[ ti ];
1681 break;
1682 }
1683 }
1684 // Every rich-text attr IS an attribution attr (no body attr on this block)
1685 // — fall back to the first so the write still lands somewhere rather than
1686 // being silently dropped.
1687 if ( primary === undefined ) {
1688 primary = targets[ 0 ];
1689 }
1690 }
1691 if ( primary !== undefined && ( a[ primary ] === undefined || a[ primary ] === '' ) ) {
1692 a[ primary ] = body;
1693 }
1694 [ 'content', 'text' ].forEach( function ( alias ) {
1695 if ( targets.indexOf( alias ) === -1 ) {
1696 delete a[ alias ];
1697 }
1698 } );
1699 return a;
1700 }
1701
1702 // Plain text the block renders — its rich-text attrs concatenated, tags
1703 // stripped. Used for the effectiveness check + the result_text the reply
1704 // surfaces so the brain SEES what actually landed.
1705 function renderedTextOf( block ) {
1706 const attrs = ( block && block.attributes ) || {};
1707 let s = '';
1708 richTextAttrsOf( block && block.name ).forEach( function ( k ) {
1709 // Core rich-text attrs become RichTextData OBJECTS after createBlock
1710 // (not strings); String() yields their text. Spectra stores plain
1711 // strings. A `typeof === "string"` check misses the core case and
1712 // falsely fires assertEffectiveContent's empty_content (live-found).
1713 const v = attrs[ k ];
1714 if ( v !== null && v !== undefined && v !== '' ) {
1715 s += ' ' + String( v );
1716 }
1717 } );
1718 return s.replace( /<[^>]*>/g, '' ).trim();
1719 }
1720
1721 function intendedContent( spec ) {
1722 const a = ( spec && spec.attributes ) || {};
1723 return ( typeof a.content === 'string' && a.content !== '' ) ||
1724 ( typeof a.text === 'string' && a.text !== '' );
1725 }
1726
1727 // A CONTENT rewrite that would DROP inline markup — the "make it 4/5" flatten.
1728 // True when the block's CURRENT value carries real inline markup (a tag, not a
1729 // bare entity) and the incoming value is PLAIN text. get-context surfaces the
1730 // rich `html` precisely so a rewrite re-authors the SAME inline structure; a
1731 // plain string instead silently strips the design pattern (the large number's
1732 // styling, an emphasis span, per-word colours). PURE so a unit test locks it.
1733 const CONTENT_TAG_RE = /<[a-z!/][^>]*>/i;
1734 function contentWouldFlatten( oldVal, newVal ) {
1735 // Only a PLAIN-text overwrite can flatten. A core RichTextData old value is
1736 // an object whose String() yields its HTML — coerce so both block families
1737 // are covered; the incoming model value is always a plain JSON string.
1738 if ( typeof newVal !== 'string' ) {
1739 return false;
1740 }
1741 const oldStr = ( oldVal === null || oldVal === undefined ) ? '' : String( oldVal );
1742 return CONTENT_TAG_RE.test( oldStr ) && ! CONTENT_TAG_RE.test( newVal );
1743 }
1744
1745 // Fail CLOSED before dispatch when an updateBlockAttributes content write would
1746 // flatten the block's existing inline markup. The message steers recovery to
1747 // the correct path (re-author the rich `html` from get_context, keeping the
1748 // inline structure) rather than letting a lossy plain-text write report a false
1749 // success. Symmetric with assertEffectiveContent's empty_content guard.
1750 function assertNoContentFlatten( block, normAttrs ) {
1751 if ( ! block || ! normAttrs ) {
1752 return;
1753 }
1754 const contentKeys = richTextAttrsOf( block.name );
1755 const current = block.attributes || {};
1756 contentKeys.forEach( function ( key ) {
1757 if ( ! Object.prototype.hasOwnProperty.call( normAttrs, key ) ) {
1758 return;
1759 }
1760 if ( contentWouldFlatten( current[ key ], normAttrs[ key ] ) ) {
1761 throw new Error(
1762 'content_flatten:' + block.name + ':' + key +
1763 '. This block\'s content carries inline formatting that a plain-text value would DROP' +
1764 ' (the design pattern: styled spans / emphasis). Re-read the block with editor__get_context' +
1765 ' and rewrite its `html` (the rich value) with your new words INSIDE the same inline structure,' +
1766 ' not a flat string.',
1767 );
1768 }
1769 } );
1770 }
1771
1772 // A spec that ASKED for text content but produced an EMPTY block is a silent
1773 // content loss — fail it LOUD (before dispatch) so the brain never reports a
1774 // false success on an empty insert/replace. Generalized: it compares INTENT
1775 // (the spec carried content) against RESULT (the built block renders no text
1776 // and has no children) — never a per-block-type rule.
1777 function assertEffectiveContent( specs, blocks ) {
1778 ( specs || [] ).forEach( function ( spec, i ) {
1779 const blk = blocks[ i ];
1780 if ( ! intendedContent( spec ) || ! blk ) {
1781 return;
1782 }
1783 const hasChildren = blk.innerBlocks && blk.innerBlocks.length > 0;
1784 if ( renderedTextOf( blk ) === '' && ! hasChildren ) {
1785 throw new Error( 'empty_content:' + ( spec.name || 'block' ) );
1786 }
1787 } );
1788 }
1789
1790 // JSON block-spec → Gutenberg block (recursive). NOT the markup-string path.
1791 // Applies ONLY the attrs the block's registry declares (partitionAttrs, the
1792 // getBlockType SSOT) and pushes the keys it doesn't into `unknownSink` (when
1793 // provided) — recursively across innerBlocks. The brain folds every attr
1794 // through with no allowlist; this is where insert/replace decide validity and
1795 // report back what they dropped, exactly like the setAttributes path (real
1796 // Gutenberg's createBlock already sanitizes undeclared attrs away — silently;
1797 // collecting them here is what makes the drop visible to the brain).
1798 function toBlock( spec, unknownSink ) {
1799 // Insert-time block-name gate (SaaS remediation plan E2E-F1,
1800 // live-observed 2026-06-11): an UNREGISTERED name must fail the op
1801 // typed-and-loud — createBlock would otherwise mint a missing-block
1802 // placeholder ("Unsupported block") the user has to delete by hand
1803 // (the model invented `core/input`/`core/textarea` and the page
1804 // collected junk until read-back). Same feedback philosophy as
1805 // unknown attrs: structured, model-actionable, never silent.
1806 if ( ! ( window.wp.blocks &&
1807 window.wp.blocks.getBlockType &&
1808 window.wp.blocks.getBlockType( spec.name ) ) ) {
1809 throw new Error(
1810 'unregistered_block_type: "' + spec.name + '" is not a registered block on this site — ' +
1811 'never invent block names. Use registered blocks (spectra/container, spectra/content, ' +
1812 'core/paragraph, …) or embed a real form via { "name": "srfm/form", "attrs": { "id": <formId> } }.'
1813 );
1814 }
1815 const inner = ( spec.innerBlocks || [] ).map( function ( s ) {
1816 return toBlock( s, unknownSink );
1817 } );
1818 // S4 (PR #282): strip the 8 GBS-banned visual attrs FIRST, exactly like the
1819 // updateBlockAttributes path (normalizePlanAttrs) — so insert/replace can't
1820 // paint a banned visual attr into a freshly-minted block even if a stale/
1821 // forked brain bundle folds one through. Defense-in-depth behind the brain
1822 // blockSpec zod; symmetric with the update path.
1823 const parts = partitionAttrs( spec.name, placeContent( spec.name, stripBannedAttrs( spec.attributes ) ) );
1824 if ( unknownSink && parts.unknown.length ) {
1825 Array.prototype.push.apply( unknownSink, parts.unknown );
1826 }
1827 return window.wp.blocks.createBlock( spec.name, parts.valid, inner );
1828 }
1829
1830 // updateBlockAttributes shallow-merges at the TOP key only, so deep-merge
1831 // plain-object values (layout, responsiveControls) to keep sibling nested keys.
1832 function deepMerge( cur, partial ) {
1833 const out = Object.assign( {}, cur );
1834 for ( const k in partial ) {
1835 if ( ! Object.prototype.hasOwnProperty.call( partial, k ) ) {
1836 continue;
1837 }
1838 const pv = partial[ k ];
1839 const cv = cur ? cur[ k ] : undefined;
1840 out[ k ] = isPlainObject( pv ) && isPlainObject( cv ) ? deepMerge( cv, pv ) : pv;
1841 }
1842 return out;
1843 }
1844
1845 // INVARIANT — gs-* identity is immutable on a className write. updateBlockAttributes
1846 // replaces className wholesale, so an incoming string keeps the block's CURRENT gs-*
1847 // tokens verbatim and contributes only its NON-gs (utility) tokens; foreign gs-* are
1848 // dropped. Prevents the Edit-5 clobber (a paragraph's `gs-0a6168-text` overwritten by
1849 // a heading's `gs-ae2ba6-h1`) — the executor owns identity, the model owns utilities.
1850 function reconcileClassName( currentClassName, incomingClassName ) {
1851 const isGs = function ( t ) {
1852 return t.indexOf( 'gs-' ) === 0;
1853 };
1854 const toTokens = function ( s ) {
1855 // eslint-disable-next-line eqeqeq
1856 return String( s == null ? '' : s ).trim().split( /\s+/ ).filter( Boolean );
1857 };
1858 const curGs = toTokens( currentClassName ).filter( isGs );
1859 const incUtil = toTokens( incomingClassName ).filter( function ( t ) {
1860 return ! isGs( t );
1861 } );
1862 const seen = Object.create( null );
1863 return curGs.concat( incUtil ).filter( function ( t ) {
1864 if ( seen[ t ] ) {
1865 return false;
1866 }
1867 seen[ t ] = true;
1868 return true;
1869 } ).join( ' ' );
1870 }
1871
1872 // Every listed block must actually be a child of `parent`. A re-order is only
1873 // meaningful among SIBLINGS, and Gutenberg does NOT refuse a non-child: with
1874 // from === to it takes its same-parent branch and computes
1875 // `subState.indexOf(clientIds[0])`, which is -1 for a stranger. The resulting
1876 // moveTo(order, -1, i) splices from the END, so the container's LAST child is
1877 // displaced while the block the plan actually named never moves — and the op
1878 // reports success. Refuse instead, on BOTH order paths: the derived parent
1879 // (below) and the caller-supplied one, which had no check at all.
1880 function assertOrderSiblings( sel, order, parent ) {
1881 ( order || [] ).forEach( function ( id ) {
1882 if ( ( sel.getBlockRootClientId( id ) || '' ) !== parent ) {
1883 throw new Error(
1884 'order_parent_mismatch: block ' + id + ' is not a child of the container being ' +
1885 're-ordered, so there is no single container to re-order these in. Re-order only ' +
1886 'siblings, or move them one at a time.'
1887 );
1888 }
1889 } );
1890 }
1891
1892 // The parent a bare `order` re-order should happen INSIDE — the parent the listed
1893 // blocks already share. '' (the page root) is a legitimate answer for a top-level
1894 // re-order. Falls back to '' only if the first id can't be resolved, which
1895 // liveness() surfaces separately.
1896 function orderParentOf( sel, order ) {
1897 const ids = order || [];
1898 const first = ids[ 0 ];
1899 if ( ! first ) {
1900 return '';
1901 }
1902 // Adopting the first id's parent when the others don't share it would
1903 // RELOCATE them into it — the very re-parenting this resolution exists to
1904 // prevent. (The brain now requires an explicit parent, so the derived path
1905 // is only reachable from an older or hand-built envelope.)
1906 const parent = sel.getBlockRootClientId( first ) || '';
1907 assertOrderSiblings( sel, ids, parent );
1908 return parent;
1909 }
1910
1911 // Realize an explicit child order within `parent` (reorder). Place each id at
1912 // its target index in sequence — earlier indices are already correct, so each
1913 // move lands the next block at position i.
1914 function reorderChildren( sel, dis, parent, order ) {
1915 // M2 — the order form previously bypassed EVERY lock check (its targets
1916 // ride `order`, not `clientIds`, so the runOp pre-check resolved no
1917 // targets). canMoveBlock per child folds in the parent's template/content
1918 // locks — a locked container's reorder now fails typed instead of
1919 // silently applying.
1920 order.forEach( function ( id ) {
1921 liveness( sel, id );
1922 assertMutable( sel, id, 'move' );
1923 } );
1924 // Same omitted-vs-null distinction the move path makes (KEEP_CURRENT_PARENT).
1925 // Collapsing both to '' re-parented EVERY listed block to the page root, so a
1926 // plain "re-order these cards" un-nested the whole row. Explicit null still
1927 // means the page root; omitted means "the parent these blocks already share".
1928 let root;
1929 if ( parent === null ) {
1930 root = '';
1931 assertOrderSiblings( sel, order, root );
1932 } else if ( parent === undefined ) {
1933 root = orderParentOf( sel, order );
1934 } else {
1935 root = parent;
1936 // An EXPLICIT parent was trusted blindly, so a plan naming a container
1937 // the blocks don't belong to silently mangled that container instead of
1938 // failing (see assertOrderSiblings). Same check, same refusal.
1939 assertOrderSiblings( sel, order, root );
1940 }
1941 for ( let i = 0; i < order.length; i++ ) {
1942 // Undo coalescing: each move after the first merges into the same
1943 // undo level so the whole reorder reverts in one ⌘Z. (The caller
1944 // already marked the first dispatch when this isn't the batch's
1945 // first change.)
1946 if ( i > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) {
1947 dis.__unstableMarkNextChangeAsNotPersistent();
1948 }
1949 dis.moveBlocksToPosition( [ order[ i ] ], root, root, i );
1950 }
1951 }
1952
1953 // L5 — THE single documented use of a private Gutenberg API in this
1954 // codebase. `__unstableMarkNextChangeAsNotPersistent` coalesces undo levels
1955 // (one ⌘Z per plan) and keeps typewriter ticks out of the undo stack.
1956 // DEGRADATION CONTRACT: it is feature-checked everywhere; if a future
1957 // Gutenberg removes/renames it, every call becomes a no-op and behaviour
1958 // degrades to MORE undo levels (one per dispatch) — correct, just noisier.
1959 // Nothing may ever depend on it for correctness, only for undo ergonomics.
1960 function markNonPersistent( dis ) {
1961 if ( dis && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) {
1962 dis.__unstableMarkNextChangeAsNotPersistent();
1963 }
1964 }
1965
1966 // H4 — saving is LOCKED while a typewriter stream is live. The persistent
1967 // full text is applied by runOp BEFORE the stream starts, but the stream
1968 // then clears the attr and re-types it word-by-word — so for words×28ms the
1969 // LIVE edited attribute is partial. A manual Ctrl+S or the 60s autosave in
1970 // that window serializes the truncated text into the post (non-persistent
1971 // suppresses the undo level, NOT what getEditedPostContent reads). Lock
1972 // via the core/editor store while ANY stream is active; unlock on drain.
1973 // Feature-checked: an editor without lockPostSaving keeps today's window
1974 // (no false lock, no throw).
1975 const TYPEWRITER_SAVE_LOCK = 'zipwp-vibe-typewriter';
1976 function lockSavingForTypewriter() {
1977 try {
1978 const ed = window.wp && window.wp.data && window.wp.data.dispatch( 'core/editor' );
1979 if ( ed && typeof ed.lockPostSaving === 'function' ) {
1980 ed.lockPostSaving( TYPEWRITER_SAVE_LOCK );
1981 }
1982 } catch ( e ) { /* never break the apply on a lock failure */ }
1983 }
1984 function unlockSavingForTypewriter() {
1985 try {
1986 const ed = window.wp && window.wp.data && window.wp.data.dispatch( 'core/editor' );
1987 if ( ed && typeof ed.unlockPostSaving === 'function' ) {
1988 ed.unlockPostSaving( TYPEWRITER_SAVE_LOCK );
1989 }
1990 } catch ( e ) { /* unlock is best-effort; the lock name is idempotent */ }
1991 }
1992
1993 // --- Typewriter (streaming text effect) -------------------------------
1994 // ANY text the agent writes — a rewrite (updateBlockAttributes) OR new copy in
1995 // an inserted/replaced block — STREAMS in word-by-word instead of snapping.
1996 // Ms between words; named single cadence source.
1997 const TYPEWRITER_WORD_MS = 28;
1998
1999 // Animation off when the host can't animate (no matchMedia — e.g. jsdom under
2000 // jest) or the user prefers reduced motion. Keeps the persistent full content.
2001 function typewriterDisabled() {
2002 try {
2003 return ! window.matchMedia ||
2004 window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
2005 } catch ( e ) {
2006 return true;
2007 }
2008 }
2009
2010 // Current rich-text content of a block (the first content attr that HAS text),
2011 // '' if none. The op-agnostic text identity we diff before/after an op.
2012 function contentOf( blk ) {
2013 const cattr = firstRichTextWithContent( blk );
2014 return cattr ? String( blk.attributes[ cattr ] ) : '';
2015 }
2016
2017 // Every clientId an op NAMES as an operand — generic id-bearing fields ONLY, no
2018 // switch on op.function. The before/after text diff over these (+ newly minted
2019 // ids) is what decides what streams, so no operation is ever special-cased.
2020 function opOperandIds( op ) {
2021 let ids = [];
2022 if ( op ) {
2023 if ( Array.isArray( op.clientIds ) ) {
2024 ids = ids.concat( op.clientIds );
2025 }
2026 // eslint-disable-next-line eqeqeq
2027 if ( op.rootClientId != null ) {
2028 ids.push( op.rootClientId );
2029 }
2030 // eslint-disable-next-line eqeqeq
2031 if ( op.clientId != null ) {
2032 ids.push( op.clientId );
2033 }
2034 }
2035 return ids;
2036 }
2037
2038 // First rich-text content attr of a block that actually HAS text — used to find
2039 // what to stream in a freshly inserted/replaced block.
2040 function firstRichTextWithContent( blk ) {
2041 const cattrs = richTextAttrsOf( blk.name );
2042 for ( let i = 0; i < cattrs.length; i++ ) {
2043 const k = cattrs[ i ];
2044 const v = ( blk.attributes || {} )[ k ];
2045 if ( typeof v === 'string' && v.trim() ) {
2046 return k;
2047 }
2048 }
2049 return null;
2050 }
2051
2052 // Active streams + the SINGLE shared ticker that advances them all together, so
2053 // a BULK operation (many blocks at once) types CONCURRENTLY off one timer.
2054 let _twStreams = [];
2055 let _twTimer = null;
2056 function _twTick() {
2057 let typing = false;
2058 for ( let s = 0; s < _twStreams.length; s++ ) {
2059 const st = _twStreams[ s ];
2060 if ( st.i >= st.tokens.length ) {
2061 continue;
2062 }
2063 // Liveness re-check: the block can vanish mid-stream (a later op in the
2064 // same plan removed/replaced it, or the user deleted it). Writing to a
2065 // gone block is a wasted dispatch that can warn — drop the stream.
2066 if ( st.sel && ! st.sel.getBlock( st.id ) ) {
2067 st.i = st.tokens.length; continue;
2068 }
2069 // H4 — a throw from updateBlockAttributes must NOT escape the tick:
2070 // it would skip the drain/unlock below and leave post-saving locked
2071 // for the rest of the session (the whole page becomes unsavable).
2072 // The persistent full content is already applied by the caller, so a
2073 // failed cosmetic type-in is safe to abandon — drop just that stream.
2074 try {
2075 st.acc += st.tokens[ st.i ];
2076 st.i++;
2077 markNonPersistent( st.dis ); // VISUAL only — never an undo level
2078 const a = {};
2079 a[ st.contentAttr ] = st.acc;
2080 st.dis.updateBlockAttributes( st.id, a );
2081 } catch ( e ) {
2082 console.warn( '[ZIP AI] typewriter stream aborted for block', st.id, e ); // eslint-disable-line no-console -- intentional error surfacing
2083 st.i = st.tokens.length; // mark done so the drain path runs
2084 continue;
2085 }
2086 if ( st.i < st.tokens.length ) {
2087 typing = true;
2088 }
2089 }
2090 if ( typing ) {
2091 _twTimer = setTimeout( _twTick, TYPEWRITER_WORD_MS ); return;
2092 }
2093 _twStreams = [];
2094 _twTimer = null;
2095 // H4 — every stream drained: the live attrs hold full text again, so
2096 // saving is safe. (Unlock here, the ONLY drain point.)
2097 unlockSavingForTypewriter();
2098 }
2099
2100 // VISUAL typewriter overlay for block `id`'s content attr. The REAL change (the
2101 // full `target` content) is ALREADY applied PERSISTENTLY by the caller; this
2102 // only adds a non-persistent type-in, so it NEVER touches undo and works the
2103 // same for a rewrite or a freshly-inserted block. Clears the content
2104 // SYNCHRONOUSLY (same frame as the op → no flash of the full text) then types
2105 // it back word-by-word off the shared ticker. No-op when animation is disabled
2106 // or the text is too short (leaves the persistent full content).
2107 function typewriterStream( dis, sel, id, contentAttr, target ) {
2108 if ( typewriterDisabled() ) {
2109 return;
2110 }
2111 // eslint-disable-next-line eqeqeq
2112 const tokens = String( target != null ? target : '' ).split( /(\s+)/ );
2113 if ( tokens.length <= 2 ) {
2114 return;
2115 }
2116 // Same-block dedup: if a stream for this block+attr is already active (an
2117 // earlier op in the plan touched the same block), DROP it — two tickers
2118 // writing one block's content fight and garble. Last write wins.
2119 _twStreams = _twStreams.filter( function ( st ) {
2120 return ! ( st.id === id && st.contentAttr === contentAttr );
2121 } );
2122 // H4 — clear FIRST, then take the save-lock. If markNonPersistent or the
2123 // clear dispatch throws we have NOT locked yet, so the exception
2124 // propagates cleanly with no lock to leak (the earlier version locked
2125 // first, so a throwing clear left post-saving locked for the whole
2126 // session — the exact bug this guards). Once the clear succeeds the live
2127 // attribute is blank; lock on the very next statement (no async gap, so
2128 // no save can interleave) and hold it until _twTick drains + unlocks.
2129 // Idempotent per lock name, so a bulk plan's many streams lock once — and
2130 // if a later stream's clear throws, an already-running ticker still owns
2131 // the lock and releases it on drain.
2132 markNonPersistent( dis );
2133 const clear = {};
2134 clear[ contentAttr ] = '';
2135 dis.updateBlockAttributes( id, clear );
2136 lockSavingForTypewriter();
2137 _twStreams.push( { dis, sel, id, contentAttr, tokens, i: 0, acc: '' } );
2138 if ( ! _twTimer ) {
2139 _twTimer = setTimeout( _twTick, TYPEWRITER_WORD_MS );
2140 }
2141 }
2142
2143 // Snapshot the rich-text content of block subtrees by clientId (BEFORE an op),
2144 // into `out`. Recurses innerBlocks so a container op also captures its children.
2145 function snapshotContent( sel, ids, out ) {
2146 ids.forEach( function ( cid ) {
2147 const root = sel.getBlock( cid );
2148 if ( ! root ) {
2149 return;
2150 }
2151 ( function walk( b ) {
2152 out[ b.clientId ] = contentOf( b );
2153 if ( b.innerBlocks ) {
2154 b.innerBlocks.forEach( walk );
2155 }
2156 }( root ) );
2157 } );
2158 }
2159
2160 // SSOT — the ONLY place streaming is decided, AFTER an op: stream every
2161 // rich-text block (within the op's operand subtrees OR newly minted) whose text
2162 // CHANGED vs the pre-op snapshot, or is NEW (no snapshot). It's a pure text
2163 // diff — op.function is NEVER inspected, so a rewrite, an insert, a replace, or
2164 // any future operation all get the effect identically; a move / remove / style-
2165 // only change leaves the text equal and streams nothing.
2166 function streamChangedText( dis, sel, ids, before ) {
2167 if ( typewriterDisabled() ) {
2168 return;
2169 }
2170 const seen = {};
2171 ids.forEach( function ( cid ) {
2172 const root = sel.getBlock( cid );
2173 if ( ! root ) {
2174 return;
2175 }
2176 ( function walk( b ) {
2177 if ( seen[ b.clientId ] ) {
2178 return;
2179 }
2180 seen[ b.clientId ] = true;
2181 const cattr = firstRichTextWithContent( b );
2182 if ( cattr ) {
2183 const now = String( b.attributes[ cattr ] );
2184 if ( now && now !== ( before[ b.clientId ] || '' ) ) {
2185 typewriterStream( dis, sel, b.clientId, cattr, now );
2186 }
2187 }
2188 if ( b.innerBlocks ) {
2189 b.innerBlocks.forEach( walk );
2190 }
2191 }( root ) );
2192 } );
2193 }
2194
2195 // Apply ONE change. Returns { new_client_ids?, result_text?, deferRebase? }
2196 // or throws a typed Error. `isAnchor` (batch's first applied change) controls
2197 // the streaming-text undo anchor; it's irrelevant to the non-animated kinds.
2198 // Resolve an anchor-relative placement (insert/move "before"/"after" a
2199 // sibling block) into the {parent, index} the dispatch API needs — using the
2200 // LIVE store (getBlockRootClientId + getBlockIndex). This is why the MODEL no
2201 // longer computes parent/index: it just names the neighbour and the layer that
2202 // OWNS the tree resolves the coordinates (closing the parent:null move-to-page-
2203 // root failure). A stale anchor throws the typed stale_client_id miss via
2204 // liveness → failed[]. parent is '' for page root (matches getBlockRootClientId
2205 // + the move toRoot convention). `position` is 'before' | 'after'.
2206 function resolveAnchor( sel, anchorId, position ) {
2207 liveness( sel, anchorId );
2208 return {
2209 parent: sel.getBlockRootClientId( anchorId ) || '',
2210 index: sel.getBlockIndex( anchorId ) + ( position === 'after' ? 1 : 0 ),
2211 };
2212 }
2213
2214 // Pull the anchor (before/after) off a change, if present. before/after are
2215 // mutually exclusive (the brain superRefine enforces it); after wins only if
2216 // both somehow arrive.
2217 function anchorOf( c ) {
2218 if ( c.after !== undefined ) {
2219 return { id: c.after, position: 'after' };
2220 }
2221 if ( c.before !== undefined ) {
2222 return { id: c.before, position: 'before' };
2223 }
2224 return null;
2225 }
2226
2227 // Blast radius of a DESTRUCTIVE op — the reverse edge the editor otherwise
2228 // never computes. Returns the JS on OTHER blocks that targets an anchor inside
2229 // the removed subtree (their `getElementById(deletedAnchor)` goes null and
2230 // THROWS, killing that block's whole spectraCustomJS IIFE — not a clean no-op).
2231 // SURFACED, not blocked: the model relays it to the user, and native undo /
2232 // "don't save" restores the deleted block. Computed BEFORE the dispatch (while
2233 // the blocks still exist) via the shared dependents SSOT.
2234 // The removed blocks' OWN subtree (ids + all descendants), walked via
2235 // getBlockOrder. NOT sel.getClientIdsWithDescendants( clientIds ) — that WP
2236 // selector ignores its argument and returns EVERY page id, so `subtree`
2237 // covered the whole page, anchorDependents excluded everything, and
2238 // orphaned_js was ALWAYS []; the impact warning never fired in production.
2239 function subtreeOf( sel, clientIds ) {
2240 const out = [];
2241 const stack = ( clientIds || [] ).slice();
2242 while ( stack.length ) {
2243 const id = stack.pop();
2244 out.push( id );
2245 const kids = sel.getBlockOrder ? sel.getBlockOrder( id ) : [];
2246 for ( let i = 0; i < kids.length; i++ ) {
2247 stack.push( kids[ i ] );
2248 }
2249 }
2250 return out;
2251 }
2252 function removalImpact( sel, clientIds ) {
2253 const u = sharedEditorUtils();
2254 if ( ! u || ! u.anchorDependents || ! clientIds || ! clientIds.length ) {
2255 return undefined;
2256 }
2257 const subtree = subtreeOf( sel, clientIds );
2258 const anchors = [];
2259 subtree.forEach( function ( id ) {
2260 const a = sel.getBlockAttributes ? sel.getBlockAttributes( id ) : null;
2261 if ( a && typeof a.anchor === 'string' && a.anchor !== '' ) {
2262 anchors.push( a.anchor );
2263 }
2264 } );
2265 if ( ! anchors.length ) {
2266 return undefined;
2267 }
2268 const orphaned = u.anchorDependents( sel, anchors, subtree );
2269 return orphaned.length ? { orphaned_js: orphaned } : undefined;
2270 }
2271
2272 // --- Computed-effect verification --------------------------------------
2273 // A className edit can be SET yet paint NOTHING — a phantom token
2274 // (bg-neutral-900), or a gs- !important rule winning the cascade. Rather than
2275 // classify the className, we ask the only question that matters: did the
2276 // PAINTED style move? Snapshot the target's FULL computed style before +
2277 // after dispatch; a setAttributes that changed the class string but moved
2278 // ZERO computed values is a silent no-op. The brain nudges on it instead of
2279 // reporting a false success. (No token list / regex — the rendered result is
2280 // the source of truth; this also catches specificity loss, not just phantoms.)
2281 function canvasCtx() {
2282 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' );
2283 return iframe && iframe.contentDocument
2284 ? { doc: iframe.contentDocument, win: iframe.contentWindow }
2285 : { doc: document, win: window };
2286 }
2287 function computedSnapshot( clientId ) {
2288 const ctx = canvasCtx();
2289 const el = ctx.doc.querySelector( '[data-block="' + clientId + '"]' );
2290 if ( ! el ) {
2291 return null;
2292 }
2293 const cs = ctx.win.getComputedStyle( el );
2294 let out = '';
2295 for ( let i = 0; i < cs.length; i++ ) {
2296 out += cs[ i ] + ':' + cs.getPropertyValue( cs[ i ] ) + ';';
2297 }
2298 return out;
2299 }
2300 // True when the computed style moved (or we can't compare — never a false noop).
2301 function computedChanged( before, after ) {
2302 return ! before || ! after ? true : before !== after;
2303 }
2304 // The GBS utility stylesheet text (~1MB). L6 — memoized per source element,
2305 // invalidated by the parsed sheet's RULE COUNT (cheap — no string
2306 // materialization to compare): the live JIT only ever APPENDS rules, so a
2307 // changed count is the exact "stylesheet grew" signal. When the CSSOM sheet
2308 // is unreadable (detached element) we fall through to a fresh read. The
2309 // rare fallback path (no id'd element) stays unmemoized.
2310 let _utilCssCache = null; // { el, ruleCount, css }
2311 function utilSheetRuleCount( el ) {
2312 try {
2313 return el.sheet && el.sheet.cssRules ? el.sheet.cssRules.length : -1;
2314 } catch ( e ) {
2315 return -1;
2316 }
2317 }
2318 // CSS injected by ensureLiveUtilityCss (tokens compiled server-side after a
2319 // live insert). Appended to every readUtilityCss() result so the no-op /
2320 // hasLiveUtility checks see the just-injected rules as real utilities.
2321 function liveJitCssText( doc ) {
2322 const el = doc.getElementById( 'zipwp-gbs-live-jit' );
2323 return el ? ( el.textContent || '' ) : '';
2324 }
2325 function readUtilityCss() {
2326 const ctx = canvasCtx();
2327 const el = ctx.doc.getElementById( 'spectra-gs-utility-classes-inline-css' );
2328 if ( el ) {
2329 const count = utilSheetRuleCount( el );
2330 if ( _utilCssCache && _utilCssCache.el === el && count !== -1 && _utilCssCache.ruleCount === count ) {
2331 return _utilCssCache.css + liveJitCssText( ctx.doc );
2332 }
2333 const text = el.textContent || '';
2334 _utilCssCache = { el, ruleCount: count, css: text };
2335 return text + liveJitCssText( ctx.doc );
2336 }
2337 let css = '';
2338 const styles = ctx.doc.querySelectorAll( 'style' );
2339 for ( let s = 0; s < styles.length; s++ ) {
2340 css += styles[ s ].textContent || '';
2341 }
2342 return css;
2343 }
2344 // A recognized GBS utility (bg-base-800, text-primary-500, p-10, …) is NOT a
2345 // no-op even when the live editor canvas doesn't move. The editor's live JIT
2346 // emits the utility at low specificity (`:root .token`, 0,2,0), which LOSES to
2347 // a per-block gs- style's editor selector (`.editor-styles-wrapper
2348 // [class*="wp-block"].gs-…`, 0,3,0) — so the canvas preview stays put. But the
2349 // SERVER JIT re-emits the SAME utility at `:root .token×5` (0,5,0) on save +
2350 // frontend, where it WINS and paints. So a className whose tokens are real
2351 // utilities (their `.token` rule exists in the GBS stylesheet) WILL render on
2352 // save; flagging it `noop` would nudge the brain into a false "couldn't style
2353 // it" retry loop. Only a token with NO matching rule (a typo / phantom like
2354 // bg-neutral-900) is a true no-op. Substring match (cheap; the JIT writes
2355 // `.token{…}`); gs-/semantic classes carry no `.token` utility rule so they
2356 // don't false-trigger.
2357 function classNameHasLiveUtility( className, css ) {
2358 if ( typeof className !== 'string' || className.trim() === '' || ! css ) {
2359 return false;
2360 }
2361 const tokens = className.trim().split( /\s+/ );
2362 for ( let t = 0; t < tokens.length; t++ ) {
2363 const tok = tokens[ t ];
2364 if ( ! tok || tok.indexOf( 'gs-' ) === 0 ) {
2365 continue;
2366 } // gs- = per-block style
2367 // Match the token as a COMPLETE class selector, not a prefix: the JIT
2368 // writes `.token{` / `.token:hover` / `.token,`, so the char after the
2369 // token must be a selector boundary — a `\w`/`-` after it means the token
2370 // is only a PREFIX of a real rule. Without this, a truncated token
2371 // (`bg-base`) substring-matches `.bg-base-800` and a phantom class is
2372 // exempted from the no-op check → reported as painted (silent false success).
2373 const esc = tok.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
2374 if ( new RegExp( '\\.' + esc + '(?![\\w-])' ).test( css ) ) {
2375 return true;
2376 }
2377 }
2378 return false;
2379 }
2380
2381 // ── Phantom-animation detection (the live-found `animate-bounce` no-op) ─────
2382 // An `animate-<ident>` utility compiles to a `.animate-<ident>{ animation:
2383 // <ident> … }` RULE even when NO `@keyframes <ident>` is registered on the
2384 // site (the JIT's animate-<ident> fallback + a Tailwind default like `bounce`
2385 // that the GBS system never defines). So the class "applies" and
2386 // classNameHasLiveUtility sees a rule — but with the keyframes missing the
2387 // browser paints NOTHING. That is why a bundled `border-red-500 animate-bounce`
2388 // reported applied (the border moved computed style) while the bounce silently
2389 // did nothing. We detect it from the RESOLVED cascade, not the token: read the
2390 // element's computed `animation-name` and flag any name (≠ `none`) that has no
2391 // matching `@keyframes` rule. Resolved-name based, so it's blind to how the
2392 // class maps to a keyframe (preset vs fallback) and never false-flags a
2393 // preset whose keyframes DO exist.
2394
2395 // Does the canvas define `@keyframes <ident>`? Read via the CSSOM
2396 // (CSSKeyframesRule — rule type 7 — carries a `.name`), walking @media groups,
2397 // and skipping unreadable (cross-origin) sheets. Pure over (doc, ident).
2398 function hasKeyframesRule( doc, ident ) {
2399 if ( ! doc || ! ident ) {
2400 return false;
2401 }
2402 function walk( rules ) {
2403 for ( let i = 0; i < rules.length; i++ ) {
2404 const r = rules[ i ];
2405 // CSSRule.KEYFRAMES_RULE === 7.
2406 if ( r && r.type === 7 && r.name === ident ) {
2407 return true;
2408 }
2409 // Recurse into ANY grouping rule that carries child rules — @media
2410 // (4) and @supports (12) plus @layer / @container, whose legacy
2411 // numeric `type` is 0 and would otherwise be skipped, hiding
2412 // keyframes nested inside them. (Skip type 7 — its children are the
2413 // keyframe steps, already handled by the name check above.)
2414 if ( r && r.type !== 7 && r.cssRules && walk( r.cssRules ) ) {
2415 return true;
2416 }
2417 }
2418 return false;
2419 }
2420 // Scan both regular sheets and constructable adoptedStyleSheets (Gutenberg
2421 // / Spectra inject some canvas CSS this way; doc.styleSheets omits them).
2422 const styleSheets = ( doc && doc.styleSheets ) || [];
2423 const adopted = ( doc && doc.adoptedStyleSheets ) || [];
2424 const sheets = [];
2425 for ( let i = 0; i < styleSheets.length; i++ ) {
2426 sheets.push( styleSheets[ i ] );
2427 }
2428 for ( let i = 0; i < adopted.length; i++ ) {
2429 sheets.push( adopted[ i ] );
2430 }
2431 for ( let s = 0; s < sheets.length; s++ ) {
2432 let rules;
2433 try {
2434 rules = sheets[ s ].cssRules || sheets[ s ].rules;
2435 } catch ( e ) {
2436 continue;
2437 } // cross-origin
2438 if ( rules && walk( rules ) ) {
2439 return true;
2440 }
2441 }
2442 return false;
2443 }
2444
2445 // Names in a computed `animation-name` value that have NO `@keyframes` rule →
2446 // phantom motion (paints nothing). Pure over (animationNameValue, doc) so a
2447 // unit test locks it. `none` and empty are ignored; duplicates collapse.
2448 function missingKeyframeNames( animationNameValue, doc ) {
2449 if ( typeof animationNameValue !== 'string' ) {
2450 return [];
2451 }
2452 const names = animationNameValue.split( ',' )
2453 .map( function ( n ) {
2454 return n.trim();
2455 } )
2456 .filter( function ( n ) {
2457 return n && n !== 'none';
2458 } );
2459 const missing = [];
2460 for ( let i = 0; i < names.length; i++ ) {
2461 if ( missing.indexOf( names[ i ] ) === -1 && ! hasKeyframesRule( doc, names[ i ] ) ) {
2462 missing.push( names[ i ] );
2463 }
2464 }
2465 return missing;
2466 }
2467
2468 // The phantom animation names actually resolved on a block (its computed
2469 // animation-name minus any that have keyframes). '' / unreadable node → [].
2470 function phantomAnimationsOnBlock( clientId ) {
2471 const ctx = canvasCtx();
2472 const el = ctx.doc.querySelector( '[data-block="' + clientId + '"]' );
2473 if ( ! el ) {
2474 return [];
2475 }
2476 const cs = ctx.win.getComputedStyle( el );
2477 return missingKeyframeNames( cs.getPropertyValue( 'animation-name' ), ctx.doc );
2478 }
2479
2480 // boostUtilitySpecificity + utilityRuleBody REMOVED — their own stated
2481 // REMOVAL CONDITION ("once editor-side utilities reach parity with the
2482 // server JIT") is met by ensureLiveUtilityCss below, which injects the
2483 // server compiler's OWN output (×5 base + media-wrapped responsive
2484 // variants) for every token a plan touches. The boost was also the root
2485 // cause of the stacked-grid-until-reload bug: it re-emitted BASE tokens
2486 // (e.g. grid-cols-1) at ×5 specificity WITHOUT their media context, so a
2487 // boosted base beat its md:/lg: variant siblings at every viewport width
2488 // until a reload dropped the session-scoped boost element. Any lingering
2489 // `zipwp-gbs-editor-boost` element from an older session dies on reload.
2490
2491 // ── LIVE-JIT for tokens the canvas has never seen ─────────────────────────
2492 // ROOT CAUSE this closes: the per-post JIT stylesheet is compiled from SAVED
2493 // content at editor load (spectra-blocks Engine::enqueue_jit_for_current_post
2494 // → JitCache::get_for_post). Blocks inserted programmatically AFTER load
2495 // (Vibe Editing section inserts) can carry utility tokens with NO rule
2496 // anywhere in the canvas — they render unstyled (grids stack) until the next
2497 // save + reload. Fix at the source of truth: ask Spectra's OWN compiler
2498 // (REST /spectra-blocks/v1/global-styles/jit-compile — the same JitCompiler
2499 // that runs at save) for the missing tokens' CSS and inject it into the
2500 // canvas BEFORE the settle/no-op check reads computed styles, so the live
2501 // preview and the verification both converge to saved-page truth instantly.
2502 // Session-deduped per token; network-tolerant (the rpc reply must never
2503 // hang on this — hard 1500ms cap, failure = canvas converges on save).
2504 const _liveJitRequested = {}; // token → 1 (session-scoped request dedupe)
2505 function collectPlanClassNames( ops ) {
2506 const out = [];
2507 function fromBlocks( blocks ) {
2508 if ( ! blocks ) {
2509 return;
2510 }
2511 for ( let b = 0; b < blocks.length; b++ ) {
2512 const blk = blocks[ b ];
2513 if ( ! blk || typeof blk !== 'object' ) {
2514 continue;
2515 }
2516 const cn = blk.attributes && blk.attributes.className;
2517 if ( typeof cn === 'string' && cn.trim() !== '' ) {
2518 out.push( cn );
2519 }
2520 fromBlocks( blk.innerBlocks );
2521 }
2522 }
2523 for ( let i = 0; i < ops.length; i++ ) {
2524 const op = ops[ i ] || {};
2525 if ( op.attributes && typeof op.attributes.className === 'string' ) {
2526 out.push( op.attributes.className );
2527 }
2528 fromBlocks( op.blocks );
2529 }
2530 return out;
2531 }
2532 function ensureLiveUtilityCss( classNames ) {
2533 try {
2534 const ctx = canvasCtx();
2535 // Compile EVERY touched token (session-deduped) — not only the ones
2536 // with no live rule. The live/static sheets carry tokens at MIXED
2537 // specificities (static 0,2,0; persisted dynamic ×5), and partial
2538 // injection breaks the responsive cascade: a base token already
2539 // present at ×5 (e.g. grid-cols-1) beats a freshly-injected
2540 // md:/lg: variant unless the variant arrives at the SAME server
2541 // parity alongside it. Server output is the SSOT cascade — inject
2542 // it wholesale.
2543 const missing = [];
2544 for ( let c = 0; c < classNames.length; c++ ) {
2545 const toks = String( classNames[ c ] ).trim().split( /\s+/ );
2546 for ( let t = 0; t < toks.length; t++ ) {
2547 const tok = toks[ t ];
2548 if ( ! tok || tok.indexOf( 'gs-' ) === 0 || _liveJitRequested[ tok ] ) {
2549 continue;
2550 }
2551 _liveJitRequested[ tok ] = 1;
2552 missing.push( tok );
2553 }
2554 }
2555 if ( ! missing.length ) {
2556 return Promise.resolve();
2557 }
2558 const apiFetch = window.wp && window.wp.apiFetch;
2559 if ( ! apiFetch ) {
2560 return Promise.resolve();
2561 }
2562 const fetchP = apiFetch( {
2563 path: '/spectra-blocks/v1/global-styles/jit-compile',
2564 method: 'POST',
2565 data: { class_strings: [ missing.join( ' ' ) ] },
2566 } ).then( function ( res ) {
2567 const css = res && typeof res.css === 'string' ? res.css : '';
2568 if ( ! css ) {
2569 return;
2570 }
2571 let styleEl = ctx.doc.getElementById( 'zipwp-gbs-live-jit' );
2572 if ( ! styleEl ) {
2573 styleEl = ctx.doc.createElement( 'style' );
2574 styleEl.id = 'zipwp-gbs-live-jit';
2575 ( ctx.doc.head || ctx.doc.documentElement ).appendChild( styleEl );
2576 }
2577 styleEl.textContent = ( styleEl.textContent || '' ) + '\n' + css;
2578 } ).catch( function () {
2579 // Un-mark on FAILURE so a later plan retries. Marking at collection
2580 // time is what dedupes a burst inside one plan, but keeping the mark
2581 // after a failed request meant one transient REST error (or a site
2582 // whose spectra-blocks predates the jit-compile route, where EVERY
2583 // request 404s) left those tokens uncompiled for the whole editor
2584 // session. Two user-visible consequences, not one: the section renders
2585 // unstyled until save+reload, AND classNameHasLiveUtility then reads a
2586 // correctly-applied className as a no-op — so the paint check reports a
2587 // false no-op and the brain nudges the model to "fix" working styling.
2588 // Not un-marked on the 1500ms cap: that request is still in flight and
2589 // will inject when it lands, so a retry there would just duplicate it.
2590 for ( let m = 0; m < missing.length; m++ ) {
2591 delete _liveJitRequested[ missing[ m ] ];
2592 }
2593 } );
2594 const capP = new Promise( function ( resolve ) {
2595 setTimeout( resolve, 1500 );
2596 } );
2597 return Promise.race( [ fetchP, capP ] );
2598 } catch ( e ) {
2599 return Promise.resolve();
2600 }
2601 }
2602
2603 // ── Persist a generated section's GBS class bodies (DEFERRED to Save) ─────
2604 // A generate_section insert carries `args.styles` (section-scoped schema-v1
2605 // buckets: classes/wrapperStyles/mediaQuery) authored with the site's SEMANTIC
2606 // tokens (var(--primary)/var(--heading)/…). ensureLiveUtilityCss above cannot
2607 // help — it SKIPS gs-* tokens because the JIT only compiles the utility ramp.
2608 // These custom class bodies do NOT ride the block className, so they need the
2609 // page GBS store — but writing it NOW would persist to the DB before the user
2610 // Saves (breaking the "nothing hits the DB until Save" contract and orphaning
2611 // CSS if they discard). So we QUEUE it (shared.queueSectionGbsForSave): it
2612 // RENDERS + injects the CSS for a live PREVIEW immediately (a pure compile — no
2613 // DB write) and flushes the payload to the store only when the editor completes
2614 // a real Save. Best-effort: a preview failure logs and the section converges on
2615 // Save; it NEVER fails the insert.
2616 function persistSectionStyles( styles, postId ) {
2617 try {
2618 const apiFetch = window.wp && window.wp.apiFetch;
2619 const u = sharedEditorUtils();
2620 if ( ! apiFetch || ! u || ! u.queueSectionGbsForSave ) {
2621 return Promise.resolve();
2622 }
2623 const persistP = u.queueSectionGbsForSave( apiFetch, styles, postId ).catch( function ( e ) {
2624 // eslint-disable-next-line no-console -- developer signal; the section still inserts + persists on Save
2625 console.warn( '[ZIP AI:apply-change] section GBS preview failed — section may render unstyled until Save', e );
2626 } );
2627 // The preview render folds into the reply-blocking liveCssReady below. A
2628 // REST *rejection* is caught above, but a stalled/hung socket never
2629 // settles, which would hold the apply_change reply until the brain's RPC
2630 // timeout (the false-timeout -> duplicate-content failure the settle
2631 // tuning avoids). Cap it the same way ensureLiveUtilityCss caps its fetch
2632 // so the reply always fires; the paint still converges on Save.
2633 const capP = new Promise( function ( resolve ) {
2634 setTimeout( resolve, 1500 );
2635 } );
2636 return Promise.race( [ persistP, capP ] );
2637 } catch ( e ) {
2638 return Promise.resolve();
2639 }
2640 }
2641
2642 // ── Serialized Gutenberg execution plan ({ version, operations[] }). One
2643 // operation = ONE real wp.data.dispatch('core/block-editor') function + named
2644 // args, keyed on op.function. Each op reuses the shared guards
2645 // (createBlock/placeContent/partitionAttrs/stripBannedAttrs/resolveAnchor/
2646 // liveness/deepMerge). The brain zod already coerced grid + ran the GBS
2647 // denylist; we re-run them (defense-in-depth) so a direct/forked caller can't
2648 // crash the canvas or paint banned props.
2649
2650 function coerceGridLayoutLengthAttrs( attrs ) {
2651 if ( ! isPlainObject( attrs ) ) {
2652 return attrs;
2653 }
2654 function coerce( layout ) {
2655 if ( ! isPlainObject( layout ) || typeof layout.minimumColumnWidth !== 'number' ) {
2656 return layout;
2657 }
2658 const c = {};
2659 for ( const k in layout ) {
2660 if ( Object.prototype.hasOwnProperty.call( layout, k ) ) {
2661 c[ k ] = layout[ k ];
2662 }
2663 }
2664 c.minimumColumnWidth = layout.minimumColumnWidth + 'px';
2665 return c;
2666 }
2667 const out = {};
2668 for ( const k in attrs ) {
2669 if ( Object.prototype.hasOwnProperty.call( attrs, k ) ) {
2670 out[ k ] = attrs[ k ];
2671 }
2672 }
2673 if ( 'layout' in out ) {
2674 out.layout = coerce( out.layout );
2675 }
2676 if ( isPlainObject( out.responsiveControls ) ) {
2677 const rc = {};
2678 for ( const bp in out.responsiveControls ) {
2679 if ( ! Object.prototype.hasOwnProperty.call( out.responsiveControls, bp ) ) {
2680 continue;
2681 }
2682 const v = out.responsiveControls[ bp ];
2683 rc[ bp ] = ( isPlainObject( v ) && 'layout' in v )
2684 ? Object.assign( {}, v, { layout: coerce( v.layout ) } )
2685 : v;
2686 }
2687 out.responsiveControls = rc;
2688 }
2689 return out;
2690 }
2691
2692 // Image-attribute routing: a source tool (find-assets / import-media /
2693 // ai_generate_image) yields { id, url } and the agent sets a UNIFORM
2694 // { url, id, alt } on the target block. Map that to the block's REAL image
2695 // attribute so it lands regardless of block type — most importantly a Spectra
2696 // container BACKGROUND, which lives in `background:{ type:'image', media:{
2697 // id, url, type:'image' } }` (NOT a bare `url`, and NOT the `overlay*` keys —
2698 // those are a separate tint ON TOP of the background). This mirrors exactly
2699 // what Spectra's own Background control writes (components/background onSelect).
2700 // Keyed off the block's REGISTERED schema (not a hardcoded name): a native
2701 // image block (has `url`) keeps {url,id,alt}; a block exposing a `background`
2702 // object gets the background-media shape. deepMerge at apply time preserves
2703 // the container's other background keys (position/size/repeat). Conservative:
2704 // only fires on a bare image url/id with no agent-supplied `background` object.
2705 // Pull an { id, url } image ref out of a candidate object (any of the
2706 // url/id-bearing shapes weak models emit).
2707 function pickImageRef( o ) {
2708 if ( ! isPlainObject( o ) ) {
2709 return null;
2710 }
2711 const url = typeof o.url === 'string' && o.url !== '' ? o.url : undefined;
2712 const id = typeof o.id === 'number'
2713 ? o.id
2714 : ( typeof o.id === 'string' && o.id !== '' ? o.id : undefined );
2715 return url !== undefined || id !== undefined ? { url, id } : null;
2716 }
2717
2718 // Find the image the agent intends, from EITHER a bare { url, id } (the
2719 // contract) OR a mis-built `background` object — models routinely invent the
2720 // old UAGB shape (`background.backgroundImageDesktop`) or guess `media`/`image`
2721 // sub-keys. We extract the ref from any of them so the SHAPE the model used
2722 // can't break the write. Overlay keys are intentionally NOT consulted — an
2723 // overlay is a separate tint, not the background.
2724 function extractImageRef( attrs ) {
2725 const bare = pickImageRef( attrs );
2726 if ( bare ) {
2727 return bare;
2728 }
2729 const bg = attrs.background;
2730 if ( isPlainObject( bg ) ) {
2731 const keys = [ 'media', 'backgroundImageDesktop', 'image', 'imageDesktop', 'backgroundImage' ];
2732 for ( let i = 0; i < keys.length; i++ ) {
2733 const ref = pickImageRef( bg[ keys[ i ] ] );
2734 if ( ref ) {
2735 return ref;
2736 }
2737 }
2738 }
2739 return null;
2740 }
2741
2742 function normalizeImageAttrs( name, attrs, currentAttrs ) {
2743 if ( ! isPlainObject( attrs ) ) {
2744 return attrs;
2745 }
2746 const registered = registeredAttrKeysOf( name );
2747 if ( registered === null ) {
2748 return attrs;
2749 } // unknown block — degrade open
2750 // Native image block (core/image, spectra/image) takes {url,id,alt} as-is.
2751 if ( registered.indexOf( 'url' ) !== -1 ) {
2752 return attrs;
2753 }
2754 // Background-capable container (Spectra): the bg image lives in
2755 // background.{type:'image', media:{id,url,type:'image'}} — mirror exactly
2756 // what Spectra's Background control writes. We REBUILD it from the
2757 // extracted ref (ignoring the model's invented sub-keys), so any input
2758 // shape lands. deepMerge at apply preserves the block's other background
2759 // keys (position/size/repeat).
2760 if ( registered.indexOf( 'background' ) === -1 ) {
2761 return attrs;
2762 }
2763 const ref = extractImageRef( attrs );
2764 if ( ref === null ) {
2765 return attrs;
2766 } // no image intent (pure style/overlay edit)
2767 const out = {};
2768 Object.keys( attrs ).forEach( function ( k ) {
2769 if ( k !== 'url' && k !== 'id' && k !== 'alt' && k !== 'background' ) {
2770 out[ k ] = attrs[ k ];
2771 }
2772 } );
2773 function freshMedia() {
2774 const m = { type: 'image' };
2775 if ( ref.id !== undefined ) {
2776 m.id = ref.id;
2777 }
2778 if ( ref.url !== undefined ) {
2779 m.url = ref.url;
2780 }
2781 return m;
2782 }
2783 out.background = { type: 'image', media: freshMedia() };
2784 // A Spectra container ALSO holds per-breakpoint background overrides under
2785 // responsiveControls.{lg,md,sm}.background. The renderer reads the active
2786 // device's override FIRST, so a base-only update leaves the old image
2787 // showing at any breakpoint that has its own image background. Mirror the
2788 // new image into every responsive breakpoint that CURRENTLY has an image
2789 // background (don't create one where there isn't, and don't touch a
2790 // color/gradient/video override). deepMerge at apply preserves each
2791 // breakpoint's other keys (layout/height).
2792 const rc = currentAttrs && currentAttrs.responsiveControls;
2793 if ( isPlainObject( rc ) ) {
2794 // Merge the mirror ON TOP of any responsiveControls the agent set in
2795 // THIS op (out already carries it from the copy above) — replacing it
2796 // wholesale would drop a same-op RC edit (e.g. responsiveControls.md.layout).
2797 const rcOut = isPlainObject( out.responsiveControls ) ? Object.assign( {}, out.responsiveControls ) : {};
2798 let mirrored = false;
2799 Object.keys( rc ).forEach( function ( device ) {
2800 const dbg = rc[ device ] && rc[ device ].background;
2801 if ( isPlainObject( dbg ) && dbg.type === 'image' ) {
2802 // Per-device shallow merge: keep the agent's other per-device
2803 // keys (layout/height) for this breakpoint, swap the background.
2804 rcOut[ device ] = Object.assign( {}, rcOut[ device ], {
2805 background: { type: 'image', media: freshMedia() },
2806 } );
2807 mirrored = true;
2808 }
2809 } );
2810 if ( mirrored ) {
2811 out.responsiveControls = rcOut;
2812 }
2813 }
2814 return out;
2815 }
2816
2817 // Normalize a bare attributes object for updateBlockAttributes: GBS strip +
2818 // grid coerce + content routing + image routing + registry partition
2819 // (collects unknown_attrs). `currentAttrs` (the live block's attributes) lets
2820 // image routing mirror a container background into its responsive overrides.
2821 function normalizePlanAttrs( name, attributes, sink, currentAttrs ) {
2822 let a = stripBannedAttrs( attributes || {} );
2823 a = coerceGridLayoutLengthAttrs( a );
2824 a = placeContent( name, a );
2825 a = normalizeImageAttrs( name, a, currentAttrs );
2826 const parts = partitionAttrs( name, a );
2827 if ( sink ) {
2828 Array.prototype.push.apply( sink, parts.unknown );
2829 }
2830 return parts.valid;
2831 }
2832
2833 // Sentinel destination meaning "keep each target in its OWN current parent"
2834 // (a pure re-order). Used when the plan carried an `index` but no explicit
2835 // `toRootClientId`: '' is the PAGE ROOT in Gutenberg, so defaulting an omitted
2836 // destination to '' silently RELOCATED a nested block out to the top level.
2837 // The brain now rejects that plan shape outright; this keeps an older or
2838 // hand-built envelope from doing damage here.
2839 const KEEP_CURRENT_PARENT = { __keepCurrentParent: true };
2840
2841 // Move targets grouped by source root (multi-parent safe; preserves input order
2842 // at the destination by advancing insertAt per group). Shared shape with the
2843 // 5-kind move case. `toRoot` may be KEEP_CURRENT_PARENT, in which case each
2844 // group moves within its own parent.
2845 function moveTargetsGrouped( sel, dis, targets, toRoot, insertAt ) {
2846 const groups = {};
2847 const groupOrder = [];
2848 ( targets || [] ).forEach( function ( id ) {
2849 const fromRoot = sel.getBlockRootClientId( id ) || '';
2850 if ( ! Object.prototype.hasOwnProperty.call( groups, fromRoot ) ) {
2851 groups[ fromRoot ] = [];
2852 groupOrder.push( fromRoot );
2853 }
2854 groups[ fromRoot ].push( id );
2855 } );
2856 let moveIdx = 0;
2857 groupOrder.forEach( function ( fromRoot ) {
2858 if ( moveIdx > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) {
2859 dis.__unstableMarkNextChangeAsNotPersistent();
2860 }
2861 moveIdx++;
2862 const grp = groups[ fromRoot ];
2863 const dest = toRoot === KEEP_CURRENT_PARENT ? fromRoot : toRoot;
2864 dis.moveBlocksToPosition( grp, fromRoot, dest, insertAt );
2865 // Advance ONLY when every group lands in the same container. The
2866 // accumulator exists to preserve input order at a SHARED destination;
2867 // under KEEP_CURRENT_PARENT each group lands in its own parent, so
2868 // advancing measures group 2's index against a container group 1 never
2869 // touched ({clientIds:['a','b'], index:0} across two parents put `b` at
2870 // index 1 of its own parent instead of the requested 0). An OMITTED
2871 // index must also stay omitted — `undefined + n` is NaN, which
2872 // dispatches a garbage position for every group after the first (that
2873 // half predates the sentinel and hit any multi-parent move that carried
2874 // no explicit index).
2875 if ( toRoot !== KEEP_CURRENT_PARENT && insertAt !== undefined ) {
2876 insertAt += grp.length;
2877 }
2878 } );
2879 }
2880
2881 // Parse an op's AUTHORITATIVE resolved section markup, if it carries any.
2882 //
2883 // `section_markup` is present when the brain materialized the section against
2884 // the live site — real SureForms/SureDonation ids and re-hosted image urls. The
2885 // `op.blocks` array is the PRE-resolution snapshot (formId:0, remote urls) and
2886 // is stale once that happened, so whenever markup is present it WINS.
2887 //
2888 // Shared by insertBlocks and replaceInnerBlocks. It used to be inline in the
2889 // insert case only, which is why placing a form-bearing section as a REVAMP
2890 // shipped an unconfigured placeholder form while the identical section ADDED
2891 // came out working.
2892 //
2893 // Returns an array of blocks, or undefined to fall back to the op.blocks path.
2894 // Throws only for an unregistered block type — a real, typed op failure.
2895 function parseResolvedSectionMarkup( op ) {
2896 const markup = typeof op.section_markup === 'string' ? op.section_markup.trim() : '';
2897 if ( markup === '' || ! window.wp.blocks || typeof window.wp.blocks.parse !== 'function' ) {
2898 return undefined;
2899 }
2900 let blocks;
2901 // A parse throw is very unlikely (wp.blocks.parse is tolerant), but if it
2902 // does, DON'T fail the op — fall back to the op.blocks path.
2903 try {
2904 blocks = window.wp.blocks.parse( markup ).filter( function ( b ) {
2905 return b && b.name;
2906 } );
2907 } catch ( e ) {
2908 return undefined;
2909 }
2910 if ( ! Array.isArray( blocks ) || ! blocks.length ) {
2911 return undefined;
2912 }
2913 // F1: wp.blocks.parse maps an UNREGISTERED name to core/missing (which HAS a
2914 // name, so it survived the filter) — the unregistered_block_type gate toBlock()
2915 // enforces on the block path is bypassed here. Fail typed-and-loud instead of
2916 // planting an "Unsupported block" placeholder. Outside the try so it is a real
2917 // op failure, not swallowed into the fallback.
2918 const missing = blocks.filter( function ( b ) {
2919 return b.name === 'core/missing';
2920 } );
2921 if ( missing.length ) {
2922 const orig = missing[ 0 ].attributes && missing[ 0 ].attributes.originalName;
2923 throw new Error(
2924 'unregistered_block_type: "' + ( orig || 'unknown' ) +
2925 '" is not registered on this site — the section cannot be placed.'
2926 );
2927 }
2928 // F2: the markup path bypasses toBlock → stripBannedAttrs. Reapply it here
2929 // (symmetric with the block path, S4/PR#282) so a spectra/* block in resolved
2930 // markup can't paint a GBS-banned visual attr onto the page + GBS store.
2931 blocks.forEach( stripBannedAttrsDeep );
2932 return blocks;
2933 }
2934
2935 // Apply ONE operation. Returns { new_client_ids?, result_text?, unknown_attrs? }
2936 // or throws a typed Error (→ failed[]). The allowlist gate (defense-in-depth;
2937 // the brain zod already gated) is the default case.
2938 function runOp( sel, dis, op ) {
2939 // Block-safety pre-check for mutating ops (locked / synced / template).
2940 const mutKind = MUTATION_KIND[ op.function ];
2941 if ( mutKind ) {
2942 // eslint-disable-next-line eqeqeq
2943 const mutTargets = op.clientIds || ( op.rootClientId != null ? [ op.rootClientId ] : [] );
2944 mutTargets.forEach( function ( id ) {
2945 assertMutable( sel, id, mutKind );
2946 } );
2947 }
2948 switch ( op.function ) {
2949 case 'updateBlockAttributes': {
2950 const ids = op.clientIds || [];
2951 ids.forEach( function ( id ) {
2952 liveness( sel, id );
2953 } );
2954 const sinkU = [];
2955 // Normalize + fail-closed pre-checks for ALL targets BEFORE any
2956 // dispatch, so a rejected write (e.g. a content flatten) fails the
2957 // whole op with NO partial apply — the model gets a clean typed
2958 // error, not a half-applied bulk edit.
2959 const planned = ids.map( function ( id ) {
2960 const blk = sel.getBlock( id );
2961 const norm = normalizePlanAttrs( blk.name, op.attributes, sinkU, blk.attributes );
2962 // FAIL CLOSED — a plain-text content write must not silently
2963 // strip the block's inline formatting (the "make it 4/5" defect).
2964 assertNoContentFlatten( blk, norm );
2965 // gs-* identity is immutable on a className write (anti-clobber):
2966 // keep the block's own gs- tokens, apply only the model's utilities.
2967 if ( typeof norm.className === 'string' ) {
2968 norm.className = reconcileClassName(
2969 blk.attributes && blk.attributes.className,
2970 norm.className,
2971 );
2972 }
2973 return { id, blk, norm };
2974 } );
2975 // Apply the final attrs (persistent). The typewriter flourish is
2976 // added uniformly afterwards by the runPlan before/after text diff
2977 // (streamChangedText, SSOT) — never per-op here.
2978 planned.forEach( function ( p, j ) {
2979 // L2 — coalesce a BULK restyle into one undo level: only the
2980 // first target's dispatch is persistent (whether THAT one is
2981 // persistent is the caller's op-level decision); the rest
2982 // merge into it, matching reorderChildren/moveTargetsGrouped.
2983 if ( j > 0 ) {
2984 markNonPersistent( dis );
2985 }
2986 dis.updateBlockAttributes( p.id, deepMerge( p.blk.attributes || {}, p.norm ) );
2987 } );
2988 return sinkU.length ? { unknown_attrs: sinkU } : {};
2989 }
2990 case 'insertBlocks': {
2991 const insAnchor = anchorOf( op );
2992 let insRoot = op.rootClientId;
2993 let insIndex = op.index;
2994 if ( insAnchor ) {
2995 const ra = resolveAnchor( sel, insAnchor.id, insAnchor.position );
2996 insRoot = ra.parent === '' ? null : ra.parent;
2997 insIndex = ra.index;
2998 // eslint-disable-next-line eqeqeq
2999 } else if ( op.rootClientId != null ) {
3000 liveness( sel, op.rootClientId );
3001 }
3002 assertInsertable( sel, insRoot ); // GBR-2 — destination container lock
3003 const sinkI = [];
3004 // Resolved markup WINS when present (see parseResolvedSectionMarkup).
3005 // assertEffectiveContent is skipped on that path — it aligns specs to
3006 // blocks by index, and the parsed blocks don't index-align with the
3007 // stale op.blocks specs.
3008 let insBlocks = parseResolvedSectionMarkup( op );
3009 if ( ! insBlocks || insBlocks.length === 0 ) {
3010 // No resolved markup (plain section, no bound site, or it parsed
3011 // to nothing) → the proven block-object path, unchanged.
3012 insBlocks = ( op.blocks || [] ).map( function ( s ) {
3013 return toBlock( s, sinkI );
3014 } );
3015 assertEffectiveContent( op.blocks, insBlocks );
3016 }
3017 // eslint-disable-next-line eqeqeq
3018 dis.insertBlocks( insBlocks, insIndex, insRoot == null ? undefined : insRoot );
3019 return {
3020 new_client_ids: insBlocks.map( function ( b ) {
3021 return b.clientId;
3022 } ),
3023 result_text: insBlocks.map( renderedTextOf ),
3024 unknown_attrs: sinkI.length ? sinkI : undefined,
3025 };
3026 }
3027 case 'removeBlocks': {
3028 ( op.clientIds || [] ).forEach( function ( id ) {
3029 liveness( sel, id );
3030 } );
3031 const rmImpact = removalImpact( sel, op.clientIds );
3032 dis.removeBlocks( op.clientIds );
3033 return rmImpact ? { impact: rmImpact } : {};
3034 }
3035 case 'moveBlocksToPosition': {
3036 if ( op.order ) {
3037 reorderChildren( sel, dis, op.toRootClientId, op.order ); return {};
3038 }
3039 ( op.clientIds || [] ).forEach( function ( id ) {
3040 liveness( sel, id );
3041 } );
3042 const mvAnchor = anchorOf( op );
3043 let toRoot, insertAt;
3044 if ( mvAnchor ) {
3045 const rm = resolveAnchor( sel, mvAnchor.id, mvAnchor.position );
3046 toRoot = rm.parent;
3047 insertAt = rm.index;
3048 } else {
3049 // Distinguish an EXPLICIT page-root destination (null) from an
3050 // OMITTED one (undefined). Collapsing both to '' meant a plan
3051 // carrying only `{clientIds, index}` relocated nested blocks to
3052 // the PAGE ROOT — a silent, destructive un-nesting. An omitted
3053 // destination now means "re-order within the current parent".
3054 if ( op.toRootClientId === null ) {
3055 toRoot = '';
3056 } else if ( op.toRootClientId === undefined ) {
3057 toRoot = KEEP_CURRENT_PARENT;
3058 } else {
3059 toRoot = op.toRootClientId;
3060 }
3061 insertAt = op.index;
3062 }
3063 // M2 — the destination container must accept a move-in
3064 // (canMoveBlock above only folds in the SOURCE parent's lock).
3065 // A keep-current-parent move never crosses a container boundary, so
3066 // there is no new destination lock to check.
3067 if ( toRoot !== KEEP_CURRENT_PARENT ) {
3068 assertMoveDestination( sel, toRoot );
3069 }
3070 moveTargetsGrouped( sel, dis, op.clientIds, toRoot, insertAt );
3071 return {};
3072 }
3073 case 'replaceBlocks': {
3074 ( op.clientIds || [] ).forEach( function ( id ) {
3075 liveness( sel, id );
3076 } );
3077 const sinkR = [];
3078 const repBlocks = ( op.blocks || [] ).map( function ( s ) {
3079 return toBlock( s, sinkR );
3080 } );
3081 assertEffectiveContent( op.blocks, repBlocks );
3082 const repImpact = removalImpact( sel, op.clientIds );
3083 dis.replaceBlocks( op.clientIds, repBlocks );
3084 return {
3085 new_client_ids: repBlocks.map( function ( b ) {
3086 return b.clientId;
3087 } ),
3088 result_text: repBlocks.map( renderedTextOf ),
3089 unknown_attrs: sinkR.length ? sinkR : undefined,
3090 impact: repImpact,
3091 };
3092 }
3093 case 'replaceInnerBlocks': {
3094 liveness( sel, op.rootClientId );
3095 // Structurally this ADDS/REMOVES the container's children — the
3096 // same class of change insertBlocks/duplicateBlocks need GBR-2
3097 // for (assertMutable's 'edit' kind above only covers editing the
3098 // container's OWN attributes, not manipulating its children).
3099 // Previously missing here, so a templateLock:'all'/'insert'
3100 // container's content could be wiped via this op alone.
3101 assertInsertable( sel, op.rootClientId );
3102 // The OLD children are removed by this op — surface any JS elsewhere
3103 // that targets an anchor inside them (computed before the swap).
3104 const innerRemovedImpact = removalImpact( sel, sel.getBlockOrder ? sel.getBlockOrder( op.rootClientId ) : [] );
3105 const sinkN = [];
3106 // Resolved markup WINS here too. Without this, placing a section as a
3107 // REVAMP shipped an unconfigured placeholder form while the identical
3108 // section ADDED came out working — the same content, two outcomes,
3109 // decided only by which op the model happened to pick.
3110 let innerBlocks = parseResolvedSectionMarkup( op );
3111 if ( ! innerBlocks || innerBlocks.length === 0 ) {
3112 innerBlocks = ( op.blocks || [] ).map( function ( s ) {
3113 return toBlock( s, sinkN );
3114 } );
3115 // Same empty-content guard insertBlocks/replaceBlocks apply — a
3116 // spec that asked for text but built an empty block must fail
3117 // loud, not silently succeed. No-ops when op.blocks is legitimately
3118 // [] (clearing the container is the intentional, allowed case).
3119 // Skipped on the markup path for the same index-alignment reason
3120 // as insertBlocks.
3121 assertEffectiveContent( op.blocks, innerBlocks );
3122 }
3123 dis.replaceInnerBlocks( op.rootClientId, innerBlocks );
3124 return {
3125 new_client_ids: innerBlocks.map( function ( b ) {
3126 return b.clientId;
3127 } ),
3128 result_text: innerBlocks.map( renderedTextOf ),
3129 unknown_attrs: sinkN.length ? sinkN : undefined,
3130 impact: innerRemovedImpact,
3131 };
3132 }
3133 case 'duplicateBlocks': {
3134 // Group by ORIGINAL parent, like moveTargetsGrouped already does for
3135 // moveBlocksToPosition — native duplicateBlocks assumes a single-
3136 // parent selection (mirrors real UI multi-select, which can only
3137 // span one parent), so cross-parent clientIds in ONE call misplace
3138 // later duplicates into the FIRST group's parent (found live
3139 // 2026-07-14: duplicating a tab's trigger+panel together landed the
3140 // panel copy inside the trigger row, not beside its sibling panels).
3141 // Dispatching per root — one native call per parent — keeps each
3142 // duplicate next to its own original.
3143 //
3144 // Snapshotting each root's child order BEFORE its dispatch also
3145 // recovers the NEW clientIds (Gutenberg mints them internally — we
3146 // never see them from the dispatch call itself) by diffing after.
3147 // Without this, a "duplicate this, then customize the copy" plan
3148 // (e.g. add a tab) has no way to target the copy in a follow-up op.
3149 const groups = {};
3150 const groupOrder = [];
3151 // Drop a selected block whose ANCESTOR is also selected — duplicating
3152 // the ancestor already copies it, so keeping it mints a redundant copy.
3153 const selectedIds = op.clientIds || [];
3154 const topLevelIds = selectedIds.filter( function ( id ) {
3155 const parents = typeof sel.getBlockParents === 'function' ? sel.getBlockParents( id ) || [] : [];
3156 return ! parents.some( function ( p ) {
3157 return selectedIds.indexOf( p ) !== -1;
3158 } );
3159 } );
3160 topLevelIds.forEach( function ( id ) {
3161 liveness( sel, id );
3162 const root = sel.getBlockRootClientId( id ) || '';
3163 // GBR-2 — a duplicate lands as a sibling, so the PARENT container
3164 // must allow insertion (template-locked parent → refuse).
3165 assertInsertable( sel, root );
3166 if ( ! Object.prototype.hasOwnProperty.call( groups, root ) ) {
3167 groups[ root ] = { ids: [], before: sel.getBlockOrder( root ).slice() };
3168 groupOrder.push( root );
3169 }
3170 groups[ root ].ids.push( id );
3171 } );
3172 const newIds = [];
3173 groupOrder.forEach( function ( root, g ) {
3174 if ( g > 0 ) {
3175 markNonPersistent( dis );
3176 } // one ⌘Z for the whole plan
3177 dis.duplicateBlocks( groups[ root ].ids );
3178 const before = groups[ root ].before;
3179 sel.getBlockOrder( root ).forEach( function ( id ) {
3180 if ( before.indexOf( id ) === -1 ) {
3181 newIds.push( id );
3182 }
3183 } );
3184 } );
3185 return { new_client_ids: newIds };
3186 }
3187 case 'selectBlock': {
3188 liveness( sel, op.clientId );
3189 dis.selectBlock( op.clientId );
3190 return {};
3191 }
3192 default:
3193 throw new Error( 'forbidden_function:' + op.function );
3194 }
3195 }
3196
3197 // A style op whose computed paint we verify (updateBlockAttributes w/ className).
3198 function isPlanStyleOp( op ) {
3199 return op && op.function === 'updateBlockAttributes' &&
3200 op.attributes && typeof op.attributes.className === 'string' &&
3201 op.attributes.className.trim() !== '' &&
3202 Array.isArray( op.clientIds ) && op.clientIds.length > 0;
3203 }
3204
3205 // [RTRACE] browser-side round-trip tracer. Always pushes structured entries
3206 // into window.__zipwpTrace (read via agent-browser eval); the console mirror is
3207 // GATED behind window.ZIPAI_CONFIG.debug so it doesn't spam every shopper's
3208 // devtools in production (PR #282 S5). Diagnostic only — wrapped so it can
3209 // never break an apply.
3210 function rtrace( hop, data ) {
3211 try {
3212 const entry = Object.assign( { hop, ts: Date.now() }, data || {} );
3213 ( window.__zipwpTrace = window.__zipwpTrace || [] ).push( entry );
3214 if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) {
3215 console.log( '[RTRACE] gutenberg ' + hop, entry );
3216 }
3217 } catch ( e ) { /* never break the apply on a trace failure */ }
3218 }
3219
3220 // F4 — STRUCTURAL-effect verdict for a minting op. insertBlocks /
3221 // duplicateBlocks / replaceInnerBlocks all MINT clientIds; the op only
3222 // truly LANDED if those minted blocks are actually in the tree now. A
3223 // dispatch that produced an applied entry but whose minted ids never
3224 // appeared (target detached, coalesced away, silent drop) changed nothing —
3225 // the "Added a sixth card" false success. Non-minting ops
3226 // (updateBlockAttributes / move / remove) are not this verdict's concern and
3227 // return true (they carry their own noop/unverifiable verdicts). `getBlock`
3228 // is the live tree probe, bound by the caller. Pure + exported so a unit
3229 // test locks the REAL logic against a mock sel.
3230 function structurallyLanded( op, entry, getBlock ) {
3231 const fn = op && op.function;
3232 if ( fn !== 'insertBlocks' && fn !== 'duplicateBlocks' && fn !== 'replaceInnerBlocks' ) {
3233 return true;
3234 }
3235 const ids = ( entry && entry.new_client_ids ) || [];
3236 // Every id the op DID mint must be live in the tree — a minted-but-absent
3237 // id is the silent drop we're guarding against (the "sixth card" bug).
3238 if ( ! ids.every( function ( id ) {
3239 return !! getBlock( id );
3240 } ) ) {
3241 return false;
3242 }
3243 // insertBlocks / duplicateBlocks ALWAYS add ≥1 block — zero minted means
3244 // nothing was added. replaceInnerBlocks may legitimately mint zero
3245 // (clearing a container's children is a valid structural change; a
3246 // non-empty blocks[] always mints, so empty here ⇒ intended clear), so
3247 // it is NOT required to mint.
3248 if ( ( fn === 'insertBlocks' || fn === 'duplicateBlocks' ) && ids.length === 0 ) {
3249 return false;
3250 }
3251 return true;
3252 }
3253
3254 function runPlan( args ) {
3255 if ( ! window.wp || ! window.wp.data || ! window.wp.blocks ) {
3256 return { success: false, error: 'block_editor_unavailable' };
3257 }
3258 const sel = window.wp.data.select( 'core/block-editor' );
3259 const dis = window.wp.data.dispatch( 'core/block-editor' );
3260 if ( ! sel || ! dis ) {
3261 return { success: false, error: 'block_editor_unavailable' };
3262 }
3263
3264 const livePostId = currentPostId();
3265 const expectedPostId = args && args.post_id;
3266 // SEC-2 — FAIL CLOSED. This guard is the SOLE protection against applying a
3267 // plan to the WRONG open post (a second editor tab). If the brain stamped an
3268 // expected post_id but the live post id can't be resolved, we cannot verify
3269 // we are on the right post — refuse rather than apply blind. (Previously this
3270 // fell open when livePostId was null, skipping the check entirely.)
3271 // eslint-disable-next-line eqeqeq
3272 if ( expectedPostId != null && livePostId == null ) {
3273 return {
3274 success: true,
3275 data: {
3276 post_id: null, applied: [], failed: [],
3277 refused: { reason: 'post_id_unresolvable', detail: 'expected=' + expectedPostId + ',live=null' },
3278 },
3279 };
3280 }
3281 // eslint-disable-next-line eqeqeq
3282 if ( expectedPostId != null && livePostId != null && Number( livePostId ) !== Number( expectedPostId ) ) {
3283 return {
3284 success: true,
3285 data: {
3286 post_id: livePostId, applied: [], failed: [],
3287 refused: { reason: 'post_id_mismatch', detail: 'expected=' + expectedPostId + ',live=' + livePostId },
3288 },
3289 };
3290 }
3291
3292 // Subtree scope-lock — enforce ONLY when the brain bound one AND the
3293 // locked container is still live this turn. A stale lock (selection
3294 // changed / block gone) degrades OPEN — never a false block.
3295 let scopeLock = args && typeof args.scope_lock === 'string' && args.scope_lock !== ''
3296 ? args.scope_lock
3297 : null;
3298 if ( scopeLock && ! sel.getBlock( scopeLock ) ) {
3299 scopeLock = null;
3300 }
3301
3302 const ops = ( args && args.operations ) || [];
3303 const applied = [];
3304 const failed = [];
3305 const beforeFx = {};
3306 for ( let b = 0; b < ops.length; b++ ) {
3307 // EXEC-2: snapshot EVERY target of a (possibly bulk) restyle, not just
3308 // clientIds[0] — else idB/idC painting nothing slips through as success.
3309 if ( isPlanStyleOp( ops[ b ] ) ) {
3310 beforeFx[ b ] = ops[ b ].clientIds.map( function ( id ) {
3311 return computedSnapshot( id );
3312 } );
3313 }
3314 }
3315
3316 // [RTRACE] EXEC #1 — the whole plan the brain handed to live Gutenberg.
3317 rtrace( 'runPlan:entry', {
3318 post_id: args && args.post_id, version: args && args.version,
3319 livePostId, op_count: ops.length,
3320 functions: ops.map( function ( o ) {
3321 return o && o.function;
3322 } ),
3323 } );
3324
3325 for ( let i = 0; i < ops.length; i++ ) {
3326 try {
3327 const op = ops[ i ] || {};
3328 // Subtree confinement: refuse a mutating op that reaches OUTSIDE
3329 // the selected container (→ failed[]); in-scope ops still apply.
3330 if ( scopeLock ) {
3331 assertWithinScope( sel, op, scopeLock );
3332 }
3333 // [RTRACE] EXEC #2 — the exact wp.data.dispatch('core/block-editor') call about
3334 // to fire, with its resolved args + the LIVE target block(s) it will hit.
3335 // eslint-disable-next-line eqeqeq
3336 const __tids = op.clientIds || ( op.clientId != null ? [ op.clientId ] : ( op.rootClientId != null ? [ op.rootClientId ] : [] ) );
3337 rtrace( 'runOp:dispatch', {
3338 index: i, fn: op.function,
3339 dispatch: "wp.data.dispatch('core/block-editor')." + op.function + '(...)',
3340 clientIds: op.clientIds, clientId: op.clientId,
3341 rootClientId: op.rootClientId, toRootClientId: op.toRootClientId,
3342 index_arg: op.index, order: op.order, before: op.before, after: op.after,
3343 blocks: ( op.blocks || [] ).map( function ( s ) {
3344 return s && s.name;
3345 } ),
3346 attr_keys: op.attributes ? Object.keys( op.attributes ) : undefined,
3347 className: op.attributes && op.attributes.className,
3348 targets: __tids.map( function ( id ) {
3349 const blk = sel.getBlock( id );
3350 return { clientId: id, live: !! blk, name: blk && blk.name };
3351 } ),
3352 } );
3353 if ( applied.length > 0 ) {
3354 markNonPersistent( dis );
3355 } // undo coalescing → one ⌘Z
3356 // SSOT streaming: snapshot operand text BEFORE the op, then stream
3357 // whatever text CHANGED or APPEARED after — a pure diff, no check on
3358 // op.function. Works for any operation, present or future.
3359 const _before = {};
3360 snapshotContent( sel, opOperandIds( op ), _before );
3361 const res = runOp( sel, dis, op );
3362 streamChangedText(
3363 dis, sel,
3364 opOperandIds( op ).concat( ( res && res.new_client_ids ) || [] ),
3365 _before
3366 );
3367 const entry = { index: i };
3368 if ( res && res.new_client_ids && res.new_client_ids.length ) {
3369 entry.new_client_ids = res.new_client_ids;
3370 }
3371 if ( res && res.result_text ) {
3372 entry.result_text = res.result_text;
3373 }
3374 if ( res && res.unknown_attrs && res.unknown_attrs.length ) {
3375 entry.unknown_attrs = res.unknown_attrs;
3376 }
3377 if ( res && res.impact ) {
3378 entry.impact = res.impact;
3379 }
3380 applied.push( entry );
3381 // [RTRACE] EXEC #3 — what the dispatch returned (minted clientIds / rendered text / dropped attrs).
3382 rtrace( 'runOp:result', {
3383 index: i, fn: op.function,
3384 new_client_ids: res && res.new_client_ids, result_text: res && res.result_text,
3385 unknown_attrs: res && res.unknown_attrs,
3386 } );
3387 } catch ( e ) {
3388 failed.push( { index: i, error: ( e && e.message ) || 'apply_failed' } );
3389 // [RTRACE] EXEC #3b — typed failure (stale_client_id / empty_content / forbidden_function).
3390 rtrace( 'runOp:failed', { index: i, fn: ( ops[ i ] || {} ).function, error: ( e && e.message ) || 'apply_failed' } );
3391 }
3392 }
3393
3394 // LIVE-JIT: compile + inject CSS for utility tokens this plan introduced
3395 // (inserted blocks included — the className collector below only covers
3396 // updateBlockAttributes). Kicked off NOW so the network round-trip runs
3397 // in parallel with the 120ms paint settle; the settle body awaits it so
3398 // the no-op check reads computed styles WITH the new rules present.
3399 const utilCssReady = ensureLiveUtilityCss( collectPlanClassNames( ops ) );
3400
3401 // Auto-focus: honour an explicit trailing selectBlock (model-driven), else leave selection.
3402 // eslint-disable-next-line eqeqeq
3403 const postId = livePostId != null ? livePostId : expectedPostId;
3404 // Fold the generated-section GBS persist into the readiness promise so the
3405 // paint settle below waits for the class bodies to land before it reads
3406 // computed styles. Only when the brain attached section styles AND a valid
3407 // post id resolved (a real post id is a positive int → truthy).
3408 const sectionStyles = args && args.styles;
3409 const gbsReady = sectionStyles && postId
3410 ? persistSectionStyles( sectionStyles, postId )
3411 : Promise.resolve();
3412 const liveCssReady = Promise.all( [ utilCssReady, gbsReady ] );
3413 return new Promise( function ( resolve ) {
3414 // M5 — TWO-PHASE settle. The 120ms snapshot is a heuristic: a slow
3415 // re-render (large page, busy main thread) can leave the computed
3416 // style untouched at 120ms and repaint later — a FALSE noop that
3417 // nudges the brain into a pointless restyle retry. So a noop is only
3418 // CONFIRMED by a second unchanged reading 160ms later; ops whose
3419 // style already moved at phase 1 (the common case) pay nothing extra
3420 // and the reply leaves at 120ms exactly as before.
3421 function finishReply( classNames ) {
3422 // F4 — stamp landed:false on any minting op whose blocks aren't in
3423 // the tree now (see structurallyLanded). Runs in BOTH settle paths
3424 // (fast + phase-2) because it lives here. Only sets the flag when
3425 // NOT landed — an absent flag means landed (brain degrades-open).
3426 for ( let s = 0; s < applied.length; s++ ) {
3427 const landed = structurallyLanded(
3428 ops[ applied[ s ].index ],
3429 applied[ s ],
3430 function ( id ) {
3431 return sel.getBlock( id );
3432 }
3433 );
3434 if ( ! landed ) {
3435 applied[ s ].landed = false;
3436 }
3437 // Phantom motion: a className op whose block resolves an
3438 // `animation-name` with NO `@keyframes` (e.g. `animate-bounce`
3439 // with no `bounce` keyframes). The class applied but paints no
3440 // motion — surface it so the brain never reports the animation
3441 // as working (it stays applied; this is advisory, not a failure).
3442 // Gate on the op actually INTRODUCING an `animate-*` token so a
3443 // plain edit (e.g. `border-red-500`) on a block already carrying
3444 // a theme/AOS animation can't mis-attribute that pre-existing
3445 // animation as this op's phantom.
3446 const op = ops[ applied[ s ].index ];
3447 if ( op && op.function === 'updateBlockAttributes' &&
3448 op.attributes && typeof op.attributes.className === 'string' &&
3449 /(^|\s)animate-/.test( op.attributes.className ) &&
3450 Array.isArray( op.clientIds ) ) {
3451 const phantoms = [];
3452 for ( let t = 0; t < op.clientIds.length; t++ ) {
3453 phantomAnimationsOnBlock( op.clientIds[ t ] ).forEach( function ( name ) {
3454 if ( phantoms.indexOf( name ) === -1 ) {
3455 phantoms.push( name );
3456 }
3457 } );
3458 }
3459 if ( phantoms.length ) {
3460 applied[ s ].phantom_animations = phantoms;
3461 }
3462 }
3463 }
3464 // [RTRACE] EXEC #4 — the reply Gutenberg sends back (after the
3465 // paint-settle no-op check — 120ms, +160ms confirm when needed).
3466 // This is what the bridge POSTs to /agent/rpc-reply → brain folds it.
3467 rtrace( 'runPlan:reply', {
3468 post_id: postId, applied_count: applied.length, failed_count: failed.length,
3469 noop_indices: applied.filter( function ( a ) {
3470 return a.noop;
3471 } ).map( function ( a ) {
3472 return a.index;
3473 } ),
3474 boosted_classNames: classNames,
3475 applied: JSON.parse( JSON.stringify( applied ) ),
3476 failed: JSON.parse( JSON.stringify( failed ) ),
3477 } );
3478 resolve( { success: true, data: { post_id: postId, applied, failed } } );
3479 }
3480 setTimeout( function () {
3481 liveCssReady.then( function () {
3482 const classNames = [];
3483 for ( let a = 0; a < applied.length; a++ ) {
3484 const o = ops[ applied[ a ].index ];
3485 if ( o && o.function === 'updateBlockAttributes' && o.attributes && typeof o.attributes.className === 'string' ) {
3486 classNames.push( o.attributes.className );
3487 }
3488 }
3489 // boostUtilitySpecificity REMOVED (its own stated REMOVAL
3490 // CONDITION is met): ensureLiveUtilityCss now injects server-
3491 // parity rules (×5 base + media-wrapped variants) for every
3492 // touched token. The boost actively BROKE responsive variants:
3493 // it re-emitted base tokens (grid-cols-1) at ×5 UNCONDITIONALLY,
3494 // which beat md:/lg: siblings at every width until reload.
3495 const utilCss = readUtilityCss();
3496 // Phase 1 — collect noop CANDIDATES (unchanged at 120ms).
3497 // candidates[i] = { k (applied index), targets: clientIds still unchanged }
3498 const candidates = [];
3499 for ( let k = 0; k < applied.length; k++ ) {
3500 const idx = applied[ k ].index;
3501 if ( Object.prototype.hasOwnProperty.call( beforeFx, idx ) ) {
3502 const o2 = ops[ idx ];
3503 // The className applies to EVERY target in the op; if it is a
3504 // recognized live utility it renders post-save → never a no-op.
3505 const hasLiveUtil = classNameHasLiveUtility( o2.attributes && o2.attributes.className, utilCss );
3506 if ( hasLiveUtil ) {
3507 continue;
3508 }
3509 const beforeArr = beforeFx[ idx ];
3510 for ( let t = 0; t < o2.clientIds.length; t++ ) {
3511 const before = beforeArr[ t ];
3512 const after = computedSnapshot( o2.clientIds[ t ] );
3513 // F3 — can't read the canvas node (null before/after): the
3514 // paint is UNVERIFIABLE, not confirmed. Flag the op so the
3515 // brain treats it as unconfirmed (verify, don't narrate done)
3516 // instead of silently counting an unreadable op as painted.
3517 if ( before === null || after === null ) {
3518 applied[ k ].unverifiable = true;
3519 // Report WHICH block we tried to measure, under a key of
3520 // its OWN. One shared `client_id` carried two verdicts
3521 // that can disagree: an op with an unreadable target A
3522 // and an unchanged target B ends up `unverifiable:true`
3523 // with `client_id:'B'` once phase 2 stamps the noop id —
3524 // naming a block that WAS verifiably measured as the
3525 // unverifiable one. First unreadable target wins so the
3526 // value is deterministic rather than last-write.
3527 if ( applied[ k ].unverifiable_client_id === undefined ) {
3528 applied[ k ].unverifiable_client_id = o2.clientIds[ t ];
3529 }
3530 continue;
3531 }
3532 // EXEC-2: ANY target that painted nothing marks the op a no-op.
3533 if ( before === after ) {
3534 candidates.push( { k, id: o2.clientIds[ t ], before } );
3535 break; // one unchanged target is enough to re-check this op
3536 }
3537 }
3538 }
3539 }
3540 if ( candidates.length === 0 ) {
3541 finishReply( classNames ); return;
3542 }
3543 // Phase 2 — confirm: still unchanged 160ms later = a real noop;
3544 // moved = a late paint, NOT a noop.
3545 setTimeout( function () {
3546 for ( let c = 0; c < candidates.length; c++ ) {
3547 if ( ! computedChanged( candidates[ c ].before, computedSnapshot( candidates[ c ].id ) ) ) {
3548 applied[ candidates[ c ].k ].noop = true;
3549 // The block whose paint we actually measured — the brain
3550 // keys its no-op tracking by this, so a later corrective
3551 // edit to the SAME block clears it.
3552 applied[ candidates[ c ].k ].client_id = candidates[ c ].id;
3553 }
3554 }
3555 finishReply( classNames );
3556 }, 160 );
3557 } );
3558 }, 120 );
3559 } );
3560 }
3561
3562 function handleApplyChange( args ) {
3563 // Vibe Editing v2: every apply_change is a serialized execution plan
3564 // ({ post_id?, operations[] }). runPlan validates the live editor, enforces
3565 // the post_id two-tab guard, applies each operation in order, and returns
3566 // per-index applied[] / failed[] with computed-effect no-op detection. There
3567 // is no other shape — the brain zod emits operations[] only.
3568 return runPlan( args );
3569 }
3570
3571 // Register once the bridge is ready (same retry pattern as get-context).
3572 function initHandler() {
3573 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
3574 window.zipwpMcp.registerTool(
3575 'editor/apply-change',
3576 async function ( args ) {
3577 return handleApplyChange( args );
3578 },
3579 { previewMode: 'client' }
3580 );
3581 } else {
3582 setTimeout( initHandler, 100 );
3583 }
3584 }
3585
3586 initHandler();
3587
3588 // Test-only surface (Node/CommonJS). The browser bundle has no `module`, so
3589 // this block is inert there and the IIFE registration path above is untouched.
3590 // Exposes the PURE functions so unit tests exercise the REAL logic (no
3591 // reimplementation) against a mocked window.wp.data / window.wp.blocks.
3592 if ( typeof module !== 'undefined' && module.exports ) {
3593 module.exports = {
3594 handleApplyChange,
3595 runPlan,
3596 runOp,
3597 normalizePlanAttrs,
3598 coerceGridLayoutLengthAttrs,
3599 placeContent,
3600 renderedTextOf,
3601 assertEffectiveContent,
3602 // Fail-closed content-flatten invariant — a plain-text write must not
3603 // silently strip a block's inline formatting. Exported PURE so a unit
3604 // test locks the predicate, not a reimplementation.
3605 contentWouldFlatten,
3606 deepMerge,
3607 // gs-* identity is immutable on a className write (anti-clobber).
3608 // Exported PURE so a unit test locks the reconcile rule.
3609 reconcileClassName,
3610 toBlock,
3611 // Computed-effect no-op decision logic — the highest-risk path
3612 // (gs-utility specificity inversion has regressed before). Exported
3613 // PURE so unit tests lock the real logic, not a reimplementation.
3614 computedChanged,
3615 classNameHasLiveUtility,
3616 // Phantom-animation detection (`animate-bounce` with no keyframes).
3617 // Exported PURE so unit tests lock the resolved-name → missing-keyframes
3618 // logic without a live canvas.
3619 hasKeyframesRule,
3620 missingKeyframeNames,
3621 stripBannedAttrs,
3622 // Registry-driven attr validation — getBlockType(name).attributes is
3623 // the SSOT for attr validity (mirrors what Gutenberg accepts). Exported
3624 // PURE so unit tests lock the partition, not a reimplementation.
3625 partitionAttrs,
3626 registeredAttrKeysOf,
3627 // Image-attribute routing — maps the agent's uniform {url,id,alt} to a
3628 // block's real image attr (incl. container background overlay). Exported
3629 // PURE so unit tests lock the routing per block type.
3630 normalizeImageAttrs,
3631 // Subtree scope-lock (selection confinement) — the tree-aware
3632 // airtight half of "stay inside the selected element". Exported PURE
3633 // so unit tests lock the in/out-of-scope decision against a mock sel.
3634 isWithinScope,
3635 scopeGoverningIds,
3636 resolvedDestinationOf,
3637 assertWithinScope,
3638 // F4 structural-effect verdict — a minting op only landed if its
3639 // minted clientIds are in the tree. Exported PURE so a unit test
3640 // locks the real "did it actually add a block?" logic.
3641 structurallyLanded,
3642 };
3643 }
3644 }() );
3645
3646
3647 /**
3648 * ZipWP MCP — Vibe Editing v2: editor/get-context handler.
3649 *
3650 * Reads a slice of the LIVE Gutenberg block tree from wp.data and returns it as
3651 * structured JSON. Dispatched by the brain's AgentBrowserLoop as a `js_rpc`
3652 * tool (same eager-execute path as js_hook); the result is POSTed back to the
3653 * Laravel /agent/rpc-reply route so the loop resolves it IN THE SAME TURN.
3654 *
3655 * Input:
3656 * scope (optional) — a block clientId. Present → return that block's subtree;
3657 * absent → the page's top-level sections (a cheap outline the brain can then
3658 * expand by re-calling with a section clientId).
3659 *
3660 * Returns { success, data: { scope, postId, blocks: [row] } } where each row is
3661 * { clientId, blockName, text, className, path, html?, computed? } — a
3662 * DESIGN-FAITHFUL view: `text` identifies the block (stripped), `html` is the
3663 * rich content WITH inline markup (the design pattern, when present), `computed`
3664 * is the rendered-style digest (fontSize/color/background/padding/fontWeight,
3665 * best-effort) so the brain edits relative to RENDERED reality, not class
3666 * strings. The clientId is the LIVE targeting handle (re-snapshotted every
3667 * call); the brain never persists it across turns.
3668 *
3669 * @package
3670 */
3671 ( function () {
3672 'use strict';
3673
3674 // Content-read budget. A get_context row must carry the block's REAL copy so
3675 // the editor LLM rewrites what is actually on the page — the old 120/600 caps
3676 // meant a long paragraph came back as ~120 chars and the model invented the
3677 // rest (generic, off-page output). Sized to hold any single real block whole
3678 // (~1300 words); only paid on an explicit scoped read, and a section's
3679 // containers carry little own-text so the outline map stays lean.
3680 const CONTENT_READ_MAX = 8000;
3681
3682 // Trim to the budget, but make any clip VISIBLE — a silent truncation is the
3683 // content-loss bug we are closing (the model confidently rewrites a long block
3684 // it only half-saw and drops the tail). With the marker it knows the copy is
3685 // incomplete and preserves/appends instead of replacing blind.
3686 function clip( s, max ) {
3687 return s.length > max ? s.slice( 0, max ) + ' …[+' + ( s.length - max ) + ' more chars not shown]' : s;
3688 }
3689
3690 // Plain-text of a block's own copy — the brain identifies the block by it AND,
3691 // for a plain (no-markup) leaf, rewrites THIS string. Full copy up to the read
3692 // budget so a content edit sees the whole thing, not a stub.
3693 function textOf( block ) {
3694 const a = block.attributes || {};
3695 const raw = a.text || a.content || a.label || a.title || '';
3696 return clip( String( raw ).replace( /<[^>]*>/g, '' ).trim(), CONTENT_READ_MAX );
3697 }
3698
3699 // RICH content WITH inline markup intact — the carrier of the page's design
3700 // PATTERN (per-word colour <span>s, emphasis). `text` above is stripped for
3701 // identification; this is what the brain must edit so a content rewrite can
3702 // re-author the SAME inline structure with new words instead of flattening
3703 // it to a plain string (the headline-flattening defect). Surfaced ONLY when
3704 // the content actually carries inline markup, so plain blocks stay lean.
3705 function htmlOf( block ) {
3706 const a = block.attributes || {};
3707 // eslint-disable-next-line eqeqeq
3708 const raw = a.content != null ? a.content : ( a.text != null ? a.text : null );
3709 if ( typeof raw !== 'string' || raw.indexOf( '<' ) === -1 ) {
3710 return null;
3711 }
3712 return clip( raw, CONTENT_READ_MAX );
3713 }
3714
3715 // The live editor canvas document (iframe in the block editor; falls back to
3716 // the main document for the no-iframe mount). Shared shape with apply-change's
3717 // canvasCtx — kept minimal here (read-only lookup) to avoid a cross-handler
3718 // load-order dependency.
3719 function canvasDoc() {
3720 const f = document.querySelector( 'iframe[name="editor-canvas"]' );
3721 return f && f.contentDocument ? f.contentDocument : document;
3722 }
3723
3724 // The canvas node for a block (the live rendered element). Resolved ONCE per
3725 // row by the caller and shared by computedOf + renderedContentOf so the
3726 // [data-block] lookup runs once, not per-field. null when not mounted
3727 // (off-screen/virtualized).
3728 function nodeForBlock( doc, clientId ) {
3729 return doc.querySelector( '[data-block="' + clientId + '"]' );
3730 }
3731
3732 // RENDERED design facts for a block — the load-bearing axes the brain
3733 // otherwise cannot perceive (it sees authored class tokens, never the
3734 // rendered px/colour). Lets "bigger" be relative to the real size and lets
3735 // the brain see when an authored class did NOT move a property (a gs-*
3736 // !important rule owns it). Best-effort: null when the canvas node isn't
3737 // mounted (off-screen/virtualized) — the doctrine then falls back to
3738 // className. Read-only; never throws.
3739 function computedOf( el, win ) {
3740 try {
3741 if ( ! el ) {
3742 return null;
3743 }
3744 const cs = win.getComputedStyle( el );
3745 return {
3746 fontSize: cs.fontSize,
3747 color: cs.color,
3748 backgroundColor: cs.backgroundColor,
3749 padding: cs.padding,
3750 fontWeight: cs.fontWeight,
3751 };
3752 } catch ( e ) {
3753 return null;
3754 }
3755 }
3756
3757 // Collapse whitespace + trim — for the RENDERED DOM text, which the browser
3758 // already decoded to plain text (no entities/tags). Deliberately NOT routed
3759 // through an innerHTML round-trip: that would mangle a literal "<" in the text.
3760 function collapseWs( s ) {
3761 return ( s === null || s === undefined ? '' : String( s ) ).replace( /\s+/g, ' ' ).trim();
3762 }
3763
3764 // Decode HTML entities + strip tags + collapse whitespace — for the AUTHORED
3765 // attr, which may carry entities/markup. A detached element (cached, plain
3766 // `document` — decoding is document-agnostic) does the exact decode the browser
3767 // does; under jest (no DOM) it degrades to a tag strip. Pairs with collapseWs
3768 // so authored + rendered compare like-for-like — entity/markup/whitespace
3769 // differences never cause a false divergence.
3770 let _decodeEl = null;
3771 function normContent( s ) {
3772 let str = s === null || s === undefined ? '' : String( s );
3773 try {
3774 if ( _decodeEl === null ) {
3775 _decodeEl = document.createElement( 'div' );
3776 }
3777 _decodeEl.innerHTML = str;
3778 str = _decodeEl.textContent || '';
3779 } catch ( e ) {
3780 str = str.replace( /<[^>]*>/g, '' );
3781 }
3782 return str.replace( /\s+/g, ' ' ).trim();
3783 }
3784
3785 // CONTENT OWNERSHIP (the content analog of get-styles' styleContext): the
3786 // RENDERED text of a LEAF content block vs its own authored content attr.
3787 // When they DIFFER, the block's own attr is a DEAD LEVER — the rendered value
3788 // is owned UPSTREAM (a parent composite computes it / pushes it via block
3789 // context: a countdown unit's label comes from the parent's `{unit}sLabel`).
3790 // Editing the child attr then silently no-ops on render. Surfacing the
3791 // rendered value (NOT a server-computed boolean — the brain compares `text`
3792 // vs `rendered` and judges) lets the brain redirect the edit to the owning
3793 // parent attr, WITHOUT any per-block knowledge — the render is the source of
3794 // truth, exactly like styleContext's `effective`.
3795 //
3796 // Scoped HARD to avoid false positives: LEAF blocks only (a container's
3797 // textContent is its whole subtree → would always "differ"); only TEXT attrs
3798 // (content/text/label) — NOT `number`, which leaves self-format/animate
3799 // (1000→"1,000", count-up mid-flight), so value-inequality there is not
3800 // ownership; both sides normalized; empty/partial renders (unmounted, or
3801 // mid-typewriter where rendered is a prefix of authored) are skipped. Takes the
3802 // pre-resolved canvas node. Returns the normalized rendered value, or null.
3803 function renderedContentOf( block, el ) {
3804 if ( ( block.innerBlocks || [] ).length > 0 ) {
3805 return null;
3806 } // leaf-only — never a container subtree
3807 const a = block.attributes || {};
3808 // eslint-disable-next-line eqeqeq
3809 const authoredRaw = a.content != null ? a.content : a.text != null ? a.text : a.label != null ? a.label : null;
3810 if ( authoredRaw === null || authoredRaw === '' ) {
3811 return null;
3812 }
3813 if ( ! el ) {
3814 return null;
3815 }
3816 try {
3817 const rendered = collapseWs( el.textContent || '' );
3818 if ( ! rendered ) {
3819 return null;
3820 } // unmounted / cleared (mid-stream)
3821 // indexOf===0 covers BOTH equality (child owns it) and rendered being a
3822 // strict prefix of authored (mid-typewriter) — either way, not shadowed.
3823 if ( normContent( authoredRaw ).indexOf( rendered ) === 0 ) {
3824 return null;
3825 }
3826 return rendered.slice( 0, 120 );
3827 } catch ( e ) {
3828 return null;
3829 }
3830 }
3831
3832 // Current className (L2 styling). Surfaced so an apply_change.setAttributes
3833 // can PRESERVE the existing utility/gs classes instead of replacing them
3834 // wholesale — the brain appends to this string.
3835 function classOf( block ) {
3836 const a = block.attributes || {};
3837 return typeof a.className === 'string' ? a.className : '';
3838 }
3839
3840 // The 8 GBS-banned visual styling props are NEVER advertised as authorable
3841 // attrs (styling lives in `className` ONLY, the Spectra GBS JIT grammar). The
3842 // list is the SHARED SSOT (editor-shared-utils.isBannedVisualAttr) so this +
3843 // apply-change can never drift. If the shared module isn't resolved we
3844 // advertise the raw registry keys (the brain's zod still rejects the 8 on
3845 // write — degrade consistently, never a duplicate list).
3846 function isBannedVisualAttr( key ) {
3847 const u = sharedEditorUtils();
3848 return !! ( u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr( key ) );
3849 }
3850
3851 // The block's VALID structural attr keys — straight from the registry
3852 // (getBlockType(blockName).attributes, the browser-side SSOT for what
3853 // Gutenberg accepts) minus the 8 GBS-banned styling attrs. Surfaced so the
3854 // model authors valid `attrs` first-try (e.g. counter `endNumber`, button
3855 // `linkURL`, container `isRootBlock`) instead of guessing against a brain
3856 // allowlist — the registry, not a hand-maintained list, decides validity.
3857 // null when the type isn't registered (forked/unknown block) → row omits the
3858 // field rather than advertise an empty/false set.
3859 function attrKeysOf( name ) {
3860 const t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType
3861 ? window.wp.blocks.getBlockType( name )
3862 : null;
3863 if ( ! t || ! t.attributes ) {
3864 return null;
3865 }
3866 const out = [];
3867 Object.keys( t.attributes ).forEach( function ( k ) {
3868 // `spectraId` is an internal identifier that renders as a conditional
3869 // `data-spectra-id` ATTRIBUTE, never a queryable class/id — advertising
3870 // it as authorable lured the model into `querySelector('.<spectraId>')`
3871 // dead selectors. The sanctioned identity channel is `anchor` (→ the
3872 // element's frontend `id`), surfaced at the row level instead.
3873 if ( k === 'spectraId' ) {
3874 return;
3875 }
3876 if ( ! isBannedVisualAttr( k ) ) {
3877 out.push( k );
3878 }
3879 } );
3880 return out;
3881 }
3882
3883 // SET (non-default) attribute VALUES — the higher-specificity styling/layout
3884 // layer that OUTRANKS className (cascade: block attribute > GBS class > block
3885 // default). Surfaced so the model SEES a pinned `style.*` / `layout` / spacing
3886 // attr BEFORE it sets a class that would silently no-op against it, and can
3887 // clear/move it. Uses the PROPER Gutenberg notion of "comment attributes": walk
3888 // the registered schema (getBlockType), drop attrs SOURCE-d from markup
3889 // (content/html — those are the text, not a styling override) and attrs still at
3890 // their declared `default` — exactly the set Gutenberg's serializer persists.
3891 // FULLY registry-driven — no hardcoded key list: only the schema's own `source`
3892 // (markup-backed) + `default` + the banned visual props filter it. So whatever
3893 // is pinned (style.*, layout, spacing, …) surfaces; the model picks the ones
3894 // relevant to its edit. null when nothing is overridden.
3895 function setAttrsOf( block ) {
3896 const t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType
3897 ? window.wp.blocks.getBlockType( block.name )
3898 : null;
3899 if ( ! t || ! t.attributes ) {
3900 return null;
3901 }
3902 const a = block.attributes || {};
3903 const schema = t.attributes;
3904 const out = {};
3905 Object.keys( schema ).forEach( function ( k ) {
3906 const def = schema[ k ] || {};
3907 if ( def.source ) {
3908 return;
3909 } // sourced from the markup — it's the content, not a styling override
3910 if ( isBannedVisualAttr( k ) ) {
3911 return;
3912 } // never authorable in attrs anyway
3913 const v = a[ k ];
3914 if ( v === undefined || v === null || v === '' ) {
3915 return;
3916 }
3917 if ( Array.isArray( v ) && v.length === 0 ) {
3918 return;
3919 }
3920 if ( typeof v === 'object' && ! Array.isArray( v ) && Object.keys( v ).length === 0 ) {
3921 return;
3922 }
3923 try {
3924 if ( JSON.stringify( v ) === JSON.stringify( def.default ) ) {
3925 return;
3926 } // unchanged from default
3927 } catch ( e ) { /* unstringifiable → treat as a real override */ }
3928 out[ k ] = v;
3929 } );
3930 return Object.keys( out ).length ? out : null;
3931 }
3932
3933 function rowOf( block, path ) {
3934 const row = {
3935 clientId: block.clientId,
3936 blockName: block.name,
3937 text: textOf( block ),
3938 className: classOf( block ),
3939 path,
3940 };
3941 // The block's HTML `anchor` → its frontend `id` (deterministic:
3942 // BlockAttributes wrapper sets id = anchor). The sanctioned way to TARGET
3943 // this block from custom JS is `#<anchor>` — surfaced beside className so
3944 // the model reads/reuses a real id instead of guessing a frontend class.
3945 const anchor = block.attributes && typeof block.attributes.anchor === 'string' ? block.attributes.anchor : '';
3946 if ( anchor !== '' ) {
3947 row.anchor = anchor;
3948 }
3949 // Registry-declared valid attr keys (minus GBS-banned) so the model
3950 // authors valid setAttributes.attrs first-try. Omitted for unregistered
3951 // types — never advertise a guess.
3952 const attrKeys = attrKeysOf( block.name );
3953 if ( attrKeys !== null ) {
3954 row.attrKeys = attrKeys;
3955 }
3956 // Pinned (non-default) attr VALUES — the higher-specificity layer that
3957 // overrides className. Lets the model reconcile specificity before a class no-ops.
3958 const setAttrs = setAttrsOf( block );
3959 if ( setAttrs !== null ) {
3960 row.setAttrs = setAttrs;
3961 }
3962 // Design-faithful enrichment — only present when meaningful, so the
3963 // payload stays lean: `html` for blocks carrying inline markup, `computed`
3964 // when the live canvas node is readable. The canvas node is resolved ONCE
3965 // here and shared by computedOf + renderedContentOf (one [data-block]
3966 // lookup per row, not per field).
3967 const html = htmlOf( block );
3968 if ( html !== null ) {
3969 row.html = html;
3970 }
3971 const cdoc = canvasDoc();
3972 const cel = nodeForBlock( cdoc, block.clientId );
3973 const computed = computedOf( cel, cdoc.defaultView || window );
3974 if ( computed !== null ) {
3975 row.computed = computed;
3976 }
3977 // Content ownership: present ONLY when the rendered text differs from this
3978 // leaf's own content attr — i.e. the attr is a dead lever and the value is
3979 // owned upstream (a parent composite). The brain compares `text` vs
3980 // `rendered` and redirects the edit to the parent's owning attr.
3981 const rendered = renderedContentOf( block, cel );
3982 if ( rendered !== null ) {
3983 row.rendered = rendered;
3984 }
3985 return row;
3986 }
3987
3988 // Read bounds. An unbounded subtree dump on a large/whole page can exceed the
3989 // brain's compaction backstop and clear the active read with NO recovery
3990 // (the brain holds only the js_rpc envelope, not this payload). Cap rows +
3991 // depth + total bytes so a single read is always self-contained; a truncated
3992 // read flags `truncated` and stamps per-parent `childCount`/`hasMore` so the
3993 // model drills down with a narrower scope instead of getting a silent cut.
3994 const MAX_CONTEXT_ROWS = 250;
3995 const MAX_CONTEXT_DEPTH = 8;
3996 const MAX_CONTEXT_BYTES = 48000;
3997
3998 // Push a row into the bounded accumulator. Returns false (caller stops) when
3999 // a cap is hit. Tracks an approximate serialized size so a few heavy rows
4000 // (long html / many attrKeys) trip the ceiling as readily as many light ones.
4001 function pushRow( state, row ) {
4002 if ( state.rows.length >= MAX_CONTEXT_ROWS ) {
4003 state.truncated = true; return false;
4004 }
4005 let sz = 200;
4006 try {
4007 sz = JSON.stringify( row ).length;
4008 } catch ( e ) {
4009 sz = 200;
4010 }
4011 if ( state.bytes + sz > MAX_CONTEXT_BYTES && state.rows.length > 0 ) {
4012 state.truncated = true;
4013 return false;
4014 }
4015 state.rows.push( row );
4016 state.bytes += sz;
4017 return true;
4018 }
4019
4020 // Flatten a block + descendants into bounded rows (DFS): clientId (live
4021 // handle), block name, text snippet, className, dot-path. Stops at the
4022 // row/byte caps; at the depth cap it emits the node with childCount/hasMore
4023 // rather than descending, so deeper blocks stay discoverable via a scoped
4024 // re-read.
4025 function flatten( block, path, depth, state ) {
4026 const inner = block.innerBlocks || [];
4027 const row = rowOf( block, path );
4028 if ( inner.length > 0 && depth >= MAX_CONTEXT_DEPTH ) {
4029 row.childCount = inner.length;
4030 row.hasMore = true;
4031 pushRow( state, row );
4032 return;
4033 }
4034 if ( ! pushRow( state, row ) ) {
4035 return;
4036 }
4037 for ( let i = 0; i < inner.length; i++ ) {
4038 if ( state.truncated ) {
4039 // The caps tripped before this parent's children were exhausted —
4040 // flag it so the omitted subtree is discoverable.
4041 row.childCount = inner.length;
4042 row.hasMore = true;
4043 return;
4044 }
4045 flatten( inner[ i ], path === '' ? String( i ) : path + '.' + i, depth + 1, state );
4046 }
4047 }
4048
4049 // Shared editor utilities — resolved at call time from the ONE source
4050 // (editor/shared/editor-shared-utils.js): window in the browser, require()
4051 // under jest. currentPostId lives there so this handler and apply-change can
4052 // never drift; a missing module degrades consistently (null).
4053 function sharedEditorUtils() {
4054 if ( typeof window !== 'undefined' && window.zipwpEditorShared ) {
4055 return window.zipwpEditorShared;
4056 }
4057 if ( typeof require === 'function' ) {
4058 try {
4059 return require( '../shared/editor-shared-utils.js' );
4060 } catch ( e ) {
4061 return null;
4062 }
4063 }
4064 return null;
4065 }
4066 function currentPostId() {
4067 const u = sharedEditorUtils();
4068 return u && u.currentPostId ? u.currentPostId() : null;
4069 }
4070
4071 function handleGetContext( args ) {
4072 if ( ! window.wp || ! window.wp.data ) {
4073 return { success: false, error: 'block_editor_unavailable' };
4074 }
4075 const sel = window.wp.data.select( 'core/block-editor' );
4076 if ( ! sel ) {
4077 return { success: false, error: 'block_editor_unavailable' };
4078 }
4079
4080 // A scope that isn't a real clientId — empty, or a sentinel the model
4081 // sometimes emits when it means "the whole page" (the literal string
4082 // "null", "root", "page", …) — degrades to the page outline rather than
4083 // a hard miss. The outline is the cheap top-level map the model can
4084 // re-target from; a hard error here just burns a round-trip.
4085 const scopeRaw = args && args.scope ? String( args.scope ) : '';
4086 const SCOPE_SENTINELS = { '': 1, null: 1, undefined: 1, root: 1, page: 1, 0: 1, none: 1, false: 1 };
4087 let scope = SCOPE_SENTINELS[ scopeRaw.toLowerCase() ] ? '' : scopeRaw;
4088 // Bounded accumulator (rows + running byte size + truncation flag).
4089 const state = { rows: [], bytes: 0, truncated: false };
4090
4091 // A stale/rotated clientId — gone after a structural edit (replace/insert
4092 // mint new ids), a page change, or replayed from older conversation history
4093 // — must NOT dead-end the turn. A hard `scope_not_found` error makes the
4094 // model re-ask the SAME dead id (wasted round-trips, duplicate-call blocks,
4095 // token bloat). Instead DEGRADE to the page outline and FLAG the miss, so the
4096 // model re-grounds against the live tree and re-targets in the SAME turn. The
4097 // grounding tool is the model's only map; it must always return a usable one.
4098 let scopeMissed = '';
4099 if ( scope ) {
4100 const root = sel.getBlock( scope );
4101 if ( root ) {
4102 flatten( root, '', 0, state );
4103 } else {
4104 scopeMissed = scope;
4105 scope = '';
4106 }
4107 }
4108 if ( ! scope ) {
4109 const top = sel.getBlocks() || [];
4110 for ( let i = 0; i < top.length; i++ ) {
4111 // Outline read = top-level sections only (no recurse). Still
4112 // row/byte-bounded so a page with hundreds of sections can't
4113 // blow the read.
4114 if ( ! pushRow( state, rowOf( top[ i ], String( i ) ) ) ) {
4115 break;
4116 }
4117 }
4118 }
4119 const blocks = state.rows;
4120
4121 const data = {
4122 scope: scope || null,
4123 postId: currentPostId(),
4124 blocks,
4125 };
4126 // The read hit a cap (rows/bytes) — tell the model these rows are a
4127 // PREFIX, so it narrows the scope (re-read a child clientId) rather than
4128 // assuming it saw the whole tree. Pairs with per-parent childCount/hasMore.
4129 if ( state.truncated ) {
4130 data.truncated = true;
4131 }
4132 // Flag a degraded read so the model KNOWS the id it asked for is gone and
4133 // these rows are the live outline to re-target from (not the requested
4134 // subtree) — turns a silent substitution into an explicit re-ground signal.
4135 if ( scopeMissed ) {
4136 data.scopeNotFound = scopeMissed;
4137 }
4138 return { success: true, data };
4139 }
4140
4141 // Register once the bridge is ready (same retry pattern as the other
4142 // editor tools — the bridge mounts asynchronously).
4143 function initHandler() {
4144 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
4145 window.zipwpMcp.registerTool(
4146 'editor/get-context',
4147 async ( args ) => handleGetContext( args ),
4148 { previewMode: 'client' }
4149 );
4150 } else {
4151 setTimeout( initHandler, 100 );
4152 }
4153 }
4154
4155 initHandler();
4156 }() );
4157
4158
4159 /**
4160 * editor/get-scripts + editor/set-scripts — typed read/patch over a BLOCK's
4161 * `spectraCustomJS` attribute (the per-block JS store). `spectraCustomJS` in
4162 * post_content is the single source of truth for page behaviour — Spectra Pro's
4163 * BlockJsCompiler renders it once at wp_footer (spectra-blocks-pro #165),
4164 * superseding the removed per-page `_spectra_blocks_page_scripts` meta.
4165 *
4166 * SESSION-SCOPED BY DESIGN (the apply_change philosophy): reads come from
4167 * `core/block-editor` getBlockAttributes (the live editing session, incl.
4168 * unsaved edits) and writes go through updateBlockAttributes — the block's JS
4169 * updates in the session immediately, is DISCARDED if the user discards the
4170 * session, and persists only on Save. A REST write here would race the open
4171 * editor's copy and mutate before save — never do that.
4172 *
4173 * Target a block by `clientId` (default = the page root container, the first
4174 * top-level block — page-wide behaviour). The stored code is raw JS; the
4175 * renderer wraps it in an IIFE and resolves the `_current_block_` token to the
4176 * block's scope class.
4177 */
4178 ( function () {
4179 // Block-editor store access comes from the ONE shared source
4180 // (editor/shared/editor-shared-utils.js): window in the browser, require()
4181 // under jest — so the editor handlers can't drift.
4182 function sharedEditorUtils() {
4183 if ( typeof window !== 'undefined' && window.zipwpEditorShared ) {
4184 return window.zipwpEditorShared;
4185 }
4186 if ( typeof require === 'function' ) {
4187 try {
4188 return require( '../shared/editor-shared-utils.js' );
4189 } catch ( e ) {
4190 return null;
4191 }
4192 }
4193 return null;
4194 }
4195 function blockEditorSelect() {
4196 const u = sharedEditorUtils(); return u && u.blockEditorSelect ? u.blockEditorSelect() : null;
4197 }
4198 function blockEditorDispatch() {
4199 const u = sharedEditorUtils(); return u && u.blockEditorDispatch ? u.blockEditorDispatch() : null;
4200 }
4201 function rootClientId( sel ) {
4202 const u = sharedEditorUtils(); return u && u.rootClientId ? u.rootClientId( sel ) : null;
4203 }
4204 function currentPostId() {
4205 const u = sharedEditorUtils(); return u && u.currentPostId ? u.currentPostId() : null;
4206 }
4207 function isJsCapable( blockName ) {
4208 const u = sharedEditorUtils(); return !! ( u && u.isJsCapable && u.isJsCapable( blockName ) );
4209 }
4210
4211 // ── Anchor resolution for JS selectors ──────────────────────────────────
4212 // The AI can't see the rendered DOM. The sanctioned way to target a block in
4213 // JS is its HTML `anchor`, which renders as the element's frontend `id`
4214 // (BlockAttributes::get_wrapper_attributes → `$wrapper_attrs['id'] = anchor`).
4215 // collectAnchors gathers those; unresolvedSelectors reports the #ids a script
4216 // references that no anchor provides. Those are surfaced to the brain as a
4217 // FACT, not hard-blocked — the id may still resolve on the frontend (an
4218 // imported header/footer part the editor's page tree omits, or an id authored
4219 // inside block content: form fields, rich-text spans). Class/tag selectors are
4220 // never gated — they name runtime state, utilities, and block-internal DOM.
4221 function collectAnchors( sel ) {
4222 const anchors = Object.create( null );
4223 const ids = sel.getClientIdsWithDescendants ? sel.getClientIdsWithDescendants() : null;
4224 if ( ! ids ) {
4225 return anchors;
4226 }
4227 for ( let i = 0; i < ids.length; i++ ) {
4228 const a = sel.getBlockAttributes( ids[ i ] );
4229 if ( a && typeof a.anchor === 'string' && a.anchor !== '' ) {
4230 anchors[ a.anchor ] = true;
4231 }
4232 }
4233 return anchors;
4234 }
4235
4236 // Every id the JS references (getElementById / #id) — from the shared SSOT, so
4237 // the write-time gate parses JS identically to apply-change's delete-time
4238 // orphan-JS surface (drift here would be a new bug class). Local fallback keeps
4239 // the handler working if the shared bundle hasn't loaded yet.
4240 function referencedIds( code ) {
4241 const u = sharedEditorUtils();
4242 if ( u && u.referencedIds ) {
4243 return u.referencedIds( code );
4244 }
4245 const ids = [];
4246 if ( typeof code !== 'string' || code === '' ) {
4247 return ids;
4248 }
4249 let m;
4250 const reGid = /getElementById\(\s*['"]([A-Za-z][\w-]*)['"]\s*\)/g;
4251 while ( ( m = reGid.exec( code ) ) !== null ) {
4252 ids.push( m[ 1 ] );
4253 }
4254 const reQs = /querySelector(?:All)?\(\s*(['"])([^'"]*)\1/g;
4255 while ( ( m = reQs.exec( code ) ) !== null ) {
4256 // eslint-disable-next-line no-var
4257 var idm,
4258 reId = /#([A-Za-z][\w-]*)/g;
4259 while ( ( idm = reId.exec( m[ 2 ] ) ) !== null ) {
4260 ids.push( idm[ 1 ] );
4261 }
4262 }
4263 return ids;
4264 }
4265
4266 // The #ids the JS references that no block ANCHOR in THIS page's editor tree
4267 // provides. Surfaced to the brain as a FACT — NOT hard-blocked: the id may
4268 // still resolve on the frontend (an imported header/footer part the editor
4269 // tree omits, or an id authored inside block content — form fields, rich-text
4270 // spans). The brain has the site context to judge; blocking here false-rejects
4271 // those legit targets. Mirrors removalImpact (adapter surfaces facts, brain decides).
4272 function unresolvedSelectors( sel, code ) {
4273 const anchors = collectAnchors( sel );
4274 const ids = referencedIds( code );
4275 return ids.filter( function ( id, i ) {
4276 return ids.indexOf( id ) === i && ! anchors[ id ];
4277 } );
4278 }
4279
4280 // The target block: an explicit clientId, else the page root container.
4281 function resolveClientId( sel, args ) {
4282 const cid = args && typeof args.clientId === 'string' && args.clientId !== '' ? args.clientId : null;
4283 return cid || rootClientId( sel );
4284 }
4285
4286 // A block's current spectraCustomJS ('' when unset or the block is gone).
4287 // eslint-disable-next-line no-unused-vars
4288 function customJsOf( sel, clientId ) {
4289 const attrs = sel && sel.getBlockAttributes ? sel.getBlockAttributes( clientId ) : null;
4290 return attrs && typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '';
4291 }
4292
4293 function handleGetScripts( args ) {
4294 const sel = blockEditorSelect();
4295 if ( ! sel || ! sel.getBlockAttributes ) {
4296 return { success: false, error: 'editor_unavailable: core/block-editor store not present (is the block editor open?)' };
4297 }
4298 let clientId = resolveClientId( sel, args );
4299 if ( ! clientId ) {
4300 return { success: false, error: 'no_block: the page has no blocks to read JS from' };
4301 }
4302 // Mirror set-scripts' redirect: a non-JS-capable target's JS lives on the
4303 // page root (where set-scripts wrote it), so READ from there — else the
4304 // brain reads '' from the original block and re-issues, stacking duplicate JS.
4305 if ( sel.getBlockName && ! isJsCapable( sel.getBlockName( clientId ) ) ) {
4306 const root = rootClientId( sel );
4307 if ( root ) {
4308 clientId = root;
4309 }
4310 }
4311 // Block-gone is NOT the same as no-JS: if the clientId no longer resolves,
4312 // report it as a stale target (else the model reads code:'' as "no JS" and
4313 // may author a fresh script against a dead block).
4314 const attrs = sel.getBlockAttributes( clientId );
4315 if ( ! attrs ) {
4316 return { success: false, error: 'unknown_block: no block with clientId ' + clientId + ' in the live tree (it may have been deleted — re-read the outline / get-context first)' };
4317 }
4318 return {
4319 success: true,
4320 data: {
4321 post_id: currentPostId(),
4322 client_id: clientId,
4323 code: typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '',
4324 },
4325 };
4326 }
4327
4328 // Throws on code the block renderer would reject — typed and loud beats a
4329 // silent drop. `<script>` tags never belong in the store (it wraps the raw JS).
4330 function validateCode( code ) {
4331 if ( typeof code !== 'string' || code === '' ) {
4332 throw new Error( 'invalid_input: code is required (the raw JS source, no <script> tags)' );
4333 }
4334 if ( /<\/?script/i.test( code ) ) {
4335 throw new Error( 'invalid_input: code must be the raw JS source only, with no <script> tags (the store wraps it)' );
4336 }
4337 }
4338
4339 // `code` REPLACES the block's spectraCustomJS; `append: true` adds to the
4340 // existing JS instead (read first with editor/get-scripts).
4341 function handleSetScripts( args ) {
4342 const sel = blockEditorSelect();
4343 const dis = blockEditorDispatch();
4344 if ( ! sel || ! sel.getBlockAttributes || ! dis || ! dis.updateBlockAttributes ) {
4345 return { success: false, error: 'editor_unavailable: core/block-editor store not present (is the block editor open?)' };
4346 }
4347
4348 try {
4349 validateCode( args ? args.code : undefined );
4350 } catch ( e ) {
4351 return { success: false, error: String( e && e.message ? e.message : e ) };
4352 }
4353
4354 let clientId = resolveClientId( sel, args );
4355 if ( ! clientId ) {
4356 return { success: false, error: 'no_block: the page has no blocks to attach JS to' };
4357 }
4358 let attrs = sel.getBlockAttributes( clientId );
4359 if ( ! attrs ) {
4360 return { success: false, error: 'unknown_block: no block with clientId ' + clientId + ' in the live tree (read the outline / get-context first)' };
4361 }
4362
4363 // The target must actually persist spectraCustomJS, or it paints this
4364 // session and silently vanishes on Save. Redirect to the root container —
4365 // the same fallback the converter uses for a script whose owner class
4366 // sits on a non-capable block — instead of writing something that's lost.
4367 let redirected = false;
4368 if ( sel.getBlockName && ! isJsCapable( sel.getBlockName( clientId ) ) ) {
4369 const root = rootClientId( sel );
4370 // The redirect target must ALSO be JS-capable — a page whose first
4371 // top-level block is core/group / core/cover would otherwise take the
4372 // write, report success, then drop the attribute on Save.
4373 if ( root && sel.getBlockName && ! isJsCapable( sel.getBlockName( root ) ) ) {
4374 return {
4375 success: false,
4376 error: 'block_not_js_capable: this block type cannot hold JavaScript, and the page root container cannot either — wrap the target in a Spectra container that holds JS, then retry.',
4377 };
4378 }
4379 const rootAttrs = root ? sel.getBlockAttributes( root ) : null;
4380 if ( ! rootAttrs ) {
4381 return {
4382 success: false,
4383 error: 'block_not_js_capable: this block type cannot hold JavaScript, and no root container was found to redirect to',
4384 };
4385 }
4386 redirected = true;
4387 clientId = root;
4388 attrs = rootAttrs;
4389 }
4390
4391 // Block-safety guard — the SAME invariant apply-change enforces via
4392 // assertMutable (SCENARIO-003), applied here because this is the other path
4393 // that mutates a block's attributes. `spectraCustomJS` is a block attribute,
4394 // and Gutenberg's UPDATE_BLOCK_ATTRIBUTES reducer does NOT consult the lock
4395 // selectors (they gate the UI, not a programmatic dispatch) — so without this
4396 // check set_scripts could write JS into a template-locked block, or into a
4397 // synced-pattern (core/block) instance's content-locked inner block, which
4398 // edits content shared with every other page that uses that pattern. Checked
4399 // AFTER the js-capable redirect so it validates the block actually written to.
4400 // Degrades to ALLOW when the selector is absent (older Gutenberg), matching
4401 // assertMutable — never a false block.
4402 if ( typeof sel.canEditBlock === 'function' && sel.canEditBlock( clientId ) === false ) {
4403 return {
4404 success: false,
4405 error: 'locked_block: ' + clientId + ' is locked in the editor (template lock, content lock, or a synced-pattern instance), so its JavaScript cannot be changed here. Edit the pattern itself, or unlock the block.',
4406 };
4407 }
4408
4409 // Unresolved #id selectors are a FACT for the brain, not a hard block: the
4410 // id may resolve on the frontend (imported header/footer part, block content)
4411 // even when no anchor in THIS page's editor tree matches. Store the JS and
4412 // surface them so the brain can re-target if they're genuinely dead.
4413 const unresolved = unresolvedSelectors( sel, args.code );
4414
4415 const existing = typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '';
4416 const next = ( args.append === true && existing !== '' ) ? existing + '\n' + args.code : args.code;
4417 dis.updateBlockAttributes( clientId, { spectraCustomJS: next } );
4418
4419 return {
4420 success: true,
4421 data: {
4422 client_id: clientId,
4423 code: next,
4424 note: ( redirected
4425 ? 'Session-scoped: the requested block can\'t hold JS (it would be dropped on Save), so it was attached to the page root container instead.'
4426 : 'Session-scoped: the block\'s JS updates now; persists when the user saves the page.' ) +
4427 ( unresolved.length
4428 ? ' Heads up: #' + unresolved.join( ', #' ) + ' match no block anchor on this page — fine if they resolve in the imported header/footer or block content, otherwise set an anchor or re-target.'
4429 : '' ),
4430 ...( unresolved.length ? { unresolved_selectors: unresolved } : {} ),
4431 },
4432 };
4433 }
4434
4435 // Register once the bridge is ready (same retry pattern as get-context /
4436 // apply-change). The react-manager glob auto-enqueues this file.
4437 function initHandler() {
4438 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
4439 window.zipwpMcp.registerTool(
4440 'editor/get-scripts',
4441 async function ( args ) {
4442 return handleGetScripts( args );
4443 },
4444 { previewMode: 'client' }
4445 );
4446 window.zipwpMcp.registerTool(
4447 'editor/set-scripts',
4448 async function ( args ) {
4449 return handleSetScripts( args );
4450 },
4451 { previewMode: 'client' }
4452 );
4453 } else {
4454 setTimeout( initHandler, 100 );
4455 }
4456 }
4457
4458 initHandler();
4459
4460 // Test-only surface (Node/CommonJS) — inert in the browser bundle.
4461 if ( typeof module !== 'undefined' && module.exports ) {
4462 module.exports = {
4463 handleGetScripts,
4464 handleSetScripts,
4465 };
4466 }
4467 }() );
4468
4469
4470 /**
4471 * editor/get-styles + editor/set-styles — the GBS CSS store, symmetric with
4472 * editor/get-context (HTML) and editor/get-scripts (JS).
4473 *
4474 * READ (get-styles) has two scopes: 'page' (this page's payload + the SELECTED
4475 * block's ownership styleContext) and 'global' (the site-wide option). Pure reads.
4476 *
4477 * WRITE (set-styles) is GLOBAL-scope ONLY — the header/footer / site-wide CHROME
4478 * WP OPTION (read via GET /global-styles/user-css, written via the shared
4479 * /global-styles/sitewide merge route — IMMEDIATE + site-wide, not reversible by
4480 * discard). The former scope:'page' WRITE (an IMMEDIATE `/global-styles/save`
4481 * write to this post's `spectra_blocks_pro_gs_user_css` meta) was REMOVED: it
4482 * persisted a per-block style edit to the DB before the user Saved (no undo) and,
4483 * on a SHARED gs- class body, silently restyled every section carrying it. All
4484 * per-block styling is now a utility className via editor/apply-change (editor
4485 * state, committed on Save). scope:'page' here is rejected, defensively.
4486 *
4487 * The global write does READ → MERGE only the touched buckets → WRITE (never
4488 * full-replace, so importer chrome + user classes survive), then RENDERs the
4489 * merged payload via the SSOT GenCssRenderer (REST /global-styles/render) and
4490 * injects it into the canvas iframe for live paint.
4491 *
4492 * @package
4493 */
4494 ( function () {
4495 // eslint-disable-next-line no-unused-vars
4496 const META_KEY = 'spectra_blocks_pro_gs_user_css'; // page post-meta AND global option key
4497 const NS = '/spectra-blocks/v1/global-styles';
4498
4499 // Editor-store access (select/dispatch core/editor + session meta) comes from
4500 // the ONE shared source (editor/shared/editor-shared-utils.js): window in the
4501 // browser, require() under jest — so the editor handlers can't drift.
4502 function sharedEditorUtils() {
4503 if ( typeof window !== 'undefined' && window.zipwpEditorShared ) {
4504 return window.zipwpEditorShared;
4505 }
4506 if ( typeof require === 'function' ) {
4507 try {
4508 return require( '../shared/editor-shared-utils.js' );
4509 } catch ( e ) {
4510 return null;
4511 }
4512 }
4513 return null;
4514 }
4515 function editorSelect() {
4516 const u = sharedEditorUtils(); return u && u.editorSelect ? u.editorSelect() : null;
4517 }
4518 function blockEditorSelect() {
4519 const u = sharedEditorUtils(); return u && u.blockEditorSelect ? u.blockEditorSelect() : null;
4520 }
4521 // How many blocks on the page carry a gs- token (the reverse `class -> blocks`
4522 // edge). >1 means editing this class body restyles other sections too. Shared SSOT.
4523 function classUsage( token ) {
4524 const u = sharedEditorUtils();
4525 const besel = blockEditorSelect();
4526 if ( ! u || ! u.classDependents || ! besel ) {
4527 return 1;
4528 }
4529 const n = u.classDependents( besel, token, [] ).length;
4530 return n > 0 ? n : 1;
4531 }
4532 function apiFetch( opts ) {
4533 if ( ! ( window.wp && window.wp.apiFetch ) ) {
4534 return Promise.reject( new Error( 'wp.apiFetch unavailable' ) );
4535 }
4536 return window.wp.apiFetch( opts );
4537 }
4538
4539 // GBS page-store logic (merge / iframe-inject / the read→merge→save→render→inject
4540 // persist) lives in ONE place — editor/shared/editor-shared-utils.js — so this
4541 // styling tool and editor/apply-change can't drift. These are thin forwarders,
4542 // the same pattern as editorSelect above.
4543 function mergePayload( existing, incoming ) {
4544 const u = sharedEditorUtils();
4545 return u && u.mergePayload ? u.mergePayload( existing, incoming ) : Object.assign( {}, existing, incoming );
4546 }
4547 function canvasDoc() {
4548 const u = sharedEditorUtils();
4549 return u && u.canvasDoc ? u.canvasDoc() : document;
4550 }
4551 function injectCss( elementId, css ) {
4552 const u = sharedEditorUtils();
4553 if ( u && u.injectCss ) {
4554 u.injectCss( elementId, css );
4555 }
4556 }
4557
4558 function bucketsOf( payload ) {
4559 return Object.keys( payload || {} ).filter( function ( k ) {
4560 return k !== 'v';
4561 } );
4562 }
4563
4564 // ── STYLE CONTEXT (ownership) ──────────────────────────────────────────────
4565 // The OWNERSHIP MODEL: a visual property is set by ONE of three layers, in
4566 // descending CSS specificity — a block ATTRIBUTE, a GBS CLASS body, or the
4567 // block DEFAULT (DEVELOPER-INSTRUCTIONS §5.1). To change a property you edit
4568 // its current OWNER (update the existing class, don't stack a new one; clear a
4569 // block attr that pins it). This resolver answers "who owns each property" so
4570 // the agent never hand-resolves specificity. It does NOT compute the exact
4571 // frontend winner (the editor canvas inverts utility-vs-gsClass specificity,
4572 // and utilities are JIT-compiled, not in the GBS payload) — `effective` is the
4573 // rendered truth and the agent's verify-iterate loop corrects any mis-guess.
4574 // The property map is small and explicit ON PURPOSE: it is this tool's job.
4575 // `prop` is the kebab CSS property — it doubles as the GBS-body key (bodies are
4576 // kebab, e.g. `font-size`) and the getComputedStyle key. Only `attrs` (the
4577 // block-attribute path(s) for that property) is non-derivable.
4578 // NOTE: ownership matches the EXACT property name — the shorthands `padding` /
4579 // `margin` resolve against a rule's `padding` / `margin` declaration, not a
4580 // longhand-only rule (`padding-top`). This system's utilities + gs- bodies write
4581 // the shorthand, so that is the intended scope; widen this list if a longhand-
4582 // only owner ever needs to be surfaced.
4583 const STYLE_PROPS = [
4584 { prop: 'color', attrs: [ 'style.color.text' ] },
4585 { prop: 'background-color', attrs: [ 'style.color.background', 'background.color' ] },
4586 { prop: 'padding', attrs: [ 'style.spacing.padding' ] },
4587 { prop: 'margin', attrs: [ 'style.spacing.margin' ] },
4588 { prop: 'font-size', attrs: [ 'style.typography.fontSize' ] },
4589 { prop: 'font-weight', attrs: [ 'style.typography.fontWeight' ] },
4590 { prop: 'text-align', attrs: [ 'align' ] },
4591 { prop: 'max-width', attrs: [ 'maxWidth' ] },
4592 // Border / shadow / motion — className-utility-owned (the visual block
4593 // attributes for these are BANNED, so there is no block-attribute owner
4594 // path: `attrs` is empty and ownership resolves purely from the live
4595 // cascade). Tracked so a `border-2 border-red-500`, `rounded-lg`,
4596 // `shadow-md`, `animate-*`, or a transform utility applied via apply_change
4597 // has an `effective`/`owner` the agent can VERIFY — without these the
4598 // resolver was blind to them and the agent could never confirm the edit
4599 // (the "the border is applied, the bounce could not be confirmed" hedge).
4600 // Longhands (not the `border` shorthand): utilities author border-width /
4601 // border-color separately, and getComputedStyle returns longhands.
4602 { prop: 'border-color', attrs: [] },
4603 { prop: 'border-width', attrs: [] },
4604 { prop: 'border-radius', attrs: [] },
4605 { prop: 'box-shadow', attrs: [] },
4606 { prop: 'animation', attrs: [] },
4607 { prop: 'transform', attrs: [] },
4608 ];
4609 function deepGet( obj, path ) {
4610 let cur = obj;
4611 const parts = path.split( '.' );
4612 for ( let i = 0; i < parts.length; i++ ) {
4613 if ( cur === null || typeof cur !== 'object' ) {
4614 return undefined;
4615 }
4616 cur = cur[ parts[ i ] ];
4617 }
4618 return cur;
4619 }
4620 function gsTokensOf( block ) {
4621 const cn = ( block && block.attributes && typeof block.attributes.className === 'string' )
4622 ? block.attributes.className : '';
4623 return cn.trim().split( /\s+/ ).filter( function ( t ) {
4624 return t && t.indexOf( 'gs-' ) === 0;
4625 } );
4626 }
4627 function computedForBlock( clientId ) {
4628 try {
4629 const doc = canvasDoc();
4630 const el = doc.querySelector( '[data-block="' + clientId + '"]' );
4631 if ( ! el ) {
4632 return null;
4633 }
4634 return ( doc.defaultView || window ).getComputedStyle( el );
4635 } catch ( e ) {
4636 return null;
4637 }
4638 }
4639 // ── CSSOM CASCADE RESOLUTION ───────────────────────────────────────────────
4640 // The ROOT of ownership: `effective` is read from the rendered cascade, so
4641 // `owner` MUST be resolved from that SAME cascade — not an authored guess. The
4642 // authored join (attrs + GBS payload) is blind to JIT utility classes (they are
4643 // compiled to real CSS rules, never in the GBS payload) and to source-order
4644 // ties between two utilities — the exact gap behind "colour set but not
4645 // applied" (owner came back `default` while the pixel was clearly painted). We
4646 // read the real matched rules from the canvas stylesheets instead.
4647
4648 // The live element for a block (the [data-block] node in the canvas), or null
4649 // when unmounted (off-screen / virtualized) — then we degrade to the authored
4650 // join below, flagged as best-effort.
4651 function elForBlock( doc, clientId ) {
4652 try {
4653 return doc && clientId ? doc.querySelector( '[data-block="' + clientId + '"]' ) : null;
4654 } catch ( e ) {
4655 return null;
4656 }
4657 }
4658 function computedForEl( el, doc ) {
4659 try {
4660 return el ? ( doc.defaultView || window ).getComputedStyle( el ) : null;
4661 } catch ( e ) {
4662 return null;
4663 }
4664 }
4665
4666 // Approximate CSS specificity [ids, classes/attrs/pseudo-classes, types] for a
4667 // single (comma-free) selector. Our selectors are single classes (`.gs-x`,
4668 // `.text-primary-600`) → [0,1,0]; the ties they create are broken by SOURCE
4669 // ORDER, which is the real decider and is tracked separately.
4670 function specificityOf( sel ) {
4671 const s = String( sel );
4672 const ids = ( s.match( /#[\w-]+/g ) || [] ).length;
4673 const classesAttrsPc =
4674 ( s.match( /\.[\w-]+/g ) || [] ).length +
4675 ( s.match( /\[[^\]]*\]/g ) || [] ).length +
4676 ( s.match( /:(?!:)[\w-]+/g ) || [] ).length;
4677 const stripped = s
4678 .replace( /::?[\w-]+(\([^)]*\))?/g, ' ' )
4679 .replace( /[.#][\w-]+/g, ' ' )
4680 .replace( /\[[^\]]*\]/g, ' ' );
4681 const types = ( stripped.match( /[a-zA-Z][\w-]*/g ) || [] ).length;
4682 return [ ids, classesAttrsPc, types ];
4683 }
4684 function cmpSpec( a, b ) {
4685 for ( let i = 0; i < 3; i++ ) {
4686 if ( a[ i ] !== b[ i ] ) {
4687 return a[ i ] - b[ i ];
4688 }
4689 }
4690 return 0;
4691 }
4692 // Does a conditional group rule (@media / @supports) currently apply? Permissive
4693 // on the way in — a group we can't evaluate is INCLUDED (better to surface a
4694 // possibly-inactive source than hide the active one), matching the live paint
4695 // which the model verifies against `effective` anyway.
4696 function groupRuleApplies( doc, rule ) {
4697 try {
4698 const win = doc.defaultView || window;
4699 if ( rule.type === 4 /* MEDIA */ ) {
4700 const mt = rule.media && rule.media.mediaText;
4701 return ! mt || ! win.matchMedia ? true : win.matchMedia( mt ).matches;
4702 }
4703 if ( rule.type === 12 /* SUPPORTS */ ) {
4704 const ct = rule.conditionText;
4705 return ! ct || ! ( win.CSS && win.CSS.supports ) ? true : win.CSS.supports( ct );
4706 }
4707 } catch ( e ) { /* permissive */ }
4708 return true;
4709 }
4710
4711 // Every stylesheet declaration of `prop` whose selector MATCHES `el`, sorted in
4712 // ascending cascade priority (winner LAST): !important tier, then specificity,
4713 // then source order. Reads real CSSOM rules from the canvas — the true source
4714 // of the painted value.
4715 function matchedDeclarations( el, doc, prop ) {
4716 const out = [];
4717 let order = 0;
4718 function walk( rules ) {
4719 for ( let i = 0; i < rules.length; i++ ) {
4720 const r = rules[ i ];
4721 if ( r.type === 1 /* STYLE_RULE */ && r.selectorText && r.style ) {
4722 order++;
4723 const value = r.style.getPropertyValue( prop );
4724 if ( value === '' || value === null || value === undefined ) {
4725 continue;
4726 }
4727 const important = r.style.getPropertyPriority( prop ) === 'important';
4728 let best = null;
4729 String( r.selectorText ).split( ',' ).forEach( function ( part ) {
4730 const p = part.trim();
4731 if ( ! p ) {
4732 return;
4733 }
4734 let matches = false;
4735 try {
4736 matches = el.matches( p );
4737 } catch ( e ) {
4738 matches = false;
4739 }
4740 if ( ! matches ) {
4741 return;
4742 }
4743 const spec = specificityOf( p );
4744 if ( ! best || cmpSpec( spec, best.specificity ) > 0 ) {
4745 best = { part: p, specificity: spec };
4746 }
4747 } );
4748 if ( best ) {
4749 out.push( { selectorPart: best.part, value: String( value ).trim(), important, specificity: best.specificity, order } );
4750 }
4751 } else if ( ( r.type === 4 || r.type === 12 ) && r.cssRules && groupRuleApplies( doc, r ) ) {
4752 walk( r.cssRules );
4753 }
4754 }
4755 }
4756 const sheets = ( doc && doc.styleSheets ) || [];
4757 for ( let s = 0; s < sheets.length; s++ ) {
4758 let rules;
4759 try {
4760 rules = sheets[ s ].cssRules || sheets[ s ].rules;
4761 } catch ( e ) {
4762 continue;
4763 } // cross-origin sheet
4764 if ( rules ) {
4765 walk( rules );
4766 }
4767 }
4768 out.sort( function ( a, b ) {
4769 return ( a.important ? 1 : 0 ) - ( b.important ? 1 : 0 ) ||
4770 cmpSpec( a.specificity, b.specificity ) ||
4771 a.order - b.order;
4772 } );
4773 return out;
4774 }
4775
4776 // Map a single-selector matched rule to an EDITABLE source ON this block: a
4777 // `.gs-*` class (edit its body via set_styles) or a utility class (replace it
4778 // in className via apply_change). A compound selector (`.parent .child`) isn't
4779 // a class the model owns here → surfaced as `selector` so it edits the real
4780 // owner (or an ancestor) rather than stacking.
4781 function classifySelectorPart( part, el ) {
4782 const sel = String( part ).trim();
4783 if ( ! el || ! el.classList ) {
4784 return { type: 'selector', selector: sel };
4785 }
4786 // Class tokens named anywhere in the selector (a `[class]` attribute selector
4787 // or `:root` scoping carries no dot, so it is ignored here). The GBS JIT
4788 // boosts per-block rules to `[class].gs-x.gs-x` (attribute + DOUBLED class)
4789 // to outrank utilities — the same class repeated is ONE owner, editable via
4790 // set_styles. We must see through that, and through `:root .gs-x` scoping.
4791 const classTokens = ( sel.match( /\.[\w-]+/g ) || [] ).map( function ( c ) {
4792 return c.slice( 1 );
4793 } );
4794 if ( classTokens.length === 0 ) {
4795 return { type: 'selector', selector: sel };
4796 }
4797 // A SELF-target: every class the selector names is on THIS element (so it is
4798 // repetition / attribute-boosting / a structural `:root` scope, not a real
4799 // ancestor). A descendant selector like `.wrap .child` names an ancestor
4800 // class that ISN'T on el → it stays a contextual `selector:` the model can't
4801 // edit here. Exactly one DISTINCT on-el class keeps the owner unambiguous.
4802 const distinct = [];
4803 let allOnEl = true;
4804 classTokens.forEach( function ( c ) {
4805 if ( ! el.classList.contains( c ) ) {
4806 allOnEl = false;
4807 }
4808 if ( distinct.indexOf( c ) === -1 ) {
4809 distinct.push( c );
4810 }
4811 } );
4812 if ( ! allOnEl || distinct.length !== 1 ) {
4813 return { type: 'selector', selector: sel };
4814 }
4815 const cls = distinct[ 0 ];
4816 return cls.indexOf( 'gs-' ) === 0
4817 ? { type: 'gbs', class: cls }
4818 : { type: 'utility', class: cls };
4819 }
4820 function ownerLabel( src ) {
4821 if ( ! src ) {
4822 return 'default';
4823 }
4824 if ( src.type === 'block_attribute' ) {
4825 return 'block_attribute:' + src.path;
4826 }
4827 if ( src.type === 'gbs' ) {
4828 return 'gbs:' + src.class;
4829 }
4830 if ( src.type === 'utility' ) {
4831 return 'utility:' + src.class;
4832 }
4833 if ( src.type === 'selector' ) {
4834 return 'selector:' + src.selector;
4835 }
4836 return 'default';
4837 }
4838
4839 // Cascade-ordered editable sources for ONE property on a MOUNTED block, winner
4840 // first. Merges the authored inline attribute (Spectra renders style.* inline,
4841 // a normal declaration above class selectors) with the real matched rules, then
4842 // sorts by the CSS cascade: !important tier > inline > selectors, each broken by
4843 // specificity then source order.
4844 function resolveCascadeSources( def, attrs, el, doc, usageByToken ) {
4845 const candidates = [];
4846 def.attrs.forEach( function ( path ) {
4847 const v = deepGet( attrs, path );
4848 if ( v !== undefined && v !== null && v !== '' ) {
4849 // level 2 = normal inline; specificity above any class selector.
4850 candidates.push( { src: { type: 'block_attribute', path, value: v }, level: 2, specificity: [ 1, 0, 0 ], order: Number.MAX_SAFE_INTEGER } );
4851 }
4852 } );
4853 matchedDeclarations( el, doc, def.prop ).forEach( function ( d ) {
4854 const cls = classifySelectorPart( d.selectorPart, el );
4855 let src;
4856 if ( cls.type === 'gbs' ) {
4857 src = { type: 'gbs', class: cls.class, value: d.value };
4858 if ( usageByToken && usageByToken[ cls.class ] !== undefined ) {
4859 src.usage = usageByToken[ cls.class ];
4860 }
4861 } else if ( cls.type === 'utility' ) {
4862 src = { type: 'utility', class: cls.class, value: d.value };
4863 } else {
4864 src = { type: 'selector', selector: cls.selector, value: d.value };
4865 }
4866 candidates.push( { src, level: d.important ? 3 : 1, specificity: d.specificity, order: d.order } );
4867 } );
4868 candidates.sort( function ( a, b ) {
4869 return a.level - b.level || cmpSpec( a.specificity, b.specificity ) || a.order - b.order;
4870 } );
4871 return candidates.reverse().map( function ( c ) {
4872 return c.src;
4873 } ); // winner first
4874 }
4875
4876 // Per-property { effective, owner, availableSources[] } for ONE block. When the
4877 // block is MOUNTED (the real case: the selected block is on-screen), ownership
4878 // is resolved from the live CSS cascade (CSSOM) so `owner` can never disagree
4879 // with the painted `effective`. When unmounted (off-screen / no canvas — e.g.
4880 // jsdom), we degrade to the authored join (attrs + GBS payload), which is
4881 // best-effort. `canvas` ({ doc, el }) is an injectable seam for tests.
4882 function buildStyleContext( block, pagePayload, usageByToken, canvas ) {
4883 if ( ! block ) {
4884 return null;
4885 }
4886 const classes = ( pagePayload && pagePayload.classes && typeof pagePayload.classes === 'object' )
4887 ? pagePayload.classes : {};
4888 const gsTokens = gsTokensOf( block );
4889 const attrs = block.attributes || {};
4890 const doc = ( canvas && canvas.doc ) || canvasDoc();
4891 const el = ( canvas && canvas.el !== undefined ) ? canvas.el : elForBlock( doc, block.clientId );
4892 const cs = el ? computedForEl( el, doc ) : computedForBlock( block.clientId );
4893 const properties = {};
4894 if ( el ) {
4895 // ROOT PATH — resolve ownership from the live cascade (CSSOM), so a
4896 // colour painted by a JIT utility (or the winner of two competing
4897 // utilities) is named correctly instead of collapsing to `default`.
4898 STYLE_PROPS.forEach( function ( def ) {
4899 const sources = resolveCascadeSources( def, attrs, el, doc, usageByToken );
4900 properties[ def.prop ] = {
4901 effective: cs ? cs.getPropertyValue( def.prop ) : null,
4902 owner: sources.length ? ownerLabel( sources[ 0 ] ) : 'default',
4903 availableSources: sources,
4904 };
4905 } );
4906 } else {
4907 // FALLBACK — block not mounted (no canvas node): the authored join
4908 // (attrs + GBS payload) is the best we can do and is flagged best-effort
4909 // by the absence of a rendered `effective`. It cannot see utilities.
4910 STYLE_PROPS.forEach( function ( def ) {
4911 const sources = [];
4912 // block_attribute tier (highest specificity)
4913 def.attrs.forEach( function ( path ) {
4914 const v = deepGet( attrs, path );
4915 if ( v !== undefined && v !== null && v !== '' ) {
4916 sources.push( { type: 'block_attribute', path, value: v } );
4917 }
4918 } );
4919 // gbs class tier — only classes ACTUALLY on this block, only if they declare it.
4920 // `usage` = how many blocks carry this class: >1 means editing its body
4921 // restyles those other sections too (the reverse-dependency signal).
4922 gsTokens.forEach( function ( token ) {
4923 const body = classes[ token ] && classes[ token ].default;
4924 if ( ! body || typeof body !== 'object' ) {
4925 return;
4926 }
4927 const v = body[ def.prop ];
4928 if ( v !== undefined && v !== null && v !== '' ) {
4929 sources.push( { type: 'gbs', class: token, value: v, usage: usageByToken ? usageByToken[ token ] : undefined } );
4930 }
4931 } );
4932 const owner = sources.length === 0
4933 ? 'default'
4934 : ( sources[ 0 ].type === 'block_attribute'
4935 ? 'block_attribute:' + sources[ 0 ].path
4936 : 'gbs:' + sources[ 0 ].class );
4937 properties[ def.prop ] = {
4938 effective: null,
4939 owner,
4940 availableSources: sources,
4941 };
4942 } );
4943 }
4944 // Classes on THIS block that also style other sections — edit their body
4945 // and every listed block moves with it. The model surfaces this to the user
4946 // (or restyles this block with utilities instead — see the doctrine).
4947 const sharedClasses = usageByToken
4948 ? gsTokens.filter( function ( t ) {
4949 return ( usageByToken[ t ] || 1 ) > 1;
4950 } )
4951 .map( function ( t ) {
4952 return { class: t, used_by: usageByToken[ t ] };
4953 } )
4954 : [];
4955 return {
4956 client_id: block.clientId,
4957 gbs_classes: gsTokens,
4958 properties,
4959 shared_gbs_classes: sharedClasses,
4960 };
4961 }
4962
4963 // ── get-styles ───────────────────────────────────────────────────────────
4964 async function handleGetStyles( args ) {
4965 const scope = args && args.scope === 'global' ? 'global' : 'page';
4966 if ( scope === 'page' ) {
4967 const sel = editorSelect();
4968 const postId = sel && sel.getCurrentPostId ? sel.getCurrentPostId() : 0;
4969 if ( ! postId ) {
4970 return { success: false, error: 'editor_unavailable: no current post id (is the block editor open?)' };
4971 }
4972 try {
4973 const pres = await apiFetch( { path: NS + '/save?scope=page&post_id=' + postId } );
4974 const pp = ( pres && pres.payload && typeof pres.payload === 'object' ) ? pres.payload : {};
4975 const out = { scope: 'page', post_id: postId, buckets: bucketsOf( pp ), payload: pp };
4976 // STYLE CONTEXT (ownership) for a target block — the agent reads
4977 // this BEFORE a styling edit so it updates the existing owner
4978 // instead of guessing/stacking. Best-effort: a missing block /
4979 // unmounted node simply omits styleContext (never blocks the read).
4980 const clientId = args && typeof args.client_id === 'string' ? args.client_id : null;
4981 if ( clientId && sel && sel.getBlock ) {
4982 const blk = sel.getBlock( clientId );
4983 // Reverse-dependency counts for the block's gs- tokens, so the
4984 // model sees "shared by N" BEFORE it picks an owner to edit.
4985 const usageByToken = {};
4986 ( blk && blk.attributes && typeof blk.attributes.className === 'string'
4987 ? blk.attributes.className.trim().split( /\s+/ ) : [] )
4988 .filter( function ( t ) {
4989 return t.indexOf( 'gs-' ) === 0;
4990 } )
4991 .forEach( function ( t ) {
4992 usageByToken[ t ] = classUsage( t );
4993 } );
4994 const ctx = buildStyleContext( blk, pp, usageByToken );
4995 if ( ctx ) {
4996 out.styleContext = ctx;
4997 }
4998 }
4999 return { success: true, data: out };
5000 } catch ( e ) {
5001 return { success: false, error: 'page_read_failed: ' + String( e && e.message ? e.message : e ) };
5002 }
5003 }
5004 try {
5005 const res = await apiFetch( { path: NS + '/user-css' } );
5006 const gp = ( res && res.payload && typeof res.payload === 'object' ) ? res.payload : {};
5007 return { success: true, data: { scope: 'global', buckets: bucketsOf( gp ), payload: gp } };
5008 } catch ( e ) {
5009 return { success: false, error: 'global_read_failed: ' + String( e && e.message ? e.message : e ) };
5010 }
5011 }
5012
5013 // ── set-styles ───────────────────────────────────────────────────────────
5014 // GLOBAL (chrome) scope ONLY. The former scope:'page' write — an IMMEDIATE,
5015 // irreversible REST write to this post's `spectra_blocks_pro_gs_user_css` meta
5016 // — was REMOVED: it persisted a per-block style edit to the DB even if the user
5017 // never Saved (no undo), and editing a SHARED gs- class body silently restyled
5018 // every section carrying it. All per-block styling now goes through
5019 // editor/apply-change utility classNames (editor state, committed on Save). This
5020 // handler still rejects scope:'page' defensively so a stale/forked brain bundle
5021 // can never resurrect the silent DB write.
5022 async function handleSetStyles( args ) {
5023 const scope = args && args.scope === 'global' ? 'global' : null;
5024 const incoming = args && args.payload;
5025 if ( args && args.scope === 'page' ) {
5026 return { success: false, error: 'unsupported_scope: per-page style writes were removed (they wrote the DB before Save and restyled shared classes). Change per-block styling with a utility className via apply_change instead. set_styles is header/footer chrome (scope:"global") only.' };
5027 }
5028 if ( scope === null ) {
5029 return { success: false, error: 'invalid_input: scope must be "global" (per-block styling uses apply_change utilities)' };
5030 }
5031 if ( ! incoming || typeof incoming !== 'object' || Array.isArray( incoming ) ) {
5032 return { success: false, error: 'invalid_input: payload must be a schema-v1 object of style buckets' };
5033 }
5034 if ( ! bucketsOf( incoming ).length ) {
5035 return { success: false, error: 'invalid_input: payload has no style buckets (classes / wrapperStyles / rootStyles / …)' };
5036 }
5037
5038 // scope === 'global' — read-modify-write the option (immediate, site-wide).
5039 let existingGlobal;
5040 try {
5041 // eslint-disable-next-line no-redeclare, no-var
5042 var g = await apiFetch( { path: NS + '/user-css' } );
5043 existingGlobal = ( g && g.payload && typeof g.payload === 'object' ) ? g.payload : {};
5044 } catch ( e ) {
5045 return { success: false, error: 'global_read_failed: ' + String( e && e.message ? e.message : e ) };
5046 }
5047 const mergedGlobal = mergePayload( existingGlobal, incoming );
5048 try {
5049 // /sitewide replaces non-class buckets wholesale → send the FULL merged
5050 // payload so the write equals the merged state (chrome/user classes kept).
5051 await apiFetch( { path: NS + '/sitewide', method: 'POST', data: { payload: mergedGlobal } } );
5052 } catch ( e ) {
5053 return { success: false, error: 'global_write_failed: ' + String( e && e.message ? e.message : e ) };
5054 }
5055 try {
5056 const rg = await apiFetch( { path: NS + '/render', method: 'POST', data: { payload: mergedGlobal, post_id: 0, scope: 'global' } } );
5057 // Append-last override so the live global paint wins source-order ties
5058 // (deletions converge on reload, consistent with the live-JIT model).
5059 injectCss( 'zipwp-gbs-live-global', rg && rg.css );
5060 } catch ( e ) {
5061 return { success: false, error: 'render_failed: ' + String( e && e.message ? e.message : e ) };
5062 }
5063 return {
5064 success: true,
5065 data: {
5066 scope: 'global',
5067 buckets: bucketsOf( incoming ),
5068 note: 'Site-wide + IMMEDIATE: applied to every page now (not reversible by discarding the editor).',
5069 },
5070 };
5071 }
5072
5073 function initHandler() {
5074 if ( window.zipwpMcp && window.zipwpMcp.registerTool ) {
5075 window.zipwpMcp.registerTool( 'editor/get-styles', async function ( args ) {
5076 return handleGetStyles( args );
5077 }, { previewMode: 'client' } );
5078 window.zipwpMcp.registerTool( 'editor/set-styles', async function ( args ) {
5079 return handleSetStyles( args );
5080 }, { previewMode: 'client' } );
5081 } else {
5082 setTimeout( initHandler, 100 );
5083 }
5084 }
5085 initHandler();
5086
5087 // Test-only surface (Node/CommonJS) — inert in the browser bundle.
5088 if ( typeof module !== 'undefined' && module.exports ) {
5089 module.exports = {
5090 handleGetStyles,
5091 handleSetStyles,
5092 mergePayload,
5093 // Ownership resolver (per-property effective/owner/availableSources).
5094 // Exported PURE so a unit test locks the join, not a reimplementation.
5095 buildStyleContext,
5096 };
5097 }
5098 }() );
5099
5100
5101 /**
5102 * Block Context Picker
5103 *
5104 * Adds a "+" pin button on block hover in Gutenberg editor.
5105 * Selected blocks are sent as context to the chat assistant.
5106 * Supports both classic and iframed block editor (WP 6.3+).
5107 *
5108 * @since x.x.x
5109 */
5110
5111 ( function() {
5112 'use strict';
5113
5114 /** SVG for the "+" (add) icon */
5115 const ICON_ADD = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
5116
5117 /** SVG for the checkmark (pinned) icon */
5118 const ICON_CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
5119
5120 function BlockContextPicker() {
5121 /** @type {Map<string, Object>} clientId → serialized block data */
5122 this.contextBlocks = new Map();
5123
5124 /** @type {HTMLElement|null} Single floating pin button */
5125 this.pinButton = null;
5126
5127 /** @type {string|null} clientId of the block currently being hovered */
5128 this.hoveredClientId = null;
5129
5130 /** @type {boolean} Whether the picker is currently active */
5131 this.active = false;
5132
5133 /** @type {Function|null} wp.data.subscribe unsubscribe handle */
5134 this._unsubscribeBlockDeletion = null;
5135
5136 /** @type {Document} The document where blocks live (parent or iframe) */
5137 this._editorDocument = document;
5138
5139 /** @type {number} Last known block count for quick deletion check */
5140 this._lastBlockCount = 0;
5141
5142 /** @type {Function|null} Bound handler references for cleanup */
5143 const self = this;
5144 this._onMouseOver = function( e ) {
5145 self._handleMouseOver( e );
5146 };
5147 this._onMouseOut = function( e ) {
5148 self._handleMouseOut( e );
5149 };
5150 this._onPinClick = function( e ) {
5151 self._handlePinClick( e );
5152 };
5153 }
5154
5155 /**
5156 * Enable the picker — called when sidebar opens.
5157 *
5158 * @since x.x.x
5159 */
5160 BlockContextPicker.prototype.enable = function() {
5161 if ( this.active ) {
5162 return;
5163 }
5164
5165 if ( ! window.wp || ! window.wp.data || ! window.wp.data.select( 'core/block-editor' ) ) {
5166 return;
5167 }
5168
5169 this.active = true;
5170 this._resolveEditorDocument();
5171 this._createPinButton();
5172 this._bindEditorEvents();
5173 this._watchBlockDeletion();
5174 };
5175
5176 /**
5177 * Disable the picker — called when sidebar closes.
5178 *
5179 * @since x.x.x
5180 */
5181 BlockContextPicker.prototype.disable = function() {
5182 if ( ! this.active ) {
5183 return;
5184 }
5185
5186 this.active = false;
5187 this._removePinButton();
5188 this._unbindEditorEvents();
5189 this._unwatchBlockDeletion();
5190 this.hoveredClientId = null;
5191 };
5192
5193 /**
5194 * Clear all pinned context blocks.
5195 *
5196 * @since x.x.x
5197 */
5198 BlockContextPicker.prototype.clear = function() {
5199 this.contextBlocks.clear();
5200 this.hoveredClientId = null;
5201 this._removeAllHighlights();
5202 this._updateBadge();
5203 };
5204
5205 /**
5206 * Get all context blocks as an array for the chat context payload.
5207 *
5208 * @since x.x.x
5209 * @return {Array<Object>}
5210 */
5211 BlockContextPicker.prototype.getContextBlocks = function() {
5212 return Array.from( this.contextBlocks.values() );
5213 };
5214
5215 /**
5216 * Check if any blocks are pinned.
5217 *
5218 * @since x.x.x
5219 * @return {boolean}
5220 */
5221 BlockContextPicker.prototype.hasContextBlocks = function() {
5222 return this.contextBlocks.size > 0;
5223 };
5224
5225 // ── Editor document resolution (iframe support) ─────────────────
5226
5227 /**
5228 * Detect whether the block editor uses an iframe canvas (WP 6.3+).
5229 *
5230 * @since x.x.x
5231 */
5232 BlockContextPicker.prototype._resolveEditorDocument = function() {
5233 const editorIframe = document.querySelector( 'iframe[name="editor-canvas"]' );
5234 if ( editorIframe && editorIframe.contentDocument ) {
5235 this._editorDocument = editorIframe.contentDocument;
5236 } else {
5237 this._editorDocument = document;
5238 }
5239 };
5240
5241 // ── DOM — pin button ────────────────────────────────────────────
5242
5243 /**
5244 * Create the floating pin button element.
5245 *
5246 * @since x.x.x
5247 */
5248 BlockContextPicker.prototype._createPinButton = function() {
5249 if ( this.pinButton ) {
5250 return;
5251 }
5252
5253 const doc = this._editorDocument;
5254 const btn = doc.createElement( 'button' );
5255 btn.className = 'zipwp-context-pin';
5256 btn.type = 'button';
5257 btn.setAttribute( 'aria-label', 'Pin block to chat context' );
5258 btn.innerHTML = ICON_ADD;
5259
5260 btn.addEventListener( 'click', this._onPinClick );
5261 doc.body.appendChild( btn );
5262 this.pinButton = btn;
5263
5264 // Inject styles into iframe editor if needed
5265 if ( doc !== document ) {
5266 this._injectIframeStyles( doc );
5267 }
5268 };
5269
5270 /**
5271 * Inject context picker CSS into the editor iframe document.
5272 *
5273 * @since x.x.x
5274 * @param {Document} doc
5275 */
5276 BlockContextPicker.prototype._injectIframeStyles = function( doc ) {
5277 if ( doc.getElementById( 'zipwp-context-picker-styles' ) ) {
5278 return;
5279 }
5280
5281 const style = doc.createElement( 'style' );
5282 style.id = 'zipwp-context-picker-styles';
5283 style.textContent =
5284 '.zipwp-context-pin{display:none;position:absolute;z-index:100000;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:none;border-radius:6px;background:#6366f1;color:#fff;cursor:pointer;box-shadow:0 2px 8px rgba(99,102,241,.35);transition:background-color .15s ease,transform .15s ease,box-shadow .15s ease}' +
5285 '.zipwp-context-pin:hover{background:#4f46e5;transform:scale(1.1);box-shadow:0 4px 12px rgba(99,102,241,.45)}' +
5286 '.zipwp-context-pin svg{width:16px;height:16px;display:block;pointer-events:none}' +
5287 '.zipwp-context-pin--active{background:#16a34a}' +
5288 '.zipwp-context-pin--active:hover{background:#dc2626}' +
5289 '.zipwp-context-selected{box-shadow:0 0 0 2px #6366f1 !important;border-radius:2px}';
5290 doc.head.appendChild( style );
5291 };
5292
5293 /**
5294 * Remove the pin button from DOM.
5295 *
5296 * @since x.x.x
5297 */
5298 BlockContextPicker.prototype._removePinButton = function() {
5299 if ( this.pinButton ) {
5300 this.pinButton.removeEventListener( 'click', this._onPinClick );
5301 this.pinButton.remove();
5302 this.pinButton = null;
5303 }
5304 };
5305
5306 /**
5307 * Position the pin button next to the hovered block.
5308 *
5309 * @since x.x.x
5310 * @param {HTMLElement} blockEl
5311 */
5312 BlockContextPicker.prototype._positionPinButton = function( blockEl ) {
5313 if ( ! this.pinButton ) {
5314 return;
5315 }
5316
5317 const doc = this._editorDocument;
5318 const rect = blockEl.getBoundingClientRect();
5319 const scrollTop = doc.defaultView.scrollY || doc.documentElement.scrollTop;
5320 const scrollLeft = doc.defaultView.scrollX || doc.documentElement.scrollLeft;
5321
5322 this.pinButton.style.top = ( rect.top + scrollTop + 4 ) + 'px';
5323 this.pinButton.style.left = ( rect.right + scrollLeft - 36 ) + 'px';
5324 this.pinButton.style.display = 'flex';
5325
5326 // Update icon for pinned state
5327 const clientId = blockEl.dataset.block;
5328 if ( clientId && this.contextBlocks.has( clientId ) ) {
5329 this.pinButton.classList.add( 'zipwp-context-pin--active' );
5330 this.pinButton.innerHTML = ICON_CHECK;
5331 this.pinButton.setAttribute( 'aria-label', 'Unpin block from chat context' );
5332 } else {
5333 this.pinButton.classList.remove( 'zipwp-context-pin--active' );
5334 this.pinButton.innerHTML = ICON_ADD;
5335 this.pinButton.setAttribute( 'aria-label', 'Pin block to chat context' );
5336 }
5337 };
5338
5339 /**
5340 * Hide the pin button.
5341 *
5342 * @since x.x.x
5343 */
5344 BlockContextPicker.prototype._hidePinButton = function() {
5345 if ( this.pinButton ) {
5346 this.pinButton.style.display = 'none';
5347 }
5348 };
5349
5350 // ── Events — hover delegation ───────────────────────────────────
5351
5352 /**
5353 * Bind mouseover/mouseout on the editor canvas via delegation.
5354 *
5355 * @since x.x.x
5356 */
5357 BlockContextPicker.prototype._bindEditorEvents = function() {
5358 const doc = this._editorDocument;
5359 doc.addEventListener( 'mouseover', this._onMouseOver, true );
5360 doc.addEventListener( 'mouseout', this._onMouseOut, true );
5361 };
5362
5363 /**
5364 * Unbind editor hover events.
5365 *
5366 * @since x.x.x
5367 */
5368 BlockContextPicker.prototype._unbindEditorEvents = function() {
5369 const doc = this._editorDocument;
5370 doc.removeEventListener( 'mouseover', this._onMouseOver, true );
5371 doc.removeEventListener( 'mouseout', this._onMouseOut, true );
5372 };
5373
5374 /**
5375 * Handle mouseover — find the closest block wrapper.
5376 *
5377 * @since x.x.x
5378 * @param {MouseEvent} e
5379 */
5380 BlockContextPicker.prototype._handleMouseOver = function( e ) {
5381 if ( ! this.active ) {
5382 return;
5383 }
5384
5385 if ( this.pinButton && this.pinButton.contains( e.target ) ) {
5386 return;
5387 }
5388
5389 const blockEl = e.target.closest( '[data-block]' );
5390 if ( ! blockEl ) {
5391 return;
5392 }
5393
5394 const clientId = blockEl.dataset.block;
5395 if ( ! clientId || clientId === this.hoveredClientId ) {
5396 return;
5397 }
5398
5399 this.hoveredClientId = clientId;
5400 this._positionPinButton( blockEl );
5401 };
5402
5403 /**
5404 * Handle mouseout — hide pin when cursor leaves all blocks.
5405 *
5406 * @since x.x.x
5407 * @param {MouseEvent} e
5408 */
5409 BlockContextPicker.prototype._handleMouseOut = function( e ) {
5410 if ( ! this.active ) {
5411 return;
5412 }
5413
5414 if ( this.pinButton && this.pinButton.contains( e.relatedTarget ) ) {
5415 return;
5416 }
5417
5418 const relatedBlock = e.relatedTarget && e.relatedTarget.closest
5419 ? e.relatedTarget.closest( '[data-block]' )
5420 : null;
5421 if ( relatedBlock ) {
5422 return;
5423 }
5424
5425 this.hoveredClientId = null;
5426 this._hidePinButton();
5427 };
5428
5429 /**
5430 * Handle click on the pin button — toggle block in context.
5431 *
5432 * @since x.x.x
5433 * @param {MouseEvent} e
5434 */
5435 BlockContextPicker.prototype._handlePinClick = function( e ) {
5436 e.preventDefault();
5437 e.stopPropagation();
5438
5439 const clientId = this.hoveredClientId;
5440 if ( ! clientId ) {
5441 return;
5442 }
5443
5444 if ( this.contextBlocks.has( clientId ) ) {
5445 // Unpin
5446 this.contextBlocks.delete( clientId );
5447 this._removeHighlight( clientId );
5448 if ( this.pinButton ) {
5449 this.pinButton.classList.remove( 'zipwp-context-pin--active' );
5450 this.pinButton.innerHTML = ICON_ADD;
5451 this.pinButton.setAttribute( 'aria-label', 'Pin block to chat context' );
5452 }
5453 } else {
5454 // Pin
5455 const blockData = this._serializeBlockForContext( clientId );
5456 if ( blockData ) {
5457 this.contextBlocks.set( clientId, blockData );
5458 this._addHighlight( clientId );
5459 if ( this.pinButton ) {
5460 this.pinButton.classList.add( 'zipwp-context-pin--active' );
5461 this.pinButton.innerHTML = ICON_CHECK;
5462 this.pinButton.setAttribute( 'aria-label', 'Unpin block from chat context' );
5463 }
5464 }
5465 }
5466
5467 this._updateBadge();
5468 this._notifyContextChanged();
5469 };
5470
5471 // ── Bridge notification ─────────────────────────────────────────
5472
5473 /**
5474 * Notify the React app that context blocks changed.
5475 * Emits event on the React DirectBridge so components can react.
5476 *
5477 * @since x.x.x
5478 */
5479 BlockContextPicker.prototype._notifyContextChanged = function() {
5480 const appBridge = window.zipwpMcpAppBridge;
5481 if ( appBridge && appBridge.emit ) {
5482 appBridge.emit( 'context_blocks_changed', {
5483 context_blocks: this.getContextBlocks(),
5484 count: this.contextBlocks.size,
5485 } );
5486 }
5487 };
5488
5489 // ── Block serialization ─────────────────────────────────────────
5490
5491 /**
5492 * Serialize a block for chat context.
5493 *
5494 * @since x.x.x
5495 * @param {string} clientId
5496 * @return {Object|null}
5497 */
5498 BlockContextPicker.prototype._serializeBlockForContext = function( clientId ) {
5499 if ( ! window.wp || ! window.wp.data || ! window.wp.blocks ) {
5500 return null;
5501 }
5502
5503 const blockEditorSelect = window.wp.data.select( 'core/block-editor' );
5504 const block = blockEditorSelect.getBlock( clientId );
5505 if ( ! block ) {
5506 return null;
5507 }
5508
5509 let blocksHtml = '';
5510 try {
5511 blocksHtml = window.wp.blocks.serialize( [ block ] );
5512 const bridge = window.zipwpMcpBridge;
5513 if ( bridge && bridge.fixUnicodeEscapes ) {
5514 blocksHtml = bridge.fixUnicodeEscapes( blocksHtml );
5515 }
5516 } catch ( e ) {
5517 console.warn( 'BlockContextPicker: Failed to serialize block', e ); // eslint-disable-line no-console -- intentional error surfacing
5518 }
5519
5520 const rootClientId = blockEditorSelect.getBlockRootClientId( clientId );
5521 const blockIndex = blockEditorSelect.getBlockIndex( clientId );
5522
5523 return {
5524 clientId: block.clientId,
5525 name: block.name,
5526 blocks_html: blocksHtml,
5527 block_index: blockIndex,
5528 root_client_id: rootClientId || null,
5529 };
5530 };
5531
5532 // ── Visual feedback — block highlights ──────────────────────────
5533
5534 /**
5535 * @param clientId
5536 * @since x.x.x
5537 */
5538 BlockContextPicker.prototype._addHighlight = function( clientId ) {
5539 const blockEl = this._editorDocument.querySelector( '[data-block="' + clientId + '"]' );
5540 if ( blockEl ) {
5541 blockEl.classList.add( 'zipwp-context-selected' );
5542 }
5543 };
5544
5545 /**
5546 * @param clientId
5547 * @since x.x.x
5548 */
5549 BlockContextPicker.prototype._removeHighlight = function( clientId ) {
5550 const blockEl = this._editorDocument.querySelector( '[data-block="' + clientId + '"]' );
5551 if ( blockEl ) {
5552 blockEl.classList.remove( 'zipwp-context-selected' );
5553 }
5554 };
5555
5556 /**
5557 * @since x.x.x
5558 */
5559 BlockContextPicker.prototype._removeAllHighlights = function() {
5560 const els = this._editorDocument.querySelectorAll( '.zipwp-context-selected' );
5561 for ( let i = 0; i < els.length; i++ ) {
5562 els[ i ].classList.remove( 'zipwp-context-selected' );
5563 }
5564 };
5565
5566 // ── Badge on FAB trigger button ─────────────────────────────────
5567
5568 /**
5569 * @since x.x.x
5570 */
5571 BlockContextPicker.prototype._updateBadge = function() {
5572 const trigger = document.getElementById( 'zip-ai-floating-trigger' );
5573 if ( ! trigger ) {
5574 return;
5575 }
5576
5577 let badge = trigger.querySelector( '.zipwp-context-badge' );
5578 const count = this.contextBlocks.size;
5579
5580 if ( count === 0 ) {
5581 if ( badge ) {
5582 badge.remove();
5583 }
5584 return;
5585 }
5586
5587 if ( ! badge ) {
5588 badge = document.createElement( 'span' );
5589 badge.className = 'zipwp-context-badge';
5590 trigger.appendChild( badge );
5591 }
5592
5593 badge.textContent = count;
5594 };
5595
5596 // ── Block deletion watcher ──────────────────────────────────────
5597
5598 /**
5599 * @since x.x.x
5600 */
5601 BlockContextPicker.prototype._watchBlockDeletion = function() {
5602 if ( ! window.wp || ! window.wp.data ) {
5603 return;
5604 }
5605
5606 const self = this;
5607 const subscribe = window.wp.data.subscribe;
5608 const blockEditorSelect = window.wp.data.select( 'core/block-editor' );
5609
5610 this._lastBlockCount = blockEditorSelect.getGlobalBlockCount
5611 ? blockEditorSelect.getGlobalBlockCount()
5612 : blockEditorSelect.getBlocks().length;
5613
5614 this._unsubscribeBlockDeletion = subscribe( function() {
5615 if ( self.contextBlocks.size === 0 ) {
5616 return;
5617 }
5618
5619 const currentCount = blockEditorSelect.getGlobalBlockCount
5620 ? blockEditorSelect.getGlobalBlockCount()
5621 : blockEditorSelect.getBlocks().length;
5622
5623 if ( currentCount >= self._lastBlockCount ) {
5624 self._lastBlockCount = currentCount;
5625 return;
5626 }
5627
5628 self._lastBlockCount = currentCount;
5629
5630 const currentBlockIds = self._getAllBlockClientIds();
5631 let changed = false;
5632
5633 // Use Array.from to avoid iterator issues in older environments
5634 const pinnedIds = Array.from( self.contextBlocks.keys() );
5635 for ( let i = 0; i < pinnedIds.length; i++ ) {
5636 if ( ! currentBlockIds.has( pinnedIds[ i ] ) ) {
5637 self.contextBlocks.delete( pinnedIds[ i ] );
5638 self._removeHighlight( pinnedIds[ i ] );
5639 changed = true;
5640 }
5641 }
5642
5643 if ( changed ) {
5644 self._updateBadge();
5645 self._notifyContextChanged();
5646 }
5647 } );
5648 };
5649
5650 /**
5651 * @since x.x.x
5652 */
5653 BlockContextPicker.prototype._unwatchBlockDeletion = function() {
5654 if ( this._unsubscribeBlockDeletion ) {
5655 this._unsubscribeBlockDeletion();
5656 this._unsubscribeBlockDeletion = null;
5657 }
5658 };
5659
5660 /**
5661 * Get a Set of all block clientIds in the editor.
5662 *
5663 * @since x.x.x
5664 * @return {Set<string>}
5665 */
5666 BlockContextPicker.prototype._getAllBlockClientIds = function() {
5667 const ids = new Set();
5668 if ( ! window.wp || ! window.wp.data ) {
5669 return ids;
5670 }
5671
5672 const blockEditorSelect = window.wp.data.select( 'core/block-editor' );
5673 if ( ! blockEditorSelect ) {
5674 return ids;
5675 }
5676
5677 const collectIds = function( blocks ) {
5678 for ( let i = 0; i < blocks.length; i++ ) {
5679 ids.add( blocks[ i ].clientId );
5680 if ( blocks[ i ].innerBlocks && blocks[ i ].innerBlocks.length > 0 ) {
5681 collectIds( blocks[ i ].innerBlocks );
5682 }
5683 }
5684 };
5685
5686 collectIds( blockEditorSelect.getBlocks() );
5687 return ids;
5688 };
5689
5690 // Expose singleton on window
5691 window.zipwpMcpContextPicker = new BlockContextPicker();
5692 }() );
5693
5694
5695 /**
5696 * Popover Drag Manager
5697 *
5698 * Handles drag-to-reposition for the popover panel mode.
5699 * Position persists in localStorage so it stays where the user left it.
5700 *
5701 * Used by WPBridgeHost — not standalone.
5702 */
5703
5704 ( function() {
5705 'use strict';
5706
5707 // Keys + geometry come from the ZIPWP_LAYOUT SSOT (loaded first); the
5708 // fallbacks keep this resilient if that global is ever absent.
5709 const L = ( typeof window !== 'undefined' && window.ZIPWP_LAYOUT ) || {};
5710 const LKEYS = L.keys || {};
5711 const LPOP = L.popover || {};
5712 const STORAGE_KEY = LKEYS.popoverPosition || 'zipwp-popover-position';
5713 const SIZE_STORAGE_KEY = LKEYS.popoverSize || 'zipwp-popover-size';
5714 const CONTAINER_ID = 'zip-ai-assistant-container';
5715 const DRAG_HANDLE_SELECTOR = '[data-popover-drag]';
5716 const RESIZE_HANDLE_SELECTOR = '[data-popover-resize]';
5717 const INTERACTIVE_SELECTOR = 'button, input, textarea, a, [role="menuitem"], [data-radix-collection-item]';
5718 const DRAG_THRESHOLD = ( L.fab && L.fab.dragThreshold ) || 4; // px — ignore tiny accidental drags
5719 const MIN_WIDTH = LPOP.minWidth || 360;
5720 const MIN_HEIGHT = LPOP.minHeight || 400;
5721 const MAX_WIDTH_MARGIN = LPOP.maxWidthMargin || 40; // min margin from viewport edge
5722 const MAX_HEIGHT_MARGIN = LPOP.maxHeightMargin || 60;
5723
5724 /**
5725 * Sets up drag listeners on the container.
5726 * Called once from WPBridgeHost.setup().
5727 * @param getBridgeLayout
5728 */
5729 function setupPopoverDrag( getBridgeLayout ) {
5730 const container = document.getElementById( CONTAINER_ID );
5731 if ( ! container ) {
5732 return;
5733 }
5734
5735 injectResizeHandles( container );
5736
5737 let isDragging = false;
5738 let didMove = false;
5739 let startX, startY, startLeft, startTop;
5740 let isResizing = false;
5741 let resizeEdges = null;
5742 let startWidth, startHeight;
5743
5744 function onMouseMove( e ) {
5745 const dx = e.clientX - startX;
5746 const dy = e.clientY - startY;
5747
5748 // Ignore until threshold is met
5749 if ( ! didMove && Math.abs( dx ) + Math.abs( dy ) < DRAG_THRESHOLD ) {
5750 return;
5751 }
5752 didMove = true;
5753
5754 let newLeft = startLeft + dx;
5755 let newTop = startTop + dy;
5756
5757 // Clamp to viewport
5758 const cw = container.offsetWidth;
5759 const ch = container.offsetHeight;
5760 newLeft = Math.max( 0, Math.min( newLeft, window.innerWidth - cw ) );
5761 newTop = Math.max( 0, Math.min( newTop, window.innerHeight - ch ) );
5762
5763 container.style.left = newLeft + 'px';
5764 container.style.top = newTop + 'px';
5765 container.style.right = 'auto';
5766 container.style.bottom = 'auto';
5767 }
5768
5769 function onMouseUp() {
5770 if ( ! isDragging ) {
5771 return;
5772 }
5773 isDragging = false;
5774 document.body.style.userSelect = '';
5775 container.style.transition = '';
5776 document.removeEventListener( 'mousemove', onMouseMove );
5777 document.removeEventListener( 'mouseup', onMouseUp );
5778 // Persist position only if the user actually moved
5779 if ( didMove ) {
5780 savePosition();
5781 }
5782 }
5783
5784 container.addEventListener( 'mousedown', function( e ) {
5785 if ( getBridgeLayout() !== 'popover' ) {
5786 return;
5787 }
5788
5789 // Resize takes priority over drag
5790 const resizeHandle = e.target.closest( RESIZE_HANDLE_SELECTOR );
5791 if ( resizeHandle ) {
5792 isResizing = true;
5793 resizeEdges = resizeHandle.getAttribute( 'data-popover-resize' );
5794 const rect = container.getBoundingClientRect();
5795 startX = e.clientX;
5796 startY = e.clientY;
5797 startLeft = rect.left;
5798 startTop = rect.top;
5799 startWidth = rect.width;
5800 startHeight = rect.height;
5801
5802 document.body.style.userSelect = 'none';
5803 container.style.transition = 'none';
5804 e.preventDefault();
5805 e.stopPropagation();
5806
5807 document.addEventListener( 'mousemove', onResizeMove );
5808 document.addEventListener( 'mouseup', onResizeEnd );
5809 return;
5810 }
5811
5812 // Only drag from header area
5813 const dragHandle = e.target.closest( DRAG_HANDLE_SELECTOR );
5814 if ( ! dragHandle ) {
5815 return;
5816 }
5817 // Don't drag when clicking interactive elements
5818 if ( e.target.closest( INTERACTIVE_SELECTOR ) ) {
5819 return;
5820 }
5821
5822 isDragging = true;
5823 didMove = false;
5824 const dragRect = container.getBoundingClientRect();
5825 startX = e.clientX;
5826 startY = e.clientY;
5827 startLeft = dragRect.left;
5828 startTop = dragRect.top;
5829
5830 document.body.style.userSelect = 'none';
5831 container.style.transition = 'none';
5832 e.preventDefault();
5833
5834 document.addEventListener( 'mousemove', onMouseMove );
5835 document.addEventListener( 'mouseup', onMouseUp );
5836 } );
5837
5838 function onResizeMove( e ) {
5839 const dx = e.clientX - startX;
5840 const dy = e.clientY - startY;
5841 const maxW = window.innerWidth - MAX_WIDTH_MARGIN;
5842 const maxH = window.innerHeight - MAX_HEIGHT_MARGIN;
5843 let newWidth = startWidth;
5844 let newHeight = startHeight;
5845 let newLeft = startLeft;
5846 let newTop = startTop;
5847
5848 if ( resizeEdges.indexOf( 'e' ) !== -1 ) {
5849 newWidth = clamp( startWidth + dx, MIN_WIDTH, maxW );
5850 }
5851 if ( resizeEdges.indexOf( 'w' ) !== -1 ) {
5852 newWidth = clamp( startWidth - dx, MIN_WIDTH, maxW );
5853 newLeft = startLeft + ( startWidth - newWidth );
5854 }
5855 if ( resizeEdges.indexOf( 's' ) !== -1 ) {
5856 newHeight = clamp( startHeight + dy, MIN_HEIGHT, maxH );
5857 }
5858 if ( resizeEdges.indexOf( 'n' ) !== -1 ) {
5859 newHeight = clamp( startHeight - dy, MIN_HEIGHT, maxH );
5860 newTop = startTop + ( startHeight - newHeight );
5861 }
5862
5863 container.style.setProperty( '--zipwp-popover-w', newWidth + 'px' );
5864 container.style.setProperty( '--zipwp-popover-h', newHeight + 'px' );
5865 container.style.setProperty( '--zipwp-popover-max-h', 'none' );
5866 if ( resizeEdges.indexOf( 'w' ) !== -1 || resizeEdges.indexOf( 'n' ) !== -1 || container.style.left ) {
5867 container.style.left = newLeft + 'px';
5868 container.style.top = newTop + 'px';
5869 container.style.right = 'auto';
5870 container.style.bottom = 'auto';
5871 }
5872 }
5873
5874 function onResizeEnd() {
5875 if ( ! isResizing ) {
5876 return;
5877 }
5878 isResizing = false;
5879 resizeEdges = null;
5880 document.body.style.userSelect = '';
5881 container.style.transition = '';
5882 document.removeEventListener( 'mousemove', onResizeMove );
5883 document.removeEventListener( 'mouseup', onResizeEnd );
5884 saveSize();
5885 if ( container.style.left ) {
5886 savePosition();
5887 }
5888 }
5889 }
5890
5891 function clamp( v, lo, hi ) {
5892 return Math.max( lo, Math.min( hi, v ) );
5893 }
5894
5895 /**
5896 * Inject resize handle DOM elements — 4 edges + 4 corners.
5897 * Only visible in popover mode (CSS-gated by body class).
5898 * @param container
5899 */
5900 function injectResizeHandles( container ) {
5901 if ( container.querySelector( RESIZE_HANDLE_SELECTOR ) ) {
5902 return;
5903 }
5904 const edges = [ 'n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se' ];
5905 edges.forEach( function( edge ) {
5906 const el = document.createElement( 'div' );
5907 el.setAttribute( 'data-popover-resize', edge );
5908 el.className = 'zipwp-popover-resize zipwp-popover-resize--' + edge;
5909 container.appendChild( el );
5910 } );
5911 }
5912
5913 function saveSize() {
5914 const container = document.getElementById( CONTAINER_ID );
5915 if ( ! container ) {
5916 return;
5917 }
5918 try {
5919 localStorage.setItem( SIZE_STORAGE_KEY, JSON.stringify( {
5920 width: container.offsetWidth,
5921 height: container.offsetHeight,
5922 } ) );
5923 } catch ( e ) {}
5924 }
5925
5926 function restoreSize() {
5927 const container = document.getElementById( CONTAINER_ID );
5928 if ( ! container ) {
5929 return;
5930 }
5931 try {
5932 const raw = localStorage.getItem( SIZE_STORAGE_KEY );
5933 if ( ! raw ) {
5934 return;
5935 }
5936 const size = JSON.parse( raw );
5937 if ( ! size || typeof size.width !== 'number' || typeof size.height !== 'number' ) {
5938 return;
5939 }
5940 const maxW = window.innerWidth - MAX_WIDTH_MARGIN;
5941 const maxH = window.innerHeight - MAX_HEIGHT_MARGIN;
5942 const w = clamp( size.width, MIN_WIDTH, maxW );
5943 const h = clamp( size.height, MIN_HEIGHT, maxH );
5944 container.style.setProperty( '--zipwp-popover-w', w + 'px' );
5945 container.style.setProperty( '--zipwp-popover-h', h + 'px' );
5946 container.style.setProperty( '--zipwp-popover-max-h', 'none' );
5947 } catch ( e ) {}
5948 }
5949
5950 /**
5951 * Save current container position to localStorage.
5952 */
5953 function savePosition() {
5954 const container = document.getElementById( CONTAINER_ID );
5955 if ( ! container || ! container.style.left ) {
5956 return;
5957 }
5958 try {
5959 localStorage.setItem( STORAGE_KEY, JSON.stringify( {
5960 left: parseInt( container.style.left, 10 ),
5961 top: parseInt( container.style.top, 10 ),
5962 // Viewport + panel size at save time. Restore uses these to
5963 // re-anchor to the closer edge (so a right/bottom-docked panel
5964 // keeps its margin when the window is a different size next
5965 // load) AND to size the edge/clamp math off the panel's real
5966 // dimensions — at load time offsetWidth is briefly the wide
5967 // sidebar width before the popover width var applies.
5968 vw: window.innerWidth,
5969 vh: window.innerHeight,
5970 w: container.offsetWidth,
5971 h: container.offsetHeight,
5972 } ) );
5973 } catch ( e ) {}
5974 }
5975
5976 /**
5977 * Restore saved position from localStorage, clamped to current viewport.
5978 */
5979 function restorePosition() {
5980 const container = document.getElementById( CONTAINER_ID );
5981 if ( ! container ) {
5982 return;
5983 }
5984 // Restore size first so clamping uses the restored dimensions
5985 restoreSize();
5986 try {
5987 const raw = localStorage.getItem( STORAGE_KEY );
5988 if ( ! raw ) {
5989 return;
5990 }
5991 const pos = JSON.parse( raw );
5992 if ( ! pos || typeof pos.left !== 'number' || typeof pos.top !== 'number' ) {
5993 return;
5994 }
5995
5996 let left = pos.left;
5997 let top = pos.top;
5998 // Prefer the size saved with the position (avoids the load-time
5999 // wide-sidebar offsetWidth that would skew the edge decision/clamp).
6000 // Legacy records have no w/h — fall back to the post-restoreSize()
6001 // offsetWidth so a resized-then-dragged panel still clamps to its
6002 // real width, then to the default popover size.
6003 const cw = pos.w || container.offsetWidth || ( LPOP.defaultWidth || 420 );
6004 const ch = pos.h || container.offsetHeight || ( LPOP.defaultHeight || 620 );
6005
6006 // Re-anchor to the closer edge if the viewport changed size since
6007 // save: a right-docked panel shifts with the right edge (keeps its
6008 // right margin); a left-docked one stays put. Same for top/bottom.
6009 if ( typeof pos.vw === 'number' && pos.vw > 0 ) {
6010 if ( pos.left + cw / 2 > pos.vw / 2 ) {
6011 left += window.innerWidth - pos.vw;
6012 }
6013 }
6014 if ( typeof pos.vh === 'number' && pos.vh > 0 ) {
6015 if ( pos.top + ch / 2 > pos.vh / 2 ) {
6016 top += window.innerHeight - pos.vh;
6017 }
6018 }
6019
6020 // Clamp to viewport
6021 if ( left + cw > window.innerWidth ) {
6022 left = window.innerWidth - cw;
6023 }
6024 if ( top + ch > window.innerHeight ) {
6025 top = window.innerHeight - ch;
6026 }
6027 if ( left < 0 ) {
6028 left = 0;
6029 }
6030 if ( top < 0 ) {
6031 top = 0;
6032 }
6033
6034 container.style.left = left + 'px';
6035 container.style.top = top + 'px';
6036 container.style.right = 'auto';
6037 container.style.bottom = 'auto';
6038 } catch ( e ) {}
6039 }
6040
6041 /**
6042 * Clear saved position and inline styles from the container.
6043 */
6044 function clearPosition() {
6045 const container = document.getElementById( CONTAINER_ID );
6046 if ( ! container ) {
6047 return;
6048 }
6049 container.style.left = '';
6050 container.style.top = '';
6051 container.style.right = '';
6052 container.style.bottom = '';
6053 try {
6054 localStorage.removeItem( STORAGE_KEY );
6055 } catch ( e ) {}
6056 // Note: size intentionally NOT cleared here — user's custom popover
6057 // size persists across mode switches. Only resetLayout() wipes size.
6058 }
6059
6060 /**
6061 * Reset popover to default size and position.
6062 * Removes CSS vars + inline position styles + clears storage.
6063 */
6064 function resetLayout() {
6065 const container = document.getElementById( CONTAINER_ID );
6066 if ( ! container ) {
6067 return;
6068 }
6069 container.style.left = '';
6070 container.style.top = '';
6071 container.style.right = '';
6072 container.style.bottom = '';
6073 container.style.removeProperty( '--zipwp-popover-w' );
6074 container.style.removeProperty( '--zipwp-popover-h' );
6075 container.style.removeProperty( '--zipwp-popover-max-h' );
6076 try {
6077 localStorage.removeItem( STORAGE_KEY );
6078 localStorage.removeItem( SIZE_STORAGE_KEY );
6079 } catch ( e ) {}
6080 }
6081
6082 // Expose for WPBridgeHost
6083 window.zipwpPopoverDrag = {
6084 setup: setupPopoverDrag,
6085 restorePosition,
6086 clearPosition,
6087 resetLayout,
6088 };
6089 }() );
6090
6091
6092 /**
6093 * ZipWP MCP — pure js_rpc dispatch-dedup decision (B-1 / P5).
6094 *
6095 * The bridge's executeTools loop owns the seen-map I/O (sessionStorage-backed,
6096 * FIFO-bounded). THIS module is the pure DECISION over a prior seen entry, so the
6097 * idempotency logic — the silent-until-it-bites duplicate-content path — is
6098 * unit-tested instead of living only inline in the browser IIFE.
6099 *
6100 * Decisions, given the prior _rpcSeen entry for a js_rpc call_id (or undefined):
6101 * - a prior entry (IN-FLIGHT or COMPLETED) → repost_cached: NEVER re-dispatch
6102 * (re-running the handler double-applies the mutation); re-POST the stored
6103 * reply so a still-waiting brain BRPOP resolves.
6104 * - no prior entry → run: the caller records IN_FLIGHT_MARKER BEFORE the handler
6105 * mutates the tree, so a crash BETWEEN the mutation and the completion-record
6106 * still leaves a marker. A replay then reposts an uncertain (ok:false) reply
6107 * and the brain verifies with get_context before any retry — never a blind
6108 * re-dispatch.
6109 *
6110 * Dual-mode: window.zipwpRpcDedup in the browser; CommonJS for jest.
6111 *
6112 * @package
6113 */
6114 ( function () {
6115 'use strict';
6116
6117 // The reply a replay receives while the first run is mid-apply, or after a
6118 // crash before the completion-record. ok:false → the brain treats it as an
6119 // unconfirmed apply (verify-before-retry), never a blind re-apply.
6120 const IN_FLIGHT_MARKER = { ok: false, data: undefined, error: 'apply_pending_no_confirmation' };
6121
6122 // A COMPLETED session-scoped write (apply_change / set_scripts — both mutate
6123 // block attributes in the OPEN editor session) cached on a PRIOR page load: the
6124 // page reloaded since, and Gutenberg DISCARDS unsaved session edits on reload.
6125 // The cached ok:true would then mask a LOST edit (the brain folds success while
6126 // the page reverted). Repost this uncertain reply so the brain re-verifies with
6127 // get_context instead — same "don't assume, verify" contract as IN_FLIGHT.
6128 const RELOAD_REVERIFY_MARKER = { ok: false, data: undefined, error: 'apply_unconfirmed_after_reload' };
6129
6130 // `currentPageLoadId` is the token minted once per page load. A cached entry
6131 // tagged `mutating` (a session-scoped write) whose `pageLoadId` differs means a
6132 // reload happened between the apply and this replay → the edit is gone.
6133 function decideJsRpcDispatch( seenEntry, currentPageLoadId ) {
6134 if ( seenEntry ) {
6135 if ( seenEntry.mutating === true &&
6136 currentPageLoadId !== undefined && currentPageLoadId !== null &&
6137 seenEntry.pageLoadId !== undefined &&
6138 seenEntry.pageLoadId !== currentPageLoadId ) {
6139 return { action: 'repost_cached', reply: RELOAD_REVERIFY_MARKER };
6140 }
6141 return { action: 'repost_cached', reply: seenEntry };
6142 }
6143 return { action: 'run', inFlightMarker: IN_FLIGHT_MARKER };
6144 }
6145
6146 if ( typeof window !== 'undefined' ) {
6147 window.zipwpRpcDedup = window.zipwpRpcDedup || {};
6148 window.zipwpRpcDedup.decideJsRpcDispatch = decideJsRpcDispatch;
6149 window.zipwpRpcDedup.IN_FLIGHT_MARKER = IN_FLIGHT_MARKER;
6150 window.zipwpRpcDedup.RELOAD_REVERIFY_MARKER = RELOAD_REVERIFY_MARKER;
6151 }
6152 if ( typeof module !== 'undefined' && module.exports ) {
6153 module.exports = {
6154 decideJsRpcDispatch,
6155 IN_FLIGHT_MARKER,
6156 RELOAD_REVERIFY_MARKER,
6157 };
6158 }
6159 }() );
6160
6161
6162 /**
6163 * WordPress Bridge Host
6164 *
6165 * Same-window host bridge for the mounted React assistant.
6166 * Exposes window.zipwpMcpBridge for the React DirectBridge to call.
6167 */
6168
6169 ( function() {
6170 'use strict';
6171
6172 // Block attribute keys that hold human-readable text (used by buildPageOutline
6173 // to separate copy from config/style attrs).
6174 const TEXT_KEYS = [ 'content', 'text', 'title', 'label', 'heading', 'question' ];
6175
6176 // B1 capability handshake — this bundle's browser-RPC protocol version,
6177 // stamped into every editor context snapshot (getEditorContext).
6178 //
6179 // 1 = executes `js_rpc` envelopes for the editor/* handlers and POSTs replies
6180 // to /agent/rpc-reply with {call_id, session_id, ok, data?, error?}.
6181 // 2 = apply-change parses a resolved `section_markup` on the REVAMP path
6182 // (replaceInnerBlocks), not just on an insert — so a form-bearing section
6183 // placed as a revamp materializes a real form instead of an unconfigured
6184 // placeholder.
6185 //
6186 // The brain refuses to route a turn to its AgentBrowserLoop unless it sees
6187 // rpc >= 1, so ROUTING is unchanged by a bump. Bump when the dispatch/reply
6188 // contract changes incompatibly OR when the brain must be able to tell whether
6189 // this bundle can do something (the version is the ONE capability signal —
6190 // do not add a parallel flag). The brain reads this as
6191 // REVAMP_MATERIALIZATION_RPC_VERSION and degrades CLOSED below it: it keeps
6192 // warning the model that a revamped form would ship broken, because on an
6193 // un-updated site it still would.
6194 const RPC_PROTOCOL_VERSION = 2;
6195
6196 // B-1 idempotency — a js_rpc apply mutates the live block tree BEFORE it
6197 // POSTs its reply, so a SECOND dispatch of the SAME tool_use call_id (an SSE
6198 // replay, a brain turn-replay after a mid-turn restart, OR hydrateSession's
6199 // replay_events after a page reload) would apply the mutation twice and
6200 // silently duplicate content. We record every js_rpc call_id we have already
6201 // executed, with the reply we produced; an exact replay skips the handler
6202 // entirely and just re-POSTs the cached reply (the brain may still be
6203 // BRPOP-waiting on it). Bounded FIFO so it can't grow unbounded.
6204 //
6205 // M3 — backed by sessionStorage (per-tab, survives a reload) because the
6206 // duplicate-apply hazard's worst case is exactly a mid-turn reload: the user
6207 // saves, reloads, hydrateSession replays the turn's tool_call_result events
6208 // through the SAME handlers, and a memory-only map is gone. Degrades to the
6209 // in-memory map when sessionStorage is unavailable (quota / privacy mode) —
6210 // never throws into the dispatch path.
6211 const RPC_DEDUP_MAX = 64;
6212 const RPC_DEDUP_STORAGE_KEY = 'zipwpRpcSeen.v1';
6213 const _rpcSeen = loadRpcSeen(); // call_id -> { ok, data, error, mutating?, pageLoadId? }
6214 // Unique per PAGE LOAD (a reload mints a fresh module + a fresh id, while the
6215 // sessionStorage-backed _rpcSeen survives). A cached session-scoped write whose
6216 // pageLoadId differs from this = the page reloaded since the apply → the
6217 // in-memory edit was discarded (see rpc-dedup.js RELOAD_REVERIFY_MARKER).
6218 const PAGE_LOAD_ID = 'pl-' + Date.now() + '-' + Math.random().toString( 36 ).slice( 2, 8 );
6219 // Session-scoped writes (discarded on reload) — apply_change + set_scripts both
6220 // mutate block attributes in the open editor session; set_styles writes REST
6221 // (survives reload) so it is NOT here.
6222 const SESSION_SCOPED_TOOLS = { 'editor/apply-change': true, 'editor/set-scripts': true };
6223 function loadRpcSeen() {
6224 try {
6225 const raw = window.sessionStorage && window.sessionStorage.getItem( RPC_DEDUP_STORAGE_KEY );
6226 if ( raw ) {
6227 const entries = JSON.parse( raw );
6228 if ( Array.isArray( entries ) ) {
6229 return new Map( entries );
6230 }
6231 }
6232 } catch ( e ) { /* corrupt/unavailable storage → start fresh */ }
6233 return new Map();
6234 }
6235 function saveRpcSeen() {
6236 try {
6237 if ( ! window.sessionStorage ) {
6238 return;
6239 }
6240 window.sessionStorage.setItem(
6241 RPC_DEDUP_STORAGE_KEY,
6242 JSON.stringify( Array.from( _rpcSeen.entries() ) )
6243 );
6244 } catch ( e ) { /* quota/unavailable → memory-only behaviour, same as before */ }
6245 }
6246 function rpcSeenGet( callId ) {
6247 return callId ? _rpcSeen.get( callId ) : undefined;
6248 }
6249 function rpcSeenRemember( callId, ok, data, error, meta ) {
6250 if ( ! callId ) {
6251 return;
6252 }
6253 if ( _rpcSeen.has( callId ) ) {
6254 _rpcSeen.delete( callId );
6255 }
6256 const entry = { ok, data, error };
6257 // Tag session-scoped completed writes with the page-load id so a later
6258 // replay after a reload reposts an uncertain reply instead of a vanished
6259 // success (F2). Reads / non-session writes stay untagged (repost cached).
6260 if ( meta && meta.mutating === true ) {
6261 entry.mutating = true;
6262 entry.pageLoadId = meta.pageLoadId;
6263 }
6264 _rpcSeen.set( callId, entry );
6265 while ( _rpcSeen.size > RPC_DEDUP_MAX ) {
6266 _rpcSeen.delete( _rpcSeen.keys().next().value );
6267 }
6268 saveRpcSeen();
6269 }
6270
6271 // Map WP editor getSettings() colors/fontSizes (`[{name, slug, color|size}]`)
6272 // into the brain's wire shape `{ colors: [{slug, hex}], font_sizes: [{slug, size}] }`.
6273 // Drops malformed entries (blank slug/value). `size` is coerced to a string
6274 // (theme.json may give numbers). Returns null when no valid tokens exist, so
6275 // the caller omits `theme_tokens` entirely rather than send an empty object.
6276 function buildThemeTokens( settings ) {
6277 const tokens = {};
6278
6279 // WP exposes the palette in TWO shapes and a BLOCK THEME (theme.json) uses
6280 // ONLY the second: the legacy flat `settings.colors` (classic themes), and
6281 // `settings.__experimentalFeatures.color.palette.{theme,custom,default}`
6282 // (block themes). Reading only the flat array meant a theme.json site sent
6283 // NO palette, so the brain styled new sections with the generic default
6284 // accent (cyan) instead of the site's real brand. Prefer the flat array;
6285 // fall back to the experimental palette (theme + custom — the site's own
6286 // colours, not WP's built-in defaults).
6287 const exp = settings.__experimentalFeatures || {};
6288 const expPalette = ( exp.color && exp.color.palette ) || {};
6289 const srcColors = ( Array.isArray( settings.colors ) && settings.colors.length )
6290 ? settings.colors
6291 : [].concat(
6292 Array.isArray( expPalette.theme ) ? expPalette.theme : [],
6293 Array.isArray( expPalette.custom ) ? expPalette.custom : [],
6294 );
6295 const colors = [];
6296 // Null-proto so a palette slug that collides with an Object.prototype key
6297 // (constructor, __proto__, hasOwnProperty) can't resolve to an inherited
6298 // truthy value and silently drop that colour from theme_tokens.
6299 const seenColor = Object.create( null );
6300 for ( let i = 0; i < srcColors.length; i++ ) {
6301 const c = srcColors[ i ] || {};
6302 const cslug = typeof c.slug === 'string' ? c.slug : '';
6303 const hex = typeof c.color === 'string' ? c.color : '';
6304 if ( cslug && hex && ! seenColor[ cslug ] ) {
6305 seenColor[ cslug ] = true;
6306 colors.push( { slug: cslug, hex } );
6307 }
6308 }
6309 if ( colors.length ) {
6310 tokens.colors = colors;
6311 }
6312
6313 const expTypo = exp.typography || {};
6314 const srcSizes = ( Array.isArray( settings.fontSizes ) && settings.fontSizes.length )
6315 ? settings.fontSizes
6316 : ( Array.isArray( expTypo.fontSizes ) ? expTypo.fontSizes : [] );
6317 const fontSizes = [];
6318 // Null-proto: same prototype-key guard as seenColor above.
6319 const seenSize = Object.create( null );
6320 for ( let j = 0; j < srcSizes.length; j++ ) {
6321 const f = srcSizes[ j ] || {};
6322 const fslug = typeof f.slug === 'string' ? f.slug : '';
6323 const size = f.size;
6324 const hasSize = typeof size === 'string' ? size !== '' : typeof size === 'number';
6325 if ( fslug && hasSize && ! seenSize[ fslug ] ) {
6326 seenSize[ fslug ] = true;
6327 fontSizes.push( { slug: fslug, size: String( size ) } );
6328 }
6329 }
6330 if ( fontSizes.length ) {
6331 tokens.font_sizes = fontSizes;
6332 }
6333
6334 return ( tokens.colors || tokens.font_sizes ) ? tokens : null;
6335 }
6336
6337 // The dominant repeated DIRECT-child block type — the block name occurring
6338 // 3+ times among `children`, with the highest count — or null when none
6339 // qualifies (fewer than 3 children, or no single type reaches 3). One
6340 // detection shared by the page outline (repeated_children flag) and the
6341 // selected-block repeater signal (flag + count + clone target), so the two
6342 // can never drift. Iframe path mirrors this in src/utils/selectedBlockDto.js.
6343 function dominantRepeatedChildType( children ) {
6344 if ( ! Array.isArray( children ) || children.length < 3 ) {
6345 return null;
6346 }
6347 const counts = {};
6348 for ( let i = 0; i < children.length; i++ ) {
6349 const name = children[ i ] && children[ i ].name;
6350 if ( typeof name === 'string' ) {
6351 counts[ name ] = ( counts[ name ] || 0 ) + 1;
6352 }
6353 }
6354 let best = null;
6355 const names = Object.keys( counts );
6356 for ( let k = 0; k < names.length; k++ ) {
6357 const n = counts[ names[ k ] ];
6358 if ( n >= 3 && ( best === null || n > best.count ) ) {
6359 best = { name: names[ k ], count: n };
6360 }
6361 }
6362 return best;
6363 }
6364
6365 // A grid nested ONE level down: when the DIRECT children aren't a repeater
6366 // but EXACTLY ONE direct child is a container whose children ARE a repeater,
6367 // that inner container is the grid (the Spectra `section > content >
6368 // [card×N]` layout). "Exactly one" keeps it unambiguous. Iframe path mirrors
6369 // this in src/utils/selectedBlockDto.js::nestedGrid — keep both in lockstep.
6370 function nestedGrid( children ) {
6371 let found = null;
6372 for ( let i = 0; i < children.length; i++ ) {
6373 const c = children[ i ];
6374 const inner = c && Array.isArray( c.innerBlocks ) ? c.innerBlocks : null;
6375 if ( ! inner ) {
6376 continue;
6377 }
6378 const d = dominantRepeatedChildType( inner );
6379 if ( d === null ) {
6380 continue;
6381 }
6382 if ( found !== null ) {
6383 return null;
6384 } // 2+ candidate grids — ambiguous, bail
6385 found = { dominant: d, children: inner };
6386 }
6387 return found;
6388 }
6389
6390 class WPBridgeHost {
6391 constructor() {
6392 this.config = window.zipwpIframeConfig || {};
6393 this._snapshotCounter = 0;
6394 this._selectionRevision = 0;
6395 this._lastSelectedId = null;
6396 this.init();
6397 }
6398
6399 init() {
6400 if ( document.readyState === 'loading' ) {
6401 document.addEventListener( 'DOMContentLoaded', () => this.setup() );
6402 } else {
6403 this.setup();
6404 }
6405 }
6406
6407 setup() {
6408 this.setupAdminBarToggle();
6409 this.setupResizeHandle();
6410 this.loadSavedSidebarWidth();
6411 this.setupInlineEditShortcut();
6412 this.restorePanelLayout();
6413 if ( window.zipwpPopoverDrag ) {
6414 const bridge = this;
6415 window.zipwpPopoverDrag.setup( function() {
6416 return bridge._panelLayout || 'sidebar';
6417 } );
6418 }
6419 this.checkAutoOpen();
6420 this.restorePanelState();
6421 }
6422
6423 // ── Panel Toggle ──────────────────────────────────────────────
6424
6425 setupAdminBarToggle() {
6426 const self = this;
6427 document.addEventListener( 'click', function( e ) {
6428 const trigger = e.target.closest( '#zip-ai-floating-trigger' );
6429 if ( trigger ) {
6430 e.preventDefault();
6431 self.togglePanel();
6432 }
6433 } );
6434
6435 // Escape closes the panel when focus is inside it.
6436 document.addEventListener( 'keydown', function( e ) {
6437 if ( e.key !== 'Escape' ) {
6438 return;
6439 }
6440 const container = document.getElementById( 'zip-ai-assistant-container' );
6441 if ( ! container || ! container.classList.contains( 'zip-ai-iframe-visible' ) ) {
6442 return;
6443 }
6444 // Escape belongs to the TOPMOST layer. The React app mounts
6445 // directly into this container (same document, despite the
6446 // `iframe` class name), and this listener is on `document`
6447 // while the app's own Esc handlers are on `window` — document
6448 // fires FIRST on the bubble path, so without this guard the
6449 // panel closed out from under an open overlay: Escape in the
6450 // fullscreen pick-a-look preview dismissed the preview AND the
6451 // whole chat, instead of dropping back to the conversation.
6452 //
6453 // Keyed on the ARIA contract, not on a component or class name,
6454 // so every dialog in the app — today's preview stage, session
6455 // list, attachment preview, and anything added later — owns its
6456 // own Escape for free.
6457 if ( container.querySelector( '[role="dialog"][aria-modal="true"]' ) ) {
6458 return;
6459 }
6460 // Only close if focus is inside the panel (or on body — e.g. after clicking inside)
6461 // eslint-disable-next-line @wordpress/no-global-active-element
6462 if ( container.contains( document.activeElement ) || document.activeElement === document.body ) {
6463 e.preventDefault();
6464 self.togglePanel();
6465 }
6466 } );
6467
6468 // Global keyboard shortcut:
6469 // - Primary: Cmd+E / Ctrl+E (more reliable across OS/browser combos)
6470 // - Legacy: Cmd/Ctrl+Shift+Space (kept for backward compatibility)
6471 // Registered on the parent page so it works even when the panel is closed.
6472 document.addEventListener( 'keydown', function( e ) {
6473 const modKey = e.metaKey || e.ctrlKey;
6474
6475 // Avoid intercepting typing inside form fields or contenteditable regions.
6476 const target = e.target;
6477 const tagName = target && target.tagName;
6478 const isTextInput =
6479 tagName === 'INPUT' ||
6480 tagName === 'TEXTAREA' ||
6481 ( target && target.isContentEditable );
6482
6483 if ( isTextInput || ! modKey ) {
6484 return;
6485 }
6486
6487 // Primary shortcut: Cmd/Ctrl + E
6488 if ( String( e.key ).toLowerCase() === 'e' ) {
6489 e.preventDefault();
6490 self.togglePanel();
6491 return;
6492 }
6493
6494 // Legacy shortcut: Cmd/Ctrl + Shift + Space
6495 if ( ! e.shiftKey ) {
6496 return;
6497 }
6498
6499 const isSpaceKey =
6500 e.code === 'Space' ||
6501 e.key === ' ' ||
6502 e.key === 'Spacebar';
6503
6504 if ( ! isSpaceKey ) {
6505 return;
6506 }
6507
6508 e.preventDefault();
6509 self.togglePanel();
6510 } );
6511 }
6512
6513 togglePanel() {
6514 const container = document.getElementById( 'zip-ai-assistant-container' );
6515 if ( ! container ) {
6516 // Full-page mode has no sidebar container. "Close" navigates back to admin home.
6517 if ( this.config.displayMode === 'fullpage' ) {
6518 window.location.href = this.config.adminHomeUrl || '/wp-admin/';
6519 }
6520 return;
6521 }
6522
6523 const isVisible = container.classList.contains( 'zip-ai-iframe-visible' );
6524
6525 if ( isVisible ) {
6526 container.classList.remove( 'zip-ai-iframe-visible' );
6527 document.body.classList.remove( 'zip-ai-assistant-open' );
6528 this.toggleFullscreen( false );
6529 // Make hidden container fully non-interactive (prevents click interception).
6530 container.setAttribute( 'inert', '' );
6531
6532 // Disable block context picker when sidebar closes
6533 if ( window.zipwpMcpContextPicker ) {
6534 window.zipwpMcpContextPicker.disable();
6535 }
6536
6537 try {
6538 localStorage.setItem( 'zipwp-panel-open', '0' );
6539 } catch ( e ) {}
6540 } else {
6541 container.removeAttribute( 'inert' );
6542 container.classList.add( 'zip-ai-iframe-visible' );
6543 document.body.classList.add( 'zip-ai-assistant-open' );
6544 this.emitFullscreenChanged( container.classList.contains( 'zip-ai-iframe-fullscreen' ) );
6545
6546 // Enable block context picker when sidebar opens
6547 if ( window.zipwpMcpContextPicker ) {
6548 window.zipwpMcpContextPicker.enable();
6549 }
6550
6551 try {
6552 localStorage.setItem( 'zipwp-panel-open', '1' );
6553 } catch ( e ) {}
6554 }
6555 }
6556
6557 checkAutoOpen() {
6558 const urlParams = new URLSearchParams( window.location.search );
6559
6560 // Legacy param: ?zipwp_open_assistant
6561 if ( urlParams.has( 'zipwp_open_assistant' ) ) {
6562 this._autoOpened = true;
6563 urlParams.delete( 'zipwp_open_assistant' );
6564 const newUrl = window.location.pathname +
6565 ( urlParams.toString() ? '?' + urlParams.toString() : '' ) +
6566 window.location.hash;
6567 window.history.replaceState( {}, '', newUrl );
6568
6569 // eslint-disable-next-line no-var
6570 var self = this;
6571 setTimeout( function() {
6572 self.togglePanel();
6573 }, 300 );
6574 return;
6575 }
6576
6577 // New param: ?auto_open=true&prompt=...
6578 if ( urlParams.get( 'auto_open' ) === 'true' ) {
6579 const prompt = urlParams.get( 'prompt' ) || '';
6580
6581 // Clean URL params
6582 urlParams.delete( 'auto_open' );
6583 urlParams.delete( 'prompt' );
6584 urlParams.delete( 'mode' );
6585 const cleanUrl = window.location.pathname +
6586 ( urlParams.toString() ? '?' + urlParams.toString() : '' ) +
6587 window.location.hash;
6588 window.history.replaceState( {}, '', cleanUrl );
6589
6590 // Stash prompt for React to pick up after mount. sessionStorage
6591 // (not a window global) so it survives the logged-out
6592 // login -> reloadWithAutoOpen() full-page reload before an
6593 // authed InputBox can consume it.
6594 if ( prompt ) {
6595 try {
6596 sessionStorage.setItem( 'zipaiAutoPrompt', prompt );
6597 } catch ( e ) {}
6598 }
6599
6600 this._autoOpened = true;
6601
6602 // Open sidebar panel if not already on fullpage (fullpage is always open)
6603 if ( this.config.displayMode !== 'fullpage' ) {
6604 // eslint-disable-next-line no-redeclare, no-var
6605 var self = this;
6606 setTimeout( function() {
6607 self.togglePanel();
6608 }, 300 );
6609 }
6610 }
6611 }
6612
6613 restorePanelState() {
6614 // Skip in full-page mode — panel is always visible there.
6615 if ( this.config.displayMode === 'fullpage' ) {
6616 return;
6617 }
6618 // Skip if checkAutoOpen already scheduled a deferred toggle — avoid double-toggle race.
6619 if ( this._autoOpened ) {
6620 return;
6621 }
6622
6623 const container = document.getElementById( 'zip-ai-assistant-container' );
6624 if ( ! container ) {
6625 return;
6626 }
6627
6628 const alreadyOpen = container.classList.contains( 'zip-ai-iframe-visible' );
6629 let wasOpen = false;
6630 try {
6631 wasOpen = localStorage.getItem( 'zipwp-panel-open' ) === '1';
6632 } catch ( e ) {}
6633
6634 if ( wasOpen && ! alreadyOpen ) {
6635 this.togglePanel();
6636 }
6637 }
6638
6639 // ── Panel Layout (sidebar vs popover) ─────────────────────────
6640 // 'sidebar' = pushes WP content left (default)
6641 // 'popover' = floating card, no viewport shrink
6642
6643 restorePanelLayout() {
6644 // Resolution (explicit choice wins; else context default) lives in the
6645 // ZIPWP_LAYOUT SSOT.
6646 const L = window.ZIPWP_LAYOUT;
6647 this._panelLayout = L ? L.resolveLayout() : 'sidebar';
6648 this._applyPanelLayoutClasses( this._panelLayout );
6649 }
6650
6651 setPanelLayout( layout ) {
6652 const L = window.ZIPWP_LAYOUT;
6653 // Validate via the SSOT when present; fall back to the known layouts so
6654 // a missing config never lets an arbitrary string get persisted.
6655 const valid = L ? L.isValidLayout( layout ) : ( layout === 'sidebar' || layout === 'popover' );
6656 if ( ! valid ) {
6657 return;
6658 }
6659 this._panelLayout = layout;
6660 try {
6661 localStorage.setItem( L ? L.keys.layout : 'zipwp-panel-layout', layout );
6662 } catch ( e ) {}
6663 // _applyPanelLayoutClasses already calls drag.clearPosition()
6664 // on the non-popover branch — no additional cleanup needed.
6665 this._applyPanelLayoutClasses( layout );
6666 }
6667
6668 _applyPanelLayoutClasses( layout ) {
6669 const drag = window.zipwpPopoverDrag;
6670 document.body.classList.remove( 'zip-ai-overlay-mode', 'zip-ai-popover-mode' );
6671 if ( layout === 'popover' ) {
6672 document.body.classList.add( 'zip-ai-popover-mode' );
6673 if ( drag ) {
6674 drag.restorePosition();
6675 }
6676 } else if ( drag ) {
6677 drag.clearPosition();
6678 }
6679 }
6680
6681 // Reset EVERYTHING (layout choice, popover size/position, FAB position,
6682 // theme) to defaults via the SSOT — which clears storage + broadcasts
6683 // the reset event that the FAB launcher and header theme listen for —
6684 // then re-resolve + re-apply the context-aware default here.
6685 resetPopoverLayout() {
6686 const drag = window.zipwpPopoverDrag;
6687 if ( drag && drag.resetLayout ) {
6688 drag.resetLayout();
6689 }
6690 if ( window.ZIPWP_LAYOUT ) {
6691 window.ZIPWP_LAYOUT.resetToDefaults();
6692 }
6693 this.restorePanelLayout();
6694 }
6695
6696 reloadWithAutoOpen() {
6697 // In full-page mode, just reload — the assistant is already the whole page.
6698 if ( this.config.displayMode === 'fullpage' ) {
6699 window.location.reload();
6700 return;
6701 }
6702 const url = new URL( window.location.href );
6703 url.searchParams.set( 'zipwp_open_assistant', '1' );
6704 window.location.href = url.toString();
6705 }
6706
6707 // ── Context ───────────────────────────────────────────────────
6708
6709 getContext() {
6710 return {
6711 wp_user_id: this.config.userId || null,
6712 editor_context: this.getEditorContext(),
6713 page_context: this.getPageContext(),
6714 website_context: {
6715 current_url: window.location.href,
6716 ...( this.config.websiteContext || {} ),
6717 },
6718 theme_context: this.config.themeContext || {},
6719 installed_plugins: this.config.installedPlugins || {},
6720 admin_screen: this.getAdminScreenContext(),
6721 };
6722 }
6723
6724 getEditorContext() {
6725 const snapshotId = ++this._snapshotCounter;
6726
6727 // PHP-authoritative flag from `get_current_screen()->is_block_editor()`,
6728 // already narrowed server-side to the native post/page editor
6729 // (screen base 'post'; see React_Manager::is_block_editor_screen).
6730 // Used as a fallback when `wp.data` / `core/block-editor` haven't
6731 // initialized yet at iframe boot — so the brain doesn't see
6732 // is_block_editor=false on the first turn and route to dashboard mode.
6733 const phpIsBlockEditor = !! ( this.config && this.config.isBlockEditor );
6734
6735 // Native post/page editor URL gate. The DOM/store heuristic below
6736 // (block-list layout, .editor-styles-wrapper, #editor) matches ANY
6737 // screen rendering @wordpress/block-editor — including SureCart's
6738 // page editor and the Site Editor — so without this gate it would
6739 // re-flag those as the block editor even after PHP is narrowed.
6740 // The host bridge runs in the parent admin window, so location is
6741 // the real wp-admin URL: only post.php / post-new.php may qualify.
6742 const editorPath = ( window.location && window.location.pathname ) || '';
6743 // eslint-disable-next-line @wordpress/no-unused-vars-before-return
6744 const isNativePostEditorUrl = /\/(post|post-new)\.php$/.test( editorPath );
6745
6746 const editorContext = {
6747 is_block_editor: phpIsBlockEditor,
6748 // B1 capability handshake — this bundle's browser-RPC protocol
6749 // version. Declares that THIS host bridge executes js_rpc
6750 // editor/* dispatches and POSTs replies to /agent/rpc-reply.
6751 // The brain routes to its AgentBrowserLoop ONLY when it sees
6752 // rpc >= 1; a bundle without the stamp (v1) degrades to the
6753 // dashboard flow instead of timing out on every editor tool.
6754 // Bump ONLY when the dispatch/reply contract changes shape.
6755 rpc: RPC_PROTOCOL_VERSION,
6756 selected_block: null,
6757 };
6758
6759 if ( ! window.wp || ! window.wp.data ) {
6760 return editorContext;
6761 }
6762
6763 const select = window.wp.data.select;
6764 const blockEditorSelect = select( 'core/block-editor' );
6765 if ( ! blockEditorSelect ) {
6766 return editorContext;
6767 }
6768
6769 const currentSelectedId = blockEditorSelect.getSelectedBlockClientId();
6770 if ( currentSelectedId !== this._lastSelectedId ) {
6771 this._lastSelectedId = currentSelectedId;
6772 this._selectionRevision = ( this._selectionRevision || 0 ) + 1;
6773 }
6774
6775 const blocks = blockEditorSelect.getBlocks();
6776 const hasEditorDOM = document.body.classList.contains( 'block-editor-page' ) ||
6777 document.querySelector( '.block-editor-block-list__layout' ) ||
6778 document.querySelector( '.editor-styles-wrapper' ) ||
6779 document.querySelector( '#editor' );
6780
6781 // Fallback: core/editor.getEditingMode() returns 'visual' in block editor context
6782 // Guards against DOM selector drift across WP versions
6783 const coreEditorSelectEarly = select( 'core/editor' );
6784 const hasEditorMode = coreEditorSelectEarly
6785 ? ( coreEditorSelectEarly.getEditingMode?.() === 'visual' || coreEditorSelectEarly.getCurrentPostId?.() > 0 )
6786 : false;
6787
6788 editorContext.is_block_editor = phpIsBlockEditor || ( isNativePostEditorUrl && Array.isArray( blocks ) && ( hasEditorDOM || hasEditorMode ) );
6789
6790 // Include post_id from the core/editor store so the brain knows which page is open
6791 const coreEditorSelect = coreEditorSelectEarly;
6792 if ( coreEditorSelect ) {
6793 const postId = coreEditorSelect.getCurrentPostId();
6794 const postTitle = coreEditorSelect.getEditedPostAttribute( 'title' );
6795 const postType = coreEditorSelect.getCurrentPostType();
6796 if ( postId ) {
6797 editorContext.post_id = postId;
6798 }
6799 if ( postTitle ) {
6800 editorContext.post_title = postTitle;
6801 }
6802 if ( postType ) {
6803 editorContext.post_type = postType;
6804 }
6805 }
6806
6807 // Build the compact page outline (top-level sections) for AI navigation.
6808 // Block detail is pulled on demand by the brain via editor__get_context
6809 // (fresh clientIds + classNames), never dumped here — so no live block index.
6810 if ( editorContext.is_block_editor && blocks.length > 0 ) {
6811 editorContext.page_outline = this.buildPageOutline( blocks );
6812 }
6813
6814 editorContext.snapshot_id = snapshotId;
6815 editorContext.selection_revision = this._selectionRevision || 0;
6816
6817 const selectedBlock = blockEditorSelect.getSelectedBlock();
6818 const hasLiveSelection = !! ( selectedBlock && selectedBlock.clientId && selectedBlock.name );
6819 // Explicit scope intent — replaces the brain's page-wide TEXT regex
6820 // (computeSelectionBinding). A live block selection means the user is
6821 // acting on THAT element, so confine the edit to it (page_wide:false);
6822 // no selection means a page-wide edit is fine (page_wide:true).
6823 // Deselecting is the explicit affordance to widen scope. Driven by
6824 // REAL editor state, never the user's words — so a coincidental "all
6825 // sections" phrase can't silently disable the selection scope lock.
6826 // Keep in lockstep with the iframe path (src/hooks/useMessageSubmit.js).
6827 editorContext.page_wide = ! hasLiveSelection;
6828 if ( hasLiveSelection ) {
6829 // Build the brain's selected_block DTO DIRECTLY in snake_case — the
6830 // exact shape the brain's zod reads ({ client_id, block_name }). NO
6831 // camelCase intermediate (serializeBlockLight emitted clientId/name +
6832 // texts, all of which the brain strips) so there is no two-spelling
6833 // drift surface to fall out of sync — the original "client_id: Required"
6834 // turn-drop bug. Mirrors src/utils/selectedBlockDto.js, the iframe path's
6835 // builder; keep the two in lockstep. The guard above omits the selection
6836 // entirely when ids are absent, never a partial.
6837 editorContext.selected_block = {
6838 client_id: selectedBlock.clientId,
6839 block_name: selectedBlock.name,
6840 };
6841 // Repeater signal — so the brain sees "the user selected a card
6842 // grid" and routes "add one more" to a CHILD clone (duplicateBlocks
6843 // the last child) instead of authoring a brand-new section. Mirrors
6844 // the page-outline heuristic (selectedBlockRepeatInfo). Iframe path
6845 // computes the same in src/utils/selectedBlockDto.js — keep both in
6846 // lockstep. getSelectedBlock() carries innerBlocks live this turn.
6847 const repeatInfo = this.selectedBlockRepeatInfo( selectedBlock );
6848 editorContext.selected_block.repeated_children = repeatInfo.repeated_children;
6849 editorContext.selected_block.repeated_child_count = repeatInfo.repeated_child_count;
6850 if ( repeatInfo.last_child_client_id ) {
6851 editorContext.selected_block.last_child_client_id = repeatInfo.last_child_client_id;
6852 }
6853 // NOTE: selected_block carries ONLY {client_id, block_name}. The
6854 // brain's wire schema declares {client_id, block_name, text} and
6855 // STRIPS anything else (wireToDomain). Two attr bundles were computed
6856 // here every turn and silently dropped: a parent_* bundle
6857 // (parent_client_id / parent_block_name / parent_attributes_schema /
6858 // parent_config_attrs) and selected_block.config_attrs (pickConfigAttrs)
6859 // — pure wasted per-turn compute + a boundary smear (plugin-side attr-
6860 // schema interpretation that never reached the model). Exact attribute
6861 // names are available on demand via editor__get_context. Removed (L-8).
6862 }
6863
6864 // Live theme.json design tokens (editor getSettings()) — the
6865 // deterministic, always-present palette/type SSOT the brain styles
6866 // against. Emitted directly in the BRAIN WIRE SHAPE
6867 // (`theme_tokens: { colors: [{slug, hex}], font_sizes: [{slug, size}] }`)
6868 // so Laravel relays it VERBATIM with no reshaping. The WP getSettings()
6869 // shape is `[{name, slug, color|size}]`; we map + drop malformed entries
6870 // here at the source.
6871 const editorSettings = blockEditorSelect.getSettings();
6872 if ( editorSettings ) {
6873 const themeTokens = buildThemeTokens( editorSettings );
6874 if ( themeTokens ) {
6875 editorContext.theme_tokens = themeTokens;
6876 }
6877 }
6878
6879 // Read-the-neighbour matcher: a representative existing section's
6880 // ACTUALLY-RENDERED signature (heading font/colour + brand accent).
6881 // theme_tokens/getSettings expose fixed slots, but on a multi-brand
6882 // page the page's real brand may live in a different slot (--accent,
6883 // not --primary) or an untokenised font — so the brain matches a new
6884 // section to what a real neighbour RENDERS. Canvas-read, best-effort.
6885 if ( editorContext.is_block_editor ) {
6886 const sectionSignature = this.buildSectionSignature();
6887 if ( sectionSignature ) {
6888 editorContext.section_signature = sectionSignature;
6889 }
6890 }
6891
6892 // Inject and clear results from the previous turn's js_hook executions
6893 if ( window.__zipwpLastToolResults && window.__zipwpLastToolResults.length > 0 ) {
6894 editorContext.last_tool_results = window.__zipwpLastToolResults;
6895 window.__zipwpLastToolResults = null;
6896 }
6897
6898 return editorContext;
6899 }
6900
6901 // Read-the-neighbour matcher. Sample EVERY top-level section in the live
6902 // canvas and return the page's DOMINANT rendered signature — the most
6903 // common heading font-family + colour, and the most common brand accent
6904 // (button backgrounds, else link/icon colours). Reading all sections + a
6905 // mode makes it robust to an outlier band that a single sample could land
6906 // on. The brain resolves the accent colour to whichever site token renders
6907 // it (so a page whose brand is --accent matches, not the stale --primary).
6908 // Best-effort: any failure → null, so the editor-context build never
6909 // breaks. Canvas render ≠ frontend, but accent/font resolve correctly in
6910 // the canvas — enough to match by.
6911 buildSectionSignature() {
6912 try {
6913 const shared = window.zipwpEditorShared;
6914 const doc = shared && shared.canvasDoc ? shared.canvasDoc() : document;
6915 const view = doc.defaultView || window;
6916 const all = Array.prototype.slice.call(
6917 doc.querySelectorAll( 'section, .wp-block-spectra-container' )
6918 ).filter( ( s ) => s.offsetHeight > 160 );
6919 const top = all.filter( ( s ) => ! all.some( ( o ) => o !== s && o.contains( s ) ) );
6920 if ( ! top.length ) {
6921 return null;
6922 }
6923 // Read EVERY top-level section and take the most common value per
6924 // field (the MODE) — a single sample can land on an outlier band;
6925 // the mode is the page's DOMINANT brand signature. Buttons are the
6926 // clearest brand pop, so button backgrounds decide the accent and
6927 // link/icon colours are only a fallback when no section has one.
6928 const opaque = ( c ) => c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent';
6929 const rgbOf = ( c ) => {
6930 const m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec( c || '' );
6931 return m ? [ Number( m[ 1 ] ), Number( m[ 2 ] ), Number( m[ 3 ] ) ] : null;
6932 };
6933 // A colour worth counting as a brand ACCENT: opaque AND chromatic.
6934 // The old opaque()-only gate accepted near-neutrals (a white/black/
6935 // grey button, or the near-black/near-white a plain link contributes)
6936 // and reported them as the page accent. Require real saturation in a
6937 // mid lightness band so #000/#fff/greys never become the "brand pop".
6938 const isBrandAccent = ( c ) => {
6939 if ( ! opaque( c ) ) {
6940 return false;
6941 }
6942 const p = rgbOf( c );
6943 if ( ! p ) {
6944 return false;
6945 }
6946 const max = Math.max( p[ 0 ], p[ 1 ], p[ 2 ] );
6947 const min = Math.min( p[ 0 ], p[ 1 ], p[ 2 ] );
6948 const sat = max === 0 ? 0 : ( max - min ) / max;
6949 const light = max / 255;
6950 return sat >= 0.15 && light >= 0.12 && light <= 0.95;
6951 };
6952 const fonts = {};
6953 const headingColors = {};
6954 const btnAccents = {};
6955 const linkAccents = {};
6956 const bump = ( map, key ) => {
6957 if ( key ) {
6958 map[ key ] = ( map[ key ] || 0 ) + 1;
6959 }
6960 };
6961 for ( let i = 0; i < top.length; i++ ) {
6962 const sec = top[ i ];
6963 const h = sec.querySelector( 'h1, h2, h3' );
6964 if ( h ) {
6965 const hcs = view.getComputedStyle( h );
6966 bump( fonts, hcs.fontFamily );
6967 bump( headingColors, hcs.color );
6968 }
6969 const btn = sec.querySelector( '.wp-block-button__link, a.wp-block-button__link, button' );
6970 const btnBg = btn ? view.getComputedStyle( btn ).backgroundColor : '';
6971 if ( isBrandAccent( btnBg ) ) {
6972 bump( btnAccents, btnBg );
6973 } else {
6974 // Fallback: an accent-BEARING element, never a bare text link.
6975 // The brain's global CSS sets `a { color: var(--primary) }`, so
6976 // a plain <a> echoes --primary — the exact false signal that
6977 // stamps a new section gold on a green-brand page. Prefer a
6978 // button-styled link's TEXT colour (captures an outline/ghost
6979 // CTA whose transparent bg skipped the branch above), then an
6980 // icon; the chroma filter drops any neutral that slips through.
6981 const pop = sec.querySelector( 'a.wp-block-button__link, i[class*="fa-"], svg' );
6982 const popColor = pop ? view.getComputedStyle( pop ).color : '';
6983 if ( isBrandAccent( popColor ) ) {
6984 bump( linkAccents, popColor );
6985 }
6986 }
6987 }
6988 const mode = ( map ) => {
6989 let best = null;
6990 let bestN = 0;
6991 const keys = Object.keys( map );
6992 for ( let i = 0; i < keys.length; i++ ) {
6993 if ( map[ keys[ i ] ] > bestN ) {
6994 bestN = map[ keys[ i ] ];
6995 best = keys[ i ];
6996 }
6997 }
6998 return best;
6999 };
7000 const sig = {};
7001 const hf = mode( fonts );
7002 if ( hf ) {
7003 sig.heading_font = hf;
7004 }
7005 const hc = mode( headingColors );
7006 if ( hc ) {
7007 sig.heading_color = hc;
7008 }
7009 const accent = mode( btnAccents ) || mode( linkAccents );
7010 if ( accent ) {
7011 sig.accent_color = accent;
7012 }
7013 return Object.keys( sig ).length ? sig : null;
7014 } catch ( e ) {
7015 return null;
7016 }
7017 }
7018
7019 buildPageOutline( blocks ) {
7020 function countDeep( innerBlocks ) {
7021 if ( ! innerBlocks || ! innerBlocks.length ) {
7022 return 0;
7023 }
7024 let count = 0;
7025 const queue = innerBlocks.slice();
7026 while ( queue.length > 0 ) {
7027 const b = queue.shift();
7028 count++;
7029 if ( b.innerBlocks && b.innerBlocks.length > 0 ) {
7030 for ( let i = 0; i < b.innerBlocks.length; i++ ) {
7031 queue.push( b.innerBlocks[ i ] );
7032 }
7033 }
7034 }
7035 return count;
7036 }
7037
7038 function clipLabel( s ) {
7039 return s.length > 80 ? s.substring( 0, 77 ) + '...' : s;
7040 }
7041 // A block's OWN representative text — LIVE TEXT_KEYS attributes ONLY.
7042 // We deliberately do NOT fall back to block.originalContent: that is
7043 // the parse-time serialized HTML, which goes STALE vs the live
7044 // attributes after an in-editor edit. A stale label can steer the
7045 // model to match the WRONG section → wrong clientId (select-X →
7046 // operate-Y). A block with no live text attr returns null, and
7047 // extractHeading then DESCENDS to a child that has one — so a section
7048 // CONTAINER (no own text) is always labelled by its real heading.
7049 function ownText( block ) {
7050 const attrs = block && block.attributes;
7051 if ( attrs ) {
7052 for ( let i = 0; i < TEXT_KEYS.length; i++ ) {
7053 const val = attrs[ TEXT_KEYS[ i ] ];
7054 if ( typeof val === 'string' && val.trim() ) {
7055 const stripped = val.replace( /<[^>]*>/g, '' ).trim();
7056 if ( stripped ) {
7057 return stripped;
7058 }
7059 }
7060 }
7061 }
7062 return null;
7063 }
7064 // A heading-ish block: a heading block type, or a content block whose
7065 // tag is h1-h6. Preferred as a section's label over body copy.
7066 function isHeadingBlock( block ) {
7067 if ( ! block || ! block.name ) {
7068 return false;
7069 }
7070 if ( block.name.indexOf( 'heading' ) !== -1 ) {
7071 return true;
7072 }
7073 const tag = block.attributes && ( block.attributes.tagName || block.attributes.htmlTag );
7074 return typeof tag === 'string' && /^h[1-6]$/i.test( tag );
7075 }
7076 // A section's label. A section CONTAINER carries no text of its own —
7077 // its identifying heading lives in a NESTED child — so when the block
7078 // itself has no text we DESCEND (document order) and surface the first
7079 // nested heading (h1-h6 / heading block), falling back to the first
7080 // nested text. Without this every Spectra/FSE section reads as an
7081 // anonymous `spectra/container` (heading: null) and the agent cannot
7082 // tell the hero from the CTA — forcing a get_context drill per section
7083 // (or a fabricated answer). With it, the outline is a real section map.
7084 function extractHeading( block ) {
7085 const own = ownText( block );
7086 if ( own ) {
7087 return clipLabel( own );
7088 }
7089 let firstText = null;
7090 function walk( b ) {
7091 if ( ! b ) {
7092 return null;
7093 }
7094 const t = ownText( b );
7095 if ( t ) {
7096 if ( isHeadingBlock( b ) ) {
7097 return t;
7098 } // best: a real heading
7099 if ( firstText === null ) {
7100 firstText = t;
7101 } // fallback: first text in order
7102 }
7103 if ( b.innerBlocks ) {
7104 for ( let i = 0; i < b.innerBlocks.length; i++ ) {
7105 const r = walk( b.innerBlocks[ i ] );
7106 if ( r ) {
7107 return r;
7108 }
7109 }
7110 }
7111 return null;
7112 }
7113 const inner = block.innerBlocks || [];
7114 for ( let j = 0; j < inner.length; j++ ) {
7115 const found = walk( inner[ j ] );
7116 if ( found ) {
7117 return clipLabel( found );
7118 }
7119 }
7120 return firstText !== null ? clipLabel( firstText ) : null;
7121 }
7122
7123 function hasButtons( block ) {
7124 // DFS scan for button blocks
7125 const stack = [ block ];
7126 while ( stack.length > 0 ) {
7127 const b = stack.pop();
7128 if ( b.name && ( b.name.indexOf( 'button' ) !== -1 || b.name.indexOf( 'cta' ) !== -1 ) ) {
7129 return true;
7130 }
7131 if ( b.innerBlocks ) {
7132 for ( let i = 0; i < b.innerBlocks.length; i++ ) {
7133 stack.push( b.innerBlocks[ i ] );
7134 }
7135 }
7136 }
7137 return false;
7138 }
7139
7140 function hasFormFields( block ) {
7141 const stack = [ block ];
7142 while ( stack.length > 0 ) {
7143 const b = stack.pop();
7144 if ( b.name && ( b.name.indexOf( 'form' ) !== -1 || b.name.indexOf( 'input' ) !== -1 || b.name.indexOf( 'field' ) !== -1 ) ) {
7145 return true;
7146 }
7147 if ( b.innerBlocks ) {
7148 for ( let i = 0; i < b.innerBlocks.length; i++ ) {
7149 stack.push( b.innerBlocks[ i ] );
7150 }
7151 }
7152 }
7153 return false;
7154 }
7155
7156 // --- Section surfacing ------------------------------------------
7157 // Spectra/FSE pages nest every visual section under one (or a chain
7158 // of) WRAPPER container(s) whose own class is empty or purely
7159 // layout/alignment. getBlocks() therefore hands us the wrapper, not
7160 // the sections. Descend through such a single wrapper until we reach
7161 // the level where the sections actually live — multiple siblings, a
7162 // single SEMANTICALLY-classed section, or a non-container. This makes
7163 // the outline a real section map the agent can target directly.
7164 function blockHasText( b ) {
7165 const a = ( b && b.attributes ) || {};
7166 const t = typeof a.content === 'string' ? a.content : ( typeof a.text === 'string' ? a.text : '' );
7167 return t.replace( /<[^>]*>/g, '' ).trim().length > 0;
7168 }
7169 function isContainerBlock( b ) {
7170 return !! ( b && b.name && ( b.name.indexOf( 'container' ) !== -1 || b.name === 'core/group' || b.name === 'core/columns' ) );
7171 }
7172 // A class string is "wrapper-generic" when every token is a layout /
7173 // alignment / auto class (alignfull, is-layout-*, wp-block-*, has-*)
7174 // — nothing SEMANTIC like zip-ai-hero. Such a container is a pass-through
7175 // wrapper we descend past; a semantic class marks a real section.
7176 function isGenericWrapperClass( cls ) {
7177 if ( ! cls ) {
7178 return true;
7179 }
7180 const tokens = cls.split( /\s+/ );
7181 for ( let k = 0; k < tokens.length; k++ ) {
7182 const t = tokens[ k ];
7183 if ( t && ! /^align(full|wide|left|right|center|none)$|^is-layout-|^wp-block-|^has-/.test( t ) ) {
7184 return false;
7185 }
7186 }
7187 return true;
7188 }
7189 function sectionLevel( rootBlocks ) {
7190 let level = rootBlocks || [];
7191 for ( let hop = 0; hop < 6; hop++ ) {
7192 // meaningful siblings = drop trailing freeform / empty spacers
7193 // so a lone wrapper isn't masked by decorative neighbours.
7194 const meaningful = [];
7195 for ( let j = 0; j < level.length; j++ ) {
7196 const b = level[ j ];
7197 if ( ! b || ! b.name || b.name.indexOf( 'freeform' ) !== -1 ) {
7198 continue;
7199 }
7200 if ( ( b.innerBlocks && b.innerBlocks.length > 0 ) || blockHasText( b ) ) {
7201 meaningful.push( b );
7202 }
7203 }
7204 if ( meaningful.length !== 1 ) {
7205 break;
7206 } // 0 or 2+ siblings → this IS the section level
7207 const only = meaningful[ 0 ];
7208 const cls = ( only.attributes && typeof only.attributes.className === 'string' ? only.attributes.className : '' ).trim();
7209 const canDescend = isContainerBlock( only ) && isGenericWrapperClass( cls ) && only.innerBlocks && only.innerBlocks.length > 0;
7210 if ( ! canDescend ) {
7211 break;
7212 } // semantic-classed container = a section; non-container = stop
7213 level = only.innerBlocks;
7214 }
7215 return level.length > 0 ? level : ( rootBlocks || [] );
7216 }
7217
7218 // Emits the BRAIN WIRE SHAPE for each outline row directly (snake_case
7219 // `client_id`, optional fields OMITTED rather than null). Laravel relays
7220 // page_outline VERBATIM — it does no renaming, hand-picking, or default
7221 // injection. The brain's EditorSchema (wireSchemas.ts) is the sole
7222 // validator: `heading_excerpt` is z.string().min(1).optional() so it must
7223 // be ABSENT (not null) when there's no heading.
7224 return sectionLevel( blocks ).map( function( block ) {
7225 // Repeated card-like children (3+ of one block type) — shared
7226 // detection with the selected-block repeater signal.
7227 const repeated = dominantRepeatedChildType( block.innerBlocks || [] ) !== null;
7228
7229 const row = {
7230 client_id: block.clientId,
7231 block_name: block.name,
7232 // Section's current class string — so the brain can restyle a
7233 // section (className REPLACES wholesale, so it needs the
7234 // existing classes to keep) DIRECTLY from the outline, without
7235 // a get_context round-trip.
7236 class_name: ( block.attributes && typeof block.attributes.className === 'string' )
7237 ? block.attributes.className : '',
7238 inner_block_count: countDeep( block.innerBlocks ),
7239 has_buttons: hasButtons( block ),
7240 has_form_fields: hasFormFields( block ),
7241 repeated_children: repeated,
7242 };
7243
7244 // Optional hint — present only when the section actually carries a
7245 // heading. Omitted (never null) to satisfy the brain's optional() schema.
7246 const heading = extractHeading( block );
7247 if ( heading ) {
7248 row.heading_excerpt = heading;
7249 }
7250
7251 return row;
7252 } );
7253 }
7254
7255 // Repeater shape of the SELECTED block, in the brain wire shape, via the
7256 // shared dominantRepeatedChildType detection. The clone target is the
7257 // last child OF THE DOMINANT type (never a trailing CTA / spacer of a
7258 // different type), and the count is that type's occurrence count (the
7259 // number of cards), not the deep descendant total. Kept in lockstep with
7260 // the iframe builder src/utils/selectedBlockDto.js::repeatInfoFromInnerBlocks.
7261 selectedBlockRepeatInfo( block ) {
7262 const children = ( block && block.innerBlocks ) || [];
7263 let dominant = dominantRepeatedChildType( children );
7264 let gridChildren = children;
7265 if ( dominant === null ) {
7266 const nested = nestedGrid( children );
7267 if ( nested !== null ) {
7268 dominant = nested.dominant;
7269 gridChildren = nested.children;
7270 }
7271 }
7272 if ( dominant === null ) {
7273 return { repeated_children: false, repeated_child_count: 0 };
7274 }
7275
7276 const info = { repeated_children: true, repeated_child_count: dominant.count };
7277 for ( let i = gridChildren.length - 1; i >= 0; i-- ) {
7278 const c = gridChildren[ i ];
7279 if ( c && c.name === dominant.name && typeof c.clientId === 'string' && c.clientId !== '' ) {
7280 info.last_child_client_id = c.clientId;
7281 break;
7282 }
7283 }
7284 return info;
7285 }
7286
7287 getPageContext() {
7288 const phpPageContext = this.config.pageContext || {};
7289 return {
7290 post_id: phpPageContext.post_id || null,
7291 post_type: phpPageContext.post_type || null,
7292 post_title: phpPageContext.post_title || null,
7293 post_status: phpPageContext.post_status || null,
7294 };
7295 }
7296
7297 getAdminScreenContext() {
7298 const phpContext = window.zipwpMcpContext || {};
7299 const currentScreen = phpContext.currentScreen || {};
7300 return {
7301 id: currentScreen.id || null,
7302 base: currentScreen.base || null,
7303 post_type: currentScreen.post_type || null,
7304 action: currentScreen.action || null,
7305 parent_base: currentScreen.parent_base || null,
7306 is_admin: phpContext.isAdmin || false,
7307 };
7308 }
7309
7310 // ── Block Serialization ───────────────────────────────────────
7311
7312 serializeBlock( block ) {
7313 if ( ! block ) {
7314 return null;
7315 }
7316
7317 let blocksHtml = '';
7318 if ( window.wp && window.wp.blocks && window.wp.blocks.serialize ) {
7319 try {
7320 blocksHtml = window.wp.blocks.serialize( [ block ] );
7321 blocksHtml = this.fixUnicodeEscapes( blocksHtml );
7322 } catch ( e ) {
7323 console.warn( 'Failed to serialize block:', e );
7324 }
7325 }
7326
7327 const self = this;
7328 return {
7329 clientId: block.clientId,
7330 name: block.name,
7331 blockName: block.name,
7332 attributes: block.attributes,
7333 attrs: block.attributes,
7334 innerBlocks: ( block.innerBlocks || [] ).map( function( innerBlock ) {
7335 return self.serializeBlock( innerBlock );
7336 } ),
7337 blocks_html: blocksHtml,
7338 };
7339 }
7340
7341 fixUnicodeEscapes( html ) {
7342 if ( ! html || typeof html !== 'string' ) {
7343 return html;
7344 }
7345 return html
7346 .replace( /\\\\u002d/gi, '-' )
7347 .replace( /\\u002d/gi, '-' )
7348 .replace( /u002d/gi, '-' );
7349 }
7350
7351 // ── Block Editor Operations ───────────────────────────────────
7352
7353 captureBlockScreenshot( clientId, options ) {
7354 if ( ! clientId ) {
7355 return Promise.reject( new Error( 'Block clientId is required' ) );
7356 }
7357
7358 const utils = window.zipwpMcpSpectraUtils;
7359 if ( ! utils || ! utils.captureBlockScreenshot ) {
7360 return Promise.reject( new Error( 'Screenshot utility not available' ) );
7361 }
7362
7363 return utils.captureBlockScreenshot( clientId, options || {} ).then( function( screenshot ) {
7364 if ( ! screenshot ) {
7365 throw new Error( 'Failed to capture screenshot' );
7366 }
7367 return {
7368 success: true,
7369 base64: screenshot.base64,
7370 width: screenshot.width,
7371 height: screenshot.height,
7372 };
7373 } );
7374 }
7375
7376 /**
7377 * Capture a screenshot of the editor content area.
7378 *
7379 * Uses html2canvas to render the editor styles wrapper (or fallback targets)
7380 * and returns a base64 JPEG data URL (quality 0.7, max width 1280px).
7381 *
7382 * @since x.x.x
7383 * @return {Promise<Object>} { success, dataUrl, width, height } or { success: false, error }
7384 */
7385 async captureScreenshot() {
7386 try {
7387 // Find the best element to capture
7388 const target = document.querySelector( '.editor-styles-wrapper' ) ||
7389 document.querySelector( '.block-editor-block-list__layout' ) ||
7390 document.querySelector( '#wpwrap' );
7391
7392 if ( ! target ) {
7393 return { success: false, error: 'No editor content found' };
7394 }
7395
7396 // html2canvas is available via the WP plugin
7397 let html2canvas = window.html2canvas;
7398 if ( ! html2canvas ) {
7399 // Try dynamic import
7400 try {
7401 const mod = await import( 'html2canvas' );
7402 html2canvas = mod.default || mod;
7403 } catch ( e ) {
7404 return { success: false, error: 'html2canvas not available' };
7405 }
7406 }
7407
7408 const canvas = await html2canvas( target, {
7409 useCORS: true,
7410 allowTaint: true,
7411 scale: 1,
7412 logging: false,
7413 windowWidth: target.scrollWidth,
7414 windowHeight: target.scrollHeight,
7415 } );
7416
7417 // Resize if too large
7418 const maxWidth = 1280;
7419 let finalCanvas = canvas;
7420 if ( canvas.width > maxWidth ) {
7421 finalCanvas = document.createElement( 'canvas' );
7422 const ratio = maxWidth / canvas.width;
7423 finalCanvas.width = maxWidth;
7424 finalCanvas.height = Math.round( canvas.height * ratio );
7425 const ctx = finalCanvas.getContext( '2d' );
7426 ctx.drawImage( canvas, 0, 0, finalCanvas.width, finalCanvas.height );
7427 }
7428
7429 return {
7430 success: true,
7431 dataUrl: finalCanvas.toDataURL( 'image/jpeg', 0.7 ),
7432 width: finalCanvas.width,
7433 height: finalCanvas.height,
7434 };
7435 } catch ( e ) {
7436 return { success: false, error: e.message || 'Screenshot capture failed' };
7437 }
7438 }
7439
7440 // ── Tool Execution ────────────────────────────────────────────
7441
7442 // `sessionId` (M1) — the chat session that dispatched these calls; the
7443 // React SSE layer passes it so js_rpc replies can carry it to Laravel,
7444 // which verifies it against the brain's owner key for the call_id.
7445 async executeTools( executionResults, sessionId ) {
7446 if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) {
7447 console.log( '[ZIP AI:wp-bridge-host] executeTools called count=%d tools=%s',
7448 executionResults.length,
7449 executionResults.map( ( r ) => r.js_handler || r.tool_name ).join( ', ' ) );
7450 }
7451
7452 for ( let i = 0; i < executionResults.length; i++ ) {
7453 const result = executionResults[ i ];
7454 if ( result.execution_mode === 'js_hook' || result.execution_mode === 'hybrid' || result.execution_mode === 'js_rpc' ) {
7455 try {
7456 // eslint-disable-next-line no-var
7457 var toolName = result.js_handler || result.tool_name;
7458 const toolArguments = result.arguments || {};
7459 // Anthropic-assigned tool_use_id, stamped by the
7460 // SSE handler before this dispatch. Carried onto
7461 // window.__zipwpLastToolResults so the brain's
7462 // jsHookReconciler can attribute the next-turn
7463 // result deterministically to the originating
7464 // todo via todo.dispatched_call_ids.
7465 // eslint-disable-next-line no-var
7466 var toolCallId = result.call_id || null;
7467
7468 // B-1 / P5 idempotency: decide BEFORE running the handler.
7469 // The pure decision (core/rpc-dedup.js, unit-tested) reads
7470 // the prior _rpcSeen entry: a prior entry (in-flight OR
7471 // completed) → NEVER re-dispatch (that double-applies) —
7472 // re-POST it so a waiting brain BRPOP resolves; a first run
7473 // → record the IN-FLIGHT marker NOW (before the handler
7474 // mutates the tree) so a crash mid-apply still blocks a
7475 // replay (the replay reposts an uncertain ok:false reply →
7476 // the brain verifies before retrying). Degrades to the
7477 // pre-P5 seen-check if the helper module isn't loaded (no
7478 // crash-window marker) — never a duplicate of the logic.
7479 if ( result.execution_mode === 'js_rpc' ) {
7480 const seenReply = rpcSeenGet( toolCallId );
7481 const dedupApi = ( typeof window !== 'undefined' ) ? window.zipwpRpcDedup : null;
7482 const dedup = ( dedupApi && dedupApi.decideJsRpcDispatch )
7483 ? dedupApi.decideJsRpcDispatch( seenReply, PAGE_LOAD_ID )
7484 : { action: seenReply ? 'repost_cached' : 'run', reply: seenReply, inFlightMarker: null };
7485 if ( dedup.action === 'repost_cached' ) {
7486 console.warn( '[ZIP AI:wp-bridge-host] js_rpc duplicate/in-flight call_id=%s — skipping re-dispatch, re-POSTing reply', toolCallId );
7487 await this.postRpcReply( toolCallId, dedup.reply.ok, dedup.reply.data, dedup.reply.error, sessionId );
7488 continue;
7489 }
7490 if ( dedup.inFlightMarker ) {
7491 const inflight = dedup.inFlightMarker;
7492 rpcSeenRemember( toolCallId, inflight.ok, inflight.data, inflight.error );
7493 }
7494 }
7495
7496 const hasHandler = window.zipwpMcp && window.zipwpMcp.toolHooks &&
7497 window.zipwpMcp.toolHooks.hasHandler( toolName );
7498
7499 if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) {
7500 console.log( '[ZIP AI:wp-bridge-host] executing tool=%s hasHandler=%s zipwpMcp=%s',
7501 toolName, hasHandler, !! ( window.zipwpMcp ) );
7502 }
7503
7504 // [RTRACE] BRIDGE-IN — the js_rpc envelope arrives at the browser bridge
7505 // and the registered handler is about to be invoked with the wire args.
7506 if ( result.execution_mode === 'js_rpc' ) {
7507 try {
7508 // attr_keys is the load-bearing signal: a key the
7509 // target block doesn't define (e.g. tagName on a
7510 // spectra/container, which uses htmlTag) is silently
7511 // dropped by the registry → the model never sees the
7512 // edit "complete" and re-tries. Surface it at the JS
7513 // hop so the loop is visible from the browser too.
7514 const _ops = ( toolArguments && Array.isArray( toolArguments.operations ) ) ? toolArguments.operations : [];
7515 const _attrKeys = _ops.reduce( function ( acc, o ) {
7516 if ( o && o.attributes && typeof o.attributes === 'object' ) {
7517 acc.push.apply( acc, Object.keys( o.attributes ) );
7518 }
7519 return acc;
7520 }, [] );
7521 ( window.__zipwpTrace = window.__zipwpTrace || [] ).push( {
7522 hop: 'bridge:receive+invoke', ts: Date.now(),
7523 tool_name: toolName, call_id: toolCallId,
7524 execution_mode: result.execution_mode, has_handler: !! hasHandler,
7525 version: toolArguments && toolArguments.version,
7526 post_id: toolArguments && toolArguments.post_id,
7527 scope: toolArguments && toolArguments.scope,
7528 op_count: _ops.length,
7529 functions: _ops.map( function ( o ) {
7530 return o && o.function;
7531 } ),
7532 attr_keys: _attrKeys.length ? _attrKeys : null,
7533 } );
7534 if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) {
7535 console.log( '[RTRACE] bridge:receive+invoke tool=%s call_id=%s ops=%d attr_keys=%s', toolName, toolCallId, _ops.length, JSON.stringify( _attrKeys ) );
7536 }
7537 } catch ( e ) { /* trace never breaks dispatch */ }
7538 }
7539
7540 if ( ! hasHandler ) {
7541 throw new Error( 'No handler available for tool: ' + toolName );
7542 }
7543
7544 // L-16: the full-page getContext() re-walk was merged
7545 // into every envelope as `_wordpress_context` — but NO
7546 // editor handler reads it (the brain strips it at the
7547 // wire). Dropped the per-envelope re-walk; pass the wire
7548 // args through directly.
7549 // eslint-disable-next-line no-var
7550 var toolArgs = Object.assign( {}, toolArguments );
7551
7552 const hookResult = await window.zipwpMcp.toolHooks.executeToolHook(
7553 toolName,
7554 toolArgs,
7555 { jsExecutionResult: executionResults }
7556 );
7557
7558 // Capture result for next-turn context injection
7559 if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) {
7560 console.log( '[ZIP AI:wp-bridge-host] handler finished tool=%s hookResult=%s', toolName, JSON.stringify( hookResult ) );
7561 }
7562
7563 // Vibe Editing v2 — synchronous RPC: POST the reply so the brain's
7564 // AgentBrowserLoop (blocked on BRPOP for this call_id) resolves the
7565 // call IN THE SAME TURN, then skip the next-turn buffer below.
7566 if ( result.execution_mode === 'js_rpc' ) {
7567 const rpcOk = !! ( hookResult && hookResult.success !== false );
7568 const rpcData = hookResult ? hookResult.data : undefined;
7569 const rpcErr = hookResult ? ( hookResult.error || null ) : 'js_rpc handler returned no result';
7570 // [RTRACE] BRIDGE-OUT — what wp.data actually produced,
7571 // before the reply goes back to the brain. The reply
7572 // summary (applied / failed / unknown_attrs) is what the
7573 // model reacts to next step; unknown_attrs here is the
7574 // browser-side proof of a silent registry drop.
7575 try {
7576 const _ap = ( rpcData && Array.isArray( rpcData.applied ) ) ? rpcData.applied : [];
7577 const _ua = _ap.reduce( function ( acc, a ) {
7578 if ( a && Array.isArray( a.unknown_attrs ) ) {
7579 acc.push.apply( acc, a.unknown_attrs );
7580 }
7581 return acc;
7582 }, [] );
7583 ( window.__zipwpTrace = window.__zipwpTrace || [] ).push( {
7584 hop: 'bridge:reply', ts: Date.now(),
7585 tool_name: toolName, call_id: toolCallId, ok: rpcOk,
7586 applied: _ap.length,
7587 failed: ( rpcData && Array.isArray( rpcData.failed ) ) ? rpcData.failed.length : null,
7588 unknown_attrs: _ua.length ? _ua : null,
7589 blocks: ( rpcData && Array.isArray( rpcData.blocks ) ) ? rpcData.blocks.length : null,
7590 error: rpcErr,
7591 } );
7592 if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) {
7593 console.log( '[RTRACE] bridge:reply tool=%s call_id=%s ok=%s applied=%d unknown_attrs=%s', toolName, toolCallId, rpcOk, _ap.length, JSON.stringify( _ua ) );
7594 }
7595 } catch ( e ) { /* trace never breaks dispatch */ }
7596 // UPGRADE the in-flight marker to the REAL reply, and
7597 // before posting so a replay during the POST round-trip
7598 // still de-dups against the final reply (B-1 / P5).
7599 rpcSeenRemember( toolCallId, rpcOk, rpcData, rpcErr, {
7600 mutating: rpcOk === true && SESSION_SCOPED_TOOLS[ toolName ] === true,
7601 pageLoadId: PAGE_LOAD_ID,
7602 } );
7603 await this.postRpcReply( toolCallId, rpcOk, rpcData, rpcErr, sessionId );
7604 continue;
7605 }
7606
7607 if ( ! window.__zipwpLastToolResults ) {
7608 window.__zipwpLastToolResults = [];
7609 }
7610 if ( hookResult && typeof hookResult === 'object' ) {
7611 window.__zipwpLastToolResults.push( {
7612 tool: toolName,
7613 // Primary correlation: Anthropic tool_use_id assigned by the LLM,
7614 // plumbed end-to-end. The brain's jsHookReconciler matches this
7615 // against todo.dispatched_call_ids — deterministic, identity-based.
7616 call_id: toolCallId,
7617 // Fallback correlation: original tool arguments. Used when
7618 // call_id is missing (legacy bundle or non-LLM-initiated paths)
7619 // — the reconciler subset-matches against todo.tool_call.arguments.
7620 args: toolArgs || null,
7621 success: hookResult.success !== false,
7622 message: hookResult.message || null,
7623 user_summary: hookResult.user_summary || null,
7624 operation: hookResult.operation || null,
7625 verification: hookResult.verification || hookResult.data?.verification || null,
7626 error: hookResult.error || null,
7627 } );
7628 } else {
7629 console.warn( '[ZIP AI:wp-bridge-host] js_hook returned null/undefined for tool=%s — result NOT captured in last_tool_results', toolName );
7630 window.__zipwpLastToolResults.push( {
7631 tool: toolName,
7632 call_id: toolCallId,
7633 args: toolArgs || null,
7634 success: false,
7635 error: 'js_hook handler returned no result',
7636 } );
7637 }
7638 } catch ( error ) {
7639 console.error( '[ZIP AI:wp-bridge-host] handler threw for tool=%s error=%s', toolName, error?.message, error );
7640 if ( result.execution_mode === 'js_rpc' ) {
7641 // A thrown handler may still have partially mutated
7642 // the tree, so de-dup this call_id too (B-1).
7643 const thrownErr = error?.message || 'js_rpc handler threw';
7644 // A session-scoped tool that THREW may still have partially
7645 // mutated the tree — tag it mutating so a mid-turn reload
7646 // replays RELOAD_REVERIFY_MARKER (re-verify against the
7647 // discarded state), not the stale thrown error verbatim.
7648 rpcSeenRemember( toolCallId, false, undefined, thrownErr, {
7649 mutating: SESSION_SCOPED_TOOLS[ toolName ] === true,
7650 } );
7651 await this.postRpcReply( toolCallId, false, undefined, thrownErr, sessionId );
7652 continue;
7653 }
7654 if ( ! window.__zipwpLastToolResults ) {
7655 window.__zipwpLastToolResults = [];
7656 }
7657 window.__zipwpLastToolResults.push( {
7658 tool: toolName,
7659 call_id: toolCallId,
7660 args: toolArgs || null,
7661 success: false,
7662 error: error?.message || 'js_hook threw an exception',
7663 } );
7664 }
7665 }
7666 }
7667 }
7668
7669 // Vibe Editing v2 — POST a synchronous editor-RPC reply to the SaaS so
7670 // the brain's AgentBrowserLoop (blocked on BRPOP for this call_id)
7671 // resolves the tool in the SAME turn. Auth + base URL come from
7672 // window.ZIPAI_CONFIG (the same source the React app's api client uses).
7673 //
7674 // `sessionId` (M1) — included as session_id so Laravel can verify the
7675 // reply against the brain-claimed owner of this call_id (the owner key's
7676 // VALUE is the dispatching session). Without it any authenticated tenant
7677 // holding a call_id could inject a reply into a foreign turn.
7678 //
7679 // H1 — bounded retry. The reply is the ONLY confirmation the brain gets
7680 // and there is no later resend, so a transient failure (network blip,
7681 // 5xx, or a 409 from a not-yet-claimed owner key) must not drop it
7682 // permanently: every dropped reply costs the brain a full BRPOP window
7683 // plus a verify round-trip. Retries are safe by construction — Laravel
7684 // RPUSHes at most one consumable copy per POST and the brain releases
7685 // the owner key after consuming, so a duplicate late POST is 409-rejected,
7686 // never double-folded; re-execution is impossible (the handler already
7687 // ran; _rpcSeen replays the cached reply).
7688 async postRpcReply( callId, ok, data, error, sessionId ) {
7689 // The editor turn's executeTools loop AWAITS this POST before moving
7690 // to the next call, so a hung request would stall the whole turn.
7691 // Bound it with an AbortController timeout — the brain's BRPOP times
7692 // out independently on its side, so dropping a slow reply is safe.
7693 //
7694 // B-1 INVARIANT: total time here (attempts × timeout + backoff) MUST
7695 // stay BELOW the brain's BRAIN_EDITOR_RPC_TIMEOUT_MS (default 20s).
7696 // The plugin applies the change BEFORE replying, so if the brain
7697 // gives up first it reads a false "nothing applied" and may nudge a
7698 // retry → duplicate content. Per-attempt timeout 5s × 3 + backoff
7699 // ≈ 15.9s worst case, ~4s inside the 20s window (F6: widened margin).
7700 const cfg = window.ZIPAI_CONFIG || {};
7701 const attempts = 3;
7702 const backoffMs = 300;
7703 // F6 — cap the per-attempt timeout so the TOTAL reply budget stays safely
7704 // under the brain's BRPOP window even if an operator sets a large
7705 // rpcReplyTimeoutMs. The old 6s default left only ~1s of headroom, and the
7706 // offset between the brain STARTING its wait and this reply arriving
7707 // (handler-exec + SSE + the reply route's ownership check) ate it — so a
7708 // reply that actually succeeded landed after the brain gave up and was
7709 // read as a FALSE timeout (the edit had already applied). 5s keeps the
7710 // total ≈ 15.9s, ~4s inside the window.
7711 const perAttemptMax = 5000;
7712 const configured = Number( cfg.rpcReplyTimeoutMs ) > 0 ? Number( cfg.rpcReplyTimeoutMs ) : perAttemptMax;
7713 const timeoutMs = Math.min( configured, perAttemptMax );
7714 const apiUrl = ( cfg.apiUrl || '/api' ).replace( /\/+$/, '' );
7715 // Route the reply DIRECT to the brain when configured (same switch
7716 // as the React api client's brainPath); else fall back to Laravel.
7717 const rpcReplyUrl = ( cfg.brainUrl ? cfg.brainUrl.replace( /\/+$/, '' ) : apiUrl ) + '/agent/rpc-reply';
7718 const body = { call_id: callId, ok: !! ok };
7719 if ( sessionId ) {
7720 body.session_id = String( sessionId );
7721 }
7722 if ( data !== undefined && data !== null ) {
7723 body.data = data;
7724 }
7725 if ( error ) {
7726 body.error = String( error );
7727 }
7728 const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
7729 if ( cfg.token ) {
7730 headers.Authorization = 'Bearer ' + cfg.token;
7731 }
7732 const payload = JSON.stringify( body );
7733
7734 // [RTRACE] BRIDGE-OUT — the reply POSTs DIRECT to the brain (brainUrl),
7735 // whose AgentBrowserLoop is blocked on BRPOP for this call_id. Clearing
7736 // brainUrl reverts to the Laravel path (plugin-wide direct-to-brain toggle).
7737 try {
7738 ( window.__zipwpTrace = window.__zipwpTrace || [] ).push( {
7739 hop: 'bridge:postRpcReply', ts: Date.now(),
7740 call_id: callId, ok: !! ok,
7741 endpoint: rpcReplyUrl,
7742 applied: ( data && data.applied ) ? data.applied.length : null,
7743 failed: ( data && data.failed ) ? data.failed.length : null,
7744 refused: ( data && data.refused ) ? data.refused : null,
7745 error: error ? String( error ) : null,
7746 } );
7747 if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) {
7748 console.log( '[RTRACE] bridge:postRpcReply call_id=%s ok=%s', callId, !! ok );
7749 }
7750 } catch ( e ) { /* trace never breaks reply */ }
7751
7752 // eslint-disable-next-line no-var
7753 for ( var attempt = 1; attempt <= attempts; attempt++ ) {
7754 // eslint-disable-next-line no-var
7755 var controller = ( typeof AbortController !== 'undefined' ) ? new AbortController() : null;
7756 const timer = controller ? setTimeout( function () {
7757 controller.abort();
7758 }, timeoutMs ) : null;
7759 try {
7760 const opts = { method: 'POST', headers, body: payload };
7761 if ( controller ) {
7762 opts.signal = controller.signal;
7763 }
7764 const res = await fetch( rpcReplyUrl, opts );
7765 if ( res.ok ) {
7766 return;
7767 }
7768 // 4xx other than 409 won't improve on retry (bad payload /
7769 // auth); 409 can be the claim race or an already-consumed
7770 // reply — retry covers the former, the latter stays 409 and
7771 // we stop after the budget.
7772 const retryable = res.status === 409 || res.status >= 500;
7773 console.error( '[ZIP AI:wp-bridge-host] rpc-reply POST failed status=%d call_id=%s attempt=%d/%d',
7774 res.status, callId, attempt, attempts );
7775 if ( ! retryable ) {
7776 return;
7777 }
7778 } catch ( e ) {
7779 const reason = ( e && e.name === 'AbortError' ) ? ( 'timeout after ' + timeoutMs + 'ms' ) : ( e && e.message );
7780 console.error( '[ZIP AI:wp-bridge-host] rpc-reply POST threw call_id=%s attempt=%d/%d err=%s',
7781 callId, attempt, attempts, reason );
7782 } finally {
7783 if ( timer ) {
7784 clearTimeout( timer );
7785 }
7786 }
7787 if ( attempt < attempts ) {
7788 await new Promise( function ( r ) {
7789 setTimeout( r, backoffMs * attempt );
7790 } );
7791 }
7792 }
7793 }
7794
7795 // ── Canvas Loader Protocol ────────────────────────────────────
7796
7797 applyCanvasLoader( toolName, target ) {
7798 if ( ! target ) {
7799 return;
7800 }
7801
7802 switch ( target.type ) {
7803 case 'block':
7804 this._applyBlockLoader( target.id );
7805 break;
7806 case 'selector':
7807 this._applySelectorLoader( target.selector );
7808 break;
7809 case 'global':
7810 this._applyGlobalLoader( toolName );
7811 break;
7812 case 'fullpage':
7813 this._applyFullPageLoader();
7814 break;
7815 }
7816 }
7817
7818 // eslint-disable-next-line no-unused-vars
7819 removeCanvasLoader( toolName, target, success ) {
7820 if ( ! target ) {
7821 return;
7822 }
7823
7824 switch ( target.type ) {
7825 case 'block':
7826 this._removeBlockLoader( target.id );
7827 break;
7828 case 'selector':
7829 this._removeSelectorLoader( target.selector );
7830 break;
7831 case 'global':
7832 this._removeGlobalLoader();
7833 break;
7834 case 'fullpage':
7835 this._removeFullPageLoader();
7836 break;
7837 }
7838 }
7839
7840 _applyBlockLoader( clientId ) {
7841 if ( ! clientId ) {
7842 return;
7843 }
7844 const blockNode = document.querySelector( '[data-block="' + clientId + '"]' );
7845 if ( blockNode ) {
7846 blockNode.classList.add( 'is-ai-processing' );
7847 }
7848
7849 if ( window.wp && window.wp.data ) {
7850 try {
7851 window.wp.data.dispatch( 'core/block-editor' )
7852 .updateBlockAttributes( clientId, { lock: { move: true, remove: true } } );
7853 } catch ( e ) { /* block may not support lock */ }
7854 }
7855 }
7856
7857 _removeBlockLoader( clientId ) {
7858 if ( ! clientId ) {
7859 return;
7860 }
7861 const blockNode = document.querySelector( '[data-block="' + clientId + '"]' );
7862 if ( blockNode ) {
7863 blockNode.classList.remove( 'is-ai-processing' );
7864 }
7865
7866 if ( window.wp && window.wp.data ) {
7867 try {
7868 window.wp.data.dispatch( 'core/block-editor' )
7869 .updateBlockAttributes( clientId, { lock: undefined } );
7870 } catch ( e ) { /* silent */ }
7871 }
7872 }
7873
7874 _applySelectorLoader( selector ) {
7875 try {
7876 const el = document.querySelector( selector );
7877 if ( el ) {
7878 el.classList.add( 'is-ai-processing' );
7879 }
7880 } catch ( e ) { /* invalid selector */ }
7881 }
7882
7883 _removeSelectorLoader( selector ) {
7884 try {
7885 const el = document.querySelector( selector );
7886 if ( el ) {
7887 el.classList.remove( 'is-ai-processing' );
7888 }
7889 } catch ( e ) { /* silent */ }
7890 }
7891
7892 _applyGlobalLoader() {
7893 if ( document.getElementById( 'ai-global-loader' ) ) {
7894 return;
7895 }
7896 const toast = document.createElement( 'div' );
7897 toast.id = 'ai-global-loader';
7898 toast.className = 'ai-global-processing-toast';
7899 toast.textContent = 'AI is updating design settings\u2026';
7900 document.body.appendChild( toast );
7901 }
7902
7903 _removeGlobalLoader() {
7904 const toast = document.getElementById( 'ai-global-loader' );
7905 if ( toast ) {
7906 toast.remove();
7907 }
7908 }
7909
7910 _applyFullPageLoader() {
7911 if ( document.getElementById( 'ai-fullpage-overlay' ) ) {
7912 return;
7913 }
7914 const overlay = document.createElement( 'div' );
7915 overlay.id = 'ai-fullpage-overlay';
7916 overlay.className = 'ai-fullpage-processing-overlay';
7917 const text = document.createElement( 'div' );
7918 text.className = 'ai-fullpage-text';
7919 text.textContent = 'AI is building your page\u2026';
7920 overlay.appendChild( text );
7921 document.body.appendChild( overlay );
7922 }
7923
7924 _removeFullPageLoader() {
7925 const overlay = document.getElementById( 'ai-fullpage-overlay' );
7926 if ( overlay ) {
7927 overlay.remove();
7928 }
7929 }
7930
7931 // ── Color Palette Preview ─────────────────────────────────────
7932
7933 previewPalette( colors ) {
7934 if ( ! colors || ! Array.isArray( colors ) ) {
7935 return;
7936 }
7937
7938 let styleEl = document.getElementById( 'zipwp-palette-preview' );
7939 if ( ! styleEl ) {
7940 styleEl = document.createElement( 'style' );
7941 styleEl.id = 'zipwp-palette-preview';
7942 document.head.appendChild( styleEl );
7943 }
7944
7945 const vars = colors.map( function( c, i ) {
7946 const slug = ( typeof c === 'object' && c.slug ) || ( 'ast-global-color-' + i );
7947 const color = typeof c === 'string' ? c : ( c.color || c.hex || '' );
7948 return '--' + slug + ':' + color;
7949 } ).join( ';' );
7950
7951 styleEl.textContent = ':root{' + vars + '}';
7952 }
7953
7954 // ── Auth ──────────────────────────────────────────────────────
7955
7956 checkAuthStatus() {
7957 const self = this;
7958 const formData = new URLSearchParams();
7959 formData.append( 'action', 'zipwp_verify_auth_status' );
7960 formData.append( 'nonce', self.config.nonce );
7961 return fetch( self.config.ajaxUrl, {
7962 method: 'POST',
7963 credentials: 'same-origin',
7964 body: formData,
7965 } )
7966 .then( function( response ) {
7967 return response.json();
7968 } )
7969 .then( function( result ) {
7970 return !! ( result && result.success && result.data && result.data.is_authorized );
7971 } )
7972 .catch( function() {
7973 return false;
7974 } );
7975 }
7976
7977 openAuthPopup() {
7978 const self = this;
7979 return new Promise( function( resolve, reject ) {
7980 const url = self.config.authUrl;
7981 if ( ! url || ! self.config.ajaxUrl || ! self.config.nonce ) {
7982 reject( new Error( 'Assistant auth configuration is missing. Reload the page and try again.' ) );
7983 return;
7984 }
7985
7986 const width = 600;
7987 const height = 700;
7988 const left = ( screen.width - width ) / 2;
7989 const top = ( screen.height - height ) / 2;
7990
7991 const popupWindow = window.open(
7992 url,
7993 'ZipWP Login',
7994 'width=' + width + ',height=' + height + ',top=' + top + ',left=' + left + ',popup=yes,toolbar=no,location=no,menubar=no'
7995 );
7996
7997 if ( ! popupWindow || popupWindow.closed ) {
7998 reject( new Error( 'Popup was blocked. Please allow popups for this site and try again.' ) );
7999 return;
8000 }
8001
8002 let iterations = 0;
8003 const maxIterations = 300;
8004
8005 // eslint-disable-next-line no-var
8006 var authPollingInterval = setInterval( async function() {
8007 if ( popupWindow.closed || iterations >= maxIterations ) {
8008 clearInterval( authPollingInterval );
8009 if ( ! popupWindow.closed ) {
8010 popupWindow.close();
8011 }
8012 // The popup can close (ZipWP auto-closes on success, or the
8013 // user closes it) between two 500ms polls — before a tick
8014 // catches is_authorized. Do one final check so a completed
8015 // login still reloads into the authed app instead of
8016 // stranding the user on the auth screen.
8017 if ( popupWindow.closed && await self.checkAuthStatus() ) {
8018 self.reloadWithAutoOpen();
8019 return;
8020 }
8021 if ( iterations >= maxIterations ) {
8022 reject( new Error( 'Authentication timeout' ) );
8023 }
8024 return;
8025 }
8026
8027 if ( await self.checkAuthStatus() ) {
8028 if ( ! popupWindow.closed ) {
8029 popupWindow.close();
8030 }
8031 clearInterval( authPollingInterval );
8032 self.reloadWithAutoOpen();
8033 }
8034
8035 iterations++;
8036 }, 500 );
8037 } );
8038 }
8039
8040 // ── Inline Edit Shortcut (Cmd+J / Ctrl+J) ────────────────────
8041
8042 /**
8043 * Register Cmd+J / Ctrl+J keyboard shortcut to open sidebar and focus chat input.
8044 *
8045 * @since x.x.x
8046 */
8047 setupInlineEditShortcut() {
8048 const self = this;
8049 const handler = function( e ) {
8050 const isMac = navigator.platform.toUpperCase().indexOf( 'MAC' ) >= 0;
8051 const modKey = isMac ? e.metaKey : e.ctrlKey;
8052
8053 if ( modKey && e.key === 'j' ) {
8054 e.preventDefault();
8055 e.stopPropagation();
8056
8057 // Open sidebar if not visible
8058 const container = document.getElementById( 'zip-ai-assistant-container' );
8059 if ( container && ! container.classList.contains( 'zip-ai-iframe-visible' ) ) {
8060 self.togglePanel();
8061 }
8062
8063 // Emit event for React to focus input and lock context
8064 if ( window.zipwpMcpAppBridge ) {
8065 window.zipwpMcpAppBridge.emit( 'inline_edit_shortcut', {} );
8066 }
8067 }
8068 };
8069
8070 // Listen on main document
8071 document.addEventListener( 'keydown', handler, true );
8072
8073 // Also listen inside the block editor iframe (WP 6.3+)
8074 const attachToEditorIframe = function() {
8075 const iframes = document.querySelectorAll( 'iframe[name="editor-canvas"]' );
8076 iframes.forEach( function( iframe ) {
8077 try {
8078 const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
8079 if ( iframeDoc && ! iframeDoc._zipwpShortcutAttached ) {
8080 iframeDoc.addEventListener( 'keydown', handler, true );
8081 iframeDoc._zipwpShortcutAttached = true;
8082 }
8083 } catch ( e ) { /* cross-origin iframe, skip */ }
8084 } );
8085 };
8086
8087 // Retry attaching since editor iframe loads asynchronously
8088 setTimeout( attachToEditorIframe, 1000 );
8089 setTimeout( attachToEditorIframe, 3000 );
8090 }
8091
8092 // ── Fullscreen ────────────────────────────────────────────────
8093
8094 emitFullscreenChanged( isFullscreen ) {
8095 if ( window.zipwpMcpAppBridge && typeof window.zipwpMcpAppBridge.emit === 'function' ) {
8096 window.zipwpMcpAppBridge.emit( 'fullscreen_changed', { fullscreen: !! isFullscreen } );
8097 }
8098 }
8099
8100 toggleFullscreen( isFullscreen ) {
8101 const container = document.getElementById( 'zip-ai-assistant-container' );
8102 if ( ! container ) {
8103 return;
8104 }
8105
8106 if ( isFullscreen ) {
8107 container.classList.add( 'zip-ai-iframe-fullscreen' );
8108 document.body.classList.add( 'zip-ai-assistant-fullscreen' );
8109 // Legacy alias for older selectors/scripts.
8110 document.body.classList.add( 'zip-ai-iframe-fullscreen' );
8111 } else {
8112 container.classList.remove( 'zip-ai-iframe-fullscreen' );
8113 document.body.classList.remove( 'zip-ai-assistant-fullscreen' );
8114 document.body.classList.remove( 'zip-ai-iframe-fullscreen' );
8115 }
8116
8117 this.emitFullscreenChanged( isFullscreen );
8118 }
8119
8120 // ── Resize Handle ─────────────────────────────────────────────
8121
8122 setupResizeHandle() {
8123 const resizeHandle = document.getElementById( 'zip-ai-resize-handle' );
8124 if ( ! resizeHandle ) {
8125 return;
8126 }
8127
8128 let isDragging = false;
8129 let startX = 0;
8130 let startWidth = 0;
8131 const self = this;
8132
8133 const onMouseDown = function( e ) {
8134 e.preventDefault();
8135 isDragging = true;
8136 startX = e.clientX;
8137 const container = document.getElementById( 'zip-ai-assistant-container' );
8138 startWidth = container ? container.offsetWidth : 550;
8139
8140 resizeHandle.classList.add( 'dragging' );
8141 document.body.classList.add( 'zip-ai-resizing' );
8142
8143 document.addEventListener( 'mousemove', onMouseMove );
8144 document.addEventListener( 'mouseup', onMouseUp );
8145 };
8146
8147 // eslint-disable-next-line no-var
8148 var onMouseMove = function( e ) {
8149 if ( ! isDragging ) {
8150 return;
8151 }
8152 const delta = startX - e.clientX;
8153 const newWidth = startWidth + delta;
8154 self.resizeSidebar( newWidth );
8155 };
8156
8157 // eslint-disable-next-line no-var
8158 var onMouseUp = function() {
8159 if ( ! isDragging ) {
8160 return;
8161 }
8162
8163 isDragging = false;
8164 resizeHandle.classList.remove( 'dragging' );
8165 document.body.classList.remove( 'zip-ai-resizing' );
8166
8167 document.removeEventListener( 'mousemove', onMouseMove );
8168 document.removeEventListener( 'mouseup', onMouseUp );
8169
8170 const container = document.getElementById( 'zip-ai-assistant-container' );
8171 if ( container ) {
8172 self.saveSidebarWidth( container.offsetWidth );
8173 }
8174 };
8175
8176 resizeHandle.addEventListener( 'mousedown', onMouseDown );
8177
8178 resizeHandle.addEventListener( 'touchstart', function( e ) {
8179 const touch = e.touches[ 0 ];
8180 onMouseDown( { clientX: touch.clientX, preventDefault() {
8181 e.preventDefault();
8182 } } );
8183 } );
8184
8185 document.addEventListener( 'touchmove', function( e ) {
8186 if ( ! isDragging ) {
8187 return;
8188 }
8189 const touch = e.touches[ 0 ];
8190 onMouseMove( { clientX: touch.clientX } );
8191 } );
8192
8193 document.addEventListener( 'touchend', onMouseUp );
8194 }
8195
8196 resizeSidebar( width ) {
8197 const minWidth = 360;
8198 const maxWidth = Math.round( window.innerWidth * 0.8 );
8199 const clampedWidth = Math.min( maxWidth, Math.max( minWidth, width ) );
8200
8201 document.documentElement.style.setProperty( '--zipwp-sidebar-width', clampedWidth + 'px' );
8202
8203 const container = document.getElementById( 'zip-ai-assistant-container' );
8204 if ( container ) {
8205 container.style.width = clampedWidth + 'px';
8206 }
8207 }
8208
8209 saveSidebarWidth( width ) {
8210 const minWidth = 360;
8211 const maxWidth = Math.round( window.innerWidth * 0.8 );
8212 const clampedWidth = Math.min( maxWidth, Math.max( minWidth, width ) );
8213
8214 localStorage.setItem( 'zipwp-sidebar-width', clampedWidth.toString() );
8215 this.resizeSidebar( clampedWidth );
8216 }
8217
8218 loadSavedSidebarWidth() {
8219 const savedWidth = localStorage.getItem( 'zipwp-sidebar-width' );
8220 if ( savedWidth ) {
8221 this.resizeSidebar( parseInt( savedWidth, 10 ) );
8222 }
8223 }
8224
8225 // ── Generic Message Handler ───────────────────────────────────
8226
8227 // eslint-disable-next-line no-unused-vars
8228 handleMessage( type, data ) {
8229 // Extensibility point for future message types
8230 }
8231 }
8232
8233 // Initialize and expose globally
8234 const bridgeHost = new WPBridgeHost();
8235 window.zipwpMcpBridge = bridgeHost;
8236 }() );
8237