/* global AIPB */
/* Aura — the Studio (chat + preview).
   Two contexts:
   - A COMPONENT (ai_section): a single reusable section (template/schema/values).
   - A PAGE (post/page): a STACK OF BLOCKS. Every block is either an inline section (with its own
     template/schema/values) or a reference to a component.
   The chat acts on the selected block, or on the whole page when nothing is selected. */
( function () {
	'use strict';

	// i18n: an alias to wp.i18n (the 'wp-i18n' dependency); it falls back to the identity when absent.
	var _i18n = ( window.wp && window.wp.i18n ) || {};
	var __ = _i18n.__ ? function ( s ) { return _i18n.__( s, 'aipb' ); } : function ( s ) { return s; };
	var _n = _i18n._n ? function ( s, p, n ) { return _i18n._n( s, p, n, 'aipb' ); } : function ( s, p, n ) { return 1 === n ? s : p; };
	var sprintf = _i18n.sprintf || function ( f ) { var a = arguments, i = 0; return String( f ).replace( /%[sd]/g, function () { return a[ ++i ]; } ); };

	var root = document.getElementById( 'aipb-studio' );
	if ( ! root || typeof AIPB === 'undefined' ) {
		return;
	}

	var TYPES = ( AIPB.types && AIPB.types.length ) ? AIPB.types : [ { type: 'wp_block', label: __( 'Pattern' ) } ];
	var DEFAULT_TYPE = TYPES.filter( function ( t ) { return t.type === 'wp_block'; } )[ 0 ] || TYPES[ 0 ];

	// The "component/pattern" types (a single section). Everything else is a stack of blocks,
	// the FSE templates (wp_template/part) included, which are a SET of sections like pages are.
	var COMPONENT_TYPES = { wp_block: 1, ai_section: 1 };
	function isPageType( pt ) { return pt && ! COMPONENT_TYPES[ pt ]; }

	// For the Page/Section switch: the two "poles".
	var SECTION_TYPE  = TYPES.filter( function ( t ) { return ! isPageType( t.type ); } )[ 0 ] || DEFAULT_TYPE;
	var DEFAULT_PAGE  = TYPES.filter( function ( t ) { return t.type === 'page'; } )[ 0 ]
		|| TYPES.filter( function ( t ) { return isPageType( t.type ); } )[ 0 ];
	// The Studio starts in Page mode (it is a page builder); it falls back to Section when there are no page types.
	var START_TYPE    = DEFAULT_PAGE || SECTION_TYPE;

	// How many variants may be asked for. IT LIVES UP HERE, away from the rest of the variants code,
	// for one reason: `state` below is built by CALLING loadVariantPrefs(), and a `var` further down
	// the file is hoisted as a name but not as a value — it would still be undefined at that moment,
	// and reading .indexOf off it takes the whole Studio down with a white screen before anything is
	// drawn. Anything the state object calls at birth has to be fully defined above it.
	var VARIANT_COUNTS = [ 2, 3 ];

	/* -------------------------------- State -------------------------------- */

	var state = {
		sectionId: null,
		title: '',
		postType: START_TYPE.type,
		typeLabel: START_TYPE.label,
		mode: isPageType( START_TYPE.type ) ? 'page' : 'section',
		// Section mode:
		template: '',
		schema: { fields: [] },
		values: {},
		// Page mode:
		blocks: [],
		selectedBlockId: null,
		status: 'draft', // the content's status (pages only): 'draft' | 'publish'
		// Shared:
		shortcode: '',
		permalink: '',
		editLink: '',
		messages: [],
		// The problems the checks found, per block: { <block id>: [issues], _page: [...] }.
		// They live HERE and not in the chat because they are a STATE of the page, not an event in
		// the conversation: they hold until they are fixed, whereas a message scrolls away and after
		// three generations is unreachable. In the chat they did two kinds of damage at once — they
		// buried what you were reading and disappeared exactly when they were needed.
		issues: {},
		attachments: [], // reference screenshots waiting to be sent
		pickTarget: null, // { label, snippet } of the element picked in the preview for the next change
		busy: false,
		dirty: false,
		tab: 'chat', // 'chat' | 'blocks' | 'fields' | 'code'
		codeSubtab: 'html', // the code editor's subtab: 'html' | 'css' | 'js' (it survives a refresh)
		// WHICH ACT the next message performs, and what it applies to. Never deduced from the text:
		// see the table of acts further down. `tier` null = the group's default; `tierContext` is
		// what the acts on offer apply to, and when it changes the choice goes back to that default.
		tier: null,
		tierContext: '',
		fidelity: loadFidelity(), // 'faithful' | 'balanced' | 'creative': how closely the AI follows the reference
		reproducing: false, // true only while an import fan-out runs: the generations are copies, not designs
		importAssets: null, // token -> real image address, for the length of one import (see restoreAssets)
		variantsOn: loadVariantPrefs().on, // several versions of a section to choose between: OFF unless asked for
		variantsN: loadVariantPrefs().n,   // how many, when it is on: 2 or 3
		viewport: 'desktop', // the preview: 'desktop' | 'tablet' | 'mobile'
		renamingId: null, // id of the block being renamed inline (transient)
		// The sections MARKED with ctrl/cmd+click, which together with the selected one are what
		// Unite merges. Ids and not blocks: the list is rebuilt at every render, and a mark that
		// survives as an object would go on pointing at a section the page no longer has.
		markedBlockIds: [],
	};

	var dom = {};
	var previewTimer = null;
	var skeletonMode = false; // true while the AI creates/regenerates a section (it shows the skeleton)
	var busyStart = 0, busyTimer = null;
	var lastPreviewKey = '';

	// The "busy" state with feedback: after a few seconds it shows the elapsed time.
	function setBusy( on ) {
		state.busy = on;
		if ( on ) {
			busyStart = Date.now();
			clearInterval( busyTimer );
			busyTimer = setInterval( function () { if ( state.busy ) { renderMessages(); } }, 1000 );
		} else {
			clearInterval( busyTimer ); busyTimer = null;
		}
		// The three-dots bubble is drawn by renderMessages() looking at state.busy: changing the flag
		// without redrawing leaves it on the page once the work is done, with the seconds frozen at
		// the last rendered value (the dots go on animating: that is CSS). It happened on whole
		// pages, where the last message — «Page complete» — is written WHILE we are still busy, and
		// after that draw nobody redraws again. Redrawing here closes the case once and for all:
		// whoever owns the flag also owns what the flag shows, and no caller has to remember.
		if ( dom.messages ) { renderMessages(); }
		updatePreviewSkeleton();
		refreshSendButton();
		// Same story for Update/Save draft/Preview: they are disabled while busy, so the flag's owner
		// redraws them too. Without this, a generation that ends leaves the buttons greyed out until
		// something else happens to call refreshSaveState() — an undo, a rename — and the freshly
		// generated section could not be saved.
		if ( dom.save ) { refreshSaveState(); }

		// Once the work is done the month's bill is re-read, once and unhurriedly: during a
		// generation it would be one network request per section, and the number does not change
		// fast enough to deserve that.
		if ( ! on && sessionGen ) {
			clearTimeout( usageTimer );
			usageTimer = setTimeout( refreshUsage, 2000 );
			autosaveDraft();
		}
	}
	var usageTimer = null;

	/**
	 * The tab does not close over paid work without saying so.
	 *
	 * There IS a net underneath — every change is stashed in localStorage (see saveDraft) and offered
	 * back on reopening — but it is a net nobody is told about at the moment they need it. Whoever
	 * closes the tab after generating a page has no reason to believe the work survived, and the
	 * belief is what decides whether they ever come back to look. So the browser asks, which is the
	 * one thing that happens BEFORE the decision rather than after it.
	 *
	 * `busy` counts as much as `dirty`: a generation in flight is money already committed, and
	 * closing the tab abandons it halfway with nothing written anywhere.
	 *
	 * The text is the browser's own — every modern one refuses a custom message, and rightly, since
	 * that field was used to hold pages hostage. The two lines below are the incantation that still
	 * works everywhere: preventDefault for the standard, returnValue for what remembers the old way.
	 */
	window.addEventListener( 'beforeunload', function ( e ) {
		if ( ! state.dirty && ! state.busy ) { return; }
		e.preventDefault();
		e.returnValue = '';

		return '';
	} );

	/**
	 * A generation that has never been saved anywhere gets a draft of its own.
	 *
	 * WHAT IT SOLVES. A whole page is N calls to the model and several minutes of waiting, and until
	 * somebody presses Save it exists in one browser tab and in that tab's localStorage. That is
	 * recoverable but private: it is not in the site's list of pages, a colleague cannot see it, and
	 * another computer never will. For work that has been PAID FOR that is the wrong place to leave
	 * it lying.
	 *
	 * THE THREE GUARDS ARE WHAT MAKE IT SAFE TO DO WITHOUT ASKING, and each one removes a way this
	 * could act against its owner:
	 *
	 *  - `state.sectionId` — only content that has NEVER been saved. Anything that already exists on
	 *    the server is left alone, so no automatism of ours can overwrite a page somebody has, or
	 *    push a half-finished generation onto a page that is online;
	 *  - `pageMode()` — a component is saved published, because that is how a pattern becomes
	 *    reusable. A pattern appearing in the library nobody put there is a surprise, and a draft is
	 *    not what would be created;
	 *  - `blocks.length` — there is nothing to give a home to.
	 *
	 * What is left is pure gain: a piece of work that had no home gets one, as a draft, offline.
	 */
	var autosaving = false;
	function autosaveDraft() {
		if ( autosaving || state.busy || state.sectionId || ! pageMode() || ! state.blocks.length ) { return; }

		// The post does not exist yet, so giving it one is a round trip during which `sectionId` is
		// still empty. A second generation finishing in that window would look at the same empty
		// field and create a SECOND draft — two pages in the list for one piece of work, and the
		// tab writing into only one of them.
		autosaving = true;
		var done = function () { autosaving = false; };
		var p = doSave( 'draft' );
		if ( p && p.then ) { p.then( done, done ); } else { done(); }
	}

	/* --------------------- Cancelling a generation (STOP) ------------------ */

	// The abort controller of the AI generation in flight. One per submit: aborting it stops the
	// fetch in flight (and therefore the whole chain, the sections still to be generated included).
	var currentAbort = null;
	// The reference (a single image) each section's band is cropped from while generating a page.
	// Set on submit, read in generateSectionsParallel. null = no cropping.
	var genCropRef = null;
	function beginGeneration() {
		// A pending choice does not survive the next request. Whatever is on screen at that moment is
		// what the person decided to build on, so it is kept as if «Keep this one» had been pressed:
		// dropping it silently would throw away a section they are visibly working from.
		if ( typeof variantsLive === 'function' && variantsLive() ) { keepVariant(); }
		currentAbort = ( 'AbortController' in window ) ? new AbortController() : null;
	}
	function abortSignal() { return currentAbort ? currentAbort.signal : undefined; }
	function stopGeneration() {
		if ( ! state.busy ) { return; }
		if ( currentAbort ) { try { currentAbort.abort(); } catch ( e ) {} }
		// The rejection of the fetch in flight lands in genError(), which clears the busy flag and says so.
	}
	function isAbort( err ) {
		return !! err && ( err.name === 'AbortError' || /abort/i.test( err.message || '' ) );
	}
	/**
	 * Is the error one of those that pass on their own?
	 *
	 * Provider overload, a per-minute request ceiling, a momentary fault in the service, the network
	 * dropping: they are all cases where the very same request, an instant later, succeeds. A 400 or
	 * a 402 is not — those answer the same however many times you ask, and retrying them costs twice
	 * for the same outcome.
	 */
	function isTransient( err ) {
		var m = ( err && err.message ) ? String( err.message ) : '';

		return /\b(429|500|502|503|504|529)\b/.test( m )
			|| /overload|too many generations|try again|timeout|temporar|failed to fetch|networkerror/i.test( m );
	}

	/**
	 * Is the error the QUEUE rather than a fault?
	 *
	 * «Too many at once» is not a hiccup that a second attempt an instant later gets past: it says
	 * the slots are taken, and they stay taken for as long as a generation lasts. Asking again
	 * immediately spends the retry against the very same wall, which is how a section was being
	 * lost on a service that was working perfectly.
	 */
	function isQueued( err ) {
		var m = ( err && err.message ) ? String( err.message ) : '';

		return /\b429\b/.test( m ) || /too many generations|too many requests|overload/i.test( m );
	}
	function waitMs( ms ) {
		return new Promise( function ( resolve ) { setTimeout( resolve, ms ); } );
	}
	/**
	 * Asks, and asks again if the answer was one of those that pass on their own.
	 *
	 * Two attempts for an ordinary transient fault (a 529 lasting a second): more would only cost
	 * twice for the same outcome. Three, and spaced out, when it is the queue: the wait is what
	 * makes the difference there, because the slot frees when somebody else's generation ends —
	 * seconds, not milliseconds. The delays are deliberately longer than the `Retry-After: 5` the
	 * service sends, which does not survive WordPress's REST proxy as a header.
	 *
	 * @param {Function} ask   makes the request, returns a promise
	 * @param {Function} onRetry told the attempt number, so the person watching sees it is not stuck
	 */
	function askRetrying( ask, onRetry ) {
		var attempt = 0;

		function run() {
			attempt++;

			return ask().catch( function ( err ) {
				if ( isAbort( err ) || ! isTransient( err ) ) { throw err; }

				var queued = isQueued( err );
				var max    = queued ? 3 : 2;
				if ( attempt >= max ) { throw err; }

				var delay = queued ? attempt * 6000 : 800;
				if ( onRetry ) { onRetry( attempt, delay ); }

				return waitMs( delay ).then( run );
			} );
		}

		return run();
	}
	// The generations' shared error handler: if the user pressed STOP it says "stopped", otherwise it
	// shows the API's real error (as the previous flow already did).
	function genError( err ) {
		setBusy( false );
		if ( isAbort( err ) ) { botMsg( MARK.skip + ' ' + __( 'Generation stopped.' ) ); }
		else { botMsg( sprintf( __( 'Error: %s' ), ( err && err.message ) ? err.message : __( 'unexpected' ) ), 'warn' ); }
	}

	// The send button's icon: a paper plane when it is ready, a STOP square while generating.
	var SEND_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"></path><path d="M22 2 15 22 11 13 2 9z"></path></svg>';
	var STOP_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2"></rect></svg>';
	/**
	 * The send button says what pressing it will do.
	 *
	 * It is the confirmation, moved INSIDE the action instead of in front of it. A dialog on the most
	 * frequent gesture in the product is read three times and dismissed blind ever after — it then
	 * protects nothing and costs a click every time. A word on the button you are already pressing
	 * cannot be skipped, and costs none.
	 *
	 * It matters most for the act nobody would guess is expensive: «Global» carries its own price in
	 * calls, because that is the one place where a click can cost ten times the click before it.
	 */
	function refreshSendButton() {
		if ( ! dom.send ) { return; }
		if ( state.busy ) {
			dom.send.innerHTML = STOP_ICON;
			dom.send.title = __( 'Stop the generation' );
			dom.send.classList.add( 'is-stop' );
			dom.send.disabled = false;
			return;
		}

		dom.send.classList.remove( 'is-stop' );

		var tier = tierByKey( currentTier() );
		var label = tier ? tier.label : '';
		if ( tier && 'global' === tier.key ) {
			label = sprintf( __( '%1$s · %2$d calls' ), label, builtSections() );
		}

		dom.send.innerHTML = SEND_ICON + '<span class="aipb-send-lbl"></span>';
		dom.send.querySelector( '.aipb-send-lbl' ).textContent = label;
		dom.send.title = tier ? tier.desc : '';
	}

	// The loading skeleton: shown while sections are being created/regenerated (skeletonMode), not
	// for a Touch-up (a surgical patch) nor for a Global pass, where the preview stays useful.
	function updatePreviewSkeleton() {
		if ( ! dom.previewSkeleton ) { return; }
		var show = state.busy && skeletonMode;
		dom.previewSkeleton.style.display = show ? 'block' : 'none';
		if ( show && dom.previewEmpty ) { dom.previewEmpty.style.display = 'none'; }
	}

	// The history for undo/redo (the editable state only; the chat stays out of it).
	var history = [], histIndex = -1, historyTimer = null, restoring = false;

	function snapshot() {
		return JSON.parse( JSON.stringify( {
			template: state.template, schema: state.schema, values: state.values,
			blocks: state.blocks, selectedBlockId: state.selectedBlockId,
			title: state.title, postType: state.postType, typeLabel: state.typeLabel, mode: state.mode,
		} ) );
	}

	function resetHistory() { history = []; histIndex = -1; commitHistory(); }

	function commitHistory() {
		if ( restoring ) { return; }
		var snap = snapshot();
		if ( histIndex >= 0 && JSON.stringify( history[ histIndex ] ) === JSON.stringify( snap ) ) { return; }
		history = history.slice( 0, histIndex + 1 );
		history.push( snap );
		if ( history.length > 60 ) { history.shift(); }
		histIndex = history.length - 1;
		refreshUndoButtons();
		scheduleDraft();
	}

	function scheduleHistory() {
		if ( restoring ) { return; }
		clearTimeout( historyTimer );
		historyTimer = setTimeout( commitHistory, 500 );
	}

	function applySnapshot( snap ) {
		restoring = true;
		var s = JSON.parse( JSON.stringify( snap ) );
		state.template = s.template; state.schema = s.schema; state.values = s.values;
		state.blocks = s.blocks; state.selectedBlockId = s.selectedBlockId;
		state.title = s.title; state.postType = s.postType; state.typeLabel = s.typeLabel; state.mode = s.mode;
		state.dirty = true;
		dom.title.value = state.title;
		updateLayout();
		renderBlocks(); renderFields(); refreshCodeIfOpen(); renderPreview(); renderMeta(); refreshSaveState(); updateComposerHint();
		restoring = false;
	}

	function undo() {
		if ( historyTimer ) { clearTimeout( historyTimer ); commitHistory(); }
		if ( histIndex > 0 ) { histIndex--; applySnapshot( history[ histIndex ] ); refreshUndoButtons(); }
	}
	function redo() {
		if ( histIndex < history.length - 1 ) { histIndex++; applySnapshot( history[ histIndex ] ); refreshUndoButtons(); }
	}
	function refreshUndoButtons() {
		if ( dom.undoBtn ) { dom.undoBtn.disabled = histIndex <= 0; }
		if ( dom.redoBtn ) { dom.redoBtn.disabled = histIndex >= history.length - 1; }
	}
	var uidCounter = 0;
	function uid() { uidCounter++; return 'b_' + uidCounter + '_' + ( '' + Math.random() ).slice( 2, 7 ); }

	function pageMode() { return state.mode === 'page'; }
	function activeBlock() {
		if ( ! state.selectedBlockId ) { return null; }
		return state.blocks.filter( function ( b ) { return b.id === state.selectedBlockId; } )[ 0 ] || null;
	}

	/* ------------------------------ Commands ------------------------------- */

	var COMMANDS = [
		{ cmd: '/new', arg: __( '[title]' ), desc: __( 'Start a new content' ) },
		{ cmd: '/save', arg: '', desc: __( 'Save the current content' ) },
		{ cmd: '/title', arg: __( '<text>' ), desc: __( 'Rename the content' ) },
		{ cmd: '/blocks', arg: '', desc: __( 'Show the page\'s blocks' ) },
		{ cmd: '/model', arg: __( '[what is needed]' ), desc: __( 'Design and create the content types the page needs (post types, taxonomies, fields)' ) },
		{ cmd: '/consistency', arg: __( '[what to align]' ), desc: __( 'Align the style and rhythm across every section' ) },
		{ cmd: '/split', arg: __( '[where to cut]' ), desc: __( 'Split the selected section into several sections' ) },
		{ cmd: '/unite', arg: __( '[how to merge]' ), desc: __( 'Merge the marked sections into one (ctrl/cmd+click to mark them)' ) },
		{ cmd: '/import', arg: __( '<address>' ), desc: __( 'Rebuild a page from the web as sections of this one' ) },
		{ cmd: '/tidy', arg: '', desc: __( 'Tidy up the section\'s code (duplicate rules, dead declarations)' ) },
		{ cmd: '/fields', arg: '', desc: __( 'Show the editable fields' ) },
		{ cmd: '/preview', arg: '', desc: __( 'Refresh the preview' ) },
		{ cmd: '/list', arg: '', desc: __( 'Saved components' ) },
		{ cmd: '/clear', arg: '', desc: __( 'Clear the conversation' ) },
		{ cmd: '/help', arg: '', desc: __( 'Show the available commands' ) },
	];

	/* --------------------------------- API --------------------------------- */

	// What every generation carries with it, whichever route it takes.
	function enrichGeneration( path, body ) {
		if ( path.indexOf( '/generate' ) !== 0 || ! body || typeof body !== 'object' ) { return body; }
		// The fidelity mode travels with EVERY generation, without touching the individual call sites.
		if ( body.fidelity === undefined ) { body.fidelity = state.fidelity; }
		// And with it WHAT is being built: on an archive template the dynamic title is term.*, on a
		// single item it is post.*, and the model cannot guess that from the code alone.
		if ( body.contentId === undefined ) { body.contentId = state.sectionId || 0; }
		return body;
	}

	/**
	 * The count of THIS session's generations, and the month's from the service.
	 *
	 * The first is local and instantaneous: it is incremented where every generation passes, so no
	 * caller has to remember it — and it is exactly the number nobody had, because a whole page
	 * fires fifteen of them between the plan, the sections, the templates and the touch-ups.
	 *
	 * The second comes from the service, which is where the real accounting lives: it is asked for
	 * when the Studio opens and then only once the work is done, never during — it is a number, not
	 * a real-time reading, and asking for it on every generation would be one network request per
	 * line.
	 */
	var sessionGen = 0;
	var monthUsage = null;

	/** The credits with two decimals, formatted the way the reader's locale writes them. */
	function credits( micro, per ) {
		var v = ( micro || 0 ) / ( per || 1000000 );

		return v.toLocaleString( undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
	}

	/**
	 * The counter at the top: how many credits are left and how many have been spent.
	 *
	 * WHY NOT ALWAYS CREDITS. Usage is worked out from the tokens, and an account served by a
	 * SUBSCRIPTION provider (the bridge) reports zero tokens: `used_micro` stays at zero however much
	 * work is done. Writing «0.00 credits» there would be a false number — it would look as though
	 * nothing were being spent, when in fact that plan is not measured that way. In that case the
	 * generation count is shown, which is the only true quantity, and the why lives in the tooltip.
	 *
	 * WHEN THERE IS A CEILING the warning comes BEFORE hitting it: the service answers 402 and blocks
	 * once the quota is spent (Kernel::generate), so a counter that notices the limit only when the
	 * generations start failing is the same mute defect as ever.
	 */
	function renderUsage() {
		if ( ! dom.usage ) { return; }

		var per     = ( monthUsage && monthUsage.micro_per_credit ) || 1000000;
		var used    = ( monthUsage && monthUsage.used_micro ) || 0;
		var cap     = ( monthUsage && monthUsage.credits_limit ) || 0;
		var metered = !! ( monthUsage && ( used > 0 || cap > 0 ) ); // is this account billed in credits?
		var share   = 0;

		dom.usage.classList.remove( 'quasi', 'esaurito' );

		if ( metered && cap > 0 ) {
			var left = Math.max( 0, cap - used );
			share = used / cap;
			dom.usage.textContent = sprintf(
				/* translators: 1: credits left, 2: the plan's ceiling. */
				__( '%1$s of %2$s credits' ), credits( left, per ), credits( cap, per )
			);
			if ( share >= 0.95 ) { dom.usage.classList.add( 'esaurito' ); }
			else if ( share >= 0.8 ) { dom.usage.classList.add( 'quasi' ); }
		} else if ( metered ) {
			// No ceiling on this plan: a balance does not exist, and inventing one would be worse
			// than not showing it. Only what has been spent is said.
			dom.usage.textContent = sprintf( __( '%s credits used' ), credits( used, per ) );
		} else {
			var bits = [];
			if ( sessionGen ) { bits.push( sprintf( _n( '%d generation', '%d generations', sessionGen ), sessionGen ) ); }
			if ( monthUsage && monthUsage.generations ) {
				bits.push( sprintf( __( '%d this month' ), monthUsage.generations ) );
			}
			dom.usage.textContent = bits.join( ' · ' );
		}

		var lines = [];
		if ( metered ) {
			lines.push( sprintf( __( 'Spent this month: %s credits.' ), credits( used, per ) ) );
			if ( cap > 0 ) {
				lines.push( sprintf( __( 'The plan\'s ceiling: %s credits (%d%% used).' ),
					credits( cap, per ), Math.round( share * 100 ) ) );
				if ( share >= 0.8 ) {
					lines.push( __( 'Once the credits are gone the service refuses new generations.' ) );
				}
			} else {
				lines.push( __( 'This plan has no monthly ceiling.' ) );
			}
		} else {
			lines.push( __( 'This account is not billed in credits: the configured provider consumes a subscription, not tokens, so the usage stays at zero.' ) );
		}
		if ( monthUsage && monthUsage.generations ) {
			lines.push( sprintf( __( 'Generations this month: %d.' ), monthUsage.generations ) );
		}
		if ( sessionGen ) {
			lines.push( sprintf( _n( 'Since you opened the Studio: %d generation.', 'Since you opened the Studio: %d generations.', sessionGen ), sessionGen ) );
		}
		dom.usage.title = lines.join( '\n' );
	}

	/** The month's figure from the service. Silent: if it does not answer, the session count stays. */
	function refreshUsage() {
		api( '/usage', null, 'GET' ).then( function ( r ) {
			monthUsage = ( r && r.server ) ? r.server : ( r && r.local ? { generations: r.local.count } : null );
			renderUsage();
		} ).catch( function () {} );
	}

	function api( path, body, method, signal ) {
		// Every generation goes through here, so the counting lives here: hanging it off the
		// individual call sites would mean forgetting it on the next one somebody adds.
		if ( 0 === path.indexOf( '/generate' ) ) {
			sessionGen++;
			renderUsage();
		}
		enrichGeneration( path, body );
		return fetch( AIPB.root + path, {
			method: method || 'POST',
			headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': AIPB.nonce },
			body: body ? JSON.stringify( body ) : undefined,
			signal: signal,
		} ).then( function ( r ) {
			return r.json().catch( function () { return {}; } ).then( function ( data ) {
				if ( ! r.ok ) {
					throw new Error( ( data && data.message ) || ( 'Error ' + r.status ) );
				}
				return data;
			} );
		} );
	}

	/* ---------------------- A generation delivered in pieces ---------------- */

	// Can the service deliver while it generates, and can this browser read a stream? The first half
	// is declared by the service in /health (AIPB.features): a service older than the plugin stays
	// perfectly usable, it simply does not stream.
	function canStream() {
		return serverSupports( 'streaming' ) && 'ReadableStream' in window && typeof TextDecoder !== 'undefined';
	}

	// Like api('/generate', …), but the text arrives as it is produced: onDelta receives every
	// piece, and the promise settles with exactly the same object the synchronous route would
	// return. The caller has no two cases to handle: it has one more function to pass.
	function apiStream( body, onDelta, signal, onMeta ) {
		if ( ! canStream() ) {
			if ( onMeta ) { onMeta( { streaming: false } ); }

			return api( '/generate', body, undefined, signal );
		}

		// The streaming route does not go through api(), so the count has to be incremented here
		// too: otherwise the most common generation of all — the single section, the only one that
		// streams — would be the only one counting for nothing.
		sessionGen++;
		renderUsage();

		enrichGeneration( '/generate', body );

		return fetch( AIPB.root + '/generate/stream', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': AIPB.nonce, Accept: 'text/event-stream' },
			body: JSON.stringify( body ),
			signal: signal,
		} ).then( function ( r ) {
			// An error BEFORE the first piece comes back as ordinary JSON (permissions, a missing
			// prompt): it is read the way it has always been read.
			if ( ! r.ok || ! r.body || ! r.body.getReader ) {
				return r.json().catch( function () { return {}; } ).then( function ( data ) {
					if ( ! r.ok ) { throw new Error( ( data && data.message ) || ( 'Error ' + r.status ) ); }
					return data;
				} );
			}

			var reader = r.body.getReader();
			var decoder = new TextDecoder();
			var buffer = '';
			var result = null;
			var failure = '';

			function handle( block ) {
				var event = 'message';
				var data = '';
				block.split( '\n' ).forEach( function ( line ) {
					if ( line.indexOf( 'event:' ) === 0 ) { event = line.slice( 6 ).trim(); }
					else if ( line.indexOf( 'data:' ) === 0 ) { data += line.slice( 5 ).trim(); }
				} );
				if ( ! data ) { return; }

				var payload;
				try { payload = JSON.parse( data ); } catch ( e ) { return; }

				if ( event === 'delta' && payload && typeof payload.text === 'string' ) { onDelta( payload.text ); }
				// The bridge says up front whether it will speak in pieces: it cannot, on a host with
				// no cURL, and then the answer arrives in one block at the end. Saying so is the
				// point — a degradation nobody announces is indistinguishable from a bug, and costs
				// whoever is looking at it the afternoon it takes to work out which of the two it is.
				else if ( event === 'meta' && onMeta ) { onMeta( payload || {} ); }
				else if ( event === 'result' ) { result = payload; }
				else if ( event === 'error' ) { failure = ( payload && payload.message ) || __( 'Something went wrong during the generation.' ); }
			}

			function pump() {
				return reader.read().then( function ( chunk ) {
					if ( chunk.done ) { return; }
					buffer += decoder.decode( chunk.value, { stream: true } );
					var sep;
					// An event can be split between two network chunks: the incomplete tail is
					// kept aside and joined onto the next one.
					while ( ( sep = buffer.indexOf( '\n\n' ) ) !== -1 ) {
						handle( buffer.slice( 0, sep ) );
						buffer = buffer.slice( sep + 2 );
					}
					return pump();
				} );
			}

			return pump().then( function () {
				if ( failure ) { throw new Error( failure ); }
				if ( ! result ) {
					// The stream closed with no result: almost always a proxy that cut the
					// connection. Say it as it is, instead of leaving a frozen screen.
					throw new Error( __( 'The connection closed before the generation had finished.' ) );
				}
				return result;
			} );
		} );
	}

	// The bubble that updates while the code arrives. It is not decoration: without it, for a minute
	// or two «it is working» cannot be told from «it is stuck», and the only possible reaction is to
	// reload the page — throwing away a generation that has already been paid for.
	/**
	 * WHAT the model is writing, while it writes it.
	 *
	 * It used to be a character counter — «Writing the section… 4210 characters» — which is a timer
	 * with extra steps: it tells you the thing is alive, never that it understood you. The text was
	 * already coming through this very callback and was being thrown away.
	 *
	 * Two things are shown now, and the order matters:
	 *
	 *  - the DECLARATION OF INTENT, which the prompts ask for as the first line of every answer
	 *    (`<<<INTENT>>>…<<<END>>>`). It arrives within the first second or two, before any code, and
	 *    it is the only thing that answers «has it understood me?» while there is still time to
	 *    press STOP. Everything after it is code, and a section starts with its <style>: waiting for
	 *    the markup to find out whether the request was read right means waiting for the end.
	 *  - the TAIL of what is arriving, a few lines, so the wait has something moving in it that is
	 *    the real work and not an animation.
	 *
	 * The parsing is duplicated here and in the plugin's `split_intent()`, and deliberately so: the
	 * browser needs it EARLY, off a stream that is still open, and the PHP side needs it whether the
	 * answer streamed or not. One of the two cannot serve the other.
	 *
	 * @param {string}   label  what is being made, for the line shown until the intent arrives.
	 * @param {Function} digest optional: turns the text received so far into what to show, for the
	 *                          modes whose output is not code to watch scroll by (the plan's JSON).
	 */
	/* The preamble the prompts ask for in front of every answer: the declaration of intent, the
	   section's name, and (faithful-from-image) a LOCK inventory. Stripped here for the LIVE view;
	   the authoritative removal is `AIPB_AI_Provider::split_preamble()`. The two are duplicates on
	   purpose — this one has to work on a stream that is still open. LOCK is larger: it lists the
	   copy strings, not a sentence. */
	var PREAMBLE_RE = /^\s*(?:<<<(?:INTENT|NAME)>>>[\s\S]{0,600}?<<<END>>>\s*|<<<LOCK>>>[\s\S]{0,4000}?<<<END>>>\s*){0,3}/;

	function streamProgress( label, digest ) {
		var msg = null;
		var buffer = '';       // everything received, intent included: the markers may straddle two deltas
		var intent = '';
		var done = false;      // the intent has been read (or ruled out): stop looking for it
		var lastPaint = 0;

		// The last lines of code, which is what makes the wait legible. Blank lines are dropped:
		// they would use up the window without showing anything.
		function tail() {
			// Both declarations come off, not just the intent: leaving the NAME block in would put
			// a marker at the top of the live view, which is the one place it was never meant to be
			// seen.
			var body = buffer.replace( PREAMBLE_RE, '' );
			if ( digest ) { return digest( body ); }
			var lines = body.split( '\n' ).filter( function ( l ) { return l.trim(); } );

			return lines.slice( -6 ).map( function ( l ) {
				return l.length > 160 ? l.slice( 0, 160 ) + '…' : l;
			} ).join( '\n' );
		}

		function readIntent() {
			if ( done ) { return; }
			// The NAME block may come first: the prompt asks for one order, a model that picks the
			// other has still said both things, and the PHP side tolerates either — so this does
			// too, or the intent would go unread on exactly those answers.
			var m = /^\s*(?:<<<NAME>>>[\s\S]{0,600}?<<<END>>>\s*)?<<<INTENT>>>([\s\S]{0,600}?)<<<END>>>/.exec( buffer );
			if ( m ) { intent = m[ 1 ].replace( /\s+/g, ' ' ).trim(); done = true; return; }

			// Otherwise give up once the preamble could no longer fit, so the regex is not re-run on
			// every one of the hundreds of deltas that follow. A section's code opens with <style>,
			// which is one `<` away from a marker: cheap to keep testing for a moment, wrong to keep
			// testing for ever.
			if ( buffer.length > 5500 ) { done = true; }
		}

		function paint( force ) {
			var now = Date.now();
			// Redrawing on every piece is just flicker: the model sends dozens per second.
			if ( ! force && now - lastPaint < 150 ) { return; }
			lastPaint = now;
			msg.text = intent ? intent : ( MARK.run + ' ' + ( label || __( 'Writing…' ) ) );
			msg.stream = tail();
			renderMessages();
		}

		return {
			/**
			 * The FIRST event of the stream, sent before the model is even called.
			 *
			 * It opens the bubble straight away, and that is the point: from here on «the pipe is
			 * open» is something you SEE, within a second, instead of something to deduce. If only
			 * the dots are turning, the answer is being buffered somewhere between PHP and here —
			 * which for a long time looked exactly like streaming that had never been built.
			 *
			 * `streaming: false` is the honest other case: this host cannot speak in pieces (no
			 * cURL, an older service), and the answer will arrive whole at the end. Said once,
			 * plainly, rather than left to look like a fault.
			 */
			meta: function ( info ) {
				var live = ! info || false !== info.streaming;
				// TWO of these arrive, and the second is the one that knows. The first is ours and
				// goes out before the service is even called, which is what lets the bubble open
				// within the second. The second comes back from the service once it has looked at
				// the model provider behind it — and a provider that answers in one block makes a
				// live view impossible however well everything up to here works. So a later «no»
				// CORRECTS what is on screen, as long as no text has arrived to contradict it.
				if ( msg && ( live || buffer ) ) { return; }
				if ( ! msg ) {
					msg = { role: 'bot', text: '', kind: 'stream', stream: '' };
					state.messages.push( msg );
				}
				msg.text = live
					? MARK.run + ' ' + ( label || __( 'Writing…' ) )
					: MARK.run + ' ' + ( label || __( 'Writing…' ) ) + '\n' + __( 'This generation cannot be shown as it is written: it arrives in one block at the end.' );
				renderMessages();
			},

			delta: function ( text ) {
				buffer += text;
				if ( ! msg ) {
					msg = { role: 'bot', text: '', kind: 'stream', stream: '' };
					state.messages.push( msg );
				}
				var had = intent;
				readIntent();
				// The intent appearing is the one repaint worth jumping the queue for: it is the
				// answer to the question the user is sitting there asking.
				paint( had !== intent );
			},

			/**
			 * Closes the live bubble.
			 *
			 * With a final text the bubble MORPHS into it instead of vanishing and being replaced by
			 * another one: two bubbles saying the same thing is the chatter this codebase avoids
			 * everywhere else. The intent stays as the second line, because after the fact it is the
			 * record of what the AI thought it had been asked.
			 */
			end: function ( finalText, kind, fallbackIntent ) {
				// Nothing streamed — an old service, a browser with no ReadableStream, a proxy that
				// buffers — but the answer still carries the intent the model declared. It is worth
				// as much after the fact: it is the record of what it thought it had been asked.
				if ( ! intent && fallbackIntent ) { intent = String( fallbackIntent ); }
				if ( ! msg ) {
					if ( finalText ) { botMsg( finalText + ( intent ? '\n' + intent : '' ), kind ); }

					return;
				}
				var i = state.messages.indexOf( msg );
				var out = null;
				if ( ! finalText ) {
					if ( i >= 0 ) { state.messages.splice( i, 1 ); }
				} else {
					msg.text = finalText + ( intent ? '\n' + intent : '' );
					msg.kind = kind || '';
					msg.stream = '';
					out = msg;
				}
				msg = null;
				renderMessages();

				// The message is handed back so an action can be hung off it: an alternative offered
				// ON the outcome costs no bubble of its own, and is read at the moment it can be
				// judged — which is after seeing the thing, not before.
				return out;
			},

			/** What the model said it had understood, '' if it never said. */
			intent: function () { return intent; },
		};
	}

	/**
	 * The same thing as {@see streamProgress}, for the fan-out: MANY answers at once.
	 *
	 * Interleaving the deltas of eight sections into one bubble would be noise — which is why the
	 * global pass had no streaming at all. But one LINE per section is not noise, it is the map of
	 * what is happening: which sections are being worked on, which have already said «this is not
	 * about me», and above all WHAT each one thinks it has been asked. On a pass that touches the
	 * whole page, reading «Footer — recolouring the links» while it is still running is worth more
	 * than reading it afterwards in a list of proposals.
	 *
	 * Rows are addressed BY INDEX, not by block: the three fan-outs that use it work on different
	 * things — the page's sections, the parts of a split, the sections of a plan that do not exist
	 * yet — and only the position is common to all three.
	 *
	 * @param {Array}  labels the name of each job, in the order they are handed out
	 * @param {string} header the line above the list
	 */
	function fanoutProgress( labels, header ) {
		var lastPaint = 0;
		var msg = botMsg( header );
		msg.stream = '';

		var rows = labels.map( function ( name ) {
			return { name: name || __( 'Section' ), buffer: '', intent: '', state: 'wait' };
		} );

		function paint( force ) {
			var now = Date.now();
			if ( ! force && now - lastPaint < 200 ) { return; }
			lastPaint = now;
			msg.stream = rows.map( function ( r ) {
				var mark = MARK[ r.state ] || MARK.wait;
				var note = r.intent || ( 'run' === r.state ? __( 'reading…' ) : '' );

				return mark + ' ' + r.name + ( note ? ' — ' + note : '' );
			} ).join( '\n' );
			renderMessages();
		}

		return {
			delta: function ( i, text ) {
				var r = rows[ i ];
				if ( ! r ) { return; }
				r.state = 'run';
				if ( r.intent || r.buffer.length > 700 ) { paint( false ); return; }
				r.buffer += text;
				var m = /^\s*<<<INTENT>>>([\s\S]{0,600}?)<<<END>>>/.exec( r.buffer );
				if ( m ) { r.intent = m[ 1 ].replace( /\s+/g, ' ' ).trim(); }
				paint( !! m );
			},

			settle: function ( i, res, err ) {
				var r = rows[ i ];
				if ( ! r ) { return; }
				// An empty answer is not always a failure. On the global pass it is the fan-out
				// working — this section read the request and decided it was not about it — so it
				// gets a mark of its own, and «nothing happened here» stops reading as «something
				// broke here».
				r.state = err ? 'err' : ( ( res && res.template ) ? 'done' : 'skip' );
				if ( ! r.intent && res && res._intent ) { r.intent = res._intent; }
				paint( true );
			},

			/** Renames a row: the plan gives its sections a name before they have one of their own. */
			label: function ( i, name ) {
				if ( rows[ i ] && name ) { rows[ i ].name = name; paint( true ); }
			},

			/**
			 * With a final text the list STAYS, under a new header: it is the record of what was
			 * made and what was left alone. Without one it goes, for the fan-outs whose real answer
			 * is what comes next — the global pass, where the checklist of proposals follows.
			 */
			end: function ( finalText, kind ) {
				var i = state.messages.indexOf( msg );
				if ( finalText ) {
					msg.text = finalText;
					msg.kind = kind || '';
				} else if ( i >= 0 ) {
					state.messages.splice( i, 1 );
				}
				renderMessages();
			},
		};
	}

	// Runs N jobs keeping at most GEN_CONCURRENCY in flight, and returns the results IN THE JOBS'
	// ORDER, never rejecting.
	//
	// It is needed everywhere "one thing per section" is generated. Firing them all at once is no
	// faster: on a fifteen-section page that is fifteen simultaneous requests against a service that
	// works on a few at a time — the server's processes fill up, the model answers 429, and the
	// result is a half-modified page. The limit is the same one that governs generating a whole
	// page; now one function governs it instead of three copies.
	//
	// If the user presses STOP, the jobs that have not started do not start: the ones already in
	// flight fail with an AbortError, which the callers already recognise.
	function runPool( items, worker ) {
		var results = new Array( items.length );
		var cursor = 0;

		// The slots never handed out stay empty objects and not undefined: whoever reads the results
		// does r.error / r.res, and would blow up on undefined.
		for ( var j = 0; j < items.length; j++ ) { results[ j ] = {}; }

		function next() {
			var aborted = currentAbort && currentAbort.signal && currentAbort.signal.aborted;
			if ( aborted || cursor >= items.length ) { return Promise.resolve(); }
			var i = cursor++;
			return Promise.resolve()
				.then( function () { return worker( items[ i ], i ); } )
				.then(
					function ( r ) { results[ i ] = r; },
					function ( e ) { results[ i ] = { error: e }; }
				)
				.then( next );
		}

		var pool = [];
		for ( var k = 0; k < Math.min( GEN_CONCURRENCY, items.length ); k++ ) { pool.push( next() ); }
		return Promise.all( pool ).then( function () { return results; } );
	}

	/* ------------------------------- Helpers ------------------------------- */

	function el( tag, cls, text ) {
		var e = document.createElement( tag );
		if ( cls ) { e.className = cls; }
		if ( text != null ) { e.textContent = text; }
		return e;
	}

	function toast( msg, isErr ) {
		var box = document.getElementById( 'aipb-toasts' );
		if ( ! box ) {
			box = el( 'div' ); box.id = 'aipb-toasts'; box.className = 'aipb-toasts';
			document.body.appendChild( box );
		}
		var t = el( 'div', 'aipb-toast' + ( isErr ? ' err' : '' ), msg );
		box.appendChild( t );
		setTimeout( function () { t.remove(); }, 2600 );
	}

	/**
	 * A message from the bot, with optional ACTIONS at the end.
	 *
	 * The actions live in the message and not in the DOM because renderMessages() rebuilds the list
	 * from scratch on every round: a button hung off the bubble would disappear with the next
	 * message.
	 *
	 * @param {Array} actions [{ label, primary, once, run }] — `once` removes the action after use.
	 */
	/**
	 * THE CHAT'S WHOLE VOCABULARY OF MARKS. Five, and no more will be added.
	 *
	 * What was here before was an emoji per SUBJECT — a magnifying glass for looking, a folder for
	 * a content type, a tag for a taxonomy, a set square for designing, a compass for the menu, a
	 * globe for a global pass. Nineteen of them, each invented at the moment the line was written,
	 * none of them meaning anything a reader could learn: the folder and the tag are the same kind
	 * of event, drawn differently, and the reader has to notice that they are not a distinction.
	 * Decoration priced as information.
	 *
	 * These five say the one thing a line of a running log actually has to say — WHAT STATE IT IS
	 * IN — and they were already here, in fanoutProgress, where a column of steps needed exactly
	 * that. This makes them the language of the whole chat instead of one function's private
	 * shorthand.
	 *
	 *   ·   waiting            ▸   happening now         ✓   done
	 *   –   nothing to do here !   it went wrong
	 *
	 * A PLAIN SENTENCE TAKES NO MARK. A statement, a report, an answer to a question is not a step
	 * and is not in a state: marking it would make the marks mean «this is a chat message», which
	 * is the one thing the bubble already says. They are text glyphs, not emoji: they take the
	 * message's own font and colour, and they line up into a column down the left of the chat.
	 */
	var MARK = { wait: '·', run: '▸', done: '✓', skip: '–', err: '!' };

	function botMsg( text, kind, actions ) {
		var m = { role: 'bot', text: text, kind: kind || '', actions: actions || null, choices: null };
		state.messages.push( m );
		renderMessages();

		return m;
	}
	function userMsg( text, images ) { state.messages.push( { role: 'user', text: text, images: images || [] } ); renderMessages(); }

	/**
	 * The name a new block carries in the page's list.
	 *
	 * Four layers, because the answer used to be one and it was «Section». A page ended up with
	 * «Hero AGR Studio» — that one came from a plan, which asks the model for a name — followed by
	 * three rows all called the same thing, which is a list you cannot navigate and cannot rename
	 * without opening each one to find out what it is.
	 *
	 *  1. what the CALLER knows: the plan's name, the parts of a split, an imported file. It wins,
	 *     because those were chosen deliberately;
	 *  2. what the MODEL declared, in the `<<<NAME>>>` preamble it now returns with every section;
	 *  3. the section's own first HEADING, for an older service or a model that skipped it. Rough,
	 *     but «Get in touch» beats «Section» every time;
	 *  4. «Section», which is now genuinely the last resort.
	 */
	function blockName( section, name ) {
		return name
			|| ( section && ( section.name || section._name ) )
			|| headingOf( section && section.template )
			|| __( 'Section' );
	}

	/** The text of the first heading in a section's markup, trimmed to a label. */
	function headingOf( template ) {
		var m = /<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i.exec( String( template || '' ) );
		if ( ! m ) { return ''; }

		// The heading holds markup and placeholders: what is wanted is the words a person reads.
		// A {{t:key|Real text}} gives up its default, which is exactly the visible text.
		var text = m[ 1 ]
			.replace( /\{\{[tcrhus]:[^|}]*\|([^}]*)\}\}/g, '$1' )
			.replace( /\{\{[^}]*\}\}/g, '' )
			.replace( /<[^>]*>/g, ' ' )
			.replace( /\s+/g, ' ' )
			.trim();
		if ( text.length < 2 ) { return ''; }

		return text.length > 42 ? text.slice( 0, 42 ).replace( /\s+\S*$/, '' ) + '…' : text;
	}

	/**
	 * The same name twice in one list is the defect we are fixing, in a smaller size: a numbered
	 * suffix, exactly as {@see anchorSlug} has always done for the anchors, and for the same reason.
	 */
	function uniqueName( label ) {
		var taken = {};
		( state.blocks || [] ).forEach( function ( b ) { if ( b && b.name ) { taken[ b.name ] = true; } } );
		if ( ! taken[ label ] ) { return label; }

		var n = 2;
		while ( taken[ label + ' ' + n ] ) { n++; }

		return label + ' ' + n;
	}

	function makeInlineBlock( section, name ) {
		var label = uniqueName( blockName( section, name ) );

		var layer = section && section.scope === 'page';

		return {
			type: 'inline', id: uid(),
			name: label,
			scope: layer ? 'page' : 'section',
			// The ANCHOR is born with the block and never changes after that, not even if the block
			// is renamed: an anchor that moves is an in-page link that stops working without saying
			// anything, and it is the kind of failure discovered months later. Page layers are not
			// in the flow: they are not a link target.
			anchor: layer ? '' : anchorSlug( label ),
			template: section.template || '',
			schema: section.schema || { fields: [] },
			values: section.values || {},
			// The id of the generation that produced it. It builds nothing: it is for the day
			// somebody says «this section came out badly», because it is the only number support can
			// use to find it again in the service's log.
			req: section._request || '',
		};
	}

	function makeCatalogBlock( section, name ) {
		var label  = uniqueName( blockName( section, name ) );
		var fields = section.fields || section.values || {};

		return {
			type: 'catalog', id: uid(),
			name: label,
			anchor: anchorSlug( label ),
			component: section.component,
			variant: section.variant || 'default',
			fields: fields,
			values: fields,
			fx: ( section.fx === undefined ) ? null : section.fx,
			template: section.template || '',
			schema: section.schema || { fields: [] },
			req: section._request || '',
		};
	}

	function blockFromGen( section, name ) {
		restoreAssets( section, state.importAssets );
		if ( section && section.component ) {
			return makeCatalogBlock( section, name );
		}

		return makeInlineBlock( section, name );
	}

	/** A readable slug, unique within the page: it is what will end up in an href="#…". */
	function anchorSlug( name ) {
		var base = String( name || 'section' )
			.toLowerCase()
			.normalize ? String( name || 'section' ).toLowerCase().normalize( 'NFD' ).replace( /[̀-ͯ]/g, '' ) : String( name || 'section' ).toLowerCase();
		base = base.replace( /[^a-z0-9]+/g, '-' ).replace( /^-+|-+$/g, '' ) || 'section';

		var taken = {};
		( state.blocks || [] ).forEach( function ( b ) { if ( b && b.anchor ) { taken[ b.anchor ] = true; } } );
		if ( ! taken[ base ] ) { return base; }

		var n = 2;
		while ( taken[ base + '-' + n ] ) { n++; }

		return base + '-' + n;
	}

	/** The anchors already available in the page: it is what an in-page link can point at. */
	function pageAnchors() {
		return ( state.blocks || [] )
			.filter( function ( b ) { return b && b.anchor; } )
			.map( function ( b ) { return { anchor: b.anchor, name: b.name }; } );
	}


	/* ------------------------------- Shell --------------------------------- */

	function mount() {
		root.innerHTML = '';

		if ( ! AIPB.configured ) {
			var banner = el( 'div', 'aipb-banner' );
			banner.innerHTML = sprintf( __( 'The <b>Aura service</b> is not configured: you can open and hand-edit the existing sections, but not generate new ones. Paste your activation key into the <a href="%s">settings</a>.' ), AIPB.settings );
			root.appendChild( banner );
		}

		/* --- The top bar: the way out, the title, the actions, saving --- */
		var top = el( 'div', 'aipb-topbar' );

		var exitBtn = el( 'button', 'aipb-head-btn', '' );
		exitBtn.innerHTML = '&#8592;'; exitBtn.title = __( 'Leave the Studio' );
		exitBtn.addEventListener( 'click', function () {
			if ( state.dirty && ! window.confirm( __( 'There are unsaved changes. Leave anyway?' ) ) ) { return; }
			if ( AIPB.exitUrl ) { window.location.href = AIPB.exitUrl; } else { window.history.back(); }
		} );

		dom.title = el( 'input', 'aipb-title' );
		dom.title.type = 'text';
		dom.title.placeholder = __( 'Untitled' );
		dom.title.addEventListener( 'input', function () { state.title = dom.title.value; state.dirty = true; refreshSaveState(); scheduleHistory(); } );

		// An (invisible) anchor for the patterns list popup, still reachable through /list.
		dom.listWrap = el( 'span', 'aipb-listwrap' );

		// The primary mode switch: Page (a stack of blocks) / Section (a reusable pattern).
		dom.modeSwitch = el( 'div', 'aipb-modeswitch' );
		dom.modeSwitch.setAttribute( 'role', 'group' );
		dom.modeSwitch.setAttribute( 'aria-label', __( 'What you are building' ) );
		dom.modePage = el( 'button', 'aipb-modebtn', __( 'Page' ) );
		dom.modePage.type = 'button';
		dom.modePage.title = __( 'Build a whole page as a stack of sections' );
		dom.modeSection = el( 'button', 'aipb-modebtn', __( 'Section' ) );
		dom.modeSection.type = 'button';
		dom.modeSection.title = __( 'Build a single reusable section (a pattern)' );
		dom.modePage.addEventListener( 'click', function () {
			if ( ! pageMode() && DEFAULT_PAGE ) { setPostType( DEFAULT_PAGE.type ); }
		} );
		dom.modeSection.addEventListener( 'click', function () {
			if ( pageMode() ) { setPostType( SECTION_TYPE.type ); }
		} );
		dom.modeSwitch.appendChild( dom.modePage );
		dom.modeSwitch.appendChild( dom.modeSection );

		dom.typeChip = el( 'span', 'aipb-typechip' );
		dom.typeChip.style.display = 'none';

		dom.undoBtn = el( 'button', 'aipb-head-btn', '' );
		dom.undoBtn.innerHTML = '&#8630;'; dom.undoBtn.title = __( 'Undo (⌘Z)' );
		dom.undoBtn.addEventListener( 'click', undo );
		dom.redoBtn = el( 'button', 'aipb-head-btn', '' );
		dom.redoBtn.innerHTML = '&#8631;'; dom.redoBtn.title = __( 'Redo (⌘⇧Z)' );
		dom.redoBtn.addEventListener( 'click', redo );

		// The preview actions (an eye = a new window, a link = the public page): created here but
		// placed in the bar above the preview, so the top bar stays clean.
		dom.previewBtn = el( 'button', 'aipb-pv-btn', '' );
		dom.previewBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
		dom.previewBtn.title = __( 'Open the preview (unpublished changes) in a new window' );
		dom.previewBtn.setAttribute( 'aria-label', __( 'Open the preview in a new window' ) );
		dom.previewBtn.addEventListener( 'click', showPreview );

		dom.viewBtn = el( 'button', 'aipb-pv-btn', '' );
		dom.viewBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>';
		dom.viewBtn.title = __( 'See the page as a visitor will, with the theme around it' );
		dom.viewBtn.setAttribute( 'aria-label', __( 'See the page as a visitor' ) );
		dom.viewBtn.addEventListener( 'click', function () {
			// On a DRAFT the public permalink leads nowhere (or to the old version): WordPress's
			// native preview shows the saved content inside the real theme instead, which is what
			// people want to see here. It is also different from the Studio's preview, which draws
			// the sections outside their chrome.
			var published = 'publish' === state.status;
			var url = ( ! published && state.previewLink ) ? state.previewLink : state.permalink;
			if ( ! url ) { toast( __( 'Save the page at least once to be able to look at it.' ), true ); return; }
			if ( state.dirty ) {
				toast( __( 'Careful: you will see the last SAVED version, not the changes you have just made.' ) );
			}
			window.open( url, '_blank', 'noopener' );
		} );

		// No "Canvas" toggle: a page built here is ALWAYS full page, because its sections already
		// contain a header and a footer. It is not a preference, it is a consequence. The exception
		// (a page inside the theme) is asked for with the `aipb_canvas` PHP filter.

		// The preview's responsive switch: moved into the top bar (centred) to give the preview more
		// room.
		dom.vpSwitch = el( 'div', 'aipb-vp-switch' );
		dom.vpSwitch.setAttribute( 'role', 'group' );
		dom.vpSwitch.setAttribute( 'aria-label', __( 'Preview size' ) );
		buildViewportSwitch();

		// The "Draft" status badge (only on pages not yet published).
		dom.statusBadge = el( 'span', 'aipb-status-badge', __( 'Draft' ) );
		dom.statusBadge.style.display = 'none';

		// The version history (browse/restore a saved version).
		dom.histBtn = el( 'button', 'aipb-head-btn', '' );
		dom.histBtn.type = 'button';
		dom.histBtn.title = __( 'Version history' );
		dom.histBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>';
		dom.histWrap = el( 'span', 'aipb-listwrap' );
		dom.histWrap.appendChild( dom.histBtn );
		dom.histBtn.addEventListener( 'click', toggleHistory );

		// The secondary "Save draft" button (pages only).
		dom.draftBtn = el( 'button', 'aipb-btn ghost', __( 'Save draft' ) );
		dom.draftBtn.type = 'button';
		dom.draftBtn.style.display = 'none';
		dom.draftBtn.addEventListener( 'click', function () { doSave( 'draft' ); } );

		dom.save = el( 'button', 'aipb-btn primary', __( 'Publish' ) );
		dom.save.addEventListener( 'click', save );

		top.appendChild( exitBtn );
		top.appendChild( dom.title );
		top.appendChild( dom.statusBadge );
		top.appendChild( el( 'div', 'aipb-spacer' ) );
		// How much is being spent. It lives here and not on a separate page because the number is
		// needed WHILE generating: it is what makes you think twice before pressing «regenerate» a
		// fourth time. Keeping it invisible does not make it free, it makes it unpredictable.
		dom.usage = el( 'span', 'aipb-usage' );
		dom.usage.style.cssText = 'font-size:11px;opacity:.6;white-space:nowrap;cursor:default';
		top.appendChild( dom.usage );
		// The version switch: it only exists while there is something to choose between, and it sits
		// above the preview because that is where the choice is actually made.
		dom.variantBar = el( 'div', 'aipb-variantbar' );
		dom.variantBar.style.display = 'none';
		dom.variantBar.setAttribute( 'role', 'group' );
		dom.variantBar.setAttribute( 'aria-label', __( 'Choose a version of the section' ) );
		top.appendChild( dom.variantBar );
		top.appendChild( dom.vpSwitch );
		top.appendChild( el( 'div', 'aipb-spacer' ) );
		top.appendChild( dom.modeSwitch );
		top.appendChild( dom.typeChip );
		top.appendChild( dom.undoBtn );
		top.appendChild( dom.redoBtn );
		top.appendChild( dom.histWrap );
		top.appendChild( dom.previewBtn );
		top.appendChild( dom.viewBtn );
		top.appendChild( dom.listWrap );
		top.appendChild( dom.draftBtn );
		top.appendChild( dom.save );
		root.appendChild( top );
		renderUsage();
		refreshUsage();

		var studio = el( 'div', 'aipb-studio' );
		dom.studio = studio;

		/* --- The left side: the tabs (Chat/Blocks/Fields/Code) --- */
		var left = el( 'div', 'aipb-left' );

		var tabs = el( 'div', 'aipb-tabs' );
		tabs.setAttribute( 'role', 'tablist' );
		dom.tabChat = tabBtn( __( 'Chat' ), 'chat' );
		dom.tabBlocks = tabBtn( __( 'Blocks' ), 'blocks' );
		dom.tabLibrary = tabBtn( __( 'Library' ), 'library' );
		dom.tabFields = tabBtn( __( 'Fields' ), 'fields' );
		// Style sits between the data and the source: the fields say WHAT the section says, Style
		// says what it looks like, Code is where both end up. Its panel is built by
		// studio-style.js, which fills dom.styleWrap below.
		dom.tabStyle = tabBtn( __( 'Style' ), 'style' );
		dom.tabCode = tabBtn( __( 'Code' ), 'code' );
		dom.tabChecks = tabBtn( __( 'Checks' ), 'checks' );
		// The little numbered dot: it is the only signal that lives outside this tab, and it is
		// needed — a problem visible only by entering a tab nobody opens is not reported at all.
		dom.checksBadge = el( 'span', 'aipb-tab-badge' );
		dom.checksBadge.style.display = 'none';
		dom.tabChecks.appendChild( dom.checksBadge );
		tabs.appendChild( dom.tabChat );
		tabs.appendChild( dom.tabBlocks );
		tabs.appendChild( dom.tabLibrary );
		tabs.appendChild( dom.tabFields );
		tabs.appendChild( dom.tabStyle );
		tabs.appendChild( dom.tabCode );
		tabs.appendChild( dom.tabChecks );

		var panels = el( 'div', 'aipb-panels' );
		dom.panels = panels;

		// The chat panel
		dom.chatPanel = el( 'div', 'aipb-panel-chat' );
		dom.messages = el( 'div', 'aipb-messages' );

		var composer = el( 'div', 'aipb-composer' );
		dom.cmdmenu = el( 'div', 'aipb-cmdmenu' );
		dom.cmdmenu.style.display = 'none';

		dom.attachStrip = el( 'div', 'aipb-attachments' );
		dom.attachStrip.style.display = 'none';

		dom.fileInput = el( 'input' );
		dom.fileInput.type = 'file';
		dom.fileInput.accept = 'image/*,video/*,application/pdf,.pdf';
		dom.fileInput.multiple = true;
		dom.fileInput.style.display = 'none';
		dom.fileInput.addEventListener( 'change', function ( e ) {
			var files = e.target.files;
			for ( var i = 0; i < files.length; i++ ) { addAttachment( files[ i ] ); }
			dom.fileInput.value = '';
		} );

		// The bar of acts: what can be done to whatever is selected, and which of them the next
		// message will do. It is rebuilt on every render because its CONTENT changes with the
		// selection, not just which chip is lit — see renderTierBar().
		dom.tierBar = el( 'div', 'aipb-tierbar' );
		dom.tierBar.setAttribute( 'role', 'group' );
		dom.tierBar.setAttribute( 'aria-label', __( 'What the next message will do' ) );

		// The fidelity mode selector (Faithful / Balanced / Creative): how closely the AI follows the
		// reference. It sits on the same row as the tier bar, aligned right.
		dom.fidSwitch = el( 'div', 'aipb-fidswitch' );
		dom.fidSwitch.setAttribute( 'role', 'group' );
		dom.fidSwitch.setAttribute( 'aria-label', __( 'Fidelity mode' ) );
		buildFidelitySwitch();
		// Variants: off until switched on, and it says what it costs while it is on.
		dom.varSwitch = el( 'div', 'aipb-varswitch' );
		dom.varSwitch.setAttribute( 'role', 'group' );
		dom.varSwitch.setAttribute( 'aria-label', __( 'Variants' ) );
		buildVariantControl();

		dom.controlsRow = el( 'div', 'aipb-controls' );
		dom.controlsRow.appendChild( dom.tierBar );
		dom.controlsRow.appendChild( dom.varSwitch );
		dom.controlsRow.appendChild( dom.fidSwitch );

		var inputRow = el( 'div', 'aipb-input-row' );
		dom.attachBtn = el( 'button', 'aipb-attach' );
		dom.attachBtn.title = __( 'Attach reference screenshots, videos or PDFs' );
		dom.attachBtn.innerHTML = '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>';
		dom.attachBtn.addEventListener( 'click', function () { dom.fileInput.click(); } );

		// Element picking: point at an element in the preview for a targeted change.
		dom.pickBtn = el( 'button', 'aipb-attach aipb-pick' );
		dom.pickBtn.type = 'button';
		dom.pickBtn.title = __( 'Pick an element to change in the preview' );
		dom.pickBtn.innerHTML = '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="7"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
		dom.pickBtn.addEventListener( 'click', togglePickMode );

		dom.input = el( 'textarea', 'aipb-input' );
		dom.input.rows = 1;
		dom.input.addEventListener( 'input', onInput );
		dom.input.addEventListener( 'keydown', onKey );
		dom.input.addEventListener( 'paste', onPaste );

		dom.send = el( 'button', 'aipb-send' );
		dom.send.innerHTML = SEND_ICON;
		// While the AI generates, the same button becomes "Stop" and cancels the generation.
		dom.send.addEventListener( 'click', function () {
			if ( state.busy ) { stopGeneration(); } else { submit(); }
		} );

		inputRow.appendChild( dom.attachBtn );
		inputRow.appendChild( dom.pickBtn );
		inputRow.appendChild( dom.input );
		inputRow.appendChild( dom.send );

		// WHAT THE NEXT MESSAGE WILL LAND ON. It is drawn by updateComposerHint().
		//
		// Until now the only sign that a section was selected was the input's PLACEHOLDER — «Change
		// the "Hero" block…» — which is grey, and gone the moment you start typing, which is exactly
		// the moment it matters. Write «create a contact section» with the hero selected and the
		// hero was what changed. (There was a line saying so, but it was written into an element
		// never added to the page: see dom.hint below.)
		dom.targetRow = el( 'div', 'aipb-target' );
		dom.targetRow.style.display = 'none';

		// The chip of the picked element (the target of the next targeted change).
		dom.pickChip = el( 'div', 'aipb-pickchip' );
		dom.pickChip.style.display = 'none';

		// A hint detached from the DOM: not shown, but updateComposerHint() writes into it safely.
		dom.hint = el( 'div', 'aipb-hint' );
		composer.appendChild( dom.cmdmenu );
		composer.appendChild( dom.targetRow );
		composer.appendChild( dom.pickChip );
		composer.appendChild( dom.attachStrip );
		composer.appendChild( dom.controlsRow );
		composer.appendChild( inputRow );
		composer.appendChild( dom.fileInput );
		dom.chatPanel.appendChild( dom.messages );
		dom.chatPanel.appendChild( composer );

		// Drag & drop of screenshots onto the chat.
		dom.dropOverlay = el( 'div', 'aipb-drop', __( 'Drop screenshots, videos or PDFs here' ) );
		dom.dropOverlay.style.display = 'none';
		dom.chatPanel.appendChild( dom.dropOverlay );
		setupDropZone( dom.chatPanel );

		dom.blocksWrap = el( 'div', 'aipb-blocks' );
		dom.libraryWrap = el( 'div', 'aipb-library' );
		dom.fieldsWrap = el( 'div', 'aipb-fields' );
		// Left empty here: studio-style.js builds its panel inside it when it loads. The container
		// is created by studio.js all the same, so the tab order does not depend on which script
		// arrives first.
		dom.styleWrap = el( 'div', 'aipb-style-panel' );
		dom.codeWrap = el( 'div', 'aipb-code' );
		dom.checksWrap = el( 'div', 'aipb-checks' );

		panels.appendChild( dom.chatPanel );
		panels.appendChild( dom.blocksWrap );
		panels.appendChild( dom.libraryWrap );
		panels.appendChild( dom.fieldsWrap );
		panels.appendChild( dom.styleWrap );
		panels.appendChild( dom.codeWrap );
		panels.appendChild( dom.checksWrap );

		left.appendChild( tabs );
		left.appendChild( panels );

		/* --- The right side: the preview, ALWAYS visible (the responsive/action toolbar has moved
		   into the top bar). The preview refreshes itself live: no "refresh" button. --- */
		var side = el( 'div', 'aipb-side' );

		dom.previewWrap = el( 'div', 'aipb-preview-wrap' );
		dom.frame = el( 'iframe', 'aipb-preview-frame' );
		dom.previewEmpty = el( 'div', 'aipb-preview-empty', __( 'The preview will appear here.' ) );
		dom.previewWrap.appendChild( dom.frame );
		dom.previewWrap.appendChild( dom.previewEmpty );
		// The skeleton shown during the FIRST generation (when there is no preview yet).
		dom.previewSkeleton = el( 'div', 'aipb-skeleton' );
		dom.previewSkeleton.style.display = 'none';
		dom.previewSkeleton.innerHTML = '<div class="aipb-sk-row" style="height:200px"></div><div class="aipb-sk-row" style="height:26px;width:60%"></div><div class="aipb-sk-row" style="height:16px;width:80%"></div><div class="aipb-sk-grid"><span></span><span></span><span></span></div>';
		dom.previewWrap.appendChild( dom.previewSkeleton );
		// The section's hover outline (under the toolbar, it does not intercept the mouse).
		dom.secOutline = el( 'div', 'aipb-sectoutline' );
		dom.secOutline.style.display = 'none';
		dom.previewWrap.appendChild( dom.secOutline );
		// The section's context toolbar (it appears on hover over the preview, in page mode only).
		dom.secToolbar = el( 'div', 'aipb-sectbar' );
		dom.secToolbar.style.display = 'none';
		dom.secToolbar.addEventListener( 'mouseenter', function () { secToolbarHover = true; clearTimeout( secToolbarHideTimer ); } );
		dom.secToolbar.addEventListener( 'mouseleave', function () { secToolbarHover = false; scheduleHideSecToolbar(); } );
		dom.previewWrap.appendChild( dom.secToolbar );
		// The drop overlay: it appears over the preview while a Library item is being dragged, so the
		// iframe does not "eat" the drag events and we can show where it will land.
		dom.libDrop = el( 'div', 'aipb-libdrop' );
		dom.libDrop.style.display = 'none';
		dom.libLine = el( 'div', 'aipb-libdrop-line' );
		dom.libDrop.appendChild( dom.libLine );
		dom.libDrop.appendChild( el( 'div', 'aipb-libdrop-hint', __( 'Drop to insert here' ) ) );
		dom.previewWrap.appendChild( dom.libDrop );
		setupLibraryDrop();
		// The "Embed"/shortcode bar, detached from the DOM: renderMeta() writes into it safely.
		dom.meta = el( 'div', 'aipb-meta' );
		dom.meta.style.display = 'none';
		side.appendChild( dom.previewWrap );

		studio.appendChild( left );
		studio.appendChild( side );
		root.appendChild( studio );

		updateLayout();
		applyViewport();
		renderMessages();
		renderBlocks();
		renderFields();
		renderPreview();
		renderMeta();
		refreshSaveState();
		updateComposerHint();
		resetHistory();
	}

	/**
	 * The icons of the left panel's tabs.
	 *
	 * They are drawn HERE, as strings, and not taken from Dashicons: the Studio has a look of its
	 * own and WordPress's icons — filled, from another era — would be the only foreign thing on the
	 * screen. They are stroked at the weight of the rest of the interface and inherit the colour
	 * through currentColor, so the active tab tints its icon with no second rule.
	 *
	 * The pairing that carries the whole thing: BLOCKS are the rows of this page, LIBRARY is the
	 * grid of the ones to choose from. Rows against grid says «what is here» against «what there
	 * is» without a word.
	 */
	var TAB_ICONS = {
		chat: '<path d="M21 15a2 2 0 0 1-2 2H8l-4 3.5V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z"/>',
		blocks: '<rect x="3" y="3" width="18" height="18" rx="2.5"/><path d="M3 9.5h18M3 15h18"/>',
		library: '<rect x="3" y="3" width="7.5" height="7.5" rx="1.8"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.8"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.8"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.8"/>',
		fields: '<path d="M4 7h10M18 7h2M4 17h2M10 17h10"/><circle cx="16" cy="7" r="2.2"/><circle cx="8" cy="17" r="2.2"/>',
		// The palette: the same drawing the Style button had in the top bar, so whoever knew it there
		// recognises it here — moving a control is allowed, changing its face at the same time is not.
		style: '<path d="M12 3a9 9 0 1 0 0 18h1.5a2.5 2.5 0 0 0 0-5H13a2 2 0 0 1 0-4h3.5A4.5 4.5 0 0 0 21 7.5C21 5 17 3 12 3Z"/><circle cx="7.5" cy="10.5" r=".9"/><circle cx="12" cy="7.5" r=".9"/><circle cx="16.5" cy="10.5" r=".9"/>',
		code: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
		checks: '<circle cx="12" cy="12" r="9"/><path d="m8.2 12.4 2.6 2.6 5-5.6"/>'
	};

	function tabIcon( name ) {
		var wrap = el( 'span', 'aipb-tab-ico' );
		wrap.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" '
			+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + TAB_ICONS[ name ] + '</svg>';

		return wrap;
	}

	/**
	 * A tab: the icon alone.
	 *
	 * The name does not disappear — it is the title and the accessible name — but it stops taking up
	 * room in a column that is 34% of the screen and holds the whole conversation. Six words in a
	 * row read as a list to be gone through; six icons read as one bar.
	 */
	function tabBtn( label, tab ) {
		var b = el( 'button', 'aipb-tab' );
		b.appendChild( tabIcon( tab ) );
		b.title = label;
		b.setAttribute( 'aria-label', label );
		b.setAttribute( 'role', 'tab' );
		b.setAttribute( 'aria-selected', 'false' );
		b.addEventListener( 'click', function () { setTab( tab ); } );

		return b;
	}

	/* ------------------------------- Messages ------------------------------ */

	function renderMessages() {
		dom.messages.innerHTML = '';

		if ( ! state.messages.length && ! state.busy ) {
			dom.messages.appendChild( emptyState() );
			return;
		}

		state.messages.forEach( function ( m ) {
			var row = el( 'div', 'aipb-msg ' + m.role + ( m.kind ? ' ' + m.kind : '' ) );
			var bubble = el( 'div', 'aipb-msg-bubble' );
			bubble.innerHTML = escapeAllowCode( m.text );
			// What the model is writing right now. textContent and not innerHTML: this is the
			// model's raw output, tags and all, and it goes on the screen as characters — never as
			// markup, whatever ends up in it.
			if ( m.stream ) {
				var live = el( 'pre', 'aipb-msg-stream' );
				live.textContent = m.stream;
				bubble.appendChild( live );
			}
			if ( m.images && m.images.length ) {
				var grid = el( 'div', 'aipb-msg-imgs' );
				m.images.forEach( function ( u ) {
					// Backwards compatible: the old entries were URL strings; now they are { url, kind, name }.
					var url  = ( u && typeof u === 'object' ) ? u.url : u;
					var name = ( u && typeof u === 'object' ) ? u.name : '';
					if ( url ) {
						var im = el( 'img' ); im.src = url; im.alt = name || ''; grid.appendChild( im );
					} else {
						// The same 24-grid, currentColor marks the rest of the Studio uses. An emoji here
						// was the one thing in a message that did not take the message's own colour.
						var doc = el( 'div', 'aipb-msg-doc' );
						doc.innerHTML = SVG_DOC;
						doc.appendChild( document.createTextNode( ' ' + ( name || 'PDF' ) ) );
						grid.appendChild( doc );
					}
				} );
				bubble.appendChild( grid );
			}
			if ( m.choices && m.choices.length ) {
				var choiceList = el( 'div', 'aipb-msg-choices' );
				m.choices.forEach( function ( c ) {
					var lab = el( 'label', 'aipb-msg-choice' );
					var box = document.createElement( 'input' );
					box.type = 'checkbox';
					box.checked = !! c.on;
					// The state lives in the model, not in the checkbox: on the next renderMessages()
					// the box is recreated, and without this it would come back ticked.
					box.addEventListener( 'change', function () { c.on = box.checked; } );
					lab.appendChild( box );
					lab.appendChild( el( 'span', '', c.label ) );
					choiceList.appendChild( lab );
				} );
				bubble.appendChild( choiceList );
			}

			if ( m.actions && m.actions.length ) {
				var actionBar = el( 'div', 'aipb-msg-actions' );
				m.actions.forEach( function ( a ) {
					var b = el( 'button', 'aipb-btn' + ( a.primary ? ' primary' : '' ), a.label );
					b.type = 'button';
					b.addEventListener( 'click', function () {
						// A consumed action disappears: leaving it pressable twice is the simplest
						// way of applying the same thing twice.
						if ( a.once ) { m.actions = null; renderMessages(); }
						a.run();
					} );
					actionBar.appendChild( b );
				} );
				bubble.appendChild( actionBar );
			}
			row.appendChild( bubble );
			dom.messages.appendChild( row );
		} );

		if ( state.busy ) {
			var row = el( 'div', 'aipb-msg bot' );
			var bubble = el( 'div', 'aipb-msg-bubble' );
			var secs = busyStart ? Math.floor( ( Date.now() - busyStart ) / 1000 ) : 0;
			var elapsed = secs >= 3 ? ' <span class="aipb-elapsed">' + secs + 's</span>' : '';
			bubble.innerHTML = '<span class="aipb-typing"><span></span><span></span><span></span></span>' + elapsed;
			row.appendChild( bubble );
			dom.messages.appendChild( row );
		}

		dom.messages.scrollTop = dom.messages.scrollHeight;
	}

	function emptyState() {
		var wrap = el( 'div', 'aipb-empty' );
		var chipsData;
		if ( pageMode() ) {
			wrap.appendChild( el( 'h2', '', __( 'Build a page' ) ) );
			wrap.appendChild( el( 'p', '', __( 'Describe the page: the AI generates several consistent sections. Then pick a block to refine it, or insert a reusable one.' ) ) );
			chipsData = [ __( 'A landing page for a fitness app' ), __( 'An "About us" page' ), __( 'A home page for a law firm' ), __( 'A services page with prices' ) ];
		} else {
			wrap.appendChild( el( 'h2', '', __( 'Build a pattern' ) ) );
			wrap.appendChild( el( 'p', '', __( 'Describe the reusable element you want (a button, a card, a section). Then refine it in words.' ) ) );
			chipsData = [ __( 'A reusable primary button' ), __( 'A product card' ), __( 'A dark hero with a title and a button' ), __( 'A tabbed FAQ section' ) ];
		}
		var chips = el( 'div', 'aipb-chips' );
		chipsData.forEach( function ( txt ) {
			var c = el( 'button', 'aipb-chip', txt );
			c.addEventListener( 'click', function () { sendInstruction( txt ); } );
			chips.appendChild( c );
		} );
		wrap.appendChild( chips );
		return wrap;
	}

	function escapeAllowCode( text ) {
		var safe = String( text ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' );
		return safe.replace( /`([^`]+)`/g, '<code>$1</code>' ).replace( /\n/g, '<br>' );
	}

	/* ------------------------------ Composer ------------------------------- */

	function onInput() {
		dom.input.style.height = 'auto';
		dom.input.style.height = Math.min( dom.input.scrollHeight, 140 ) + 'px';
		var v = dom.input.value;
		if ( v.charAt( 0 ) === '/' && v.indexOf( ' ' ) === -1 ) { showCmdMenu( v ); } else { hideCmdMenu(); }
	}

	function onKey( e ) {
		if ( e.key === 'Enter' && ( e.metaKey || e.ctrlKey ) ) { e.preventDefault(); submit(); }
		else if ( e.key === 'Enter' && ! e.shiftKey ) { e.preventDefault(); submit(); }
		else if ( e.key === 'Escape' ) { hideCmdMenu(); }
	}

	function showCmdMenu( prefix ) {
		var matches = COMMANDS.filter( function ( c ) { return c.cmd.indexOf( prefix ) === 0; } );
		if ( ! matches.length ) { hideCmdMenu(); return; }
		dom.cmdmenu.innerHTML = '';
		matches.forEach( function ( c ) {
			var row = el( 'div', 'aipb-cmd' );
			row.appendChild( el( 'b', '', c.cmd + ( c.arg ? ' ' + c.arg : '' ) ) );
			row.appendChild( el( 'span', '', c.desc ) );
			row.addEventListener( 'click', function () {
				dom.input.value = c.arg ? c.cmd + ' ' : c.cmd;
				hideCmdMenu(); dom.input.focus();
				if ( ! c.arg ) { submit(); }
			} );
			dom.cmdmenu.appendChild( row );
		} );
		dom.cmdmenu.style.display = 'block';
	}
	function hideCmdMenu() { dom.cmdmenu.style.display = 'none'; }

	function submit() {
		if ( state.busy ) { return; }
		var text = dom.input.value.trim();
		if ( ! text ) { return; }
		dom.input.value = ''; dom.input.style.height = 'auto';
		hideCmdMenu(); renderTierBar();
		if ( text.charAt( 0 ) === '/' ) { runCommand( text ); } else { sendInstruction( text ); }
	}

	/* ------------------------ Attachments (screenshots) -------------------- */

	function onPaste( e ) {
		var items = ( e.clipboardData && e.clipboardData.items ) || [];
		for ( var i = 0; i < items.length; i++ ) {
			if ( items[ i ].kind === 'file' && items[ i ].type.indexOf( 'image/' ) === 0 ) {
				var file = items[ i ].getAsFile();
				if ( file ) { addAttachment( file ); e.preventDefault(); }
			}
		}
	}

	var MAX_VIDEO_BYTES = 5 * 1024 * 1024;  // a video travels inline: keep it light
	var MAX_PDF_BYTES   = 10 * 1024 * 1024; // a PDF travels inline too (base64): a prudent ceiling

	// Which attachments the service accepts: it declares them in /health, we do not guess. It used to
	// depend on the provider chosen in the site; now the provider is the service's choice, and the
	// site simply refrains from offering what it already knows would be discarded.
	function serverSupports( feature ) {
		return Array.isArray( AIPB.features ) && AIPB.features.indexOf( feature ) !== -1;
	}

	function addAttachment( file ) {
		if ( ! file ) { return; }
		var isImg = file.type.indexOf( 'image/' ) === 0;
		var isVid = file.type.indexOf( 'video/' ) === 0;
		var isPdf = file.type === 'application/pdf' || /\.pdf$/i.test( file.name || '' );
		if ( ! isImg && ! isVid && ! isPdf ) { return; }
		if ( state.attachments.length >= 6 ) { toast( __( '6 attachments at most' ), true ); return; }

		if ( isVid ) {
			if ( ! serverSupports( 'video' ) ) { toast( __( 'The service does not read videos: use a screenshot of the moment you care about.' ), true ); return; }
			if ( file.size > MAX_VIDEO_BYTES ) { toast( __( 'Video too big (~5 MB max). Compress or shorten the clip.' ), true ); return; }
			videoToAttachment( file ).then( function ( a ) {
				state.attachments.push( a ); renderAttachments();
			} ).catch( function () { toast( __( 'Invalid video' ), true ); } );
			return;
		}

		if ( isPdf ) {
			if ( ! serverSupports( 'documents' ) ) { toast( __( 'The service is not reading PDFs at the moment.' ), true ); return; }
			if ( file.size > MAX_PDF_BYTES ) { toast( __( 'PDF too big (~10 MB max).' ), true ); return; }
			pdfToAttachment( file ).then( function ( a ) {
				state.attachments.push( a ); renderAttachments();
			} ).catch( function () { toast( __( 'Invalid PDF' ), true ); } );
			return;
		}

		fileToAttachment( file ).then( function ( a ) {
			state.attachments.push( a ); renderAttachments();
		} ).catch( function () { toast( __( 'Invalid image' ), true ); } );
	}

	// A PDF → raw base64 (no thumbnail: we show a document tile with the file name).
	function pdfToAttachment( file ) {
		return new Promise( function ( resolve, reject ) {
			var reader = new FileReader();
			reader.onload = function () {
				var dataUrl = String( reader.result );
				var b64 = dataUrl.indexOf( ',' ) !== -1 ? dataUrl.split( ',' )[ 1 ] : '';
				if ( ! b64 ) { reject(); return; }
				resolve( { mime: 'application/pdf', data: b64, name: file.name || 'document.pdf', kind: 'pdf', dataUrl: '' } );
			};
			reader.onerror = reject;
			reader.readAsDataURL( file );
		} );
	}

	// A video → base64 + a poster (the first frame) for the thumbnail.
	function videoToAttachment( file ) {
		return new Promise( function ( resolve, reject ) {
			var reader = new FileReader();
			reader.onload = function () {
				var dataUrl = reader.result;
				var base = { mime: file.type || 'video/mp4', data: String( dataUrl ).split( ',' )[ 1 ], name: file.name || 'video', kind: 'video', dataUrl: '' };
				var done = false;
				function finish( poster ) { if ( done ) { return; } done = true; base.dataUrl = poster || ''; resolve( base ); }

				var video = document.createElement( 'video' );
				video.preload = 'metadata'; video.muted = true;
				video.onloadedmetadata = function () {
					try { video.currentTime = Math.min( 0.1, ( video.duration || 1 ) / 2 ); } catch ( e ) { finish( '' ); }
				};
				video.onseeked = function () {
					try {
						var c = document.createElement( 'canvas' );
						var w = video.videoWidth || 160, h = video.videoHeight || 90, max = 160, r = Math.min( max / w, max / h, 1 );
						c.width = Math.round( w * r ); c.height = Math.round( h * r );
						c.getContext( '2d' ).drawImage( video, 0, 0, c.width, c.height );
						finish( c.toDataURL( 'image/jpeg', 0.7 ) );
					} catch ( e ) { finish( '' ); }
				};
				video.onerror = function () { finish( '' ); };
				video.src = dataUrl;
			};
			reader.onerror = reject;
			reader.readAsDataURL( file );
		} );
	}

	// Resizes on the client (long edge REF_MAX_EDGE at most) and re-encodes as JPEG to lighten the upload.
	// It ALSO keeps a high-resolution copy (long edge 2600px at most) used only for the per-section
	// crop: cutting from that one BEFORE going down to REF_MAX_EDGE recovers real detail on a very tall
	// full-page screenshot (a section band comes back sharp instead of squashed).
	function scaleToCanvas( img, max ) {
		var w = img.width, h = img.height;
		if ( w > max || h > max ) { var r = Math.min( max / w, max / h ); w = Math.round( w * r ); h = Math.round( h * r ); }
		var canvas = document.createElement( 'canvas' );
		canvas.width = w; canvas.height = h;
		canvas.getContext( '2d' ).drawImage( img, 0, 0, w, h );
		return canvas;
	}

	function fileToAttachment( file ) {
		return new Promise( function ( resolve, reject ) {
			var reader = new FileReader();
			reader.onload = function () {
				var img = new Image();
				img.onload = function () {
					var mime = 'image/jpeg';
					var sendUrl = scaleToCanvas( img, REF_MAX_EDGE ).toDataURL( mime, 0.85 );
					var fullUrl = scaleToCanvas( img, 2600 ).toDataURL( mime, 0.92 );
					resolve( {
						mime: mime,
						dataUrl: sendUrl,                    // the thumbnail + the whole upload
						data: sendUrl.split( ',' )[ 1 ],     // the base64 sent when NOT cropping
						full: fullUrl,                       // the high-resolution source for the crop
						name: file.name || 'image',
						kind: 'image',
					} );
				};
				img.onerror = reject;
				img.src = reader.result;
			};
			reader.onerror = reject;
			reader.readAsDataURL( file );
		} );
	}

	function setupDropZone( zone ) {
		function hasFiles( e ) {
			var dt = e.dataTransfer;
			return dt && dt.types && Array.prototype.indexOf.call( dt.types, 'Files' ) !== -1;
		}
		zone.addEventListener( 'dragover', function ( e ) {
			if ( ! hasFiles( e ) ) { return; }
			e.preventDefault();
			dom.dropOverlay.style.display = 'flex';
		} );
		zone.addEventListener( 'dragleave', function ( e ) {
			if ( ! zone.contains( e.relatedTarget ) ) { dom.dropOverlay.style.display = 'none'; }
		} );
		zone.addEventListener( 'drop', function ( e ) {
			if ( ! hasFiles( e ) ) { return; }
			e.preventDefault();
			dom.dropOverlay.style.display = 'none';
			var files = ( e.dataTransfer && e.dataTransfer.files ) || [];
			for ( var i = 0; i < files.length; i++ ) { addAttachment( files[ i ] ); }
		} );
	}

	function renderAttachments() {
		dom.attachStrip.innerHTML = '';
		if ( ! state.attachments.length ) { dom.attachStrip.style.display = 'none'; return; }
		dom.attachStrip.style.display = 'flex';
		state.attachments.forEach( function ( a, idx ) {
			var thumb = el( 'div', 'aipb-thumb' + ( a.kind === 'video' ? ' video' : ( a.kind === 'pdf' ? ' pdf' : '' ) ) );
			thumb.title = a.name || '';
			if ( a.dataUrl ) {
				var img = el( 'img' ); img.src = a.dataUrl; img.alt = a.name; thumb.appendChild( img );
			} else {
				var ph = el( 'div', 'aipb-thumb-ph' );
				ph.innerHTML = 'video' === a.kind ? SVG_VIDEO : ( 'pdf' === a.kind ? SVG_DOC : SVG_PICTURE );
				thumb.appendChild( ph );
			}
			if ( a.kind === 'video' ) { thumb.appendChild( el( 'span', 'aipb-thumb-badge', '▶' ) ); }
			else if ( a.kind === 'pdf' ) { thumb.appendChild( el( 'span', 'aipb-thumb-badge', __( 'PDF' ) ) ); }
			var x = el( 'button', 'aipb-thumb-x', '✕' );
			x.title = __( 'Remove' );
			x.addEventListener( 'click', function () { state.attachments.splice( idx, 1 ); renderAttachments(); } );
			thumb.appendChild( x );
			dom.attachStrip.appendChild( thumb );
		} );
	}

	/* ----------------------- Picking an element (targeting) ---------------- */

	var pickMode = false;

	function sendPickMode( on ) {
		try { dom.frame.contentWindow.postMessage( { aipb: 'pickmode', on: !! on }, '*' ); } catch ( e ) {}
	}
	function togglePickMode() {
		var hasContent = pageMode() ? state.blocks.length : !! state.template;
		if ( ! hasContent ) { toast( __( 'Generate a section to change first.' ) ); return; }
		pickMode = ! pickMode;
		sendPickMode( pickMode );
		refreshPickBtn();
		if ( pickMode ) { toast( __( 'Click an element to change in the preview.' ) ); }
	}
	function refreshPickBtn() {
		if ( ! dom.pickBtn ) { return; }
		dom.pickBtn.classList.toggle( 'active', pickMode );
		dom.pickBtn.setAttribute( 'aria-pressed', pickMode ? 'true' : 'false' );
	}
	/**
	 * An element has been picked in the preview.
	 *
	 * IT TAKES THE SECTION IN HAND, and that is the part that was missing. Picking used to set the
	 * target and nothing else: the block stayed unselected, so currentTemplate() was empty, so the
	 * only act that can act on an element — Touch-up — was not even in the bar. What was on offer
	 * instead were the page's acts, and choosing one of them sent «apply the change ONLY to this
	 * specific element» into a whole-page plan or into writing a brand new section. Not an error,
	 * not a warning: a full generation, sometimes N of them, spent on a request that had nothing to
	 * do with what was asked.
	 *
	 * The id comes from the preview, which knows the ancestor `[data-aipb-block]` of whatever was
	 * clicked. When it does not (a pattern, a piece of the theme, a component with no blocks) the
	 * selection is left alone and availableTiers() falls back: crosshair or not, you cannot touch up
	 * something that is not there.
	 */
	function onElementPicked( label, snippet, id ) {
		pickMode = false; refreshPickBtn();
		state.pickTarget = { label: label || 'element', snippet: snippet || '' };
		if ( id ) { selectBlockById( id ); }
		renderPickChip();
		renderTierBar();
		refreshSendButton();
		updateComposerHint();
		if ( dom.input ) { dom.input.focus(); }
	}

	/**
	 * Takes a block in hand WITHOUT moving the sidebar.
	 *
	 * focusBlock() ends on setTab('fields'), which is right for a click — a click on a section says
	 * «show me this one» — and wrong here: the crosshair says «I am about to type an instruction
	 * about this», and the eyes are going to the composer, not to the fields.
	 */
	function selectBlockById( id ) {
		if ( ! pageMode() || state.selectedBlockId === id ) { return; }
		var found = state.blocks.filter( function ( b ) { return b.id === id; } )[ 0 ];
		// The preview can be a moment older than the state: a section deleted a beat ago is still
		// drawn until the render lands.
		if ( ! found ) { return; }
		state.selectedBlockId = id;
		state.markedBlockIds = [];
		renderBlocks(); renderFields(); refreshCodeIfOpen();
	}

	function clearPickTarget() {
		state.pickTarget = null;
		renderPickChip();
		// The other acts come back with it: the bar has to say so the moment the ✕ is pressed.
		renderTierBar();
		refreshSendButton();
		updateComposerHint();
	}
	function renderPickChip() {
		if ( ! dom.pickChip ) { return; }
		dom.pickChip.innerHTML = '';
		if ( ! state.pickTarget ) { dom.pickChip.style.display = 'none'; return; }
		dom.pickChip.style.display = 'flex';
		var pico = el( 'span', 'aipb-pickchip-ico' );
		pico.innerHTML = SVG_TARGET;
		dom.pickChip.appendChild( pico );
		dom.pickChip.appendChild( el( 'span', 'aipb-pickchip-lbl', state.pickTarget.label ) );
		var x = el( 'button', 'aipb-pickchip-x', '✕' );
		x.type = 'button'; x.title = __( 'Clear the selection' );
		x.addEventListener( 'click', clearPickTarget );
		dom.pickChip.appendChild( x );
	}

	function updateComposerHint() {
		if ( pageMode() ) {
			var b = activeBlock();
			var marked = uniteTargets();
			// A marked set is the loudest thing about the current state, and it is built in another
			// panel: without saying so here, the composer would go on talking about one section
			// while «Unite» is about to act on four.
			if ( marked.length > 1 ) {
				dom.input.placeholder = sprintf( __( 'How to merge the %d marked sections…' ), marked.length );
				dom.hint.innerHTML = sprintf(
					__( '<b>%d sections</b> marked · «Unite», under Actions, merges them into one · ctrl/cmd+click to change the set' ),
					marked.length
				);
			} else if ( b ) {
				dom.input.placeholder = sprintf( __( 'Change the "%s" block…' ), b.name );
				dom.hint.innerHTML = sprintf( __( 'You are editing <b>%s</b> · the buttons above say what the next message will do · click the block again to deselect it' ), escapeAllowCode( b.name ) );
			} else {
				dom.input.placeholder = __( 'Describe the page, or add a section…' );
				dom.hint.innerHTML = __( 'You are acting on the whole page · pick a block to work on one section · <code>/help</code>' );
			}
		} else {
			dom.input.placeholder = __( 'Write what you want to create, or "/" for the commands…' );
			dom.hint.innerHTML = __( 'Enter to send · Shift+Enter for a new line · <code>/help</code>' );
		}
		renderTargetRow();
		renderTierBar();
	}

	/**
	 * The row that says what the next message will change — and offers the way out of it.
	 *
	 * It only appears with a section SELECTED, which is the ambiguous state: with nothing selected
	 * the composer is already talking about the page and there is nothing to warn about. The two
	 * controls are the two things somebody in that state might want and could not previously reach
	 * from here: stop editing this one, or start a new one.
	 */
	function renderTargetRow() {
		if ( ! dom.targetRow ) { return; }
		var b = pageMode() ? activeBlock() : null;
		dom.targetRow.innerHTML = '';
		if ( ! b ) { dom.targetRow.style.display = 'none'; return; }
		dom.targetRow.style.display = 'flex';

		var chip = el( 'span', 'aipb-target-chip' );
		var tico = el( 'span', 'aipb-target-ico' );
		tico.innerHTML = SVG_EDIT;
		chip.appendChild( tico );
		chip.appendChild( el( 'span', 'aipb-target-lbl', b.name || __( 'Section' ) ) );
		chip.title = sprintf( __( 'What you write changes «%s». It does not create a new section.' ), b.name || __( 'Section' ) );
		dom.targetRow.appendChild( chip );

		var out = el( 'button', 'aipb-target-x', '✕' );
		out.type = 'button';
		out.title = __( 'Stop editing this section and act on the whole page' );
		out.setAttribute( 'aria-label', out.title );
		out.addEventListener( 'click', function () {
			state.selectedBlockId = null;
			renderBlocks(); updateComposerHint();
			dom.input.focus();
		} );
		dom.targetRow.appendChild( out );

		var add = el( 'button', 'aipb-target-new', '+ ' + __( 'New section' ) );
		add.type = 'button';
		add.title = __( 'Deselect and describe a section to add to the page' );
		add.addEventListener( 'click', function () {
			state.selectedBlockId = null;
			renderBlocks(); updateComposerHint();
			setTab( 'chat' );
			botMsg( __( 'Describe the section to add.' ) );
			dom.input.focus();
		} );
		dom.targetRow.appendChild( add );
	}

	/* ------------------------------ Commands ------------------------------- */

	function runCommand( text ) {
		var parts = text.split( /\s+/ );
		var name = parts[ 0 ].toLowerCase();
		var rest = text.slice( parts[ 0 ].length ).trim();

		switch ( name ) {
			case '/new': newSection( rest ); break;
			case '/save': save(); break;
			case '/model':
				if ( ! AIPB.canModel ) {
					botMsg( __( 'Creating content types needs an administrator.' ), 'warn' );
					break;
				}
				if ( ! rest ) { botMsg( __( 'Usage: `/model a Products type with price and photo`' ) ); break; }
				setBusy( true );
				ensureContentTypes( rest ).then( function () { setBusy( false ); } ).catch( genError );
				break;
			case '/title':
				if ( ! rest ) { botMsg( __( 'Usage: `/title New name`' ) ); break; }
				state.title = rest; dom.title.value = rest; state.dirty = true; refreshSaveState();
				botMsg( sprintf( __( 'Title set: `%s`' ), rest ) );
				break;
			case '/blocks':
				if ( ! pageMode() ) { botMsg( __( 'Blocks only exist in a page context.' ), 'warn' ); break; }
				setTab( 'blocks' );
				break;
			case '/consistency': runConsistencyPass( rest ); break;
			case '/split': runSplitCommand( rest ); break;
			case '/unite': runUniteCommand( rest ); break;
			case '/import': runImportCommand( rest ); break;
			case '/tidy': tidySection(); break;
			case '/fields': setTab( 'fields' ); break;
			case '/preview': schedulePreview( 0 ); break;
			case '/list': openSections(); break;
			case '/clear': state.messages = []; renderMessages(); break;
			case '/help': helpMsg(); break;
			default: botMsg( __( 'Unknown command. Write `/help` for the list.' ), 'warn' );
		}
	}

	function helpMsg() {
		var lines = COMMANDS.map( function ( c ) {
			return '`' + c.cmd + ( c.arg ? ' ' + c.arg : '' ) + '` — ' + c.desc;
		} ).join( '\n' );
		botMsg( __( 'Available commands:' ) + '\n' + lines );
	}

	/* ---------------------------- Generation ------------------------------- */

	/* -------- Intent tiers: Touch-up (patch) / Section / Global ------------ */

	/**
	 * WHAT THE NEXT MESSAGE WILL DO — the seven acts, and nothing between them and the user.
	 *
	 * HOW THIS USED TO WORK, and why it does not any more. Six regular expressions read the sentence
	 * being typed and chose the act from it. It was a good idea badly placed: three of the six had a
	 * chip in the bar that lit up as you typed, so a wrong reading was caught by the eye and fixed
	 * with a click — and three had NOTHING. «Add a section» decided between adding one and
	 * overwriting the selected one; a page-shaped verb decided between ONE call and a dozen; a
	 * content-type word created post types on the site. Three silent decisions taken from words, in
	 * a product whose interface speaks four languages and whose prompts write in forty.
	 *
	 * So the acts are chosen, never deduced. What makes that bearable — a choice before every
	 * message is a tax paid constantly — is that the SELECTION already decides which acts are even
	 * possible, and the bar shows those and only those:
	 *
	 *   nothing selected   →  Add · Whole page · Global
	 *   one section        →  Touch-up · Regenerate · Split
	 *   two or more marked →  … · Unite
	 *   a pattern          →  Touch-up · Regenerate
	 *
	 * Three at a time, never a greyed-out chip, never a door to open. You do not choose from seven:
	 * you look at what can be done to the thing you have in front of you.
	 *
	 * AND THE CHOICE RESETS WITH THE SELECTION, which is the part that answers «when do I have to
	 * change it». A mode that survives a change of target is the classic way of arriving at «why did
	 * it do that»: you set Global four messages ago, forgot, and every message since has cost one
	 * call per section. Here picking another block puts you back on that block's safe default, so the
	 * only moment worth a thought is when you want something OTHER than the obvious thing — and the
	 * obvious thing is written on the send button.
	 *
	 * The default of each group is the cheapest and the least destructive of it. Guessing small is
	 * recoverable; guessing big is not.
	 */
	var TIERS = [
		{
			key: 'patch', label: __( 'Touch-up' ),
			desc: __( 'Change one detail of the selected section. Few tokens, and the rest of the section is left alone.' ),
			cost: __( 'The cheapest there is: the model is sent the section and a find/replace comes back.' ),
			needs: __( 'A section selected in the blocks list or in the preview.' ),
			when: function () { return !! currentTemplate(); },
		},
		{
			key: 'section', label: __( 'Regenerate' ),
			desc: __( 'Rewrite the selected section from scratch: another layout, another copy, the same place in the page.' ),
			cost: __( 'One generation.' ),
			needs: __( 'A section selected in the blocks list or in the preview.' ),
			loses: __( 'The section is rewritten whole, so the style touch-ups made by hand with the Style panel go. You are warned first when there are any, and undo brings everything back.' ),
			when: function () { return !! currentTemplate(); },
		},
		{
			key: 'split', label: __( 'Split' ),
			desc: __( 'Break the selected section into several distinct sections of the page — a hero that had become three bands becomes three blocks you can move and regenerate one at a time.' ),
			cost: __( 'One generation.' ),
			needs: __( 'A section selected.' ),
			when: canSplit,
		},
		{
			key: 'unite', label: __( 'Unite' ),
			desc: __( 'Merge the marked sections into ONE, keeping every content.' ),
			cost: __( 'One generation.' ),
			needs: __( 'Two or more sections marked with ctrl/cmd+click in the blocks list.' ),
			loses: __( 'The sections that go in leave the page: what arrives is one section in their place. Nothing is thrown away — every content is carried across — but the boundaries between them are gone, and only undo puts them back.' ),
			when: canUnite,
		},
		{
			key: 'add', label: __( 'Add a section' ),
			desc: __( 'Describe a section and it is written and appended at the bottom of the page. Move it afterwards from the blocks list.' ),
			cost: __( 'One generation.' ),
			needs: __( 'No section selected — click the selected one again to let go of it.' ),
			when: function () { return pageMode() && ! currentTemplate() && builtSections() > 0; },
		},
		{
			key: 'page', label: __( 'Whole page' ),
			desc: __( 'Describe the page and it is broken down into sections, which are then written in parallel.' ),
			cost: __( 'One call to plan it, plus one per section: the most expensive thing here. The plan is SHOWN before anything is built, and you take out the sections you do not want — those cost nothing.' ),
			needs: __( 'No section selected. On an empty page this is the only act there is, because there is nothing yet to add to.' ),
			when: function () { return pageMode() && ! currentTemplate(); },
		},
		{
			key: 'global', label: __( 'Global' ),
			desc: __( 'One instruction carried across every section of the page — «the headings are too big», «give the cards more air» — so they agree with each other instead of being aligned one at a time.' ),
			cost: __( 'One call per section of the page. It asks before starting, and the send button carries the number.' ),
			needs: __( 'No section selected, and at least two sections to align.' ),
			loses: __( 'Nothing directly: a section the instruction does not concern comes back as it was. But every section is passed to the model, so hand-made style touch-ups can be rewritten along the way.' ),
			when: function () { return ! currentTemplate() && canGlobal(); },
		},
	];

	/*
	 * THE PAGE'S ACTS ARE OFFERED WHENEVER THERE IS NO SECTION IN HAND, and «in hand» is
	 * currentTemplate() rather than activeBlock() for one case that would otherwise leave the bar
	 * EMPTY: a `ref` block — a saved pattern placed in the page — can be selected like any other,
	 * and there is nothing to do to it from here, because it is edited where it lives. Read as «a
	 * block is selected», that state offered the section's acts on a section that is not there and
	 * hid the page's; read as «nothing editable is in hand», it correctly offers the page's.
	 */

	/** How many real sections the page has been built out of. */
	function builtSections() {
		return state.blocks.filter( function ( b ) { return isInline( b ); } ).length;
	}

	/**
	 * The acts possible right now, in the order they are offered.
	 *
	 * A HELD CROSSHAIR LEAVES ONE. Pointing at an element is not a preference about how to work, it
	 * is the whole request: «this paragraph, here». Every other act contradicts it — Whole page
	 * plans a page and pays for each of its sections, Add a section writes one more at the bottom,
	 * Global carries the instruction across all of them — and the target does not stop them, it
	 * merely rides along inside the prompt as «apply the change ONLY to this specific element»,
	 * which is a sentence a page planner cannot obey and will not refuse. What comes back is a
	 * generation nobody asked for, at the price of the act that was chosen.
	 *
	 * The fallback matters as much as the rule: if Touch-up cannot run — nothing in hand, a pick
	 * that resolved to no section — the bar goes back to the ordinary list rather than emptying.
	 * Showing no acts at all because of a target we cannot honour would be a worse lie than the one
	 * this fixes.
	 */
	function availableTiers() {
		var can = TIERS.filter( function ( t ) { return t.when(); } );
		if ( ! state.pickTarget ) { return can; }
		var patch = can.filter( function ( t ) { return 'patch' === t.key; } );

		return patch.length ? patch : can;
	}

	/**
	 * The act a group falls back to when nothing has been chosen: the cheapest one it has.
	 *
	 * On an empty page that is «Whole page», and it is not an exception to the rule but the same
	 * rule read properly: there is nothing yet to add a section to, and «describe the page» is
	 * exactly the request somebody arrives with.
	 */
	function defaultTier() {
		var have = availableTiers().map( function ( t ) { return t.key; } );
		// Merging leads, and it is the one entry that is not there for cheapness: marking sections
		// with ctrl/cmd+click is a gesture with exactly one purpose, so somebody who has marked two
		// has already said what they want. Everything after it is ordered by what it costs and by
		// how much of somebody's work it can undo.
		var order = [ 'unite', 'patch', 'add', 'page', 'section', 'split', 'global' ];
		for ( var i = 0; i < order.length; i++ ) {
			if ( have.indexOf( order[ i ] ) >= 0 ) { return order[ i ]; }
		}

		return 'section';
	}

	/**
	 * WHICH THING the acts on offer apply to. When this changes, the choice goes back to the default.
	 *
	 * The block's id is part of it on purpose: moving from one section to another is a change of
	 * subject, and carrying «Regenerate» across it would regenerate the wrong section on the next
	 * message — with nothing having said so.
	 */
	function tierContext() {
		// Picking an element, and letting go of it, are both changes of subject: «Whole page» chosen
		// a minute ago must not survive the crosshair, and Touch-up must not outlive the ✕ that
		// cleared the thing it was going to touch up.
		var pick = state.pickTarget ? '+pick' : '';
		if ( ! pageMode() ) { return 'component' + pick; }
		if ( canUnite() && ! state.pickTarget ) { return 'marked:' + uniteTargets().length; }
		var b = activeBlock();

		return ( b ? 'block:' + b.id : 'page' ) + pick;
	}

	/**
	 * The act the next message will perform. No text is read: it is what was chosen, or the group's
	 * default when nothing was — or when what was chosen no longer applies to what is selected.
	 */
	function currentTier() {
		var ctx = tierContext();
		if ( state.tierContext !== ctx ) {
			state.tierContext = ctx;
			state.tier = null;
		}

		var have = availableTiers().map( function ( t ) { return t.key; } );
		if ( state.tier && have.indexOf( state.tier ) >= 0 ) { return state.tier; }

		state.tier = null;

		return defaultTier();
	}

	function tierByKey( key ) {
		return TIERS.filter( function ( t ) { return t.key === key; } )[ 0 ] || null;
	}

	/**
	 * Is the request about a design system VALUE?
	 *
	 * A brand colour, a font, a radius, a shadow, the spacing between sections, the content's width:
	 * they are all declared once in Design Tokens, where changing them holds across the whole site,
	 * is instantaneous and costs no generation. Asking the global pass for them instead means paying
	 * N calls to the model to rewrite by hand, section after section, a number that could have
	 * changed on its own.
	 *
	 * IT ROUTES NOTHING — it is the one reading of a sentence left in this file, and it only ever
	 * adds a line to a question somebody is already being asked. A suggestion inside a confirmation
	 * cannot take a decision away from anybody.
	 */
	var TOKEN_TALK_RE = /\b(colori?|palette|brand|font|carattere|tipografic\w*|raggi\w*|arrotond\w*|ombr[ae]|spaziatur\w*|padding|margin[ei]?|interlinea|larghezz\w*|container|colou?rs?|typograph\w*|radi(us|i)|rounded|shadows?|spacing|line.height|widths?)\b/i;

	function looksLikeToken( text ) {
		return TOKEN_TALK_RE.test( text || '' );
	}

	function isPageLayer( b ) {
		return !! ( b && b.scope === 'page' );
	}

	/**
	 * Is this request an overlay that follows the whole page, not a section and not a page plan?
	 *
	 * A custom cursor, a scroll progress bar, particles behind the page: generating those as a
	 * section puts them in the flow, which is exactly what does not work. A landing that merely
	 * MENTIONS a cursor still goes through the plan — that is a page with an overlay in it, not
	 * an overlay on its own.
	 */
	var PAGE_LAYER_RE = /\b(cursore?\b|custom\s+cursor|progress\s*bar|barra\s+di\s+progresso|scroll\s+progress|particell[ae]|particles?\b|mascott[ea]|follows?\s+(the\s+)?scroll|segue\s+(lo\s+)?scroll|page[\s-]?wide|full[\s-]?page\s+(fx|effect|overlay|cursor)|overlay\s+(di\s+pagina|full))\b/i;
	var PAGE_BUILD_RE = /\b(landing|homepage|home\s*page|sito\s+web|whole\s+page|intera\s+pagina|pagina\s+con|page\s+with|sezioni|sections)\b/i;

	function looksLikePageLayer( text ) {
		if ( PAGE_BUILD_RE.test( text || '' ) ) { return false; }

		return PAGE_LAYER_RE.test( text || '' );
	}

	function canSplit() {
		var b = activeBlock();
		return !! ( pageMode() && b && b.type === 'inline' && b.template && ! isPageLayer( b ) );
	}

	/**
	 * The sections Unite would merge: the selected one, plus the ones marked with ctrl/cmd+click.
	 *
	 * THE ORDER IS THE PAGE'S, never the clicks'. What comes out is one section read from top to
	 * bottom, and the only order a reader will ever see is the one the page already has: merging
	 * «Footer, Hero» because that is the order they were clicked in would silently reorder the
	 * content of somebody's page.
	 *
	 * The marks are resolved against the CURRENT list at every call, so a marked section that has
	 * since been deleted — or swallowed by an earlier merge — simply stops counting, and nothing has
	 * to remember to clean up after it. A saved component (`ref`) is not merge-able: there is no
	 * code here to merge, it lives in its own section.
	 */
	function uniteTargets() {
		if ( ! pageMode() ) { return []; }

		var wanted = {};
		( state.markedBlockIds || [] ).forEach( function ( id ) { wanted[ id ] = true; } );
		if ( state.selectedBlockId ) { wanted[ state.selectedBlockId ] = true; }

		return state.blocks.filter( function ( b ) {
			return wanted[ b.id ] && b.type === 'inline' && b.template && ! isPageLayer( b );
		} );
	}

	// Merging one section with itself is not a merge: two is the floor.
	function canUnite() {
		return uniteTargets().length >= 2;
	}

	/**
	 * Is the request about a design system VALUE?
	 *
	 * A brand colour, a font, a radius, a shadow, the spacing between sections, the content's width:
	 * they are all declared once in Design Tokens, where changing them holds across the whole site,
	 * is instantaneous and costs no generation. Asking the global pass for them instead means paying
	 * N calls to the model to rewrite by hand, section after section, a number that could have
	 * changed on its own.
	 *
	 * It blocks nothing: it is a suggestion, and it only shows where the damage would be large (the
	 * fan-out). On a touch-up to a single section it would be noise, and indeed it does not appear.
	 */
	var TOKEN_TALK_RE = /\b(colori?|palette|brand|font|carattere|tipografic\w*|raggi\w*|arrotond\w*|ombr[ae]|spaziatur\w*|padding|margin[ei]?|interlinea|larghezz\w*|container|colou?rs?|typograph\w*|radi(us|i)|rounded|shadows?|spacing|line.height|widths?)\b/i;

	function looksLikeToken( text ) {
		return TOKEN_TALK_RE.test( text || '' );
	}

	/**
	 * The global fan-out only makes sense where there is something to align: a page with at least two
	 * inline sections. With a single section «global» is a touch-up that costs like a global pass.
	 *
	 * It decides whether the chip is OFFERED at all: an act that cannot be performed is not shown,
	 * so nothing in the bar can be clicked into a fallback.
	 */
	function canGlobal() {
		if ( ! pageMode() ) { return false; }

		return state.blocks.filter( function ( b ) { return b.type === 'inline' && b.template; } ).length >= 2;
	}

	// The template of the section currently editable (the selected inline block, or the component).
	function currentTemplate() {
		var block = activeBlock();
		if ( block ) { return block.type === 'inline' ? ( block.template || '' ) : ''; }
		return pageMode() ? '' : ( state.template || '' );
	}

	/**
	 * The bar: the acts possible RIGHT NOW, and the one that will happen.
	 *
	 * Rebuilt on every render rather than painted, because its content changes with the selection and
	 * not only its state. A chip that is present but disabled is a chip people click twice and then
	 * stop trusting; a chip that is simply not there for something you cannot do asks no questions.
	 */
	function renderTierBar() {
		if ( ! dom.tierBar ) { return; }

		var active = currentTier();
		dom.tierBar.innerHTML = '';

		availableTiers().forEach( function ( tier ) {
			var chip = el( 'button', 'aipb-tier', tier.label );
			chip.type = 'button';
			chip.title = tier.desc;
			chip.dataset.tier = tier.key;
			chip.setAttribute( 'aria-pressed', tier.key === active ? 'true' : 'false' );
			chip.classList.toggle( 'active', tier.key === active );
			chip.addEventListener( 'click', function () {
				state.tier = tier.key;
				renderTierBar();
				refreshSendButton();
				dom.input.focus();
			} );
			dom.tierBar.appendChild( chip );
		} );

		// The way IN to the acts that are not on offer right now. It matters more since the acts
		// became contextual: a chip you cannot use is no longer shown, which is right — a disabled
		// control with no explanation is a control people click twice and then distrust — but it
		// also means Split and Unite have nowhere left to be discovered. Somebody who has never
		// marked two sections has no way of learning that marking them does anything at all. This
		// button is where all seven live, whether or not they can be reached from here.
		var help = el( 'button', 'aipb-tier-info', '' );
		help.type = 'button';
		help.innerHTML = INFO_ICON;
		help.title = __( 'What each of these does, and what it costs' );
		help.setAttribute( 'aria-label', help.title );
		help.addEventListener( 'click', openTierGuide );
		dom.tierBar.appendChild( help );

		refreshSendButton();
	}

	var INFO_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4M12 8h.01"></path></svg>';

	/**
	 * All seven acts, explained: what each does, what it costs, what it needs, what it destroys.
	 *
	 * WHY A WINDOW AND NOT A TOOLTIP. Every chip already carries its one line on hover, and one line
	 * is the right size for «remind me». This answers the other question — «which of these do I
	 * want, and what will it cost me» — and that one is a comparison: it is answered by seeing them
	 * next to each other, with their prices in the same column. A tooltip can only ever show you the
	 * one you are already pointing at.
	 *
	 * THE ONES YOU CANNOT USE RIGHT NOW ARE HERE TOO, and they are the reason it exists. They say
	 * what to select to reach them, which is the only way left to find out that marking two sections
	 * with ctrl/cmd+click turns on an act that merges them.
	 */
	function openTierGuide() {
		var live = currentTier();
		var back = el( 'div', 'aipb-cp-back' );
		var box = el( 'div', 'aipb-cp aipb-guide' );

		var head = el( 'div', 'aipb-cp-head' );
		head.appendChild( el( 'span', 'aipb-cp-title', __( 'What the next message will do' ) ) );
		var x = el( 'button', 'aipb-cp-x', '×' );
		x.type = 'button';
		x.title = __( 'Close (Esc)' );
		head.appendChild( x );
		box.appendChild( head );

		var body = el( 'div', 'aipb-cp-body' );
		body.appendChild( el( 'p', 'aipb-guide-lede', __( 'The act is never guessed from what you write: it is the one lit up in the bar, and the send button carries its name. Which acts are on offer depends on what you have selected — so the list below says, for each one, how to reach it.' ) ) );

		TIERS.forEach( function ( tier ) {
			var on = tier.when();
			var row = el( 'div', 'aipb-guide-row' + ( on ? '' : ' is-off' ) );

			var top = el( 'div', 'aipb-guide-top' );
			var name = el( 'span', 'aipb-guide-name' + ( tier.key === live ? ' is-live' : '' ), tier.label );
			top.appendChild( name );
			if ( tier.key === live ) {
				top.appendChild( el( 'span', 'aipb-guide-tag is-live', __( 'what will happen now' ) ) );
			} else if ( on ) {
				top.appendChild( el( 'span', 'aipb-guide-tag', __( 'available' ) ) );
			}
			row.appendChild( top );

			row.appendChild( el( 'p', 'aipb-guide-desc', tier.desc ) );
			if ( tier.cost ) { row.appendChild( guideLine( __( 'Cost' ), tier.cost ) ); }
			if ( ! on && tier.needs ) { row.appendChild( guideLine( __( 'To reach it' ), tier.needs ) ); }
			if ( tier.loses ) { row.appendChild( guideLine( __( 'Careful' ), tier.loses, 'is-warn' ) ); }

			body.appendChild( row );
		} );

		box.appendChild( body );
		back.appendChild( box );
		root.appendChild( back );

		function close() {
			if ( back.parentNode ) { back.parentNode.removeChild( back ); }
			document.removeEventListener( 'keydown', onKeyClose, true );
		}
		function onKeyClose( e ) {
			if ( 'Escape' !== e.key ) { return; }
			e.stopPropagation();
			close();
		}

		x.addEventListener( 'click', close );
		back.addEventListener( 'mousedown', function ( e ) { if ( e.target === back ) { close(); } } );
		document.addEventListener( 'keydown', onKeyClose, true );
		x.focus();
	}

	/** One labelled line inside a guide row. */
	function guideLine( label, text, extra ) {
		var p = el( 'p', 'aipb-guide-line' + ( extra ? ' ' + extra : '' ) );
		p.appendChild( el( 'span', 'aipb-guide-lbl', label ) );
		p.appendChild( document.createTextNode( text ) );

		return p;
	}
	/* -------- Fidelity modes: Faithful / Balanced / Creative -------------- */

	// How closely the AI has to follow the reference/instruction. On the server side it governs the
	// temperature (where the provider accepts one) and a prompt nudge (which works on Claude too).
	var FIDELITIES = [
		{ key: 'faithful', label: __( 'Faithful' ), desc: __( 'Maximum adherence to the reference and the instruction: no reinterpretation.' ) },
		{ key: 'balanced', label: __( 'Balanced' ), desc: __( 'A compromise between adherence and stylistic freedom (the default).' ) },
		{ key: 'creative', label: __( 'Creative' ), desc: __( 'More freedom to reinterpret and enrich the style.' ) },
	];

	function loadFidelity() {
		var v;
		try { v = localStorage.getItem( 'aipb_fidelity' ); } catch ( e ) {}
		return ( v === 'faithful' || v === 'creative' ) ? v : 'balanced';
	}

	function setFidelity( key ) {
		if ( ! FIDELITIES.some( function ( f ) { return f.key === key; } ) ) { return; }
		state.fidelity = key;
		try { localStorage.setItem( 'aipb_fidelity', key ); } catch ( e ) {}
		renderFidelitySwitch();
		if ( dom.input ) { dom.input.focus(); }
	}

	function buildFidelitySwitch() {
		if ( ! dom.fidSwitch ) { return; }
		dom.fidSwitch.innerHTML = '';
		FIDELITIES.forEach( function ( f ) {
			var b = el( 'button', 'aipb-fid-btn', f.label );
			b.type = 'button';
			b.dataset.fid = f.key;
			b.title = f.desc;
			b.addEventListener( 'click', function () { setFidelity( f.key ); } );
			dom.fidSwitch.appendChild( b );
		} );
		renderFidelitySwitch();
	}

	function renderFidelitySwitch() {
		if ( ! dom.fidSwitch ) { return; }
		Array.prototype.forEach.call( dom.fidSwitch.children, function ( b ) {
			b.classList.toggle( 'active', b.dataset.fid === state.fidelity );
		} );
	}

	/* ------------------------ Variants: 1, or 2–3 ------------------------- */

	/*
	 * WHY IT IS OFF UNTIL SOMEBODY TURNS IT ON, and stays visible once it is.
	 *
	 * Beauty is not specified, it is chosen: one shot means describing what you want to somebody who
	 * cannot see your face, and then arguing with the result. Two or three side by side turn that
	 * into a decision, which is the thing a person is actually good at.
	 *
	 * But it MULTIPLIES what a page costs, and a cost that grows without being asked for is the kind
	 * of feature people find out about from the bill. So: off by default, switched on deliberately,
	 * with the multiplier written on the control itself rather than buried in a tooltip — and said
	 * out loud, once, the first time it is turned on.
	 *
	 * ONLY WHEN A WHOLE SECTION IS BEING DESIGNED. A Touch-up in three versions is three ways of
	 * moving a button; a page plan in three versions is thirty generations and a choice nobody can
	 * hold in their head. Variants belong where the question is open — this section, what should it
	 * look like — which is the Section tier and nowhere else.
	 */
	/* VARIANT_COUNTS is declared at the top of the file, next to the state it is read by: see the
	   note there for why it cannot live here. */

	function loadVariantPrefs() {
		var on = false, n = 3;
		try {
			on = 'on' === localStorage.getItem( 'aipb_variants' );
			n  = parseInt( localStorage.getItem( 'aipb_variants_n' ), 10 );
		} catch ( e ) {}

		return { on: on, n: VARIANT_COUNTS.indexOf( n ) >= 0 ? n : 3 };
	}

	function saveVariantPrefs() {
		try {
			localStorage.setItem( 'aipb_variants', state.variantsOn ? 'on' : 'off' );
			localStorage.setItem( 'aipb_variants_n', String( state.variantsN ) );
		} catch ( e ) {}
	}

	/** Whether the user has already been told what this costs. */
	function variantsWarned() {
		try { return 'yes' === localStorage.getItem( 'aipb_variants_warned' ); } catch ( e ) { return false; }
	}

	function setVariantsOn( on ) {
		// The warning is shown ONCE, and BEFORE the first switch-on takes effect: telling somebody
		// what something costs after they have started paying for it is not telling them.
		if ( on && ! variantsWarned() ) {
			var m = botMsg( '⎇ ' + sprintf(
				__( 'With variants on, every section you design is generated %d times and you pick one.' ),
				state.variantsN
			) + ' ' + sprintf(
				__( 'It costs %d× the tokens of a normal generation, every time, until you switch it back off.' ),
				state.variantsN
			) + ' ' + __( 'It only applies when a whole section is being designed — not to touch-ups, and not to a whole-page plan.' ) );

			m.actions = [
				{
					label: __( 'Got it, switch it on' ),
					primary: true,
					once: true,
					run: function () {
						try { localStorage.setItem( 'aipb_variants_warned', 'yes' ); } catch ( e ) {}
						state.variantsOn = true;
						saveVariantPrefs();
						renderVariantControl();
					},
				},
				{ label: __( 'Leave it off' ), once: true, run: function () {} },
			];
			renderMessages();

			return;
		}

		state.variantsOn = !! on;
		saveVariantPrefs();
		renderVariantControl();
	}

	function setVariantCount( n ) {
		if ( VARIANT_COUNTS.indexOf( n ) < 0 ) { return; }
		state.variantsN = n;
		saveVariantPrefs();
		renderVariantControl();
	}

	function buildVariantControl() {
		if ( ! dom.varSwitch ) { return; }
		dom.varSwitch.innerHTML = '';

		dom.varToggle = el( 'button', 'aipb-var-btn aipb-var-toggle' );
		dom.varToggle.type = 'button';
		dom.varToggle.addEventListener( 'click', function () { setVariantsOn( ! state.variantsOn ); } );
		dom.varSwitch.appendChild( dom.varToggle );

		VARIANT_COUNTS.forEach( function ( n ) {
			var b = el( 'button', 'aipb-var-btn aipb-var-n', '×' + n );
			b.type = 'button';
			b.dataset.n = String( n );
			b.title = sprintf( __( '%d variants for each section designed', 'aipb' ), n );
			b.addEventListener( 'click', function () { setVariantCount( n ); } );
			dom.varSwitch.appendChild( b );
		} );

		renderVariantControl();
	}

	function renderVariantControl() {
		if ( ! dom.varToggle ) { return; }

		dom.varToggle.textContent = state.variantsOn
			? sprintf( __( 'Variants ×%d' ), state.variantsN )
			: __( 'Variants' );
		dom.varToggle.classList.toggle( 'active', state.variantsOn );
		dom.varToggle.setAttribute( 'aria-pressed', state.variantsOn ? 'true' : 'false' );
		dom.varToggle.title = state.variantsOn
			? sprintf( __( 'On: every section designed costs %d generations instead of 1. Click to switch off.' ), state.variantsN )
			: __( 'Off. Generates several versions of a section so you can pick one — and costs one generation per version.' );

		Array.prototype.forEach.call( dom.varSwitch.children, function ( b ) {
			if ( ! b.dataset.n ) { return; }
			b.style.display = state.variantsOn ? '' : 'none';
			b.classList.toggle( 'active', parseInt( b.dataset.n, 10 ) === state.variantsN );
		} );
	}

	/**
	 * How many versions this send should produce. 1 = the ordinary single generation.
	 *
	 * The tier is the gate, and it is checked here rather than at the three call sites so that a
	 * route added later cannot quietly inherit variants it was never meant to have.
	 */
	function variantsFor( tier ) {
		return ( state.variantsOn && 'section' === tier ) ? state.variantsN : 1;
	}

	/* -------- The responsive preview: Desktop / Tablet / Mobile ----------- */

	var VIEWPORTS = [
		{ key: 'desktop', label: __( 'Desktop' ), width: 0,   icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
		{ key: 'tablet',  label: __( 'Tablet' ),  width: 768, icon: '<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M12 18h.01"/>' },
		{ key: 'mobile',  label: __( 'Mobile' ),  width: 390, icon: '<rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/>' },
	];

	function buildViewportSwitch() {
		dom.vpSwitch.innerHTML = '';
		VIEWPORTS.forEach( function ( vp ) {
			var b = el( 'button', 'aipb-vp-btn', '' );
			b.type = 'button';
			b.dataset.vp = vp.key;
			b.title = vp.label + ( vp.width ? ' (' + vp.width + 'px)' : '');
			b.setAttribute( 'aria-label', vp.label );
			b.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + vp.icon + '</svg>';
			b.addEventListener( 'click', function () { setViewport( vp.key ); } );
			dom.vpSwitch.appendChild( b );
		} );
		renderViewportSwitch();
	}

	function setViewport( key ) {
		state.viewport = key;
		applyViewport();
		renderViewportSwitch();
	}

	function renderViewportSwitch() {
		if ( ! dom.vpSwitch ) { return; }
		Array.prototype.forEach.call( dom.vpSwitch.children, function ( b ) {
			b.classList.toggle( 'active', b.dataset.vp === state.viewport );
		} );
	}

	// It constrains the iframe's width to simulate the device; desktop = fluid.
	function applyViewport() {
		if ( ! dom.previewWrap || ! dom.frame ) { return; }
		var vp = VIEWPORTS.filter( function ( v ) { return v.key === state.viewport; } )[ 0 ] || VIEWPORTS[ 0 ];
		var isDevice = vp.width > 0;
		dom.previewWrap.classList.toggle( 'is-device', isDevice );
		dom.frame.style.width = isDevice ? ( vp.width + 'px' ) : '';
	}

	function sendInstruction( text ) {
		// A web address on its own, with nothing else said about it, is not a brief: nobody
		// describes what they want by pasting a link. It is a request to rebuild that page, and
		// treating it as an instruction would send the URL to the model as prose and generate a
		// section ABOUT a link.
		if ( BARE_URL_RE.test( text.trim() ) && pageMode() && ! activeBlock() ) {
			runImportCommand( text.trim() );
			return;
		}

		var block = activeBlock();
		if ( block && block.type === 'ref' ) {
			userMsg( text );
			botMsg( sprintf( __( 'The "%s" block is a synced pattern. To change it here only, open it in the «Fields» tab and press «Unlink and edit here». To change it everywhere, open it from «Patterns» (☰).' ), block.name ), 'warn' );
			return;
		}

		// It consumes the attached screenshots: once for the submit, then it empties the strip.
		var attach = state.attachments.slice();
		var images = attach.map( function ( a ) { return { mime: a.mime, data: a.data }; } );
		// The source for the per-section crop: ONLY with a single reference image (the full-page
		// screenshot case). With several images/PDFs the whole reference is sent as it is.
		genCropRef = ( attach.length === 1 && attach[0].kind === 'image' && attach[0].full ) ? attach[0] : null;
		state.attachments = [];
		renderAttachments();

		// The skeleton only where a section is being WRITTEN — regenerated, appended, or planned —
		// because there the preview has nothing useful to show meanwhile. Not for a Touch-up nor a
		// Global pass: those leave a page on screen that is worth looking at while it changes.
		skeletonMode = ( [ 'section', 'add', 'page' ].indexOf( currentTier() ) >= 0 );

		// The element picked in the preview: we inject it into the AI's prompt (a targeted change),
		// showing only the user's text in the chat. Then the target is consumed.
		//
		// The preamble is written in the SAME LANGUAGE AS THE PROMPTS (English, see the service's
		// `Aura\Prompt\Composer`): it is glued in front of the instruction the model reads, not
		// shown to the user. It says nothing about which language the ANSWER comes back in — that
		// follows the site's locale, and it is the server that declares it.
		var aiText = text, target = state.pickTarget;
		if ( target ) {
			aiText = 'Apply the change ONLY to this specific element of the section (and to whatever is strictly necessary), NOT to the rest:\n'
				+ target.snippet + '\n\nInstruction: ' + text;
			clearPickTarget();
		}

		userMsg( text + ( target ? '  ·  ' + target.label : '' ), attach.map( function ( a ) { return { url: a.dataUrl, kind: a.kind, name: a.name }; } ) );
		beginGeneration(); // it opens the AbortController: the STOP button cancels this generation
		setBusy( true );
		refreshSaveState();
		renderMessages();

		routeInstruction( aiText, images );
	}

	// Routes the instruction to the right tier. Touch-up → a targeted patch; Section →
	// generate/regenerate; Global → a pass over every section.
	/**
	 * Sends the instruction to whichever act was chosen.
	 *
	 * WHAT USED TO BE HERE was the last of the sentence readers, and the most consequential of them:
	 * a pattern looking for «post type», «tassonomia», «Beitragstyp», and — on a match — creating
	 * those types on the site before generating. It read a request out of words and answered it with
	 * something PERMANENT, with no confirmation and nothing on screen that had said it was about to
	 * happen. Of the six readers it was the only one whose success wrote to the database.
	 *
	 * It has gone, and nothing replaces it in this path, because the capability was never missing —
	 * only its door was. Content types are made where they can be seen being made: the Content model
	 * page, `/model` from here, and the whole-page plan, which proposes the types a page needs and
	 * shows them before a single one is created.
	 */
	function routeInstruction( text, images ) {
		routeByTier( text, images );
	}

	/**
	 * A line of work in the chat that RESOLVES instead of doubling.
	 *
	 * It is born as «Adding the Products content type…» and becomes «Products content type added ✓»
	 * once the operation is over. Two bubbles per step tell the same thing and fill up the chat: a
	 * line that changes state reads as work progressing, two lines read as chatter.
	 *
	 * The text lives in the message and not in the DOM because renderMessages() rebuilds the list
	 * from scratch: writing into the bubble would make it disappear with the next message.
	 */
	function stepMsg( text ) {
		var m = botMsg( text );

		return {
			done: function ( t, kind ) { m.text = t; if ( kind ) { m.kind = kind; } renderMessages(); },
		};
	}

	/**
	 * Designs and creates the content types the request names, before generating.
	 *
	 * It goes through /model/propose — the same mode as the «Content model» page, which already
	 * knows what the site has and only proposes what is missing — and from there into createModel().
	 */
	function ensureContentTypes( request ) {
		var step = stepMsg( MARK.run + ' ' + __( 'Looking at which contents this request needs…' ) );

		// The brief is framed and not bare, for two reasons: it tells the model this is a specific
		// request and not the description of a site to design from scratch, and it clears the route's
		// minimum length even when the user writes «create the venues cpt». It is written in the
		// prompts' language (see the note in sendInstruction).
		var brief = 'While building a page of this site I have been asked for: ' + String( request || '' );

		return api( '/model/propose', { brief: brief }, undefined, abortSignal() )
			.then( function ( p ) {
				// The fields arrive in a separate object, indexed by type: the creation routes want
				// them inside the type, along with its initial contents.
				var types = ( ( p && p.types ) ? p.types : [] ).map( function ( t ) {
					return {
						slug: t.slug, singular: t.singular, plural: t.plural, archive: t.archive,
						fields: ( p.fields && p.fields[ t.slug ] ) ? p.fields[ t.slug ] : [],
						items: t.items || [],
					};
				} );
				var taxes = ( p && p.taxonomies ) ? p.taxonomies : [];

				if ( ! types.length && ! taxes.length ) {
					step.done( __( 'No new content type is needed: I am using the ones the site already has.' ) );
					( ( p && p.notes ) || [] ).forEach( function ( n ) { botMsg( n, 'warn' ); } );
					return;
				}

				step.done( MARK.done + ' ' + sprintf( __( 'This needs: %s.' ), modelRecap( types, taxes ) ) );
				( ( p && p.notes ) || [] ).forEach( function ( n ) { botMsg( n, 'warn' ); } );

				// No page design on this route: no page is being planned, a specific request is
				// being answered.
				return createModel( types, taxes );
			} )
			.catch( function ( err ) {
				if ( isAbort( err ) ) { throw err; }
				botMsg( sprintf( __( 'Content type not created: %s. I am going on with the request.' ), ( err && err.message ) ? err.message : __( 'unexpected error' ) ), 'warn' );
			} );
	}

	/**
	 * The names of a taxonomy's groups.
	 *
	 * A group can arrive as a string or as { name, fields }: the two forms coexist because almost
	 * always only the name is needed, and forcing the object form would have made the normal case
	 * unreadable. It is normalised once here, so nobody has to remember it elsewhere.
	 */
	function termNames( terms ) {
		return ( terms || [] ).map( function ( t ) {
			return ( t && typeof t === 'object' ) ? ( t.name || '' ) : String( t || '' );
		} ).filter( Boolean );
	}

	/** «Products (4 fields, 8 contents) and the Categories taxonomy (3 groups)» */
	function modelRecap( types, taxes ) {
		var bits = types.map( function ( t ) {
			var d = [];
			if ( ( t.fields || [] ).length ) { d.push( sprintf( _n( '%d field', '%d fields', t.fields.length ), t.fields.length ) ); }
			if ( ( t.items || [] ).length ) { d.push( sprintf( _n( '%d content', '%d contents', t.items.length ), t.items.length ) ); }
			return ( t.plural || t.slug ) + ( d.length ? ' (' + d.join( ', ' ) + ')' : '' );
		} );
		taxes.forEach( function ( tx ) {
			var d = [];
			var n = termNames( tx.terms ).length;
			if ( n ) { d.push( sprintf( _n( '%d group', '%d groups', n ), n ) ); }
			if ( ( tx.fields || [] ).length ) { d.push( sprintf( _n( '%d field', '%d fields', tx.fields.length ), tx.fields.length ) ); }
			bits.push( sprintf( __( 'the %s taxonomy' ), tx.plural || tx.slug )
				+ ( d.length ? ' (' + d.join( ', ' ) + ')' : '' ) );
		} );

		return bits.join( ' · ' );
	}

	/**
	 * Crea il modello dei dati raccontandolo passo per passo.
	 *
	 * ONE CALL PER OBJECT CREATED, and not a single one for everything. A single one would have
	 * worked just as well but would have produced a mute wait followed by a summary, and whoever is
	 * watching would not know whether it had stalled or was working. This way every line in the chat
	 * corresponds to a round that really finished: the type, then the taxonomy, then the contents.
	 *
	 * The price is N saves instead of one, each of which regenerates the permalinks. It is paid
	 * gladly: the types are four at most (the parser cuts them there), it happens once per request
	 * and not on every page load. The real cost of this whole affair is the call to the model that
	 * has just ended, not these writes.
	 *
	 * It never fails noisily: whatever goes wrong, the generation continues — the user gets their
	 * section anyway, with the lists written inside it.
	 *
	 * @return {Promise<{created: Array, skipped: Array}>} the types created and the ones left out,
	 *         which is what the sections need to know (see modelNote).
	 */
	function createModel( types, taxes, design, tokensSpec ) {
		var made = {};

		// The types one by one, then the taxonomies: those hook onto these, and a taxonomy arriving
		// first would not find the type to apply itself to.
		var chain = types.reduce( function ( c, t ) {
			return c.then( function () { return createType( t, made ); } );
		}, Promise.resolve() );

		// The slugs are for the route, not for the reader: a taxonomy announces itself with the
		// type's name as it appears in the sidebar.
		var labels = {};
		types.forEach( function ( t ) { labels[ t.slug ] = t.plural || t.slug; } );

		chain = taxes.reduce( function ( c, tx ) {
			return c.then( function () { return createTaxonomy( tx, labels ); } );
		}, chain );

		// The CONTENTS last, and only for the types really created: on a type skipped because it
		// already existed, whoever put the contents in put them in, and it is not our business.
		chain = types.reduce( function ( c, t ) {
			if ( ! ( t.items || [] ).length ) { return c; }
			return c.then( function () { return made[ t.slug ] ? createContents( t ) : null; } );
		}, chain );

		// The PAGES of those contents: the single item and, if the type has an archive, the list.
		// Before the menu, on purpose: the menu makes the archive reachable, and you do not put up a
		// sign towards a room that is not furnished yet.
		chain = types.reduce( function ( c, t ) {
			return c.then( function () { return made[ t.slug ] ? buildTemplates( t, design, tokensSpec ) : null; } );
		}, chain );

		// And finally the MENU, for the types that have an archive: without it, that page exists as
		// an address and no link on the site leads there.
		chain = types.reduce( function ( c, t ) {
			if ( ! t.archive ) { return c; }
			return c.then( function () { return made[ t.slug ] ? addToMenu( t ) : null; } );
		}, chain );

		return chain.then( function () {
			return {
				created: types.filter( function ( t ) { return made[ t.slug ]; } ),
				skipped: types.filter( function ( t ) { return ! made[ t.slug ]; } ),
			};
		} );
	}

	/** ONE content type, with its fields. It marks `made` when it worked. */
	function createType( t, made ) {
		var name = t.plural || t.slug;
		var step = stepMsg( MARK.run + ' ' + sprintf( __( 'Adding the %s content type…' ), name ) );

		return api( '/model/apply', { model: { types: [ {
			slug: t.slug, singular: t.singular, plural: t.plural, archive: t.archive, fields: t.fields,
		} ] } }, undefined, abortSignal() )
			.then( function ( r ) {
				var c = ( r && r.types && r.types[ 0 ] ) ? r.types[ 0 ] : null;
				if ( ! c ) {
					step.done( sprintf( __( '%s not added: it already exists, or the save did not go through.' ), name ), 'warn' );
					( ( r && r.notes ) || [] ).forEach( function ( n ) { botMsg( n, 'warn' ); } );
					return;
				}
				made[ t.slug ] = true;

				// The fields are named: they are the boxes the user will find on opening a content,
				// and seeing them listed here is what tells them whether the model understood.
				var labels = ( t.fields || [] ).map( function ( f ) { return f.label || f.key; } );
				step.done( MARK.done + ' ' + sprintf( __( '%s content type added' ), name )
					+ ( labels.length
						? ' — ' + sprintf( _n( '%d field: %s', '%d fields: %s', labels.length ), labels.length, labels.join( ', ' ) )
						: '' ) + '.' );
				( ( r && r.notes ) || [] ).forEach( function ( n ) { botMsg( n, 'warn' ); } );
			} )
			.catch( function ( err ) {
				// STOP: propagate it, the .catch upstream collects it — the only place where
				// «– stopped» is said once and only once.
				if ( isAbort( err ) ) { throw err; }
				step.done( sprintf( __( '%1$s not added: %2$s' ), name, ( err && err.message ) ? err.message : __( 'unexpected error' ) ), 'warn' );
			} );
	}

	/** ONE taxonomy, with its groups, hooked to its types. */
	function createTaxonomy( tx, labels ) {
		var name = tx.plural || tx.slug;
		var on   = ( tx.types || [] ).map( function ( s ) { return ( labels && labels[ s ] ) || s; } ).join( ', ' );
		var step = stepMsg( MARK.run + ' ' + sprintf( __( 'Adding the %1$s taxonomy to %2$s…' ), name, on ) );

		return api( '/model/apply', { model: { taxonomies: [ tx ] } }, undefined, abortSignal() )
			.then( function ( r ) {
				var c = ( r && r.taxonomies && r.taxonomies[ 0 ] ) ? r.taxonomies[ 0 ] : null;
				if ( ! c ) {
					step.done( sprintf( __( 'The %s taxonomy was not added: it already exists, or it applied to no type.' ), name ), 'warn' );
				} else {
					var groups = termNames( tx.terms );
					var fields = ( tx.fields || [] ).map( function ( f ) { return f.label || f.key; } );
					step.done( MARK.done + ' ' + sprintf( __( 'The %s taxonomy was added' ), name )
						+ ( groups.length ? ' — ' + sprintf( _n( '%d group: %s', '%d groups: %s', groups.length ), groups.length, groups.join( ', ' ) ) : '' )
						+ ( fields.length ? ' · ' + sprintf( _n( '%d term field: %s', '%d term fields: %s', fields.length ), fields.length, fields.join( ', ' ) ) : '' )
						+ '.' );
				}
				( ( r && r.notes ) || [] ).forEach( function ( n ) { botMsg( n, 'warn' ); } );
			} )
			.catch( function ( err ) {
				if ( isAbort( err ) ) { throw err; }
				step.done( sprintf( __( 'The %1$s taxonomy was not added: %2$s' ), name, ( err && err.message ) ? err.message : __( 'unexpected error' ) ), 'warn' );
			} );
	}

	/**
	 * A content type's pages: the single item and, if there is an archive, the list.
	 *
	 * WHAT IT CLOSES. Until now Aura created the type, filled it and put it in the menu — and whoever
	 * clicked landed on a page drawn by the theme, which knows nothing about that content. With the
	 * menu entry it had become worse: WE were the ones making a page we had not designed reachable.
	 *
	 * THE ORDER matters twice. First it GENERATES and only then CREATES the template: an empty Aura
	 * wp_template would replace the theme's with nothing, and every single-item page on the site
	 * would go blank — the worst case of a half failure has to stay «the template is not there»,
	 * which is how it was before. And this step comes BEFORE the menu, because you do not put up a
	 * sign towards a room that is not furnished yet.
	 *
	 * IT COSTS one generation per template, and that is why each has its own line in the chat instead
	 * of disappearing into a summary: whoever is watching has to see what they are paying for.
	 */
	function buildTemplates( t, design, tokensSpec ) {
		return api( '/model/templates', { post_type: t.slug }, undefined, abortSignal() )
			.then( function ( r ) {
				var wanted = ( ( r && r.templates ) ? r.templates : [] ).filter( function ( x ) { return ! x.exists; } );
				if ( ! wanted.length ) { return; }

				return wanted.reduce( function ( c, tpl ) {
					return c.then( function () { return buildOneTemplate( t, tpl, design, tokensSpec ); } );
				}, Promise.resolve() );
			} )
			.catch( function ( err ) {
				if ( isAbort( err ) ) { throw err; }
				botMsg( sprintf( __( 'I could not prepare the pages of %1$s: %2$s' ), t.plural || t.slug, ( err && err.message ) ? err.message : __( 'unexpected error' ) ), 'warn' );
			} );
	}

	/** ONE template: the section is generated, and ONLY if it arrives is the template built around it. */
	function buildOneTemplate( t, tpl, design, tokensSpec ) {
		var name = t.plural || t.slug;
		var step = stepMsg( MARK.run + ' ' + sprintf( __( 'Designing %1$s (%2$s)…' ), tpl.task, tpl.slug ) );

		var ds = design ? ( 'DESIGN SYSTEM OF THE PAGE (follow it to the letter, so this page stays consistent with the rest of the site):\n' + design + '\n' ) : '';
		var tk = tokensSpec ? ( 'MEASURED DESIGN VALUES:\n' + tokensSpec + '\n' ) : '';
		var prompt = 'You are building the FSE template `' + tpl.slug + '`: ' + tpl.task + '.\n'
			+ ds + tk
			+ 'Build the WHOLE page of this template, with its heading and its body. '
			+ 'The data comes from the content the visitor is looking at: do NOT write sample texts in its place.\n'
			+ 'Take particular care over responsive behaviour and accessibility (semantics, alt, keyboard).';

		return api( '/generate', { prompt: prompt, mode: 'section', context: { viewport: state.viewport }, images: [] }, undefined, abortSignal() )
			.then( function ( res ) {
				if ( ! res || ! res.template ) {
					step.done( sprintf( __( '%s not designed: the theme\'s one stays.' ), tpl.task ), 'warn' );
					return;
				}
				var block = makeInlineBlock( res, tpl.slug );

				return api( '/model/templates', {
					post_type: t.slug,
					slug: tpl.slug,
					title: name + ' — ' + tpl.task,
					blocks: [ { name: block.name, template: block.template, schema: block.schema, values: block.values } ],
				}, undefined, abortSignal() ).then( function ( c ) {
					if ( c && c.ok ) {
						step.done( MARK.done + ' ' + sprintf( __( '%1$s done (%2$s).' ), tpl.task, tpl.slug ) );
					} else if ( c && 'already-there' === c.reason ) {
						step.done( sprintf( __( '%s was already there: I have not touched it.' ), tpl.slug ) );
					} else {
						step.done( sprintf( __( '%s was generated but not saved: the theme\'s one stays.' ), tpl.slug ), 'warn' );
					}
				} );
			} )
			.catch( function ( err ) {
				if ( isAbort( err ) ) { throw err; }
				step.done( sprintf( __( '%1$s not done: %2$s' ), tpl.slug, ( err && err.message ) ? err.message : __( 'unexpected error' ) ), 'warn' );
			} );
	}

	/**
	 * A type's archive, put into the site's menu.
	 *
	 * It never fails noisily, and the «this site has no menu» case is not an error: on a theme that
	 * shows the list of pages there is nothing to change, and fabricating a menu with a single entry
	 * in it would change its header as a side effect.
	 */
	function addToMenu( t ) {
		var name = t.plural || t.slug;
		var step = stepMsg( MARK.run + ' ' + sprintf( __( 'Putting %s into the site\'s menu…' ), name ) );

		return api( '/model/menu', { post_type: t.slug }, undefined, abortSignal() )
			.then( function ( r ) {
				if ( r && r.ok && 'already-there' === r.reason ) {
					step.done( sprintf( __( '%s was already in the menu.' ), name ) );
				} else if ( r && r.ok ) {
					step.done( MARK.done + ' ' + sprintf( __( '%1$s added to the «%2$s» menu.' ), name, r.menu ) );
				} else if ( r && 'no-menu' === r.reason ) {
					step.done( sprintf( __( 'I have not put %s into the menu: this site has none, the theme shows the list of pages.' ), name ) );
				} else {
					step.done( sprintf( __( 'I could not put %s into the menu.' ), name ), 'warn' );
				}
			} )
			.catch( function ( err ) {
				if ( isAbort( err ) ) { throw err; }
				step.done( sprintf( __( '%1$s not added to the menu: %2$s' ), name, ( err && err.message ) ? err.message : __( 'unexpected error' ) ), 'warn' );
			} );
	}

	/** The contents of ONE type. An error here stops neither the other types nor the generation. */
	function createContents( t ) {
		var how_many = ( t.items || [] ).length;
		var name     = t.plural || t.slug;
		var step     = stepMsg( MARK.run + ' ' + sprintf( __( 'Creating the contents of %1$s (%2$s)…' ), name, sprintf( _n( '%d entry', '%d entries', how_many ), how_many ) ) );

		return api( '/model/contents', { post_type: t.slug, items: t.items }, undefined, abortSignal() )
			.then( function ( r ) {
				var n = ( r && r.created ) ? r.created : 0;
				if ( ! n ) {
					step.done( sprintf( __( 'No content created for %s.' ), name ), 'warn' );
				} else {
					var extra = [];
					if ( r.skipped ) { extra.push( sprintf( _n( '%d was already there', '%d were already there', r.skipped ), r.skipped ) ); }
					if ( r.failed ) { extra.push( sprintf( _n( '%d failed', '%d failed', r.failed ), r.failed ) ); }
					step.done( MARK.done + ' ' + sprintf( __( '%1$s: %2$s published' ), name, sprintf( _n( '%d content', '%d contents', n ), n ) )
						+ ( extra.length ? ' (' + extra.join( ', ' ) + ')' : '' ) + '.' );
				}
				( ( r && r.notes ) || [] ).forEach( function ( n2 ) { botMsg( n2, 'warn' ); } );
			} )
			.catch( function ( err ) {
				if ( isAbort( err ) ) { throw err; }
				step.done( sprintf( __( 'The contents of %1$s were not created: %2$s' ), name, ( err && err.message ) ? err.message : __( 'unexpected error' ) ), 'warn' );
			} );
	}

	/* ASKING FOR A NEW SECTION while one is selected.
	   The verb has to govern the SECTION: «add a contact section» is a new block, «add a CTA to
	   this section» is a change to the one in front — the word «section» appears in both, and the
	   difference is what it is the object OF. Both languages, like every other intent pattern here.
	   `rifai`/`redo` are deliberately absent: those ask for THIS section again, not another one. */
	/**
	 * Performs the act that was CHOSEN. Nothing here reads the sentence.
	 *
	 * Every branch that used to exist for a tier forced out of context has gone with the deduction
	 * that made it necessary: an act that cannot be performed on what is selected is not offered in
	 * the bar, so it cannot arrive here. Nor is there anything left to decide between adding one
	 * section and planning a dozen — that was a sentence-shaped guess between one call and twelve,
	 * and it is now two chips.
	 */
	function routeByTier( text, images ) {
		var tier = currentTier();

		if ( 'split' === tier ) { splitSection( text, images, text ); return; }
		if ( 'unite' === tier ) { uniteSections( text, images ); return; }
		if ( 'patch' === tier ) { patchSection( text, images ); return; }
		if ( ( 'add' === tier || 'page' === tier ) && looksLikePageLayer( text ) ) {
			generatePageLayer( text, images );
			return;
		}
		if ( 'page' === tier ) { generatePage( text, images ); return; }

		if ( 'global' === tier ) { globalPass( text, images ); return; }

		if ( 'add' === tier ) {
			appendOneSection( text, images, null, variantsFor( 'section' ) )
				.then( function () { setBusy( false ); } )
				.catch( genError );
			return;
		}

		// What is left is «Regenerate»: the selected section, or the pattern being worked on.
		generateSection( text, images, variantsFor( 'section' ) );
	}

	/**
	 * The pass over every section, and the one confirmation left on a generation.
	 *
	 * IT IS HERE BECAUSE OF THE BILL, not because of any doubt about the intention. This is the only
	 * act whose price is a multiple — one call per section of the page — and it is the one place
	 * where a click can cost ten times what the click before it cost.
	 *
	 * The question used to be asked only when the tier had been DEDUCED: «if the user chose it we
	 * go; if we deduced it from a sentence, we ask first». That was exactly right while a sentence
	 * could land somebody here by accident. Now that nothing lands anywhere by accident the reason
	 * has moved, and the condition with it: what deserves a question is not how you got here, it is
	 * how much it costs.
	 */
	function globalPass( text, images ) {
		var inline = state.blocks.filter( function ( b ) { return b.type === 'inline' && b.template; } );

		var tokenish = looksLikeToken( text );
		var question = sprintf(
			_n( 'The change will be considered on %d section of the page. Go ahead?',
				'The change will be considered on all %d sections of the page. Go ahead?', inline.length ),
			inline.length
		);
		if ( tokenish ) {
			question += '\n\n' + __( 'Note: if this is a design system value (a colour, a font, a radius, a spacing, a width), changing it in Design Tokens holds across the WHOLE site, is immediate and costs no generation.' );
		}

		if ( ! window.confirm( question ) ) {
			setBusy( false );
			botMsg( __( 'Cancelled. To act on one section alone, pick it in the blocks list and choose «Touch-up».' ),
				'',
				// If they cancelled and it was a token request, we open the right door for them:
				// telling them where it is without taking them there is half the job.
				tokenish && AIPB.tokens ? [ {
					label: __( 'Open Design Tokens' ),
					run: function () { window.open( AIPB.tokens, '_blank', 'noopener' ); },
				} ] : null
			);
			renderMessages();
			return;
		}

		generateGlobal( text, images, inline );
	}

	/* /import <address> — a page on the web, rebuilt as sections of this one. */
	function runImportCommand( url ) {
		url = String( url || '' ).trim();

		if ( ! url ) {
			botMsg( __( 'Usage: `/import https://example.com/the-page`. You can also just paste the address on its own.' ) );
			return;
		}
		if ( ! pageMode() ) {
			botMsg( __( 'Importing builds a stack of sections, so it needs a page. Switch to Page at the top, or paste the address there.' ), 'warn' );
			return;
		}
		if ( ! /^https?:\/\//i.test( url ) ) {
			// Somebody pasting «example.com» means https, and refusing over the scheme would be
			// pedantry: the service checks the address properly either way.
			url = 'https://' + url.replace( /^\/+/, '' );
		}

		userMsg( '/import ' + url );
		beginGeneration();
		setBusy( true );
		refreshSaveState();
		renderMessages();
		importFromUrl( url );
	}

	/* Something that is only a web address, and nothing else, is a request to import it. */
	var BARE_URL_RE = /^https?:\/\/[^\s]+$/i;

	// /split [where to cut] — breaks the selected section up without depending on the words used.
	function runSplitCommand( what ) {
		if ( ! canSplit() ) {
			botMsg( __( 'Pick the section of the page to split first.' ), 'warn' );
			return;
		}
		var text = what || __( 'Split it into its logical blocks, cutting where the structure suggests.' );
		userMsg( '/split' + ( what ? ' ' + what : '' ) );
		beginGeneration();
		setBusy( true );
		refreshSaveState();
		renderMessages();
		splitSection( text, [], text );
	}

	// /unite [how to merge] — merges the marked sections without depending on the words used.
	function runUniteCommand( how ) {
		if ( ! canUnite() ) {
			botMsg( __( 'Mark at least two sections first: ctrl/cmd+click them in the blocks list.' ), 'warn' );
			return;
		}
		userMsg( '/unite' + ( how ? ' ' + how : '' ) );
		beginGeneration();
		setBusy( true );
		refreshSaveState();
		renderMessages();
		// With no instruction the criterion is the obvious one, and saying it beats leaving the
		// model to infer from the code alone what these parts have in common.
		uniteSections( how || __( 'Merge them into one coherent section, keeping every content and removing what only existed to keep them apart.' ), [] );
	}

	/* --------------- Measuring the page's conventions, for free --------------- */

	/**
	 * WHAT THE PAGE ACTUALLY DOES, read off the sections already on it.
	 *
	 * This exists because «Align the sections» could not work, and could not have. It fans out one
	 * call per section, and each call receives ONLY its own code (see generateGlobal): the model was
	 * being told «align yourself with the others» and shown none of them. It could push towards the
	 * design tokens where a token existed, and where none did it had to guess — four blind guesses
	 * that do not converge, paid four times. On a bad day the page came out less consistent than it
	 * went in.
	 *
	 * The missing term of comparison was never far away: the CSS of every section is right here, in
	 * the browser. So it is MEASURED — no call, no cost — and what comes out is a fact anybody can
	 * check: «the page uses 96px of vertical padding in three sections out of four; the Footer uses
	 * 48px». From there the pass has something to aim at, and only the sections that deviate are
	 * worth spending on.
	 *
	 * WHAT IS MEASURED, and nothing else: the things that mean the same in every section, and that
	 * can be read without guessing what a class is FOR. A `.btn` and a `.card` both carry a
	 * border-radius and are supposed to differ; deciding which is which from the selector is a
	 * heuristic, and a wrong reading here does not produce a wrong warning — it produces a wrong
	 * generation. So those stay out.
	 */
	var CONVENTIONS = [
		{ key: 'font',      label: __( 'text font' ) },
		{ key: 'padding_y', label: __( 'section vertical padding' ) },
		{ key: 'container', label: __( 'content width' ) },
		{ key: 'h1',        label: __( 'h1 size' ) },
		{ key: 'h2',        label: __( 'h2 size' ) },
		{ key: 'h3',        label: __( 'h3 size' ) },
	];

	/**
	 * The top-level rules of a stylesheet: selector and declarations.
	 *
	 * @media is SKIPPED whole. What is inside it is a viewport override — a heading that shrinks on
	 * mobile is not a section disagreeing with the others, and counting it would turn every
	 * well-made responsive section into a deviation.
	 *
	 * It is a small scanner and not the real parser in studio-style.js: that one lives inside its
	 * own IIFE, is loaded after this file, and knows about positions and rewriting, which is a
	 * different job from counting.
	 */
	function topLevelRules( css ) {
		var out = [];
		var text = String( css || '' ).replace( /\/\*[\s\S]*?\*\//g, '' );
		var i = 0;

		while ( i < text.length ) {
			var open = text.indexOf( '{', i );
			if ( open < 0 ) { break; }
			var sel = text.slice( i, open ).trim();

			// Match braces to find where this block ends: a nested one must not cut it short.
			var depth = 1, j = open + 1;
			while ( j < text.length && depth > 0 ) {
				if ( text.charAt( j ) === '{' ) { depth++; }
				else if ( text.charAt( j ) === '}' ) { depth--; }
				j++;
			}
			var body = text.slice( open + 1, j - 1 );
			i = j;

			if ( 0 === sel.indexOf( '@' ) ) { continue; }
			if ( body.indexOf( '{' ) !== -1 ) { continue; } // nested CSS: not ours to read
			if ( ! sel ) { continue; }

			var decls = {};
			body.split( ';' ).forEach( function ( d ) {
				var c = d.indexOf( ':' );
				if ( c < 1 ) { return; }
				decls[ d.slice( 0, c ).trim().toLowerCase() ] = d.slice( c + 1 ).trim();
			} );
			out.push( { sel: sel, decls: decls } );
		}

		return out;
	}

	/**
	 * A value, in the form two sections can be COMPARED on.
	 *
	 * `var(--aipb-space-lg, 96px)` and `96px` are the same measurement written two ways — the DSL
	 * asks for the fallback precisely so the number is always there — so the fallback is what gets
	 * compared. Without this every section using the variable would look like it disagreed with
	 * every section using the number.
	 */
	function normValue( v ) {
		var out = String( v || '' ).trim().toLowerCase().replace( /\s+/g, ' ' );
		var m = /^var\(\s*--[a-z0-9-]+\s*,\s*(.+)\)$/.exec( out );
		if ( m ) { out = m[ 1 ].trim(); }

		return out.replace( /!important$/, '' ).trim();
	}

	/** The first family of a font stack, which is the one that identifies it. */
	function normFont( v ) {
		var first = normValue( v ).split( ',' )[ 0 ] || '';

		return first.replace( /['"]/g, '' ).trim();
	}

	/** The vertical padding of a rule, from whichever of the four spellings it used. */
	function paddingY( decls ) {
		if ( decls['padding-top'] ) { return normValue( decls['padding-top'] ); }
		if ( decls['padding-block'] ) { return normValue( decls['padding-block'] ).split( ' ' )[ 0 ]; }
		if ( decls['padding-block-start'] ) { return normValue( decls['padding-block-start'] ); }
		if ( decls.padding ) { return normValue( decls.padding ).split( ' ' )[ 0 ]; }

		return '';
	}

	/** The classes on the section's outermost element: the rule for one of them is its root rule. */
	function rootClasses( template ) {
		var m = /<(?:section|div|header|footer|article|aside|nav|main)\b[^>]*\bclass\s*=\s*["']([^"']+)["']/i.exec( String( template || '' ) );

		return m ? m[ 1 ].split( /\s+/ ).filter( Boolean ) : [];
	}

	/** What ONE section declares, for each convention. Missing values stay out: '' means «did not say». */
	function readConventions( template ) {
		var css = ( /<style\b[^>]*>([\s\S]*?)<\/style>/i.exec( String( template || '' ) ) || [ '', '' ] )[ 1 ];
		var rules = topLevelRules( css );
		var roots = rootClasses( template );
		var found = { font: '', padding_y: '', container: '', h1: '', h2: '', h3: '' };

		rules.forEach( function ( r ) {
			var sel = r.sel.toLowerCase();

			// The FONT: the first family declared anywhere. A section states it once, at the top.
			if ( ! found.font && r.decls['font-family'] ) { found.font = normFont( r.decls['font-family'] ); }

			// The section's own vertical padding: only from the rule of its ROOT element, which is
			// the only padding that means «how much this section breathes».
			if ( ! found.padding_y ) {
				roots.forEach( function ( cls ) {
					if ( sel === '.' + cls.toLowerCase() ) {
						var p = paddingY( r.decls );
						if ( p ) { found.padding_y = p; }
					}
				} );
			}

			// The CONTAINER: the widest max-width on the page is the content column. Anything
			// narrower is a card or an image, and comparing those across sections means nothing.
			if ( r.decls['max-width'] ) {
				var w = normValue( r.decls['max-width'] );
				var px = parseFloat( w );
				if ( /px$/.test( w ) && px >= 600 && ( ! found.container || px > parseFloat( found.container ) ) ) {
					found.container = w;
				}
			}

			// HEADINGS, by level. The selector names the element, so no guessing is needed.
			var h = /(?:^|[\s,>+~])h([1-3])\b/.exec( sel );
			if ( h && r.decls['font-size'] && ! found[ 'h' + h[ 1 ] ] ) {
				found[ 'h' + h[ 1 ] ] = normValue( r.decls['font-size'] );
			}
		} );

		return found;
	}

	/**
	 * The page's conventions, and who breaks them.
	 *
	 * A convention needs at least TWO sections saying the same thing: one section alone is not a
	 * convention, it is just a section, and calling everybody else a deviation from it is how a
	 * measuring tool starts doing damage. Where there is no majority, the key is skipped and
	 * nothing is proposed about it.
	 *
	 * @param {Array} blocks the inline sections, each with { id, name, template }
	 */
	function measureConventions( blocks ) {
		var read = blocks.map( function ( b ) { return { block: b, values: readConventions( b.template ) }; } );
		var page = {};

		CONVENTIONS.forEach( function ( c ) {
			var tally = {};
			read.forEach( function ( r ) {
				var v = r.values[ c.key ];
				if ( v ) { tally[ v ] = ( tally[ v ] || 0 ) + 1; }
			} );

			var best = '', count = 0, tied = false;
			Object.keys( tally ).forEach( function ( v ) {
				if ( tally[ v ] > count ) { best = v; count = tally[ v ]; tied = false; }
				else if ( tally[ v ] === count ) { tied = true; }
			} );

			// Two sections agreeing, and no tie: a tie is two camps of equal size, and picking one
			// of them as «the page» would be a coin toss that costs generations.
			if ( count >= 2 && ! tied ) { page[ c.key ] = { value: best, count: count }; }
		} );

		var deviations = [];
		read.forEach( function ( r ) {
			var items = [];
			CONVENTIONS.forEach( function ( c ) {
				var mine = r.values[ c.key ];
				var want = page[ c.key ];
				if ( want && mine && mine !== want.value ) {
					items.push( { key: c.key, label: c.label, mine: mine, page: want.value } );
				}
			} );
			if ( items.length ) { deviations.push( { block: r.block, items: items } ); }
		} );

		return { page: page, deviations: deviations, total: blocks.length };
	}

	/* ------------------- The consistency pass (/consistency) ---------------- */

	// Sections are born one at a time, each from its own call: even with a shared design system they
	// can diverge (buttons with different radii, headings on different scales, padding that does not
	// breathe the same way). This is the global fan-out — which the engine already had — turned into
	// an explicit action, with a well-written prompt instead of one left to the user's words.
	//
	// The prompt is written in the SAME LANGUAGE AS THE PROMPTS (English, see the service's
	// `Aura\Prompt\Composer`): it goes to the model, not to the screen.
	function runConsistencyPass( what ) {
		// The button is drawn by renderBlocks(), which does not run again on every state change: the
		// real guard is here, otherwise two generations start together on the same blocks.
		if ( state.busy ) { return; }
		if ( ! pageMode() ) {
			botMsg( __( 'The consistency pass applies to a page with several sections.' ), 'warn' );
			return;
		}
		var inline = state.blocks.filter( function ( b ) { return b.type === 'inline' && b.template; } );
		if ( inline.length < 2 ) {
			botMsg( __( 'At least two inline sections are needed to align.' ), 'warn' );
			return;
		}

		userMsg( '/consistency' + ( what ? ' ' + what : '' ) );

		// Asked for something SPECIFIC («/consistency the buttons»): the measurement has nothing to
		// say about it, so the old road stays open — the whole page, on the user's own words. They
		// named the thing; that is a decision, not a guess.
		if ( what ) {
			runConsistencyOn( inline, 'Concentrate ONLY on: ' + what + '.' );

			return;
		}

		// Otherwise: MEASURE first. It costs nothing, it happens here in the browser, and it turns
		// the button from «align yourselves somehow» into a fact and a target.
		var found = measureConventions( inline );
		var keys  = Object.keys( found.page );

		if ( ! keys.length ) {
			botMsg( __( 'I cannot tell what this page\'s conventions are: no value is repeated across at least two sections. There is nothing to align to — with sections this different, redoing one is worth more than aligning it.' ), 'warn' );

			return;
		}

		var report = __( 'The page uses:' ) + '\n'
			+ CONVENTIONS.filter( function ( c ) { return found.page[ c.key ]; } ).map( function ( c ) {
				var p = found.page[ c.key ];

				return '· ' + c.label + ' ' + p.value + '  '
					+ sprintf( _n( '(%1$d section out of %2$d)', '(%1$d sections out of %2$d)', p.count ), p.count, found.total );
			} ).join( '\n' );

		if ( ! found.deviations.length ) {
			botMsg( report + '\n\n' + __( 'Every section agrees: there is nothing to align, and nothing to spend.' ) );

			return;
		}

		report += '\n\n' + sprintf(
			_n( '%1$d section out of %2$d differs:', '%1$d sections out of %2$d differ:', found.deviations.length ),
			found.deviations.length, found.total
		) + '\n' + found.deviations.map( function ( d ) {
			return '· ' + ( d.block.name || __( 'Section' ) ) + ' — ' + d.items.map( function ( it ) {
				return it.label + ' ' + it.mine;
			} ).join( ', ' );
		} ).join( '\n' );

		var m = botMsg( report );
		m.actions = [
			{
				label: sprintf( _n( 'Align %d section', 'Align %d sections', found.deviations.length ), found.deviations.length ),
				primary: true,
				once: true,
				run: function () {
					if ( state.busy ) { return; }
					// ONLY the sections that deviate, each told exactly what to match. The ones that
					// already agree are never sent — which is the whole difference between this and
					// what the button used to do.
					runConsistencyOn(
						found.deviations.map( function ( d ) { return d.block; } ),
						'',
						found.deviations.map( function ( d ) {
							return d.items.map( function ( it ) {
								return it.label + ': the page uses ' + it.page + ', this section uses ' + it.mine;
							} ).join( '; ' );
						} )
					);
				},
			},
			{ label: __( 'Cancel' ), once: true, run: function () {} },
		];
		renderMessages();
	}

	/**
	 * Runs the pass over the blocks given, with the target each one has to match.
	 *
	 * `targets[i]` is what was MEASURED for that block: concrete values, not an adjective. It is the
	 * difference between «make this consistent» — which the model can only interpret — and «the page
	 * uses 96px, you use 48px», which it can simply do.
	 */
	function runConsistencyOn( blocks, scope, targets ) {
		beginGeneration();
		setBusy( true );
		refreshSaveState();
		renderMessages();

		var base = 'CONSISTENCY PASS. This section belongs to a page whose other sections were '
			+ 'generated separately, and it has drifted from them.\n'
			+ ( scope || '' )
			+ 'Change ONLY the style values named below, and use the var(--aipb-*) variables wherever '
			+ 'the matching token exists. Do NOT change the content (texts, images, links) and do NOT '
			+ 'change the structure or the layout. If nothing here applies to this section, return NOTHING.';

		if ( ! targets ) {
			generateGlobal( base, [], blocks );

			return;
		}

		// One prompt per section, because each one is off by different things. generateGlobal sends
		// the same text to all of them, so the fan-out is done here, one call at a time through the
		// same engine — which keeps the proposals-and-confirm screen exactly as it is.
		generateGlobal( blocks.map( function ( b, i ) {
			return base + '\n\nWHAT THIS SECTION HAS TO MATCH, measured on the page:\n' + targets[ i ] + '.';
		} ), [], blocks );
	}

	// A global change: a fan-out of the patch engine over EVERY inline section, a few at a time. Each
	// call decides whether it is relevant (otherwise a no-op → the section is untouched). One undo.
	//
	// `text` is one instruction for all of them, or an ARRAY with one per block. The array is what
	// the consistency pass needs: every section is off by different things, and telling each one the
	// specific values it has to match is the difference between a change it can make and an
	// adjective it has to interpret.
	function generateGlobal( text, images, inlineBlocks ) {
		var total = inlineBlocks.length;
		var progress = fanoutProgress(
			inlineBlocks.map( function ( b ) { return b.name; } ),
			MARK.run + ' ' + sprintf( _n( 'A global change over %d section…', 'A global change over %d sections…', total ), total )
		);

		runPool( inlineBlocks, function ( b, i ) {
			var context = { template: b.template, schema: b.schema, values: b.values, viewport: state.viewport };
			return apiStream(
				{ prompt: Array.isArray( text ) ? text[ i ] : text, mode: 'global', context: context, images: images || [] },
				function ( piece ) { progress.delta( i, piece ); },
				abortSignal()
			)
				.then( function ( res ) { progress.settle( i, res ); return { block: b, res: res }; } )
				.catch( function ( err ) { progress.settle( i, null, err ); return { block: b, error: err }; } );
		} ).then( function ( results ) {
			// STOP was pressed: we apply no partial changes, we just say so.
			if ( results.some( function ( r ) { return r.error && isAbort( r.error ); } ) ) {
				progress.end();
				setBusy( false ); botMsg( MARK.skip + ' ' + __( 'Generation stopped.' ) ); return;
			}
			setBusy( false );

			// The proposals are COLLECTED, not applied. Until now the fan-out rewrote every section
			// and announced it afterwards: the only remedy was undo, which is all or nothing — «fine
			// on these three, not on that one» could not be said. The model decided on its own over
			// eight sections; whoever really decides looks at them first.
			var proposals = [], failed = 0, warning = '';
			results.forEach( function ( r ) {
				if ( r.error ) { failed++; return; }
				var res = r.res;
				if ( res && res.template ) {
					proposals.push( { block: r.block, res: res, edits: res._applied || 0 } );
				}
				if ( res && res._warning ) { warning = res._warning; }
				// res._noop or empty → the section is not relevant: there is nothing to propose.
			} );

			if ( ! proposals.length ) {
				var none = __( 'No section turned out to be relevant: nothing was changed.' );
				if ( failed ) { none += ' ' + sprintf( _n( '(%d with an error)', '(%d with errors)', failed ), failed ); }
				// The list STAYS here, under that sentence: «no section was relevant» is a claim, and
				// the rows are what backs it up — each one saying, in the model's own words, why it
				// decided the request was not about it.
				progress.end( MARK.skip + ' ' + none, 'warn' );
				if ( warning ) { botMsg( warning, 'warn' ); }

				return;
			}

			// From here on the checklist is the thing to read, and it names the same sections: the
			// live list would be the same information twice.
			progress.end();

			var head = sprintf(
				_n( '%1$d section out of %2$d has a proposal. Choose where to apply it:',
					'%1$d sections out of %2$d have a proposal. Choose where to apply them:', proposals.length ),
				proposals.length, total
			);
			if ( failed ) { head += ' ' + sprintf( _n( '(%d with an error)', '(%d with errors)', failed ), failed ); }

			var m = botMsg( head );
			m.choices = proposals.map( function ( p, i ) {
				return {
					on: true,
					id: i,
					label: ( p.block.name || __( 'Section' ) )
						+ ( p.edits ? ' · ' + sprintf( _n( '%d change', '%d changes', p.edits ), p.edits ) : '' ),
				};
			} );
			m.actions = [
				{
					label: __( 'Apply to the selected ones' ),
					primary: true,
					once: true,
					run: function () { applyGlobalResults( proposals, m.choices ); },
				},
				{
					label: __( 'Discard everything' ),
					once: true,
					run: function () { botMsg( __( 'Proposals discarded: the page has stayed as it was.' ) ); },
				},
			];
			renderMessages();
			if ( warning ) { botMsg( warning, 'warn' ); }
		} );
	}

	/**
	 * Applies the fan-out's proposals that were confirmed.
	 *
	 * The sections are found again by ID and not by reference: between the proposal and the click
	 * they may have been deleted or reordered, and writing onto a block that is no longer in the page
	 * would be a change that disappears at save time without saying anything.
	 */
	function applyGlobalResults( proposals, choices ) {
		var done = 0, edits = 0, gone = 0;

		proposals.forEach( function ( p, i ) {
			if ( ! choices[ i ] || ! choices[ i ].on ) { return; }
			var live = state.blocks.filter( function ( b ) { return b.id === p.block.id; } )[ 0 ];
			if ( ! live ) { gone++; return; }

			live.template = p.res.template;
			live.schema = p.res.schema || { fields: [] };
			live.values = p.res.values || {};
			done++;
			edits += p.edits;
		} );

		if ( ! done ) {
			botMsg( gone
				? __( 'The chosen sections are no longer in the page: I have applied nothing.' )
				: __( 'No section selected: I have applied nothing.' ), 'warn' );

			return;
		}

		state.dirty = true;
		renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState();
		commitHistory(); // a single undo step for the whole global change

		var msg = sprintf( _n( 'Applied to %d section', 'Applied to %d sections', done ), done )
			+ ( edits ? ' · ' + sprintf( _n( '%d change', '%d changes', edits ), edits ) : '' ) + '.';
		if ( gone ) { msg += ' ' + sprintf( _n( '(%d is no longer in the page)', '(%d are no longer in the page)', gone ), gone ); }
		botMsg( msg );
	}

	// Applies the { template, schema, values } result to the section (the selected block or the component).
	/**
	 * The content of a section, put where it goes, and drawn.
	 *
	 * Split out of applySection because swapping between VARIANTS has to do exactly this and nothing
	 * else: no history entry per swap — you would undo your way through the ones you rejected — and
	 * no check per swap, which would spend a round of warnings on eight sections you are only
	 * looking at. Deciding is what commits; looking is free.
	 */
	function putSection( block, res ) {
		var target = block || state;
		target.template = res.template;
		target.schema = res.schema || { fields: [] };
		target.values = res.values || {};
		state.dirty = true;
		renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState();
	}

	/* ------------------- Choosing between the variants -------------------- */

	/*
	 * ONE AT A TIME, IN PLACE. The versions are not laid out one under the other: a page with the
	 * same section repeated three times is not a comparison, it is a mess — the surrounding sections
	 * move, the page stops being the page, and what you are judging is no longer what you will get.
	 * The section stays where it belongs and the switch swaps its contents underneath it, so every
	 * version is seen in the only context that matters: the real one, with the real neighbours.
	 *
	 * Nothing is committed while you look. History and the checks wait for the choice, or the whole
	 * exercise would leave three rejected versions in the undo stack.
	 */

	/** The run in progress, or null. */
	var variantRun = null;

	function variantsLive() { return !! ( variantRun && variantRun.list.length > 1 ); }

	function startVariantRun( block, list, before, fresh ) {
		variantRun = { block: block, list: list, before: before, fresh: fresh, active: 0 };
		showVariant( 0 );
		renderVariantBar();
	}

	function showVariant( i ) {
		if ( ! variantRun || ! variantRun.list[ i ] ) { return; }
		variantRun.active = i;
		putSection( variantRun.block, variantRun.list[ i ] );
		renderVariantBar();
	}

	/** The choice is made: this one stays, the others are gone, and NOW it counts. */
	function keepVariant() {
		if ( ! variantRun ) { return; }
		var block = variantRun.block;
		var kept  = variantRun.active + 1;
		var total = variantRun.list.length;

		variantRun = null;
		renderVariantBar();
		commitHistory();
		autoTokenize( block || state );
		checkSection( block || state );
		botMsg( MARK.done + ' ' + sprintf( __( 'Kept version %1$d of %2$d.' ), kept, total ) );
		renderMessages();
	}

	/** None of them: the section goes back to what it was before the run. */
	function discardVariants() {
		if ( ! variantRun ) { return; }
		var before = variantRun.before;
		var block  = variantRun.block;
		var fresh  = variantRun.fresh;

		variantRun = null;

		if ( fresh ) {
			// The section did not exist before this run: dropping it means removing it, not
			// restoring it to an emptiness it never had.
			var idx = state.blocks.indexOf( block );
			if ( idx >= 0 ) { state.blocks.splice( idx, 1 ); }
			state.dirty = true;
			renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState();
		} else if ( before ) {
			putSection( block, before );
		}

		renderVariantBar();
		commitHistory();
		botMsg( __( 'Versions discarded: nothing changed.' ) );
		renderMessages();
	}

	function renderVariantBar() {
		if ( ! dom.variantBar ) { return; }

		if ( ! variantsLive() ) {
			dom.variantBar.style.display = 'none';
			dom.variantBar.innerHTML = '';
			return;
		}

		dom.variantBar.style.display = '';
		dom.variantBar.innerHTML = '';

		var label = el( 'span', 'aipb-var-label', __( 'Version' ) );
		dom.variantBar.appendChild( label );

		var prev = el( 'button', 'aipb-var-nav', '‹' );
		prev.type = 'button';
		prev.title = __( 'Previous version' );
		prev.addEventListener( 'click', function () {
			showVariant( ( variantRun.active - 1 + variantRun.list.length ) % variantRun.list.length );
		} );
		dom.variantBar.appendChild( prev );

		variantRun.list.forEach( function ( res, i ) {
			var b = el( 'button', 'aipb-var-dot', String( i + 1 ) );
			b.type = 'button';
			b.classList.toggle( 'active', i === variantRun.active );
			b.setAttribute( 'aria-pressed', i === variantRun.active ? 'true' : 'false' );
			b.title = sprintf( __( 'Version %d' ), i + 1 );
			b.addEventListener( 'click', function () { showVariant( i ); } );
			dom.variantBar.appendChild( b );
		} );

		var next = el( 'button', 'aipb-var-nav', '›' );
		next.type = 'button';
		next.title = __( 'Next version' );
		next.addEventListener( 'click', function () {
			showVariant( ( variantRun.active + 1 ) % variantRun.list.length );
		} );
		dom.variantBar.appendChild( next );

		var keep = el( 'button', 'aipb-var-keep', '✓ ' + __( 'Keep this one' ) );
		keep.type = 'button';
		keep.addEventListener( 'click', keepVariant );
		dom.variantBar.appendChild( keep );

		var drop = el( 'button', 'aipb-var-drop', __( 'None' ) );
		drop.type = 'button';
		drop.title = __( 'Discard every version and leave the section as it was' );
		drop.addEventListener( 'click', discardVariants );
		dom.variantBar.appendChild( drop );
	}

	/**
	 * Several versions of ONE section, generated together, shown one at a time.
	 *
	 * The generations run in the same pool as a page fan-out and are reported by the same live list,
	 * so waiting for three versions reads the way waiting for three sections already does: one line
	 * each, saying what each one thinks it was asked for.
	 *
	 * A version that fails does not sink the run — two out of three is still a choice. If only one
	 * survives there is nothing to choose between, and it is simply applied, with no bar and no
	 * ceremony: a switch with one position is furniture.
	 *
	 * @param {Function} ask   called per index, returns a promise of the generated section.
	 * @param {Object}   block the block the versions go into (null = the state itself).
	 * @param {boolean}  fresh whether that block was created by this run.
	 */
	function runVariants( ask, block, fresh, n ) {
		var before   = fresh ? null : snapshotSection( block );
		var labels   = [];
		for ( var k = 0; k < n; k++ ) { labels.push( sprintf( __( 'Version %d' ), k + 1 ) ); }

		var progress = fanoutProgress( labels, '⎇ ' + sprintf( __( 'Designing %d versions…' ), n ) );
		var jobs     = [];

		for ( var i = 0; i < n; i++ ) {
			jobs.push( ( function ( idx ) {
				return ask( idx, function ( piece ) { progress.delta( idx, piece ); } )
					.then( function ( res ) {
						if ( res && res.template ) {
							progress.settle( idx, res );
							return res;
						}
						progress.settle( idx, null, true );
						return null;
					} )
					.catch( function ( err ) {
						if ( isAbort( err ) ) { throw err; }
						progress.settle( idx, null, err );
						return null;
					} );
			} )( i ) );
		}

		return Promise.all( jobs ).then( function ( results ) {
			var list = results.filter( Boolean );

			if ( ! list.length ) {
				progress.end( MARK.err + ' ' + __( 'No version came out. Nothing has changed.' ) );
				if ( fresh && block ) {
					var idx = state.blocks.indexOf( block );
					if ( idx >= 0 ) { state.blocks.splice( idx, 1 ); renderBlocks(); schedulePreview( 0 ); }
				}
				renderMessages();
				return null;
			}

			progress.end( '⎇ ' + sprintf(
				_n( '1 version ready.', '%d versions ready — switch between them above the preview and keep the one you want.', list.length ),
				list.length
			) );

			if ( 1 === list.length ) {
				applySection( block, list[ 0 ] );
			} else {
				startVariantRun( block, list, before, fresh );
			}

			renderMessages();

			return list[ 0 ];
		} );
	}

	/** A copy of what a section holds right now, to be able to put it back. */
	function snapshotSection( block ) {
		var src = block || state;

		return {
			template: src.template,
			schema: JSON.parse( JSON.stringify( src.schema || { fields: [] } ) ),
			values: JSON.parse( JSON.stringify( src.values || {} ) ),
		};
	}

	function applySection( block, res ) {
		putSection( block, res );
		commitHistory();
		autoTokenize( block || state );

		// The check on what has just arrived. It lives HERE and not in the callers because
		// applySection is the point where a section's code changes because of a generation — new,
		// regenerated, refined or corrected with a targeted change: one hook, instead of four that
		// sooner or later become three. The page fan-out does not come through here (it uses
		// makeInlineBlock), so it does not add to the end-of-page check.
		checkSection( block || state );
	}

	/**
	 * After a generation: the literals a token already says are hooked up, and it is SAID, not asked.
	 *
	 * IT USED TO BE A QUESTION, and that was the mistake. What it counts is not a matter of taste:
	 * tokenizeScan only matches a value IDENTICAL to a token the site has already defined, in a
	 * declaration that is not on a var() yet — which is to say, the exact thing three rules of
	 * prompts/design-rules.txt already forbid the model to write («never the hex values "by hand"»,
	 * «never a hand-written px value», «never a hand-written time»). Asking the user's permission to
	 * apply our own rule put the repair of a generation defect on them, once per section, for ever:
	 * answering «no» was not remembered anywhere, so the question came back on the section after.
	 *
	 * IT IS SAFE TO DO because every substitution carries its fallback — `24px` becomes
	 * `var(--aipb-space-lg, 24px)`, as prompts/dsl-core.txt demands of every var(). The page renders
	 * the same pixel today; it changes only when somebody edits that token, which is what tokens are
	 * for. And the report carries an undo, because doing something in someone's stead without a way
	 * back is worse than asking.
	 *
	 * NOT ON A REPRODUCTION. prompts/clone-rules.txt says the opposite for those — «THE MEASURED
	 * VALUES WIN OVER EVERYTHING», the variables are optional — so hooking up there would quietly
	 * undo what that prompt had just insisted on.
	 *
	 * Zero candidates is NOT a defect: on a site that has not defined its tokens yet there is nothing
	 * to hook onto, and then nothing is said. Silence is the right answer, and the Design Tokens page
	 * says it when it is empty — not this one.
	 */
	function autoTokenize( sections ) {
		var api = window.AIPBStyle;
		if ( ! api || ! api.tokenizeAll ) { return; }
		if ( state.reproducing ) { return; }

		var list = ( Array.isArray( sections ) ? sections : [ sections ] ).filter( Boolean );

		// The templates as the model wrote them, to be able to put them back. Restoring by history
		// would undo whatever happened LAST, which by the time somebody reads the line may be
		// something else entirely.
		var snaps = list.map( function ( sec ) { return { sec: sec, before: sec.template, after: '' }; } );

		var count = 0;
		list.forEach( function ( sec ) { count += api.tokenizeAll( sec ); } );
		if ( ! count ) { return; }
		snaps.forEach( function ( s ) { s.after = s.sec.template; } );

		state.dirty = true;
		refreshSaveState();
		refreshCodeIfOpen();
		schedulePreview( 0 );
		commitHistory();

		botMsg(
			sprintf(
				_n( '%d hand-written value already said what one of your design system tokens says: I have hooked it up, so it follows the design system from here on.',
					'%d hand-written values already said what your design system tokens say: I have hooked them up, so they follow the design system from here on.', count ),
				count
			),
			'',
			[ {
				label: __( 'Leave them written by hand' ),
				once: true,
				run: function () {
					// Only what is still ours: a section regenerated in the meantime is a different
					// section, and putting the old CSS back into it would be a silent corruption.
					var back = 0;
					snaps.forEach( function ( s ) {
						if ( s.sec.template === s.after ) { s.sec.template = s.before; back++; }
					} );
					if ( ! back ) { botMsg( __( 'Those sections have changed since: I have left them as they are.' ) ); renderMessages(); return; }
					state.dirty = true;
					refreshSaveState();
					refreshCodeIfOpen();
					schedulePreview( 0 );
					commitHistory();
					botMsg( __( 'Put back the way it was written.' ) );
					renderMessages();
				},
			} ]
		);
	}

	// A targeted touch-up: it sends only the section's code and applies the find/replace patches.
	// If the server cannot apply them (ambiguous anchors) it falls back on generateSection.
	function patchSection( text, images ) {
		var block = activeBlock();
		var ctxSrc = block ? block : state;
		if ( ! ctxSrc.template ) { generateSection( text, images ); return; }
		var context = { template: ctxSrc.template, schema: ctxSrc.schema, values: ctxSrc.values, viewport: state.viewport };
		if ( block && isPageLayer( block ) ) { context.scope = 'page'; }

		// A touch-up streams too, and here what arrives is the most legible thing there is: the
		// FIND anchors say WHICH element the model went for. Seeing it aim at the wrong one is worth
		// a STOP, and on a patch that costs nothing to correct.
		var progress = streamProgress( __( 'Preparing the touch-up…' ) );

		apiStream( { prompt: text, mode: 'patch', context: context, images: images || [] }, progress.delta, abortSignal(), progress.meta ).then( function ( res ) {
			setBusy( false );
			if ( res && res._fallback === 'section' ) {
				// The patch cannot be applied. We do NOT regenerate the whole section on our own (it
				// costs tokens and overturns the rest): we stop and leave the choice to the user.
				progress.end( __( 'I could not isolate this change onto the selected element. Try rephrasing it (say what to change, e.g. «increase the padding by 8px»). If you want to redo the whole section instead, use «Regenerate» (⟲) or rewrite the request as a section change.' ), 'warn', res._intent );

				return;
			}
			if ( res && res.template ) {
				applySection( block, res );
				var n = res._applied || 0;
				var det = n ? ( ' · ' + sprintf( _n( '%d change', '%d changes', n ), n ) ) : '';
				progress.end(
					res._warning || ( MARK.done + ' ' + ( block ? sprintf( __( 'Touch-up applied to «%s»' ), block.name ) : __( 'Touch-up applied' ) ) + det ),
					res._warning ? 'warn' : '',
					res._intent
				);
			} else {
				progress.end( __( 'The touch-up did not work. Try again.' ), 'warn', res && res._intent );
			}
		} ).catch( function ( err ) {
			progress.end();
			genError( err );
		} );
	}

	/* -------------------------------- Split -------------------------------- */

	// Breaks the selected section into N distinct sections of the page.
	//
	// No new format on the server side: `mode: 'section'` always returns ONE complete section, so we
	// call it N times on the SAME starting code, each time with the order to keep only its own part.
	// The N answers REPLACE the original block, in its place and in its order. It is all or nothing:
	// if one part does not arrive we touch nothing, because a half-split section would be worse than
	// where we started. A single undo step.
	function splitSection( text, images, userText ) {
		var block = activeBlock();
		if ( ! block || state.blocks.indexOf( block ) < 0 ) {
			setBusy( false );
			botMsg( __( 'No section of the page is selected: there is nothing to split.' ), 'warn' );
			return;
		}

		var parts   = splitCount( userText || text );
		var names   = splitNames( block.name, parts );
		var context = { template: block.template, schema: block.schema, values: block.values, viewport: state.viewport };

		var progress = fanoutProgress( names, MARK.run + ' ' + sprintf( __( 'Splitting «%1$s» into %2$d sections…' ), block.name, parts ) );

		runPool( names, function ( name, i ) {
			var prompt = buildSplitPrompt( text, i, parts, names );
			return apiStream(
				{ prompt: prompt, mode: 'section', context: context, images: images || [] },
				function ( piece ) { progress.delta( i, piece ); },
				abortSignal()
			)
				.then( function ( res ) { progress.settle( i, res ); return { res: res }; } )
				.catch( function ( err ) { progress.settle( i, null, err ); return { error: err }; } );
		} ).then( function ( results ) {
			progress.end();
			// STOP was pressed: no partial changes, we just say so.
			if ( results.some( function ( r ) { return r.error && isAbort( r.error ); } ) ) {
				setBusy( false ); botMsg( MARK.skip + ' ' + __( 'Generation stopped.' ) ); return;
			}
			setBusy( false );

			var missing = results.filter( function ( r ) { return r.error || ! ( r.res && r.res.template ); } );
			if ( missing.length ) {
				botMsg( sprintf(
					__( 'Split cancelled: I got %1$d parts out of %2$d. The section stays as it was.' ),
					parts - missing.length, parts
				), 'warn' );
				var err = missing[ 0 ].error;
				if ( err && ! isAbort( err ) ) { botMsg( sprintf( __( 'Error: %s' ), err.message || __( 'unexpected' ) ), 'warn' ); }
				renderMessages();
				return;
			}

			// The index is re-read NOW: during the generation the user may have moved the blocks.
			var at = state.blocks.indexOf( block );
			if ( at < 0 ) { botMsg( __( 'The starting section is no longer in the page: split cancelled.' ), 'warn' ); return; }

			var made = results.map( function ( r, i ) { return makeInlineBlock( r.res, names[ i ] ); } );
			state.blocks.splice.apply( state.blocks, [ at, 1 ].concat( made ) );
			state.selectedBlockId = made[ 0 ].id;
			state.dirty = true;
			renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState(); updateComposerHint();
			commitHistory(); // a single undo step for the whole split

			botMsg( MARK.done + ' ' + sprintf(
				__( 'Split into %1$d sections: %2$s. Check the cuts in the preview; the names change with a double click in the list.' ),
				parts, names.join( ' · ' )
			) );
			var warning = '';
			results.forEach( function ( r ) { if ( r.res._warning ) { warning = r.res._warning; } } );
			if ( warning ) { botMsg( warning, 'warn' ); }
			renderMessages();
		} ).catch( genError );
	}

	// How many parts the user asks for: "in two", "2 distinct sections"… With no number it is 2.
	// Capped at 4. Both languages, like the other patterns that read what the user types.
	function splitCount( text ) {
		var m = /\b(due|tre|quattro|two|three|four|[234])\b/i.exec( text || '' );
		if ( ! m ) { return 2; }
		var w = m[ 1 ].toLowerCase();
		var n = ( 'due' === w || 'two' === w ) ? 2
			: ( 'tre' === w || 'three' === w ) ? 3
			: ( 'quattro' === w || 'four' === w ) ? 4
			: parseInt( w, 10 );
		return ( n >= 2 && n <= 4 ) ? n : 2;
	}

	// The parts' names. If the block's name is already compound ("Header + photo hero") we split it
	// on its separators: almost always that is exactly the cut being asked for. Otherwise we number
	// them. Either way they are renamed with a double click in the blocks list.
	function splitNames( name, parts ) {
		var base = String( name || '' ).trim() || __( 'Section' );
		var bits = base.split( /\s*(?:[+\/·|&]|\be\b)\s*/i )
			.map( function ( s ) { return s.trim(); } )
			.filter( function ( s ) { return !! s; } );
		if ( bits.length === parts ) { return bits; }
		var out = [];
		for ( var i = 0; i < parts; i++ ) { out.push( base + ' ' + ( i + 1 ) ); }
		return out;
	}

	var SPLIT_ORDINALS = [ 'FIRST', 'SECOND', 'THIRD', 'FOURTH' ];

	// The prompt for ONE of the parts. The model sees the starting code (build_user_content puts it
	// there on the server, as in every section refinement) and has to return the piece that is its
	// own, self-sufficient. The goal is that the page, with the parts stacked, stays identical.
	//
	// Written in the prompts' language (see the note in sendInstruction).
	function buildSplitPrompt( text, i, parts, names ) {
		var ord   = SPLIT_ORDINALS[ i ] || ( 'no. ' + ( i + 1 ) );
		var mine  = ( names && names[ i ] ) ? ' — the one matching «' + names[ i ] + '»' : '';
		var order = ( names && names.length ) ? names.join( ' → ' ) : '';
		return 'SPLITTING THE SECTION. The section above is to be broken into ' + parts + ' INDEPENDENT sections '
			+ 'of the page, which will be stacked in order' + ( order ? ' (' + order + ')' : '' ) + '.\n'
			+ 'The criterion for the split, as the user asked for it: ' + text + '\n'
			+ 'Generate NOW ONLY the ' + ord + ' of the ' + parts + ' parts, counting from the top' + mine + '.\n'
			+ 'RULES:\n'
			+ '- Return ONE complete and SELF-SUFFICIENT section: only the markup and only the CSS that THIS part needs.\n'
			+ '- Remove everything belonging to the other parts: their markup, their CSS rules, their content.\n'
			+ '- Do NOT invent content and do NOT change texts, images, links, colours, typography or spacing: stacked back together, the page must come out IDENTICAL to what it was before the split.\n'
			+ '- If the parts were nested (e.g. a header inside the hero\'s container), rewrite the container of THIS part so that it stands on its own.\n'
			+ '- If one part is a fixed/sticky header that used to take up room in the flow, the part immediately after it makes up for that room with an equivalent padding-top.\n'
			+ '- Keep the placeholders of the editable fields belonging to this part, with the same names.';
	}

	/* -------------------------------- Unite -------------------------------- */

	/**
	 * Merges the marked sections into ONE. The inverse of Split, and it costs one call instead of N:
	 * the model reads the parts stacked and writes the single section they become.
	 *
	 * ALL OR NOTHING, like the split: either the merged section arrives whole, or the page is left
	 * exactly as it was. Half a merge — two sections gone and one arrived incomplete — is the worst
	 * outcome available here, because what is lost is content and not layout. One undo step.
	 */
	function uniteSections( text, images ) {
		var targets = uniteTargets();
		if ( targets.length < 2 ) {
			setBusy( false );
			botMsg( __( 'There are not two sections marked: there is nothing to merge.' ), 'warn' );
			return;
		}

		// NOT NEXT TO EACH OTHER. The merge is legitimate — somebody may well want the hero and the
		// footer to become one thing — but its result lands where the FIRST of them was, and the
		// ones in between move up. That is a change to the page's order, made by an operation whose
		// name says nothing about order, so it is asked for out loud. Marking three in a row, which
		// is the normal case, asks nothing.
		var idx = targets.map( function ( b ) { return state.blocks.indexOf( b ); } );
		var gap = idx[ idx.length - 1 ] - idx[ 0 ] + 1 !== idx.length;
		if ( gap && ! window.confirm( sprintf(
			__( 'The marked sections are not next to each other: %1$s will end up where «%2$s» is now, and what was in between moves up. Go ahead?' ),
			targets.map( function ( b ) { return '«' + ( b.name || __( 'Section' ) ) + '»'; } ).join( ', ' ),
			targets[ 0 ].name || __( 'Section' )
		) ) ) {
			setBusy( false );
			botMsg( __( 'Merge cancelled. The page is as it was.' ) );
			return;
		}

		var name     = uniteName( targets );
		var progress = streamProgress( '⇥ ' + sprintf(
			__( 'Merging %1$d sections into «%2$s»…' ), targets.length, name
		) );

		apiStream(
			{ prompt: buildUnitePrompt( text, targets ), mode: 'section', context: uniteContext( targets ), images: images || [] },
			progress.delta,
			abortSignal(),
			progress.meta
		).then( function ( res ) {
			setBusy( false );

			if ( ! res || ! res.template ) {
				progress.end( __( 'The merge did not work: the sections stay as they were.' ), 'warn', res && res._intent );
				return;
			}

			// The positions are re-read NOW: during the generation the user may have moved or
			// deleted the sections, and splicing on the old indexes would cut somewhere else.
			var at = state.blocks.length;
			var live = [];
			targets.forEach( function ( b ) {
				var i = state.blocks.indexOf( b );
				if ( i >= 0 ) { live.push( i ); at = Math.min( at, i ); }
			} );
			if ( live.length < 2 ) {
				progress.end( __( 'The marked sections are no longer on the page: merge cancelled.' ), 'warn' );
				return;
			}

			// Removed from the bottom up, so that each removal leaves the indexes above it — and the
			// insertion point, which is the topmost of them — where they were.
			live.sort( function ( a, b ) { return b - a; } ).forEach( function ( i ) { state.blocks.splice( i, 1 ); } );

			var made = makeInlineBlock( res, name );
			state.blocks.splice( at, 0, made );
			state.selectedBlockId = made.id;
			state.markedBlockIds = []; // the set has been spent
			state.dirty = true;

			renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState(); updateComposerHint();
			commitHistory(); // a single undo step for the whole merge

			progress.end(
				res._warning || '⇥ ' + sprintf(
					__( '%1$d sections merged into «%2$s». Check in the preview that nothing has been left behind: undo puts them back.' ),
					live.length, made.name
				),
				res._warning ? 'warn' : '',
				res._intent
			);
		} ).catch( function ( err ) {
			progress.end();
			// A stop here leaves the page exactly as it was, and in an operation that REMOVES
			// sections that is not obvious: the generic «stopped» would send you to the blocks list
			// to count them.
			if ( isAbort( err ) ) {
				setBusy( false );
				botMsg( MARK.skip + ' ' + __( 'Generation stopped. The sections are as they were.' ) );

				return;
			}
			genError( err );
		} );
	}

	/**
	 * The merged section's name: the parts' names joined with a +.
	 *
	 * It is deliberately the shape splitNames() takes APART again — «Header + photo hero» splits back
	 * into two — so that a merge you regret can be undone by hand as well as with the undo, and so
	 * that the list still says what is inside the row. Past three it would be a paragraph in a 380px
	 * column, so it is trimmed; renaming is a double click away, as always.
	 */
	function uniteName( blocks ) {
		var names = blocks
			.map( function ( b ) { return String( b.name || '' ).trim(); } )
			.filter( function ( s ) { return !! s; } );

		if ( ! names.length ) { return __( 'Section' ); }
		if ( names.length > 3 ) { return names.slice( 0, 2 ).join( ' + ' ) + ' + …'; }

		return names.join( ' + ' );
	}

	/**
	 * The N sections as ONE readable document — which is the whole difficulty of this tier.
	 *
	 * THE CONTENTS ARE FOLDED IN HERE, not on the service. The service does it itself for a normal
	 * refinement (`UserMessage::inlineForm`), but it does it with ONE values map, and two sections of
	 * the same page share keys constantly: two `{{t:title}}`, two `{{t:cta}}`. Merged into a single
	 * map one of the two contents wins and the other is quietly replaced by it — in an operation
	 * whose one promise is that nothing is lost, that is the defect that must not exist. So each
	 * section is inlined with ITS OWN values first, and the request travels with `values: {}`: what
	 * arrives is already the readable form and there is nothing left to substitute.
	 *
	 * Repeaters cannot be folded in — they are arrays, and they travel as the JSON tail the format
	 * already has. There the collision cannot be dissolved by inlining, so the key is RENAMED
	 * (`cards` → `cards_2`) in the tail and in the markup that reads it. The names are the model's to
	 * rewrite anyway when it writes the merged section: what has to survive the trip is the content.
	 */
	function uniteContext( blocks ) {
		var parts     = [];
		var repeaters = {};

		blocks.forEach( function ( b, n ) {
			var values = b.values || {};
			var tpl    = inlineValues( b.template, values );

			Object.keys( values ).forEach( function ( key ) {
				if ( ! Array.isArray( values[ key ] ) ) { return; }

				var name = key;
				var bump = 2;
				while ( Object.prototype.hasOwnProperty.call( repeaters, name ) ) { name = key + '_' + ( bump++ ); }
				if ( name !== key ) { tpl = renameRepeater( tpl, key, name ); }
				repeaters[ name ] = values[ key ];
			} );

			// The comment names the part. The model needs it to tell them apart — and it is told, in
			// the prompt, to delete them: they are scaffolding, not content.
			parts.push( '<!-- PART ' + ( n + 1 ) + ' OF ' + blocks.length + ': ' + ( b.name || __( 'Section' ) ) + ' -->\n' + tpl );
		} );

		var merged = parts.join( '\n\n' );
		if ( Object.keys( repeaters ).length ) {
			merged += '\n<!--AIPB-VALUES-->\n' + JSON.stringify( repeaters );
		}

		return { template: merged, schema: { fields: [] }, values: {}, viewport: state.viewport };
	}

	/**
	 * Folds the real contents back into the placeholders: {{t:x}} → {{t:x|Hello}}.
	 *
	 * The same regex as the service's `inlineForm`, on purpose: what the model reads has to be the
	 * one shape it reads everywhere else. A placeholder that already carries a default is left
	 * alone (it is not matched), and arrays are not foldable — those are the repeaters, and they go
	 * out in the tail.
	 */
	function inlineValues( template, values ) {
		return String( template || '' ).replace(
			/\{\{([tciurs]):([a-zA-Z0-9_]+)\}\}/g,
			function ( all, kind, key ) {
				var v = values ? values[ key ] : null;
				if ( v === undefined || v === null || 'object' === typeof v || '' === String( v ) ) { return all; }

				return '{{' + kind + ':' + key + '|' + v + '}}';
			}
		);
	}

	/**
	 * Renames a repeater inside a template: the {{#loop … name="key"}} that opens it, and the dotted
	 * references that read it from inside ({{t:key.label}}).
	 *
	 * It only runs on a COLLISION between two sections being merged, which is why it can afford to
	 * be this narrow: those two forms are how a repeater is written, and getting the rename wrong on
	 * some third form would cost a row of content — which is exactly what renaming is here to save.
	 */
	function renameRepeater( template, from, to ) {
		var q = String( from ).replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );

		return String( template || '' )
			.replace( new RegExp( '(\\{\\{#loop\\b[^}]*\\bname\\s*=\\s*")' + q + '(")', 'g' ), '$1' + to + '$2' )
			.replace( new RegExp( '(\\{\\{\\s*[tciurhs]:)' + q + '(\\.)', 'g' ), '$1' + to + '$2' );
	}

	/**
	 * The prompt. Written in the prompts' language, like buildSplitPrompt above it.
	 *
	 * The rules it carries are the ones the failures are made of: a merge that summarises, a merge
	 * that leaves the two parts stacked inside one wrapper and calls it done, and a merge that keeps
	 * the vertical padding that used to separate them and now opens a hole in the middle.
	 */
	function buildUnitePrompt( text, blocks ) {
		var names = blocks.map( function ( b ) { return b.name || __( 'Section' ); } ).join( ' → ' );

		return 'MERGING SECTIONS. Above are ' + blocks.length + ' sections of the page in the order they appear ('
			+ names + '), each opened by an HTML comment that names it. They have to become ONE section.\n'
			+ ( text ? 'What the user asked for, and it governs the merge: ' + text + '\n' : '' )
			+ 'RULES:\n'
			+ '- Return ONE complete and SELF-SUFFICIENT section: a single root container, its markup, and only the CSS it needs.\n'
			+ '- KEEP EVERY CONTENT. Every text, image, link, icon and repeater row that was in the parts is in the result. Merging is not summarising: nothing is dropped for being repetitive, and no text is rewritten shorter.\n'
			+ '- REMOVE what only existed because they were separate: the duplicated wrappers, the vertical padding that now falls in the middle of the section, a heading that repeats what the one above it already says.\n'
			+ '- The result has to READ as one section: one hierarchy, one vertical rhythm, one grid. Two blocks stacked inside a single container is not a merge, it is what was already there.\n'
			+ '- Keep the editable fields’ placeholders, with the same names and the same kinds. Where two parts used the same name for different contents, RENAME one of the two: never make them share one.\n'
			+ '- Delete the «PART n OF m» comments: they are there to tell the parts apart, they are not content.';
	}

	// A whole page: first it PLANS the sections + the global design system (one call), then it
	// GENERATES and ADDS one section at a time. That way every call to the AI is small and fast (no
	// timeouts), the preview fills up progressively and a reference with several sections is rebuilt
	// section by section. The REFERENCE (PDF/images) is passed to every section too, so each can
	// faithfully reproduce its own piece of it; the design system extracted from the plan is injected
	// everywhere for visual consistency.
	/**
	 * A page on the web, rebuilt as sections of this one.
	 *
	 * IT DELIBERATELY BECOMES A PLAN. What the service brings back — an ordered list of sections,
	 * each with a name and a vertical band — is the shape a page plan already has, so it is handed
	 * to the machinery that exists: the same checklist you thin out before spending, the same
	 * parallel fan-out, the same band-cropping that gives every section its own slice of the
	 * reference, the same checks at the end. The only new thing is what goes in the brief.
	 *
	 * AND IT IS REBUILT, NOT PASTED. It would be less work to drop the captured markup straight
	 * into a block, and the result would be a page nobody can edit: no placeholders, so no Fields
	 * panel; no var(--aipb-*), so the brand cannot repaint it; and the render engine strips the
	 * third-party scripts anyway, so the behaviour would be missing with no explanation. Going
	 * through the model costs a generation per section and returns real Aura sections.
	 */
	function importFromUrl( url ) {
		var progress = streamProgress( sprintf( __( 'Opening %s…' ), shorten( url, 60 ) ) );

		api( '/capture', { url: url, width: 1440 } )
			.then( function ( cap ) {
				progress.end();

				var sections = ( cap && cap.sections ) ? cap.sections : [];
				if ( ! sections.length ) {
					botMsg( __( 'That page opened, but I could not tell its sections apart. It may be built as one single block — try a different page of the same site.' ), 'warn' );
					setBusy( false );
					renderMessages();
					return;
				}

				// The full-page screenshot becomes the reference every section crops its own band
				// out of — exactly as it works when you attach a mockup by hand.
				if ( cap.screenshot ) {
					genCropRef = { kind: 'image', full: 'data:image/jpeg;base64,' + cap.screenshot };
				}

				/* THE FURNITURE GOES FIRST, AND SAYS WHAT IT IS.
				 *
				 * A fixed bar is not a band of the page: it is pinned to an edge of the screen and
				 * everything else scrolls past it. Rebuilt as an ordinary section it lands
				 * somewhere in the middle of the flow — which is exactly what happened to the
				 * original's bottom navigation. It is built first because it frames the rest.
				 */
				var furniture = ( cap.furniture || [] ).map( function ( f, i ) {
					var where = 'full height' === f.edge
						? 'pinned down the edge of the screen'
						: 'pinned to the ' + f.edge + ' of the screen';

					return {
						name: importName( f, i ) + ' (fixed)',
						shot: f.shot || '',
						brief: 'REPRODUCE this element. It is NOT a band of the page: it is ' + where
							+ ', with position: ' + f.position + ', and it stays put while the page scrolls behind it.'
							+ ' It is ' + f.width_px + 'px wide'
							+ ( f.full_width ? ' (the full width of the screen)' : '' )
							+ ' and ' + f.height_px + 'px tall, above the page content at z-index ' + f.z_index + '.'
							+ ' Build it as one section whose outermost element carries that position, that edge and that'
							+ ' stacking — do not let it flow with the rest of the page.'
							+ ( f.text ? '\n\nTEXT ON IT (use it exactly, word for word):\n' + f.text : '' )
							+ ( f.html ? '\n\nMARKUP OF THE ORIGINAL (same elements, same order; write your own class names,'
								+ ' and copy the image tokens exactly as they are):\n' + f.html : '' )
							+ ( f.css ? '\n\nSTYLES THE BROWSER RESOLVED (data-i="n" matches #n above). Write these values'
								+ ' as they are:\n' + f.css : '' )
							+ '\n\nIt has to survive a narrow screen: keep it pinned, and let its contents wrap or scroll'
							+ ' rather than overflow the viewport.',
					};
				} );

				var plan = sections.map( function ( sec, i ) {
					return {
						name: importName( sec, i ),
						brief: importBrief( sec, cap ),
						y_start_pct: sec.y_start_pct,
						y_end_pct: sec.y_end_pct,
						// This section's own frame, when the capture took one.
						shot: sec.shot || '',
					};
				} );

				plan = furniture.concat( plan );

				var found = [ sprintf( _n( '%d section', '%d sections', plan.length ), plan.length ) ];
				if ( furniture.length ) {
					found.push( sprintf( _n( '%d fixed element', '%d fixed elements', furniture.length ), furniture.length ) );
				}
				if ( cap.fonts && cap.fonts.length ) { found.push( sprintf( __( 'fonts: %s' ), cap.fonts.slice( 0, 3 ).join( ', ' ) ) ); }
				// `behaviours` was the page-wide guesswork this replaced; motion is per-section now.
				if ( cap.widgets && cap.widgets.length ) {
					found.push( sprintf( _n( '%d interactive element', '%d interactive elements', cap.widgets.length ), cap.widgets.length ) );
				}

				botMsg( sprintf( __( '«%s» — I found: ' ), cap.title || url ) + found.join( ' · ' )
					+ '\n\n' + __( 'Each one is rebuilt from scratch as an editable Aura section, with your design system\'s tokens where they fit. Untick what you do not want: every section costs a generation.' ) );
				renderMessages();

				var existingNames = state.blocks.map( function ( b ) { return b.name; } );

				return reviewPlan( plan, '' ).then( function ( chosen ) {
					if ( ! chosen.length ) {
						botMsg( __( 'I have generated nothing.' ) );
						setBusy( false );
						renderMessages();
						return;
					}

					// For the length of this fan-out the generations are REPRODUCTIONS: the service
					// swaps its design chapter for the reproduction rules, so the measured values
					// win over this site's tokens. It is cleared in a finally, because a flag like
					// this left standing would quietly turn the next ordinary generation into a
					// copy of nothing.
					state.reproducing = true;
					state.importAssets = cap.assets || {};

					return generateSectionsParallel(
						sprintf( __( 'A faithful reproduction of the page at %s.' ), url ) + importSystem( cap ),
						chosen,
						existingNames,
						[],
						importDesign( cap ),
						'',
						null
					).then( function () {
						state.reproducing = false;
						state.importAssets = null;
						setBusy( false );
					}, function ( err ) {
						state.reproducing = false;
						state.importAssets = null;
						throw err;
					} );
				} );
			} )
			.catch( function ( err ) { progress.end(); genError( err ); } );
	}

	/**
	 * Puts the real image addresses back, once the model has finished with the section.
	 *
	 * WHY THE MODEL NEVER SEES THEM. A CDN address is a long opaque string —
	 * `…/images/WRWdZ0VOW0YJb77EhCfOOVl5g.svg?width=1614` — and asking a language model to
	 * reproduce dozens of those exactly is asking it to do the one thing it is worst at. It
	 * generalises the pattern instead, and invents plausible neighbours: `zama-wordmark.png`,
	 * `ninja-card.jpg`, one hash begun faithfully and then padded with zeros. On the first page
	 * this was tried, twenty-eight of thirty images were fabricated and broken.
	 *
	 * So the capture hands it `AURA-IMG-7`, which anyone can copy, and the address comes back here.
	 * Applied to the template AND to the values, because the DSL splits an image between the two:
	 * the placeholder lives in the markup and its default sits in the values.
	 */
	function restoreAssets( res, assets ) {
		if ( ! res || ! assets ) { return res; }

		var swap = function ( text ) {
			if ( typeof text !== 'string' || text.indexOf( 'AURA-IMG-' ) < 0 ) { return text; }
			return text.replace( /AURA-IMG-(\d+)(-SET)?/g, function ( whole, n, isSet ) {
				var asset = assets[ 'AURA-IMG-' + n ];
				if ( ! asset ) { return whole; }
				return isSet ? ( asset.srcset || asset.src ) : asset.src;
			} );
		};

		res.template = swap( res.template );

		var walk = function ( value ) {
			if ( typeof value === 'string' ) { return swap( value ); }
			if ( Array.isArray( value ) ) { return value.map( walk ); }
			if ( value && typeof value === 'object' ) {
				var out = {};
				Object.keys( value ).forEach( function ( k ) { out[ k ] = walk( value[ k ] ); } );
				return out;
			}
			return value;
		};
		res.values = walk( res.values );

		return res;
	}

	/** A name for a captured section: its own class when it is readable, its position otherwise. */
	function importName( sec, i ) {
		var raw = String( sec.name || sec.tag || '' ).replace( /^[a-z]+\./, '' ).replace( /[-_.]+/g, ' ' ).trim();
		if ( raw && raw.length < 28 && ! /^\d/.test( raw ) ) {
			return raw.charAt( 0 ).toUpperCase() + raw.slice( 1 );
		}
		return sprintf( __( 'Section %d' ), i + 1 );
	}

	/** What the page as a whole looks like, so every section shares one description of it. */
	function importDesign( cap ) {
		var out = [ __( 'Rebuild of an existing page. Follow the reference and the measured values faithfully.' ) ];
		if ( cap.fonts && cap.fonts.length ) {
			// Swapping in the site's brand fonts was the single most visible way this stopped being
			// a copy: type is most of what makes a page recognisable. The original's family is
			// asked for, with a fallback of the same category behind it — it may well not load on
			// a visitor's machine, and a copy that falls back to the wrong category reads worse
			// than one that falls back to the right one.
			out.push( sprintf( __( 'The original is set in: %s. Reproduce that typeface, written as a full stack with a fallback of the same category behind it. Do NOT substitute this site\'s brand fonts: the point of these sections is that they look like the page they came from.' ), cap.fonts.join( ', ' ) ) );
		}
		return out.join( ' ' );
	}

	/**
	 * The original's OWN design system, read out of its stylesheets.
	 *
	 * This is the one part of a capture that is not a measurement but a reading: the tokens are the
	 * values a person wrote and named, the breakpoints are the widths they decided to change at,
	 * and the interaction rules are what happens on hover and focus — a thing no computed style can
	 * ever show, because it only exists while a pointer is there.
	 *
	 * It goes in ONCE, in the shared page description, rather than in every section's brief: it is
	 * the same for all of them, and repeating it per section would multiply it by the number of
	 * sections for no added meaning.
	 */
	function importSystem( cap ) {
		var out = [];

		if ( cap.tokens && cap.tokens.length ) {
			out.push( '\n\nTHE ORIGINAL\'S DESIGN TOKENS, as its own stylesheets declare them. Where one of these'
				+ ' matches a value you are about to write, it tells you the value was deliberate and what its'
				+ ' role is — use the number, not the name (these variables do not exist on the site being'
				+ ' built):\n' + cap.tokens.slice( 0, 120 ).join( '; ' ) );
		}

		if ( cap.breakpoints && cap.breakpoints.length ) {
			out.push( '\n\nTHE WIDTHS THE ORIGINAL CHANGES AT: ' + cap.breakpoints.join( ', ' )
				+ '. Use these as your own breakpoints rather than inventing others — they are where this design'
				+ ' was actually decided to reflow.' );
		}

		if ( cap.states && cap.states.length ) {
			out.push( '\n\nWHAT THE ORIGINAL DOES ON HOVER AND FOCUS (the resting styles you were given cannot'
				+ ' show this, so without it these states would be invented):\n'
				+ cap.states.slice( 0, 25 ).join( '\n' ) );
		}

		return out.join( '' );
	}

	/**
	 * What MOVES in one section, written for whoever has to rebuild it.
	 *
	 * WHY IT IS PER SECTION. The first version handed every section the same page-wide sentence —
	 * «this page has a carousel, an accordion and a marquee» — so the footer was told about the
	 * carousel and the section that actually owned one heard nothing louder than the rest. That is
	 * the whole of "the animations are inconsistent": nobody was told what was theirs.
	 *
	 * WHY IT IS OBSERVED AND NOT GUESSED. These are the animations the browser was really running,
	 * the reveals seen by watching an element come into view, and the @keyframes those animations
	 * name — with durations, easings and the actual from/to values. A class called `animate-thing`
	 * says something moved; `opacity 0 → 1 over 250ms, ease-out, on 19 elements` says what to write.
	 */
	function importMotion( sec, cap ) {
		var i = sec.index;
		var lines = [];

		( cap.widgets || [] ).forEach( function ( w ) {
			if ( w.section === i ) { lines.push( '· ' + w.what + ' — ' + w.element ); }
		} );

		( cap.reveals || [] ).forEach( function ( r ) {
			if ( r.section !== i ) { return; }
			var what = ( r.from.opacity !== r.to.opacity )
				? 'fades in (opacity ' + r.from.opacity + ' → ' + r.to.opacity + ')'
				: 'slides in (transform ' + r.from.transform + ' → ' + r.to.transform + ')';
			lines.push( '· ' + ( r.count > 1
				? r.count + ' elements ' + what + ' as the section enters the viewport, staggered'
				: 'one element ' + what + ' as it enters the viewport' )
				+ ( r.duration_ms ? ', ' + r.duration_ms + 'ms' : '' )
				+ ( r.easing ? ' ' + r.easing : '' )
				+ ( r.delay_ms ? ', delayed ' + r.delay_ms + 'ms' : '' )
				+ ' — e.g. ' + r.examples.join( ', ' ) );
		} );

		( cap.motion || [] ).forEach( function ( m ) {
			if ( m.section !== i ) { return; }
			lines.push( '· ' + m.kind + ' "' + m.name + '" on ' + m.element
				+ ( m.duration_ms ? ', ' + m.duration_ms + 'ms' : '' )
				+ ( m.easing ? ' ' + m.easing : '' )
				+ ( 'infinite' === m.iterations ? ', looping' : '' ) );
		} );

		if ( ! lines.length ) { return ''; }

		var out = '\n\nWHAT MOVES HERE — observed in a real browser on the original page, not guessed:\n'
			+ lines.join( '\n' )
			+ '\nReproduce this motion in plain CSS and JavaScript: no libraries, nothing loaded from another site'
			+ ' (the engine strips those, and the behaviour would go with them). Use IntersectionObserver for'
			+ ' anything that happens on entering the viewport. Respect prefers-reduced-motion.';

		// The exact definitions, when the animations named any. This is the difference between a
		// rebuild that matches and one that merely also moves.
		if ( cap.keyframes && cap.keyframes.length ) {
			out += '\n\nThe @keyframes those animations use, exactly as the original defines them:\n'
				+ cap.keyframes.join( '\n' );
		}

		return out;
	}

	/**
	 * The brief of one captured section: the picture, the words, the markup and the measurements.
	 *
	 * THE WARNING ABOUT PIXELS IS THE IMPORTANT LINE. What comes back are the values the browser
	 * RESOLVED at one viewport width, so a grid written `1fr` arrives as `1232px`. Reproduced
	 * literally it would rebuild the page frozen at the width it was measured at: right on the
	 * machine that captured it, broken on every phone. The capture already drops the measurements
	 * that were merely "this block filled its parent"; this says what to do with the ones that are
	 * left.
	 */
	function importBrief( sec, cap ) {
		var out = __( 'REPRODUCE this section. It is a copy job, not a design job: the aim is that somebody putting the two side by side cannot tell which is which. Same layout, same proportions, same type, same colours, same spacing, same content. Do not improve it, do not tidy it, do not harmonise it with anything.' );

		if ( sec.text ) {
			out += '\n\nTEXT ON THE PAGE (use it exactly, word for word — do not rewrite, do not shorten, do not translate):\n' + sec.text;
		}
		if ( sec.html ) {
			out += '\n\nMARKUP OF THE ORIGINAL (the structure to reproduce — same elements, same order, same nesting. Write your own class names and put the DSL placeholders on the text and images that a person would want to edit later, but do NOT restructure it):\n' + sec.html;

			// The addresses are tokens, and that is deliberate: see restoreAssets().
			out += '\n\nTHE IMAGES: every src in the markup above is a short token — AURA-IMG-3 and'
				+ ' the like — standing in for the original photograph. Copy the token into the image'
				+ ' placeholder exactly as it is: {{i:photo|AURA-IMG-3}}. It is swapped for the real'
				+ ' address after you are done, so do not try to guess what the picture is called, do'
				+ ' not tidy the token, and do not replace it with a stock photo or a placeholder'
				+ ' service. Where a srcset token is present (AURA-IMG-3-SET) carry that through the'
				+ ' same way. Keep every image the original has: the pictures are part of what is'
				+ ' being reproduced.';
		}
		if ( sec.css ) {
			out += '\n\nSTYLES THE BROWSER RESOLVED, element by element — data-i="n" in the markup above is the #n here. These are MEASUREMENTS OF THE REAL PAGE: write them into your CSS as they are. Do not round them, do not replace them with a design-system variable that is merely close, do not leave one out because it looks unusual:\n' + sec.css;
		}

		out += '\n\nHOW TO READ THOSE MEASUREMENTS: they were taken at a viewport ' + ( cap.width || 1440 )
			+ 'px wide, so they are the USED values, not what the author wrote. Reproduce the PROPORTIONS and the'
			+ ' relationships, not the absolute numbers: a column measured 1232px inside a 1440px page is a fluid'
			+ ' column with a max-width, not a fixed one. Type sizes, weights, letter-spacing, radii, borders and'
			+ ' colours ARE meant literally. The section must work down to 360px.';

		out += importMotion( sec, cap );

		if ( sec.truncated ) {
			out += ' Some of the markup and styles were too long to bring back whole: where something is missing,'
				+ ' follow the reference image and keep the same spirit.';
		}

		return out;
	}

	function generatePage( text, images ) {
		skeletonMode = false; updatePreviewSkeleton(); // we want to watch the preview grow
		var existingNames = state.blocks.map( function ( b ) { return b.name; } );
		var context = { blocks: state.blocks.map( function ( b ) { return { name: b.name }; } ) };

		var progress = streamProgress( __( 'Planning the page…' ), planDigest );

		apiStream( { prompt: text, mode: 'plan', context: context, images: images || [] }, progress.delta, abortSignal(), progress.meta )
			.then( function ( plan ) {
				// The live bubble goes away: the plan is not applied here, it is SHOWN a few lines
				// down, and the declaration of intent goes with it — above the checklist, which is
				// where the decision is taken and therefore where knowing what the model understood
				// is worth something. One bubble, not two saying the same thing.
				progress.end();
				var intent   = ( plan && plan._intent ) ? plan._intent : '';
				var sections = ( plan && plan.sections ) ? plan.sections : [];
				var design = ( plan && plan.design ) ? plan.design : '';
				var tokensSpec = ( plan && plan.tokens_spec ) ? plan.tokens_spec : '';
				if ( plan && plan._warning ) { botMsg( plan._warning, 'warn' ); }
				if ( ! sections.length ) {
					// No useful plan: fall back on a single section appended to the page.
					botMsg( ( intent ? intent + '\n' : '' ) + __( 'I could not plan the sections: generating a single one…' ) );
					renderMessages();
					return appendOneSection( text, images ).then( function () { setBusy( false ); } );
				}
				// The plan is SHOWN before being executed. It costs one screen and saves whole
				// generations: before, the ten sections all started together, and whoever did not
				// want the third found out once it was done — after paying for it.
				return reviewPlan( sections, intent ).then( function ( chosen ) {
					if ( ! chosen.length ) {
						botMsg( __( 'I have generated nothing.' ) );
						setBusy( false );
						renderMessages();
						return;
					}

					// The plan may have asked for content types the site does not have: they are
					// created BEFORE generating. Created afterwards, the sections would already have
					// been written without them, and the new type would stay a container nobody uses.
					return applyPlanModel( plan && plan.model, design, tokensSpec ).then( function ( outcome ) {
						return generateSectionsParallel( text, chosen, existingNames, images || [], design, tokensSpec, outcome ).then( function () {
							// Once the page is standing and can be looked at: the measurements that
							// held it together are kept for the next one. Last, after the direction,
							// because it is the same kind of statement about the site.
							adoptTokens( plan && plan.tokens_map, !! ( images && images.length ) );
							setBusy( false );
						} );
					} );
				} );
			} )
			.catch( function ( err ) { progress.end(); genError( err ); } );
	}

	/**
	 * WHAT the planner is doing, out of the JSON it is midway through writing.
	 *
	 * The raw tail would be no use here: a plan is one long line of JSON, and watching braces go by
	 * says nothing. But the keys arrive in the order the prompt lists them — design, tokens, model,
	 * sections — so the text can say which stage it is at, and then name the sections as they are
	 * born. Which is the part the user is actually waiting for.
	 */
	function planDigest( body ) {
		var lines = [];
		if ( /"design"/.test( body ) ) { lines.push( '· ' + __( 'design direction' ) ); }
		if ( /"tokens"/.test( body ) ) { lines.push( '· ' + __( 'design tokens' ) ); }
		if ( /"model"/.test( body ) ) { lines.push( '· ' + __( 'content types' ) ); }

		// Section names, from the part of the JSON that declares them: before "sections" the same
		// key belongs to taxonomy terms, and listing those as though they were sections would be a
		// lie told in the one place meant to reassure.
		var at = body.indexOf( '"sections"' );
		if ( at >= 0 ) {
			var names = body.slice( at ).match( /"name"\s*:\s*"((?:[^"\\]|\\.)*)"/g ) || [];
			names.forEach( function ( raw ) {
				var m = /"name"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec( raw );
				if ( m ) { lines.push( '· ' + m[ 1 ] ); }
			} );
			if ( ! names.length ) { lines.push( '· ' + __( 'breaking the page down…' ) ); }
		}

		return lines.slice( -6 ).join( '\n' );
	}

	/**
	 * The plan, before executing it: you look at it and thin it out.
	 *
	 * WHY. A whole page is the generation that costs the most — one call per section — and until now
	 * it all started at once, on trust. Whoever did not want the third section, or could see the
	 * model had misunderstood, could only wait for the end and delete: having paid. The checkboxes in
	 * the chat already exist (the global fan-out uses them), and here they are worth more.
	 *
	 * It does NOT reorder and does not rename: those are things better done AFTERWARDS, on the real
	 * blocks, where you can see what they are. Here all that is needed is being able to say «not this
	 * one».
	 *
	 * With a single section nothing is asked: a question confirming the obvious is a stolen click,
	 * and it is exactly the defect for which we removed the confirmation on content types.
	 */
	function reviewPlan( sections, intent ) {
		if ( sections.length < 2 ) { return Promise.resolve( sections ); }

		// The intent goes FIRST, above the list. The checklist says what will be built; the intent
		// says what the model thought it had been asked to build, and it is the difference between
		// the two that tells you to press Cancel — which is the whole reason this screen exists.
		var m = botMsg( ( intent ? intent + '\n\n' : '' ) + MARK.run + ' ' + sprintf(
			__( 'The plan is %d sections, one generation each. Take out the ones you do not want:' ),
			sections.length
		) );
		m.choices = sections.map( function ( s, i ) {
			var tag = isPageLayer( s ) ? ( ' [' + __( 'layer' ) + '] ' ) : '';
			return { on: true, id: i, label: tag + ( s.name || __( 'Section' ) ) + ( s.brief ? ' — ' + shorten( s.brief, 90 ) : '' ) };
		} );

		// While the question waits we are not busy: three dots above a question say «wait» exactly
		// while somebody is being asked to answer.
		setBusy( false );

		return new Promise( function ( resolve ) {
			m.actions = [
				{
					label: __( 'Generate' ),
					primary: true,
					once: true,
					run: function () {
						setBusy( true );
						resolve( sections.filter( function ( s, i ) { return m.choices[ i ] && m.choices[ i ].on; } ) );
					},
				},
				{
					label: __( 'Cancel' ),
					once: true,
					run: function () { setBusy( true ); resolve( [] ); },
				},
			];
			renderMessages();
		} );
	}

	/** A text shortened at a word: cutting mid-syllable reads as a defect. */
	function shorten( text, max ) {
		text = String( text || '' ).replace( /\s+/g, ' ' ).trim();
		if ( text.length <= max ) { return text; }
		var short = text.slice( 0, max );
		var sp    = short.lastIndexOf( ' ' );

		return ( sp > max / 2 ? short.slice( 0, sp ) : short ) + '…';
	}

	/**
	 * The content types the plan asked for: they are created, and it is said.
	 *
	 * NO permission is asked. Asking would be consistent with the «Content model» page, which
	 * proposes and leaves saving to a hand — but there the data model is what is being designed,
	 * which is the task; here a page is being asked for, and the content type is the MEANS of having
	 * it. A question in the middle of a generation stops the work to confirm something the user
	 * already asked for by writing «all the planets».
	 *
	 * And it is reversible enough: removing a line from «Content model» stops the type being
	 * registered and the contents stay in the database, where they are deleted from the trash like
	 * any others. It is not Ctrl+Z, but it is not a door closing behind you either.
	 *
	 * The outcome carries TWO lists: the ones created and the ones left out (no permission, an
	 * error). The sections need to know — see modelNote() — because the brief has already been
	 * written assuming the type would be there.
	 *
	 * @return {Promise<{created: Array, skipped: Array}>}
	 */
	function applyPlanModel( model, design, tokensSpec ) {
		var types = ( model && model.types ) ? model.types : [];
		var taxes = ( model && model.taxonomies ) ? model.taxonomies : [];
		if ( ! types.length && ! taxes.length ) { return Promise.resolve( { created: [], skipped: [] } ); }

		if ( ! AIPB.canModel ) {
			// Without manage_options the route would answer 403. Saying so is useful: whoever is
			// building knows why the page came out with hand-written lists, and whom to ask.
			botMsg( sprintf(
				__( 'A new content type would be needed (%s), but creating it takes an administrator: the lists end up inside the sections.' ),
				types.map( function ( t ) { return t.plural || t.slug; } ).join( ' · ' )
			), 'warn' );
			return Promise.resolve( { created: [], skipped: types } );
		}

		( model.notes || [] ).forEach( function ( n ) { botMsg( n, 'warn' ); } );

		// From here down it is the same chain as the explicit request: what changes is where the
		// proposal comes from, not what is created nor how it is told. The page's design is passed
		// because the templates are born wearing the same clothes as the sections we are about to
		// make.
		return createModel( types, taxes, design, tokensSpec );
	}

	// How many sections to generate AT THE SAME TIME. The sections are independent of each other (the
	// plan knows them all in advance, and each receives the design + the tokens + the complete list
	// of names), so the only real constraint is not tripping the provider's rate limits — and indeed
	// the value comes from the server, which lowers it to 2 on the bridge (global concurrency 2).
	var GEN_CONCURRENCY = ( AIPB.genConcurrency > 0 ) ? AIPB.genConcurrency : 3;

	/**
	 * The longest edge a reference image is sent at.
	 *
	 * It was 1568, which is the ceiling of the models BEFORE the one this service runs on: past it
	 * they downscaled anyway, so sending more was waste. The current model reads images up to 2576
	 * on the long edge, and a reference scaled to 1568 is one we blurred ourselves — on a
	 * reproduction that is type, spacing and colour thrown away before the model ever sees them.
	 *
	 * It is not free: a full-resolution image costs roughly three times the tokens of a 1568 one.
	 * That is worth paying on a job whose whole point is fidelity, and it is why the cap stays a
	 * named constant rather than a number buried in two places.
	 */
	var REF_MAX_EDGE = 2576;

	// Generates the planned sections IN PARALLEL, inserting each in its own place as soon as it is
	// ready. To EVERY section we pass: the reference (for visual fidelity), the global design system
	// and the COMPLETE list of the page's sections (for consistency). Interruptible with STOP.
	//
	// The final order is guaranteed without waiting: every section knows its position in the plan, so
	// it inserts itself by counting how many of the sections before it have already landed.
	function generateSectionsParallel( pageText, sections, existingNames, images, design, tokensSpec, modelOutcome ) {
		var offset   = existingNames.length; // the position of the 1st new section in the page
		var landed   = new Array( sections.length ); // landed[i] = section i has entered the page
		var allNames = existingNames.concat( sections.map( function ( s ) { return s.name; } ) );
		var cursor = 0;

		// One live line per section, in place of the «＋ «Hero» (3/7)» bubbles that used to pile up
		// one per landing. Same information — which ones are done — plus the two things a growing
		// list of bubbles could not give: what is being written RIGHT NOW, and what each section
		// thinks it has been asked for. At the end the list stays, as the record of what was built.
		var progress = fanoutProgress(
			sections.map( function ( sec ) { return sec.name; } ),
			MARK.run + ' ' + __( 'Generating the sections in parallel…' )
		);

		var originalFlow = 0;
		for ( var k = 0; k < state.blocks.length; k++ ) {
			if ( ! isPageLayer( state.blocks[ k ] ) ) { originalFlow++; }
		}

		function insertAt( i, block ) {
			if ( isPageLayer( block ) ) {
				state.blocks.push( block );
				landed[ i ] = true;
				return;
			}
			var idx = originalFlow;
			for ( var j = 0; j < i; j++ ) {
				if ( landed[ j ] && ! isPageLayer( sections[ j ] ) ) { idx++; }
			}
			state.blocks.splice( idx, 0, block );
			landed[ i ] = true;
		}

		// The CSS of the FIRST section to land: it is the example the others look at instead of
		// interpreting a description. See start() below for why the order matters.
		var fingerprint = '';

		function runOne( i ) {
			var sec = sections[ i ];
			var isLayer = isPageLayer( sec );
			var position = offset + i + 1; // 1-based, from the top
			return resolveSectionImages( sec, images ).then( function ( r ) {
				var secImages = r.images, cropped = r.cropped;
				var prompt = buildSectionPrompt( pageText, sec, allNames, sections, design, position, !! ( secImages && secImages.length ), tokensSpec, cropped, modelOutcome, fingerprint );
				var ctx = { viewport: state.viewport, position: position };
				if ( isLayer ) { ctx.scope = 'page'; }
				var ask = function ( mode ) {
					return apiStream(
						{ prompt: prompt, mode: isLayer ? 'section' : ( mode || ( AIPB.intent ? 'intent' : 'section' ) ), context: ctx, images: secImages, reproduce: !! state.reproducing },
						function ( piece ) { progress.delta( i, piece ); },
						abortSignal()
					).then( function ( res ) {
						if ( res && res._fallback === 'section' && AIPB.intent && ! isLayer && ( ! mode || mode === 'intent' ) ) {
							return apiStream(
								{ prompt: prompt, mode: 'section', context: ctx, images: secImages, reproduce: !! state.reproducing },
								function ( piece ) { progress.delta( i, piece ); },
								abortSignal()
							);
						}
						return res;
					} );
				};

				// Retried, and only on TRANSIENT errors. In a ten-section page a one-second 529
				// lost a section forever, and whoever was watching saw a hole without knowing that
				// asking again was enough. Not on every error, though: a prompt the model refuses
				// it will refuse again, costing twice for the same outcome.
				return askRetrying( ask, function ( attempt, delay ) {
					botMsg( sprintf(
						__( '«%1$s» did not go through (attempt %2$d): trying again in %3$d s.' ),
						sec.name, attempt, Math.round( delay / 1000 )
					) );
					renderMessages();
				} );
			} ).then( function ( res ) {
				if ( res && ( res.template || res.component ) ) {
					var block = blockFromGen( res, sec.name );
					if ( isLayer ) { block.scope = 'page'; block.anchor = ''; }
					if ( ! fingerprint && ! isLayer ) { fingerprint = cssOf( block.template ); }
					insertAt( i, block );
					state.dirty = true;
					renderBlocks(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
					progress.settle( i, res );
					if ( res._warning ) { botMsg( res._warning, 'warn' ); }
				} else {
					// Here an empty answer IS a failure: unlike the global pass, a planned section
					// that produces nothing leaves a hole in the page.
					progress.settle( i, null, true );
					botMsg( sprintf( __( 'Section «%s» was not generated: skipping it.' ), sec.name ), 'warn' );
				}
				renderMessages();
			} ).catch( function ( err ) {
				// STOP: propagate it, so the whole page stops and genError() reports once.
				if ( isAbort( err ) ) { throw err; }
				// An error in a single section: it must NOT bring down the others already in flight.
				progress.settle( i, null, err );
				botMsg( sprintf( __( 'Section «%s» was not generated: %s' ), sec.name, ( err && err.message ) ? err.message : __( 'unexpected error' ) ), 'warn' );
				renderMessages();
			} );
		}

		// The pool: every worker takes the next free section for as long as there are any.
		function worker() {
			if ( ! state.busy || cursor >= sections.length ) { return Promise.resolve(); }
			return runOne( cursor++ ).then( worker );
		}

		/**
		 * THE FIRST ONE ALONE, then all the others.
		 *
		 * Until now the sections all started together and each received the design in WORDS —
		 * «understated, plenty of air» — plus the others' names. None saw anybody's code: consistency
		 * was by description, not by example, and that is where the page with three button styles,
		 * two different radii and a vertical rhythm that changes halfway comes from. Ten sections
		 * that read the same sentence and interpreted it in ten ways.
		 *
		 * Waiting for the first costs the parallelism of ONE section — usually the hero, that is the
		 * one that decides how the rest will look — and in exchange the others stop interpreting:
		 * they look.
		 */
		function start() {
			if ( sections.length < 2 ) { return runOne( cursor++ ); }

			return runOne( cursor++ ).then( function () {
				if ( ! state.busy ) { return; }
				var pool = [];
				for ( var k = 0; k < Math.min( GEN_CONCURRENCY, sections.length - 1 ); k++ ) { pool.push( worker() ); }
				return Promise.all( pool );
			} );
		}

		return start().then( function () {
			if ( ! state.busy ) { return; } // stopped: genError() gives the message
			var made = landed.filter( Boolean ).length;
			progress.end( MARK.done + ' ' + sprintf( _n( 'Page complete: %d section.', 'Page complete: %d sections.', made ), made ) );
			// Once the page is done, once only: inside runOne() it would be one message per section.
			// landed[] holds booleans (it is there to count), so the sections are re-read from the
			// state — which is also the only place where they are in the right order.
			autoTokenize( state.blocks.filter( function ( b ) { return b.type === 'inline' && b.template; } ) );
			renderMessages();
			offerDirection( design );

			return runCheck();
		} );
	}

	/**
	 * The character decided for this page, offered as the SITE's character.
	 *
	 * The site's art direction is empty almost always: writing a brief is a task nobody does
	 * spontaneously, and without it every future generation receives a palette, some fonts and
	 * nothing about HOW the site should feel — so it falls back on the default, which is everybody
	 * else's default. Meanwhile this page's plan did decide a character, and wrote it down: it is
	 * already an art direction, and it has just been approved by the facts, because the page that
	 * came out of it is there to be looked at.
	 *
	 * It is asked, instead of saved on our own, because it is a choice that holds across the WHOLE
	 * site and over all future generations — it is not like creating a content type for the page
	 * being made. And it is not asked twice: if a direction is already there, nothing is said.
	 */
	function offerDirection( design ) {
		if ( AIPB.hasDirection || ! design || design.length < 40 ) { return; }

		// DISTILLED BEFORE IT IS OFFERED, and the reason is on the record. A plan writes its design
		// notes for THAT page — the hero, the one big object, the exact orange of the background —
		// and offering that prose verbatim as the art direction of the SITE is how a page colonises
		// everything built after it: the service hands it to every future generation labelled
		// «binding — apply it to EVERY section». A section asked only to line up with its neighbours
		// came out orange on a site whose palette has no orange in it.
		//
		// So one call turns the notes into something that can hold for a footer and a contact form
		// too, and what is shown is what will be saved — asking about one text and storing another
		// is the kind of small lie that makes a setting impossible to trust.
		var pending = botMsg( MARK.run + ' ' + __( 'This page has a character. Seeing whether any of it holds for the whole site…' ) );
		renderMessages();

		api( '/direction/distill', { notes: design } )
			.then( function ( r ) {
				dropMsg( pending );
				// Nothing survived the subtraction: those notes were a layout, not a character.
				// Saying nothing at all is right — there is no question worth asking here.
				if ( ! r || ! r.ok || ! r.direction ) { renderMessages(); return; }
				askDirection( r.direction );
			} )
			.catch( function () {
				// An older service does not know this mode. The offer still works, on the notes as
				// they are: a page-shaped direction the user can edit beats no direction at all,
				// and it is exactly what happened before this existed.
				dropMsg( pending );
				askDirection( design );
			} );
	}

	/** Removes a message that was only there to say «working on it». */
	function dropMsg( m ) {
		var i = state.messages.indexOf( m );
		if ( i >= 0 ) { state.messages.splice( i, 1 ); }
	}

	function askDirection( design ) {
		var m = botMsg( __( 'This site has no character written down anywhere yet, and I have distilled one out of this page:' )
			+ '\n\n' + design
			+ '\n\n' + __( 'Shall I keep it as the site\'s art direction? It would hold for everything you generate from here on, single sections included. You can edit it whenever you like from Aura → Design Tokens.' ) );

		m.actions = [
			{
				label: __( 'Yes, keep it' ),
				primary: true,
				once: true,
				run: function () {
					api( '/direction', { direction: design } ).then( function ( r ) {
						if ( r && r.ok ) {
							AIPB.hasDirection = true;
							botMsg( MARK.done + ' ' + sprintf( __( 'Saved. You can change it whenever you like from %s.' ), 'Aura → Design Tokens' ) );
						} else {
							botMsg( __( 'There was already an art direction: I have left that one.' ) );
						}
						renderMessages();
					} ).catch( function () {} );
				},
			},
			{ label: __( 'No, it only holds for this page' ), once: true, run: function () {} },
		];
		renderMessages();
	}

	/**
	 * The scale MEASURED off a reference, kept as the site's structural tokens.
	 *
	 * The planner measures a mockup — section padding, content width, type sizes, radii, shadows —
	 * and those numbers used to reach the prompts of this page and then evaporate: the next page
	 * measured nothing, and a section added a month later matched neither the mockup nor this page.
	 *
	 * ONLY WHEN THERE WAS SOMETHING TO MEASURE. With no reference the planner INVENTS a scale, and the
	 * site already has one of its own that the whole design system is built around: swapping a
	 * coherent set of values for another set, chosen no better, is not an improvement — it is a change.
	 * So this holds for a reference and stays quiet otherwise.
	 *
	 * IT IS DONE, NOT ASKED, and that is a correction. It used to be a question — «shall I keep them
	 * as the site's design system?» — and the question could not be answered by the person being
	 * asked: it named the machinery (a design system, a reference, tokens) instead of the
	 * consequence, and neither button said what happens if you pick the other one. Worse, it was a
	 * question with only one sensible answer. Because /tokens/adopt fills EMPTY slots only and never
	 * overwrites a decision, «yes» could not cost anything, while «no» left the site with no scale at
	 * all — the very drift this exists to stop. So it is adopted, said plainly, and undone in one
	 * click by whoever disagrees.
	 */
	function adoptTokens( map, hasReference ) {
		if ( ! hasReference || ! map ) { return; }

		var keys = Object.keys( map );
		if ( ! keys.length ) { return; }

		api( '/tokens/adopt', { tokens: map } ).then( function ( r ) {
			if ( ! r || ! r.applied ) { return; } // everything was already somebody's decision: nothing happened, nothing to say
			var written = r.values || null;

			botMsg(
				sprintf(
					_n( 'I took %d measurement off your reference — spacing, widths, text sizes — and set it as the site\'s, so the next pages and the sections you add later come out to the same scale.',
						'I took %d measurements off your reference — spacing, widths, text sizes — and set them as the site\'s, so the next pages and the sections you add later come out to the same scale.', r.applied ),
					r.applied
				) + ( ( r.kept && r.kept.length )
					? ' ' + __( 'What you had already set by hand I have left as it was.' )
					: '' ) + ' ' + sprintf( __( 'They are yours to change from %s.' ), 'Aura → Design Tokens' ),
				'',
				written ? [ {
					label: __( 'Use them for this page only' ),
					once: true,
					run: function () {
						api( '/tokens/adopt', { revert: written } ).then( function ( u ) {
							botMsg( u && u.applied
								? __( 'Undone: those measurements hold for this page and go no further.' )
								: __( 'They have been edited since: I have left them as they are.' ) );
							renderMessages();
						} ).catch( function () {} );
					},
				} ] : null
			);
			renderMessages();
		} ).catch( function () {} );
	}

	/**
	 * The page's check, told in the chat.
	 *
	 * WHY HERE. `AIPB_Check` already existed, complete — images with no alt, two <h1>s, a {{#loop}}
	 * with no {{#empty}} that leaves a white hole the day the category empties — with its own route,
	 * and nobody called it: it was a finished gate that had never been installed. It costs
	 * milliseconds and zero tokens, so the right place is right after the generation, while whoever
	 * asked for the page is still there looking at it.
	 *
	 * WHY IT DOES NOT BLOCK. It prevents nothing and asks nothing: it warns. These are defects that
	 * raise no error and are invisible in the preview while logged in with a full database — they
	 * show up later, on the customer's site, and that is exactly why they have to be said BEFORE.
	 *
	 * The problems are shown on request and not all at once: ten red lines after a page has just been
	 * born read as a failure, and the page is in fact there.
	 */
	function runCheck() {
		var blocks = state.blocks
			.filter( function ( b ) { return b.type === 'inline' && b.template; } )
			.map( function ( b ) { return { name: b.name, template: b.template, values: b.values }; } );
		if ( ! blocks.length ) { return Promise.resolve(); }

		return api( '/check', { blocks: blocks } )
			.then( function ( r ) {
				// The whole page rewrites EVERYTHING: it is the only check that has seen every
				// section together with the others, so what it does not report really is gone.
				var list = ( r && r.issues ) ? r.issues : [];
				state.issues = {};
				list.forEach( function ( p ) {
					var b = ( p.index >= 0 ) ? inlineBlocks()[ p.index ] : null;
					var key = b ? b.id : '_page';
					( state.issues[ key ] = state.issues[ key ] || [] ).push( p );
				} );
				refreshChecks();
			} )
			.catch( function () {
				// The check is not the work: if it does not answer, we stay quiet. A warning about a
				// missed check would move the attention away from the page just made.
			} );
	}

	/** The sections in the order the server saw them: it is the index the problems come back with. */
	function inlineBlocks() {
		return state.blocks.filter( function ( b ) { return b.type === 'inline' && b.template; } );
	}

	/**
	 * The check of ONE section, right after it has been generated.
	 *
	 * WHY THIS WAS NEEDED TOO. `runCheck()` looks at the whole page and ran at two moments only:
	 * once the fan-out was over and at publication. That left uncovered the most frequent way of
	 * working of all — you generate ONE section, look at it, regenerate it — which is also the one
	 * where a defect costs least to correct, because whoever asked for it is still there and knows
	 * what they wanted. Anyone building that way could reach publication without ever having seen a
	 * check.
	 *
	 * IT COUNTS DOUBLE since the engine removes third-party libraries: a section built on Swiper
	 * arrives on the page with its slides stacked, and the code that would explain it has been
	 * removed on purpose. Without this line the disappearance is mute — which is exactly the defect
	 * we said we did not want to introduce.
	 *
	 * IT STAYS QUIET WHEN IT HAS NOTHING TO SAY. An «all good» after every generation is noise, and
	 * it teaches people to skip the line exactly when it does say something.
	 */
	function checkSection( sec ) {
		if ( ! sec || ! sec.template ) { return Promise.resolve(); }

		return api( '/check', {
			template: sec.template,
			values: sec.values || {},
			name: sec.name || '',
			scope: isPageLayer( sec ) ? 'page' : '',
		} ).then( function ( r ) {
			// Only THIS section changes: the others' results stay those of the last round, which is
			// correct — nobody has touched them.
			state.issues[ sec.id || SOLO_ID ] = ( r && r.issues ) ? r.issues : [];
			refreshChecks();
		} ).catch( function () {} );
	}

	/** Every problem in one list, with the id of the block it belongs to. */
	function allIssues() {
		var out = [];
		Object.keys( state.issues || {} ).forEach( function ( id ) {
			( state.issues[ id ] || [] ).forEach( function ( p ) {
				out.push( { p: p, blockId: '_page' === id ? null : id } );
			} );
		} );

		return out;
	}

	/** The dot on the tab, and the panel when that is what is being looked at. */
	function refreshChecks() {
		var all    = allIssues();
		var errors = all.filter( function ( x ) { return 'error' === x.p.severity; } ).length;

		if ( dom.checksBadge ) {
			dom.checksBadge.textContent = all.length ? String( all.length ) : '';
			dom.checksBadge.style.display = all.length ? 'inline-block' : 'none';
			// Red only when something is broken: a dot that is always red no longer tells «the page
			// does not work» from «an alt is missing».
			dom.checksBadge.classList.toggle( 'grave', errors > 0 );
		}
		if ( 'checks' === state.tab ) { renderChecks(); }
	}

	/**
	 * The checks panel: what is wrong, grouped by section.
	 *
	 * WHY NOT IN THE CHAT. A problem is not an event in the conversation, it is a STATE of the page:
	 * it holds until it is fixed. In the chat it scrolled away — after three generations it was
	 * unreachable — and meanwhile it buried what was being read. Here it stays instead, it is read
	 * when wanted, and it disappears on its own once the problem is solved.
	 */
	function renderChecks() {
		if ( ! dom.checksWrap ) { return; }
		dom.checksWrap.innerHTML = '';

		var all = allIssues();

		var head = el( 'div', 'aipb-checks-head' );
		var again = el( 'button', 'aipb-btn ghost', '↻ ' + __( 'Check again' ) );
		again.type = 'button';
		again.disabled = !! state.busy;
		again.addEventListener( 'click', function () {
			if ( pageMode() ) { runCheck(); } else { checkSection( state ); }
		} );
		head.appendChild( el( 'div', 'aipb-checks-title', __( 'Checks' ) ) );
		head.appendChild( again );
		dom.checksWrap.appendChild( head );

		if ( ! all.length ) {
			dom.checksWrap.appendChild( el( 'div', 'aipb-fields-empty',
				__( 'No problem. The checks run on their own after every generation: they look at alternative texts, links, heading hierarchy, form fields, external libraries and missing design tokens.' ) ) );
			return;
		}

		var errors   = all.filter( function ( x ) { return 'error' === x.p.severity; } ).length;
		var warnings = all.length - errors;
		var bits     = [];
		if ( errors ) { bits.push( sprintf( _n( '%d problem', '%d problems', errors ), errors ) ); }
		if ( warnings ) { bits.push( sprintf( _n( '%d warning', '%d warnings', warnings ), warnings ) ); }
		dom.checksWrap.appendChild( el( 'p', 'aipb-checks-sub', bits.join( ' · ' ) + '. '
			+ __( 'They raise no error and are invisible in the preview: they show on the published site.' ) ) );

		// Grouped by section, the errors before the warnings: you read from the top and go down for
		// as long as you feel like, instead of hunting for the red ones among the yellow.
		var byBlock = {};
		all.forEach( function ( x ) {
			var k = x.blockId || '_page';
			( byBlock[ k ] = byBlock[ k ] || [] ).push( x.p );
		} );

		Object.keys( byBlock ).sort( function ( a ) { return '_page' === a ? -1 : 1; } ).forEach( function ( id ) {
			var list = byBlock[ id ].slice().sort( function ( a, b ) {
				return ( 'error' === b.severity ? 1 : 0 ) - ( 'error' === a.severity ? 1 : 0 );
			} );
			var block = ( '_page' !== id ) ? state.blocks.filter( function ( b ) { return b.id === id; } )[ 0 ] : null;

			var group = el( 'div', 'aipb-checks-group' );
			var title = '_page' === id ? __( 'The whole page' )
				: ( block ? block.name : ( list[0].block || __( 'Section' ) ) );

			var h = el( 'div', 'aipb-checks-sec', title );
			if ( block ) {
				// Clicking takes you to the section that has the problem: without it, whoever reads
				// «the alt is missing» then has to hunt for it by hand among eight identical blocks.
				h.classList.add( 'clickable' );
				h.addEventListener( 'click', function () {
					state.selectedBlockId = block.id;
					setTab( 'blocks' );
					// The composer has to follow: this selection decides what the next message
					// changes, and it was the one path that left the target row saying otherwise.
					renderBlocks(); updateComposerHint();
				} );
			}
			group.appendChild( h );

			list.forEach( function ( p ) {
				var row = el( 'div', 'aipb-check-item' + ( 'error' === p.severity ? ' grave' : '' ) );
				row.appendChild( el( 'span', 'aipb-check-ico', 'error' === p.severity ? '✗' : '•' ) );
				row.appendChild( el( 'span', 'aipb-check-txt', p.text ) );
				group.appendChild( row );
			} );

			dom.checksWrap.appendChild( group );
		} );
	}

	// The images to send for ONE section: if we have a single reference (a full-page screenshot) and
	// the section has a vertical band from the plan, we crop ONLY that band (sharp, focused);
	// otherwise we send the whole reference. { images: [...], cropped: bool }.
	function resolveSectionImages( sec, images ) {
		/* A SECTION'S OWN FRAME BEATS A CROP OF THE WHOLE PAGE.
		 *
		 * Cropping bands out of one full-page screenshot was the only option while that shot was
		 * all there was, and it cost dearly: a 12500px page compressed to 111 KB is 0.006 bytes per
		 * pixel — far past the point where a JPEG still holds detail. Shapes survived that;
		 * letterforms did not. On a page whose whole identity is enormous type, the model was being
		 * asked to reproduce what it could barely see.
		 *
		 * The capture now frames each section on its own, at a quality where type survives. Where
		 * one is present it is simply the better picture, and there is nothing to crop.
		 */
		if ( sec && sec.shot ) {
			return Promise.resolve( {
				images: [ { mime: 'image/jpeg', data: sec.shot } ],
				cropped: true,
			} );
		}

		if ( genCropRef && sec && sec.y_start_pct != null && sec.y_end_pct != null ) {
			return cropReferenceBand( genCropRef, sec.y_start_pct, sec.y_end_pct ).then( function ( crop ) {
				return crop ? { images: [ crop ], cropped: true } : { images: images || [], cropped: false };
			} );
		}
		return Promise.resolve( { images: images || [], cropped: false } );
	}

	// Crops the [yStart,yEnd] band (in %) from the high-resolution reference with generous PADDING
	// (half a band per side, at least 8% of the height), then scales it to <=REF_MAX_EDGE. The padding
	// guarantees that a wrong boundary never truncates the section: at worst it includes adjacent
	// context. It resolves to null when the bounds are inconsistent, the band is almost the whole
	// image, or the image does not load → the caller falls back on the whole image (no regression).
	function cropReferenceBand( refAtt, yStartPct, yEndPct ) {
		return new Promise( function ( resolve ) {
			if ( ! refAtt || ! refAtt.full || ! ( yEndPct > yStartPct ) || yStartPct < 0 || yEndPct > 100 ) {
				resolve( null ); return;
			}
			var img = new Image();
			img.onload = function () {
				try {
					var H = img.height, W = img.width;
					var pad = Math.max( ( yEndPct - yStartPct ) / 100 * 0.5, 0.08 );
					var y0 = Math.max( 0, yStartPct / 100 - pad );
					var y1 = Math.min( 1, yEndPct / 100 + pad );
					if ( ( y1 - y0 ) > 0.9 ) { resolve( null ); return; } // it covers almost everything: nothing to gain
					var sy = Math.round( y0 * H ), sh = Math.round( ( y1 - y0 ) * H );
					if ( sh <= 0 ) { resolve( null ); return; }
					var scale = Math.min( REF_MAX_EDGE / W, REF_MAX_EDGE / sh, 1 );
					var cw = Math.max( 1, Math.round( W * scale ) ), ch = Math.max( 1, Math.round( sh * scale ) );
					var c = document.createElement( 'canvas' );
					c.width = cw; c.height = ch;
					c.getContext( '2d' ).drawImage( img, 0, sy, W, sh, 0, 0, cw, ch );
					var url = c.toDataURL( 'image/jpeg', 0.9 );
					resolve( { mime: 'image/jpeg', data: url.split( ',' )[ 1 ] } );
				} catch ( e ) { resolve( null ); }
			};
			img.onerror = function () { resolve( null ); };
			img.src = refAtt.full;
		} );
	}

	// The prompt for generating ONE section of the plan, with the reference + the global design +
	// consistency. In the prompts' language (see the note in sendInstruction).
	function buildSectionPrompt( pageText, sec, createdNames, allSections, design, position, hasRef, tokensSpec, cropped, modelOutcome, fingerprint ) {
		var order = allSections.map( function ( s ) { return s.name; } ).join(', ');
		var ds = design
			? ( 'DESIGN SYSTEM OF THE PAGE (follow it TO THE LETTER — palette, typography, spacing, component style — so this section is consistent with the others):\n' + design + '\n' )
			: '';
		// Tokens MEASURED from the reference: exact values (hex/px/weights) to reproduce. For the
		// colours, if a brand colour plays the same role var(--aipb-color-*) is used; otherwise the
		// exact value is written IN THE CSS.
		//
		// There used to be «seed the colour in a {{c:role|#hex}} placeholder» here, and that line
		// arrived at the end of the user message, that is after every prohibition in the system
		// prompt: it won. The measured roles (background, surface, text, muted, accent) thus ended
		// up in the FIELDS panel, one per colour — the same colour written in two places, the field
		// and the Style panel, and whoever looked could not tell which of the two won.
		var tk = tokensSpec
			? ( 'DESIGN VALUES MEASURED FROM THE REFERENCE (reproduce them FAITHFULLY):\n' + tokensSpec + '\n'
				+ 'They hold where the site\'s design system does NOT already have its own variable: if a var(--aipb-*) exists for that measurement, use the variable and ignore the number above. For the COLOURS: if a brand colour (var(--aipb-color-*)) plays the same role use that one, otherwise write the exact value from the reference straight into the CSS rules. The roles above are NOT fields: no colour is to be exposed as an editable part, they are changed from the Style panel.\n' )
			: '';
		var ref = hasRef
			? ( cropped
				? ( 'The attached REFERENCE is the BAND of the section «' + sec.name + '»: its content is at the CENTRE of the image. Any PARTIAL content, cut off at the edges (top/bottom), belongs to the adjacent sections — IGNORE IT. Reproduce the central section FAITHFULLY: same layout, proportions, colours, typography, spacing and real content. ' )
				: ( 'In the attached REFERENCE find the section «' + sec.name + '» (roughly the ' + ordinal( position ) + ' from the top) and REPRODUCE IT FAITHFULLY: same layout, proportions, colours, typography, spacing and real content. ' ) )
			: '';
		var already = createdNames.length
			? ( 'Sections already on the page (keep absolute consistency of palette, typography, spacing, button style and rhythm with them): ' + createdNames.join(', ') + '. ' )
			: '';
		// One <h1> per page: only the section with the main title may use it.
		var heading = position === 1
			? 'HEADINGS: if this section holds the MAIN heading of the page (the hero) use <h1> once and only once; if instead it is a header/navigation menu do NOT use <h1>. '
			: 'HEADINGS: do NOT use <h1> (this is not the section of the main heading): use <h2> for this section\'s headings. ';
		return 'You are building a page made, in order, of these sections: ' + order + '.\n'
			+ ds
			+ tk
			+ 'Generate NOW ONLY the section «' + sec.name + '».\n'
			+ ref
			+ 'Brief of the section: ' + ( sec.brief || sec.name ) + '\n'
			+ modelNote( modelOutcome )
			+ imprintNote( fingerprint )
			+ anchorNote( allSections, sec )
			+ already
			+ heading
			+ 'General context of the page (the user\'s request): ' + pageText + '\n'
			+ 'Take particular care over responsive behaviour, above all on mobile, and over accessibility (semantics, alt, keyboard).';
	}

	/** "1st", "2nd", "3rd"… — it goes into a prompt, so it is not a translated string. */
	function ordinal( n ) {
		var v = n % 100;
		if ( v >= 11 && v <= 13 ) { return n + 'th'; }
		return n + ( [ 'th', 'st', 'nd', 'rd' ][ n % 10 ] || 'th' );
	}

	/** A template's <style> alone: it is the only part needed as a style example. */
	function cssOf( template ) {
		var m = String( template || '' ).match( /<style\b[^>]*>([\s\S]*?)<\/style>/i );

		return m ? m[ 1 ].trim() : '';
	}

	/**
	 * The first section's CSS, given to the following ones as an EXAMPLE.
	 *
	 * A design described in words («understated, plenty of air») leaves ten interpretations; the real
	 * CSS leaves one. It is not asked to be copied — the sections do different things — but for the
	 * same DECISIONS to be reused: the spacing scale that section really adopted, the radius of its
	 * corners, the weight of its headings, its shadow.
	 *
	 * It is truncated, because a hero's CSS can be long and what matters here are the first rules —
	 * where the container, the headings and the buttons live — not the last media query.
	 */
	function imprintNote( fingerprint, which ) {
		if ( ! fingerprint ) { return ''; }
		var css = fingerprint.length > 2600 ? fingerprint.slice( 0, 2600 ) + '\n/* … */' : fingerprint;

		return 'CONSISTENCY WITH THE REST OF THE PAGE — this is the CSS of the ' + ( which || 'FIRST' ) + ' section, already generated. '
			+ 'Do NOT copy it and do not redo that section: reuse the same DECISIONS, that is the spacing '
			+ 'scale, the corner radius, the weights and sizes of the headings, the style of the buttons, '
			+ 'the shadow and the colours in the roles it put them in. Where this section does something that '
			+ 'was not there, invent it in the same spirit.\n```css\n' + css + '\n```\n';
	}

	/**
	 * The targets an in-page link may point at.
	 *
	 * Without this list the model invents: it writes `<a href="#prices">` because that is the
	 * sensible thing to write, and on that page `#prices` exists nowhere. The browser raises no error
	 * and does not move — the classic silent failure, discovered by clicking.
	 *
	 * The anchors of the sections already in the page are the real ones; those of the sections still
	 * to be generated are known in advance because the plan decides the name: they are all passed, so
	 * the first section can link to the last even though the last does not exist yet.
	 */
	function anchorNote( allSections, sec ) {
		var live = pageAnchors();
		var seen = {};
		live.forEach( function ( a ) { seen[ a.anchor ] = true; } );

		var future = ( allSections || [] )
			.filter( function ( s ) { return s !== sec; } )
			.map( function ( s ) { return { anchor: plainAnchorSlug( s.name ), name: s.name }; } )
			.filter( function ( a ) { return a.anchor && ! seen[ a.anchor ]; } );

		var all = live.concat( future );
		if ( ! all.length ) { return ''; }

		return 'ANCHORS OF THE PAGE — if this section holds a link leading to another part of the same '
			+ 'page, use ONLY these targets, which really exist: '
			+ all.map( function ( a ) { return '#' + a.anchor + ' (' + a.name + ')'; } ).join( ', ' ) + '. '
			+ 'Do NOT invent others: an href to an anchor that does not exist raises no error, it simply leads nowhere. '
			+ 'And do not write the destination id yourself: the section receiving it already has it.\n';
	}

	/** A name's slug, without looking at what is taken: it is for the sections not yet born. */
	function plainAnchorSlug( name ) {
		var s = String( name || '' ).toLowerCase();
		if ( s.normalize ) { s = s.normalize( 'NFD' ).replace( /[̀-ͯ]/g, '' ); }

		return s.replace( /[^a-z0-9]+/g, '-' ).replace( /^-+|-+$/g, '' );
	}

	/**
	 * The types the plan had asked for and that were NOT created.
	 *
	 * About the created ones there is nothing to say: they are in the inventory that goes with the
	 * prompt, with their counts and their fields (field.acf_…), and repeating it here would be saying
	 * the same thing twice. The case to cover is the other one: the brief has already been written
	 * ASSUMING the type would be there («list the contents of the planet type»), and if the creation
	 * did not happen — no permissions, an error — the section would chase a type that does not exist.
	 * A loop over a non-existent type raises no error: it prints nothing.
	 *
	 * In the normal case (the plan asked for nothing) this line does not appear.
	 */
	function modelNote( outcome ) {
		var skipped = ( outcome && outcome.skipped ? outcome.skipped : [] ).map( function ( t ) { return t.slug; } );
		if ( ! skipped.length ) { return ''; }

		return 'NOT CREATED: the content types ' + skipped.join( ', ' ) + ' do not exist on this site. If the brief '
			+ 'asks for them to be listed, do NOT use {{#loop source="post"}} on those types — it would print emptiness: write the '
			+ 'entries as content of the section, with a repeater ({{#loop source="prop"}} and the values block at the bottom).\n';
	}

	/**
	 * The page this section is joining, shown as CODE rather than described.
	 *
	 * WHAT IT FIXES. When a plan builds a page, the first section is generated alone and its CSS is
	 * handed to all the others as an example ({@see imprintNote}) — that is what stops ten sections
	 * from inventing ten vertical rhythms. That machinery only ever ran inside a plan, and the case
	 * it never covered is the one that happens most: «add an FAQ» to a page that already exists,
	 * weeks later. That section got the art direction in words and the tokens, and not one line of
	 * what the page it is joining actually looks like — so it re-interpreted, exactly as the sections
	 * of a plan used to before the imprint existed.
	 *
	 * THE LAST SECTION is the example, being both the nearest one and the one most recently approved
	 * by whoever is looking at the page. In Section mode there is no page to be consistent with, and
	 * on an empty page there is nothing to show: in both cases this says nothing at all.
	 */
	function pageImprintNote() {
		if ( ! pageMode() ) { return ''; }

		var blocks = inlineBlocks();
		if ( ! blocks.length ) { return ''; }

		var names = blocks.map( function ( b ) { return b.name; } ).filter( Boolean );
		var note  = names.length
			? 'Sections already on the page, in order: ' + names.join( ', ' ) + '. Keep absolute consistency of palette, typography, spacing, button style and rhythm with them.\n'
			: '';

		return note + imprintNote( cssOf( blocks[ blocks.length - 1 ].template ), 'LAST' ) + '\n';
	}

	/**
	 * A page-wide overlay: one generation, appended at the end of the stack, outside the flow.
	 *
	 * The plan is the wrong door — it would invent a section — and appending a section is the other
	 * wrong door: the overlay would sit in the document between the last band and the footer.
	 */
	function generatePageLayer( text, images ) {
		var prompt = pageImprintNote() + text;
		var context = { viewport: state.viewport, scope: 'page' };
		var progress = streamProgress( __( 'Writing the page layer…' ) );

		return apiStream( { prompt: prompt, mode: 'section', context: context, images: images || [] }, progress.delta, abortSignal(), progress.meta )
			.then( function ( res ) {
				if ( res && res.template ) {
					var block = blockFromGen( res );
					block.scope = 'page';
					block.anchor = '';
					state.blocks.push( block );
					state.dirty = true;
					renderBlocks(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
					progress.end( res._warning || ( MARK.done + ' ' + __( 'Page layer added' ) ), res._warning ? 'warn' : '', res._intent );
					checkSection( block );
				} else {
					progress.end( __( 'The generation did not work. Try again.' ), 'warn', res && res._intent );
				}
				renderMessages();
			} )
			.catch( function ( err ) { progress.end(); throw err; } )
			.then( function () { setBusy( false ); }, genError );
	}

	/**
	 * One section, appended to the page: the plan's fallback, and the normal road for «add a …».
	 *
	 * It streams like every other single generation. It used not to, and it was the one route most
	 * likely to be taken — the whole «the page already exists, add one thing» case went through
	 * here — so the one place where the wait stayed a bare timer was the busiest.
	 *
	 * @param {Array} offer optional actions to hang off the finished message.
	 */
	function appendOneSection( text, images, offer, variants ) {
		var prompt = pageImprintNote() + text;
		var send   = function ( onPiece, onMeta ) {
			var mode = AIPB.intent ? 'intent' : 'section';
			return apiStream( { prompt: prompt, mode: mode, context: { viewport: state.viewport, position: state.blocks.length + 1 }, images: images || [] }, onPiece, abortSignal(), onMeta )
				.then( function ( res ) {
					if ( res && res._fallback === 'section' && AIPB.intent ) {
						return apiStream( { prompt: prompt, mode: 'section', context: { viewport: state.viewport }, images: images || [] }, onPiece, abortSignal(), onMeta );
					}
					return res;
				} );
		};

		// Several versions: the block is created empty and the versions are swapped inside it, so
		// the section keeps its place in the page while you look through them.
		if ( variants > 1 ) {
			var block = makeInlineBlock( { template: '', schema: { fields: [] }, values: {} } );
			state.blocks.push( block );
			renderBlocks();

			return runVariants( function ( i, onPiece ) { return send( onPiece ); }, block, true, variants )
				.then( function ( first ) {
					if ( first && offer ) {
						var m = botMsg( MARK.done + ' ' + __( 'Section added' ) );
						m.actions = offer;
						renderMessages();
					}
				} );
		}

		var progress = streamProgress( __( 'Writing the section…' ) );

		return send( progress.delta, progress.meta )
			.then( function ( res ) {
				if ( res && ( res.template || res.component ) ) {
					var block = blockFromGen( res );
					state.blocks.push( block );
					state.dirty = true;
					renderBlocks(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
					var m = progress.end( res._warning || ( MARK.done + ' ' + __( 'Section added' ) ), res._warning ? 'warn' : '', res._intent );
					if ( m && offer ) { m.actions = offer; }
					autoTokenize( block );
					// This route does not go through applySection (it adds a block instead of
					// replacing one), so the check has to be called by hand.
					checkSection( block );
				} else {
					progress.end( __( 'The generation did not work. Try again.' ), 'warn', res && res._intent );
				}
				renderMessages();
			} )
			.catch( function ( err ) { progress.end(); throw err; } );
	}

	// One section: a new component, or a refinement of the selected block.
	function generateSection( text, images, variants ) {
		var block = activeBlock();
		var ctxSrc = block ? block : state;
		var context = ctxSrc.template
			? { template: ctxSrc.template, schema: ctxSrc.schema, values: ctxSrc.values, viewport: state.viewport }
			: {};
		if ( block && isPageLayer( block ) ) { context.scope = 'page'; }
		var send = function ( onPiece, onMeta ) {
			return apiStream( { prompt: text, mode: 'section', context: context, images: images || [] }, onPiece, abortSignal(), onMeta );
		};

		// Several versions of the section that is already there: they are swapped into it in turn,
		// and what it held before is kept so that «None» really does put it back.
		if ( variants > 1 ) {
			runVariants( function ( i, onPiece ) { return send( onPiece ); }, block, false, variants )
				.then( function () { setBusy( false ); } )
				.catch( function ( err ) { setBusy( false ); genError( err ); } );
			return;
		}

		// This is the generation the user is WATCHING: one section, with them sitting there waiting.
		// It is the place where seeing the code arrive is worth most, and also the only one where it
		// does not confuse (in the fan-out several sections are in flight together).
		var progress = streamProgress( __( 'Writing the section…' ) );

		send( progress.delta, progress.meta ).then( function ( res ) {
			setBusy( false );
			if ( res && res.template ) {
				applySection( block, res );
				progress.end(
					res._warning || ( MARK.done + ' ' + ( block ? sprintf( __( 'Block "%s" updated' ), block.name ) : __( 'Done. Have a look at the preview.' ) ) ),
					res._warning ? 'warn' : '',
					res._intent
				);
			} else {
				progress.end( __( 'The generation did not work. Try again.' ), 'warn', res && res._intent );
			}
		} ).catch( function ( err ) {
			progress.end();
			genError( err );
		} );
	}

	/**
	 * The second pass: look at this section and make it better.
	 *
	 * DIFFERENT FROM «regenerate». Regenerating asks for a VARIANT — another layout, another style —
	 * and what you had is lost. Here the contents and the structure stay where they are: what is
	 * asked for is to criticise one's own work and fix the things that make it look like a template.
	 * It is the most reliable quality multiplier there is with a model, and the only honest one:
	 * «this is ugly» is not an automatic check, it is a judgement — so it is asked for on demand and
	 * costs a generation, instead of being imposed on every section.
	 *
	 * The prompt is in the prompts' language (see the note in sendInstruction).
	 */
	function refineBlock( block ) {
		if ( state.busy || ! block || block.type !== 'inline' ) { return; }
		state.selectedBlockId = block.id;
		renderBlocks(); updateComposerHint();
		userMsg( sprintf( __( 'Improve the design of «%s»' ), block.name || __( 'section' ) ) );
		setBusy( true ); refreshSaveState(); renderMessages();

		generateSection(
			'SECOND PASS ON THE DESIGN. Look at this section with the eye of somebody seeing it for the '
			+ 'first time and ask yourself what makes it look like a template instead of a designed page. '
			+ 'Then fix that.\n'
			+ 'Do NOT change the content (texts, images, links, data) and do NOT change the structure: the same '
			+ 'elements, in the same order, with the same placeholders and the same keys.\n'
			+ 'Work on: the contrast of the typographic scale (usually the heading is too timid and the levels '
			+ 'are too many), the air around what counts and the section\'s vertical padding, the hierarchy '
			+ '— one thing alone must be in charge —, the consistency of radii and shadows, the :hover and '
			+ ':focus-visible states, and the lazy symmetry of an equal-column grid if there is one.\n'
			+ 'If you think the section was already right, change little: redoing it for the sake of it is how you make it worse.',
			[]
		);
	}

	function regenerateBlock( block ) {
		if ( state.busy || ! block || block.type !== 'inline' ) { return; }

		// Regenerating rewrites the section from scratch, and the CSS with it: the touch-ups made by
		// hand with the Style panel disappear. They used to be lost in silence — you came back to the
		// section an hour later and the margins were the old ones, with nothing having said so. The
		// log (see `remember` in studio-style.js) at least makes it possible to warn.
		var byHand = ( block.styleEdits || [] ).length;
		if ( byHand && ! window.confirm( sprintf(
			_n( 'This section has %d style change made by hand: regenerating it will lose it. Go ahead?',
				'This section has %d style changes made by hand: regenerating it will lose them. Go ahead?', byHand ),
			byHand
		) ) ) {
			return;
		}

		state.selectedBlockId = block.id;
		renderBlocks(); updateComposerHint();
		userMsg( '↻ ' + sprintf( __( 'Regenerate «%s»' ), block.name || __( 'section' ) ) );
		skeletonMode = true;
		setBusy( true ); refreshSaveState(); renderMessages();
		generateSection( 'Propose an ALTERNATIVE VERSION of this section: same purpose and same content/texts, but a different design, layout and style.', [] );
	}

	/* ------------------------------ Blocks (a page) ------------------------ */

	function renderBlocks() {
		if ( ! dom.blocksWrap ) { return; }
		// The menu hangs outside the list and holds an index: if the list is rebuilt (a section
		// moved, the preview coming back) that index is no longer valid.
		closeBlockMenu();
		dom.blocksWrap.innerHTML = '';

		// The consistency pass: it used to be `/consistency`, that is a chat command you only know
		// about if somebody told you. It is the operation needed exactly here — in front of the list
		// of sections, when you can see they do not breathe the same.
		//
		// Pressing it now MEASURES before it spends: it reads the sections' CSS here in the browser,
		// works out what the page actually does, and says who disagrees. Only then does it offer to
		// generate, and only for those. It used to fire one call per section, each one shown nothing
		// but itself — see measureConventions() for why that could not have worked.
		if ( canGlobal() ) {
			var uni = el( 'button', 'aipb-btn ghost aipb-blocks-uniform', '≡ ' + __( 'Align the sections' ) );
			uni.type = 'button';
			uni.title = __( 'It reads the sections and works out what the page does — font, vertical padding, content width, heading sizes — then says which sections disagree. Costs nothing: you decide afterwards whether to align them.' );
			uni.disabled = !! state.busy;
			uni.addEventListener( 'click', function () { runConsistencyPass( '' ); } );
			dom.blocksWrap.appendChild( uni );
		}

		if ( ! state.blocks.length ) {
			dom.blocksWrap.appendChild( el( 'div', 'aipb-fields-empty', __( 'No block. Describe the page in the chat, or insert a component.' ) ) );
		}

		// What the next Unite would take, in the order it would take it. Read once here and not once
		// per row: it walks the whole list, and on a fifteen-section page that is fifteen walks to
		// paint fifteen badges.
		var uniteSet = uniteTargets();
		var layerHead = false;

		state.blocks.forEach( function ( b, idx ) {
			var renaming = ( state.renamingId === b.id );
			var markPos  = uniteSet.length > 1 ? uniteSet.indexOf( b ) : -1;
			var layer    = isPageLayer( b );

			if ( layer && ! layerHead ) {
				layerHead = true;
				dom.blocksWrap.appendChild( el( 'div', 'aipb-blocks-layers-label', __( 'Page layers' ) ) );
			}

			var card = el( 'div', 'aipb-block-card'
				+ ( b.id === state.selectedBlockId ? ' selected' : '' )
				+ ( markPos >= 0 ? ' marked' : '' )
				+ ( renaming ? ' renaming' : '' )
				+ ( layer ? ' layer' : '' ) );

			// Reordering by drag & drop (off while renaming, so the input stays usable). Page
			// layers sit outside the flow: dragging one between sections would put an overlay
			// back into the stack as if it were a band.
			card.draggable = ! renaming && ! layer;
			if ( ! layer ) {
			card.addEventListener( 'dragstart', function ( e ) {
				dragBlockIdx = idx; card.classList.add( 'dragging' );
				try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData( 'text/plain', 'aipb-block:' + b.id ); } catch ( err ) {}
			} );
			card.addEventListener( 'dragend', function () { dragBlockIdx = null; renderBlocks(); } );
			card.addEventListener( 'dragover', function ( e ) {
				if ( dragBlockIdx === null || dragBlockIdx === idx ) { return; }
				e.preventDefault();
				var r = card.getBoundingClientRect();
				var after = e.clientY > r.top + r.height / 2;
				card.classList.toggle( 'drop-after', after );
				card.classList.toggle( 'drop-before', ! after );
			} );
			card.addEventListener( 'dragleave', function () { card.classList.remove( 'drop-before', 'drop-after' ); } );
			card.addEventListener( 'drop', function ( e ) {
				if ( dragBlockIdx === null ) { return; }
				e.preventDefault();
				var r = card.getBoundingClientRect();
				var to = idx + ( e.clientY > r.top + r.height / 2 ? 1 : 0 );
				moveBlockTo( dragBlockIdx, to );
			} );
			}

			// The handle. That the list could be reordered by dragging was known only to whoever
			// tried: the two up/down arrows were the only signal that the order could change, and
			// they were the slow way of doing it. Now the signal is the handle, and the arrows live
			// in the menu — where they serve whoever does not use a mouse.
			var grip = el( 'span', 'aipb-block-grip' );
			grip.innerHTML = SVG_GRIP;
			grip.title = __( 'Drag to reorder' );
			grip.setAttribute( 'aria-hidden', 'true' );
			card.appendChild( grip );

			var main = el( 'div', 'aipb-block-main' );
			var badge = el( 'span', 'aipb-block-badge' + ( layer ? ' layer' : ( b.type === 'ref' ? ' ref' : ( b.type === 'catalog' ? ' catalog' : '' ) ) ), layer ? __( 'layer' ) : ( b.type === 'ref' ? __( 'component' ) : ( b.type === 'catalog' ? __( 'catalog' ) : __( 'inline' ) ) ) );
			main.appendChild( badge );

			if ( renaming ) {
				var input = el( 'input', 'aipb-block-rename' );
				input.value = b.name || '';
				input.addEventListener( 'click', function ( e ) { e.stopPropagation(); } );
				input.addEventListener( 'keydown', function ( e ) {
					if ( e.key === 'Enter' ) { e.preventDefault(); commitRename( b, input.value ); }
					else if ( e.key === 'Escape' ) { e.preventDefault(); state.renamingId = null; renderBlocks(); }
				} );
				input.addEventListener( 'blur', function () { commitRename( b, input.value ); } );
				main.appendChild( input );
				setTimeout( function () { input.focus(); input.select(); }, 0 );
			} else {
				main.addEventListener( 'click', function ( e ) {
					// Ctrl/Cmd+click MARKS instead of selecting: it is how the set Unite merges is
					// built, and it is the gesture every list of things you can pick several of
					// already uses. A plain click goes back to working on one section, and clears
					// the set — see selectBlock.
					if ( e.metaKey || e.ctrlKey ) {
						e.preventDefault();
						toggleMark( b );
						return;
					}
					focusBlock( b.id );
				} );
				// The mark's number is the order the merged section will be WRITTEN in — the page's
				// order, not the clicks'. Showing it is the only way to know that before spending a
				// generation to find out.
				if ( markPos >= 0 ) {
					var mark = el( 'span', 'aipb-block-mark', String( markPos + 1 ) );
					mark.title = b.id === state.selectedBlockId
						? sprintf(
							__( 'Position %1$d of %2$d in the merge. This is the selected section, and it is always part of it: click another one normally to move the selection.' ),
							markPos + 1, uniteSet.length
						)
						: sprintf(
							__( 'Marked for Unite, position %1$d of %2$d · ctrl/cmd+click to remove the mark' ),
							markPos + 1, uniteSet.length
						);
					main.appendChild( mark );
				}

				var nameEl = el( 'span', 'aipb-block-name', b.name || __( 'Section' ) );
				nameEl.title = __( 'Double click to rename (or F2)' )
					+ ( b.anchor ? '\n' + sprintf( __( 'Anchor: #%s' ), b.anchor ) : '' )
					+ ( b.req ? '\n' + sprintf( __( 'Generation: %s' ), b.req ) : '' )
					+ ( ( b.styleEdits || [] ).length
						? '\n' + sprintf( _n( '%d style change made by hand', '%d style changes made by hand', b.styleEdits.length ), b.styleEdits.length )
						: '' );
				nameEl.addEventListener( 'dblclick', function ( e ) { e.stopPropagation(); startRename( b ); } );
				main.appendChild( nameEl );
			}
			card.appendChild( main );

			// One button, with the actions WRITTEN OUT inside it. Before there were up to eleven
			// icons in a row in a 380px column: they wrapped onto two lines, pushed the section's
			// name out of the row, and to know that «⧉» saved a pattern you had to hover and wait
			// for the tooltip. Nobody learnt them, people clicked at random.
			var more = el( 'button', 'aipb-block-more' );
			more.type = 'button';
			// The whole card is draggable: without this, starting from the button drags the section
			// instead of opening the menu.
			more.draggable = false;
			more.innerHTML = SVG_MORE;
			more.title = __( 'The section\'s actions' );
			more.setAttribute( 'aria-label', sprintf( __( 'Actions of «%s»' ), b.name || __( 'section' ) ) );
			more.setAttribute( 'aria-haspopup', 'menu' );
			more.setAttribute( 'aria-expanded', 'false' );
			more.addEventListener( 'click', function ( e ) {
				e.stopPropagation();
				// Clicking the button again with the menu open: the mousedown has already closed it,
				// and this click would reopen it immediately. The button has to act as a switch.
				if ( menuSuppress === more && ( Date.now() - menuSuppressAt ) < 400 ) { menuSuppress = null; return; }
				openBlockMenu( blockMenuItems( b, idx ), more, null );
			} );
			card.appendChild( more );

			// Right click on the row: the same menu, where the mouse already is.
			card.addEventListener( 'contextmenu', function ( e ) {
				if ( renaming ) { return; }
				e.preventDefault();
				openBlockMenu( blockMenuItems( b, idx ), more, { x: e.clientX, y: e.clientY } );
			} );

			dom.blocksWrap.appendChild( card );
		} );

		if ( pageMode() ) {
			var footer = el( 'div', 'aipb-block-footer' );
			var addBtn = el( 'button', 'aipb-btn', __( 'Add new section' ) );
			addBtn.addEventListener( 'click', function () {
				state.selectedBlockId = null; renderBlocks();
				setTab( 'chat' ); updateComposerHint();
				botMsg( __( 'Describe the section to add.' ) );
				dom.input.focus();
			} );
			footer.appendChild( addBtn );

			// «Paste» only appears if the clipboard really holds something, and it says where from.
			var clip = readClip();
			if ( clip ) {
				var label = ( 1 === clip.items.length )
					? sprintf( __( 'Paste «%s»' ), clip.items[ 0 ].name || __( 'section' ) )
					: sprintf( __( 'Paste %d sections' ), clip.items.length );
				var pasteBtn = el( 'button', 'aipb-btn', label );
				pasteBtn.title = clip.from
					? sprintf( __( 'Copied from «%s». It goes at the end of the page, or after the selected section.' ), clip.from )
					: __( 'It goes at the end of the page, or after the selected section.' );
				pasteBtn.addEventListener( 'click', function () { pasteBlocks( clip ); } );
				footer.appendChild( pasteBtn );
			}

			// The FILE, which is a different thing from the clipboard: the clipboard carries a section
			// into another page of THIS browser, the file carries it onto another site — the case of
			// somebody who wants the same hero again on the next client. And it is the only one of
			// the two that, on arrival, says what is missing here compared with the site it came from.
			// Export and import go down to their ICONS. They are the rarest actions in this footer
			// — you use them when moving a section to another site — and as full-width buttons they
			// took two thirds of a row away from «Add new section», which is the one that gets
			// pressed. The name stays in the tooltip and in the aria-label: an icon-only control
			// that a screen reader cannot name is not a control.
			footer.appendChild( iconButton(
				SVG_EXPORT,
				__( 'Export to a file' ),
				__( 'Saves this page\'s sections into a file, to reuse them on another site.' ),
				function () { exportBlocks( state.blocks.filter( isInline ) ); }
			) );

			footer.appendChild( iconButton(
				SVG_IMPORT,
				__( 'Import from a file' ),
				__( 'Open a file exported by Aura, from another site too.' ),
				chooseImportFile
			) );

			dom.blocksWrap.appendChild( footer );
		}
	}

	// An «A» with a text cursor: rename. The pencil belongs to «Edit with the AI», which sits two
	// lines above in the menu: two pencils next to each other would be two indistinguishable entries.
	var SVG_RENAME = svgIcon( '<path d="M4 7V5h16v2"/><path d="M12 5v14"/><path d="M9 19h6"/>' );

	/**
	 * A button that is only an icon.
	 *
	 * The label does not disappear, it moves: into the tooltip for whoever hovers, and into the
	 * aria-label for whoever cannot see the icon at all. An icon-only control with no accessible
	 * name is a button that, read aloud, is called «button».
	 */
	function iconButton( icon, label, hint, onClick ) {
		var b = el( 'button', 'aipb-btn aipb-btn-icon' );
		b.type = 'button';
		b.innerHTML = icon;
		b.title = hint ? label + ' — ' + hint : label;
		b.setAttribute( 'aria-label', label );
		b.addEventListener( 'click', onClick );

		return b;
	}

	// An arrow going INTO a box: import. The mirror of the export one below, and deliberately so —
	// side by side in the footer, the direction of the arrow is the whole difference between them.
	var SVG_IMPORT = svgIcon( '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>' );

	// An arrow leaving a box: export. Distinct from the clipboard, which is another thing.
	var SVG_EXPORT = svgIcon( '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>' );

	// Arrows in a circle: regenerate. A star: improve. A grid: save as a reusable pattern.
	var SVG_REGEN  = svgIcon( '<path d="M21 3v6h-6"/><path d="M3 12a9 9 0 0 1 15.3-6.4L21 8"/><path d="M3 21v-6h6"/><path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/>' );
	var SVG_REFINE = svgIcon( '<path d="m12 4 1.8 5.2L19 11l-5.2 1.8L12 18l-1.8-5.2L5 11l5.2-1.8Z"/><path d="M18 3v3"/><path d="M19.5 4.5h-3"/>' );
	var SVG_COMPONENT = svgIcon( '<rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/>' );

	// Dots: it opens the menu. Six dots: the drag handle.
	var SVG_MORE = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="12" cy="19" r="1.9"/></svg>';
	var SVG_GRIP = '<svg width="10" height="16" viewBox="0 0 10 16" fill="currentColor" aria-hidden="true">'
		+ '<circle cx="3" cy="3" r="1.3"/><circle cx="3" cy="8" r="1.3"/><circle cx="3" cy="13" r="1.3"/>'
		+ '<circle cx="7" cy="3" r="1.3"/><circle cx="7" cy="8" r="1.3"/><circle cx="7" cy="13" r="1.3"/></svg>';

	/* --------------------- A section's actions menu ------------------------ */

	/**
	 * The menu's entries, in order of how often they are needed and grouped by family.
	 *
	 * The order is not decorative: at the top what is done ten times a day (changing the section,
	 * renaming it, duplicating it), in the middle the operations with the AI — which cost a
	 * generation, so they must not sit under the finger by accident — and at the bottom, set apart,
	 * the only one that destroys something.
	 */
	function blockMenuItems( b, idx ) {
		var items = [];
		var last = state.blocks.length - 1;

		if ( 'inline' === b.type ) {
			items.push( { icon: SVG_EDIT, label: __( 'Edit with the AI' ), hint: __( 'Describe the change in the chat' ),
				run: function () { editSection( idx ); } } );
		} else {
			items.push( { icon: SVG_PATTERN_EDIT, label: __( 'Edit the pattern' ), hint: __( 'In a new window: it changes everywhere it is used' ),
				run: function () { openPatternStudio( b.ref ); } } );
		}
		items.push( { icon: SVG_RENAME, label: __( 'Rename' ), run: function () { startRename( b ); } } );
		if ( 'inline' === b.type ) {
			items.push( { icon: SVG_DUP, label: __( 'Duplicate' ), run: function () { duplicateBlock( idx ); } } );
		} else {
			items.push( { icon: SVG_UNLINK, label: __( 'Unlink from the pattern' ), hint: __( 'From here on it is edited in this page only' ),
				run: function () { detachRef( b ); } } );
		}

		// The arrows that no longer live in the row: here they serve whoever does not use a mouse,
		// and whoever has to move a section to the top past twenty cards.
		items.push( { sep: true } );
		items.push( { icon: SVG_UP, label: __( 'Move up' ), disabled: 0 === idx, run: function () { moveBlock( idx, -1 ); } } );
		items.push( { icon: SVG_DOWN, label: __( 'Move down' ), disabled: idx >= last, run: function () { moveBlock( idx, 1 ); } } );

		if ( 'inline' === b.type ) {
			items.push( { sep: true } );
			items.push( { icon: SVG_REGEN, label: __( 'Regenerate a variant' ), hint: __( 'Same contents, different design and layout' ),
				disabled: !! state.busy, run: function () { regenerateBlock( b ); } } );
			items.push( { icon: SVG_REFINE, label: __( 'Improve the design' ), hint: __( 'Same contents, same structure' ),
				disabled: !! state.busy, run: function () { refineBlock( b ); } } );
		}

		items.push( { sep: true } );
		items.push( { icon: SVG_CLIPBOARD, label: __( 'Copy' ), hint: __( 'To the clipboard, to paste it into another page' ),
			run: function () { copyBlocks( [ b ] ); } } );
		if ( 'inline' === b.type ) {
			items.push( { icon: SVG_EXPORT, label: __( 'Export to a file' ), hint: __( 'To reuse it on another site' ),
				run: function () { exportBlocks( [ b ] ); } } );
			items.push( { icon: SVG_COMPONENT, label: __( 'Save as a pattern' ), hint: __( 'Reusable in other pages' ),
				run: function () { convertToComponent( b ); } } );
		}

		items.push( { sep: true } );
		items.push( { icon: SVG_X, label: __( 'Remove' ), danger: true, run: function () { removeBlock( idx ); } } );

		return items;
	}

	// One menu at a time. It lives in the Studio's root (not in `body`) for two reasons: the colour
	// variables are defined there, and `position: fixed` escapes the list's scrolling anyway, which
	// would otherwise clip the menu of the last sections.
	var blockMenu = null;
	var menuSuppress = null, menuSuppressAt = 0;

	function openBlockMenu( items, trigger, at ) {
		closeBlockMenu();

		var node = el( 'div', 'aipb-menu' );
		node.setAttribute( 'role', 'menu' );

		items.forEach( function ( it ) {
			if ( it.sep ) { node.appendChild( el( 'div', 'aipb-menu-sep' ) ); return; }

			var row = el( 'button', 'aipb-menu-item' + ( it.danger ? ' danger' : '' ) );
			row.type = 'button';
			row.setAttribute( 'role', 'menuitem' );
			row.disabled = !! it.disabled;

			var ico = el( 'span', 'aipb-menu-ico' );
			ico.innerHTML = it.icon || '';
			row.appendChild( ico );

			var txt = el( 'span', 'aipb-menu-txt' );
			txt.appendChild( el( 'span', 'l', it.label ) );
			if ( it.hint ) { txt.appendChild( el( 'span', 'h', it.hint ) ); }
			row.appendChild( txt );

			// Close BEFORE acting: almost all these actions redraw the list, and a menu still open
			// over a rebuilt list points at a section that has moved.
			row.addEventListener( 'click', function ( e ) { e.stopPropagation(); closeBlockMenu(); it.run(); } );
			node.appendChild( row );
		} );

		( root || document.body ).appendChild( node );
		placeBlockMenu( node, trigger, at );

		function enabledItems() {
			return Array.prototype.filter.call( node.querySelectorAll( '.aipb-menu-item' ), function ( n ) { return ! n.disabled; } );
		}

		function onDown( e ) {
			if ( node.contains( e.target ) ) { return; }
			// If the mousedown lands on the button that opened it, the click straight after must NOT
			// reopen it: it is noted here, and the button reads it.
			if ( trigger && trigger.contains( e.target ) ) { menuSuppress = trigger; menuSuppressAt = Date.now(); }
			closeBlockMenu();
		}

		function onKey( e ) {
			if ( 'Escape' === e.key ) { e.preventDefault(); e.stopPropagation(); closeBlockMenu( true ); return; }
			if ( 'Tab' === e.key ) { closeBlockMenu(); return; }
			if ( [ 'ArrowDown', 'ArrowUp', 'Home', 'End' ].indexOf( e.key ) < 0 ) { return; }
			e.preventDefault();
			var list = enabledItems();
			if ( ! list.length ) { return; }
			var i = list.indexOf( document.activeElement );
			if ( 'Home' === e.key ) { i = 0; }
			else if ( 'End' === e.key ) { i = list.length - 1; }
			else if ( 'ArrowDown' === e.key ) { i = ( i + 1 ) % list.length; }
			else { i = ( i <= 0 ? list.length : i ) - 1; }
			list[ i ].focus();
		}

		// Scrolling does not reposition the menu: it closes it. A menu chasing the row while the list
		// scrolls is worse than a menu that disappears.
		function onAway() { closeBlockMenu(); }

		document.addEventListener( 'mousedown', onDown, true );
		document.addEventListener( 'keydown', onKey, true );
		window.addEventListener( 'scroll', onAway, true );
		window.addEventListener( 'resize', onAway, true );

		blockMenu = { node: node, trigger: trigger, onDown: onDown, onKey: onKey, onAway: onAway };

		if ( trigger ) { trigger.setAttribute( 'aria-expanded', 'true' ); trigger.classList.add( 'open' ); }

		// Opened from the keyboard (Enter/Space on the button): the focus enters the menu, otherwise
		// the arrows have nowhere to start from. Not with the mouse: the first item highlighted by
		// default looks as though it were already selected.
		if ( ! at && trigger && trigger.matches( ':focus-visible' ) ) {
			var first = enabledItems()[ 0 ];
			if ( first ) { first.focus(); }
		}
	}

	function closeBlockMenu( restoreFocus ) {
		var m = blockMenu;
		if ( ! m ) { return; }
		blockMenu = null;
		document.removeEventListener( 'mousedown', m.onDown, true );
		document.removeEventListener( 'keydown', m.onKey, true );
		window.removeEventListener( 'scroll', m.onAway, true );
		window.removeEventListener( 'resize', m.onAway, true );
		m.node.remove();
		if ( m.trigger ) {
			m.trigger.setAttribute( 'aria-expanded', 'false' );
			m.trigger.classList.remove( 'open' );
			if ( restoreFocus && document.body.contains( m.trigger ) ) { m.trigger.focus(); }
		}
	}

	// Under the button and aligned right; if it does not fit below, above; and inside the window
	// either way, because the last sections in the list sit a step from the bottom edge.
	function placeBlockMenu( node, trigger, at ) {
		var m = node.getBoundingClientRect();
		var vw = window.innerWidth, vh = window.innerHeight, pad = 8;
		var top, left;

		if ( at ) {
			top = at.y; left = at.x;
		} else {
			var r = trigger.getBoundingClientRect();
			top  = r.bottom + 6;
			left = r.right - m.width;
			if ( top + m.height > vh - pad ) { top = r.top - 6 - m.height; }
		}

		node.style.top  = Math.max( pad, Math.min( top, vh - pad - m.height ) ) + 'px';
		node.style.left = Math.max( pad, Math.min( left, vw - pad - m.width ) ) + 'px';
	}

	function isInline( b ) { return b && ( 'inline' === b.type || 'catalog' === b.type ) && b.template; }

	/**
	 * Exports some sections into a file.
	 *
	 * The package is composed by the SERVER (`AIPB_Portable`), not by this code: the knowledge of
	 * what travels and what does not lives there — the template yes, the source site's contents no,
	 * because the loops on the other site have to fish from the other WordPress — and along with the
	 * package it works out the list of what is needed to make it work elsewhere.
	 */
	function exportBlocks( blocks ) {
		blocks = ( blocks || [] ).filter( isInline );
		if ( ! blocks.length ) {
			toast( __( 'There is no section to export.' ), true );
			return;
		}

		var title = state.title || dom.title.value || __( 'Aura sections' );

		api( '/export', {
			title: title,
			blocks: blocks.map( function ( b ) {
				return { name: b.name, template: b.template, schema: b.schema, values: b.values };
			} ),
		} ).then( function ( pack ) {
			var name = ( 1 === blocks.length ? ( blocks[ 0 ].name || 'section' ) : title )
				.toLowerCase().replace( /[^a-z0-9]+/g, '-' ).replace( /^-|-$/g, '' );
			downloadFile( ( name || 'aura' ) + '.aura.json', JSON.stringify( pack, null, '\t' ) );
			toast( 1 === blocks.length
				? sprintf( __( 'Exported «%s».' ), blocks[ 0 ].name || __( 'section' ) )
				: sprintf( __( 'Exported %d sections.' ), blocks.length ) );
		} ).catch( function ( err ) {
			toast( sprintf( __( 'The export did not work: %s' ), ( err && err.message ) ? err.message : __( 'unexpected error' ) ), true );
		} );
	}

	/** A file downloaded by the browser, without going through the server: we already have it. */
	function downloadFile( name, text ) {
		var url = URL.createObjectURL( new Blob( [ text ], { type: 'application/json' } ) );
		var a   = document.createElement( 'a' );
		a.href = url; a.download = name;
		document.body.appendChild( a );
		a.click();
		a.remove();
		// Revoking straight away cancels the download on some browsers: we wait a tick.
		setTimeout( function () { URL.revokeObjectURL( url ); }, 1000 );
	}

	function chooseImportFile() {
		var inp = document.createElement( 'input' );
		inp.type = 'file';
		inp.accept = '.json,application/json';
		inp.addEventListener( 'change', function () {
			var file = inp.files && inp.files[ 0 ];
			if ( ! file ) { return; }
			var reader = new FileReader();
			reader.onload = function () { importPackage( String( reader.result || '' ) ); };
			reader.readAsText( file );
		} );
		inp.click();
	}

	/**
	 * Reads a package and offers to insert it, AFTER saying what is missing on this site.
	 *
	 * The warning is the piece that makes importing useful instead of risky: a section using
	 * `type="product"` on a site with no products raises no error — it prints an empty list — and a
	 * `var(--aipb-color-brand)` that does not exist here is CSS worth nothing. They are things you
	 * would discover by looking at the page and wondering what went wrong.
	 */
	function importPackage( text ) {
		api( '/import', { json: text } ).then( function ( r ) {
			var sections = ( r && r.sections ) ? r.sections : [];
			if ( ! sections.length ) {
				toast( __( 'There is no section in the file.' ), true );
				return;
			}

			var missing = missingHere( r.inspect );
			var head    = sprintf( _n( 'The file carries %d section.', 'The file carries %d sections.', sections.length ), sections.length )
				+ ( r.origin ? ' ' + sprintf( __( 'It comes from %s.' ), r.origin ) : '' );

			setTab( 'chat' );
			var m = botMsg( head + ( missing.length ? '\n\n' + __( 'Missing on this site:' ) + '\n· ' + missing.join( '\n· ' ) : '' ),
				missing.length ? 'warn' : '' );
			m.actions = [
				{
					label: __( 'Insert them' ),
					primary: true,
					once: true,
					run: function () {
						sections.forEach( function ( s ) { state.blocks.push( makeInlineBlock( s, s.name ) ); } );
						state.dirty = true;
						renderBlocks(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
						botMsg( sprintf( _n( 'Inserted %d section, at the end of the page.', 'Inserted %d sections, at the end of the page.', sections.length ), sections.length ) );
					},
				},
				{ label: __( 'Leave it' ), once: true, run: function () {} },
			];
			renderMessages();
		} ).catch( function ( err ) {
			toast( sprintf( __( 'The import did not work: %s' ), ( err && err.message ) ? err.message : __( 'the file could not be read' ) ), true );
		} );
	}

	/** From `inspect` to readable lines. Only what is REALLY missing: the rest is not named. */
	function missingHere( inspect ) {
		var missing = ( inspect && inspect.missing ) ? inspect.missing : null;
		if ( ! missing ) { return []; }
		var labels = {
			types: __( 'content types' ),
			taxonomies: __( 'taxonomies' ),
			fields: __( 'fields' ),
			shortcodes: __( 'shortcodes' ),
			vars: __( 'design system variables' ),
		};
		var out = [];
		Object.keys( labels ).forEach( function ( k ) {
			var v = missing[ k ];
			if ( v && v.length ) { out.push( labels[ k ] + ': ' + v.join( ', ' ) ); }
		} );

		return out;
	}

	// The single entry point for renaming: a double click on the name, the pencil button, or F2 on
	// the selected block. The field opens already focused with its text selected (see renderBlocks).
	function startRename( block ) {
		if ( ! block ) { return; }
		state.renamingId = block.id;
		if ( 'blocks' !== state.tab ) { setTab( 'blocks' ); }
		renderBlocks();
	}

	// The clipboard: both the blocks list and the toolbar over the preview use it. svgIcon() is a
	// function declaration, so it is already available here even though it is written further down.
	var SVG_CLIPBOARD = svgIcon( '<rect x="9" y="2" width="6" height="4" rx="1"/><path d="M15 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>' );

	/**
	 * «I am working on THIS one»: select the section and show its fields.
	 *
	 * NOT selectBlock() + setTab( 'fields' ), and the difference is the whole point: selectBlock
	 * TOGGLES. A toggle is right in a list, where clicking what is already selected means «never
	 * mind». It is wrong anywhere the click means «open this»: clicking the same section twice
	 * landed you on the Fields panel reading «pick a block» — the gesture that asks to see the
	 * fields was also the gesture that emptied them, and only ever on the second click, which is
	 * how it got reported as «sometimes it doesn't work».
	 *
	 * Idempotent by construction: calling it ten times on the same section leaves it selected ten
	 * times.
	 */
	function focusBlock( id ) {
		if ( pageMode() ) {
			if ( ! id ) { return; }
			var found = null;
			for ( var i = 0; i < state.blocks.length; i++ ) {
				if ( state.blocks[ i ].id === id ) { found = state.blocks[ i ]; break; }
			}
			// THE PREVIEW CAN BE A MOMENT OLDER THAN THE STATE: a section deleted a beat ago is
			// still drawn until the render lands. Selecting an id that is gone would leave the
			// sidebar talking about something that no longer exists.
			if ( ! found ) { return; }

			if ( state.selectedBlockId !== id ) {
				state.selectedBlockId = id;
				// As in selectBlock: a plain click says «one section», and a marked set that
				// outlives the intention behind it is a set that merges the wrong sections three
				// clicks later.
				state.markedBlockIds = [];
				renderBlocks(); renderFields(); refreshCodeIfOpen(); updateComposerHint();
			}
		}

		// Outside page mode too: there the section is the only one, and the fields are its own.
		setTab( 'fields' );
	}

	function selectBlock( id ) {
		state.selectedBlockId = ( state.selectedBlockId === id ) ? null : id;
		// A plain click says «I am working on THIS one»: whatever was marked for a merge is not it,
		// and a set that outlived the intention behind it is a set that merges the wrong sections
		// three clicks later.
		state.markedBlockIds = [];
		renderBlocks(); renderFields(); refreshCodeIfOpen(); updateComposerHint();
	}

	/**
	 * Adds or removes a section from the set Unite will merge.
	 *
	 * The first ctrl/cmd+click with nothing selected SELECTS instead of marking: a set of one is not
	 * a merge, and leaving the Studio in a state where one section is marked, none is selected and
	 * nothing can be done with either is a dead end you can only leave by guessing.
	 */
	function toggleMark( b ) {
		if ( 'inline' !== b.type || ! b.template ) {
			botMsg( __( 'Only the page\'s own sections can be merged: a saved component has to be detached first.' ), 'warn' );
			return;
		}

		if ( ! state.selectedBlockId ) {
			selectBlock( b.id );
			return;
		}

		var ids = ( state.markedBlockIds || [] ).slice();
		var at  = ids.indexOf( b.id );
		if ( at >= 0 ) { ids.splice( at, 1 ); } else if ( b.id !== state.selectedBlockId ) { ids.push( b.id ); }
		state.markedBlockIds = ids;

		renderBlocks(); updateComposerHint();
	}

	function moveBlock( idx, dir ) {
		var j = idx + dir;
		if ( j < 0 || j >= state.blocks.length ) { return; }
		var tmp = state.blocks[ idx ]; state.blocks[ idx ] = state.blocks[ j ]; state.blocks[ j ] = tmp;
		state.dirty = true;
		renderBlocks(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
	}

	// Moves a block from `from` to position `to` (an index on the list BEFORE the removal).
	var dragBlockIdx = null;
	function moveBlockTo( from, to ) {
		if ( from === null || from < 0 || from >= state.blocks.length ) { return; }
		var b = state.blocks.splice( from, 1 )[ 0 ];
		if ( from < to ) { to--; } // the removal shortened the list before the insertion point
		to = Math.max( 0, Math.min( to, state.blocks.length ) );
		state.blocks.splice( to, 0, b );
		state.dirty = true;
		renderBlocks(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
	}

	// Duplicates an inline block: an independent copy inserted right after the original.
	function duplicateBlock( idx ) {
		var b = state.blocks[ idx ];
		if ( ! b || b.type !== 'inline' ) { return; }
		var copy = {
			type: 'inline', id: uid(), name: b.name,
			template: b.template,
			schema: JSON.parse( JSON.stringify( b.schema || { fields: [] } ) ),
			values: JSON.parse( JSON.stringify( b.values || {} ) ),
		};
		state.blocks.splice( idx + 1, 0, copy );
		state.selectedBlockId = copy.id;
		state.dirty = true;
		renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState(); updateComposerHint(); commitHistory();
		toast( __( 'Section duplicated' ) );
	}

	/* ------------------- The clipboard: sections between pages -------------- */

	// Copying a section from one page to another without going through patterns. An inline section is
	// already a self-sufficient object ({ template, schema, values }): a clipboard entry is enough.
	//
	// The entry lives in localStorage, shared by ALL the Studio's tabs and therefore by all the
	// pages, and it is ALSO written to the system clipboard, so ⌘V works between different browsers
	// and machines and the entry can be passed to somebody in a message. On http the system clipboard
	// can be denied by the browser: the local channel stays the good one either way.
	//
	// The difference from a pattern: here the copy is INDEPENDENT of the original. A pattern is for
	// when the section has to change in every page at once; the clipboard for when it only has to
	// start from there.
	var CLIP_KEY = 'aipb_clipboard';
	var CLIP_TAG = 'aipb-blocks/v1';

	// Only the fields that DEFINE the section. Not the id: it is regenerated on paste, otherwise two
	// copies in the same page would share an identity (selection and preview going mad).
	function clipItem( b ) {
		if ( 'ref' === b.type ) { return { type: 'ref', name: b.name || '', ref: b.ref }; }
		return {
			type: 'inline',
			name: b.name || '',
			template: b.template || '',
			schema: b.schema || { fields: [] },
			values: b.values || {},
		};
	}

	function parseClip( raw ) {
		if ( ! raw ) { return null; }
		var data;
		try { data = JSON.parse( raw ); } catch ( e ) { return null; }
		if ( ! data || CLIP_TAG !== data.tag || ! data.items || ! data.items.length ) { return null; }
		var items = data.items.filter( function ( it ) {
			return !! it && ( ( 'ref' === it.type && it.ref ) || ( 'inline' === it.type && it.template ) );
		} );
		return items.length ? { from: data.from || '', items: items } : null;
	}

	function readClip() {
		try { return parseClip( localStorage.getItem( CLIP_KEY ) ); } catch ( e ) { return null; }
	}

	// The system clipboard: the modern API first, then the old execCommand (which still works on
	// http, being inside the user's gesture). If both fail, never mind: it is an extra.
	function writeSystemClipboard( text ) {
		if ( navigator.clipboard && navigator.clipboard.writeText ) {
			navigator.clipboard.writeText( text ).catch( function () { legacyCopy( text ); } );
			return;
		}
		legacyCopy( text );
	}

	function legacyCopy( text ) {
		try {
			var ta = el( 'textarea' );
			ta.value = text;
			ta.style.cssText = 'position:fixed;top:-1000px;opacity:0';
			document.body.appendChild( ta );
			ta.select();
			document.execCommand( 'copy' );
			ta.remove();
		} catch ( e ) {}
	}

	function copyBlocks( blocks ) {
		blocks = ( blocks || [] ).filter( Boolean );
		if ( ! blocks.length ) { return; }
		var json = JSON.stringify( {
			tag: CLIP_TAG,
			from: state.title || '',
			items: blocks.map( clipItem ),
		} );
		var stored = true;
		try { localStorage.setItem( CLIP_KEY, json ); } catch ( e ) { stored = false; }
		writeSystemClipboard( json );
		renderBlocks(); // it makes «Paste» appear in this page too
		if ( ! stored ) {
			// Local storage full (a very heavy section): the system clipboard route is left, that is
			// ⌘V in the other page. Better to say so than to pretend it was copied.
			toast( __( 'The section is too heavy for the local clipboard: open the other page and paste it straight away with ⌘V.' ), true );
			return;
		}
		toast( 1 === blocks.length
			? sprintf( __( 'Copied «%s»: open another page and paste.' ), blocks[ 0 ].name || __( 'section' ) )
			: sprintf( __( 'Copied %d sections: open another page and paste.' ), blocks.length ) );
	}

	// Pastes at the given index, otherwise after the selected block, otherwise at the end.
	function pasteBlocks( clip, at ) {
		if ( ! pageMode() ) {
			toast( __( 'Sections are pasted into a page.' ), true );
			return;
		}
		clip = clip || readClip();
		if ( ! clip ) {
			toast( __( 'There is no section in the clipboard: copy one from a page.' ), true );
			return;
		}

		var made = clip.items.map( function ( it ) {
			var b = JSON.parse( JSON.stringify( it ) );
			b.id = uid();
			b.name = b.name || __( 'Section' );
			return b;
		} );

		var idx;
		if ( typeof at === 'number' ) {
			idx = at;
		} else {
			var sel = activeBlock();
			idx = sel ? state.blocks.indexOf( sel ) + 1 : state.blocks.length;
		}
		idx = Math.max( 0, Math.min( idx, state.blocks.length ) );

		state.blocks.splice.apply( state.blocks, [ idx, 0 ].concat( made ) );
		state.selectedBlockId = made[ made.length - 1 ].id;
		state.dirty = true;
		renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState(); updateComposerHint(); commitHistory();
		toast( 1 === made.length
			? sprintf( __( 'Pasted «%s»' ), made[ 0 ].name )
			: sprintf( __( 'Pasted %d sections' ), made.length ) );
	}

	// Confirms a block's inline rename.
	function commitRename( b, value ) {
		if ( state.renamingId !== b.id ) { return; } // already closed (it avoids a double blur+Enter commit)
		state.renamingId = null;
		var v = ( value || '' ).trim();
		if ( v && v !== b.name ) { b.name = v; state.dirty = true; renderBlocks(); refreshSaveState(); commitHistory(); }
		else { renderBlocks(); }
	}

	function removeBlock( idx ) {
		var b = state.blocks[ idx ];
		if ( b && b.id === state.selectedBlockId ) { state.selectedBlockId = null; }
		// Its problems go too: a defect that goes on counting in the dot after the section is gone
		// sends people looking for something nobody can fix any more.
		if ( b ) { delete state.issues[ b.id ]; }
		state.blocks.splice( idx, 1 );
		state.dirty = true;
		renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState(); updateComposerHint(); commitHistory();
		refreshChecks();
	}

	function convertToComponent( block ) {
		var name = block.name || __( 'Component' );
		api( '/component', { title: name, template: block.template, schema: block.schema, values: block.values } )
			.then( function ( res ) {
				// It replaces the inline block with a live reference to the component.
				block.type = 'ref';
				block.ref = res.id;
				block.name = res.title || name;
				delete block.template; delete block.schema; delete block.values;
				if ( state.selectedBlockId === block.id ) { state.selectedBlockId = null; }
				state.dirty = true;
				renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
				invalidateLibrary();
				toast( __( 'Pattern created and linked' ) );
			} ).catch( function ( err ) { toast( sprintf( __( 'Error: %s' ), err.message ), true ); } );
	}

	// Unlinks a «component» block (a ref) from the synced pattern: it copies its content and makes it
	// INLINE, so the changes stay on THIS page and touch neither the original pattern nor the other
	// pages using it. The inverse of convertToComponent.
	function detachRef( block ) {
		if ( state.busy || ! block || block.type !== 'ref' || ! block.ref ) { return; }
		toast( __( 'Unlinking from the pattern…' ) );
		api( '/section/' + block.ref, null, 'GET' ).then( function ( s ) {
			if ( ! s || s.isPage || ! s.template ) {
				toast( __( 'This element cannot be unlinked (it is not a single section).' ), true );
				return;
			}
			block.type = 'inline';
			block.template = s.template;
			block.schema = s.schema || { fields: [] };
			block.values = s.values || {};
			delete block.ref;
			state.selectedBlockId = block.id;
			state.dirty = true;
			renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState(); updateComposerHint(); commitHistory();
			toast( __( 'Unlinked: the changes will stay on this page only.' ) );
		} ).catch( function ( err ) { toast( sprintf( __( 'Error: %s' ), err.message ), true ); } );
	}

	function insertRef( sec, index ) {
		var block = { type: 'ref', id: uid(), ref: sec.id, name: sec.title };
		if ( typeof index === 'number' && index >= 0 && index <= state.blocks.length ) {
			state.blocks.splice( index, 0, block );
		} else {
			state.blocks.push( block );
		}
		state.dirty = true;
		renderBlocks(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
		toast( sprintf( __( 'Inserted: %s' ), sec.title || __( 'component' ) ) );
	}

	/* ------------------------------- The library --------------------------- */

	var libraryCache = null; // the patterns/template parts list, cached across tab switches
	var libDrag = null;      // the item currently being dragged from the Library
	var libDropIndex = null; // the insertion index computed during the drag

	function invalidateLibrary() { libraryCache = null; }

	function renderLibrary() {
		if ( ! dom.libraryWrap ) { return; }
		dom.libraryWrap.innerHTML = '';

		var head = el( 'div', 'aipb-lib-head' );
		head.appendChild( el( 'div', 'aipb-lib-title', __( 'Library' ) ) );
		var refresh = el( 'button', 'aipb-lib-refresh', '↻' );
		refresh.title = __( 'Refresh the list' );
		refresh.addEventListener( 'click', function () { invalidateLibrary(); renderLibrary(); } );
		head.appendChild( refresh );
		dom.libraryWrap.appendChild( head );

		dom.libraryWrap.appendChild( el( 'div', 'aipb-lib-sub', __( 'Click to add at the end of the page, or drag an item onto the preview at the point you want.' ) ) );

		var listBox = el( 'div', 'aipb-lib-list' );
		dom.libraryWrap.appendChild( listBox );

		function paint( list ) {
			libraryCache = list;
			listBox.innerHTML = '';
			if ( ! list.length ) {
				listBox.appendChild( el( 'div', 'aipb-lib-empty', __( 'No pattern or template part saved. Create a Section and save it as a pattern, or build a template part.' ) ) );
				return;
			}
			[ { kind: 'pattern', label: __( 'Pattern' ) }, { kind: 'part', label: __( 'Template part' ) } ].forEach( function ( g ) {
				var items = list.filter( function ( s ) { return s.kind === g.kind; } );
				if ( ! items.length ) { return; }
				listBox.appendChild( el( 'div', 'aipb-lib-group', g.label ) );
				items.forEach( function ( s ) { listBox.appendChild( libCard( s ) ); } );
			} );
		}

		if ( libraryCache ) { paint( libraryCache ); return; }

		listBox.appendChild( el( 'div', 'aipb-lib-empty', __( 'Loading…' ) ) );
		api( '/sections', null, 'GET' ).then( function ( list ) {
			libraryCache = list || [];
			if ( state.tab === 'library' ) { paint( libraryCache ); }
		} ).catch( function () {
			listBox.innerHTML = '';
			listBox.appendChild( el( 'div', 'aipb-lib-empty', __( 'The library could not be loaded.' ) ) );
		} );
	}

	function libCard( s ) {
		var card = el( 'div', 'aipb-lib-card' );
		card.setAttribute( 'draggable', 'true' );
		card.title = __( 'Drag onto the preview, or click to add' );

		var body = el( 'div', 'aipb-lib-card-body' );
		body.appendChild( el( 'div', 'aipb-lib-card-title', s.title ) );
		body.appendChild( el( 'div', 'aipb-lib-card-meta', ( s.typeLabel || '' ) + ' · ' + s.updated ) );
		card.appendChild( body );

		var add = el( 'button', 'aipb-lib-add', '+' );
		add.title = __( 'Add at the end' );
		add.addEventListener( 'click', function ( e ) { e.stopPropagation(); insertRef( s ); } );
		card.appendChild( add );

		card.addEventListener( 'click', function () { insertRef( s ); } );

		card.addEventListener( 'dragstart', function ( e ) {
			libDrag = s;
			card.classList.add( 'is-dragging' );
			try {
				e.dataTransfer.setData( 'text/plain', 'aipb-ref:' + s.id );
				e.dataTransfer.effectAllowed = 'copy';
			} catch ( err ) {}
			showLibDrop();
		} );
		card.addEventListener( 'dragend', function () {
			card.classList.remove( 'is-dragging' );
			hideLibDrop();
			libDrag = null;
		} );
		return card;
	}

	function showLibDrop() { if ( dom.libDrop ) { dom.libDrop.style.display = 'block'; } }
	function hideLibDrop() {
		if ( dom.libDrop ) { dom.libDrop.style.display = 'none'; }
		if ( dom.libLine ) { dom.libLine.style.display = 'none'; }
		libDropIndex = null;
	}

	// The vertical bounds (in page coordinates) of every block rendered in the iframe. The wrappers
	// have display:contents (no box): we measure their real children.
	function blockOffsets() {
		var out = [];
		try {
			var doc = dom.frame.contentDocument;
			var fr = dom.frame.getBoundingClientRect();
			var nodes = doc.querySelectorAll( '[data-aipb-block]' );
			for ( var i = 0; i < nodes.length; i++ ) {
				var kids = nodes[ i ].children.length ? nodes[ i ].children : [ nodes[ i ] ];
				var top = kids[ 0 ].getBoundingClientRect().top;
				var bottom = kids[ kids.length - 1 ].getBoundingClientRect().bottom;
				out.push( { top: fr.top + top, bottom: fr.top + bottom } );
			}
		} catch ( err ) {}
		return out;
	}

	function setupLibraryDrop() {
		if ( ! dom.libDrop ) { return; }
		dom.libDrop.addEventListener( 'dragover', function ( e ) {
			if ( ! libDrag ) { return; }
			e.preventDefault();
			try { e.dataTransfer.dropEffect = 'copy'; } catch ( err ) {}

			var offs = blockOffsets();
			var wrapTop = dom.previewWrap.getBoundingClientRect().top;
			var index = offs.length;
			var lineY = offs.length ? offs[ offs.length - 1 ].bottom : ( wrapTop + 24 );
			for ( var i = 0; i < offs.length; i++ ) {
				var mid = ( offs[ i ].top + offs[ i ].bottom ) / 2;
				if ( e.clientY < mid ) { index = i; lineY = offs[ i ].top; break; }
			}
			libDropIndex = index;
			dom.libLine.style.display = 'block';
			dom.libLine.style.top = Math.max( 0, lineY - wrapTop ) + 'px';
		} );

		dom.libDrop.addEventListener( 'drop', function ( e ) {
			if ( ! libDrag ) { return; }
			e.preventDefault();
			var sec = libDrag;
			var idx = ( libDropIndex == null ) ? state.blocks.length : libDropIndex;
			libDrag = null;
			hideLibDrop();
			insertRef( sec, idx );
		} );
	}

	/* ------------------------------- Sections ------------------------------ */

	function newSection( title ) {
		state.sectionId = null;
		state.title = title || '';
		state.postType = START_TYPE.type;
		state.typeLabel = START_TYPE.label;
		state.mode = isPageType( state.postType ) ? 'page' : 'section';
		state.template = ''; state.schema = { fields: [] }; state.values = {};
		state.blocks = []; state.selectedBlockId = null;
		state.shortcode = ''; state.permalink = ''; state.previewLink = ''; state.editLink = '';
		state.dirty = false; state.messages = [];
		state.attachments = []; renderAttachments();
		dom.title.value = state.title;
		updateLayout();
		renderMessages(); renderBlocks(); renderFields(); renderPreview(); renderMeta(); refreshSaveState();
		closeSections();
		resetHistory();
	}

	function setPostType( pt ) {
		state.postType = pt;
		var found = TYPES.filter( function ( t ) { return t.type === pt; } )[ 0 ];
		state.typeLabel = found ? found.label : pt;
		var newMode = isPageType( pt ) ? 'page' : 'section';
		if ( newMode !== state.mode ) {
			reconcileShape( newMode );
			state.mode = newMode;
		}
		updateLayout();
		renderBlocks(); renderFields(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
	}

	// It keeps the content when switching section<->page.
	function reconcileShape( newMode ) {
		if ( newMode === 'page' ) {
			if ( ! state.blocks.length && state.template ) {
				state.blocks = [ makeInlineBlock( { template: state.template, schema: state.schema, values: state.values }, __( 'Section' ) ) ];
			}
			state.template = ''; state.schema = { fields: [] }; state.values = {};
		} else {
			var first = state.blocks.filter( function ( b ) { return b.type === 'inline'; } )[ 0 ];
			if ( ! state.template && first ) {
				state.template = first.template; state.schema = first.schema; state.values = first.values;
			}
			state.blocks = []; state.selectedBlockId = null;
		}
	}

	function updateLayout() {
		// '' and not 'block': the tabs are laid out by the stylesheet (inline-flex, to centre the
		// icon), and an inline `display` would flatten them back into boxes with the icon in a corner.
		dom.tabBlocks.style.display = pageMode() ? '' : 'none';
		dom.tabLibrary.style.display = pageMode() ? '' : 'none';
		var t = state.tab;
		if ( ! t || t === 'preview' ) { t = 'chat'; }
		if ( ( t === 'blocks' || t === 'library' ) && ! pageMode() ) { t = 'chat'; }
		setTab( t );
		updateComposerHint();
	}

	/* ------------------- Draft autosave (localStorage) --------------------- */

	var draftTimer = null;
	function draftKey() { return 'aipb_draft_' + ( state.sectionId || 'new' ); }

	function scheduleDraft() {
		clearTimeout( draftTimer );
		draftTimer = setTimeout( saveDraft, 800 );
	}

	// It saves a draft ONLY when there are unpublished changes; when clean, it removes the draft.
	function saveDraft() {
		try {
			if ( ! state.dirty ) { localStorage.removeItem( draftKey() ); return; }
			localStorage.setItem( draftKey(), JSON.stringify( {
				v: 1, ts: Date.now(), sectionId: state.sectionId, title: state.title,
				postType: state.postType, typeLabel: state.typeLabel, mode: state.mode,
				template: state.template, schema: state.schema, values: state.values, blocks: state.blocks,
			} ) );
		} catch ( e ) {}
	}

	function clearDraft() {
		try { localStorage.removeItem( draftKey() ); localStorage.removeItem( 'aipb_draft_new' ); } catch ( e ) {}
	}

	// On opening: if a draft differs from the loaded content, offer to restore it.
	function maybeOfferDraft() {
		var raw;
		try { raw = localStorage.getItem( draftKey() ); } catch ( e ) { return; }
		if ( ! raw ) { return; }
		var d; try { d = JSON.parse( raw ); } catch ( e ) { clearDraft(); return; }
		if ( ! d ) { return; }
		var cur = JSON.stringify( { t: state.template, b: state.blocks, v: state.values, ti: state.title } );
		var drf = JSON.stringify( { t: d.template, b: d.blocks, v: d.values, ti: d.title } );
		if ( cur === drf ) { clearDraft(); return; }
		showDraftBar( d );
	}

	function showDraftBar( d ) {
		if ( dom.draftBar && dom.draftBar.parentNode ) { dom.draftBar.parentNode.removeChild( dom.draftBar ); }
		var bar = el( 'div', 'aipb-draftbar' );
		var when = '';
		try { when = d.ts ? new Date( d.ts ).toLocaleString() : ''; } catch ( e ) {}
		bar.appendChild( el( 'span', '', __( 'An unsaved draft was found' ) + ( when ? ' (' + when + ')' : '' ) + '.' ) );
		var actions = el( 'span', 'aipb-draftbar-actions' );
		var yes = el( 'button', 'aipb-btn primary', __( 'Restore' ) );
		yes.addEventListener( 'click', function () { applyDraft( d ); dismissDraftBar(); } );
		var no = el( 'button', 'aipb-btn', __( 'Ignore' ) );
		no.addEventListener( 'click', function () { clearDraft(); dismissDraftBar(); } );
		actions.appendChild( yes ); actions.appendChild( no );
		bar.appendChild( actions );
		dom.draftBar = bar;
		var studioEl = root.querySelector( '.aipb-studio' );
		root.insertBefore( bar, studioEl );
	}

	function dismissDraftBar() {
		if ( dom.draftBar && dom.draftBar.parentNode ) { dom.draftBar.parentNode.removeChild( dom.draftBar ); }
		dom.draftBar = null;
	}

	function applyDraft( d ) {
		state.title = d.title || '';
		state.postType = d.postType || state.postType;
		state.typeLabel = d.typeLabel || state.typeLabel;
		state.mode = d.mode || state.mode;
		state.template = d.template || '';
		state.schema = d.schema || { fields: [] };
		state.values = d.values || {};
		state.blocks = ( d.blocks || [] ).map( function ( b ) { if ( ! b.id ) { b.id = uid(); } return b; } );
		state.selectedBlockId = null;
		state.dirty = true;
		dom.title.value = state.title;
		updateLayout();
		renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); renderMeta(); refreshSaveState(); updateComposerHint();
		resetHistory();
		toast( __( 'Draft restored' ) );
	}

	function save() { doSave( 'publish' ); } // an alias for the /save command and the primary button

	// Saves the content with the requested status. For pages: 'draft' (a draft, no confirmation) or
	// 'publish' (online, with a confirmation). For components the status is ignored by the server
	// (they stay published so they can be reused as patterns).
	function doSave( status ) {
		if ( state.busy ) { return; }
		var hasContent = pageMode() ? state.blocks.length : !! state.template;
		if ( ! hasContent ) { return; }

		var publishing = ( 'publish' === status );
		if ( publishing && pageMode() && ! window.confirm( __( 'Publish the page? It will be visible online straight away.' ) ) ) { return; }

		// Publishing is the moment defects stop being ours and become the visitor's: the check starts
		// here, and its result arrives in the chat while the save goes on. It does not block — a
		// builder that stops you going online is a builder that gets uninstalled — and on a draft it
		// does not start at all: there the work is still going on.
		if ( publishing && pageMode() ) { runCheck(); }

		var title = ( state.title || dom.title.value || ( pageMode() ? __( 'New page' ) : __( 'New component' ) ) ).trim();
		dom.save.disabled = true;
		if ( dom.draftBtn ) { dom.draftBtn.disabled = true; }
		( publishing || ! dom.draftBtn ? dom.save : dom.draftBtn ).textContent = publishing ? __( 'Publishing…' ) : __( 'Saving…' );

		var ensureId = state.sectionId
			? Promise.resolve( state.sectionId )
			: api( '/sections', { title: title, post_type: state.postType } ).then( function ( r ) {
				state.sectionId = r.id; state.postType = r.postType || state.postType; state.typeLabel = r.typeLabel || state.typeLabel;
				state.status = r.status || state.status;
				return r.id;
			} );

		return ensureId.then( function ( id ) {
			var body = pageMode()
				? { title: title, blocks: state.blocks, status: status }
				: { title: title, template: state.template, schema: state.schema, values: state.values };
			return api( '/save/' + id, body );
		} ).then( function ( res ) {
			state.dirty = false;
			state.status = res.status || status;
			state.shortcode = res.shortcode || '';
			state.permalink = res.permalink || '';
			state.previewLink = res.previewLink || '';
			state.editLink = res.editLink || '';
			state.typeLabel = res.typeLabel || state.typeLabel;
			clearDraft();
			renderMeta(); refreshSaveState(); loadSectionsList(); invalidateLibrary();
			toast( publishing ? sprintf( __( '%s published' ), state.typeLabel ) : __( 'Draft saved' ) );
		} ).catch( function ( err ) {
			refreshSaveState(); toast( sprintf( publishing ? __( 'Publishing failed: %s' ) : __( 'Saving failed: %s' ), err.message ), true );
		} );
	}

	function refreshSaveState() {
		var hasContent = pageMode() ? state.blocks.length : !! state.template;
		var isPage = pageMode();
		var published = ( 'publish' === state.status );

		// Draft vs Publish (pages only): the secondary "Save draft" button + the status badge.
		if ( dom.draftBtn ) {
			dom.draftBtn.style.display = isPage ? 'inline-flex' : 'none';
			dom.draftBtn.disabled = state.busy || ! hasContent;
			dom.draftBtn.textContent = __( 'Save draft' ) + ( state.dirty && hasContent && ! published ? ' *' : '' );
		}
		if ( dom.statusBadge ) {
			var showBadge = isPage && !! state.sectionId && ! published;
			dom.statusBadge.style.display = showBadge ? 'inline-flex' : 'none';
		}

		dom.save.disabled = state.busy || ! hasContent;
		dom.save.textContent = ( isPage && published ? __( 'Update' ) : __( 'Publish' ) ) + ( state.dirty && hasContent ? ' *' : '' );
		if ( dom.previewBtn ) { dom.previewBtn.disabled = state.busy || ! hasContent; }
		// The «public page» button only makes sense when a published permalink exists.
		if ( dom.viewBtn ) { dom.viewBtn.style.display = state.permalink ? 'inline-flex' : 'none'; }

		var existing = !! state.sectionId;
		// The mode switch: only for new content. On content that already exists the type is fixed (a
		// published page does not become a pattern), so we show the type's label alone.
		dom.modeSwitch.style.display = existing ? 'none' : 'inline-flex';
		dom.modePage.classList.toggle( 'active', pageMode() );
		dom.modeSection.classList.toggle( 'active', ! pageMode() );
		dom.typeChip.style.display = existing ? 'inline-flex' : 'none';
		if ( existing ) { dom.typeChip.textContent = state.typeLabel || ''; }
	}

	/* --------------------------- The sections list ------------------------- */

	function openSections() {
		closeSections();
		dom.sectionsPop = el( 'div', 'aipb-sections-pop' );
		dom.sectionsPop.appendChild( el( 'div', 'aipb-sec-empty', __( 'Loading…' ) ) );
		dom.listWrap.appendChild( dom.sectionsPop );
		loadSectionsList();
	}

	function closeSections() {
		if ( dom.sectionsPop && dom.sectionsPop.parentNode ) { dom.sectionsPop.parentNode.removeChild( dom.sectionsPop ); }
		dom.sectionsPop = null;
	}

	/* --------------------------- Version history --------------------------- */

	function toggleHistory() {
		if ( dom.histPop && dom.histPop.parentNode ) { closeHistory(); return; }
		if ( ! state.sectionId ) { toast( __( 'Save at least once to have a history.' ) ); return; }
		openHistory();
	}

	function closeHistory() {
		if ( dom.histPop && dom.histPop.parentNode ) { dom.histPop.parentNode.removeChild( dom.histPop ); }
		dom.histPop = null;
	}

	function openHistory() {
		closeHistory();
		dom.histPop = el( 'div', 'aipb-sections-pop aipb-hist-pop' );
		dom.histPop.appendChild( el( 'div', 'aipb-sec-empty', __( 'Loading…' ) ) );
		dom.histWrap.appendChild( dom.histPop );
		api( '/revisions/' + state.sectionId, null, 'GET' ).then( function ( list ) {
			if ( ! dom.histPop ) { return; }
			dom.histPop.innerHTML = '';
			dom.histPop.appendChild( el( 'div', 'aipb-sec-head', __( 'Version history' ) ) );
			if ( ! list.length ) {
				dom.histPop.appendChild( el( 'div', 'aipb-sec-empty', __( 'No previous version.' ) ) );
				return;
			}
			list.forEach( function ( r ) {
				var item = el( 'div', 'aipb-sec-item' );
				item.appendChild( el( 'div', 't', r.date ) );
				item.appendChild( el( 'div', 'd', ( r.ago || '' ) + ( r.author ? ' · ' + r.author : '' ) ) );
				item.addEventListener( 'click', function () { restoreRevision( r ); } );
				dom.histPop.appendChild( item );
			} );
		} ).catch( function ( err ) {
			if ( dom.histPop ) { dom.histPop.innerHTML = ''; dom.histPop.appendChild( el( 'div', 'aipb-sec-empty', sprintf( __( 'Error: %s' ), err.message ) ) ); }
		} );
	}

	function restoreRevision( r ) {
		if ( ! window.confirm( sprintf( __( 'Restore the version from %s? The UNSAVED changes will be lost.' ), r.date ) ) ) { return; }
		closeHistory();
		api( '/restore/' + state.sectionId, { revision: r.id } ).then( function () {
			return loadSection( state.sectionId );
		} ).then( function () {
			toast( __( 'Version restored' ) );
		} ).catch( function ( err ) {
			toast( sprintf( __( 'The restore failed: %s' ), err.message ), true );
		} );
	}

	function loadSectionsList() {
		api( '/sections', null, 'GET' ).then( function ( list ) {
			if ( ! dom.sectionsPop ) { return; }
			dom.sectionsPop.innerHTML = '';
			dom.sectionsPop.appendChild( el( 'div', 'aipb-sec-head', __( 'Open a pattern' ) ) );
			if ( ! list.length ) {
				dom.sectionsPop.appendChild( el( 'div', 'aipb-sec-empty', __( 'No pattern saved.' ) ) );
				return;
			}
			list.forEach( function ( s ) {
				var item = el( 'div', 'aipb-sec-item' + ( s.id === state.sectionId ? ' active' : '' ) );
				item.appendChild( el( 'div', 't', s.title ) );
				item.appendChild( el( 'div', 'd', sprintf( __( 'Updated %s' ), s.updated ) ) );
				item.addEventListener( 'click', function () { loadSection( s.id ); } );
				dom.sectionsPop.appendChild( item );
			} );
		} ).catch( function () {} );
	}

	function loadSection( id ) {
		return api( '/section/' + id, null, 'GET' ).then( function ( s ) {
			state.sectionId = s.id;
			state.title = s.title || '';
			state.postType = s.postType || state.postType;
			state.typeLabel = s.typeLabel || state.typeLabel;
			state.mode = s.isPage ? 'page' : 'section';
			state.status = s.status || 'publish';
			state.shortcode = s.shortcode || '';
			state.permalink = s.permalink || '';
			state.editLink = s.editLink || '';
			state.dirty = false;
			state.selectedBlockId = null;
			state.attachments = []; renderAttachments();

			var name = s.title || __( 'untitled' );
			if ( s.isPage ) {
				state.blocks = ( s.blocks || [] ).map( function ( b ) {
					if ( ! b.id ) { b.id = uid(); }
					return b;
				} );
				state.template = ''; state.schema = { fields: [] }; state.values = {};
				state.messages = [ { role: 'bot', text: state.blocks.length
					? sprintf( __( '`%1$s` (%2$s): %3$d blocks. Pick one to refine it, or write to add sections.' ), name, state.typeLabel, state.blocks.length )
					: sprintf( __( '`%1$s` (%2$s) is empty. Describe the page you want.' ), name, state.typeLabel ) } ];
			} else {
				state.blocks = [];
				state.template = s.template || '';
				state.schema = s.schema || { fields: [] };
				state.values = s.values || {};
				state.messages = [ { role: 'bot', text: state.template
					? sprintf( __( '`%1$s` (%2$s) loaded. Write a change to refine it.' ), name, state.typeLabel )
					: sprintf( __( '`%1$s` (%2$s) is empty. Describe what to create.' ), name, state.typeLabel ) } ];
			}

			dom.title.value = state.title;
			updateLayout();
			renderMessages(); renderBlocks(); renderFields(); schedulePreview( 0 ); renderMeta(); refreshSaveState();
			closeSections();
			resetHistory();
			maybeOfferDraft();
		} ).catch( function ( err ) {
			toast( sprintf( __( 'It could not be loaded: %s' ), err.message ), true );
		} );
	}

	/* -------------------------------- Fields ------------------------------- */

	// The preview is ALWAYS visible on the right: the tabs govern the left panel only.
	function setTab( tab ) {
		if ( ( tab === 'blocks' || tab === 'library' ) && ! pageMode() ) { tab = 'chat'; }
		if ( tab === 'preview' ) { tab = 'chat'; } // compatibility with old references
		state.tab = tab;

		// With the names gone from the buttons, `aria-selected` is what tells a screen reader which
		// of the six is the one being looked at: the accent behind the icon says it to everyone else.
		[
			[ dom.tabChat, 'chat' ], [ dom.tabBlocks, 'blocks' ], [ dom.tabLibrary, 'library' ],
			[ dom.tabFields, 'fields' ], [ dom.tabStyle, 'style' ], [ dom.tabCode, 'code' ],
			[ dom.tabChecks, 'checks' ]
		].forEach( function ( pair ) {
			var on = tab === pair[ 1 ];
			pair[ 0 ].classList.toggle( 'active', on );
			pair[ 0 ].setAttribute( 'aria-selected', on ? 'true' : 'false' );
		} );

		dom.chatPanel.style.display = tab === 'chat' ? 'flex' : 'none';
		dom.blocksWrap.style.display = tab === 'blocks' ? 'block' : 'none';
		dom.libraryWrap.style.display = tab === 'library' ? 'block' : 'none';
		dom.fieldsWrap.style.display = tab === 'fields' ? 'block' : 'none';
		dom.styleWrap.style.display = tab === 'style' ? 'flex' : 'none';
		dom.codeWrap.style.display = tab === 'code' ? 'flex' : 'none';
		dom.checksWrap.style.display = tab === 'checks' ? 'block' : 'none';

		// Style is not a panel that merely SHOWS something: while it is open the preview is in
		// pick mode (the crosshair, the outline under the pointer, the toolbar out of the way).
		// Leaving the tab has to switch all of that off, or the preview stays unusable on a panel
		// nobody is looking at. The guard is for the moments before studio-style.js has loaded.
		if ( window.AIPBStyle && window.AIPBStyle.panelShown ) { window.AIPBStyle.panelShown( tab === 'style' ); }

		if ( tab === 'checks' ) { renderChecks(); }
		if ( tab === 'code' ) { renderCode(); }
		if ( tab === 'library' ) { renderLibrary(); }
		if ( tab === 'chat' ) { dom.messages.scrollTop = dom.messages.scrollHeight; }
	}

	function renderFields() {
		dom.fieldsWrap.innerHTML = '';

		var fields, store;
		if ( pageMode() ) {
			var b = activeBlock();
			if ( ! b ) {
				dom.fieldsWrap.appendChild( el( 'div', 'aipb-fields-empty', __( 'Pick a block (the Blocks tab) to change its fields.' ) ) );
				return;
			}
			if ( b.type === 'ref' ) {
				var box = el( 'div', 'aipb-detach-box' );
				box.appendChild( el( 'p', 'aipb-detach-title', __( 'A synced section' ) ) );
				box.appendChild( el( 'p', 'aipb-detach-desc', __( 'It is a reusable pattern: editing it from «Patterns» (☰) reflects the changes everywhere. To change it in THIS page only, unlink it: we make an independent copy of it.' ) ) );
				var detachBtn = el( 'button', 'aipb-btn', __( 'Unlink and edit here' ) );
				detachBtn.addEventListener( 'click', function () { detachRef( b ); } );
				box.appendChild( detachBtn );
				dom.fieldsWrap.appendChild( box );
				return;
			}
			fields = ( b.schema && b.schema.fields ) || [];
			store = b.values;
		} else {
			fields = ( state.schema && state.schema.fields ) || [];
			store = state.values;
		}

		if ( ! fields.length ) {
			dom.fieldsWrap.appendChild( el( 'div', 'aipb-fields-empty', __( 'No editable field here.' ) ) );
			return;
		}
		groupFields( fields ).forEach( function ( g ) {
			dom.fieldsWrap.appendChild( buildGroup( g, store ) );
		} );
	}

	/* --------------------- Fields grouped by element ----------------------- */

	// The fields of one element share the key's prefix: hero_title, hero_text, hero_cta,
	// hero_cta_url. We put them back together, so the Fields tab resembles the section instead of a
	// flat list you have to scan by eye.
	//
	// A prefix counts as a group only if AT LEAST TWO fields share it: a single field is not an
	// element, it is a field, and shutting it in a box of its own would be noise.
	/**
	 * THE ELEMENT'S SWITCH RIDES ON THE ROW OF THE FIELD IT BELONGS TO.
	 *
	 * The prompts now wrap EVERY editable element in `{{#if <head>_visible}}` rather than only the
	 * ones the model judged optional — the judgement was made differently every time, so the same
	 * kicker came out switchable in one section and welded in place in the next. Each of those
	 * conditions becomes a boolean field (see AIPB_AI_Provider::parse_result), which means a section
	 * that used to declare six fields now declares twelve.
	 *
	 * As ROWS that would be a disaster: «Title», «Show», «Kicker», «Show», «Cta», «Link», «Show» —
	 * the panel doubles in length and every second line says the same word. And a switch listed
	 * under the field it governs looks like it governs the field ABOVE it, which is the ambiguity
	 * this panel was just rebuilt to remove.
	 *
	 * So a bool called `<something>_visible` is not a field at all: it is an attribute OF
	 * `<something>`, and it goes on that field's row. Three spellings, for the reason given where
	 * the group's own switch is recognised.
	 *
	 * @return {Object} field key -> the bool that switches it
	 */
	function switchMap( fields ) {
		var byKey = {}, map = {}, tails = [ '_visible', '_visibile', '_show' ];

		fields.forEach( function ( f ) { byKey[ f.key ] = f; } );

		/**
		 * THE CONDITION IS NAMED AFTER THE ELEMENT, THE FIELD AFTER WHAT IT HOLDS, and the two words
		 * do not have to be the same one. `{{#if footer_social_visible}}` wraps a list declared as
		 * `footer_social_links`: there is no field called `footer_social` for the switch to land on,
		 * so it used to come back as a stray row reading «Social visible» at the foot of the panel —
		 * three of them in a row on the footer that started this, under the fields they govern and
		 * looking like they governed the field above.
		 *
		 * The stem still has to find its field. Two conditions, and both are load-bearing:
		 *
		 *  - THE STEM IS ITSELF A COMPOUND. `footer_social` may look for a family; `footer` may not.
		 *    `footer_visible` beside `footer_logo` is the switch for the WHOLE card and belongs in
		 *    its header — handing it to the first field under it takes the card's control away.
		 *  - THE FAMILY HOLDS EXACTLY ONE FIELD. With two there is no telling which of them the
		 *    condition wraps, and choosing wrong dims a row that is perfectly visible on the page.
		 *
		 * @return {Object|null} the single field under `stem_`, or null if that is not what it is.
		 */
		function lone( stem ) {
			var found = null;
			if ( stem.indexOf( '_' ) < 0 ) { return null; }
			for ( var i = 0; i < fields.length; i++ ) {
				if ( 'bool' === fields[ i ].type ) { continue; }
				if ( 0 !== fields[ i ].key.indexOf( stem + '_' ) ) { continue; }
				if ( found ) { return null; }
				found = fields[ i ];
			}
			return found;
		}

		fields.forEach( function ( f ) {
			if ( 'bool' !== f.type ) { return; }
			tails.forEach( function ( tail ) {
				if ( map[ f.key ] || f.key.length <= tail.length ) { return; }
				if ( f.key.slice( -tail.length ) !== tail ) { return; }

				var stem  = f.key.slice( 0, -tail.length );
				var owner = byKey[ stem ] || lone( stem );
				// Not onto another switch, and not onto a field that does not exist: a stray
				// `hero_visible` with no `hero` beside it is the GROUP's switch, and it has to stay
				// in the list for the group to find it.
				if ( owner && 'bool' !== owner.type ) { map[ f.key ] = owner.key; }
			} );
		} );

		return map;
	}

	function groupFields( fields ) {
		// The switches leave the list before anything else looks at it: from here down they are not
		// fields, they are a property of the field they belong to.
		var owners = switchMap( fields );
		var boolOf = {};
		fields.forEach( function ( f ) { if ( owners[ f.key ] ) { boolOf[ owners[ f.key ] ] = f; } } );
		fields = fields.filter( function ( f ) { return ! owners[ f.key ]; } );

		var counts = {};
		fields.forEach( function ( f ) {
			var p = groupKeyOf( f.key );
			if ( p ) { counts[ p ] = ( counts[ p ] || 0 ) + 1; }
		} );

		var groups = [], byKey = {};
		function bucket( key, label ) {
			if ( ! byKey[ key ] ) {
				byKey[ key ] = { key: key, label: label, fields: [], toggle: null, switches: boolOf };
				groups.push( byKey[ key ] );
			}
			return byKey[ key ];
		}

		fields.forEach( function ( f ) {
			var p = groupKeyOf( f.key );
			// Repeaters are already a box of their own: they stay out of the grouping.
			if ( 'repeater' === f.type || ! p || counts[ p ] < 2 ) {
				bucket( '__' + f.key, '' ).fields.push( f );
				return;
			}
			var g = bucket( p, humanizeKey( p ) );
			// THE ELEMENT'S SWITCH GOES INTO THE HEADING, next to the name of the thing it turns
			// off. Left in the body it is a row reading «Show» at the bottom of a list, and what it
			// shows or hides is anybody's guess — it looks like it belongs to the field above it,
			// which is the one thing it does not.
			//
			// THREE SPELLINGS, and none of them is optional. `_visible` is what the prompts teach;
			// `_visibile` is what they taught while they were Italian, and it lives on in the schema
			// of every section generated back then — saved on customers' sites, never regenerated;
			// `_show` is what the model writes anyway, often enough to be the common case in
			// practice. A spelling that is not recognised does not fail loudly: the switch simply
			// stops being the element's switch and comes back as a stray row, which is exactly the
			// floating «Show» this panel had.
			if ( 'bool' === f.type && ( f.key === p + '_visible' || f.key === p + '_visibile' || f.key === p + '_show' ) ) { g.toggle = f; }
			else { g.fields.push( f ); }
		} );

		return groups.filter( function ( g ) { return g.fields.length || g.toggle; } );
	}

	function groupKeyOf( key ) {
		var i = String( key || '' ).indexOf( '_' );
		return i > 0 ? key.slice( 0, i ) : '';
	}

	function humanizeKey( key ) {
		var t = String( key || '' ).replace( /_/g, ' ' ).trim();
		return t.charAt( 0 ).toUpperCase() + t.slice( 1 );
	}

	// A field's label inside a group: without the prefix, which is already in the box's title.
	function shortLabel( f, groupKey ) {
		var k = String( f.key || '' );
		if ( groupKey && 0 === k.indexOf( groupKey + '_' ) ) {
			return humanizeKey( k.slice( groupKey.length + 1 ) );
		}
		return f.label || k;
	}

	function buildGroup( g, store ) {
		// A convenience group (a single field): no box, the field goes as before.
		if ( 0 === g.key.indexOf( '__' ) ) {
			// A COPY, NOT THE SCHEMA'S OWN FIELD. withSwitch() hangs the bool on the object it is
			// handed, and this branch was the one place that handed it a field straight out of
			// `block.schema.fields` — the array serialised back to the database on the next save. It
			// wrote a second copy of one field inside another, permanently, and every reload made
			// the schema a little bigger. The other two paths already build a copy; so does this one.
			var f0 = g.fields[ 0 ];
			var only = withSwitch(
				{ key: f0.key, type: f0.type, label: f0.label, default: f0.default, subfields: f0.subfields },
				g.switches
			);
			return ( 'repeater' === only.type ) ? buildRepeater( only, store ) : buildField( only, store, only.key );
		}

		var box  = el( 'div', 'aipb-fgroup' );
		var head = el( 'div', 'aipb-fgroup-head' );
		head.appendChild( el( 'span', 'aipb-fgroup-title', g.label ) );

		var body = el( 'div', 'aipb-fgroup-body' );

		if ( g.toggle ) {
			// With the switch off, the element's fields are of no use any more: they hide too,
			// otherwise people would go on filling in something that is not visible.
			var sw = buildSwitch( g.toggle, store, function ( on ) {
				body.style.display = on ? '' : 'none';
				box.classList.toggle( 'is-off', ! on );
			} );
			head.appendChild( sw.el );
			body.style.display = sw.on ? '' : 'none';
			box.classList.toggle( 'is-off', ! sw.on );
		}

		box.appendChild( head );
		makeUnits( g.fields, g.key ).forEach( function ( u ) {
			body.appendChild( buildUnit( u, g.key, store, g.switches ) );
		} );
		box.appendChild( body );
		return box;
	}

	/* --------------- Sub-groups: one element, its own fields --------------- */

	/**
	 * Inside a group, it puts back together the fields describing THE SAME THING: the image with its
	 * alternative text, the button with its link. Two criteria, both general — no list of special
	 * cases to keep up to date:
	 *
	 *  1. a key that is a PREFIX of another is its head: hero_cta ← hero_cta_url;
	 *  2. an image adopts the first "alt" text field that follows it: hero_img ← hero_alt. It is
	 *     needed because, given the way keys are written, an image's alt almost never inherits the
	 *     image's name — but with no image in front of it, it means nothing.
	 */
	function makeUnits( fields, groupKey ) {
		var headOf = {}; // a key -> the key of its head

		// 1. Who extends whom. It looks for the LONGEST prefix, and it looks at the whole list: in
		//    the schema the keys arrive in the order they appear in the code, so hero_cta_url may
		//    well come before hero_cta. Binding them by order would be a lucky accident.
		fields.forEach( function ( f ) {
			var head = '';
			fields.forEach( function ( o ) {
				if ( o.key === f.key || 0 !== f.key.indexOf( o.key + '_' ) ) { return; }
				if ( o.key.length > head.length ) { head = o.key; }
			} );
			if ( head ) { headOf[ f.key ] = head; }
		} );

		// 2. The alt right after an image: given the way keys are written it almost never inherits
		//    the image's name (hero_img → hero_alt), but on its own it means nothing.
		fields.forEach( function ( f, i ) {
			if ( 'image' !== f.type ) { return; }
			var c = fields[ i + 1 ];
			if ( c && ! headOf[ c.key ] && ( 'text' === c.type || 'textarea' === c.type ) && /(^|_)alt$/.test( c.key ) ) {
				headOf[ c.key ] = f.key;
			}
		} );

		// A companion cannot be a head in turn: two levels are enough.
		var units = [], byKey = {};
		fields.forEach( function ( f ) {
			if ( headOf[ f.key ] ) { return; }
			byKey[ f.key ] = { head: f, mates: [] };
			units.push( byKey[ f.key ] );
		} );
		fields.forEach( function ( f ) {
			if ( ! headOf[ f.key ] ) { return; } // it is a head: its unit already exists
			var k = headOf[ f.key ];
			while ( k && ! byKey[ k ] ) { k = headOf[ k ]; } // the head's head, if needed
			if ( byKey[ k ] ) { byKey[ k ].mates.push( f ); }
			else { units.push( { head: f, mates: [] } ); } // an orphan: better shown than lost
		} );

		return units;
	}

	/* ------------------------- What kind of field is this ------------------------- */

	/**
	 * THE TYPE, AS A GLYPH INSTEAD OF A WORD.
	 *
	 * Most labels in this panel used to say only what the control already showed: «Text» over a
	 * text box, «Link» over a URL box, «Show» beside a switch. Every one of them cost a line of its
	 * own and a scan of the eye, and told you nothing you could not see. A section with a heading,
	 * a paragraph, a button and a switch produced eight rows to say four things.
	 *
	 * So the type moved INTO the control, as a 15px mark in the input's left gutter, and the label
	 * line survives only where it carries a NAME — «Kicker», «Title», «Alternative text». The rule
	 * is worth stating plainly: a label that repeats the type is not a label, it is decoration with
	 * a colon after it.
	 *
	 * The glyphs are the same family as the rest of the Studio (svgIcon: 24-grid, currentColor,
	 * 2px stroke), so they inherit the panel's greys and need no palette of their own.
	 *
	 * A function and not a `var` map because the SVG_* constants live at the bottom of this file:
	 * a declaration hoists, an assignment does not, and this is called from renderFields() far
	 * above them.
	 */
	function fieldTypeIcon( type ) {
		switch ( type ) {
			case 'textarea':
			case 'richtext':
				// Lines of prose, the last one short: a paragraph, not a single line.
				return svgIcon( '<line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="13" y2="18"/>' );
			case 'url':
				return svgIcon( '<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7L12 5"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7L12 19"/>' );
			case 'image':
				return svgIcon( '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-4.5-4.5L5 21"/>' );
			case 'color':
				// A droplet. The circle-with-a-line version this replaced read as a "forbidden"
				// sign at 15px, which is a strong thing for an icon to say by accident.
				return svgIcon( '<path d="M12 3s6 6.4 6 10a6 6 0 0 1-12 0c0-3.6 6-10 6-10Z"/>' );
			case 'bool':
				// An eye: this element is shown, or it is not.
				return svgIcon( '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>' );
			case 'shortcode':
				return svgIcon( '<polyline points="8 6 3 12 8 18"/><polyline points="16 6 21 12 16 18"/>' );
			case 'repeater':
				return svgIcon( '<rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/>' );
			default:
				// A capital T on a baseline: the plain text field.
				return svgIcon( '<polyline points="5 7 5 4 19 4 19 7"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="9" y1="20" x2="15" y2="20"/>' );
		}
	}

	/**
	 * The type in words. It never appears on screen as a label any more — it is the icon's tooltip
	 * and, where the visible label has been dropped, the input's `aria-label`.
	 *
	 * THAT SECOND USE IS NOT OPTIONAL. Removing a <label> and leaving an icon behind is how a panel
	 * becomes tidy for whoever can see it and empty for whoever cannot: an unlabelled text box is
	 * announced as "edit text, blank", which is the screen-reader equivalent of an unlabelled key
	 * on a keyboard.
	 */
	function fieldTypeName( type ) {
		switch ( type ) {
			case 'textarea':  return __( 'Text' );
			case 'richtext':  return __( 'Formatted text' );
			case 'url':       return __( 'Link' );
			case 'image':     return __( 'Image' );
			case 'color':     return __( 'Colour' );
			case 'bool':      return __( 'Show or hide' );
			case 'shortcode': return __( 'Shortcode' );
			case 'repeater':  return __( 'List' );
			default:          return __( 'Text' );
		}
	}

	/** The gutter mark itself. Decorative to a screen reader: the input carries the name. */
	function fieldIcoEl( type ) {
		var ico = el( 'span', 'aipb-field-ico' );
		ico.innerHTML = fieldTypeIcon( type );
		ico.title = fieldTypeName( type );
		ico.setAttribute( 'aria-hidden', 'true' );
		return ico;
	}

	// The labels inside a unit: the head takes the name of its type, the mates what is left of the
	// key. That way it reads «Image / Alternative text», not «Img / Alt».
	var TYPE_LABEL = { image: 'Image', url: 'Link', color: 'Colour', richtext: 'Text', textarea: 'Text', text: 'Text', bool: 'Show', shortcode: 'Shortcode' };
	// The key suffixes are the ones the prompts teach, so they stay written as they are there.
	// `visibile` is the spelling the prompts taught while they were Italian, and it lives on in the
	// schema of every section generated back then: both are recognised.
	var TAIL_LABEL = {
		alt: 'Alternative text',
		url: 'Link', link: 'Link', img: 'Image', image: 'Image',
		text: 'Text', txt: 'Text', show: 'Show', visible: 'Show', visibile: 'Show',
	};
	// Which of those tails name a TYPE rather than a thing. `alt` is the odd one out and the reason
	// this map exists: «Alternative text» is a name — it says what that text is FOR, which no icon
	// can — while «Link» and «Image» merely repeat the control. The first keeps its label line, the
	// others hand the job to the gutter mark.
	/**
	 * What a field is called, inside a unit or on its own.
	 *
	 * IT IS ALWAYS SHOWN NOW, and that is a reversal worth recording. For one iteration the labels
	 * that merely repeated the type were deleted outright, on the reasoning that the mark in the
	 * gutter already said «link». It read as tidier and was worse: under a heading reading PHONE sat
	 * two nameless boxes, bound to it by a 2px rule and to each other by nothing. Removing a word
	 * without putting an anchor in its place does not simplify a layout, it unmoors it.
	 *
	 * What made the word affordable was moving to ROWS. In a stacked layout every label cost a line
	 * of height, so a word that said nothing was worth deleting; in a row it costs a column that is
	 * there anyway, and the aligned column of names is the strongest structural signal the panel
	 * has. The icon stayed as the fast scan; the word came back as the label — a real one, with a
	 * `for`, which is also how the field gets a name for anybody not looking at it.
	 *
	 * A NAMED LIST AND NOT A LENGTH TEST. This used to end with «if the field is its unit's head and
	 * its tail is four characters or fewer, call it by its type» — which is true of `text`, `img`
	 * and `url`, and just as true of `note`, `logo`, `date`, `name` and `city`. A field somebody
	 * deliberately called «note» came out labelled «Text». A guess about a string's length cannot
	 * tell a type from a short noun; a list of the type words can, and it is nine entries long.
	 */
	function unitLabel( f, head, groupKey ) {
		var tail = f.key;
		if ( f !== head && 0 === tail.indexOf( head.key + '_' ) ) { tail = tail.slice( head.key.length + 1 ); }
		else if ( groupKey && 0 === tail.indexOf( groupKey + '_' ) ) { tail = tail.slice( groupKey.length + 1 ); }

		return TAIL_LABEL[ tail ] ? __( TAIL_LABEL[ tail ] ) : humanizeKey( tail );
	}

	function buildUnit( u, groupKey, store, switches ) {
		var fields = [ u.head ].concat( u.mates );

		// A single field: nothing to bind it to, so it is simply its own row.
		//
		// IT ASKS unitLabel() WITH ITSELF AS ITS OWN HEAD, and that is not a trick — it is the
		// common case, and it has to be named by the same rule as the rest. Most fields in a group
		// are alone in their unit (`cta_text`, `cta_url`, `text_show`); routing them through
		// shortLabel(), which knows nothing about the type tails, is what used to print «Url» over
		// a link and «Img» over a picture.
		if ( 1 === fields.length ) {
			var f = u.head;
			var alone = { key: f.key, type: f.type, label: unitLabel( f, f, groupKey ), default: f.default, subfields: f.subfields };
			alone = withSwitch( alone, switches );
			return 'repeater' === f.type ? buildRepeater( alone, store ) : buildField( alone, store, f.key );
		}

		// A UNIT NO LONGER DRAWS ANYTHING. It used to be a heading in caps over a 2px left rule —
		// a third level of nesting under the section and the element, signalled more faintly than
		// either, and the thing that made the panel unreadable: you could not tell where an element
		// ended and its sub-group began. In rows there is nothing left for it to do. Two fields
		// that describe one thing sit next to each other, named «Phone» and «Link», and the reading
		// order does the binding that the rule was failing to do.
		//
		// What survives is the part a rule could never carry anyway: the pairing, said out loud.
		var wrap = el( 'div', 'aipb-funit' );
		wrap.setAttribute( 'role', 'group' );
		wrap.setAttribute( 'aria-label', shortLabel( u.head, groupKey ) );
		fields.forEach( function ( f ) {
			var copy = { key: f.key, type: f.type, label: unitLabel( f, u.head, groupKey ), default: f.default, subfields: f.subfields };
			copy = withSwitch( copy, switches );
			wrap.appendChild( 'repeater' === f.type ? buildRepeater( copy, store ) : buildField( copy, store, f.key ) );
		} );
		return wrap;
	}

	/**
	 * A switch for a boolean field. The value stays a string ("1" / ""), which is what the template
	 * engine reads as true or false in {{#if}}: no new format to handle.
	 */
	function buildSwitch( f, store, onChange ) {
		var current = store[ f.key ];
		var on = ( current === undefined || current === null ) ? ( '' !== ( f.default || '' ) && '0' !== f.default ) : ( '' !== current && '0' !== current );
		store[ f.key ] = on ? '1' : '';

		var btn = el( 'button', 'aipb-switch' + ( on ? ' is-on' : '' ) );
		btn.type = 'button';
		btn.setAttribute( 'role', 'switch' );
		btn.setAttribute( 'aria-checked', on ? 'true' : 'false' );
		btn.title = __( 'Show or hide this element' );
		btn.appendChild( el( 'span', 'aipb-switch-dot' ) );

		btn.addEventListener( 'click', function () {
			on = ! on;
			store[ f.key ] = on ? '1' : '';
			btn.classList.toggle( 'is-on', on );
			btn.setAttribute( 'aria-checked', on ? 'true' : 'false' );
			if ( onChange ) { onChange( on ); }
			state.dirty = true; refreshSaveState(); schedulePreview( 0 ); scheduleHistory();
		} );

		return { el: btn, on: on };
	}

	/** Hands a field's copy the bool that switches it, if it has one. The SCHEMA is never touched. */
	function withSwitch( copy, switches ) {
		if ( switches && switches[ copy.key ] ) { copy.switchField = switches[ copy.key ]; }
		return copy;
	}

	/**
	 * ONE FIELD, ONE ROW: the type, the name, the control — three columns, always in that order.
	 *
	 * IT USED TO BE A STACK: a label line, then the control under it. That is why the panel could
	 * not be read. Every field cost two rows, so a section with six fields filled a screen; and
	 * because the label sat ABOVE its control, the eye had to decide by proximity alone whether a
	 * name belonged to the box under it or the box over it — which is the one thing proximity
	 * cannot settle when the spacing is even.
	 *
	 * In a row the name sits BESIDE what it names, so the question never arises, and the column of
	 * names down the left edge is the strongest structural signal the panel has: everything sharing
	 * that column is a field, and everything sharing a card is one element. Two levels, both
	 * obvious, where there used to be three and none.
	 *
	 * THE MARK IS A COLUMN NOW, not an overlay. It was absolutely positioned inside the input and
	 * centred with a hand-measured `top`, which held on the machine it was measured on and rode
	 * visibly high everywhere else. A grid cell has the row's height by definition; there is no
	 * number to get wrong.
	 */
	function buildField( f, store, key ) {
		var row  = el( 'div', 'aipb-field' );
		var name = f.label || key;
		var id   = 'aipb-f-' + String( key ).replace( /[^a-z0-9_-]/gi, '' ) + '-' + ( ++fieldSeq );

		row.appendChild( fieldIcoEl( f.type ) );

		// A REAL <label>, with a `for`. The previous pass dropped these and put the word in an
		// aria-label instead, which names the control for a screen reader and does nothing for a
		// mouse: clicking a name no longer focused its field. A label costs one attribute and
		// serves both.
		var label = el( 'label', 'aipb-field-label', name );
		label.setAttribute( 'for', id );
		row.appendChild( label );

		// A boolean: the switch is the control, pushed to the far end of its own column.
		if ( 'bool' === f.type ) {
			var sw = buildSwitch( f, store ).el;
			sw.id = id;
			row.appendChild( sw );
			rowSwitch( row, f, store );
			return row;
		}

		// Rich text ({{r:…}}): a mini editor that emits only safe inline HTML.
		if ( f.type === 'richtext' ) {
			// THE SWITCH GOES ON LAST, and in a grid that is not a detail: the cells fill the
			// columns in the order they are appended, so reserving the fourth one before the editor
			// exists puts the EDITOR in the fourth column — 34px wide, one character per line.
			row.className = 'aipb-field aipb-field--tall';
			var rte = buildRichText( row, f, store, key, name, id );
			rowSwitch( rte, f, store );
			return rte;
		}

		var input;
		if ( f.type === 'textarea' ) { input = el( 'textarea' ); row.className = 'aipb-field aipb-field--tall'; }
		else if ( f.type === 'color' ) { input = el( 'input' ); input.type = 'color'; }
		else {
			input = el( 'input' ); input.type = 'text';
			if ( f.type === 'image' ) { input.placeholder = __( 'Image URL…' ); }
			else if ( f.type === 'url' ) { input.placeholder = __( 'https://…' ); }
			// A shortcode ({{s:…}}): it accepts only the [tag attributes] form, no HTML around it.
			else if ( f.type === 'shortcode' ) { input.className = 'aipb-mono'; input.placeholder = '[shortcode]'; }
		}
		input.id = id;

		var current = store[ key ];
		input.value = ( current === undefined || current === null ) ? ( f.default || '' ) : current;
		store[ key ] = input.value;

		input.addEventListener( 'input', function () {
			store[ key ] = input.value;
			state.dirty = true; refreshSaveState(); schedulePreview( 300 ); scheduleHistory();
		} );

		if ( f.type === 'image' ) {
			// A hand-written URL has nothing to do with the attachment picked before any more: the ID
			// has to go, or the srcset and the dimensions would describe the wrong image.
			input.addEventListener( 'input', function () { delete store[ key + '_id' ]; } );

			var wrap = el( 'div', 'aipb-media-field' );

			// THE THUMBNAIL LEADS THE CONTROL. An image field is about the image: a 32px square
			// answers «which one is this» before the URL has been read. It keeps its place when
			// empty — holding a plus rather than the type's own mark, which is already one column
			// to the left — so a section with three images does not shift as they are filled in.
			var thumb = el( 'div', 'aipb-media-thumb' );
			function paintThumb() {
				var v = ( input.value || '' ).trim();
				thumb.innerHTML = '';
				if ( v ) {
					var im = el( 'img' ); im.src = v; im.alt = ''; im.loading = 'lazy';
					thumb.appendChild( im );
					thumb.classList.remove( 'is-empty' );
				} else {
					thumb.innerHTML = svgIcon( '<line x1="12" y1="6" x2="12" y2="18"/><line x1="6" y1="12" x2="18" y2="12"/>' );
					thumb.classList.add( 'is-empty' );
				}
			}
			input.addEventListener( 'input', paintThumb );
			paintThumb();

			wrap.appendChild( thumb );
			wrap.appendChild( input );
			var mb = el( 'button', 'aipb-media-btn', __( 'Media' ) );
			mb.type = 'button';
			mb.addEventListener( 'click', function () { openMediaPicker( input, store, key ); } );
			wrap.appendChild( mb );
			row.appendChild( wrap );
		} else {
			row.appendChild( input );
		}
		rowSwitch( row, f, store );
		return row;
	}

	/**
	 * THE ELEMENT'S SWITCH, at the end of its field's row — and out of the way until it is wanted.
	 *
	 * Now that every element has one, a column of switches always at full strength would be the
	 * loudest thing in the panel and the least often used: people come here to change a word, not
	 * to hide things. So it fades in on hover or focus, and the column it sits in is reserved
	 * whether or not there is one to show — a control that appears by making the row jump is worse
	 * than a control that was always there.
	 *
	 * IT STAYS VISIBLE WHEN IT IS OFF, always, and that is the part that matters. A hidden element
	 * whose switch is also hidden is an element that has vanished with nothing on screen to say
	 * why — you would be left looking at a preview missing a button and a panel that shows the
	 * button's fields, perfectly filled in, with no explanation between them. The row dims with it,
	 * so the state reads without hunting for the switch.
	 */
	function rowSwitch( row, f, store ) {
		if ( ! f.switchField ) {
			// The empty cell keeps the grid honest: without it every row without a switch would be
			// one column narrower than its neighbours and nothing would line up.
			row.appendChild( el( 'span', 'aipb-field-off' ) );
			return;
		}

		var cell = el( 'span', 'aipb-field-off aipb-field-off--live' );
		var sw   = buildSwitch( f.switchField, store, function ( on ) {
			row.classList.toggle( 'is-off', ! on );
		} );

		sw.el.title = sprintf( __( 'Show or hide «%s» on the page' ), f.label || f.key );
		row.classList.toggle( 'is-off', ! sw.on );
		cell.appendChild( sw.el );
		row.appendChild( cell );
	}

	/** Ids have to be unique on the page, and a key can repeat across two sections' schemas. */
	var fieldSeq = 0;

	// A mini editor for the "richtext" type: a bar (bold/italic/link/line break) over a
	// contenteditable div. The saved value is inline HTML; the engine sanitizes it at render time
	// (wp_kses), so producing clean markup is enough here. Enter = <br>, paste = plain text.
	function buildRichText( row, f, store, key, name, id ) {
		var current = store[ key ];
		var initial = ( current === undefined || current === null ) ? ( f.default || '' ) : String( current );

		var wrap   = el( 'div', 'aipb-rte' );
		var bar    = el( 'div', 'aipb-rte-bar' );
		var editor = el( 'div', 'aipb-rte-input' );
		editor.contentEditable = 'true';
		editor.setAttribute( 'role', 'textbox' );
		editor.setAttribute( 'aria-multiline', 'true' );
		editor.setAttribute( 'data-placeholder', name || fieldTypeName( f.type ) );
		if ( id ) { editor.id = id; }
		editor.innerHTML = initial;
		store[ key ] = initial;

		function sync() {
			store[ key ] = editor.innerHTML;
			state.dirty = true; refreshSaveState(); schedulePreview( 300 ); scheduleHistory();
		}
		function cmd( command, value ) {
			editor.focus();
			try { document.execCommand( command, false, value ); } catch ( e ) {}
			sync();
		}

		[
			{ label: 'B', cls: ' b', title: __( 'Bold' ),       run: function () { cmd( 'bold' ); } },
			{ label: 'I', cls: ' i', title: __( 'Italic' ),     run: function () { cmd( 'italic' ); } },
			// The only emoji in a bar of B / I / ↵ — it took its own colour and its own size while the
			// others took the button's. Now all four are text or marks of the same family.
			{ label: SVG_LINK, html: true, cls: '', title: __( 'Link' ), run: function () {
				var url = window.prompt( __( 'The link\'s URL:' ), 'https://' );
				if ( url ) { cmd( 'createLink', url ); }
			} },
			{ label: '↵', cls: '',  title: __( 'Line break' ), run: function () { cmd( 'insertLineBreak' ); } },
		].forEach( function ( b ) {
			var btn = el( 'button', 'aipb-rte-btn' + b.cls, b.html ? null : b.label );
			if ( b.html ) { btn.innerHTML = b.label; }
			btn.type = 'button'; btn.title = b.title;
			// mousedown preventDefault: do not lose the editor's selection/focus on click.
			btn.addEventListener( 'mousedown', function ( e ) { e.preventDefault(); } );
			btn.addEventListener( 'click', b.run );
			bar.appendChild( btn );
		} );

		editor.addEventListener( 'input', sync );
		// Enter = a clean <br> (it avoids the <div>/<p> the browser would insert on its own).
		editor.addEventListener( 'keydown', function ( e ) {
			if ( e.key === 'Enter' ) { e.preventDefault(); cmd( 'insertLineBreak' ); }
		} );
		// Paste as PLAIN text: no block HTML imported from outside.
		editor.addEventListener( 'paste', function ( e ) {
			e.preventDefault();
			var text = ( ( e.clipboardData || window.clipboardData ).getData( 'text/plain' ) ) || '';
			try { document.execCommand( 'insertText', false, text ); } catch ( e2 ) {}
			sync();
		} );

		wrap.appendChild( bar );
		wrap.appendChild( editor );
		row.appendChild( wrap );
		return row;
	}

	// WordPress's native Media Library, to pick/upload an image.
	// The field still holds a URL — one can still paste one by hand — but when the image comes from
	// the Media Library we record its ID alongside. The engine needs it to derive the srcset, the
	// dimensions and the alt (see AIPB_Template_Engine::media_companion) without having to trace it
	// back from the URL with a query.
	function openMediaPicker( input, store, key ) {
		if ( ! ( window.wp && window.wp.media ) ) { toast( __( 'The Media Library is not available' ), true ); return; }
		var frame = window.wp.media( {
			title: __( 'Choose an image' ),
			button: { text: __( 'Use this image' ) },
			library: { type: 'image' },
			multiple: false,
		} );
		frame.on( 'select', function () {
			var att = frame.state().get( 'selection' ).first().toJSON();
			input.value = att.url;
			// The event first (it updates the value and drops the old ID), then the new ID: the other
			// way round, the listener that cleans up would delete what we have just written.
			input.dispatchEvent( new Event( 'input', { bubbles: true } ) );
			if ( store && key && att.id ) { store[ key + '_id' ] = att.id; }
		} );
		frame.open();
	}

	function buildRepeater( f, store ) {
		var box  = el( 'div', 'aipb-repeater' );
		var head = el( 'div', 'aipb-rep-title' );
		head.appendChild( el( 'span', 'aipb-field-label', f.label || f.key ) );

		// A LIST IS AN ELEMENT LIKE ANY OTHER, and it gets its switch in the same place the card
		// gets one: in the heading, beside the name of the thing it turns off. A repeater has no row
		// to ride on — it IS a box — so the fourth column the ordinary fields reserve does not
		// exist here, and the switch would have had nowhere to go. It stays at full strength rather
		// than fading in on hover: a box is not a row, there is no column of them to quieten.
		if ( f.switchField ) {
			var sw = buildSwitch( f.switchField, store, function ( on ) {
				box.classList.toggle( 'is-off', ! on );
			} );
			sw.el.title = sprintf( __( 'Show or hide «%s» on the page' ), f.label || f.key );
			box.classList.toggle( 'is-off', ! sw.on );
			head.appendChild( sw.el );
		}

		box.appendChild( head );

		if ( ! Array.isArray( store[ f.key ] ) ) { store[ f.key ] = []; }
		var rows = store[ f.key ];
		var list = el( 'div' );

		function redraw() {
			list.innerHTML = '';
			rows.forEach( function ( item, idx ) {
				var card = el( 'div', 'aipb-rep-item' );
				var head = el( 'div', 'aipb-rep-head' );
				head.appendChild( el( 'span', '', ( f.label || f.key ) + ' #' + ( idx + 1 ) ) );
				var del = el( 'button', 'aipb-rep-del', __( 'Remove' ) );
				del.addEventListener( 'click', function () {
					rows.splice( idx, 1 ); redraw(); state.dirty = true; refreshSaveState(); schedulePreview( 0 ); commitHistory();
				} );
				head.appendChild( del );
				card.appendChild( head );
				// THE SAME RULE INSIDE A REPEATER. A row's subfields are named by the same hands and
				// with the same habits as everything else — `name`, `img`, `url` — so a list of
				// logos was printing «Img» above every picture. It goes through unitLabel() with no
				// group and no heading above it: the tail is the whole key, and only a literal type
				// word is dropped.
				( f.subfields || [] ).forEach( function ( sf ) {
					card.appendChild( buildField(
						{ key: sf.key, type: sf.type, label: unitLabel( sf, sf, '' ), default: sf.default },
						item,
						sf.key
					) );
				} );
				list.appendChild( card );
			} );
		}
		redraw();

		var add = el( 'button', 'aipb-rep-add', __( '+ Add an item' ) );
		add.addEventListener( 'click', function () {
			var blank = {};
			( f.subfields || [] ).forEach( function ( sf ) { blank[ sf.key ] = sf.default || ''; } );
			rows.push( blank ); redraw(); state.dirty = true; refreshSaveState(); schedulePreview( 0 ); commitHistory();
		} );

		box.appendChild( list );
		box.appendChild( add );
		return box;
	}

	/* --------------------------------- Code -------------------------------- */

	var CODE_MODES = { html: 'htmlmixed', css: 'css', js: 'javascript' };
	var codeExpanded = false; // the editor at full width over the preview
	var codeSyncing = false;  // true while we set the value from code (it avoids spurious changes)

	// The template is a single string: it is split into HTML / CSS (<style>) / JS (<script>).
	function splitTemplate( tpl ) {
		tpl = String( tpl || '' );
		var css = [], js = [];
		var html = tpl
			.replace( /<style[^>]*>([\s\S]*?)<\/style>/gi, function ( m, inner ) { css.push( inner.replace( /^\n+|\n+$/g, '' ) ); return ''; } )
			.replace( /<script[^>]*>([\s\S]*?)<\/script>/gi, function ( m, inner ) { js.push( inner.replace( /^\n+|\n+$/g, '' ) ); return ''; } );
		return {
			html: html.replace( /\n{3,}/g, '\n\n' ).replace( /^\n+|\n+$/g, '' ),
			css: css.join( '\n\n' ).trim(),
			js: js.join( '\n\n' ).trim(),
		};
	}

	function recombineTemplate( parts ) {
		var out = ( parts.html || '' ).trim();
		var css = ( parts.css || '' ).trim();
		var js = ( parts.js || '' ).trim();
		if ( css ) { out += '\n\n<style>\n' + css + '\n</style>'; }
		if ( js ) { out += '\n\n<script>\n' + js + '\n</script>'; }
		return out;
	}

	// The section whose code is shown (the selected inline block, or the current section).
	function codeTarget() {
		var sec;
		if ( pageMode() ) {
			var b = activeBlock();
			if ( ! b ) { return { err: __( 'Pick an inline block (the Blocks tab) to change its code.' ) }; }
			if ( b.type === 'ref' ) { return { err: __( 'A synced pattern: to change its code here only, unlink it from the «Fields» tab. To change it everywhere, open it from «Patterns» (☰).' ) }; }
			sec = b;
		} else {
			sec = state;
		}
		if ( ! sec.template ) { return { err: __( 'Generate a section to see its code.' ) }; }
		return { sec: sec };
	}

	function destroyCode() {
		if ( dom.code && dom.code.cm ) { try { dom.code.cm.toTextArea(); } catch ( e ) {} }
		dom.code = null;
	}

	function codeGet() { var c = dom.code; return c.useCM ? c.cm.getValue() : c.ta.value; }
	function codeSet( v ) {
		var c = dom.code;
		codeSyncing = true; // CodeMirror's setValue emits 'change': it is not a change by the user
		if ( c.useCM ) { c.cm.setValue( v ); c.cm.refresh(); } else { c.ta.value = v; }
		codeSyncing = false;
	}

	// A stable identity for the code's target: it tells whether to REBUILD the editor (a different
	// target) or only to SYNC its content (the same target, preserving caret/scroll/focus).
	function codeTargetId( sec ) { return sec === state ? 'section' : ( 'block:' + sec.id ); }

	/* --- DSL-aware: evidenziazione segnaposto + autocompletamento --- */

	// A CodeMirror overlay that colours the {{t:…}} placeholders and the {{#loop}}/{{/if}} controls.
	function aipbDslOverlay() {
		return {
			token: function ( stream ) {
				var ch;
				if ( stream.match( '{{' ) ) {
					var ctrl = /^\s*[#\/]/.test( stream.string.slice( stream.pos ) );
					while ( ( ch = stream.next() ) != null ) {
						if ( '}' === ch && '}' === stream.peek() ) { stream.next(); break; }
					}
					return ctrl ? 'aipb-ctrl' : 'aipb-ph';
				}
				while ( stream.next() != null && '{' !== stream.peek() ) {}
				return null;
			},
		};
	}

	// The list of --aipb-* tokens actually defined, for the code editor's suggestions. The cache is
	// invalidated by the CSS itself: if the tokens change (an import from a mockup, an edit to the
	// palette) the suggester follows, without reloading the Studio.
	var _aipbTokenVars = null, _aipbTokenVarsFrom = null;
	function aipbTokenVars() {
		var css = designTokensCss();
		if ( _aipbTokenVars && css === _aipbTokenVarsFrom ) { return _aipbTokenVars; }
		var out = [], seen = {}, re = /--aipb-[a-z0-9-]+/gi, m;
		while ( ( m = re.exec( css ) ) ) { if ( ! seen[ m[0] ] ) { seen[ m[0] ] = 1; out.push( m[0] ); } }
		_aipbTokenVars = out.sort();
		_aipbTokenVarsFrom = css;
		return _aipbTokenVars;
	}

	var AIPB_LOOP_FIELDS = [ 'title', 'excerpt', 'permalink', 'date', 'thumbnail', 'thumbnail_srcset', 'thumbnail_sizes', 'thumbnail_w', 'thumbnail_h', 'thumbnail_alt' ];

	function aipbPlaceholderList() {
		return [
			{ text: '{{t:key|Text}}', displayText: __( '{{t:}} editable text' ) },
			{ text: '{{r:key|Text with <strong>formatting</strong>}}', displayText: __( '{{r:}} rich text' ) },
			// No {{c:}} here: the engine still prints it — the sections already out there have it and
			// must go on working — but suggesting it means offering to write a colour in two places,
			// the field and the Style panel. Colours are changed from there.
			{ text: '{{i:image|https://picsum.photos/1200/700}}', displayText: __( '{{i:}} image' ) },
			{ text: '{{u:link|https://example.com}}', displayText: __( '{{u:}} url' ) },
			{ text: '{{#loop source="post" type="post" limit="6"}}\n  \n{{/loop}}', displayText: __( '{{#loop}} dynamic posts' ) },
			{ text: '{{#loop source="prop" name="items"}}\n  \n{{/loop}}', displayText: __( '{{#loop}} a curated list' ) },
			{ text: '{{#if key}}\n  \n{{/if}}', displayText: __( '{{#if}} a condition' ) },
		];
	}

	// Contextual suggestions: design tokens (var(--aipb-*)), loop fields (field.*), placeholders.
	function aipbHint( cm ) {
		var Pos = window.wp.CodeMirror.Pos;
		var cur = cm.getCursor();
		var upto = cm.getLine( cur.line ).slice( 0, cur.ch );
		var m;
		if ( ( m = /var\(\s*(--[a-z0-9-]*)$/i.exec( upto ) ) || ( m = /(--aipb[a-z0-9-]*)$/i.exec( upto ) ) ) {
			var typed = m[1].toLowerCase();
			var list = aipbTokenVars().filter( function ( v ) { return 0 === v.toLowerCase().indexOf( typed ); } );
			return list.length ? { list: list, from: Pos( cur.line, cur.ch - m[1].length ), to: cur } : null;
		}
		if ( ( m = /field\.([a-z0-9_]*)$/i.exec( upto ) ) ) {
			var frag = m[1];
			var fl = AIPB_LOOP_FIELDS.filter( function ( f ) { return 0 === f.indexOf( frag ); } );
			return fl.length ? { list: fl, from: Pos( cur.line, cur.ch - frag.length ), to: cur } : null;
		}
		if ( ( m = /\{\{([#\/a-z:.\s"=0-9_-]*)$/i.exec( upto ) ) ) {
			var q = m[1].toLowerCase();
			var list2 = aipbPlaceholderList().filter( function ( o ) { return 0 === o.text.toLowerCase().indexOf( '{{' + q ); } );
			if ( ! list2.length ) { list2 = aipbPlaceholderList(); }
			return { list: list2, from: Pos( cur.line, cur.ch - m[0].length ), to: cur };
		}
		return null;
	}

	// The suggestions open on their own while typing in a relevant context.
	function maybeCodeHint( cm ) {
		if ( ! cm.showHint ) { return; }
		var cur = cm.getCursor(), upto = cm.getLine( cur.line ).slice( 0, cur.ch );
		if ( /(--aipb[a-z0-9-]*|var\(\s*--[a-z0-9-]*|field\.[a-z0-9_]*|\{\{[#\/a-z:.\s"=0-9_-]*)$/i.test( upto ) ) {
			cm.showHint( { hint: aipbHint, completeSingle: false } );
		}
	}

	// Format: it re-indents with CodeMirror's native engine (no external dependency).
	function formatCode() {
		var c = dom.code;
		if ( ! c || ! c.useCM ) { return; }
		var cm = c.cm, cursor = cm.getCursor();
		cm.operation( function () {
			cm.execCommand( 'selectAll' );
			cm.indentSelection( 'smart' );
			cm.setCursor( cursor );
		} );
		cm.focus();
	}

	// The entry point. It rebuilds the editor ONLY when the target section changes (or on the first
	// round); otherwise it updates in place, so a background refresh does not reset the caret, the
	// scroll, the focus or the active HTML/CSS/JS subtab.
	function renderCode() {
		var t = codeTarget();
		if ( t.err ) {
			// In an error state there is no "collapse" button left: leave the expanded mode so as not
			// to trap the user in a full-screen overlay with the message alone.
			if ( codeExpanded ) { codeExpanded = false; applyCodeExpand(); }
			destroyCode();
			dom.codeWrap.innerHTML = '';
			dom.codeWrap.appendChild( el( 'div', 'aipb-fields-empty', t.err ) );
			return;
		}
		var tid = codeTargetId( t.sec );
		if ( dom.code && dom.code.targetId === tid ) {
			dom.code.sec = t.sec;
			syncCodeFromTarget();
			return;
		}
		buildCodeEditor( t.sec, tid );
	}

	// It updates the shown value ONLY if the template changed from outside (a generation, an undo…):
	// if the change came from the editor itself it is already in sync and we leave the caret alone.
	function syncCodeFromTarget() {
		var c = dom.code;
		if ( c.sec.template === recombineTemplate( c.parts ) ) {
			if ( c.useCM && state.tab === 'code' ) { c.cm.refresh(); }
			return;
		}
		c.parts = splitTemplate( c.sec.template );
		codeSet( c.parts[ c.current ] || '' );
	}

	function buildCodeEditor( sec, tid ) {
		destroyCode();
		dom.codeWrap.innerHTML = '';

		var parts = splitTemplate( sec.template );
		var cur = state.codeSubtab || 'html';
		var labels = { html: 'HTML', css: 'CSS', js: 'JS' };

		var bar = el( 'div', 'aipb-code-tabs' );
		var btns = {};
		[ 'html', 'css', 'js' ].forEach( function ( k ) {
			var btn = el( 'button', k === cur ? 'active' : '', labels[ k ] );
			btn.addEventListener( 'click', function () { switchSubtab( k ); } );
			bar.appendChild( btn ); btns[ k ] = btn;
		} );
		// The "Format" button (it re-indents) — pushed right with the actions group.
		var fmtBtn = el( 'button', 'aipb-code-fmt', __( 'Format' ) );
		fmtBtn.type = 'button';
		fmtBtn.title = __( 'Tidies the code\'s indentation' );
		fmtBtn.addEventListener( 'click', formatCode );
		bar.appendChild( fmtBtn );
		// The expand/collapse button: the editor at full width over the preview.
		dom.codeExpandBtn = el( 'button', 'aipb-code-expand', '' );
		dom.codeExpandBtn.type = 'button';
		dom.codeExpandBtn.addEventListener( 'click', toggleCodeExpand );
		bar.appendChild( dom.codeExpandBtn );

		var host = el( 'div', 'aipb-code-host' );
		var ta = el( 'textarea' );
		host.appendChild( ta );

		dom.codeWrap.appendChild( bar );
		dom.codeWrap.appendChild( host );

		var useCM = !! ( window.wp && window.wp.CodeMirror );
		var cm = null;
		if ( useCM ) {
			cm = window.wp.CodeMirror.fromTextArea( ta, {
				mode: CODE_MODES[ cur ],
				lineNumbers: true, lineWrapping: true,
				autoCloseTags: true, matchBrackets: true,
				indentUnit: 2, tabSize: 2,
				extraKeys: {
					'Esc': function () { if ( codeExpanded ) { toggleCodeExpand(); } },
					'Ctrl-Space': function ( c ) { if ( c.showHint ) { c.showHint( { hint: aipbHint, completeSingle: false } ); } },
					'Cmd-F': 'findPersistent', 'Ctrl-F': 'findPersistent',
					'Cmd-G': 'findNext', 'Ctrl-G': 'findNext',
					'Shift-Cmd-G': 'findPrev', 'Shift-Ctrl-G': 'findPrev',
					'Cmd-Alt-F': 'replace', 'Shift-Ctrl-F': 'replace',
				},
			} );
			cm.setValue( parts[ cur ] || '' );
			try { cm.addOverlay( aipbDslOverlay(), { opaque: true } ); } catch ( e ) {}
			cm.on( 'change', onCodeChange );
			cm.on( 'inputRead', function () { maybeCodeHint( cm ); } );
			setTimeout( function () { cm.refresh(); }, 30 );
		} else {
			ta.className = 'aipb-code-plain';
			ta.value = parts[ cur ] || '';
			ta.addEventListener( 'input', onCodeChange );
		}

		dom.code = { cm: cm, ta: ta, parts: parts, current: cur, sec: sec, targetId: tid, btns: btns, useCM: useCM };
		applyCodeExpand(); // it reapplies any expanded state to the new editor
		updateExpandBtn();
	}

	function switchSubtab( k ) {
		var c = dom.code;
		if ( ! c || k === c.current ) { return; }
		c.parts[ c.current ] = codeGet();
		c.current = k;
		state.codeSubtab = k; // it remembers the subtab between refreshes
		if ( c.useCM ) { c.cm.setOption( 'mode', CODE_MODES[ k ] ); }
		codeSet( c.parts[ k ] || '' );
		Object.keys( c.btns ).forEach( function ( x ) { c.btns[ x ].classList.toggle( 'active', x === k ); } );
	}

	function onCodeChange() {
		var c = dom.code;
		if ( ! c || restoring || codeSyncing ) { return; }
		c.parts[ c.current ] = codeGet();
		c.sec.template = recombineTemplate( c.parts );
		state.dirty = true; refreshSaveState();
		schedulePreview( 500 ); scheduleHistory();
	}

	function refreshCodeIfOpen() { if ( state.tab === 'code' ) { renderCode(); } }

	/* --------------------------- Tidying the code -------------------------- */

	/**
	 * A style touch-up is additive: every change appends its rule at the end of the <style>. After
	 * twenty rounds the same selector appears twelve times and half the declarations never reach the
	 * screen. Here it is put back in order — on the server, with no AI: merging rules and removing
	 * dead declarations is a problem with an exact answer (AIPB_Tidy).
	 *
	 * It applies nothing on its own initiative: it shows what would change first. A button that
	 * rewrites 13 KB of code without showing the "before" is a button you press once.
	 */
	function tidySection( index ) {
		var sec;
		if ( typeof index === 'number' ) {
			sec = state.blocks[ index ];
			if ( ! sec ) { return; }
			if ( sec.type === 'ref' ) {
				toast( __( 'It is a synced pattern: open it in its own Studio to tidy it up.' ), true );
				return;
			}
		} else {
			var t = codeTarget();
			if ( t.err ) { toast( t.err, true ); return; }
			sec = t.sec;
		}
		if ( ! sec.template ) { toast( __( 'Nothing to tidy up.' ), true ); return; }

		toast( __( 'Analysing the code…' ) );
		api( '/tidy', { code: sec.template } ).then( function ( res ) {
			if ( ! res.changed ) {
				toast( __( 'The code is already in order: nothing to do.' ) );
				return;
			}
			openTidyPanel( sec, res );
		} ).catch( function ( e ) {
			toast( e.message || __( 'The tidy-up did not work' ), true );
		} );
	}

	// A summary of what would change, with the code before/after. It applies only on confirmation.
	function openTidyPanel( sec, res ) {
		var rep = res.report || {};
		var css = rep.css || {}, html = rep.html || {}, js = rep.js || {};

		var back = el( 'div', 'aipb-tidy-back' );
		var box = el( 'div', 'aipb-tidy' );

		var head = el( 'div', 'aipb-tidy-head' );
		head.appendChild( el( 'h3', '', sprintf( __( 'Tidying up «%s»' ), sec.name || __( 'this section' ) ) ) );
		var closeX = el( 'button', 'aipb-tidy-x', '×' );
		closeX.title = __( 'Close' );
		head.appendChild( closeX );
		box.appendChild( head );

		var body = el( 'div', 'aipb-tidy-body' );

		function row( label, value, hint ) {
			var r = el( 'div', 'aipb-tidy-row' );
			r.appendChild( el( 'span', 'k', label ) );
			r.appendChild( el( 'span', 'v', value ) );
			if ( hint ) { r.appendChild( el( 'span', 'h', hint ) ); }
			body.appendChild( r );
		}

		var before = css.rules_before || 0, after = css.rules_after || 0;
		row( __( 'CSS rules' ), before + ' → ' + after, before > after ? sprintf( __( '%d fewer' ), before - after ) : '' );
		if ( css.merged ) { row( __( 'Blocks merged' ), String( css.merged ), __( 'same selector, same context' ) ); }
		if ( css.dropped ) { row( __( 'Dead declarations' ), String( css.dropped ), __( 'already overridden: they never reached the screen' ) ); }
		if ( css.empty ) { row( __( 'Empty blocks' ), String( css.empty ), '' ); }
		if ( html.classes ) { row( __( 'Repeated classes' ), String( html.classes ), __( 'in the same tag' ) ); }
		if ( html.attrs ) { row( __( 'Repeated attributes' ), String( html.attrs ), __( 'in the same tag' ) ); }
		if ( html.blank || js.blank || js.trailing ) { row( __( 'Excess blank lines' ), String( ( html.blank || 0 ) + ( js.blank || 0 ) ), '' ); }

		var kb = function ( n ) { return ( n / 1024 ).toFixed( 1 ) + ' KB'; };
		if ( rep.bytes ) {
			row( __( 'Weight' ), kb( rep.bytes.before ) + ' → ' + kb( rep.bytes.after ),
				rep.bytes.after > rep.bytes.before ? __( 'more lines, fewer rules: the code is indented' ) : '' );
		}

		// The selectors that were written more than once: it is the line that explains what happened.
		if ( css.top && Object.keys( css.top ).length ) {
			var t = el( 'div', 'aipb-tidy-list' );
			t.appendChild( el( 'div', 'lbl', __( 'Written more than once:' ) ) );
			Object.keys( css.top ).forEach( function ( s ) {
				t.appendChild( el( 'div', 'it', s + '  ×' + ( css.top[ s ] + 1 ) ) );
			} );
			body.appendChild( t );
		}

		if ( css.kept ) {
			body.appendChild( el( 'p', 'aipb-tidy-note',
				sprintf( __( '%d rules stay where they are: moving them would change their cascade order against others touching the same properties.' ), css.kept ) ) );
		}
		if ( rep.orphans && rep.orphans.length ) {
			body.appendChild( el( 'p', 'aipb-tidy-warn',
				__( 'In the CSS but never in the HTML: ' ) + '.' + rep.orphans.join( ', .' ) + ' — ' + __( 'not removed (they can come from a loop or from the JS).' ) ) );
		}
		( rep.notes || [] ).forEach( function ( n ) {
			body.appendChild( el( 'p', 'aipb-tidy-warn', n ) );
		} );

		// The code, before and after: whoever wants to check has to be able to.
		var diff = el( 'div', 'aipb-tidy-diff' );
		var mk = function ( title, code ) {
			var col = el( 'div', 'col' );
			col.appendChild( el( 'div', 'lbl', title ) );
			var pre = el( 'pre' );
			pre.appendChild( el( 'code', '', code ) );
			col.appendChild( pre );
			return col;
		};
		diff.appendChild( mk( __( 'Before' ), sec.template ) );
		diff.appendChild( mk( __( 'After' ), res.code ) );
		diff.style.display = 'none';
		body.appendChild( diff );

		box.appendChild( body );

		var foot = el( 'div', 'aipb-tidy-foot' );
		var seeBtn = el( 'button', 'aipb-tidy-btn', __( 'See the code' ) );
		seeBtn.addEventListener( 'click', function () {
			var open = diff.style.display === 'none';
			diff.style.display = open ? 'flex' : 'none';
			seeBtn.textContent = open ? __( 'Hide the code' ) : __( 'See the code' );
		} );
		var cancel = el( 'button', 'aipb-tidy-btn', __( 'Cancel' ) );
		var apply = el( 'button', 'aipb-tidy-btn primary', __( 'Apply' ) );

		var close = function () { back.remove(); document.removeEventListener( 'keydown', onKey ); };
		function onKey( e ) { if ( e.key === 'Escape' ) { close(); } }
		document.addEventListener( 'keydown', onKey );
		cancel.addEventListener( 'click', close );
		closeX.addEventListener( 'click', close );
		back.addEventListener( 'click', function ( e ) { if ( e.target === back ) { close(); } } );

		apply.addEventListener( 'click', function () {
			sec.template = res.code;
			state.dirty = true;
			renderBlocks(); renderFields(); refreshCodeIfOpen(); schedulePreview( 0 ); refreshSaveState(); commitHistory();
			close();
			toast( sprintf( __( 'Code tidied up: %d rules fewer. Undo with ⌘Z.' ), Math.max( 0, before - after ) ) );
		} );

		foot.appendChild( seeBtn );
		foot.appendChild( cancel );
		foot.appendChild( apply );
		box.appendChild( foot );

		back.appendChild( box );
		// Inside the Studio root: --bg/--text/--border live there. On document.body they are
		// undefined, var() invalidates the declaration, and the card is a transparent sheet.
		( root || document.body ).appendChild( back );
		apply.focus();
	}

	/* --- The expanded mode: the editor at full width over the preview --- */
	function toggleCodeExpand() {
		codeExpanded = ! codeExpanded;
		applyCodeExpand();
		updateExpandBtn();
		if ( dom.code && dom.code.useCM ) { setTimeout( function () { dom.code.cm.refresh(); }, 0 ); }
	}
	function applyCodeExpand() {
		if ( ! dom.codeWrap || ! dom.studio || ! dom.panels ) { return; }
		if ( codeExpanded ) {
			if ( dom.codeWrap.parentNode !== dom.studio ) { dom.studio.appendChild( dom.codeWrap ); }
			dom.codeWrap.classList.add( 'aipb-code-expanded' );
		} else {
			if ( dom.codeWrap.parentNode !== dom.panels ) { dom.panels.appendChild( dom.codeWrap ); }
			dom.codeWrap.classList.remove( 'aipb-code-expanded' );
		}
	}
	function updateExpandBtn() {
		if ( ! dom.codeExpandBtn ) { return; }
		// The icon: arrows pointing out (expand) / in (collapse).
		dom.codeExpandBtn.innerHTML = codeExpanded
			? '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg>'
			: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>';
		dom.codeExpandBtn.title = codeExpanded ? __( 'Collapse the editor (Esc)' ) : __( 'Expand the editor to full width' );
		dom.codeExpandBtn.classList.toggle( 'active', codeExpanded );
	}

	/* ------------------------------ The preview ---------------------------- */

	function schedulePreview( delay ) {
		clearTimeout( previewTimer );
		previewTimer = setTimeout( renderPreview, delay == null ? 300 : delay );
	}

	/* The script injected into the iframe.
	 *
	 *   hover  → it notifies the parent of the id + rect (for the toolbar and the outline)
	 *   click  → it tells the parent WHICH section was clicked, and swallows any navigation
	 *   pick   → in "pick an element" mode it sends the element instead, and swallows the click
	 *
	 * NOTHING IN HERE NAVIGATES, and that is not a detail. This is a builder: the document is a
	 * mock-up of a page, not the page. A click on a link used to open the real site — in a new tab,
	 * because of the `<base target="_blank">` up in previewDoc() — which is the one thing a click on
	 * a section must never do: you go to look at a section's fields and you land on the front end.
	 * `submit` is caught for the same reason, since a form is a link that has learnt to POST.
	 *
	 * IT DOES NOT stopPropagation OUTSIDE PICK MODE, deliberately. The Style panel's own agent
	 * listens for the same click further down the document (see studio-style.js) and a section's
	 * scripts may listen on their own elements: swallowing the event here would break both. Only
	 * the DEFAULT ACTION is cancelled — the navigation — never the event.
	 */
	// hover → it notifies the parent of the id + rect (for the
	// toolbar and the section's outline); scroll → it repositions. In "element picking" mode
	// (pickmode) it highlights the element under the mouse and on click tells the parent about it
	// (for a targeted change).
	var PREVIEW_JS = '<script>(function(){var cur=null,picking=false,hi=null;function rectOf(el){var k=el.children.length?el.children:[el];var t=1/0,l=1/0,r=-1/0,b=-1/0,f=false;for(var i=0;i<k.length;i++){var c=k[i].getBoundingClientRect();if(c.width===0&&c.height===0)continue;f=true;if(c.top<t)t=c.top;if(c.left<l)l=c.left;if(c.right>r)r=c.right;if(c.bottom>b)b=c.bottom;}if(!f){var e=el.getBoundingClientRect();return{top:e.top,bottom:e.bottom,left:e.left,right:e.right};}return{top:t,bottom:b,left:l,right:r};}function send(el){if(!el){if(cur){parent.postMessage({aipb:"hoverout"},"*");}cur=null;return;}cur=el;parent.postMessage({aipb:"hover",id:el.getAttribute("data-aipb-block"),rect:rectOf(el)},"*");}function ensureHi(){if(hi)return hi;hi=document.createElement("div");hi.style.cssText="position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #6d5efc;background:rgba(109,94,252,.12);border-radius:4px;display:none";document.body.appendChild(hi);return hi;}function setPick(on){picking=on;document.body.style.cursor=on?"crosshair":"";if(hi&&!on){hi.style.display="none";}}function elInfo(el){var tag=el.tagName.toLowerCase();var cls=(el.className&&el.className.split)?el.className.split(" ").filter(Boolean)[0]:"";var txt=(el.textContent||"").replace(/\\s+/g," ").trim().slice(0,40);var oh=(el.outerHTML||"").replace(/\\s+/g," ").trim().slice(0,240);return{label:"<"+tag+(cls?" ."+cls:"")+">"+(txt?" "+txt:""),snippet:oh};}document.addEventListener("mousemove",function(e){if(picking){var el=e.target;if(el&&el!==document.body&&el!==document.documentElement){var r=el.getBoundingClientRect();var h=ensureHi();h.style.display="block";h.style.left=r.left+"px";h.style.top=r.top+"px";h.style.width=r.width+"px";h.style.height=r.height+"px";}return;}var s=e.target.closest&&e.target.closest("[data-aipb-block]");if(s!==cur){send(s);}},true);document.addEventListener("click",function(e){if(picking){e.preventDefault();e.stopPropagation();var info=elInfo(e.target);var blk=e.target.closest?e.target.closest("[data-aipb-block]"):null;parent.postMessage({aipb:"picked",label:info.label,snippet:info.snippet,id:blk?blk.getAttribute("data-aipb-block"):""},"*");setPick(false);return;}var t=e.target;var a=t&&t.closest?t.closest("a[href]"):null;if(a){e.preventDefault();}var s=t&&t.closest?t.closest("[data-aipb-block]"):null;if(s){parent.postMessage({aipb:"select",id:s.getAttribute("data-aipb-block")},"*");}},true);document.addEventListener("submit",function(e){e.preventDefault();},true);document.addEventListener("mouseleave",function(){if(!picking){send(null);}},true);window.addEventListener("scroll",function(){if(cur&&!picking){send(cur);}},true);window.addEventListener("message",function(e){if(e.data&&e.data.aipb==="pickmode"){setPick(!!e.data.on);}});})();<\/script>';

	// The current design system. AIPB.* is the snapshot taken when the page loaded; the server sends
	// a FRESH copy with every preview, because the tokens are a global option that can change (e.g.
	// by importing them from a mockup in another tab) while the Studio is open. Without this, new
	// sections would use variables that do not exist here yet and would look unstyled despite being
	// correct.
	function rememberDesign( res ) {
		if ( res && res.tokensCss ) {
			liveDesign = { tokensCss: res.tokensCss, fontsUrl: res.fontsUrl || '' };
			// The Style panel shows the tokens as a palette: let it re-read the new values.
			if ( window.AIPBStyle ) { window.AIPBStyle.forgetTokens(); }
		}
	}
	function designTokensCss() { return ( liveDesign && liveDesign.tokensCss ) || AIPB.tokensCss || ''; }
	function designFontsUrl() { return ( liveDesign && liveDesign.fontsUrl ) || AIPB.fontsUrl || ''; }
	var liveDesign = null;

	function previewDoc( body ) {
		// The base + the design system's tokens, so the preview is faithful to the frontend.
		var base = '<style>*,*::before,*::after{box-sizing:border-box}.aipb-s{display:block}.aipb-page-layer{position:fixed;inset:0;pointer-events:none;z-index:40}.aipb-page-layer [data-aipb-hit]{pointer-events:auto}</style>';
		var fontsUrl = designFontsUrl();
		var fonts = fontsUrl ? '<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="' + fontsUrl + '">' : '';
		var tokensCss = designTokensCss();
		var tokens = tokensCss ? '<style id="aipb-tokens">' + tokensCss + '</style>' : '';
		var motion = AIPB.motionCss ? '<style id="aipb-motion">' + AIPB.motionCss + '</style>' : '';
		// The sections runtime (it runs the scripts confined to their own section).
		var runtime = AIPB.runtimeJs ? '<script>' + AIPB.runtimeJs + '<\/script>' : '';
		// The Style panel's agent: it reads the computed style and receives the live preview of the
		// rules being edited. Absent if the panel has not mounted.
		var styler = ( window.AIPBStyle && window.AIPBStyle.previewJs() ) || '';
		return '<!doctype html><html><head><meta charset="utf-8"><base target="_blank">' + base + fonts + tokens + motion + '</head><body style="margin:0">' + body + runtime + styler + '</body></html>';
	}

	function renderPreview() {
		var hasContent = pageMode() ? state.blocks.length : !! state.template;
		if ( ! hasContent ) {
			dom.frame.style.display = 'none';
			dom.previewEmpty.style.display = 'flex';
			lastPreviewKey = '';
			return;
		}
		dom.previewEmpty.style.display = 'none';
		dom.frame.style.display = 'block';

		var body = pageMode() ? { blocks: state.blocks } : { template: state.template, values: state.values };
		// Which content is being built: the server needs it to choose the sample to show where the
		// template says «the current content» (post.*), which does not exist here.
		body.contentId = state.sectionId || 0;
		// Nothing has changed since the last preview: it avoids a pointless render + queries.
		var key = JSON.stringify( body );
		if ( key === lastPreviewKey ) { return; }
		lastPreviewKey = key;
		api( '/preview', body ).then( function ( res ) {
			if ( ! res ) { return; }
			rememberDesign( res );

			// A single section travels as one block: from here down there is only one code path.
			var blocks = ( pageMode() && res.blocks ) ? res.blocks
				: ( typeof res.html === 'string' ? [ { id: SOLO_ID, html: res.html } ] : null );
			if ( ! blocks ) { return; }

			// An in-place update where possible: no new document, so no flash.
			if ( patchPreview( blocks, res ) ) { return; }

			// Reloading srcdoc resets the iframe's scroll: we save it and restore it, so after a
			// change you stay exactly where you were looking.
			var scroll = captureFrameScroll();
			// Every block is wrapped in a "transparent" wrapper (display:contents) with its id, so a
			// click bubbles up to the block without altering the layout.
			var inner = blocks.map( blockWrap ).join( '' );
			setFrameSrcdoc( previewDoc( inner + PREVIEW_JS ), scroll );
			lastBlockHtml = {};
			blocks.forEach( function ( b ) { lastBlockHtml[ b.id ] = b.html; } );
		} ).catch( function () {} );
	}

	var SOLO_ID = '__solo';

	function blockWrap( b ) {
		// `data-aipb-holder` as well as `data-aipb-block`: the block's attribute is ALSO on the
		// section in here (light_wrap puts it there), so looking for that one would find twice as
		// many containers and it would no longer be clear which is the shell and which the content.
		var id = String( b.id ).replace( /"/g, '' );
		return '<div data-aipb-holder="' + id + '" data-aipb-block="' + id + '" style="display:contents">' + b.html + '</div>';
	}

	/* ------------------- A preview with no flash (in-place update) -------------------

	   Reassigning `srcdoc` builds a NEW document: a white screen for an instant, fonts and images
	   requested again from scratch, the scroll zeroed and put back afterwards — the "flash". And
	   since the document restarts, so does everything living inside it: the sections' scripts, the
	   Style panel's agent, the selection in progress.

	   Almost always, though, a new document is not needed. Changing a field changes ONE section, and
	   the others have no reason to be touched; changing a style does not even change the markup, only
	   the text inside a <style>. So:

	     nothing has changed → nothing is touched
	     only the CSS        → the <style> contents are rewritten, the DOM stays identical
	     the markup          → THAT section is replaced, the others stay where they are

	   It falls back on a new document only when the structure changes — sections added, removed or
	   reordered: there the flash is there, but that is the moment everything is expected to change. */

	var lastBlockHtml = {}; // section id → the HTML of the last render applied to the live document

	function patchPreview( blocks, res ) {
		var doc;
		try { doc = dom.frame.contentDocument; } catch ( e ) { return false; }
		if ( ! doc || ! doc.body || ! doc.body.firstChild ) { return false; }

		// The same blocks, in the same order, and already seen by us: otherwise it is structural.
		var holders = doc.querySelectorAll( '[data-aipb-holder]' ), i;
		if ( holders.length !== blocks.length ) { return false; }
		for ( i = 0; i < blocks.length; i++ ) {
			if ( holders[ i ].getAttribute( 'data-aipb-holder' ) !== String( blocks[ i ].id ) ) { return false; }
			if ( ! lastBlockHtml.hasOwnProperty( blocks[ i ].id ) ) { return false; }
		}

		// The design system's tokens may have changed underneath (an import from a mockup).
		var tk = doc.getElementById( 'aipb-tokens' );
		if ( tk && res && res.tokensCss && tk.textContent !== res.tokensCss ) { tk.textContent = res.tokensCss; }

		var replaced = false;
		for ( i = 0; i < blocks.length; i++ ) {
			var b = blocks[ i ], holder = holders[ i ];
			if ( lastBlockHtml[ b.id ] === b.html ) { continue; }
			if ( ! patchInPlace( doc, holder, lastBlockHtml[ b.id ], b.html ) ) {
				holder.innerHTML = b.html;
				replaced = true;
			}
			lastBlockHtml[ b.id ] = b.html;
		}

		// The scripts of the replaced section never started: the runtime skips the ones that already
		// carry their mark and starts only the new ones.
		if ( replaced ) {
			try { dom.frame.contentWindow.__aipbRun(); } catch ( e ) {}
		}
		if ( window.AIPBStyle && window.AIPBStyle.previewPatched ) { window.AIPBStyle.previewPatched( replaced ); }
		return true;
	}

	/* The minimal update of a section: the OLD HTML is compared with the NEW one and only the
	   difference is applied to the live document — a text, an attribute, the CSS inside a <style>. If
	   the structure matches, the DOM is not rebuilt: the scripts do not restart, the Style panel's
	   selection is not lost, images and animations do not bounce.

	   The comparison is OLD against NEW, not live-document against new: the live document may have
	   been changed by the section's own scripts (a counter, a slider that has moved), and taking it
	   back to how the server left it would mean resetting its state on every key pressed in a field.

	   At the slightest structural difference it says no and the caller redoes the section: better one
	   extra rebuild than a DOM that silently diverges from what the code says. */
	function patchInPlace( doc, holder, before, after ) {
		if ( typeof before !== 'string' ) { return false; }
		var a = doc.createElement( 'div' ), b = doc.createElement( 'div' );
		a.innerHTML = before;
		b.innerHTML = after;
		return patchChildren( holder, a, b );
	}

	function patchChildren( live, oldN, newN ) {
		var oc = oldN.childNodes, nc = newN.childNodes, lc = live.childNodes;
		if ( oc.length !== nc.length || lc.length !== nc.length ) { return false; }
		for ( var i = 0; i < nc.length; i++ ) {
			if ( ! patchNode( lc[ i ], oc[ i ], nc[ i ] ) ) { return false; }
		}
		return true;
	}

	function patchNode( live, o, n ) {
		if ( ! live || o.nodeType !== n.nodeType || live.nodeType !== n.nodeType ) { return false; }

		// Text and comments: we write only if the server really changed its mind.
		if ( 3 === n.nodeType || 8 === n.nodeType ) {
			if ( o.nodeValue !== n.nodeValue ) { live.nodeValue = n.nodeValue; }
			return true;
		}
		if ( 1 !== n.nodeType ) { return true; }
		if ( o.nodeName !== n.nodeName || live.nodeName !== n.nodeName ) { return false; }

		// A changed <script> has to be RUN AGAIN, and rewriting its text does not run it: here the
		// section must be rebuilt, so the runtime restarts it.
		if ( 'SCRIPT' === n.nodeName && o.textContent !== n.textContent ) { return false; }

		var i, at;
		for ( i = 0; i < n.attributes.length; i++ ) {
			at = n.attributes[ i ];
			if ( o.getAttribute( at.name ) !== at.value ) { live.setAttribute( at.name, at.value ); }
		}
		for ( i = 0; i < o.attributes.length; i++ ) {
			at = o.attributes[ i ];
			if ( ! n.hasAttribute( at.name ) ) { live.removeAttribute( at.name ); }
		}

		return patchChildren( live, o, n );
	}

	// Reads the preview's current scroll (best effort; the iframe is same-origin through srcdoc).
	function captureFrameScroll() {
		try {
			var w = dom.frame.contentWindow;
			return { x: w.scrollX || 0, y: w.scrollY || 0 };
		} catch ( e ) { return { x: 0, y: 0 }; }
	}

	// Replaces the preview's content, restoring the scroll after the load (and one frame later,
	// because the render and the images can shift the heights).
	function setFrameSrcdoc( html, scroll ) {
		if ( scroll && ( scroll.x || scroll.y ) ) {
			dom.frame.addEventListener( 'load', function onLoad() {
				dom.frame.removeEventListener( 'load', onLoad );
				var restore = function () { try { dom.frame.contentWindow.scrollTo( scroll.x, scroll.y ); } catch ( e ) {} };
				restore();
				try { requestAnimationFrame( restore ); } catch ( e ) {}
			} );
		}
		// Reloading the iframe resets the "element picking" state: align the flag on the parent side.
		if ( pickMode ) { pickMode = false; refreshPickBtn(); }
		// A new document: what we knew about the live content is no longer valid.
		lastBlockHtml = {};
		dom.frame.srcdoc = html;
	}

	// A full-page preview in a new window, with the changes NOT yet published.
	function showPreview() {
		var hasContent = pageMode() ? state.blocks.length : !! state.template;
		if ( ! hasContent ) { toast( __( 'Nothing to show in the preview' ), true ); return; }

		// The window has to be opened synchronously in the click handler, otherwise the browser blocks
		// the popup. We fill it with a placeholder and then write the preview into it.
		var win = window.open( '', 'aipb_preview' );
		if ( ! win ) { toast( __( 'Allow popups to see the preview' ), true ); return; }
		win.document.write( '<!doctype html><meta charset="utf-8"><title>' + __( 'Preview' ) + '</title><body style="margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;color:#6b7280">' + __( 'Generating the preview…' ) + '</body>' );
		win.document.close();

		var body = pageMode() ? { blocks: state.blocks } : { template: state.template, values: state.values };
		body.contentId = state.sectionId || 0;
		api( '/preview', body ).then( function ( res ) {
			if ( ! res ) { return; }
			rememberDesign( res );
			var html = ( pageMode() && res.blocks )
				? res.blocks.map( function ( b ) { return b.html; } ).join( '' )
				: ( typeof res.html === 'string' ? res.html : null );
			if ( null === html ) { return; }
			win.document.open();
			win.document.write( previewDoc( html ) );
			win.document.close();
		} ).catch( function ( err ) {
			try { win.document.body.innerHTML = '<div style="padding:40px;font-family:system-ui;color:#b91c1c">' + sprintf( __( 'Preview error: %s' ), err.message ) + '</div>'; } catch ( e ) {}
		} );
	}

	// The pencil on the section's toolbar: it selects the section to give the AI its context and
	// moves the caret into the chat. (A plain click on the section opens its FIELDS instead — see
	// focusBlock: the two gestures answer two different questions.)
	function editSection( index ) {
		var b = state.blocks[ index ];
		if ( ! b ) { return; }
		state.selectedBlockId = b.id;
		renderBlocks(); renderFields(); refreshCodeIfOpen(); updateComposerHint();
		setTab( 'chat' );
		if ( dom.input ) { dom.input.focus(); }
		toast( sprintf( __( 'Section «%s» — write the change in the chat' ), b.name || __( 'unnamed' ) ) );
	}

	/* ------------- The section's context toolbar in the preview ------------- */

	function svgIcon( inner ) {
		return '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
	}
	var SVG_EDIT = svgIcon( '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>' );
	var SVG_UP = svgIcon( '<polyline points="18 15 12 9 6 15"/>' );
	var SVG_DOWN = svgIcon( '<polyline points="6 9 12 15 18 9"/>' );
	var SVG_DUP = svgIcon( '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' );
	var SVG_UNLINK = svgIcon( '<path d="M18.84 12.25 20 11.11a4 4 0 0 0-5.66-5.66l-1.14 1.16"/><path d="M5.17 11.75 4 12.89a4 4 0 0 0 5.66 5.66l1.14-1.14"/><line x1="8" y1="2" x2="8" y2="5"/><line x1="2" y1="8" x2="5" y2="8"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="19" y1="16" x2="22" y2="16"/>' );
	// The marks used outside the section toolbar: attachments, the pick chip, the rich-text link.
	var SVG_DOC = svgIcon( '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><polyline points="14 2 14 8 20 8"/>' );
	var SVG_VIDEO = svgIcon( '<rect x="2" y="4" width="14" height="16" rx="2"/><path d="m22 8-6 4 6 4Z"/>' );
	var SVG_PICTURE = svgIcon( '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-4.5-4.5L5 21"/>' );
	var SVG_TARGET = svgIcon( '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><line x1="12" y1="1" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/>' );
	var SVG_LINK = svgIcon( '<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7L12 5"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7L12 19"/>' );
	var SVG_X = svgIcon( '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>' );
	// "Open in a new window" (an external link): edit the pattern in its own Studio.
	var SVG_PATTERN_EDIT = svgIcon( '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>' );
	// A broom: tidy up the section's code (rules merged, dead declarations gone).
	var SVG_TIDY = svgIcon( '<path d="M19 5 12.5 11.5"/><path d="m9 9 6 6"/><path d="M8.5 10.5 3 16l5 5 5.5-5.5"/><path d="m6 13 5 5"/>' );

	// Opens the Studio on a SPECIFIC pattern (a wp_block post) in a new tab, so it can be changed
	// everywhere it is used without leaving the current page.
	function openPatternStudio( ref ) {
		if ( ! ref || ! AIPB.studioUrl ) { toast( __( 'The pattern is not available' ), true ); return; }
		window.open( AIPB.studioUrl + '&post=' + encodeURIComponent( ref ), '_blank', 'noopener' );
	}

	var secToolbarHideTimer = null;
	var secToolbarHover = false; // true while the mouse is on the toolbar (the timer must not hide it)

	function scheduleHideSecToolbar() {
		clearTimeout( secToolbarHideTimer );
		secToolbarHideTimer = setTimeout( function () {
			// The iframe's hoverout arrives asynchronously, after the toolbar's mouseenter: if the
			// mouse is on the toolbar do NOT hide it (or it vanishes as soon as you move onto it).
			if ( secToolbarHover ) { return; }
			if ( dom.secToolbar ) { dom.secToolbar.style.display = 'none'; }
			if ( dom.secOutline ) { dom.secOutline.style.display = 'none'; }
		}, 160 );
	}

	// Hide straight away, with no wait: it is needed when the Style panel takes over the mouse.
	function hideSecToolbar() {
		clearTimeout( secToolbarHideTimer );
		if ( dom.secToolbar ) { dom.secToolbar.style.display = 'none'; }
		if ( dom.secOutline ) { dom.secOutline.style.display = 'none'; }
	}

	function secBtn( svg, title, onClick ) {
		var b = el( 'button', 'aipb-sectbtn', '' );
		b.title = title; b.innerHTML = svg;
		b.addEventListener( 'click', function ( e ) { e.stopPropagation(); onClick(); } );
		return b;
	}

	function onPreviewHover( id, rect ) {
		if ( ! pageMode() || ! dom.secToolbar || ! id ) { return; }
		var block = null, index = -1;
		for ( var i = 0; i < state.blocks.length; i++ ) {
			if ( state.blocks[ i ].id === id ) { block = state.blocks[ i ]; index = i; break; }
		}
		if ( ! block ) { scheduleHideSecToolbar(); return; }
		clearTimeout( secToolbarHideTimer );

		// Rebuild the buttons only when the section changes (scrolling sends the same id).
		if ( dom.secToolbar.dataset.blockId !== id ) {
			dom.secToolbar.dataset.blockId = id;
			dom.secToolbar.innerHTML = '';
			if ( block.type === 'inline' ) {
				dom.secToolbar.appendChild( secBtn( SVG_EDIT, __( 'Edit with the AI' ), function () { editSection( index ); } ) );
				dom.secToolbar.appendChild( secBtn( SVG_TIDY, __( 'Tidy up the code (duplicate rules, dead declarations)' ), function () { tidySection( index ); } ) );
			}
			dom.secToolbar.appendChild( secBtn( SVG_UP, __( 'Move up' ), function () { moveBlock( index, -1 ); } ) );
			dom.secToolbar.appendChild( secBtn( SVG_DOWN, __( 'Move down' ), function () { moveBlock( index, 1 ); } ) );
			dom.secToolbar.appendChild( secBtn( SVG_CLIPBOARD, __( 'Copy to the clipboard (for another page)' ), ( function ( bb ) { return function () { copyBlocks( [ bb ] ); }; } )( block ) ) );
			if ( block.type === 'inline' ) {
				dom.secToolbar.appendChild( secBtn( SVG_DUP, __( 'Duplicate' ), function () { duplicateBlock( index ); } ) );
			} else if ( block.type === 'ref' ) {
				dom.secToolbar.appendChild( secBtn( SVG_PATTERN_EDIT, __( 'Edit the pattern (a new window)' ), ( function ( bb ) { return function () { openPatternStudio( bb.ref ); }; } )( block ) ) );
				dom.secToolbar.appendChild( secBtn( SVG_UNLINK, __( 'Unlink from the pattern' ), function () { detachRef( block ); } ) );
			}
			dom.secToolbar.appendChild( secBtn( SVG_X, __( 'Delete' ), function () { removeBlock( index ); } ) );
		}

		positionSecToolbar( rect );
		positionSecOutline( rect );
		dom.secToolbar.style.display = 'flex';
		if ( dom.secOutline ) { dom.secOutline.style.display = 'block'; }
	}

	// A thin outline following the section under the mouse (an overlay in the parent, it blocks no events).
	function positionSecOutline( rect ) {
		if ( ! dom.secOutline ) { return; }
		var fr = dom.frame.getBoundingClientRect();
		var wr = dom.previewWrap.getBoundingClientRect();
		dom.secOutline.style.top = ( ( fr.top - wr.top ) + rect.top ) + 'px';
		dom.secOutline.style.left = ( ( fr.left - wr.left ) + rect.left ) + 'px';
		dom.secOutline.style.width = Math.max( 0, rect.right - rect.left ) + 'px';
		dom.secOutline.style.height = Math.max( 0, rect.bottom - rect.top ) + 'px';
	}

	function positionSecToolbar( rect ) {
		var fr = dom.frame.getBoundingClientRect();
		var wr = dom.previewWrap.getBoundingClientRect();
		var top = ( fr.top - wr.top ) + rect.top + 8;
		top = Math.max( 6, Math.min( top, wr.height - 44 ) );
		var right = wr.width - ( ( fr.left - wr.left ) + rect.right ) + 10;
		right = Math.max( 8, Math.min( right, wr.width - 44 ) );
		dom.secToolbar.style.top = top + 'px';
		dom.secToolbar.style.right = right + 'px';
		dom.secToolbar.style.left = 'auto';
	}

	function renderMeta() {
		dom.meta.innerHTML = '';
		if ( ! state.sectionId ) { dom.meta.style.display = 'none'; return; }
		dom.meta.style.display = 'flex';

		// The shortcode makes sense for reusable components, not for pages.
		if ( state.shortcode && ! pageMode() ) {
			dom.meta.appendChild( el( 'span', '', __( 'Embed:' ) ) );
			var chip = el( 'span', 'aipb-shortcode', state.shortcode );
			chip.title = __( 'Click to copy' );
			chip.addEventListener( 'click', function () {
				navigator.clipboard && navigator.clipboard.writeText( state.shortcode );
				toast( __( 'Shortcode copied' ) );
			} );
			dom.meta.appendChild( chip );
		}
		if ( state.permalink ) {
			var view = el( 'a', 'aipb-metalink', __( 'View ↗' ) );
			view.href = state.permalink; view.target = '_blank'; view.rel = 'noopener';
			dom.meta.appendChild( view );
		}
	}

	/* -------------------------------- Start -------------------------------- */

	document.addEventListener( 'click', function ( e ) {
		if ( dom.sectionsPop && ! dom.sectionsPop.contains( e.target ) && ! e.target.closest( '.aipb-head-btn' ) ) {
			closeSections();
		}
	} );

	// Events from the preview (the iframe): hover → the toolbar + the section's outline.
	window.addEventListener( 'message', function ( e ) {
		if ( ! e.data || ! e.data.aipb ) { return; }
		// With the Style panel open an ELEMENT is being pointed at: the section's toolbar would cover
		// exactly what the user wants to click.
		var styling = !! ( window.AIPBStyle && window.AIPBStyle.active() );
		if ( styling && ( e.data.aipb === 'hover' || e.data.aipb === 'hoverout' ) ) { hideSecToolbar(); return; }

		// WITH THE STYLE PANEL OPEN THE CLICK IS NOT OURS. There the pointer is choosing an
		// ELEMENT, and studio-style.js is listening for the same click inside the frame; selecting
		// the section here as well would drag the sidebar onto Fields at the exact moment somebody
		// is picking something to restyle. The frame still swallowed the navigation, which is what
		// we wanted from it either way.
		if ( styling && e.data.aipb === 'select' ) { return; }

		if ( e.data.aipb === 'hover' ) { onPreviewHover( e.data.id, e.data.rect ); }
		else if ( e.data.aipb === 'hoverout' ) { scheduleHideSecToolbar(); }
		else if ( e.data.aipb === 'select' ) { focusBlock( e.data.id ); }
		else if ( e.data.aipb === 'picked' ) { onElementPicked( e.data.label, e.data.snippet, e.data.id ); }
	} );

	// Undo/redo from the keyboard (but not while typing in a field or in the code editor).
	document.addEventListener( 'keydown', function ( e ) {
		if ( ! ( e.metaKey || e.ctrlKey ) || String( e.key ).toLowerCase() !== 'z' ) { return; }
		var t = e.target;
		if ( t && ( t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || ( t.closest && t.closest( '.CodeMirror' ) ) ) ) { return; }
		e.preventDefault();
		if ( e.shiftKey ) { redo(); } else { undo(); }
	} );

	// The clipboard from the keyboard: ⌘/Ctrl+C copies the selected section, ⌘/Ctrl+V pastes it. We
	// leave the normal case alone: if somebody is typing, or there is selected text to copy, the
	// shortcut stays the browser's.
	document.addEventListener( 'keydown', function ( e ) {
		if ( ! ( e.metaKey || e.ctrlKey ) ) { return; }
		var k = String( e.key ).toLowerCase();
		if ( 'c' !== k && 'v' !== k ) { return; }

		var t = e.target;
		if ( t && ( t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable || ( t.closest && t.closest( '.CodeMirror' ) ) ) ) { return; }
		if ( state.renamingId ) { return; }

		if ( 'c' === k ) {
			var sel = '';
			try { sel = String( window.getSelection() ); } catch ( err ) {}
			if ( sel ) { return; } // the user is copying text, not the section
			var block = activeBlock();
			if ( ! block ) { return; }
			e.preventDefault();
			copyBlocks( [ block ] );
			return;
		}

		// Paste: the local entry takes precedence (it works even where the browser denies the system
		// clipboard). If there is none, we let the 'paste' event below through, which can carry an
		// entry that arrived from another browser.
		if ( ! pageMode() ) { return; }
		var clip = readClip();
		if ( ! clip ) { return; }
		e.preventDefault();
		pasteBlocks( clip );
	} );

	// An entry arriving from outside (another browser, another machine, a message): we recognise it
	// by its tag and paste it. Any other text passes without us doing anything.
	document.addEventListener( 'paste', function ( e ) {
		if ( ! pageMode() ) { return; }
		var t = e.target;
		if ( t && ( t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable || ( t.closest && t.closest( '.CodeMirror' ) ) ) ) { return; }
		var raw = '';
		try { raw = e.clipboardData ? e.clipboardData.getData( 'text/plain' ) : ''; } catch ( err ) {}
		var clip = parseClip( raw );
		if ( ! clip ) { return; }
		e.preventDefault();
		try { localStorage.setItem( CLIP_KEY, raw ); } catch ( err ) {} // so it stays here too
		pasteBlocks( clip );
	} );

	// A copy made in another Studio tab: it makes «Paste» appear here too, straight away.
	window.addEventListener( 'storage', function ( e ) {
		if ( e && e.key === CLIP_KEY ) { renderBlocks(); }
	} );

	// Other shortcuts: ⌘/Ctrl+S publishes; Esc deselects the block.
	document.addEventListener( 'keydown', function ( e ) {
		if ( ( e.metaKey || e.ctrlKey ) && String( e.key ).toLowerCase() === 's' ) {
			e.preventDefault(); save(); return;
		}
		// F2: rename the selected block, as in a file manager.
		if ( 'F2' === e.key && ! state.renamingId ) {
			var ft = e.target;
			if ( ft && ( ft.tagName === 'INPUT' || ft.tagName === 'TEXTAREA' || ft.isContentEditable ) ) { return; }
			var sel = activeBlock();
			if ( sel ) { e.preventDefault(); startRename( sel ); }
			return;
		}
		if ( e.key === 'Escape' ) {
			var t = e.target;
			var typing = t && ( t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || ( t.closest && t.closest( '.CodeMirror' ) ) );
			if ( typing || state.renamingId ) { return; } // handled locally (the composer/the rename)
			if ( state.selectedBlockId ) {
				state.selectedBlockId = null;
				renderBlocks(); renderFields(); refreshCodeIfOpen(); updateComposerHint();
			}
		}
	} );

	// It stops an image dragged outside the chat from opening the file in the browser.
	[ 'dragover', 'drop' ].forEach( function ( ev ) {
		window.addEventListener( ev, function ( e ) {
			var dt = e.dataTransfer;
			if ( dt && dt.types && Array.prototype.indexOf.call( dt.types, 'Files' ) !== -1 ) { e.preventDefault(); }
		} );
	} );

	/* The bridge for the Style panel (assets/studio-style.js). It lives in a file of its own — it is
	   a large, independent function — but it needs the Studio's state and has to write into the
	   section's template. This is the only surface we expose to it: if something here is renamed one
	   day, the panel notices it cannot find it and simply does not mount, instead of breaking the
	   Studio. */
	window.AIPBStudio = {
		state: state,
		dom: dom,
		el: el,
		__: __,
		_n: _n,
		sprintf: sprintf,
		toast: toast,
		pageMode: pageMode,
		activeBlock: activeBlock,
		splitTemplate: splitTemplate,
		recombineTemplate: recombineTemplate,
		designTokensCss: designTokensCss,
		designFontsUrl: designFontsUrl,
		schedulePreview: schedulePreview,
		// The Style panel lives in a tab now: opening and closing it means changing tab, and that
		// decision belongs here.
		setTab: setTab,
		refreshCodeIfOpen: refreshCodeIfOpen,
		refreshSaveState: refreshSaveState,
		commitHistory: commitHistory,
	};

	mount();

	if ( AIPB.targetId ) {
		loadSection( AIPB.targetId ).catch( function () { toast( __( 'The requested content could not be opened.' ), true ); } );
	} else {
		maybeOfferDraft();
	}
} )();
