PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.13
ZIP AI – AI Website Builder & AI Agent v0.0.13
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / assets / js / dist / zip-ai.js

zip-ai.js in ZIP AI – AI Website Builder & AI Agent 0.0.13, at assets/js/dist/zip-ai.js

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