/* global AIPB */
/* Aura — the Style panel (a tab in the Studio's left column).

   What it is for: correcting a section's CSS by hand without writing it. You point at an element in
   the preview, the panel shows the rules that concern it as FIELDS (the way Gutenberg would) and the
   changes go back into the section's <style>, as real CSS.

   Three choices that explain all the rest:

   1) We write RULES, not `style=""`. The template stays readable in the Code tab, the AI goes on
      understanding it, and a change on `.card` holds for every card — which is almost always what is
      wanted. The «only this one» switch moves to a structural selector
      (`.grid > div:nth-of-type(2)`) when a single element really is the target.

   2) We EDIT the existing text, we do not rewrite it. The CSS is parsed keeping every
      declaration's offsets: changing a value replaces those few characters and nothing else.
      Reserialising the sheet would reformat hundreds of AI-written lines on every touch-up, making
      the diff in the Code tab and the history unreadable.

   3) The default selector is the one that ALREADY has a rule. If the element is
      `<div class="card card--promo">` and `.card` exists in the CSS, the panel opens on `.card`:
      that way it really shows the element's rules instead of creating new ones next to them.

   The feedback is immediate because the rule being edited is injected into a <style> inside the
   preview (the same scope, so the same cascade as the final result); the re-render from the server
   arrives later, once the hand has stopped.

   It depends on window.AIPBStudio, the bridge exposed by studio.js. */
( function () {
	'use strict';

	var S = window.AIPBStudio;
	if ( ! S || typeof AIPB === 'undefined' ) { return; }

	var el = S.el, __ = S.__, _n = S._n, sprintf = S.sprintf, toast = S.toast;
	var dom = S.dom, state = S.state;

	/* ====================================================================== */
	/* 1. The CSS engine: parsing with offsets, surgical reads and writes.    */
	/* ====================================================================== */

	/* Extracts the balanced { … } block starting at `pos`. It respects strings. */
	function cssBlock( css, pos ) {
		var depth = 0, inStr = false, q = '', i;
		for ( i = pos; i < css.length; i++ ) {
			var c = css.charAt( i );
			if ( inStr ) {
				if ( c === q && css.charAt( i - 1 ) !== '\\' ) { inStr = false; }
				continue;
			}
			if ( c === '"' || c === "'" ) { inStr = true; q = c; continue; }
			if ( c === '{' ) { depth++; } else if ( c === '}' ) {
				depth--;
				if ( ! depth ) { return { inner: css.slice( pos + 1, i ), start: pos + 1, end: i, after: i + 1 }; }
			}
		}
		return { inner: css.slice( pos + 1 ), start: pos + 1, end: css.length, after: css.length };
	}

	/* The declarations of a rule's body, with their ABSOLUTE offsets in the sheet (base = the body's
	   offset). `cutFrom`/`cutTo` delimit what has to go to delete the declaration — leading spaces
	   and the semicolon included — so a removal does not leave empty lines behind. */
	function cssDecls( body, base ) {
		var out = [], i, len = body.length, from = 0, depth = 0, inStr = false, q = '';

		function push( a, b, semi ) {
			var txt = body.slice( a, b );
			if ( ! txt.trim() ) { return; }
			var m = /^(\s*)([-a-zA-Z_][-a-zA-Z0-9_]*)\s*:\s*/.exec( txt );
			if ( ! m ) { return; }
			var vs = a + m[ 0 ].length, ve = b;
			while ( ve > vs && /\s/.test( body.charAt( ve - 1 ) ) ) { ve--; }
			out.push( {
				prop: m[ 2 ].toLowerCase(),
				value: body.slice( vs, ve ),
				valueStart: base + vs,
				valueEnd: base + ve,
				start: base + a + m[ 1 ].length,
				cutFrom: base + a,
				cutTo: base + ( semi ? semi : b ),
				semi: !! semi,
			} );
		}

		for ( i = 0; i < len; i++ ) {
			var c = body.charAt( i );
			if ( inStr ) {
				if ( c === q && body.charAt( i - 1 ) !== '\\' ) { inStr = false; }
				continue;
			}
			if ( c === '"' || c === "'" ) { inStr = true; q = c; continue; }
			if ( c === '(' ) { depth++; } else if ( c === ')' ) { depth = Math.max( 0, depth - 1 ); }
			else if ( c === ';' && ! depth ) { push( from, i, i + 1 ); from = i + 1; }
		}
		push( from, len, 0 );
		return out;
	}

	/* A light tree of the sheet. Nodes: rule / at (media, supports, container, layer) / raw
	   (keyframes, font-face, @import: never touched) / comment. The offsets are absolute. */
	function cssParse( css, base ) {
		base = base || 0;
		var nodes = [], i = 0, len = css.length;

		while ( i < len ) {
			var c = css.charAt( i );
			if ( c === '/' && css.charAt( i + 1 ) === '*' ) {
				var ce = css.indexOf( '*/', i + 2 );
				ce = ce === -1 ? len : ce + 2;
				nodes.push( { type: 'comment', start: base + i, end: base + ce } );
				i = ce;
				continue;
			}
			if ( /\s/.test( c ) ) { i++; continue; }

			if ( c === '@' ) {
				var j = i, prelude = '';
				while ( j < len && css.charAt( j ) !== '{' && css.charAt( j ) !== ';' ) { prelude += css.charAt( j ); j++; }
				if ( j >= len ) { break; }
				if ( css.charAt( j ) === ';' ) {
					nodes.push( { type: 'raw', start: base + i, end: base + j + 1 } );
					i = j + 1;
					continue;
				}
				var ablk = cssBlock( css, j );
				var nm = ( /^@([a-zA-Z-]+)/.exec( prelude.trim() ) || [ '', '' ] )[ 1 ].toLowerCase();
				if ( nm === 'media' || nm === 'supports' || nm === 'container' || nm === 'layer' ) {
					nodes.push( {
						type: 'at', name: nm, prelude: prelude.trim(),
						start: base + i, bodyStart: base + ablk.start, bodyEnd: base + ablk.end, end: base + ablk.after,
						nodes: cssParse( ablk.inner, base + ablk.start ),
					} );
				} else {
					nodes.push( { type: 'raw', start: base + i, end: base + ablk.after } );
				}
				i = ablk.after;
				continue;
			}

			// `at` is where the rule REALLY starts: do not derive it from sel.length, which is
			// shorter after the trim — the start would slide forward and deleting the rule would
			// leave the selector's first character behind.
			var at = i, k = i, sel = '';
			while ( k < len && css.charAt( k ) !== '{' ) { sel += css.charAt( k ); k++; }
			if ( k >= len ) { break; }
			var blk = cssBlock( css, k );
			i = blk.after;
			sel = sel.trim();
			if ( ! sel ) { continue; }
			// A body with braces inside = nested CSS: we leave it alone, we cannot edit it.
			var nested = blk.inner.indexOf( '{' ) !== -1;
			nodes.push( {
				type: 'rule', sel: sel, nested: nested,
				start: base + at, bodyStart: base + blk.start, bodyEnd: base + blk.end, end: base + blk.after,
				decls: nested ? [] : cssDecls( blk.inner, base + blk.start ),
			} );
		}
		return nodes;
	}

	/* Comparing selectors and @media preludes up to whitespace. */
	function selKey( s ) {
		return String( s || '' ).replace( /\s+/g, ' ' ).replace( /\s*([>+~,])\s*/g, '$1' ).trim();
	}
	function mediaKey( s ) {
		return String( s || '' ).replace( /\s+/g, '' ).toLowerCase();
	}

	function findMedia( nodes, media ) {
		if ( ! media ) { return null; }
		var want = mediaKey( media );
		for ( var i = 0; i < nodes.length; i++ ) {
			if ( nodes[ i ].type === 'at' && mediaKey( nodes[ i ].prelude ) === want ) { return nodes[ i ]; }
		}
		return null;
	}

	/* The rule for that selector. Only if the selector is EXACTLY that one: editing `.a, .b`
	   because `.a` is being worked on would change `.b` too without saying so. */
	function findRule( nodes, sel ) {
		var want = selKey( sel );
		for ( var i = 0; i < nodes.length; i++ ) {
			var n = nodes[ i ];
			if ( n.type === 'rule' && ! n.nested && selKey( n.sel ) === want ) { return n; }
		}
		return null;
	}

	function indentAt( css, pos ) {
		var ls = css.lastIndexOf( '\n', pos - 1 ) + 1;
		var m = /^[ \t]*/.exec( css.slice( ls, pos ) );
		return m ? m[ 0 ] : '';
	}

	/* The declarations written for <sel> in the <media> context ('' = outside any @media). */
	function cssRead( css, sel, media ) {
		var nodes = cssParse( String( css || '' ), 0 ), scope = nodes, out = {};
		if ( media ) {
			var m = findMedia( nodes, media );
			if ( ! m ) { return out; }
			scope = m.nodes;
		}
		var rule = findRule( scope, sel );
		if ( ! rule ) { return out; }
		rule.decls.forEach( function ( d ) { out[ d.prop ] = d.value; } );
		return out;
	}

	/* Is there a rule for this selector (in any context)? It is what picks the default selector and
	   marks the candidates in the panel. */
	function cssHasRule( css, sel ) {
		var nodes = cssParse( String( css || '' ), 0 );
		if ( findRule( nodes, sel ) ) { return true; }
		for ( var i = 0; i < nodes.length; i++ ) {
			if ( nodes[ i ].type === 'at' && findRule( nodes[ i ].nodes, sel ) ) { return true; }
		}
		return false;
	}

	/* Writes a declaration (value === '' removes it) touching only the text concerned. */
	function cssWrite( css, sel, media, prop, value ) {
		css = String( css || '' );
		var nodes = cssParse( css, 0 ), scope = nodes, container = null;

		if ( media ) {
			container = findMedia( nodes, media );
			if ( ! container ) {
				if ( value === '' ) { return css; }
				return css.replace( /\s*$/, '' ) + '\n\n' + media + ' {\n\t' + sel + ' {\n\t\t' + prop + ': ' + value + ';\n\t}\n}\n';
			}
			scope = container.nodes;
		}

		var rule = findRule( scope, sel );
		if ( ! rule ) {
			if ( value === '' ) { return css; }
			if ( container ) {
				var ind = indentAt( css, container.start ) + '\t';
				var block = '\n' + ind + sel + ' {\n' + ind + '\t' + prop + ': ' + value + ';\n' + ind + '}\n' + indentAt( css, container.start );
				return css.slice( 0, container.bodyEnd ) + block + css.slice( container.bodyEnd );
			}
			return css.replace( /\s*$/, '' ) + '\n\n' + sel + ' {\n\t' + prop + ': ' + value + ';\n}\n';
		}

		var decl = null;
		for ( var i = rule.decls.length - 1; i >= 0; i-- ) {
			if ( rule.decls[ i ].prop === prop ) { decl = rule.decls[ i ]; break; }
		}
		if ( decl ) {
			if ( value === '' ) {
				// A rule left empty: it goes too, and with it any @media left empty.
				return cssPrune( css.slice( 0, decl.cutFrom ) + css.slice( decl.cutTo ), sel, media );
			}
			return css.slice( 0, decl.valueStart ) + value + css.slice( decl.valueEnd );
		}
		if ( value === '' ) { return css; }

		var last = rule.decls[ rule.decls.length - 1 ];
		if ( last ) {
			// With no trailing semicolon the cut reaches the end of the body, spaces included: we
			// append right after the VALUE, or the ';' would end up detached ("red ;").
			var at = last.semi ? last.cutTo : last.valueEnd;
			// A one-line rule (`.z { color: red }`): the indentation to use is not the
			// declaration's — it is the rule's, plus one level.
			var oneLine = css.slice( rule.bodyStart, last.start ).indexOf( '\n' ) === -1;
			var ind2 = oneLine ? indentAt( css, rule.start ) + '\t' : indentAt( css, last.start );
			var add = ( last.semi ? '' : ';' ) + '\n' + ind2 + prop + ': ' + value + ';';
			return css.slice( 0, at ) + add + css.slice( at );
		}
		var ri = indentAt( css, rule.start );
		return css.slice( 0, rule.bodyStart ) + '\n' + ri + '\t' + prop + ': ' + value + ';\n' + ri + css.slice( rule.bodyEnd );
	}

	/* After a removal: if <sel>'s rule is left with no declarations we take it away, and the @media
	   with it if nothing else is left in there. Only that rule and that container: a sheet may
	   contain empty rules written by the AI, and it is not up to us to decide to delete them. */
	function cssPrune( css, sel, media ) {
		function cut( node ) {
			var from = node.start, ls = css.lastIndexOf( '\n', from - 1 ) + 1;
			if ( ! css.slice( ls, from ).trim() ) { from = ls; }
			var to = node.end;
			while ( to < css.length && /[ \t]/.test( css.charAt( to ) ) ) { to++; }
			if ( css.charAt( to ) === '\n' ) { to++; }
			var head = css.slice( 0, from ), tail = css.slice( to );
			// The blank line that separated this rule from the previous one now separates
			// nothing: ahead of it there is another blank line, or the closing brace itself.
			if ( /\n[ \t]*\n$/.test( head ) && /^[ \t]*(\n|\})/.test( tail ) ) {
				head = head.replace( /\n[ \t]*\n$/, '\n' );
			}
			css = head + tail;
		}

		var nodes = cssParse( css, 0 );
		var container = media ? findMedia( nodes, media ) : null;
		if ( media && ! container ) { return css; }
		var rule = findRule( container ? container.nodes : nodes, sel );
		if ( ! rule || rule.decls.length ) { return css; }
		cut( rule );

		if ( ! container ) { return css; }
		container = findMedia( cssParse( css, 0 ), media );
		if ( container && ! container.nodes.length ) { cut( container ); }
		return css;
	}

	/* The list of selectors with a rule in the sheet: it feeds the «other rules» menu. */
	function cssSelectors( css ) {
		var nodes = cssParse( String( css || '' ), 0 ), out = [];
		( function walk( list, media ) {
			list.forEach( function ( n ) {
				if ( n.type === 'rule' && ! n.nested ) { out.push( { sel: n.sel, media: media } ); }
				else if ( n.type === 'at' && n.name === 'media' ) { walk( n.nodes, n.prelude ); }
			} );
		} )( nodes, '' );
		return out;
	}

	/* ====================================================================== */
	/* 2. The design system's tokens: the fields' palette.                    */
	/* ====================================================================== */

	var tokenCache = null;

	function tokens() {
		if ( tokenCache ) { return tokenCache; }
		var css = ( S.designTokensCss && S.designTokensCss() ) || AIPB.tokensCss || '';
		var out = { color: [], font: [], text: [], space: [], radius: [], shadow: [], width: [] };
		var re = /--aipb-([a-z0-9-]+)\s*:\s*([^;}]+)/gi, m;
		while ( ( m = re.exec( css ) ) ) {
			var name = m[ 1 ], value = m[ 2 ].trim(), bucket = '', key = name;
			if ( name.indexOf( 'color-' ) === 0 ) { bucket = 'color'; key = name.slice( 6 ); }
			else if ( name.indexOf( 'font-' ) === 0 ) { bucket = 'font'; key = name.slice( 5 ); }
			else if ( name.indexOf( 'text-' ) === 0 ) { bucket = 'text'; key = name.slice( 5 ); }
			else if ( name.indexOf( 'space-' ) === 0 ) { bucket = 'space'; key = name.slice( 6 ); }
			else if ( name.indexOf( 'radius-' ) === 0 ) { bucket = 'radius'; key = name.slice( 7 ); }
			else if ( name.indexOf( 'shadow-' ) === 0 ) { bucket = 'shadow'; key = name.slice( 7 ); }
			else if ( name.indexOf( 'container' ) === 0 ) { bucket = 'width'; }
			// The bucket travels with the token: it is what decides how to show it (a bar for a
			// spacing, a corner for a radius, «Aa» for a text size).
			if ( bucket ) { out[ bucket ].push( { bucket: bucket, key: key, css: 'var(--aipb-' + name + ')', value: value } ); }
		}
		tokenCache = out;
		return out;
	}
	// The tokens can change while the Studio is open (an import from a mockup): the preview receives
	// a fresh copy on every render, so we invalidate along with it.
	function forgetTokens() { tokenCache = null; }

	/* ====================================================================== */
	/* 3. The agent inside the preview.                                       */
	/* ====================================================================== */

	/* It will run inside the iframe: none of the variables from here are visible in there, it
	   receives everything as an argument. We write it as a function (and not as a string) so it can
	   be read. */
	function previewAgent( PROPS, TXT ) {
		var active = false, hoverBox = null, selBox = null, selected = null, live = null;

		function tag( e ) { return e.tagName.toLowerCase(); }
		function classList( e ) {
			var c = e.getAttribute( 'class' ) || '';
			return c.split( /\s+/ ).filter( function ( x ) { return !! x; } );
		}
		function scopeOf( e ) { return ( e.closest && e.closest( '.aipb-s' ) ) || document.body; }

		function box( color ) {
			var d = document.createElement( 'div' );
			d.style.cssText = 'position:fixed;z-index:2147483646;pointer-events:none;border:2px solid ' + color +
				';border-radius:3px;display:none;transition:all .06s linear';
			document.body.appendChild( d );
			return d;
		}
		function place( b, e ) {
			if ( ! e || ! e.getBoundingClientRect ) { b.style.display = 'none'; return; }
			var r = e.getBoundingClientRect();
			b.style.display = 'block';
			b.style.left = r.left + 'px'; b.style.top = r.top + 'px';
			b.style.width = r.width + 'px'; b.style.height = r.height + 'px';
		}
		function redraw() {
			if ( selBox && selected && selected.isConnected ) { place( selBox, selected ); }
		}

		/* The path for finding the element again after the preview is re-rendered. */
		function pathOf( e ) {
			var p = [], cur = e;
			while ( cur && cur.parentNode && cur !== document.body ) {
				p.unshift( Array.prototype.indexOf.call( cur.parentNode.children, cur ) );
				cur = cur.parentNode;
			}
			return p;
		}
		function elAt( path ) {
			var cur = document.body, i;
			for ( i = 0; i < path.length; i++ ) {
				cur = cur && cur.children[ path[ i ] ];
				if ( ! cur ) { return null; }
			}
			return cur;
		}

		/* A structural selector: it anchors to the first ancestor with a class that is unique within
		   the section, then descends with tag + :nth-of-type. It is the «only this element». */
		function structural( e, root ) {
			if ( e === root ) { return ':root'; }
			// If the element already has a class that within the section is its and its alone, that
			// IS the precise selector: a structural path would only be more fragile.
			var own = uniqueClassSel( e, root );
			if ( own ) { return own; }
			var parts = [], cur = e;
			while ( cur && cur !== root ) {
				var uniq = uniqueClassSel( cur, root );
				if ( uniq && cur !== e ) { parts.unshift( uniq ); return parts.join( ' > ' ); }
				var t = tag( cur ), idx = 1, sib = cur, same = 0, ch = cur.parentNode.children, i;
				while ( ( sib = sib.previousElementSibling ) ) { if ( tag( sib ) === t ) { idx++; } }
				for ( i = 0; i < ch.length; i++ ) { if ( tag( ch[ i ] ) === t ) { same++; } }
				parts.unshift( t + ( same > 1 ? ':nth-of-type(' + idx + ')' : '' ) );
				cur = cur.parentNode;
			}
			return parts.join( ' > ' );
		}
		function uniqueClassSel( e, root ) {
			var cs = classList( e ), i, sel;
			for ( i = 0; i < cs.length; i++ ) {
				sel = '.' + cssEscape( cs[ i ] );
				try { if ( root.querySelectorAll( sel ).length === 1 ) { return sel; } } catch ( err ) {}
			}
			return '';
		}
		function cssEscape( s ) { return String( s ).replace( /([^\w-])/g, '\\$1' ); }

		function labelOf( e ) {
			var cls = classList( e );
			return cls.length ? '.' + cls[ 0 ] : tag( e );
		}

		/* What goes off the screen, and by how much.

		   The check only starts if the page really scrolls horizontally: an element wider than its
		   container, but inside an `overflow:hidden`, is not a defect — it is a decoration
		   overflowing on purpose, and reporting it would only teach people to ignore the warnings.
		   We report the outermost culprits: if a grid overflows, saying it about its six cells too
		   adds nothing. */
		function overflowers() {
			var doc = document.documentElement;
			var view = doc.clientWidth;
			if ( doc.scrollWidth <= view + 1 ) { return []; }

			var all = document.querySelectorAll( 'body *' ), out = [], i;
			for ( i = 0; i < all.length; i++ ) {
				var e = all[ i ];
				if ( e.id === 'aipb-live' || e.tagName === 'STYLE' || e.tagName === 'SCRIPT' ) { continue; }
				var r = e.getBoundingClientRect();
				if ( ! r.width || ! r.height ) { continue; }
				var over = Math.round( r.right - view );
				if ( over < 2 ) { continue; }

				// What overflows is the third card, but what has to be fixed is the grid holding
				// them: pointing at the symptom sends people looking in the wrong place. If the
				// parent cannot contain its own content, the parent is the culprit.
				var e2 = e, over2 = over, p = e.parentNode;
				if ( p && p.nodeType === 1 && ! p.classList.contains( 'aipb-s' ) && p.tagName !== 'BODY'
					&& p.scrollWidth > p.clientWidth + 1 ) {
					e2 = p;
					over2 = Math.round( p.scrollWidth - p.clientWidth );
				}

				// Already covered by a reported ancestor: it is the same news, told twice.
				var covered = false;
				for ( var j = 0; j < out.length; j++ ) {
					if ( out[ j ].el === e2 || out[ j ].el.contains( e2 ) ) { covered = true; break; }
				}
				if ( covered ) { continue; }
				out.push( { el: e2, label: labelOf( e2 ), over: over2, path: pathOf( e2 ) } );
				if ( out.length >= 4 ) { break; }
			}
			return out.map( function ( o ) {
				return { label: o.label, over: o.over, path: o.path };
			} );
		}

		/* Highlighting on demand (from the breadcrumbs or from the overflow warning). */
		function flash( path, keep ) {
			var t = path && elAt( path );
			if ( ! hoverBox ) { hoverBox = box( 'rgba(109,94,252,.55)' ); }
			if ( ! t ) { hoverBox.style.display = 'none'; return; }
			place( hoverBox, t );
			try { t.scrollIntoView( { block: 'nearest', inline: 'nearest' } ); } catch ( err ) {}
			if ( ! keep ) { return; }
			clearTimeout( flash.t );
			flash.t = setTimeout( function () { hoverBox.style.display = 'none'; }, 1600 );
		}

		function countFor( sel, root ) {
			try { return root.querySelectorAll( sel ).length; } catch ( err ) { return 0; }
		}

		function computedOf( e ) {
			var cs = getComputedStyle( e ), out = {}, i;
			for ( i = 0; i < PROPS.length; i++ ) { out[ PROPS[ i ] ] = cs.getPropertyValue( PROPS[ i ] ); }
			return out;
		}

		/* A written declaration can fail to reach the screen for two different reasons: the value is
		   not valid, or another stronger rule wins. We tell them apart by putting the same
		   declaration INLINE on the element — maximum specificity, no cascade to beat — and looking
		   at whether the computed value changes. If it changes, our rule was losing; if the browser
		   rejects the value, it was written badly. It is the only honest way: comparing strings does
		   not work, because `var(--x)`, `1.4` and `2rem` become something else entirely once
		   computed. */
		function weakProps( e, list ) {
			var out = { losing: [], invalid: [] }, i, cs = getComputedStyle( e );
			for ( i = 0; i < list.length; i++ ) {
				var p = list[ i ].prop, v = list[ i ].value;
				var got = cs.getPropertyValue( p );
				var prev = e.style.getPropertyValue( p ), pri = e.style.getPropertyPriority( p );
				e.style.setProperty( p, v );
				if ( e.style.getPropertyValue( p ) === '' ) { out.invalid.push( p ); }
				else if ( getComputedStyle( e ).getPropertyValue( p ) !== got ) { out.losing.push( p ); }
				if ( prev ) { e.style.setProperty( p, prev, pri ); } else { e.style.removeProperty( p ); }
			}
			return out;
		}

		function info( e ) {
			var root = scopeOf( e ), cls = classList( e ), isRoot = e === root;
			var cands = cls.map( function ( c ) {
				return { sel: '.' + cssEscape( c ), label: '.' + c, count: countFor( '.' + cssEscape( c ), root ) };
			} );
			if ( cls.length > 1 ) {
				var all = '.' + cls.map( cssEscape ).join( '.' );
				cands.push( { sel: all, label: '.' + cls.join( '.' ), count: countFor( all, root ) } );
			}
			if ( ! cls.length && ! isRoot ) {
				// With no classes of its own, the tag alone (`span`) loses against any more specific
				// rule in the sheet — and the change would never reach the screen. The good
				// candidate is anchored to the nearest ancestor that does have a class:
				// `.stat > span` says «those spans in there», which is almost always what is meant.
				var anc = e.parentNode, hop = 0;
				while ( anc && anc !== root && hop < 4 ) {
					var acls = classList( anc );
					if ( acls.length ) {
						var ctx = '.' + cssEscape( acls[ 0 ] ) + ( anc === e.parentNode ? ' > ' : ' ' ) + tag( e );
						cands.push( { sel: ctx, label: ctx, count: countFor( ctx, root ) } );
						break;
					}
					anc = anc.parentNode;
					hop++;
				}
				cands.push( { sel: tag( e ), label: tag( e ), count: countFor( tag( e ), root ) } );
			}
			// The chain from the section down to here: the breadcrumbs for climbing back up without
			// having to aim the mouse at an element nested inside four others.
			var chain = [], up2 = e, guard = 0;
			while ( up2 && guard++ < 12 ) {
				// The `.aipb-s` container is added by us at render time: calling it by its technical
				// name would send people looking in the template for a class that is not in there.
				chain.unshift( { label: up2 === root ? TXT.section : labelOf( up2 ), path: pathOf( up2 ) } );
				if ( up2 === root ) { break; }
				up2 = up2.parentNode;
			}

			// The ancestors' classes, nearest first: they anchor the selector when the chosen one
			// loses against a more specific rule.
			var ancestors = [], up = e.parentNode;
			while ( up && up !== root && ancestors.length < 5 ) {
				var ac = classList( up );
				if ( ac.length ) { ancestors.push( '.' + cssEscape( ac[ 0 ] ) ); }
				up = up.parentNode;
			}

			var holder = ( e.closest && e.closest( '[data-aipb-block]' ) ) || root;
			return {
				chain: chain,
				ancestors: ancestors,
				aipb: 'stylepick',
				blockId: ( holder.getAttribute && holder.getAttribute( 'data-aipb-block' ) ) || '',
				scopeId: root.id || '',
				isRoot: isRoot,
				tag: tag( e ),
				classes: cls,
				text: ( e.textContent || '' ).replace( /\s+/g, ' ' ).trim().slice( 0, 60 ),
				candidates: cands,
				elemSel: structural( e, root ),
				path: pathOf( e ),
				computed: computedOf( e ),
			};
		}

		function select( e ) {
			selected = e;
			if ( ! selBox ) { selBox = box( '#6d5efc' ); }
			place( selBox, e );
			if ( hoverBox ) { hoverBox.style.display = 'none'; }
			parent.postMessage( info( e ), '*' );
		}

		function setActive( on ) {
			active = !! on;
			document.body.style.cursor = active ? 'crosshair' : '';
			if ( ! active ) {
				if ( hoverBox ) { hoverBox.style.display = 'none'; }
				if ( selBox ) { selBox.style.display = 'none'; }
				selected = null;
			}
		}

		document.addEventListener( 'mousemove', function ( e ) {
			if ( ! active ) { return; }
			var t = e.target;
			if ( ! t || t === document.documentElement ) { return; }
			if ( ! hoverBox ) { hoverBox = box( 'rgba(109,94,252,.55)' ); }
			if ( t === selected ) { hoverBox.style.display = 'none'; return; }
			place( hoverBox, t );
		}, true );

		document.addEventListener( 'click', function ( e ) {
			if ( ! active ) { return; }
			e.preventDefault(); e.stopPropagation();
			var t = e.target;
			if ( t && t !== document.documentElement ) { select( t ); }
		}, true );

		window.addEventListener( 'scroll', redraw, true );
		window.addEventListener( 'resize', redraw );

		window.addEventListener( 'message', function ( e ) {
			var d = e.data;
			if ( ! d || ! d.aipb ) { return; }

			if ( d.aipb === 'stylemode' ) { setActive( d.on ); return; }

			/* An immediate preview of the rule being edited: a <style> at the end of the document,
			   with the same scope as the section's CSS — so the same cascade as the final result,
			   apart from the order within the sheet. */
			if ( d.aipb === 'stylelive' ) {
				if ( ! live ) {
					live = document.createElement( 'style' );
					live.id = 'aipb-live';
					document.body.appendChild( live );
				}
				live.textContent = d.css || '';
				if ( selected && selected.isConnected ) {
					place( selBox, selected );
					parent.postMessage( {
						aipb: 'stylecomputed',
						computed: computedOf( selected ),
						weak: weakProps( selected, d.check || [] ),
					}, '*' );
				}
				return;
			}

			// Highlights (or stops highlighting) an element pointed at by the panel.
			if ( d.aipb === 'stylehover' ) { flash( d.path, d.keep ); return; }

			// What goes off the screen: the question only makes sense where the screen is narrow.
			if ( d.aipb === 'styleoverflow' ) {
				parent.postMessage( { aipb: 'styleoverflowed', items: overflowers() }, '*' );
				return;
			}

			/* Which of the sheet's selectors REALLY hit this element: by asking the browser
			   (`matches`) instead of guessing from the selector's text. */
			if ( d.aipb === 'stylematch' ) {
				var hits = [];
				if ( selected ) {
					( d.sels || [] ).forEach( function ( s ) {
						try { if ( selected.matches( s ) ) { hits.push( s ); } } catch ( err ) {}
					} );
				}
				parent.postMessage( { aipb: 'stylematched', hits: hits }, '*' );
				return;
			}

			/* Re-selecting after a re-render: the path first, then the selector. */
			if ( d.aipb === 'stylesel' ) {
				var t = ( d.path && elAt( d.path ) ) || null;
				if ( ! t && d.sel ) {
					try { t = document.querySelector( ( d.scopeId ? '#' + d.scopeId + ' ' : '' ) + d.sel ); } catch ( err ) {}
				}
				if ( t ) { select( t ); } else { parent.postMessage( { aipb: 'stylelost' }, '*' ); }
			}
		} );
	}

	/* Injected at the end of the preview document (see previewDoc in studio.js). */
	function previewJs() {
		// The few words the agent shows come from here: inside the stringified function there is no
		// closure, so there are no translations either.
		var txt = { section: __( 'section' ) };
		return '<scr' + 'ipt>(' + previewAgent.toString() + ')(' +
			JSON.stringify( READ_PROPS ) + ',' + JSON.stringify( txt ) + ');</scr' + 'ipt>';
	}

	/* The properties read from the preview: the fields' «current value». */
	var READ_PROPS = [
		'display', 'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap',
		'grid-template-columns',
		'width', 'max-width', 'height', 'min-height',
		'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
		'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
		'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing',
		'text-align', 'text-transform', 'text-decoration-line', 'color', 'background-color',
		'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
		'border-top-style', 'border-top-color',
		'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius',
		'box-shadow', 'opacity', 'overflow', 'position', 'top', 'right', 'bottom', 'left', 'z-index',
	];

	/* ====================================================================== */
	/* 4. The panel's state.                                                  */
	/* ====================================================================== */

	var open = false;          // the sidebar is open (and selection mode is active)
	var sel = null;            // the last `info` received from the preview
	var scopeMode = 'class';   // 'class' (the rule) | 'element' (only this one)
	var selIndex = 0;          // which of sel.candidates
	var pseudo = '';           // '' | ':hover' | ':focus'
	var scopeTouched = false;  // the user chose the scope: we no longer choose it for them
	var selPrefix = '';        // ancestors prefixed to the selector to beat a stronger rule
	var pending = null;        // the deferred commit's timer
	/**
	 * Which groups are open. ALL CLOSED on arrival, deliberately.
	 *
	 * Eight groups half-open meant the panel opened on two screens of fields, most of them empty,
	 * and finding the one you wanted was scrolling past the ones you did not. Closed, the whole
	 * thing fits on one screen and the badge on each header says where the values already are —
	 * which is the question somebody arriving actually has.
	 *
	 * What you open STAYS open while the Studio is up, selection after selection: closing them again
	 * behind somebody who is going through four cards in a row would be tidiness at their expense.
	 */
	var groupsOpen = { layout: false, space: false, size: false, type: false, color: false, border: false, fx: false, pos: false };

	/* The section whose CSS we are editing. */
	function target() {
		if ( ! S.pageMode() ) {
			return state.template ? { sec: state } : { err: __( 'Generate a section to change its style.' ) };
		}
		if ( ! sel ) { return { err: __( 'Pick an element in the preview.' ) }; }
		var b = state.blocks.filter( function ( x ) { return String( x.id ) === String( sel.blockId ); } )[ 0 ];
		if ( ! b ) {
			// A synced pattern is rendered from ITS OWN post: in the preview it carries the post's
			// id, not the block's. We recognise it from that, to say what is going on.
			var ref = String( sel.scopeId || '' ).replace( /^aipb-/, '' );
			var linked = state.blocks.filter( function ( x ) { return x.type === 'ref' && String( x.ref ) === ref; } )[ 0 ];
			if ( linked ) { b = linked; } else { return { err: __( 'Block not recognised: refresh the preview.' ) }; }
		}
		if ( b.type === 'ref' ) {
			return { err: sprintf( __( '«%s» is a synced pattern: its style holds everywhere it is used. To change it here only, unlink it from the «Fields» tab; to change it everywhere, open it from «Patterns» (☰).' ), b.name || __( 'This block' ) ) };
		}
		return { sec: b, name: b.name };
	}

	function sectionCss() {
		var t = target();
		if ( t.err ) { return ''; }
		return S.splitTemplate( t.sec.template ).css;
	}

	/* The breakpoint we write in: the top bar's Desktop/Tablet/Mobile switch.
	   If the section already has a @media for that device, we use ITS one — writing at 768px next to
	   an existing `@media (max-width: 900px)` would create two thresholds for the same idea of
	   «tablet», and the result would depend on the order. */
	var BREAKPOINTS = {
		tablet: { min: 700, max: 1100, fallback: 768 },
		mobile: { min: 360, max: 699, fallback: 480 },
	};
	function mediaNow() {
		var bp = BREAKPOINTS[ state.viewport ];
		if ( ! bp ) { return ''; }
		var found = '';
		cssParse( sectionCss(), 0 ).forEach( function ( n ) {
			if ( found || n.type !== 'at' || n.name !== 'media' ) { return; }
			var m = /max-width\s*:\s*(\d+(?:\.\d+)?)px/i.exec( n.prelude );
			if ( ! m ) { return; }
			var w = parseFloat( m[ 1 ] );
			if ( w >= bp.min && w <= bp.max ) { found = n.prelude; }
		} );
		return found || '@media (max-width: ' + bp.fallback + 'px)';
	}

	/* The selector we are working on. */
	function baseSel() {
		if ( ! sel ) { return ''; }
		var base = scopeMode === 'element'
			? sel.elemSel
			: ( sel.candidates[ selIndex ] ? sel.candidates[ selIndex ].sel : sel.elemSel );
		// `:root` is the section's container: prefixing an ancestor to it means nothing.
		if ( ! selPrefix || base === ':root' ) { return base; }
		return selPrefix + ' ' + base;
	}
	function fullSel() {
		var b = baseSel();
		if ( ! b ) { return ''; }
		if ( ! pseudo ) { return b; }
		// The section's container becomes the scope itself: `:root:hover` would end up scoped as
		// `#scope :hover` (the descendants!). We do not offer states there — see renderCtx.
		if ( b === ':root' ) { return b; }
		return b + pseudo;
	}

	/* The best candidate: the one that ALREADY has a rule in the sheet FOR THE CURRENT STATE.
	   On a button the normal colour often lives in `.btn` and the hover one in `.btn--primary:hover`:
	   switching to Hover you want to see that one, not an empty rule. It only holds until a scope is
	   chosen by hand (`scopeTouched`). */
	function pickDefault() {
		if ( ! sel || ! sel.candidates.length ) { scopeMode = 'element'; selIndex = 0; return; }
		var css = sectionCss(), i;
		for ( i = 0; i < sel.candidates.length; i++ ) {
			if ( cssHasRule( css, sel.candidates[ i ].sel + pseudo ) ) { scopeMode = 'class'; selIndex = i; return; }
		}
		// No rule for this state: we fall back to the candidate that has one in the normal state.
		for ( i = 0; pseudo && i < sel.candidates.length; i++ ) {
			if ( cssHasRule( css, sel.candidates[ i ].sel ) ) { scopeMode = 'class'; selIndex = i; return; }
		}
		scopeMode = 'class';
		selIndex = 0;
	}

	function overrides() {
		return cssRead( sectionCss(), fullSel(), mediaNow() );
	}

	/* ====================================================================== */
	/* 5. Writing.                                                            */
	/* ====================================================================== */

	/* The properties the panel writes as longhands but that the AI often writes as shorthands: if
	   the rule has the shorthand, we touch all four sides together, starting from the computed
	   values. That way changing one side does not silently zero the others (a `padding: 40px 20px`
	   with a lone `padding-left` on top of it is a trap). */
	var SHORTHANDS = {
		padding: [ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left' ],
		margin: [ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left' ],
		'border-radius': [ 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius' ],
		'border-width': [ 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width' ],
	};
	/* From `40px 24px` to the four sides, keeping the TEXT of every part — `var(--aipb-space-md)`
	   included. The CSS rules: 1 value = all, 2 = vertical/horizontal, 3 = top, sides, bottom,
	   4 = clockwise. It holds identically for the radii (TL TR BR BL). It returns null for what we
	   cannot split (the elliptical radii with a slash). */
	function expandShorthand( v ) {
		v = String( v || '' ).trim();
		if ( ! v || v.indexOf( '/' ) !== -1 || /!\s*important/i.test( v ) ) { return null; }
		var parts = [], buf = '', depth = 0, i;
		for ( i = 0; i < v.length; i++ ) {
			var c = v.charAt( i );
			if ( c === '(' ) { depth++; }
			else if ( c === ')' ) { depth = Math.max( 0, depth - 1 ); }
			if ( /\s/.test( c ) && ! depth ) {
				if ( buf ) { parts.push( buf ); buf = ''; }
				continue;
			}
			buf += c;
		}
		if ( buf ) { parts.push( buf ); }
		if ( ! parts.length || parts.length > 4 ) { return null; }
		if ( parts.length === 1 ) { return [ parts[ 0 ], parts[ 0 ], parts[ 0 ], parts[ 0 ] ]; }
		if ( parts.length === 2 ) { return [ parts[ 0 ], parts[ 1 ], parts[ 0 ], parts[ 1 ] ]; }
		if ( parts.length === 3 ) { return [ parts[ 0 ], parts[ 1 ], parts[ 2 ], parts[ 1 ] ]; }
		return parts;
	}

	function shorthandOf( prop ) {
		var k;
		for ( k in SHORTHANDS ) {
			if ( SHORTHANDS[ k ].indexOf( prop ) !== -1 ) { return k; }
		}
		return '';
	}

	/**
	 * The log of the style changes made BY HAND on a section.
	 *
	 * WHY IT IS NEEDED. The panel writes into the section's CSS IN PLACE, inside the right rule —
	 * and that is the right thing to do, because appending rules at the end would leave an
	 * unreadable tail of overrides. The price is that a moment later a hand-made change is
	 * indistinguishable from the generated CSS: nobody knows what they touched any more, and above
	 * all nobody warns that REGENERATING the section throws all that work away without asking.
	 *
	 * The log lives in the block, so it travels with the save. It keeps the PREVIOUS value, which is
	 * what makes it possible to undo a single change instead of redoing everything.
	 */
	function remember( block, sel, media, prop, before, after ) {
		if ( ! block ) { return; }
		block.styleEdits = block.styleEdits || [];

		var key   = ( media || '' ) + '|' + sel + '|' + prop;
		var known = null;
		block.styleEdits.forEach( function ( e ) { if ( key === e.key ) { known = e; } } );

		if ( known ) {
			// Touched twice: the FIRST value is kept as the starting point, which is the generated
			// one — otherwise undoing would take it back to the second-to-last hand edit instead of
			// to how the section was born.
			known.to = after;
			return;
		}

		block.styleEdits.push( { key: key, sel: sel, media: media || '', prop: prop, from: before, to: after } );
	}

	function setProp( prop, value ) {
		var t = target();
		if ( t.err ) { toast( t.err, true ); return; }
		var s = fullSel();
		if ( ! s ) { return; }

		var parts = S.splitTemplate( t.sec.template );
		var media = mediaNow();
		var css = parts.css;

		// Already written that way: nothing to do. Without this check the `change` of a field losing
		// focus would redo the write the timer has just made — and with it a redraw and a re-render
		// of the preview, both wasted.
		if ( ( cssRead( css, s, media )[ prop ] || '' ) === value ) { return; }

		// A shorthand present in the rule: we expand it first, so the longhand holds no surprises.
		var sh = shorthandOf( prop );
		if ( sh && value !== '' ) {
			var have = cssRead( css, s, media );
			if ( have.hasOwnProperty( sh ) ) {
				var sides = expandShorthand( have[ sh ] );
				SHORTHANDS[ sh ].forEach( function ( p, i ) {
					if ( have.hasOwnProperty( p ) ) { return; }
					// The four sides come from the shorthand's TEXT, not from the computed value:
					// `margin: 0 0 var(--aipb-space-lg)` has to stay tied to the token, not become
					// `40px` because somebody touched the side above.
					var v = sides ? sides[ i ] : ( sel.computed[ p ] || '0px' ).trim();
					css = cssWrite( css, s, media, p, v );
				} );
				css = cssWrite( css, s, media, sh, '' );
			}
		}

		// `background: #1e293b` is almost always a colour written in short form: writing a
		// `background-color` next to it would work (the last one wins) but it would leave two
		// declarations saying the same thing. If the shorthand is a colour and nothing else, it is
		// replaced.
		if ( prop === 'background-color' && value !== '' ) {
			var bg = cssRead( css, s, media ).background;
			if ( bg && ! /\s|url\(|gradient/i.test( bg.trim() ) ) { css = cssWrite( css, s, media, 'background', '' ); }
		}

		// The value as it was BEFORE this write: it is read now, because a line later it no longer
		// exists. It is the only thing that makes a change reversible.
		remember( t.sec, s, media, prop, ( cssRead( css, s, media )[ prop ] || '' ), value );

		css = cssWrite( css, s, media, prop, value );
		applyCss( t.sec, css );
		state.dirty = true;
		S.refreshSaveState();

		applyLive();
		render();
		commitSoon();
	}

	/* Puts the CSS back into the template leaving EVERYTHING else where it was.

	   The obvious route — splitTemplate + recombineTemplate — recomposes the file from scratch and
	   moves the <style> to the end: the resulting markup is equivalent but not identical, and the
	   preview can no longer get away with rewriting the text of a <style> (see patchPreview in
	   studio.js). It has to rebuild the section's DOM, and with it the scripts restart and the
	   selection is lost. By changing only what sits between the two tags, the only difference
	   between one render and the next is the CSS — which is the truth, and is also what makes the
	   update invisible.

	   With zero or more than one <style> there is no single "inside": there it recomposes as before. */
	function applyCss( sec, css ) {
		var tpl = String( sec.template || '' );
		var found = tpl.match( /<style\b[^>]*>[\s\S]*?<\/style>/gi );
		if ( found && found.length === 1 ) {
			sec.template = tpl.replace( /(<style\b[^>]*>)[\s\S]*?(<\/style>)/i, function ( all, open, close ) {
				return open + '\n' + css + '\n' + close;
			} );
			return;
		}
		var parts = S.splitTemplate( tpl );
		parts.css = css;
		sec.template = S.recombineTemplate( parts );
	}

	/* An immediate preview: we send the iframe ALL the declarations of the current selector (and of
	   its context), scoped as they really will be. */
	function applyLive() {
		if ( ! sel ) { return; }
		var s = fullSel(), scope = sel.scopeId ? '#' + sel.scopeId : '';
		if ( ! s || ! scope ) { return; }
		var media = mediaNow();
		var decls = cssRead( sectionCss(), s, media );
		var body = Object.keys( decls ).map( function ( p ) { return p + ':' + decls[ p ]; } ).join( ';' );
		var scoped = s === ':root' ? scope : scope + ' ' + s;
		var css = body ? scoped + '{' + body + '}' : '';
		if ( css && media ) { css = media + '{' + css + '}'; }
		// `check`: the same declarations, so the preview can tell us which ones reach the screen.
		// With a state active there is no point asking — the element is not hovered, so the rule
		// rightly does not apply and they would all look like losers.
		var check = pseudo ? [] : Object.keys( decls ).map( function ( p ) {
			return { prop: p, value: decls[ p ] };
		} );
		post( { aipb: 'stylelive', css: css, check: check } );
	}

	/* Asks the preview which of the sheet's selectors hit the element. It is what lists the «other
	   rules» and says which one is winning. */
	function askMatches() {
		var sels = cssSelectors( sectionCss() ).map( function ( r ) { return r.sel; } );
		post( { aipb: 'stylematch', sels: sels } );
	}

	/* Approximate specificity (ids, classes/attributes/pseudo-classes, tags): enough to say which of
	   two rules hitting the same element wins. */
	function specificity( s ) {
		s = String( s || '' ).split( ',' )[ 0 ];
		var ids = ( s.match( /#[\w-]+/g ) || [] ).length;
		var cls = ( s.match( /\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+(\([^)]*\))?/g ) || [] ).length;
		var tags = ( s.replace( /\[[^\]]*\]|::?[\w-]+(\([^)]*\))?|[#.][\w-]+/g, ' ' ).match( /[a-z][\w-]*/gi ) || [] ).length;
		return ids * 10000 + cls * 100 + tags;
	}

	/* Which rule wins on this property, among the ones that really hit the element. */
	function winnerFor( prop ) {
		if ( ! sel || ! sel.hits ) { return null; }
		var css = sectionCss(), mine = selKey( fullSel() ), best = null, score = -1;
		cssSelectors( css ).forEach( function ( r ) {
			if ( sel.hits.indexOf( r.sel ) === -1 || selKey( r.sel ) === mine ) { return; }
			var decls = cssRead( css, r.sel, r.media );
			if ( ! decls.hasOwnProperty( prop ) ) { return; }
			var sp = specificity( r.sel );
			if ( sp > score ) { score = sp; best = { sel: r.sel, media: r.media, value: decls[ prop ], score: sp }; }
		} );
		return best;
	}

	/* Ancestors to prefix to the selector until it stands up to `need`.

	   It skips the ones the selector already contains: the `.stat > span` candidate is born from the
	   parent's class, and putting it back in front would give `.stat .stat > span` — a selector that
	   matches nothing, written with the face of one that works. The comparison is on word
	   boundaries, otherwise `.stat` would be «found» inside `.stats`. */
	function anchorUpTo( need ) {
		var ancs = ( sel && sel.ancestors ) || [], pre = [], i;
		for ( i = 0; i < ancs.length; i++ ) {
			var cur = pre.concat( [ baseSel() ] ).join( ' ' );
			if ( specificity( cur ) >= need ) { break; }
			var esc = ancs[ i ].replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
			if ( new RegExp( esc + '(?![\\w-])' ).test( cur ) ) { continue; }
			pre.unshift( ancs[ i ] );
		}
		return pre.join( ' ' );
	}

	/* Moves ONLY the properties that do not reach the screen onto a selector that gets them there.
	   The others stay where they are: the rule may contain declarations written by the AI, and
	   carrying those off too would be silent damage.

	   «More precise» is not enough: if `.stats .stat span` is winning, a `.stat > span` loses all the
	   same. The new selector is built by anchoring the current one to the ancestors' classes until it
	   reaches the winner's specificity — on a tie the order decides, and our rule sits lower in the
	   sheet. */
	function moveWeakProps() {
		var t = target();
		if ( t.err || ! sel ) { return; }
		var weak = ( sel.weak && sel.weak.losing ) || [];
		if ( ! weak.length ) { return; }

		var from = fullSel(), media = mediaNow();
		var decls = cssRead( sectionCss(), from, media );

		var need = 0, bang = false;
		weak.forEach( function ( p ) {
			var who = winnerFor( p );
			if ( ! who ) { return; }
			need = Math.max( need, who.score );
			if ( /!\s*important/i.test( who.value ) ) { bang = true; }
		} );

		// We start again from the chosen selector, not from an anchor left by a previous attempt.
		selPrefix = '';
		selPrefix = anchorUpTo( need );

		// The ancestors are not enough: we move to the structural selector, which is the most
		// precise one we can write, and anchor that in turn.
		if ( specificity( fullSel() ) < need && scopeMode !== 'element' ) {
			scopeMode = 'element';
			scopeTouched = true;
			selPrefix = '';
			selPrefix = anchorUpTo( need );
		}

		var to = fullSel();
		if ( selKey( to ) === selKey( from ) ) {
			toast( __( 'I cannot write a selector stronger than this one: give the element a class from the Code tab.' ), true );
			render();
			return;
		}

		var parts = S.splitTemplate( t.sec.template ), css = parts.css;
		weak.forEach( function ( p ) {
			if ( ! decls.hasOwnProperty( p ) ) { return; }
			// The other rule is `!important`: specificity has nothing to do with it any more, and
			// the only way to be heard is to speak the same language.
			var v = decls[ p ].replace( /\s*!\s*important\s*$/i, '' ) + ( bang ? ' !important' : '' );
			css = cssWrite( css, from, media, p, '' );
			css = cssWrite( css, to, media, p, v );
		} );
		applyCss( t.sec, css );
		state.dirty = true;
		S.refreshSaveState();

		applyLive();
		render();
		askMatches();
		commitSoon();
		toast( sprintf( __( 'Moved onto «%s».' ), to ) );
	}

	/* The re-render from the server (and the history) once the hand has stopped: while a slider is
	   being dragged the live <style> is enough, and one undo per pixel would make ⌘Z useless. */
	function commitSoon() {
		clearTimeout( pending );
		pending = setTimeout( function () {
			S.refreshCodeIfOpen();
			S.schedulePreview( 0 );
			S.commitHistory();
			askMatches(); // the sheet has changed: there may be one more rule hitting here
		}, 600 );
	}

	function post( msg ) {
		try { dom.frame.contentWindow.postMessage( msg, '*' ); } catch ( e ) {}
	}

	/* ====================================================================== */
	/* 6. The controls.                                                       */
	/* ====================================================================== */

	var vals = {};    // the current values (the override if there is one, otherwise the computed value)
	var setKeys = {}; // the properties really written in the current selector

	/* The panel redraws itself entirely on every change — it is what keeps it consistent with the
	   code — but redrawing it under the fingers of whoever is typing takes the field out of their
	   hands halfway through «300», and the redraw also comes from outside (the preview's check, the
	   list of rules). So: before rebuilding the body we note who had the focus, what was written in
	   it and where the caret was; afterwards, we give them back. Every control signs itself with
	   `data-k`. */
	/* How long we wait before writing into the template what is being typed. It has to sit above the
	   pause between two keystrokes of somebody typing «300» — below that, the field would be redrawn
	   between one key and the next. Enter, or leaving the field, writes straight away anyway. */
	var TYPING = 550;

	function mark( node, key ) {
		node.setAttribute( 'data-k', key );
		return node;
	}
	function grabFocus() {
		var a = document.activeElement;
		if ( ! a || ! dom.sbBody || ! dom.sbBody.contains( a ) ) { return null; }
		var k = a.getAttribute && a.getAttribute( 'data-k' );
		if ( ! k ) { return null; }
		var g = { k: k, value: 'value' in a ? a.value : null };
		// selectionStart does not exist on input[type=number]: there the focus is enough.
		try { g.s = a.selectionStart; g.e = a.selectionEnd; } catch ( err ) {}
		return g;
	}
	function restoreFocus( g ) {
		if ( ! g || ! dom.sbBody ) { return; }
		var n = dom.sbBody.querySelector( '[data-k="' + g.k.replace( /"/g, '' ) + '"]' );
		if ( ! n ) { return; }
		// The typed value takes precedence over the one re-read from the CSS: it is more recent.
		if ( g.value != null && 'value' in n ) { n.value = g.value; }
		n.focus();
		try { if ( g.s != null ) { n.setSelectionRange( g.s, g.e ); } } catch ( err ) {}
	}

	/* Properties we write in short form but that the browser only computes per side/part. */
	var COMPUTED_AS = {
		'border-style': 'border-top-style',
		'border-color': 'border-top-color',
		'text-decoration': 'text-decoration-line',
	};

	function cur( prop ) {
		if ( setKeys[ prop ] ) { return vals[ prop ]; }
		if ( ! sel ) { return ''; }
		return sel.computed[ COMPUTED_AS[ prop ] || prop ] || '';
	}
	function isSet( prop ) { return !! setKeys[ prop ]; }

	/* A row: the label on the left, the control on the right, a dot + reset when it is overridden. */
	function row( label, props, node ) {
		var r = el( 'div', 'aipb-sb-row' );
		var lab = el( 'label', 'aipb-sb-lab', label );
		var on = props.some( isSet );

		// The field is filled in but that value never reaches the screen: better to say so here,
		// where the eye is, and not only in the warning at the top.
		var weak = ( sel && sel.weak ) || {};
		var bad = props.filter( function ( p ) {
			return ( weak.losing || [] ).indexOf( p ) !== -1 || ( weak.invalid || [] ).indexOf( p ) !== -1;
		} );
		// Tied to the design system: the diamond on the label is visible even when the group is
		// scrolled, and it tells «I wrote this» (a dot) from «the brand decides it» (a diamond).
		if ( props.some( tokenSet ) ) {
			r.classList.add( 'is-token' );
			var dia = el( 'span', 'aipb-sb-labdia', '◆' );
			dia.title = __( 'A value taken from the design system: it changes along with the brand.' );
			lab.appendChild( dia );
		}

		if ( bad.length ) {
			r.classList.add( 'is-weak' );
			var warn = el( 'span', 'aipb-sb-rowwarn', '!' );
			warn.title = ( weak.invalid || [] ).indexOf( bad[ 0 ] ) !== -1
				? __( 'Invalid value: the browser ignores it.' )
				: __( 'A more specific rule wins: this value is not visible.' );
			lab.appendChild( warn );
		}

		if ( on ) {
			var dot = el( 'button', 'aipb-sb-dot' );
			dot.type = 'button';
			dot.title = __( 'Remove this change' );
			dot.addEventListener( 'click', function () { props.forEach( function ( p ) { setProp( p, '' ); } ); } );
			lab.appendChild( dot );
		}
		r.classList.toggle( 'is-set', on );
		r.appendChild( lab );
		r.appendChild( node );
		return r;
	}

	function parseLen( v ) {
		var m = /^\s*(-?\d*\.?\d+)\s*([a-z%]*)\s*$/i.exec( String( v || '' ) );
		if ( ! m ) { return null; }
		return { n: parseFloat( m[ 1 ] ), u: m[ 2 ] || '' };
	}

	/* A length: a number + a unit, with the design system's tokens in the ◆ menu. If the value IS a
	   token, the field becomes the pill with its name: showing «24px» where `var(--aipb-space-md)` is
	   written would hide the one thing worth knowing about it. */
	function ctlLen( prop, opts ) {
		opts = opts || {};
		var wrap = el( 'div', 'aipb-sb-len' );
		var v = cur( prop ), parsed = parseLen( v );
		var toks = opts.tokens ? tokens()[ opts.tokens ] : [];
		var tok = tokenSet( prop );
		// The chip says «you chose this value, click it to remove it»: it only holds for a value WE
		// wrote. Computed `normal`, `auto`, `none` stay a grey placeholder, otherwise every field
		// would look like a change waiting to be undone.
		var chip = isSet( prop ) && ! parsed && !! v;

		if ( tok ) {
			// Breaking the link: we start again from the token's real value, so the number in the
			// field is the one already on screen and not a zero to start over from.
			wrap.appendChild( tokenChip( prop, toks, tok, function () { setProp( prop, tok.value ); } ) );
			return wrap;
		}

		if ( chip ) {
			var chip = el( 'button', 'aipb-sb-chip', v );
			chip.type = 'button';
			chip.title = v;
			chip.addEventListener( 'click', function () { setProp( prop, '' ); } );
			wrap.appendChild( mark( chip, prop ) );
		} else {
			var num = el( 'input', 'aipb-sb-num' );
			num.type = 'number';
			num.step = opts.step || 1;
			if ( parsed ) { num.value = String( Math.round( parsed.n * 100 ) / 100 ); }
			else { num.placeholder = v ? short( v ) : '—'; }
			if ( ! isSet( prop ) ) { num.classList.add( 'is-ghost' ); }
			var unit = el( 'select', 'aipb-sb-unit' );
			( opts.units || [ 'px', 'rem', 'em', '%' ] ).concat( opts.keywords || [] ).forEach( function ( u ) {
				// An empty unit = a plain number (line height 1.4, z-index 10): the menu reads «×».
				var o = el( 'option', '', u === '' ? '×' : u );
				o.value = u;
				unit.appendChild( o );
			} );
			unit.value = ( parsed && parsed.u ) || ( opts.units || [ 'px' ] )[ 0 ];
			var pending = function () {
				if ( num.value === '' ) { return ''; }
				var u = unit.value;
				if ( opts.keywords && opts.keywords.indexOf( u ) !== -1 ) { return u; }
				return num.value + u;
			};
			var commit = function () { setProp( prop, pending() ); };
			var lazy = debounce( commit, TYPING );
			// While typing, ONLY the preview updates: the template — and therefore the panel's
			// redraw and the render from the server — wait for the finger to stop.
			num.addEventListener( 'input', function () {
				var v = pending();
				if ( v !== '' ) { liveSet( prop, v ); }
				lazy();
			} );
			num.addEventListener( 'change', commit ); // Enter, or leaving the field: straight away
			unit.addEventListener( 'change', commit );
			wrap.appendChild( mark( num, prop ) );
			wrap.appendChild( mark( unit, prop + '|u' ) );
		}

		if ( opts.tokens ) { wrap.appendChild( tokenBtn( toks, prop ) ); }
		return wrap;
	}

	/* The ◆ button is there even when that group's tokens are not defined: the menu then explains
	   where to define them. Hiding it would mean never letting anyone know they exist. */
	function tokenBtn( list, prop, onPick ) {
		var b = el( 'button', 'aipb-sb-tok', '◆' );
		b.type = 'button';
		b.title = list.length
			? __( 'Use a design system token' )
			: __( 'Design system: no token of this kind' );
		if ( ! list.length ) { b.classList.add( 'is-empty' ); }
		b.addEventListener( 'click', function ( e ) {
			e.stopPropagation();
			if ( onPick ) { onPick( b ); return; }
			tokenMenu( b, list, prop );
		} );
		return b;
	}

	function ctlSelect( prop, options ) {
		var s = el( 'select', 'aipb-sb-sel' );
		var v = cur( prop );
		var empty = el( 'option', '', isSet( prop ) ? __( '— none —' ) : __( '(automatic)' ) );
		empty.value = '';
		s.appendChild( empty );
		options.forEach( function ( o ) {
			var val = typeof o === 'string' ? o : o[ 0 ];
			var lbl = typeof o === 'string' ? o : o[ 1 ];
			var op = el( 'option', '', lbl );
			op.value = val;
			s.appendChild( op );
		} );
		s.value = isSet( prop ) ? v : ( options.some( function ( o ) {
			return ( typeof o === 'string' ? o : o[ 0 ] ) === String( v ).trim();
		} ) ? String( v ).trim() : '' );
		if ( ! isSet( prop ) ) { s.classList.add( 'is-ghost' ); }
		s.addEventListener( 'change', function () { setProp( prop, s.value ); } );
		return mark( s, prop );
	}

	function ctlSeg( prop, options ) {
		var w = el( 'div', 'aipb-sb-seg' );
		var v = String( cur( prop ) ).trim();
		options.forEach( function ( o ) {
			var b = el( 'button', 'aipb-sb-segb' );
			b.type = 'button';
			b.title = o[ 2 ] || o[ 0 ];
			b.innerHTML = o[ 1 ];
			if ( v === o[ 0 ] ) { b.classList.add( 'active' ); }
			if ( ! isSet( prop ) ) { b.classList.add( 'is-ghost' ); }
			b.addEventListener( 'click', function () { setProp( prop, isSet( prop ) && v === o[ 0 ] ? '' : o[ 0 ] ); } );
			w.appendChild( mark( b, prop + '|' + o[ 0 ] ) );
		} );
		return w;
	}

	/* A colour opens in a window: only the pill with the value fits in the row. */
	function ctlColor( prop, label ) {
		var v = cur( prop );
		var b = el( 'button', 'aipb-sb-colorbtn' );
		b.type = 'button';
		b.appendChild( paintSwatch( el( 'span', 'aipb-sb-swatch' ), v ) );
		b.appendChild( el( 'span', 'aipb-sb-colorval', colorLabel( v ) ) );
		if ( ! isSet( prop ) ) { b.classList.add( 'is-ghost' ); }
		b.addEventListener( 'click', function () { openColor( prop, label || prop ); } );
		return mark( b, prop );
	}

	/* How a colour reads in a narrow row: a token by its name (that is what counts), everything else
	   in hexadecimal. */
	function colorLabel( v ) {
		v = String( v || '' ).trim();
		if ( ! v ) { return __( 'none' ); }
		var t = tokenOf( v );
		if ( t ) { return t.key; }
		if ( /^transparent$/i.test( v ) || /^rgba\([^)]*,\s*0\s*\)$/i.test( v ) ) { return __( 'transparent' ); }
		var c = resolveColor( v );
		return c ? ( c.a < 1 ? rgbaCss( c ) : hexOf( c ) ) : short( v );
	}

	function paintSwatch( node, v ) {
		var c = resolveColor( v );
		node.style.backgroundImage = c ? 'linear-gradient(' + rgbaCss( c ) + ',' + rgbaCss( c ) + ')' : '';
		node.classList.toggle( 'is-empty', ! c || ! c.a );
		return node;
	}

	/* ---- Colour: conversions ---- */

	/* Every token in one list, with the group it comes from. It is what recognises that a value
	   written in the CSS is not just any number but a LINK to the design system: change the site's
	   palette or spacing, and that rule changes on its own. It is a difference that has to be
	   visible, otherwise it gets broken unwittingly by overwriting it with «20px». */
	function allTokens() {
		var t = tokens(), out = [];
		Object.keys( t ).forEach( function ( bucket ) {
			out = out.concat( t[ bucket ] );
		} );
		return out;
	}

	function tokenOf( v ) {
		var m = /^var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)$/.exec( String( v || '' ).trim() );
		if ( ! m ) { return null; }
		var want = 'var(' + m[ 1 ] + ')';
		return allTokens().filter( function ( t ) { return t.css === want; } )[ 0 ] || null;
	}

	/* What the rule says for this property: the longhand if there is one, otherwise the part that
	   belongs to it inside the shorthand. `padding: var(--aipb-space-md)` is as much a link to the
	   design system as `padding-top: var(--aipb-space-md)`, and reading only the longhands it would
	   stay invisible — in exactly the form the AI almost always writes it. */
	function writtenOf( prop ) {
		if ( isSet( prop ) ) { return vals[ prop ]; }
		var sh = shorthandOf( prop );
		if ( sh && vals.hasOwnProperty( sh ) ) {
			var parts = expandShorthand( vals[ sh ] );
			if ( parts ) { return parts[ SHORTHANDS[ sh ].indexOf( prop ) ]; }
		}
		return '';
	}

	/* Is the value written for this property a token? (Only if written IN THE RULE: an inherited
	   value is not a link, it is only what is seen.) */
	function tokenSet( prop ) { return tokenOf( writtenOf( prop ) ); }

	/* Previewing a token inside a menu: a spacing is understood by seeing it long, a radius by
	   seeing it curved, a text size by seeing it written. A list of names is not. */
	function tokenPreview( t ) {
		var p = el( 'span', 'aipb-sb-tkprev' );
		if ( t.bucket === 'color' ) {
			paintSwatch( p, t.value );
			p.classList.add( 'is-color' );
		} else if ( t.bucket === 'space' || t.bucket === 'width' ) {
			var bar = el( 'span', 'aipb-sb-tkbar' );
			// Content widths are hundreds of px: we show them to scale.
			bar.style.width = 'min(100%, ' + ( t.bucket === 'width' ? 'calc(' + t.value + ' / 12)' : t.value ) + ')';
			p.appendChild( bar );
		} else if ( t.bucket === 'radius' ) {
			var sq = el( 'span', 'aipb-sb-tksq' );
			sq.style.borderRadius = t.value;
			p.appendChild( sq );
		} else if ( t.bucket === 'shadow' ) {
			var sh = el( 'span', 'aipb-sb-tksq' );
			sh.style.boxShadow = t.value;
			p.appendChild( sh );
		} else if ( t.bucket === 'text' ) {
			var aa = el( 'span', 'aipb-sb-tkaa', 'Aa' );
			aa.style.fontSize = 'min(20px, ' + t.value + ')';
			p.appendChild( aa );
		} else if ( t.bucket === 'font' ) {
			var f = el( 'span', 'aipb-sb-tkaa', 'Aa' );
			f.style.fontFamily = t.value;
			p.appendChild( f );
		}
		return p;
	}

	/* A field's token menu. At the bottom, the two ways out: writing a value by hand (which breaks
	   the link) and removing the declaration entirely. */
	function tokenMenu( anchor, list, prop, onFree ) {
		ensureBrandFonts();
		openMenu( anchor, function ( p, close ) {
			var head = el( 'div', 'aipb-sb-pophead', __( 'Design system' ) );
			p.appendChild( head );
			if ( ! list.length ) {
				p.appendChild( emptyTokens() );
			}
			var now = isSet( prop ) ? String( vals[ prop ] ).trim() : '';
			list.forEach( function ( t ) {
				var b = el( 'button', 'aipb-sb-popi' + ( t.css === now ? ' active' : '' ) );
				b.type = 'button';
				b.title = t.css + '  =  ' + t.value;
				b.appendChild( tokenPreview( t ) );
				b.appendChild( el( 'span', 'aipb-sb-tkname', t.key ) );
				b.appendChild( el( 'span', 'aipb-sb-tkval', t.value ) );
				b.addEventListener( 'click', function () { close(); setProp( prop, t.css ); } );
				p.appendChild( b );
			} );
			p.appendChild( el( 'div', 'aipb-sb-popsep' ) );
			if ( onFree ) {
				var free = el( 'button', 'aipb-sb-popi', __( 'Write a value…' ) );
				free.type = 'button';
				free.addEventListener( 'click', function () { close(); onFree(); } );
				p.appendChild( free );
			}
			if ( isSet( prop ) ) {
				var rm = el( 'button', 'aipb-sb-popi', __( 'Remove' ) );
				rm.type = 'button';
				rm.addEventListener( 'click', function () { close(); setProp( prop, '' ); } );
				p.appendChild( rm );
			}
		} );
	}

	function emptyTokens() {
		var w = el( 'div', 'aipb-sb-popempty' );
		w.appendChild( document.createTextNode( __( 'No token of this kind. ' ) ) );
		var a = el( 'a', '', __( 'Define them in Design Tokens' ) );
		a.href = 'admin.php?page=aipb-brand';
		a.target = '_blank';
		a.rel = 'noopener';
		w.appendChild( a );
		return w;
	}

	/* The brand's fonts come from Google and are not loaded in the admin: without this, every font in
	   the menu would look the same as the others — that is, the menu would be useless. */
	var brandFontsLink = null;
	function ensureBrandFonts() {
		var url = ( S.designFontsUrl && S.designFontsUrl() ) || AIPB.fontsUrl || '';
		if ( ! url || ( brandFontsLink && brandFontsLink.href === url ) ) { return; }
		if ( ! brandFontsLink ) {
			brandFontsLink = document.createElement( 'link' );
			brandFontsLink.rel = 'stylesheet';
			document.head.appendChild( brandFontsLink );
		}
		brandFontsLink.href = url;
	}

	/* The pill that makes the link obvious: ◆ name, in the accent colour, with the real value next to
	   it. Clicking it changes token; the dot on the row's label removes the declaration. */
	function tokenChip( prop, list, t, onFree ) {
		var b = el( 'button', 'aipb-sb-tokchip' );
		b.type = 'button';
		b.title = t.css + '  =  ' + t.value + '\n' + __( 'Linked to the design system: it changes along with the brand.' );
		b.appendChild( el( 'span', 'aipb-sb-tokdia', '◆' ) );
		b.appendChild( el( 'span', 'aipb-sb-tokname', t.key ) );
		b.appendChild( el( 'span', 'aipb-sb-tokval', t.value ) );
		b.addEventListener( 'click', function () { tokenMenu( b, list, prop, onFree ); } );
		return mark( b, prop );
	}

	/* From any CSS spelling to {r,g,b,a}. We resolve the `var()`s from the token table (in here the
	   variables do not exist: they are defined in the preview); everything else — names, hsl(),
	   color-mix() — is resolved by the browser with a probe element. */
	var probeEl = null;
	function resolveColor( v ) {
		v = String( v || '' ).trim();
		if ( ! v || v === 'none' ) { return null; }
		var t = tokenOf( v );
		if ( t ) { v = t.value; }
		if ( /^var\(/.test( v ) ) { return null; }
		if ( ! probeEl ) {
			probeEl = el( 'span' );
			probeEl.style.cssText = 'position:absolute;left:-9999px;width:0;height:0';
			( dom.studio || document.body ).appendChild( probeEl );
		}
		probeEl.style.color = '';
		probeEl.style.color = v;
		if ( ! probeEl.style.color ) { return null; } // a spelling the browser rejected
		var out = getComputedStyle( probeEl ).color;
		var m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.%]+))?/i.exec( out );
		if ( ! m ) { return null; }
		var a = m[ 4 ] == null ? 1 : ( /%$/.test( m[ 4 ] ) ? parseFloat( m[ 4 ] ) / 100 : parseFloat( m[ 4 ] ) );
		return { r: Math.round( +m[ 1 ] ), g: Math.round( +m[ 2 ] ), b: Math.round( +m[ 3 ] ), a: isNaN( a ) ? 1 : a };
	}

	function hexOf( c ) {
		if ( ! c ) { return '#000000'; }
		return '#' + [ c.r, c.g, c.b ].map( function ( n ) {
			return ( '0' + Math.max( 0, Math.min( 255, Math.round( n ) ) ).toString( 16 ) ).slice( -2 );
		} ).join( '' );
	}
	function rgbaCss( c ) {
		return c.a < 1
			? 'rgba(' + c.r + ', ' + c.g + ', ' + c.b + ', ' + Math.round( c.a * 100 ) / 100 + ')'
			: 'rgb(' + c.r + ', ' + c.g + ', ' + c.b + ')';
	}
	/* How we write it in the CSS: hexadecimal when opaque, rgba() otherwise. */
	function colorCss( c ) { return c.a < 1 ? rgbaCss( c ) : hexOf( c ); }

	function rgbToHsv( c ) {
		var r = c.r / 255, g = c.g / 255, b = c.b / 255;
		var mx = Math.max( r, g, b ), mn = Math.min( r, g, b ), d = mx - mn, h = 0;
		if ( d ) {
			if ( mx === r ) { h = ( ( g - b ) / d + ( g < b ? 6 : 0 ) ) / 6; }
			else if ( mx === g ) { h = ( ( b - r ) / d + 2 ) / 6; }
			else { h = ( ( r - g ) / d + 4 ) / 6; }
		}
		return { h: h * 360, s: mx ? d / mx : 0, v: mx };
	}
	function hsvToRgb( h, s, v ) {
		h = ( ( h % 360 ) + 360 ) % 360 / 60;
		var i = Math.floor( h ), f = h - i;
		var p = v * ( 1 - s ), q = v * ( 1 - s * f ), t = v * ( 1 - s * ( 1 - f ) );
		var m = [ [ v, t, p ], [ q, v, p ], [ p, v, t ], [ p, q, v ], [ t, p, v ], [ v, p, q ] ][ i % 6 ];
		return { r: Math.round( m[ 0 ] * 255 ), g: Math.round( m[ 1 ] * 255 ), b: Math.round( m[ 2 ] * 255 ) };
	}

	function short( v ) {
		v = String( v || '' ).trim();
		return v.length > 18 ? v.slice( 0, 17 ) + '…' : v;
	}

	/* ---- Colour: the window ----

	   It lives inside .aipb-studio (not in <body>): the Studio's theme variables are defined up
	   there, and a window hung anywhere else would come out with no background and no border.

	   The order of things, which is also the order in which they are chosen: the brand's colours
	   first — a token picked here stays `var(--aipb-color-…)` in the CSS, so changing the site's
	   palette changes this too — then the ones already used in the section, and only at the bottom
	   the wheel for an arbitrary colour, which is the last resort, not the first choice. */
	function openColor( prop, title ) {
		var startValue = isSet( prop ) ? vals[ prop ] : '';
		var picked = startValue;                                  // what will be written
		var rgba = resolveColor( cur( prop ) ) || { r: 0, g: 0, b: 0, a: 1 };
		var hsv = rgbToHsv( rgba );
		if ( ! hsv.s && ! hsv.v ) { hsv.h = 0; }

		var back = el( 'div', 'aipb-cp-back' );
		var box = el( 'div', 'aipb-cp' );

		var head = el( 'div', 'aipb-cp-head' );
		head.appendChild( el( 'span', 'aipb-cp-title', sprintf( __( 'Colour — %s' ), title ) ) );
		var x = el( 'button', 'aipb-cp-x', '×' );
		x.type = 'button';
		x.title = __( 'Close without applying (Esc)' );
		head.appendChild( x );
		box.appendChild( head );

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

		/* --- The design system's palette --- */
		var pal = tokens().color;
		body.appendChild( sectionTitle( __( 'Brand colours' ) ) );
		if ( pal.length ) {
			var grid = el( 'div', 'aipb-cp-grid' );
			pal.forEach( function ( t ) {
				grid.appendChild( swatchBtn( t.value, t.key, t.css, function () { choose( t.css ); } ) );
			} );
			body.appendChild( grid );
		} else {
			var none = el( 'p', 'aipb-cp-none' );
			none.appendChild( document.createTextNode( __( 'No colour in the design system. ' ) ) );
			var a = el( 'a', '', __( 'Define them in Design Tokens' ) );
			a.href = 'admin.php?page=aipb-brand';
			a.target = '_blank';
			a.rel = 'noopener';
			none.appendChild( a );
			none.appendChild( document.createTextNode( __( ' — and the sections will follow them on their own.' ) ) );
			body.appendChild( none );
		}

		/* --- Colours already in use in the section --- */
		var used = usedColors().filter( function ( u ) {
			return ! pal.some( function ( t ) { return t.css === u.css; } );
		} );
		if ( used.length ) {
			body.appendChild( sectionTitle( __( 'Used in this section' ) ) );
			var g2 = el( 'div', 'aipb-cp-grid' );
			used.forEach( function ( u ) {
				g2.appendChild( swatchBtn( u.value, u.label, u.css, function () { choose( u.css ); } ) );
			} );
			body.appendChild( g2 );
		}

		/* --- A free colour --- */
		body.appendChild( sectionTitle( __( 'Free colour' ) ) );

		var sv = el( 'div', 'aipb-cp-sv' );
		var svDot = el( 'span', 'aipb-cp-dot' );
		sv.appendChild( svDot );
		body.appendChild( sv );

		var hue = el( 'div', 'aipb-cp-hue' );
		var hueDot = el( 'span', 'aipb-cp-dot' );
		hue.appendChild( hueDot );
		body.appendChild( hue );

		var alpha = el( 'div', 'aipb-cp-alpha' );
		var alphaInner = el( 'span', 'aipb-cp-alphafill' );
		var alphaDot = el( 'span', 'aipb-cp-dot' );
		alpha.appendChild( alphaInner );
		alpha.appendChild( alphaDot );
		body.appendChild( alpha );

		var row = el( 'div', 'aipb-cp-row' );
		var hex = el( 'input', 'aipb-cp-hex' );
		hex.type = 'text';
		hex.spellcheck = false;
		var opa = el( 'input', 'aipb-cp-opa' );
		opa.type = 'number';
		opa.min = 0; opa.max = 100; opa.step = 1;
		row.appendChild( el( 'span', 'aipb-cp-k', 'HEX' ) );
		row.appendChild( hex );
		row.appendChild( el( 'span', 'aipb-cp-k', __( 'Opacity' ) ) );
		row.appendChild( opa );
		row.appendChild( el( 'span', 'aipb-cp-k', '%' ) );
		body.appendChild( row );

		box.appendChild( body );

		var foot = el( 'div', 'aipb-cp-foot' );
		var clear = el( 'button', 'aipb-cp-btn', isSet( prop ) ? __( 'Remove' ) : __( 'Transparent' ) );
		clear.type = 'button';
		clear.title = isSet( prop )
			? __( 'Removes the declaration: it goes back to the colour it had before.' )
			: __( 'Writes «transparent».' );
		clear.addEventListener( 'click', function () {
			picked = isSet( prop ) ? '' : 'transparent';
			apply();
			close();
		} );
		var cancel = el( 'button', 'aipb-cp-btn', __( 'Cancel' ) );
		cancel.type = 'button';
		var ok = el( 'button', 'aipb-cp-btn primary', __( 'Apply' ) );
		ok.type = 'button';
		foot.appendChild( clear );
		foot.appendChild( el( 'span', 'aipb-cp-spacer' ) );
		foot.appendChild( cancel );
		foot.appendChild( ok );
		box.appendChild( foot );

		back.appendChild( box );
		( dom.studio || document.body ).appendChild( back );

		/* --- Behaviour --- */

		function sectionTitle( t ) { return el( 'div', 'aipb-cp-sect', t ); }

		function swatchBtn( value, label, css, run ) {
			var b = el( 'button', 'aipb-cp-sw' );
			b.type = 'button';
			b.title = label + '  ·  ' + css;
			var chip = paintSwatch( el( 'span', 'aipb-cp-chip' ), value );
			if ( selKey( css ) === selKey( picked || cur( prop ) ) ) { b.classList.add( 'active' ); }
			b.appendChild( chip );
			b.appendChild( el( 'span', 'aipb-cp-swname', label ) );
			b.addEventListener( 'click', run );
			return b;
		}

		// A token (or a colour already in use) applies and closes: the choice is already made.
		function choose( css ) {
			picked = css;
			apply();
			close();
		}

		function rgbNow() {
			var c = hsvToRgb( hsv.h, hsv.s, hsv.v );
			c.a = rgba.a;
			return c;
		}

		function paint( fromInputs ) {
			var c = rgbNow();
			sv.style.backgroundColor = 'hsl(' + Math.round( hsv.h ) + ', 100%, 50%)';
			svDot.style.left = ( hsv.s * 100 ) + '%';
			svDot.style.top = ( ( 1 - hsv.v ) * 100 ) + '%';
			svDot.style.background = hexOf( c );
			hueDot.style.left = ( hsv.h / 360 * 100 ) + '%';
			hueDot.style.background = 'hsl(' + Math.round( hsv.h ) + ', 100%, 50%)';
			alphaInner.style.background = 'linear-gradient(to right, rgba(' + c.r + ',' + c.g + ',' + c.b + ',0), ' + hexOf( c ) + ')';
			alphaDot.style.left = ( rgba.a * 100 ) + '%';
			alphaDot.style.background = hexOf( c );
			if ( ! fromInputs ) {
				hex.value = hexOf( c );
				opa.value = String( Math.round( rgba.a * 100 ) );
			}
			[].forEach.call( box.querySelectorAll( '.aipb-cp-sw' ), function ( b ) { b.classList.remove( 'active' ); } );
		}

		// Dragging shows up in the preview straight away, without writing anything in the template.
		function live() {
			picked = colorCss( rgbNow() );
			liveSet( prop, picked );
			paint();
		}

		function drag( node, onMove ) {
			var going = false;
			var at = function ( e ) {
				var r = node.getBoundingClientRect();
				var p = e.touches ? e.touches[ 0 ] : e;
				onMove(
					Math.max( 0, Math.min( 1, ( p.clientX - r.left ) / r.width ) ),
					Math.max( 0, Math.min( 1, ( p.clientY - r.top ) / r.height ) )
				);
			};
			node.addEventListener( 'mousedown', function ( e ) {
				e.preventDefault();
				going = true;
				at( e );
			} );
			document.addEventListener( 'mousemove', function ( e ) { if ( going ) { at( e ); } } );
			document.addEventListener( 'mouseup', function () { going = false; } );
		}

		drag( sv, function ( x, y ) { hsv.s = x; hsv.v = 1 - y; live(); } );
		drag( hue, function ( x ) { hsv.h = x * 360; live(); } );
		drag( alpha, function ( x ) { rgba.a = Math.round( x * 100 ) / 100; live(); } );

		hex.addEventListener( 'input', function () {
			var c = resolveColor( hex.value.trim() );
			if ( ! c ) { return; }
			hsv = rgbToHsv( c );
			rgba.a = c.a;
			picked = colorCss( { r: c.r, g: c.g, b: c.b, a: rgba.a } );
			liveSet( prop, picked );
			paint( true );
		} );
		opa.addEventListener( 'input', function () {
			var n = parseFloat( opa.value );
			if ( isNaN( n ) ) { return; }
			rgba.a = Math.max( 0, Math.min( 100, n ) ) / 100;
			live();
		} );

		function apply() { setProp( prop, picked ); }

		function close() {
			document.removeEventListener( 'keydown', onKey );
			back.remove();
		}
		function abandon() {
			close();
			applyLive(); // puts the preview back onto what the template really says
		}
		function onKey( e ) {
			if ( e.key === 'Escape' ) { e.stopPropagation(); abandon(); }
			else if ( e.key === 'Enter' && e.target !== hex ) { apply(); close(); }
		}

		document.addEventListener( 'keydown', onKey );
		cancel.addEventListener( 'click', abandon );
		x.addEventListener( 'click', abandon );
		back.addEventListener( 'mousedown', function ( e ) { if ( e.target === back ) { abandon(); } } );
		ok.addEventListener( 'click', function () { apply(); close(); } );

		paint();
		hex.focus();
		hex.select();
	}

	/* The colours the section already uses: the second most useful palette after the brand's,
	   because it is made of the choices somebody took in here. */
	function usedColors() {
		var css = sectionCss(), seen = {}, out = [];
		var re = /var\(\s*--aipb-color-[\w-]+\s*\)|#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/gi;
		( css.match( re ) || [] ).forEach( function ( raw ) {
			var c = resolveColor( raw );
			if ( ! c ) { return; }
			var k = rgbaCss( c );
			if ( seen[ k ] ) { return; }
			seen[ k ] = 1;
			// A short label: under a 60px chip, `rgb(11, 16, 32)` becomes «rgb(11, 16, 3…» and says
			// nothing any more. The hexadecimal fits whole.
			out.push( { css: raw, value: k, label: c.a < 1 ? hexOf( c ) + ' ' + Math.round( c.a * 100 ) + '%' : hexOf( c ) } );
		} );
		return out.slice( 0, 18 );
	}

	/* Fonts: the brand's fonts first, written IN THEIR OWN typeface — a list of names in sans-serif
	   helps nobody choose. The current value reads as a name, not as the stack
	   `"Inter", -apple-system, …`, which would not fit in the row anyway. */
	function ctlFont() {
		var prop = 'font-family';
		var list = tokens().font;
		var tok = tokenSet( prop );
		var v = cur( prop );

		var b = el( 'button', 'aipb-sb-fontbtn' );
		b.type = 'button';
		if ( tok ) {
			b.appendChild( el( 'span', 'aipb-sb-tokdia', '◆' ) );
			b.appendChild( el( 'span', 'aipb-sb-fontname', tok.key ) );
			b.style.fontFamily = tok.value;
			b.title = tok.css + '  =  ' + tok.value + '\n' + __( 'Linked to the design system: it changes along with the brand.' );
			b.classList.add( 'is-token' );
			ensureBrandFonts();
		} else {
			b.appendChild( el( 'span', 'aipb-sb-fontname', firstFamily( v ) || __( 'automatic' ) ) );
			b.style.fontFamily = v || '';
			b.title = v || '';
			if ( ! isSet( prop ) ) { b.classList.add( 'is-ghost' ); }
		}
		b.addEventListener( 'click', function () { fontMenu( b ); } );
		return mark( b, prop );
	}

	/* From the font stack to the name that matters: the first one, without quotes. */
	function firstFamily( v ) {
		return String( v || '' ).split( ',' )[ 0 ].replace( /["']/g, '' ).trim();
	}

	function fontMenu( anchor ) {
		var prop = 'font-family';
		var list = tokens().font;
		ensureBrandFonts();
		openMenu( anchor, function ( p, close ) {
			p.appendChild( el( 'div', 'aipb-sb-pophead', __( 'Brand fonts' ) ) );
			if ( ! list.length ) { p.appendChild( emptyTokens() ); }
			var now = isSet( prop ) ? String( vals[ prop ] ).trim() : '';
			list.forEach( function ( t ) {
				var b = el( 'button', 'aipb-sb-popi aipb-sb-fonti' + ( t.css === now ? ' active' : '' ) );
				b.type = 'button';
				b.title = t.css + '  =  ' + t.value;
				var name = el( 'span', 'aipb-sb-tkname', t.key );
				var prev = el( 'span', 'aipb-sb-fontprev', __( 'Aa — Every section' ) );
				prev.style.fontFamily = t.value;
				var col = el( 'span', 'aipb-sb-fontcol' );
				col.appendChild( name );
				col.appendChild( prev );
				b.appendChild( el( 'span', 'aipb-sb-tokdia', '◆' ) );
				b.appendChild( col );
				b.addEventListener( 'click', function () { close(); setProp( prop, t.css ); } );
				p.appendChild( b );
			} );

			p.appendChild( el( 'div', 'aipb-sb-popsep' ) );

			var sys = el( 'button', 'aipb-sb-popi', __( 'System font' ) );
			sys.type = 'button';
			sys.addEventListener( 'click', function () {
				close();
				setProp( prop, 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' );
			} );
			p.appendChild( sys );

			// A hand-written stack: still possible, but not the main road.
			var free = el( 'div', 'aipb-sb-popfree' );
			var inp = el( 'input', 'aipb-sb-text' );
			inp.type = 'text';
			inp.placeholder = __( 'or: "Inter", sans-serif' );
			inp.value = isSet( prop ) && ! tokenSet( prop ) ? vals[ prop ] : '';
			inp.addEventListener( 'keydown', function ( e ) {
				if ( e.key !== 'Enter' ) { return; }
				e.preventDefault();
				close();
				setProp( prop, inp.value.trim() );
			} );
			free.appendChild( inp );
			p.appendChild( free );

			if ( isSet( prop ) ) {
				var rm = el( 'button', 'aipb-sb-popi', __( 'Remove' ) );
				rm.type = 'button';
				rm.addEventListener( 'click', function () { close(); setProp( prop, '' ); } );
				p.appendChild( rm );
			}
		} );
	}

	/* A free value (a shadow, a grid) that can nonetheless come from the design system: if it is a
	   token the pill shows, otherwise the text field with the ◆ next to it. */
	function ctlFree( prop, opts ) {
		opts = opts || {};
		var list = opts.tokens ? ( tokens()[ opts.tokens ] || [] ) : [];
		var tok = tokenSet( prop );
		var w = el( 'div', 'aipb-sb-len' );
		if ( tok ) {
			w.appendChild( tokenChip( prop, list, tok, function () { setProp( prop, tok.value ); } ) );
			return w;
		}
		w.appendChild( ctlText( prop, opts.placeholder ) );
		if ( opts.tokens ) { w.appendChild( tokenBtn( list, prop ) ); }
		return w;
	}

	function ctlText( prop, placeholder ) {
		var i = el( 'input', 'aipb-sb-text' );
		i.type = 'text';
		i.value = isSet( prop ) ? vals[ prop ] : '';
		i.placeholder = placeholder || short( cur( prop ) );
		i.addEventListener( 'change', function () { setProp( prop, i.value.trim() ); } );
		return mark( i, prop );
	}

	function ctlRange( prop, min, max, step ) {
		var w = el( 'div', 'aipb-sb-range' );
		var v = parseFloat( cur( prop ) );
		if ( isNaN( v ) ) { v = max; }
		var r = el( 'input' );
		r.type = 'range';
		r.min = min; r.max = max; r.step = step;
		r.value = v;
		var out = el( 'span', 'aipb-sb-rv', String( v ) );
		r.addEventListener( 'input', function () {
			out.textContent = r.value;
			liveSet( prop, r.value );
		} );
		r.addEventListener( 'change', function () { setProp( prop, r.value ); } );
		w.appendChild( mark( r, prop ) );
		w.appendChild( out );
		return w;
	}

	/* Typing and dragging: they update ONLY the preview. The template is written by the commit, once
	   the finger stops — rewriting it on every key would mean redrawing the panel under the hands of
	   whoever is typing, and redoing the preview for a value that is still half written. It accepts
	   a single property or a group (the four linked sides). */
	function liveSet( prop, value ) {
		if ( ! sel ) { return; }
		var s = fullSel(), scope = sel.scopeId ? '#' + sel.scopeId : '';
		if ( ! s || ! scope ) { return; }
		var patch = typeof prop === 'string' ? {} : prop;
		if ( typeof prop === 'string' ) { patch[ prop ] = value; }
		var decls = cssRead( sectionCss(), s, mediaNow() );
		Object.keys( patch ).forEach( function ( p ) { decls[ p ] = patch[ p ]; } );
		var body = Object.keys( decls ).map( function ( p ) { return p + ':' + decls[ p ]; } ).join( ';' );
		var scoped = s === ':root' ? scope : scope + ' ' + s;
		var css = scoped + '{' + body + '}';
		var media = mediaNow();
		if ( media ) { css = media + '{' + css + '}'; }
		post( { aipb: 'stylelive', css: css } );
	}

	/* Four sides that can be linked (padding, margin, radii, borders). */
	function ctlBox( props, opts ) {
		opts = opts || {};
		var w = el( 'div', 'aipb-sb-box' );

		// All four on the same token: one pill says everything, four empty boxes would say nothing
		// (a `var()` is not a number and does not fit in the field).
		var toks4 = props.map( tokenSet );
		if ( toks4[ 0 ] && toks4.every( function ( t ) { return t && t.css === toks4[ 0 ].css; } ) ) {
			var list4 = opts.tokens ? ( tokens()[ opts.tokens ] || [] ) : [];
			var chip4 = tokenChip( props[ 0 ], list4, toks4[ 0 ], function () {
				props.forEach( function ( q ) { setProp( q, toks4[ 0 ].value ); } );
			} );
			chip4.addEventListener( 'click', function ( e ) {
				e.stopPropagation();
				tokenMenuMany( chip4, list4, props );
			}, true );
			w.appendChild( chip4 );
			return w;
		}
		var linked = props.every( function ( p ) { return cur( p ) === cur( props[ 0 ] ); } );
		var link = el( 'button', 'aipb-sb-link', linked ? '⛓' : '⛓̸' );
		link.type = 'button';
		link.title = linked ? __( 'Linked values: change them all together' ) : __( 'Independent values' );
		link.classList.toggle( 'active', linked );
		link.addEventListener( 'click', function () {
			// Link = bring them all to the first value; unlinking does nothing to the CSS.
			if ( ! linked ) { props.forEach( function ( p ) { setProp( p, cur( props[ 0 ] ) ); } ); }
		} );

		var grid = el( 'div', 'aipb-sb-boxgrid' );
		props.forEach( function ( p, i ) {
			var cell = el( 'div', 'aipb-sb-cell' );
			// Arrows and not initials: «T D B S» was Top/Destra/Basso/Sinistra, which says nothing in
			// English and little in any other language. An arrow points at the side it means.
			cell.appendChild( el( 'span', 'aipb-sb-cellk', ( opts.labels || [ '↑', '→', '↓', '←' ] )[ i ] ) );
			var v = parseLen( cur( p ) );
			var tokHere = tokenSet( p );
			var n = el( 'input', 'aipb-sb-num' );
			n.type = 'number';
			n.value = v ? String( Math.round( v.n * 100 ) / 100 ) : '';
			// This side alone is linked to a token: it does not fit in the box, but the diamond
			// says it is there, and the tooltip says which one.
			n.placeholder = tokHere ? '◆' : '—';
			if ( tokHere ) {
				n.title = tokHere.css + '  =  ' + tokHere.value;
				n.classList.add( 'is-token' );
			}
			if ( ! isSet( p ) ) { n.classList.add( 'is-ghost' ); }
			var sides = linked ? props : [ p ];
			var pending = function () {
				return n.value === '' ? '' : n.value + ( ( v && v.u ) || 'px' );
			};
			var commit = function () {
				var val = pending();
				sides.forEach( function ( q ) { setProp( q, val ); } );
			};
			var lazy = debounce( commit, TYPING );
			n.addEventListener( 'input', function () {
				var val = pending(), patch = {};
				if ( val !== '' ) {
					sides.forEach( function ( q ) { patch[ q ] = val; } );
					liveSet( patch );
				}
				lazy();
			} );
			n.addEventListener( 'change', commit );
			cell.appendChild( mark( n, p ) );
			grid.appendChild( cell );
		} );

		w.appendChild( grid );
		w.appendChild( link );
		if ( opts.tokens ) {
			// A token chosen here goes on all four sides: on one alone it would be useless.
			var toks = tokens()[ opts.tokens ] || [];
			w.appendChild( tokenBtn( toks, props[ 0 ], function ( anchor ) {
				tokenMenuMany( anchor, toks, props );
			} ) );
		}
		return w;
	}

	/* Like tokenMenu, but the chosen token goes onto several properties at once (the four sides). */
	function tokenMenuMany( anchor, list, props ) {
		openMenu( anchor, function ( p, close ) {
			p.appendChild( el( 'div', 'aipb-sb-pophead', __( 'Design system' ) ) );
			if ( ! list.length ) { p.appendChild( emptyTokens() ); }
			list.forEach( function ( t ) {
				var b = el( 'button', 'aipb-sb-popi' );
				b.type = 'button';
				b.title = t.css + '  =  ' + t.value;
				b.appendChild( tokenPreview( t ) );
				b.appendChild( el( 'span', 'aipb-sb-tkname', t.key ) );
				b.appendChild( el( 'span', 'aipb-sb-tkval', t.value ) );
				b.addEventListener( 'click', function () {
					close();
					props.forEach( function ( q ) { setProp( q, t.css ); } );
				} );
				p.appendChild( b );
			} );
			if ( props.some( isSet ) ) {
				p.appendChild( el( 'div', 'aipb-sb-popsep' ) );
				var rm = el( 'button', 'aipb-sb-popi', __( 'Remove' ) );
				rm.type = 'button';
				rm.addEventListener( 'click', function () {
					close();
					props.forEach( function ( q ) { setProp( q, '' ); } );
				} );
				p.appendChild( rm );
			}
		} );
	}

	/* A light context menu (tokens, colours, other rules). */
	var openPop = null;

	/* The menu lives inside .aipb-studio, not in <body>: the Studio's theme variables are defined up
	   there, and a menu hung anywhere else would come out with no background and no border — bare
	   text over the sidebar. If it gets too close to the bottom of the window, it opens upwards. */
	function openMenu( anchor, build ) {
		// Clicking again on the button that opened it closes it, as every menu does.
		var again = openPop && openPop.aipbAnchor === anchor;
		closePop();
		if ( again ) { return null; }
		var p = el( 'div', 'aipb-sb-pop' );
		p.aipbAnchor = anchor;
		build( p, closePop );
		( dom.studio || document.body ).appendChild( p );
		var r = anchor.getBoundingClientRect();
		p.style.left = Math.max( 8, Math.min( r.left, window.innerWidth - p.offsetWidth - 8 ) ) + 'px';
		var below = window.innerHeight - r.bottom - 8;
		if ( p.offsetHeight > below && r.top > below ) {
			p.style.top = Math.max( 8, r.top - p.offsetHeight - 4 ) + 'px';
		} else {
			p.style.top = ( r.bottom + 4 ) + 'px';
		}
		openPop = p;
		return p;
	}

	function popup( anchor, items ) {
		openMenu( anchor, function ( p, close ) {
			items.forEach( function ( it ) {
				var b = el( 'button', 'aipb-sb-popi' );
				b.type = 'button';
				if ( it.swatch ) {
					var s = el( 'span', 'aipb-sb-popsw' );
					s.style.background = it.swatch;
					b.appendChild( s );
				}
				b.appendChild( el( 'span', '', it.label ) );
				b.addEventListener( 'click', function () { close(); it.run(); } );
				p.appendChild( b );
			} );
		} );
	}
	function closePop() {
		if ( openPop ) { openPop.remove(); openPop = null; }
	}
	/* A click outside closes the menu. But the click that OPENED it bubbles up here immediately
	   after: without excluding the button it came from, every menu would close in the instant it
	   opens — and every caller would have to remember to stop the propagation. */
	document.addEventListener( 'click', function ( e ) {
		if ( ! openPop ) { return; }
		if ( openPop.contains( e.target ) ) { return; }
		if ( openPop.aipbAnchor && openPop.aipbAnchor.contains( e.target ) ) { return; }
		closePop();
	} );

	function debounce( fn, ms ) {
		var t;
		return function () {
			var a = arguments, self = this;
			clearTimeout( t );
			t = setTimeout( function () { fn.apply( self, a ); }, ms );
		};
	}

	/* ====================================================================== */
	/* 7. The groups of fields.                                               */
	/* ====================================================================== */

	function isFlex() {
		var d = String( cur( 'display' ) ).trim();
		return d === 'flex' || d === 'inline-flex';
	}
	function isGrid() {
		var d = String( cur( 'display' ) ).trim();
		return d === 'grid' || d === 'inline-grid';
	}

	var GROUPS = [
		{
			key: 'layout', label: __( 'Layout' ),
			props: [ 'display', 'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'grid-template-columns', 'gap' ],
			build: function ( g ) {
				g.appendChild( row( __( 'Display' ), [ 'display' ], ctlSelect( 'display', [
					'block', 'flex', 'inline-flex', 'grid', 'inline-block', 'inline', 'none',
				] ) ) );
				if ( isFlex() ) {
					g.appendChild( row( __( 'Direction' ), [ 'flex-direction' ], ctlSeg( 'flex-direction', [
						[ 'row', '→', __( 'In a row' ) ], [ 'column', '↓', __( 'In a column' ) ],
						[ 'row-reverse', '←', __( 'Row reversed' ) ], [ 'column-reverse', '↑', __( 'Column reversed' ) ],
					] ) ) );
					g.appendChild( row( __( 'Distribution' ), [ 'justify-content' ], ctlSelect( 'justify-content', [
						[ 'flex-start', __( 'Start' ) ], [ 'center', __( 'Centre' ) ], [ 'flex-end', __( 'End' ) ],
						[ 'space-between', __( 'Space between' ) ], [ 'space-around', __( 'Space around' ) ], [ 'space-evenly', __( 'Space evenly' ) ],
					] ) ) );
					g.appendChild( row( __( 'Alignment' ), [ 'align-items' ], ctlSelect( 'align-items', [
						[ 'stretch', __( 'Stretch' ) ], [ 'flex-start', __( 'Start' ) ], [ 'center', __( 'Centre' ) ],
						[ 'flex-end', __( 'End' ) ], [ 'baseline', __( 'Baseline' ) ],
					] ) ) );
					g.appendChild( row( __( 'Wrapping' ), [ 'flex-wrap' ], ctlSeg( 'flex-wrap', [
						[ 'nowrap', '⇥', __( 'On one line' ) ], [ 'wrap', '⏎', __( 'Wraps' ) ],
					] ) ) );
				}
				if ( isGrid() ) {
					g.appendChild( row( __( 'Columns' ), [ 'grid-template-columns' ],
						ctlText( 'grid-template-columns', 'repeat(3, 1fr)' ) ) );
				}
				if ( isFlex() || isGrid() ) {
					g.appendChild( row( __( 'Gap' ), [ 'gap' ], ctlLen( 'gap', { tokens: 'space' } ) ) );
				}
			},
		},
		{
			key: 'space', label: __( 'Spacing' ),
			props: SHORTHANDS.padding.concat( SHORTHANDS.margin ),
			build: function ( g ) {
				g.appendChild( row( __( 'Inside' ), SHORTHANDS.padding, ctlBox( SHORTHANDS.padding, { tokens: 'space' } ) ) );
				g.appendChild( row( __( 'Outside' ), SHORTHANDS.margin, ctlBox( SHORTHANDS.margin, { tokens: 'space' } ) ) );
			},
		},
		{
			key: 'size', label: __( 'Size' ),
			props: [ 'width', 'max-width', 'height', 'min-height' ],
			build: function ( g ) {
				g.appendChild( row( __( 'Width' ), [ 'width' ], ctlLen( 'width', { units: [ 'px', '%', 'rem', 'vw' ], keywords: [ 'auto' ], tokens: 'width' } ) ) );
				g.appendChild( row( __( 'Max width' ), [ 'max-width' ], ctlLen( 'max-width', { units: [ 'px', '%', 'rem', 'vw' ], keywords: [ 'none' ], tokens: 'width' } ) ) );
				g.appendChild( row( __( 'Height' ), [ 'height' ], ctlLen( 'height', { units: [ 'px', '%', 'rem', 'vh' ], keywords: [ 'auto' ] } ) ) );
				g.appendChild( row( __( 'Min height' ), [ 'min-height' ], ctlLen( 'min-height', { units: [ 'px', '%', 'rem', 'vh' ], keywords: [ 'auto' ] } ) ) );
			},
		},
		{
			key: 'type', label: __( 'Typography' ),
			props: [ 'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'text-align', 'text-transform', 'text-decoration' ],
			build: function ( g ) {
				g.appendChild( row( __( 'Font' ), [ 'font-family' ], ctlFont() ) );
				g.appendChild( row( __( 'Size' ), [ 'font-size' ], ctlLen( 'font-size', { units: [ 'px', 'rem', 'em', '%' ], tokens: 'text' } ) ) );
				g.appendChild( row( __( 'Weight' ), [ 'font-weight' ], ctlSelect( 'font-weight', [
					[ '300', __( 'Light (300)' ) ], [ '400', __( 'Normal (400)' ) ], [ '500', __( 'Medium (500)' ) ],
					[ '600', __( 'Semi-bold (600)' ) ], [ '700', __( 'Bold (700)' ) ], [ '800', __( 'Black (800)' ) ],
				] ) ) );
				g.appendChild( row( __( 'Line height' ), [ 'line-height' ], ctlLen( 'line-height', { units: [ '', 'px', 'rem', '%' ], step: 0.05 } ) ) );
				g.appendChild( row( __( 'Letter spacing' ), [ 'letter-spacing' ], ctlLen( 'letter-spacing', { units: [ 'px', 'em' ], step: 0.01 } ) ) );
				g.appendChild( row( __( 'Alignment' ), [ 'text-align' ], ctlSeg( 'text-align', [
					[ 'left', '⬅', __( 'Left' ) ], [ 'center', '↔', __( 'Centre' ) ],
					[ 'right', '➡', __( 'Right' ) ], [ 'justify', '☰', __( 'Justified' ) ],
				] ) ) );
				g.appendChild( row( __( 'Capitals' ), [ 'text-transform' ], ctlSelect( 'text-transform', [
					[ 'none', __( 'As written' ) ], [ 'uppercase', __( 'ALL CAPITALS' ) ],
					[ 'lowercase', __( 'all lowercase' ) ], [ 'capitalize', __( 'Initial Capitals' ) ],
				] ) ) );
				g.appendChild( row( __( 'Decoration' ), [ 'text-decoration' ], ctlSelect( 'text-decoration', [
					[ 'none', __( 'None' ) ], [ 'underline', __( 'Underlined' ) ], [ 'line-through', __( 'Struck through' ) ],
				] ) ) );
			},
		},
		{
			key: 'color', label: __( 'Colours' ),
			props: [ 'color', 'background-color' ],
			build: function ( g ) {
				g.appendChild( row( __( 'Text' ), [ 'color' ], ctlColor( 'color', __( 'text' ) ) ) );
				g.appendChild( row( __( 'Background' ), [ 'background-color' ], ctlColor( 'background-color', __( 'background' ) ) ) );
			},
		},
		{
			key: 'border', label: __( 'Border' ),
			props: SHORTHANDS[ 'border-width' ].concat( [ 'border-style', 'border-color' ], SHORTHANDS[ 'border-radius' ] ),
			build: function ( g ) {
				g.appendChild( row( __( 'Thickness' ), SHORTHANDS[ 'border-width' ], ctlBox( SHORTHANDS[ 'border-width' ] ) ) );
				g.appendChild( row( __( 'Style' ), [ 'border-style' ], ctlSelect( 'border-style', [
					[ 'none', __( 'None' ) ], [ 'solid', __( 'Solid' ) ], [ 'dashed', __( 'Dashed' ) ], [ 'dotted', __( 'Dotted' ) ],
				] ) ) );
				g.appendChild( row( __( 'Colour' ), [ 'border-color' ], ctlColor( 'border-color', __( 'border' ) ) ) );
				g.appendChild( row( __( 'Radius' ), SHORTHANDS[ 'border-radius' ], ctlBox( SHORTHANDS[ 'border-radius' ], {
					labels: [ '◜', '◝', '◞', '◟' ], tokens: 'radius',
				} ) ) );
			},
		},
		{
			key: 'fx', label: __( 'Effects' ),
			props: [ 'box-shadow', 'opacity', 'overflow' ],
			build: function ( g ) {
				g.appendChild( row( __( 'Shadow' ), [ 'box-shadow' ], ctlFree( 'box-shadow', {
					tokens: 'shadow', placeholder: '0 4px 12px rgba(0,0,0,.1)',
				} ) ) );
				g.appendChild( row( __( 'Opacity' ), [ 'opacity' ], ctlRange( 'opacity', 0, 1, 0.05 ) ) );
				g.appendChild( row( __( 'Overflow' ), [ 'overflow' ], ctlSelect( 'overflow', [
					[ 'visible', __( 'Visible' ) ], [ 'hidden', __( 'Hidden' ) ], [ 'auto', __( 'Scrollable' ) ],
				] ) ) );
			},
		},
		{
			key: 'pos', label: __( 'Position' ),
			props: [ 'position', 'top', 'right', 'bottom', 'left', 'z-index' ],
			build: function ( g ) {
				g.appendChild( row( __( 'Kind' ), [ 'position' ], ctlSelect( 'position', [
					[ 'static', __( 'Normal' ) ], [ 'relative', __( 'Relative' ) ],
					[ 'absolute', __( 'Absolute' ) ], [ 'fixed', __( 'Fixed' ) ], [ 'sticky', __( 'Sticky' ) ],
				] ) ) );
				[ [ 'top', __( 'Top' ) ], [ 'right', __( 'Right' ) ], [ 'bottom', __( 'Bottom' ) ], [ 'left', __( 'Left' ) ] ].forEach( function ( p ) {
					g.appendChild( row( p[ 1 ], [ p[ 0 ] ], ctlLen( p[ 0 ], { units: [ 'px', '%', 'rem' ], keywords: [ 'auto' ], tokens: 'space' } ) ) );
				} );
				g.appendChild( row( __( 'Layer (z)' ), [ 'z-index' ], ctlLen( 'z-index', { units: [ '' ] } ) ) );
			},
		},
	];

	/* ====================================================================== */
	/* 8. The panel (a tab in the left column).                               */
	/* ====================================================================== */

	/**
	 * The panel, built inside the LEFT column, as one of the tabs.
	 *
	 * It used to be a third column on the right, opened by a button in the top bar. Two things were
	 * wrong with that. The control was somewhere else from every other panel — the one thing on the
	 * screen you had to remember the position of instead of finding it. And the column ate 302px of
	 * the preview exactly while you were correcting how something looks, which is the worst moment
	 * to make the thing you are looking at smaller.
	 *
	 * The markup did NOT change: it keeps the `aipb-stylebar` / `aipb-sb-*` classes it always had,
	 * and so do the two hundred rules that dress its fields. What changed is the box it sits in.
	 */
	function build() {
		var bar = el( 'div', 'aipb-stylebar' );
		dom.styleBar = bar;

		var head = el( 'div', 'aipb-sb-head' );
		head.appendChild( el( 'span', 'aipb-sb-title', __( 'Style' ) ) );
		var x = el( 'button', 'aipb-sb-x', '×' );
		x.type = 'button';
		x.title = __( 'Close (Esc)' );
		x.addEventListener( 'click', function () { S.setTab( 'chat' ); } );
		head.appendChild( x );
		bar.appendChild( head );

		dom.sbBody = el( 'div', 'aipb-sb-body' );
		bar.appendChild( dom.sbBody );

		( dom.styleWrap || dom.studio ).appendChild( bar );
		return bar;
	}

	function render() {
		if ( ! dom.sbBody ) { return; }
		var body = dom.sbBody, focus = grabFocus();
		body.innerHTML = '';
		renderBody( body );
		restoreFocus( focus );
	}

	function renderBody( body ) {

		if ( ! sel ) {
			body.appendChild( renderEmpty() );
			return;
		}

		var t = target();

		body.appendChild( renderTarget( t ) );

		if ( t.err ) {
			body.appendChild( el( 'div', 'aipb-sb-warn', t.err ) );
			return;
		}

		var alert = renderWeak();
		if ( alert ) { body.appendChild( alert ); }

		/* The current values: the rule's overrides + what the preview computed. */
		vals = overrides();
		setKeys = {};
		Object.keys( vals ).forEach( function ( k ) { setKeys[ k ] = true; } );

		var groups = el( 'div', 'aipb-sb-groups' );

		GROUPS.forEach( function ( grp ) {
			// The count is worked out from the LIST of properties the group owns, not by counting
			// the rows once they are drawn. That is what lets a closed group say «there are 2 things
			// set in here»: with everything shut on arrival, a badge that only appears after you
			// open the group would be a badge that never appears when it is needed.
			var count = ( grp.props || [] ).filter( isSet ).length;
			var isOpen = !! groupsOpen[ grp.key ];

			var wrap = el( 'section', 'aipb-sb-group' + ( isOpen ? ' is-open' : '' ) + ( count ? ' is-set' : '' ) );

			var h = el( 'button', 'aipb-sb-gh' );
			h.type = 'button';
			h.setAttribute( 'aria-expanded', isOpen ? 'true' : 'false' );
			h.appendChild( el( 'span', 'aipb-sb-gt', grp.label ) );
			h.appendChild( el( 'span', 'aipb-sb-gc', count ? String( count ) : '' ) );
			h.addEventListener( 'click', function () {
				groupsOpen[ grp.key ] = ! groupsOpen[ grp.key ];
				render();
			} );
			wrap.appendChild( h );

			if ( isOpen ) {
				var g = el( 'div', 'aipb-sb-gb' );
				grp.build( g );
				wrap.appendChild( g );
			}
			groups.appendChild( wrap );
		} );

		body.appendChild( groups );
		body.appendChild( renderFoot() );
	}

	/**
	 * Nothing selected yet.
	 *
	 * It is the first thing anyone entering the tab sees, and it has one job: say what to do with
	 * the mouse. The panel cannot do anything until an element has been pointed at, and a panel that
	 * looks empty without saying why reads as broken.
	 */
	function renderEmpty() {
		var w = el( 'div', 'aipb-sb-empty' );
		var ico = el( 'span', 'aipb-sb-emptyico' );
		ico.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" '
			+ 'stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h6M4 4v6M20 20h-6M20 20v-6"/>'
			+ '<rect x="8.5" y="8.5" width="7" height="7" rx="1.5"/></svg>';
		w.appendChild( ico );
		w.appendChild( el( 'b', '', __( 'Point at something' ) ) );
		w.appendChild( el( 'span', '', __( 'Click an element in the preview: its rules appear here as fields, and what you change goes back into the section\'s CSS.' ) ) );

		return w;
	}

	/**
	 * The target card: WHAT is selected, and WHERE what you touch is going to be written.
	 *
	 * These five lines used to be five separate blocks stacked one on another — an identity, a
	 * breadcrumb trail, a column of big buttons, a row of states, a selector — and reading them took
	 * working out what each one was for. They are the same five things, in a labelled table: the
	 * caption on the left says what the line means, and the block reads in one pass instead of five.
	 */
	function renderTarget( t ) {
		var card = el( 'div', 'aipb-sb-target' );

		var id = el( 'div', 'aipb-sb-id' );
		id.appendChild( el( 'code', 'aipb-sb-tag', '<' + sel.tag + ( sel.classes.length ? '.' + sel.classes.join( '.' ) : '' ) + '>' ) );
		if ( sel.text ) { id.appendChild( el( 'span', 'aipb-sb-txt', '«' + sel.text + '»' ) ); }
		if ( t.name ) { id.appendChild( el( 'span', 'aipb-sb-sec', t.name ) ); }
		card.appendChild( id );

		if ( t.err ) { return card; }

		tline( card, __( 'Inside' ), renderChain() );
		tline( card, __( 'Applies to' ), renderScope() );
		tline( card, __( 'State' ), renderCtx() );
		tline( card, __( 'Rule' ), renderSel() );

		return card;
	}

	/** A line of the target card: the caption, then the thing. Nothing when there is nothing. */
	function tline( card, caption, node ) {
		if ( ! node ) { return; }
		var line = el( 'div', 'aipb-sb-tline' );
		line.appendChild( el( 'span', 'aipb-sb-tcap', caption ) );
		line.appendChild( node );
		card.appendChild( line );
	}

	/* The breadcrumbs: from the section down to the chosen element. Clicking a card's text gives back
	   the text's <span>, not the card — and aiming the mouse at an element nested inside four others
	   is work for a finger, not work worth doing at all. Here you climb back up. */
	function renderChain() {
		if ( ! sel || ! sel.chain || sel.chain.length < 2 ) { return null; }
		var w = el( 'div', 'aipb-sb-chain' );
		var chain = sel.chain.slice( -6 );
		chain.forEach( function ( c, i ) {
			if ( i ) { w.appendChild( el( 'span', 'aipb-sb-chsep', '›' ) ); }
			var b = el( 'button', 'aipb-sb-chb', c.label );
			b.type = 'button';
			b.title = i === chain.length - 1 ? __( 'The selected element' ) : __( 'Select this one' );
			if ( i === chain.length - 1 ) { b.classList.add( 'active' ); }
			b.addEventListener( 'mouseenter', function () { post( { aipb: 'stylehover', path: c.path } ); } );
			b.addEventListener( 'mouseleave', function () { post( { aipb: 'stylehover' } ); } );
			b.addEventListener( 'click', function () {
				post( { aipb: 'stylehover' } );
				post( { aipb: 'stylesel', path: c.path } );
			} );
			w.appendChild( b );
		} );
		return w;
	}

	/* The scope: the class (and which one) or only this element. */
	/**
	 * WHERE the change goes: on the class (so on every element that carries it) or on this one alone.
	 *
	 * It is the most important choice on the panel and it used to take the most room — a column of
	 * full-width buttons, each with a label and a subtitle. Chips say the same thing on one or two
	 * lines: the class, how many elements it hits, and a dot when a rule for it already exists.
	 */
	function renderScope() {
		var w = el( 'div', 'aipb-sb-chips' );
		var css = sectionCss();

		sel.candidates.forEach( function ( c, i ) {
			var b = el( 'button', 'aipb-sb-scb' );
			b.type = 'button';
			b.appendChild( el( 'span', 'k', c.label ) );
			b.appendChild( el( 'span', 'n', String( c.count ) ) );
			b.title = c.count > 1
				? sprintf( __( 'It writes on %1$s: %2$d elements change.' ), c.label, c.count )
				: sprintf( __( 'It writes on %s, which only this element carries.' ), c.label );
			if ( cssHasRule( css, c.sel ) ) {
				b.appendChild( el( 'span', 'r', '•' ) );
				b.title += ' ' + __( 'It already has a rule in the section\'s CSS.' );
			}
			if ( scopeMode === 'class' && selIndex === i ) { b.classList.add( 'active' ); }
			b.addEventListener( 'click', function () { scopeMode = 'class'; selIndex = i; scopeTouched = true; selPrefix = ''; refresh(); } );
			w.appendChild( b );
		} );

		var only = el( 'button', 'aipb-sb-scb' );
		only.type = 'button';
		only.appendChild( el( 'span', 'k', __( 'Only this one' ) ) );
		only.title = __( 'It writes on a precise selector, which reaches this element and no other.' );
		if ( scopeMode === 'element' ) { only.classList.add( 'active' ); }
		only.addEventListener( 'click', function () { scopeMode = 'element'; scopeTouched = true; selPrefix = ''; refresh(); } );
		w.appendChild( only );

		return w;
	}

	/* The state (normal/hover/focus) and the current breakpoint. */
	function renderCtx() {
		var w = el( 'div', 'aipb-sb-ctx' );

		var states = [ [ '', __( 'Normal' ) ], [ ':hover', __( 'Hover' ) ], [ ':focus', __( 'Focus' ) ] ];
		var seg = el( 'div', 'aipb-sb-seg' );
		var rootOnly = baseSel() === ':root';
		states.forEach( function ( s ) {
			var b = el( 'button', 'aipb-sb-segb', s[ 1 ] );
			b.type = 'button';
			if ( pseudo === s[ 0 ] ) { b.classList.add( 'active' ); }
			if ( rootOnly && s[ 0 ] ) {
				b.disabled = true;
				b.title = __( 'States are not available on the section\'s container: pick an element inside it.' );
			}
			b.addEventListener( 'click', function () {
				pseudo = s[ 0 ];
				if ( ! scopeTouched ) { pickDefault(); }
				refresh();
			} );
			seg.appendChild( b );
		} );
		w.appendChild( seg );

		var vp = state.viewport;
		var bp = el( 'div', 'aipb-sb-bp' + ( vp === 'desktop' ? '' : ' is-on' ) );
		bp.textContent = vp === 'desktop'
			? __( 'Every screen' )
			: sprintf( __( 'Only %s' ), mediaNow().replace( /^@media\s*/, '' ) );
		bp.title = __( 'It follows the Desktop/Tablet/Mobile switch at the top: the changes land in that device\'s @media.' );
		w.appendChild( bp );

		return w;
	}

	/* The selector that will be written: no hidden magic. */
	function renderSel() {
		var w = el( 'div', 'aipb-sb-selline' );
		var code = el( 'code', '', fullSel() );
		w.appendChild( code );

		var others = cssSelectors( sectionCss() ).filter( function ( r ) {
			return selKey( r.sel ) !== selKey( fullSel() ) && matchesSelected( r.sel );
		} );
		if ( others.length ) {
			var b = el( 'button', 'aipb-sb-more', sprintf( __( '+%d rules' ), others.length ) );
			b.type = 'button';
			b.title = __( 'Other rules in the CSS that hit this element' );
			b.addEventListener( 'click', function ( e ) {
				e.stopPropagation();
				popup( b, others.map( function ( r ) {
					return {
						label: r.sel + ( r.media ? '   ' + r.media.replace( /^@media\s*/, '' ) : '' ),
						run: function () { toast( sprintf( __( 'Rule «%s»: open it in the Code tab to edit it.' ), r.sel ) ); },
					};
				} ) );
			} );
			w.appendChild( b );
		}
		return w;
	}

	/* Does one of the sheet's selectors concern the selected element? The preview answers with
	   `matches` (see askMatches): until that has arrived, we claim nothing. */
	function matchesSelected( s ) {
		return !! ( sel && sel.hits && sel.hits.indexOf( s ) !== -1 );
	}

	/* «This change is not visible»: the one thing the panel cannot afford to keep quiet about. The
	   value is in the code, but it never reaches the screen — and without saying so the user goes on
	   turning the knob believing it is broken. */
	function renderWeak() {
		var weak = ( sel && sel.weak ) || {};
		var losing = weak.losing || [], invalid = weak.invalid || [];
		if ( ! losing.length && ! invalid.length ) { return null; }

		var w = el( 'div', 'aipb-sb-weak' );

		if ( invalid.length ) {
			w.appendChild( el( 'div', 'aipb-sb-weakline',
				sprintf( __( 'Invalid value: %s. The browser ignores it.' ), invalid.join( ', ' ) ) ) );
		}

		if ( losing.length ) {
			var who = winnerFor( losing[ 0 ] );
			var line = el( 'div', 'aipb-sb-weakline' );
			line.appendChild( el( 'span', '', sprintf( __( 'It does not reach the screen: %s.' ), losing.join( ', ' ) ) ) );
			line.appendChild( el( 'span', 'aipb-sb-weakwho', who
				? sprintf( __( ' «%s» wins, because it is stronger.' ), who.sel )
				: __( ' Another, stronger rule wins.' ) ) );
			w.appendChild( line );

			var fix = el( 'button', 'aipb-sb-weakfix', __( 'Move onto a more precise selector' ) );
			fix.type = 'button';
			fix.title = __( 'It rewrites only these properties onto a selector that wins, leaving the others where they are.' );
			fix.addEventListener( 'click', moveWeakProps );
			w.appendChild( fix );
		}
		return w;
	}

	/* ====================================================================== */
	/* Hooking up to the design system.                                       */
	/* ====================================================================== */

	/* Which token groups make sense for a property. Without this table a `font-size`'s `24px` would
	   be hooked to a spacing, and the CSS would say something false while rendering the same. */
	var TOKEN_FOR = [
		{ re: /(^|-)color$|^background$|^border$|^outline$|^fill$|^stroke$/, bucket: 'color' },
		{ re: /^font-family$/, bucket: 'font' },
		{ re: /^font-size$/, bucket: 'text' },
		{ re: /^(padding|margin|gap|row-gap|column-gap|inset|top|right|bottom|left)(-|$)/, bucket: 'space' },
		{ re: /border.*radius$/, bucket: 'radius' },
		{ re: /^box-shadow$/, bucket: 'shadow' },
		{ re: /^(width|max-width|min-width)$/, bucket: 'width' },
	];
	function bucketFor( prop ) {
		for ( var i = 0; i < TOKEN_FOR.length; i++ ) {
			if ( TOKEN_FOR[ i ].re.test( prop ) ) { return TOKEN_FOR[ i ].bucket; }
		}
		return '';
	}

	/* Are two values «the same value»? For colours the browser decides (`#0B1020` and
	   `rgb(11, 16, 32)` are the same colour, written by two different people); for the rest a
	   comparison up to whitespace and case is enough. No conversions between units: `1.5rem` and
	   `24px` only coincide as long as the root is 16px, and hooking them would be a bet. */
	function sameValue( a, b, bucket ) {
		a = String( a ).trim();
		b = String( b ).trim();
		if ( bucket === 'color' ) {
			var ca = resolveColor( a ), cb = resolveColor( b );
			return !! ( ca && cb && rgbaCss( ca ) === rgbaCss( cb ) );
		}
		return a.replace( /\s+/g, ' ' ).toLowerCase() === b.replace( /\s+/g, ' ' ).toLowerCase();
	}

	/* Sweeps the section's CSS and finds the literal values a token already says. It returns the
	   pieces of text to replace, with their offsets: the substitution is surgical like everything
	   else, and the sheet is not reformatted. */
	function tokenizeScan( css ) {
		var out = [];
		var byBucket = tokens();

		( function walk( nodes, media ) {
			nodes.forEach( function ( n ) {
				if ( n.type === 'at' && n.name === 'media' ) { walk( n.nodes, n.prelude ); return; }
				if ( n.type !== 'rule' || n.nested ) { return; }
				n.decls.forEach( function ( d ) {
					var bucket = bucketFor( d.prop );
					var list = bucket ? ( byBucket[ bucket ] || [] ) : [];
					if ( ! list.length || /var\(/.test( d.value ) ) { return; }

					// The value can be compound (`padding: 72px 24px`): it is hooked part by
					// part, and the parts that do not match stay as they are.
					var parts = splitValue( d.value, d.valueStart );
					parts.forEach( function ( part ) {
						list.forEach( function ( t ) {
							if ( ! sameValue( part.text, t.value, bucket ) ) { return; }
							out.push( {
								sel: n.sel, media: media, prop: d.prop,
								from: part.start, to: part.end,
								was: part.text, now: t.css, token: t,
							} );
						} );
					} );
				} );
			} );
		} )( cssParse( css, 0 ), '' );

		// From the end: replacing forwards would shift the following offsets.
		return out.sort( function ( a, b ) { return b.from - a.from; } );
	}

	/* Splits a value into its top-level parts, keeping the absolute offsets. */
	function splitValue( value, base ) {
		var out = [], depth = 0, start = -1, i;
		for ( i = 0; i <= value.length; i++ ) {
			var c = i < value.length ? value.charAt( i ) : ' ';
			if ( c === '(' ) { depth++; }
			else if ( c === ')' ) { depth = Math.max( 0, depth - 1 ); }
			var sep = ! depth && ( /\s/.test( c ) || c === ',' );
			if ( sep ) {
				if ( start !== -1 ) {
					out.push( { text: value.slice( start, i ), start: base + start, end: base + i } );
					start = -1;
				}
			} else if ( start === -1 ) { start = i; }
		}
		// A value that does not split (a shadow, a font stack) has to be taken whole.
		if ( out.length > 1 ) { out.push( { text: value, start: base, end: base + value.length } ); }
		else if ( ! out.length ) { out.push( { text: value, start: base, end: base + value.length } ); }
		return out;
	}

	/* Applies the chosen substitutions. They are already ordered from the end; it discards the ones
	   overlapping one already applied (a whole value and a part of it can both match: the first
	   wins, that is the rightmost one). */
	function tokenizeApply( css, list ) {
		var done = [];
		list.forEach( function ( c ) {
			var clash = done.some( function ( d ) { return c.from < d.to && c.to > d.from; } );
			if ( clash ) { return; }
			done.push( c );
			css = css.slice( 0, c.from ) + c.now + css.slice( c.to );
		} );
		return { css: css, applied: done.length };
	}

	/* The window: what would change, line by line, before touching anything. A button that rewrites
	   forty values without showing which ones is a button you press once. */
	function openTokenize( section ) {
		// Normally it acts on the section the Style panel points at. But the hooking-up is needed
		// above all RIGHT AFTER a generation, when the panel is not even open and nothing is
		// selected in the preview: there the section arrives from outside.
		var t = section ? { sec: section, name: section.name } : target();
		if ( t.err ) { toast( t.err, true ); return; }

		var parts = S.splitTemplate( t.sec.template );
		var found = tokenizeScan( parts.css );
		if ( ! found.length ) {
			toast( __( 'No literal value matches a design system token.' ) );
			return;
		}

		var chosen = {};
		found.forEach( function ( c, i ) { chosen[ i ] = true; } );

		var back = el( 'div', 'aipb-cp-back' );
		var box = el( 'div', 'aipb-cp aipb-tz' );

		var head = el( 'div', 'aipb-cp-head' );
		head.appendChild( el( 'span', 'aipb-cp-title', sprintf(
			__( 'Hook up to the design system — %s' ), t.name || __( 'this section' ) ) ) );
		var x = el( 'button', 'aipb-cp-x', '×' );
		x.type = 'button';
		head.appendChild( x );
		box.appendChild( head );

		var body = el( 'div', 'aipb-cp-body' );
		body.appendChild( el( 'p', 'aipb-tz-lead', sprintf(
			_n( '%d hand-written value already says what a token says. Replace it, and from here on it will follow the brand.',
				'%d hand-written values already say what the tokens say. Replace them, and from here on they will follow the brand.',
				found.length ), found.length ) ) );

		var count = el( 'div', 'aipb-tz-count' );

		var list = el( 'div', 'aipb-tz-list' );
		found.forEach( function ( c, i ) {
			var r = el( 'label', 'aipb-tz-row' );
			var cb = el( 'input' );
			cb.type = 'checkbox';
			cb.checked = true;
			cb.addEventListener( 'change', function () {
				chosen[ i ] = cb.checked;
				refreshCount();
			} );
			r.appendChild( cb );
			var mid = el( 'span', 'aipb-tz-mid' );
			var top = el( 'span', 'aipb-tz-where' );
			top.appendChild( el( 'code', '', c.sel ) );
			top.appendChild( el( 'span', 'aipb-tz-prop', c.prop ) );
			if ( c.media ) { top.appendChild( el( 'span', 'aipb-tz-media', c.media.replace( /^@media\s*/, '' ) ) ); }
			mid.appendChild( top );
			var swap = el( 'span', 'aipb-tz-swap' );
			if ( c.token.bucket === 'color' ) { swap.appendChild( paintSwatch( el( 'span', 'aipb-sb-swatch' ), c.was ) ); }
			swap.appendChild( el( 'code', 'was', c.was ) );
			swap.appendChild( el( 'span', 'arr', '→' ) );
			swap.appendChild( el( 'code', 'now', c.now ) );
			mid.appendChild( swap );
			r.appendChild( mid );
			list.appendChild( r );
		} );

		body.appendChild( count );
		body.appendChild( list );
		box.appendChild( body );

		var foot = el( 'div', 'aipb-cp-foot' );
		var none = el( 'button', 'aipb-cp-btn', __( 'None' ) );
		none.type = 'button';
		none.addEventListener( 'click', function () { setAll( false ); } );
		var all = el( 'button', 'aipb-cp-btn', __( 'All' ) );
		all.type = 'button';
		all.addEventListener( 'click', function () { setAll( true ); } );
		var cancel = el( 'button', 'aipb-cp-btn', __( 'Cancel' ) );
		cancel.type = 'button';
		var ok = el( 'button', 'aipb-cp-btn primary', __( 'Hook up' ) );
		ok.type = 'button';
		foot.appendChild( none );
		foot.appendChild( all );
		foot.appendChild( el( 'span', 'aipb-cp-spacer' ) );
		foot.appendChild( cancel );
		foot.appendChild( ok );
		box.appendChild( foot );

		back.appendChild( box );
		( dom.studio || document.body ).appendChild( back );

		function setAll( v ) {
			found.forEach( function ( c, i ) { chosen[ i ] = v; } );
			[].forEach.call( list.querySelectorAll( 'input' ), function ( cb ) { cb.checked = v; } );
			refreshCount();
		}
		function picked() {
			return found.filter( function ( c, i ) { return chosen[ i ]; } );
		}
		function refreshCount() {
			var n = picked().length;
			count.textContent = sprintf( __( '%1$d of %2$d selected' ), n, found.length );
			ok.disabled = ! n;
		}
		refreshCount();

		function close() {
			document.removeEventListener( 'keydown', onKey );
			back.remove();
		}
		function onKey( e ) { if ( e.key === 'Escape' ) { e.stopPropagation(); close(); } }
		document.addEventListener( 'keydown', onKey );
		cancel.addEventListener( 'click', close );
		x.addEventListener( 'click', close );
		back.addEventListener( 'mousedown', function ( e ) { if ( e.target === back ) { close(); } } );

		ok.addEventListener( 'click', function () {
			var res = tokenizeApply( parts.css, picked() );
			applyCss( t.sec, res.css );
			state.dirty = true;
			S.refreshSaveState();
			S.refreshCodeIfOpen();
			S.schedulePreview( 0 );
			S.commitHistory();
			render();
			close();
			toast( sprintf( _n( '%d value hooked up to the design system. Undo with ⌘Z.',
				'%d values hooked up to the design system. Undo with ⌘Z.', res.applied ), res.applied ) );
		} );
	}

	var styleClip = null; // { from: selector, decls: {prop: value} }

	/* Pasting in one go: a single write into the template instead of N, otherwise every declaration
	   would cause a redraw, a preview and a history entry of its own. */
	function pasteStyle() {
		var t = target();
		if ( t.err || ! styleClip ) { toast( t.err || __( 'Nothing to paste.' ), true ); return; }
		var s = fullSel(), media = mediaNow();
		if ( ! s ) { return; }

		var parts = S.splitTemplate( t.sec.template ), css = parts.css, n = 0;
		Object.keys( styleClip.decls ).forEach( function ( p ) {
			css = cssWrite( css, s, media, p, styleClip.decls[ p ] );
			n++;
		} );
		applyCss( t.sec, css );
		state.dirty = true;
		S.refreshSaveState();

		applyLive();
		render();
		askMatches();
		commitSoon();
		toast( sprintf( __( 'Pasted %1$d declarations onto «%2$s». Undo with ⌘Z.' ), n, s ) );
	}

	function renderFoot() {
		var w = el( 'div', 'aipb-sb-foot' );
		var n = Object.keys( vals ).length;

		// How many of these declarations are linked to the design system: it is the rule's health in
		// one line. Zero out of eight, on a section that ought to follow the brand, shows.
		var bound = Object.keys( vals ).filter( function ( p ) {
			if ( tokenOf( vals[ p ] ) ) { return true; }
			// A shorthand counts if at least one of its parts is a token.
			var parts = SHORTHANDS[ p ] ? expandShorthand( vals[ p ] ) : null;
			return !! ( parts && parts.some( tokenOf ) );
		} );
		if ( bound.length ) {
			var t = el( 'div', 'aipb-sb-bound' );
			t.appendChild( el( 'span', 'aipb-sb-tokdia', '◆' ) );
			t.appendChild( el( 'span', '', sprintf(
				__( '%1$d of %2$d values come from the design system' ), bound.length, n ) ) );
			t.title = bound.join( ', ' );
			w.appendChild( t );
		}

		/* Copy and paste style: the declarations are already read and written from here, and
		   repeating the same eight values by hand on the third button is the kind of work a machine
		   does better. The clipboard lives as long as the Studio is open and crosses sections. */
		var clip = el( 'div', 'aipb-sb-clip' );
		var copy = el( 'button', 'aipb-sb-clipbtn', __( 'Copy style' ) );
		copy.type = 'button';
		copy.disabled = ! n;
		copy.title = n ? sprintf( __( 'Copy this selector\'s %d declarations' ), n ) : __( 'There is nothing to copy' );
		copy.addEventListener( 'click', function () {
			styleClip = { from: fullSel(), decls: JSON.parse( JSON.stringify( vals ) ) };
			render();
			toast( sprintf( __( 'Copied %d declarations from «%s».' ), n, styleClip.from ) );
		} );
		clip.appendChild( copy );

		if ( styleClip ) {
			var cn = Object.keys( styleClip.decls ).length;
			var paste = el( 'button', 'aipb-sb-clipbtn', sprintf( __( 'Paste (%d)' ), cn ) );
			paste.type = 'button';
			paste.title = sprintf( __( 'Writes here the declarations copied from «%s»' ), styleClip.from );
			paste.addEventListener( 'click', pasteStyle );
			clip.appendChild( paste );
		}
		w.appendChild( clip );

		// Hook up to the design system: it acts on the WHOLE section, not on the current selector.
		// The counting happens here, at display time, because that is what makes the offer
		// believable: a button that does not say how many there are does not get pressed.
		var tz = target();
		if ( ! tz.err ) {
			var candidates = tokenizeScan( S.splitTemplate( tz.sec.template ).css );
			if ( candidates.length ) {
				var tzb = el( 'button', 'aipb-sb-tokenize' );
				tzb.type = 'button';
				tzb.appendChild( el( 'span', 'aipb-sb-tokdia', '◆' ) );
				tzb.appendChild( el( 'span', '', sprintf(
					_n( 'Hook 1 value up to the design system', 'Hook %d values up to the design system', candidates.length ),
					candidates.length ) ) );
				tzb.title = __( 'It looks through the whole section for hand-written values a token already says, and replaces them (after confirmation).' );
				tzb.addEventListener( 'click', function () { openTokenize(); } );
				w.appendChild( tzb );
			}
		}

		if ( ! n ) {
			w.appendChild( el( 'span', 'aipb-sb-hint', __( 'No rule written here: the values shown are the computed ones.' ) ) );
			return w;
		}
		var b = el( 'button', 'aipb-sb-clear', sprintf( __( 'Delete the rule (%d declarations)' ), n ) );
		b.type = 'button';
		b.addEventListener( 'click', function () {
			if ( ! window.confirm( sprintf( __( 'Delete all %1$d declarations of «%2$s»? The AI-written ones too.' ), n, fullSel() ) ) ) { return; }
			Object.keys( vals ).forEach( function ( p ) { setProp( p, '' ); } );
		} );
		w.appendChild( b );
		return w;
	}

	/* ====================================================================== */
	/* 9. Opening, messages, lifecycle.                                       */
	/* ====================================================================== */

	function refresh() {
		render();
		applyLive();
		askMatches();
	}

	/**
	 * The tab has been entered or left.
	 *
	 * Called by studio.js's setTab() and by nobody else: whoever wants to open the panel asks for
	 * the TAB (`S.setTab('style')`), so there is one route in and no way of ending up with the panel
	 * shown and pick mode off, or the other way round.
	 *
	 * Leaving switches off pick mode in the preview and throws away the live rule: with the panel
	 * out of sight, a crosshair over the preview and a CSS injection nobody can see any more are two
	 * ways of making the Studio look broken.
	 */
	function panelShown( on ) {
		on = !! on;
		if ( on === open ) { return; }
		open = on;

		if ( ! dom.styleBar ) { build(); }

		post( { aipb: 'stylemode', on: open } );

		if ( open ) {
			render();
			if ( ! sel ) { toast( __( 'Click an element in the preview.' ) ); }
		} else {
			post( { aipb: 'stylelive', css: '' } );
			closePop();
		}
	}

	window.addEventListener( 'message', function ( e ) {
		var d = e.data;
		if ( ! d || ! d.aipb ) { return; }

		if ( d.aipb === 'stylepick' ) {
			var first = ! sel || sel.blockId !== d.blockId || String( sel.classes ) !== String( d.classes );
			var keep = sel && ! first ? { weak: sel.weak, hits: sel.hits } : {};
			sel = d;
			sel.weak = keep.weak;
			sel.hits = keep.hits;
			if ( first ) { scopeTouched = false; selPrefix = ''; pickDefault(); }
			render();
			applyLive();
			askMatches();
			return;
		}
		if ( d.aipb === 'stylecomputed' && sel ) {
			sel.computed = d.computed;
			// We only redraw if WHICH declaration fails to reach the screen has changed: this
			// message arrives on every touch-up, and one render each would make the fields flicker
			// while typing.
			var was = JSON.stringify( sel.weak || {} );
			sel.weak = d.weak;
			if ( JSON.stringify( d.weak || {} ) !== was ) { render(); }
			return;
		}
		if ( d.aipb === 'styleoverflowed' ) { showOverflow( d.items ); return; }
		if ( d.aipb === 'stylematched' && sel ) {
			var before = String( sel.hits || '' );
			sel.hits = d.hits || [];
			if ( String( sel.hits ) !== before ) { render(); }
			return;
		}
		if ( d.aipb === 'stylelost' ) {
			// The element no longer exists after the re-render (the AI redid the section).
			sel = null;
			render();
		}
	} );

	/* After every preview reload: switch the mode back on and find the element again. */
	function onFrameLoad() {
		checkOverflow();
		if ( ! open ) { return; }
		post( { aipb: 'stylemode', on: true } );
		if ( sel ) {
			post( { aipb: 'stylesel', path: sel.path, sel: baseSel(), scopeId: sel.scopeId } );
		}
	}

	/* ====================================================================== */
	/* Horizontal overflow: the defect you discover after publishing.         */
	/* ====================================================================== */

	/* The question only makes sense where the screen is narrow: on desktop the preview is as wide as
	   the window, and «it goes off the screen» means nothing. */
	function checkOverflow() {
		if ( state.viewport === 'desktop' ) { showOverflow( [] ); return; }
		// The preview has just reloaded: the images can still shift the widths.
		clearTimeout( checkOverflow.t );
		checkOverflow.t = setTimeout( function () { post( { aipb: 'styleoverflow' } ); }, 350 );
	}

	function showOverflow( items ) {
		if ( ! dom.ovBar ) {
			dom.ovBar = el( 'div', 'aipb-ovbar' );
			dom.ovBar.style.display = 'none';
			if ( dom.previewWrap ) { dom.previewWrap.appendChild( dom.ovBar ); }
		}
		var bar = dom.ovBar;
		bar.innerHTML = '';
		if ( ! items || ! items.length ) { bar.style.display = 'none'; return; }
		bar.style.display = 'flex';

		bar.appendChild( el( 'span', 'aipb-ovico', '⚠' ) );
		bar.appendChild( el( 'span', 'aipb-ovlbl', _n( 'Goes off the screen:', 'Go off the screen:', items.length ) ) );

		items.forEach( function ( it ) {
			var b = el( 'button', 'aipb-ovitem' );
			b.type = 'button';
			b.appendChild( el( 'code', '', it.label ) );
			b.appendChild( el( 'span', 'px', '+' + it.over + 'px' ) );
			b.title = __( 'Highlight it in the preview and open it in the Style panel' );
			b.addEventListener( 'mouseenter', function () { post( { aipb: 'stylehover', path: it.path } ); } );
			b.addEventListener( 'mouseleave', function () { post( { aipb: 'stylehover' } ); } );
			b.addEventListener( 'click', function () {
				// The tab is the way in: asking for the panel and asking for pick mode are the same
				// request, and studio.js answers both.
				S.setTab( 'style' );
				post( { aipb: 'stylehover', path: it.path, keep: true } );
				post( { aipb: 'stylesel', path: it.path } );
			} );
			bar.appendChild( b );
		} );
	}

	function mount() {
		build();

		// There is no button in the top bar any more: the way in is the tab in the left column,
		// with the other panels. One control in one place beats two that have to agree.
		dom.frame.addEventListener( 'load', onFrameLoad );

		// The responsive switch changes the writing context: the panel has to re-read it.
		if ( dom.vpSwitch ) {
			dom.vpSwitch.addEventListener( 'click', function () {
				setTimeout( function () { refresh(); checkOverflow(); }, 0 );
			} );
		}

		document.addEventListener( 'keydown', function ( e ) {
			if ( e.key !== 'Escape' || ! open ) { return; }
			var t = e.target;
			if ( t && ( t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ) ) { return; }
			if ( openPop ) { closePop(); return; }
			S.setTab( 'chat' );
		} );

		render();
		checkOverflow();
	}

	/* The preview has been updated WITHOUT reloading (see patchPreview in studio.js): the document is
	   the same as before, so the agent is still alive and the mode is already active — there is
	   nothing to restart. All that is needed is to put back what depends on the content: if a
	   section has been replaced, the selected element was thrown away with it and has to be found
	   again; if only the CSS changed, the selection has not moved a pixel. */
	function previewPatched( replaced ) {
		checkOverflow();
		if ( ! open ) { return; }
		askMatches();
		if ( replaced && sel ) {
			post( { aipb: 'stylesel', path: sel.path, sel: baseSel(), scopeId: sel.scopeId } );
		}
	}

	window.AIPBStyle = {
		previewJs: previewJs,
		active: function () { return open; },
		panelShown: panelShown,
		forgetTokens: forgetTokens,
		previewPatched: previewPatched,

		/* How many hand-written values, in THIS template, a design system token already says. Zero
		   is not a defect: on a site with no tokens defined it is the right answer, because there
		   is nothing there to hook onto. */
		tokenizeCount: function ( template ) {
			try { return tokenizeScan( S.splitTemplate( String( template || '' ) ).css ).length; }
			catch ( e ) { return 0; }
		},

		/* Hooks up EVERY literal a token already says, with no window and no question, and returns
		   how many changed.

		   WHY IT MAY BE DONE WITHOUT ASKING, when {@see openTokenize} asks about each one. What is
		   substituted here is only a value IDENTICAL to the token's (tokenizeScan compares with
		   sameValue and skips anything already on a var), and every substitution carries its
		   fallback — `24px` becomes `var(--aipb-space-lg, 24px)`. So today it renders the same
		   pixel, and it diverges only the day somebody edits that token, which is the whole reason
		   for having tokens. There is nothing here for a person to decide.

		   The window stays for the deliberate gesture from the Style panel, where the section is old,
		   somebody is working on it by hand, and seeing what would change is the point. */
		tokenizeAll: function ( section ) {
			try {
				if ( ! section || ! section.template ) { return 0; }
				var parts = S.splitTemplate( section.template );
				var found = tokenizeScan( parts.css );
				if ( ! found.length ) { return 0; }
				var res = tokenizeApply( parts.css, found );
				applyCss( section, res.css );
				render();

				return res.applied;
			} catch ( e ) { return 0; }
		},
		openTokenize: openTokenize,
	};

	mount();
} )();
