/* Aura — the visual palette editor (Brand page).
   The user adds colours freely and names them however they like. No predefined roles: the AI
   decides where to apply them. The fields are serialised as
   aipb_brand_palette[i][name|value|slug] and saved through options.php. */

// i18n shared by the two editors (palette + fonts). An alias to wp.i18n (the 'wp-i18n' dependency),
// with a fallback.
var _aipbI = ( window.wp && window.wp.i18n ) || {};
var __ = _aipbI.__ ? function ( s ) { return _aipbI.__( s, 'aipb' ); } : function ( s ) { return s; };
// The fallback has to understand POSITIONAL placeholders too (%1$s, %2$d). Several strings on this
// page use them — a translator needs to be able to reorder the arguments — and a fallback that only
// knew %s printed the placeholder itself on screen.
var _aipbSprintf = _aipbI.sprintf || function ( f ) {
	var a = arguments, i = 0;

	return String( f ).replace( /%(?:(\d+)\$)?([sd])/g, function ( _, pos ) {
		return pos ? a[ parseInt( pos, 10 ) ] : a[ ++i ];
	} );
};

( function () {
	'use strict';

	var cfg = window.AIPB_BRAND || {};
	var OPT = cfg.option || 'aipb_brand_palette';
	var root = document.getElementById( 'aipb-palette' );
	if ( ! root ) { return; }

	// The model: an ordered list of colours. An empty slug is derived on the server.
	var colors = Array.isArray( cfg.palette )
		? cfg.palette.map( function ( c ) {
			return { slug: c.slug || '', name: c.name || '', value: c.value || '#000000' };
		} )
		: [];

	// Pleasant starting colours for new swatches (aesthetic only, no roles).
	var STARTERS = [ '#6d5efc', '#0ea5e9', '#f97316', '#10b981', '#ec4899', '#f59e0b', '#8b5cf6', '#ef4444' ];

	var dragFrom = null;

	function normHex( s ) {
		s = ( s || '' ).trim().replace( /^#/, '' );
		if ( /^[0-9a-fA-F]{3}$/.test( s ) ) {
			s = s.split( '' ).map( function ( x ) { return x + x; } ).join( '' );
		}
		if ( /^[0-9a-fA-F]{6}$/.test( s ) || /^[0-9a-fA-F]{8}$/.test( s ) ) {
			return '#' + s.toLowerCase();
		}
		return null;
	}

	function el( tag, cls ) {
		var n = document.createElement( tag );
		if ( cls ) { n.className = cls; }
		return n;
	}

	function setColor( card, val ) {
		card.style.setProperty( '--c', val );
	}

	function card( c, i ) {
		var wrap = el( 'div', 'aipb-swatch-card' );
		wrap.dataset.index = i;
		var hex = normHex( c.value );
		setColor( wrap, hex || c.value );

		// --- the colour area + the native picker ---
		var swatch = el( 'div', 'aipb-swatch' );
		var drag = el( 'span', 'aipb-drag' );
		drag.textContent = '⠿';
		drag.title = __( 'Drag to reorder' );
		var hint = el( 'span', 'aipb-pick-hint' );
		hint.textContent = __( 'Pick a colour' );
		var picker = el( 'input' );
		picker.type = 'color';
		picker.value = hex || '#000000';
		swatch.appendChild( drag );
		swatch.appendChild( hint );
		swatch.appendChild( picker );

		// --- the delete button ---
		var del = el( 'button', 'aipb-del' );
		del.type = 'button';
		del.setAttribute( 'aria-label', __( 'Remove colour' ) );
		del.textContent = '×';

		// --- the name + hex fields ---
		var fields = el( 'div', 'aipb-fields' );

		var name = el( 'input', 'aipb-name' );
		name.type = 'text';
		name.placeholder = __( 'Colour name (e.g. Coral)' );
		name.value = c.name;
		name.name = OPT + '[' + i + '][name]';

		var hexrow = el( 'div', 'aipb-hexrow' );
		var dot = el( 'span', 'aipb-dot' );
		var hexInput = el( 'input', 'aipb-hex' );
		hexInput.type = 'text';
		hexInput.spellcheck = false;
		hexInput.value = c.value;
		hexInput.name = OPT + '[' + i + '][value]';
		hexrow.appendChild( dot );
		hexrow.appendChild( hexInput );

		var slug = el( 'input' );
		slug.type = 'hidden';
		slug.value = c.slug;
		slug.name = OPT + '[' + i + '][slug]';

		fields.appendChild( name );
		fields.appendChild( hexrow );

		wrap.appendChild( swatch );
		wrap.appendChild( del );
		wrap.appendChild( fields );
		wrap.appendChild( slug );

		// --- events ---
		picker.addEventListener( 'input', function () {
			c.value = picker.value;
			hexInput.value = picker.value;
			setColor( wrap, picker.value );
		} );

		hexInput.addEventListener( 'input', function () {
			var n = normHex( hexInput.value );
			c.value = hexInput.value;
			if ( n ) { picker.value = n; }
			setColor( wrap, n || hexInput.value );
		} );
		hexInput.addEventListener( 'blur', function () {
			var n = normHex( hexInput.value );
			if ( n ) { hexInput.value = n; c.value = n; }
		} );

		name.addEventListener( 'input', function () { c.name = name.value; } );

		del.addEventListener( 'click', function () {
			colors.splice( i, 1 );
			render();
		} );

		// --- drag & drop to reorder (it only starts from the handle) ---
		drag.addEventListener( 'mousedown', function () { wrap.draggable = true; } );
		drag.addEventListener( 'touchstart', function () { wrap.draggable = true; }, { passive: true } );
		wrap.addEventListener( 'dragstart', function ( e ) {
			dragFrom = i;
			wrap.classList.add( 'is-dragging' );
			if ( e.dataTransfer ) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData( 'text/plain', String( i ) ); }
		} );
		wrap.addEventListener( 'dragend', function () {
			wrap.draggable = false;
			dragFrom = null;
			clearOver();
			wrap.classList.remove( 'is-dragging' );
		} );

		return wrap;
	}

	function clearOver() {
		var over = root.querySelectorAll( '.is-over' );
		for ( var k = 0; k < over.length; k++ ) { over[ k ].classList.remove( 'is-over' ); }
	}

	function addCard() {
		var add = el( 'button', 'aipb-add-card' );
		add.type = 'button';
		var plus = el( 'span', 'aipb-plus' );
		plus.textContent = '+';
		var label = document.createElement( 'span' );
		label.textContent = __( 'Add colour' );
		add.appendChild( plus );
		add.appendChild( label );
		add.addEventListener( 'click', addColor );
		return add;
	}

	function emptyState() {
		var box = el( 'div', 'aipb-empty' );
		var sw = el( 'div', 'aipb-empty-swatches' );
		[ '#6d5efc', '#0ea5e9', '#f97316' ].forEach( function ( hexv ) {
			var s = document.createElement( 'span' );
			s.style.background = hexv;
			sw.appendChild( s );
		} );
		var h = document.createElement( 'h3' );
		h.textContent = __( 'Your palette is empty' );
		var p = document.createElement( 'p' );
		p.textContent = __( 'Add your brand\'s colours — three is fine — and name them however you like. The AI will work out where to apply them.' );
		var btn = el( 'button', 'button button-primary' );
		btn.type = 'button';
		btn.textContent = __( '+ Add the first colour' );
		btn.addEventListener( 'click', addColor );
		box.appendChild( sw );
		box.appendChild( h );
		box.appendChild( p );
		box.appendChild( btn );
		return box;
	}

	function addColor() {
		var start = STARTERS[ colors.length % STARTERS.length ];
		colors.push( { slug: '', name: '', value: start } );
		render();
		// Focus the name of the colour just added.
		var cards = root.querySelectorAll( '.aipb-swatch-card .aipb-name' );
		if ( cards.length ) { cards[ cards.length - 1 ].focus(); }
	}

	function render() {
		root.innerHTML = '';
		if ( ! colors.length ) {
			root.appendChild( emptyState() );
			return;
		}
		colors.forEach( function ( c, i ) { root.appendChild( card( c, i ) ); } );
		root.appendChild( addCard() );
	}

	// Reordering: dragover highlights the target, drop moves it in the model.
	root.addEventListener( 'dragover', function ( e ) {
		if ( dragFrom === null ) { return; }
		e.preventDefault();
		var over = e.target.closest ? e.target.closest( '.aipb-swatch-card' ) : null;
		clearOver();
		if ( over && Number( over.dataset.index ) !== dragFrom ) { over.classList.add( 'is-over' ); }
	} );
	root.addEventListener( 'drop', function ( e ) {
		if ( dragFrom === null ) { return; }
		e.preventDefault();
		var over = e.target.closest ? e.target.closest( '.aipb-swatch-card' ) : null;
		if ( over ) {
			var to = Number( over.dataset.index );
			if ( ! isNaN( to ) && to !== dragFrom ) {
				var moved = colors.splice( dragFrom, 1 )[ 0 ];
				colors.splice( to, 0, moved );
			}
		}
		dragFrom = null;
		render();
	} );

	// The "+ Add colour" button in the panel's heading.
	var headBtn = document.getElementById( 'aipb-add-color' );
	if ( headBtn ) { headBtn.addEventListener( 'click', addColor ); }

	// The channel for the mockup import: the palette's model lives in here, so the importer does
	// not touch it — it asks us to replace it and redraw.
	window.AIPB_BRAND_API = window.AIPB_BRAND_API || {};
	window.AIPB_BRAND_API.setPalette = function ( list ) {
		if ( ! Array.isArray( list ) ) { return 0; }
		colors = list.map( function ( c ) {
			return { slug: c.slug || '', name: c.name || '', value: normHex( c.value ) || '#000000' };
		} );
		render();
		notify();
		return colors.length;
	};
	window.AIPB_BRAND_API.getPalette = function () { return colors.slice(); };

	// The specimen, the contrast matrix and the save bar all want to know when a colour moved.
	// The model is in here, so the event has to leave from here.
	function notify() {
		document.dispatchEvent( new CustomEvent( 'aipb:brand-change', { detail: { what: 'palette' } } ) );
	}
	root.addEventListener( 'input', notify );

	var _render = render;
	render = function () { _render(); notify(); };

	render();
} )();


/* ------------------------------- Typography ------------------------------- */
/* The fonts editor: a dynamic list like the palette. The user adds the fonts they want, picks the
   family from Google Fonts and the weights, and gives it a NAME that suggests its use (the AI works
   out where to apply it). Serialised as
   aipb_brand_fonts[i][slug|name|family|category][weights][]. */
( function () {
	'use strict';

	var cfg = window.AIPB_BRAND || {};
	var host = document.getElementById( 'aipb-fonts' );
	if ( ! host ) { return; }

	var OPTF    = cfg.fontsOption || 'aipb_brand_fonts';
	var CATALOG = cfg.fontCatalog || {};
	// Every font carries its `avail` (available weights) with it, so the chips work both for the
	// catalogue's fonts and for the ones installed freely (no longer filtered against the catalogue).
	var fonts   = ( Array.isArray( cfg.fonts ) ? cfg.fonts : [] )
		.filter( function ( f ) { return f && f.family; } )
		.map( function ( f ) {
			var w     = ( f.weights || [] ).map( Number ).filter( Boolean );
			var inCat = !! CATALOG[ f.family ];
			var avail = inCat ? CATALOG[ f.family ].weights.slice() : ( w.length ? w.slice() : [ 400 ] );
			return {
				slug: f.slug || '',
				name: f.name || '',
				family: f.family,
				category: inCat ? CATALOG[ f.family ].category : ( f.category || 'sans-serif' ),
				avail: avail,
				weights: w.length ? w : defaultWeights( avail )
			};
		} );

	// Starting families for new fonts (a hint only: the user changes them at will).
	var STARTERS = [ 'Inter', 'Playfair Display', 'Poppins', 'Lora', 'Space Grotesk', 'JetBrains Mono' ];
	var SAMPLE = __( 'Aa Bb — Branding starts here 123' );

	function el( tag, cls ) { var n = document.createElement( tag ); if ( cls ) { n.className = cls; } return n; }

	function defaultWeights( avail ) {
		var w = [];
		if ( avail.indexOf( 400 ) >= 0 ) { w.push( 400 ); }
		if ( avail.indexOf( 700 ) >= 0 ) { w.push( 700 ); }
		if ( ! w.length ) { w.push( avail[ 0 ] ); }
		return w;
	}

	function fallback( cat ) {
		if ( cat === 'serif' ) { return "Georgia, 'Times New Roman', serif"; }
		if ( cat === 'monospace' ) { return 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'; }
		return "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
	}

	function ensureFont( family, weights ) {
		var id = 'aipb-fontlink-' + family.toLowerCase().replace( /[^a-z0-9]+/g, '-' );
		var link = document.getElementById( id );
		if ( ! link ) { link = el( 'link' ); link.rel = 'stylesheet'; link.id = id; document.head.appendChild( link ); }
		var ws = weights.slice().sort( function ( a, b ) { return a - b; } );
		link.href = 'https://fonts.googleapis.com/css2?family=' + family.replace( / /g, '+' ) + ':wght@' + ws.join( ';' ) + '&display=swap';
	}

	// --- the family combobox (with search) ---
	var openCombos = [];
	function closeAllCombos() { openCombos.forEach( function ( c ) { if ( c._close ) { c._close(); } } ); }
	document.addEventListener( 'click', function ( e ) {
		if ( ! e.target.closest || ! e.target.closest( '.aipb-combo' ) ) { closeAllCombos(); }
	} );

	// "Best effort" title casing so the names match (css2 is case-sensitive): "jost" → "Jost".
	function titleCase( s ) {
		return String( s || '' ).replace( /\s+/g, ' ' ).trim().replace( /\S+/g, function ( w ) {
			return w.charAt( 0 ).toUpperCase() + w.slice( 1 ).toLowerCase();
		} );
	}

	function combobox( current, onSelect, onInstall ) {
		var combo = el( 'div', 'aipb-combo' );
		var btn = el( 'button', 'aipb-combo-btn' );
		btn.type = 'button';
		var lbl = el( 'span' );
		lbl.textContent = current || __( 'Choose a font…' );
		var car = el( 'i' ); car.textContent = '▾';
		btn.appendChild( lbl ); btn.appendChild( car );

		var pop = el( 'div', 'aipb-combo-pop' );
		pop.hidden = true;
		var search = el( 'input', 'aipb-combo-search' );
		search.type = 'text'; search.placeholder = __( 'Search, or paste the name of a Google font…' );
		var list = el( 'div', 'aipb-combo-list' );
		pop.appendChild( search ); pop.appendChild( list );

		var options = Object.keys( CATALOG ).sort( function ( a, b ) {
			var ca = CATALOG[ a ].category, cb = CATALOG[ b ].category;
			return ca === cb ? a.localeCompare( b ) : ca.localeCompare( cb );
		} );

		function paint( q ) {
			list.innerHTML = '';
			var raw = ( q || '' ).trim();
			var ql = raw.toLowerCase();
			var exact = false;
			options.forEach( function ( name ) {
				if ( ql && name.toLowerCase().indexOf( ql ) < 0 ) { return; }
				if ( name.toLowerCase() === ql ) { exact = true; }
				var it = el( 'div', 'aipb-combo-opt' );
				if ( name === current ) { it.className += ' is-sel'; }
				var nm = el( 'span' ); nm.textContent = name;
				var c = el( 'small' ); c.textContent = CATALOG[ name ].category;
				it.appendChild( nm ); it.appendChild( c );
				it.addEventListener( 'click', function () { close(); onSelect( name ); } );
				list.appendChild( it );
			} );
			// A font outside the catalogue: offer to install it from Google (checking its weights).
			if ( onInstall && raw && ! exact ) {
				var fam = titleCase( raw );
				var ins = el( 'div', 'aipb-combo-opt aipb-combo-install' );
				var nm2 = el( 'span' ); nm2.innerHTML = _aipbSprintf( __( '＋ Install «%s»' ), '<b>' + fam + '</b>' );
				var c2 = el( 'small' ); c2.textContent = __( 'from Google Fonts' );
				ins.appendChild( nm2 ); ins.appendChild( c2 );
				ins.addEventListener( 'click', function () { close(); onInstall( fam ); } );
				list.appendChild( ins );
			}
		}

		function open() { closeAllCombos(); pop.hidden = false; combo.classList.add( 'is-open' ); paint( '' ); search.value = ''; search.focus(); }
		function close() { pop.hidden = true; combo.classList.remove( 'is-open' ); }
		combo._close = close;

		btn.addEventListener( 'click', function () { pop.hidden ? open() : close(); } );
		search.addEventListener( 'input', function () { paint( search.value ); } );
		search.addEventListener( 'keydown', function ( e ) { if ( e.key === 'Escape' ) { close(); btn.focus(); } } );

		combo.appendChild( btn ); combo.appendChild( pop );
		openCombos.push( combo );
		return combo;
	}

	// --- a font's card ---
	function card( f, i ) {
		var wrap = el( 'div', 'aipb-font-card' );
		wrap.dataset.index = i;
		ensureFont( f.family, f.weights );

		var del = el( 'button', 'aipb-del' );
		del.type = 'button';
		del.setAttribute( 'aria-label', __( 'Remove font' ) );
		del.textContent = '×';
		del.addEventListener( 'click', function () { fonts.splice( i, 1 ); render(); } );

		// the row: name + combobox
		var top = el( 'div', 'aipb-font-top' );
		var name = el( 'input', 'aipb-font-name' );
		name.type = 'text';
		name.placeholder = __( 'Name (e.g. "Headings", "Body")' );
		name.value = f.name;
		name.name = OPTF + '[' + i + '][name]';
		name.addEventListener( 'input', function () { f.name = name.value; } );

		var combo = combobox( f.family, function ( family ) {
			f.family = family;
			f.category = CATALOG[ family ].category;
			f.avail = CATALOG[ family ].weights.slice();
			f.weights = defaultWeights( f.avail );
			render();
		}, function ( query ) {
			installFont( f, query );
		} );

		top.appendChild( name );
		top.appendChild( combo );
		wrap.appendChild( del );
		wrap.appendChild( top );

		// weights
		var wLabel = el( 'div', 'aipb-weights-label' );
		wLabel.textContent = __( 'Weights to import' );
		wrap.appendChild( wLabel );

		var chips = el( 'div', 'aipb-weights' );
		( f.avail || f.weights || [ 400 ] ).forEach( function ( w ) {
			var chip = el( 'button', 'aipb-chip' );
			chip.type = 'button';
			chip.textContent = w;
			chip.style.fontWeight = w;
			if ( f.weights.indexOf( w ) >= 0 ) { chip.className += ' is-on'; }
			chip.addEventListener( 'click', function () { toggleWeight( f, w ); } );
			chips.appendChild( chip );
		} );
		wrap.appendChild( chips );

		// preview
		var prev = el( 'div', 'aipb-font-preview' );
		prev.style.fontFamily = "'" + f.family + "', " + fallback( f.category );
		var big = el( 'div', 'aipb-font-sample' );
		big.textContent = SAMPLE;
		big.style.fontWeight = f.weights[ 0 ];
		prev.appendChild( big );
		var roww = el( 'div', 'aipb-font-weightrow' );
		f.weights.slice().sort( function ( a, b ) { return a - b; } ).forEach( function ( w ) {
			var t = el( 'span' ); t.textContent = w; t.style.fontWeight = w; roww.appendChild( t );
		} );
		prev.appendChild( roww );
		wrap.appendChild( prev );

		// hidden inputs for the submit
		var hidden = el( 'div' );
		hidden.style.display = 'none';
		addInput( hidden, OPTF + '[' + i + '][slug]', f.slug );
		addInput( hidden, OPTF + '[' + i + '][family]', f.family );
		addInput( hidden, OPTF + '[' + i + '][category]', f.category );
		f.weights.forEach( function ( w ) { addInput( hidden, OPTF + '[' + i + '][weights][]', String( w ) ); } );
		wrap.appendChild( hidden );

		return wrap;
	}

	function addInput( box, name, value ) {
		var inp = el( 'input' ); inp.type = 'hidden'; inp.name = name; inp.value = value; box.appendChild( inp );
	}

	function toggleWeight( f, w ) {
		var i = f.weights.indexOf( w );
		if ( i >= 0 ) {
			if ( f.weights.length === 1 ) { return; } // at least one weight
			f.weights.splice( i, 1 );
		} else {
			f.weights.push( w );
		}
		f.weights.sort( function ( a, b ) { return a - b; } );
		render();
	}

	// Installs a Google font from outside the catalogue: we check it on the server (that it exists,
	// and its real weights) so we do not generate broken css2 URLs, then we assign it to the card.
	var installing = false;
	function installFont( f, family ) {
		if ( installing ) { return; }
		if ( ! cfg.rest ) { window.alert( __( 'Font checking is not available.' ) ); return; }
		installing = true;
		showFontStatus( _aipbSprintf( __( 'Checking «%s» on Google Fonts…' ), family ) );
		window.fetch( cfg.rest + '/font-validate', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce || '' },
			body: JSON.stringify( { family: family } )
		} ).then( function ( r ) { return r.json(); } ).then( function ( res ) {
			installing = false; clearFontStatus();
			if ( res && res.ok && res.weights && res.weights.length ) {
				f.family = res.family || family;
				f.category = res.category || 'sans-serif';
				f.avail = res.weights.map( Number );
				f.weights = defaultWeights( f.avail );
				render();
			} else {
				window.alert( _aipbSprintf( __( 'Font «%s» not found on Google Fonts.\nCheck the EXACT name (capitals count, e.g. "Playfair Display", "Jost").' ), family ) );
			}
		} ).catch( function () {
			installing = false; clearFontStatus();
			window.alert( __( 'The font check failed (a network problem?).' ) );
		} );
	}

	function showFontStatus( msg ) {
		clearFontStatus();
		var s = el( 'div', 'aipb-font-status' );
		s.id = 'aipb-font-status';
		s.textContent = msg;
		host.insertBefore( s, host.firstChild );
	}
	function clearFontStatus() {
		var s = document.getElementById( 'aipb-font-status' );
		if ( s && s.parentNode ) { s.parentNode.removeChild( s ); }
	}

	function emptyState() {
		var box = el( 'div', 'aipb-empty' );
		var h = document.createElement( 'h3' );
		h.textContent = __( 'No font imported' );
		var p = document.createElement( 'p' );
		p.textContent = __( 'Add your brand\'s fonts from Google Fonts and give each one a name (e.g. "Headings", "Body"). The AI will work out where to apply them.' );
		var btn = el( 'button', 'button button-primary' );
		btn.type = 'button';
		btn.textContent = __( '+ Add the first font' );
		btn.addEventListener( 'click', addFont );
		box.appendChild( h ); box.appendChild( p ); box.appendChild( btn );
		return box;
	}

	function addFont() {
		var family = STARTERS[ fonts.length % STARTERS.length ];
		fonts.push( { slug: '', name: '', family: family, category: CATALOG[ family ].category, avail: CATALOG[ family ].weights.slice(), weights: defaultWeights( CATALOG[ family ].weights ) } );
		render();
		var names = host.querySelectorAll( '.aipb-font-name' );
		if ( names.length ) { names[ names.length - 1 ].focus(); }
	}

	function render() {
		host.innerHTML = '';
		openCombos = [];
		if ( ! fonts.length ) {
			host.appendChild( emptyState() );
			return;
		}
		fonts.forEach( function ( f, i ) { host.appendChild( card( f, i ) ); } );
	}

	var headBtn = document.getElementById( 'aipb-add-font' );
	if ( headBtn ) { headBtn.addEventListener( 'click', addFont ); }

	// The channel for the mockup import. The catalogue knows the available weights, the AI does not:
	// the suggested ones are intersected with the real ones, and a font with no valid weights falls
	// back on the family's defaults instead of ending up with a chip that does not exist.
	window.AIPB_BRAND_API = window.AIPB_BRAND_API || {};
	window.AIPB_BRAND_API.setFonts = function ( list ) {
		if ( ! Array.isArray( list ) ) { return 0; }
		fonts = list.filter( function ( f ) { return f && f.family; } ).map( function ( f ) {
			var inCat = !! CATALOG[ f.family ];
			var avail = inCat ? CATALOG[ f.family ].weights.slice() : ( ( f.weights || [ 400 ] ).map( Number ) );
			var want  = ( f.weights || [] ).map( Number ).filter( function ( w ) { return avail.indexOf( w ) >= 0; } );
			return {
				slug: f.slug || '',
				name: f.name || '',
				family: f.family,
				category: inCat ? CATALOG[ f.family ].category : ( f.category || 'sans-serif' ),
				avail: avail,
				weights: want.length ? want : defaultWeights( avail )
			};
		} );
		render();
		notify();
		return fonts.length;
	};
	window.AIPB_BRAND_API.getFonts = function () { return fonts.slice(); };
	// The specimen needs the real webfont in the admin page, not a name in a string.
	window.AIPB_BRAND_API.ensureFont = ensureFont;
	window.AIPB_BRAND_API.fallbackFor = fallback;

	function notify() {
		document.dispatchEvent( new CustomEvent( 'aipb:brand-change', { detail: { what: 'fonts' } } ) );
	}
	if ( host ) { host.addEventListener( 'input', notify ); }

	var _render = render;
	render = function () { _render(); notify(); };

	render();
} )();


/* --------------------- Importing tokens from a mockup --------------------- */
/* The AI is given 4-5 screenshots of a mockup and it MEASURES the design system: palette, fonts,
   type scale, spacing, radii, shadows, widths, plus an art direction brief. The result is NOT
   saved: it is poured into this page's form, where the user reviews it and then presses Save. That
   way every value goes through the same validation (and the same live preview) as the hand-written
   ones, and a wrong analysis is undone by reloading the page. */
( function () {
	'use strict';

	var cfg  = window.AIPB_BRAND || {};
	var drop = document.getElementById( 'aipb-imp-drop' );
	if ( ! drop || ! cfg.rest ) { return; }

	var fileInput = document.getElementById( 'aipb-imp-file' );
	var thumbs    = document.getElementById( 'aipb-imp-thumbs' );
	var notesIn   = document.getElementById( 'aipb-imp-notes' );
	var runBtn    = document.getElementById( 'aipb-imp-run' );
	var statusEl  = document.getElementById( 'aipb-imp-status' );
	var resultEl  = document.getElementById( 'aipb-imp-result' );

	var MAX_FILES = 5;
	var MAX_EDGE  = 1568; // beyond this it adds no useful detail and costs bandwidth and tokens
	var shots     = [];   // { name, dataUrl, mime, data }
	var busy      = false;

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

	function setStatus( msg, kind ) {
		statusEl.textContent = msg || '';
		statusEl.className = 'aipb-imp-status' + ( kind ? ' is-' + kind : '' );
	}

	/* Resizes the screenshot and converts it to base64 JPEG: a mockup's screenshots are often very
	   long, and at 1568px on the long edge the model reads everything it needs. */
	function prepare( file ) {
		return new Promise( function ( resolve ) {
			var reader = new FileReader();
			reader.onload = function () {
				var img = new Image();
				img.onload = function () {
					var scale = Math.min( MAX_EDGE / img.width, MAX_EDGE / img.height, 1 );
					var w = Math.max( 1, Math.round( img.width * scale ) );
					var h = Math.max( 1, Math.round( img.height * scale ) );
					var c = document.createElement( 'canvas' );
					c.width = w; c.height = h;
					c.getContext( '2d' ).drawImage( img, 0, 0, w, h );
					var url = c.toDataURL( 'image/jpeg', 0.9 );
					resolve( { name: file.name, dataUrl: url, mime: 'image/jpeg', data: url.split( ',' )[ 1 ] } );
				};
				img.onerror = function () { resolve( null ); };
				img.src = reader.result;
			};
			reader.onerror = function () { resolve( null ); };
			reader.readAsDataURL( file );
		} );
	}

	function addFiles( list ) {
		var files = Array.prototype.slice.call( list ).filter( function ( f ) {
			return f && /^image\//.test( f.type );
		} );
		if ( ! files.length ) { return; }

		var room = MAX_FILES - shots.length;
		if ( room <= 0 ) {
			setStatus( _aipbSprintf( __( '%d screenshots at most.' ), MAX_FILES ), 'warn' );
			return;
		}
		var dropped = files.length - room;
		files = files.slice( 0, room );

		setStatus( __( 'Preparing the screenshots…' ) );
		Promise.all( files.map( prepare ) ).then( function ( prepared ) {
			prepared.filter( Boolean ).forEach( function ( s ) { shots.push( s ); } );
			renderThumbs();
			setStatus( dropped > 0 ? _aipbSprintf( __( 'I took %1$d of them: the maximum is %2$d.' ), files.length, MAX_FILES ) : '' );
		} );
	}

	function renderThumbs() {
		thumbs.innerHTML = '';
		shots.forEach( function ( s, i ) {
			var box = el( 'div', 'aipb-imp-thumb' );
			var im  = el( 'img' );
			im.src = s.dataUrl;
			im.alt = s.name || '';
			var del = el( 'button', 'aipb-imp-thumb-del', '✕' );
			del.type = 'button';
			del.title = __( 'Remove this screenshot' );
			del.addEventListener( 'click', function () {
				shots.splice( i, 1 );
				renderThumbs();
			} );
			box.appendChild( im );
			box.appendChild( del );
			thumbs.appendChild( box );
		} );
		runBtn.disabled = busy || ! shots.length;
	}

	/* ------------------------------ The analysis ----------------------------- */

	function run() {
		if ( busy || ! shots.length ) { return; }
		busy = true;
		runBtn.disabled = true;
		resultEl.hidden = true;
		resultEl.innerHTML = '';
		setStatus( _aipbSprintf( __( 'Analysing %d screenshots… it can take a minute.' ), shots.length ), 'busy' );

		fetch( cfg.rest + '/tokens/extract', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
			body: JSON.stringify( {
				notes: notesIn ? notesIn.value : '',
				images: shots.map( function ( s ) { return { mime: s.mime, data: s.data }; } )
			} )
		} ).then( function ( r ) {
			return r.json().catch( function () { return {}; } ).then( function ( data ) {
				if ( ! r.ok ) { throw new Error( ( data && data.message ) || ( 'Error ' + r.status ) ); }
				return data;
			} );
		} ).then( function ( data ) {
			busy = false;
			runBtn.disabled = ! shots.length;
			apply( data );
		} ).catch( function ( err ) {
			busy = false;
			runBtn.disabled = ! shots.length;
			setStatus( _aipbSprintf( __( 'The analysis failed: %s' ), err.message || __( 'unexpected error' ) ), 'warn' );
		} );
	}

	/* Pours the measured values into the form. The palette and the fonts go through the two editors
	   (which own their model); the structural tokens and the direction are form fields, so they are
	   written directly — with the 'input' event, which is what every row's live preview is hooked
	   onto. */
	function apply( data ) {
		var api     = window.AIPB_BRAND_API || {};
		var applied = [];
		var skipped = [];

		var palette = Array.isArray( data.palette ) ? data.palette : [];
		if ( palette.length && api.setPalette ) {
			applied.push( _aipbSprintf( __( '%d colours' ), api.setPalette( palette ) ) );
		} else if ( ! palette.length ) {
			skipped.push( __( 'palette' ) );
		}

		var fonts = Array.isArray( data.fonts ) ? data.fonts : [];
		if ( fonts.length && api.setFonts ) {
			var n = api.setFonts( fonts );
			if ( n ) { applied.push( _aipbSprintf( __( '%d fonts' ), n ) ); }
			else { skipped.push( __( 'font' ) ); }
		} else if ( ! fonts.length ) {
			skipped.push( __( 'font' ) );
		}

		var tokens = ( data.tokens && typeof data.tokens === 'object' ) ? data.tokens : {};
		var count  = 0;
		Object.keys( tokens ).forEach( function ( id ) {
			// The fields are named aipb_brand_tokens[group:key].
			var input = document.querySelector( '.aipb-tok-input[name$="[' + id + ']"]' );
			if ( ! input ) { return; }
			input.value = tokens[ id ];
			input.dispatchEvent( new Event( 'input', { bubbles: true } ) );
			count++;
		} );
		if ( count ) { applied.push( _aipbSprintf( __( '%d structural tokens' ), count ) ); }
		else { skipped.push( __( 'structural tokens' ) ); }

		var dirEl = document.getElementById( 'aipb-direction' );
		if ( dirEl && data.direction ) {
			dirEl.value = data.direction;
			applied.push( __( 'art direction' ) );
		}

		renderResult( applied, skipped, data, palette );
	}

	function renderResult( applied, skipped, data, palette ) {
		resultEl.innerHTML = '';
		resultEl.hidden = false;

		if ( applied.length ) {
			setStatus( __( 'Done: review the values below and press «Save design system».' ), 'ok' );
			resultEl.appendChild( el( 'p', 'aipb-imp-done', '✓ ' + _aipbSprintf( __( 'Filled in: %s.' ), applied.join( ', ' ) ) ) );
		} else {
			setStatus( __( 'I could not read any token from the screenshots.' ), 'warn' );
		}

		if ( skipped.length ) {
			resultEl.appendChild( el( 'p', 'aipb-imp-skip', _aipbSprintf( __( 'Not measured (left as it was): %s.' ), skipped.join( ', ' ) ) ) );
		}

		// Where the AI saw each colour: it is the way to check by eye whether it understood, and
		// that information is not in the palette editor.
		if ( palette && palette.length ) {
			var list = el( 'ul', 'aipb-imp-swatches' );
			palette.forEach( function ( c ) {
				var li  = el( 'li' );
				var dot = el( 'span', 'aipb-imp-swatch' );
				dot.style.background = c.value;
				li.appendChild( dot );
				li.appendChild( el( 'b', '', c.name || c.value ) );
				li.appendChild( el( 'code', '', c.value ) );
				if ( c.usage ) { li.appendChild( el( 'span', 'aipb-imp-usage', c.usage ) ); }
				list.appendChild( li );
			} );
			resultEl.appendChild( list );
		}

		var notes = [];
		if ( data._warning ) { notes.push( data._warning ); }
		if ( Array.isArray( data.notes ) ) { notes = notes.concat( data.notes ); }
		if ( notes.length ) {
			var head = el( 'p', 'aipb-imp-notes-head', __( 'The AI\'s notes:' ) );
			var ul   = el( 'ul', 'aipb-imp-notelist' );
			notes.forEach( function ( n ) { ul.appendChild( el( 'li', '', n ) ); } );
			resultEl.appendChild( head );
			resultEl.appendChild( ul );
		}

		var undo = el( 'button', 'button aipb-imp-undo', __( 'Undo: reload without saving' ) );
		undo.type = 'button';
		undo.addEventListener( 'click', function () { window.location.reload(); } );
		resultEl.appendChild( undo );

		resultEl.scrollIntoView( { behavior: 'smooth', block: 'nearest' } );
	}

	/* -------------------------------- Events -------------------------------- */

	drop.addEventListener( 'click', function () { fileInput.click(); } );
	drop.addEventListener( 'keydown', function ( e ) {
		if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); fileInput.click(); }
	} );
	fileInput.addEventListener( 'change', function () {
		addFiles( fileInput.files );
		fileInput.value = ''; // so that picking the same file again fires 'change' once more
	} );
	[ 'dragenter', 'dragover' ].forEach( function ( ev ) {
		drop.addEventListener( ev, function ( e ) { e.preventDefault(); drop.classList.add( 'is-over' ); } );
	} );
	[ 'dragleave', 'dragend' ].forEach( function ( ev ) {
		drop.addEventListener( ev, function () { drop.classList.remove( 'is-over' ); } );
	} );
	drop.addEventListener( 'drop', function ( e ) {
		e.preventDefault();
		drop.classList.remove( 'is-over' );
		if ( e.dataTransfer && e.dataTransfer.files ) { addFiles( e.dataTransfer.files ); }
	} );
	runBtn.addEventListener( 'click', run );

	/* ---------------------------- From a live page ---------------------------- */

	var urlIn  = document.getElementById( 'aipb-imp-url' );
	var urlBtn = document.getElementById( 'aipb-imp-url-run' );

	if ( urlIn && urlBtn ) {
		urlBtn.addEventListener( 'click', runUrl );
		urlIn.addEventListener( 'keydown', function ( e ) {
			if ( 'Enter' === e.key ) { e.preventDefault(); runUrl(); }
		} );
	}

	function runUrl() {
		var url = ( urlIn.value || '' ).trim();
		if ( busy || ! url ) { return; }

		if ( ! /^https?:\/\//i.test( url ) ) {
			url = 'https://' + url;
			urlIn.value = url;
		}

		busy = true;
		urlBtn.disabled = true;
		resultEl.hidden = true;
		resultEl.innerHTML = '';

		// It opens a whole browser on the other side, then runs a generation: a minute is normal and
		// a spinner with no words is how people conclude it has hung.
		setStatus( __( 'Opening the page and reading its styles… it can take a minute.' ), 'busy' );

		fetch( cfg.rest + '/tokens/from-url', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
			body: JSON.stringify( { url: url, notes: notesIn ? notesIn.value : '' } )
		} ).then( function ( r ) {
			return r.json().catch( function () { return {}; } ).then( function ( data ) {
				if ( ! r.ok ) { throw new Error( ( data && data.message ) || ( 'Error ' + r.status ) ); }
				return data;
			} );
		} ).then( function ( data ) {
			busy = false;
			urlBtn.disabled = false;
			apply( data );
		} ).catch( function ( err ) {
			busy = false;
			urlBtn.disabled = false;
			setStatus( _aipbSprintf( __( 'The analysis failed: %s' ), err.message || __( 'unexpected error' ) ), 'warn' );
		} );
	}
} )();

/* ==========================================================================
   The structural tokens grid.

   It used to be an inline <script> at the bottom of the page and did one thing: repaint the little
   preview as you type. It now also answers the question the grid could not — WHICH of these
   twenty-four values are mine — and gives the way back, which before existed only as folklore
   («empty the field»).
   ========================================================================== */
( function () {
	'use strict';

	var grid = document.querySelector( '.aipb-tokgrid' );
	if ( ! grid ) { return; }

	var counter = document.querySelector( '.aipb-tok-count' );
	var born    = 0; // indices for declarations made in this session, never colliding with PHP's

	function rows() {
		return Array.prototype.slice.call( grid.querySelectorAll( '.aipb-tok-row' ) );
	}

	/* The little square/bar beside the field, repainted from the value being typed. */
	function repaint( row ) {
		var input = row.querySelector( '.aipb-tok-input' );
		var pv    = row.querySelector( '.aipb-pv' );
		if ( ! input || ! pv ) { return; }

		var val = input.value;

		// A width is not drawn at its own size — 1200px does not fit on the screen — but as a
		// proportion of a 1280px reference, which is what the bar means.
		if ( 'layout' === pv.getAttribute( 'data-kind' ) ) {
			var m = val.match( /(-?\d+(?:\.\d+)?)\s*px/ );
			pv.style.width = ( m ? Math.max( 4, Math.min( 100, ( parseFloat( m[ 1 ] ) / 1280 ) * 100 ) ) : 100 ) + '%';
			return;
		}

		pv.style[ pv.getAttribute( 'data-prop' ) ] = val;
	}

	/* Is this row still the plugin's value, or has it become the user's? */
	function mark( row ) {
		var input  = row.querySelector( '.aipb-tok-input' );
		var revert = row.querySelector( '.aipb-tok-revert' );
		if ( ! input || ! revert ) { return; }

		row.classList.toggle( 'is-mine', input.value !== revert.getAttribute( 'data-default' ) );
	}

	function recount() {
		if ( ! counter ) { return; }

		var n = rows().filter( function ( r ) { return r.classList.contains( 'is-mine' ); } ).length;
		counter.hidden = ! n;
		counter.textContent = _aipbSprintf( n === 1 ? __( '%d value of yours' ) : __( '%d values of yours' ), n );
	}

	function wire( row ) {
		var input  = row.querySelector( '.aipb-tok-input' );
		var revert = row.querySelector( '.aipb-tok-revert' );
		var drop   = row.querySelector( '.aipb-tok-drop' );
		var label  = row.querySelector( '.aipb-tok-lbl-input' );
		var keyIn  = row.querySelector( '.aipb-tok-key-input' );
		if ( ! input ) { return; }

		input.addEventListener( 'input', function () {
			repaint( row );
			mark( row );
			recount();
		} );

		// ARROW KEYS NUDGE THE NUMBER, the way every design tool does it. It only touches the FIRST
		// number in the value, which is the one that means something in `18px`, `1.5rem` and
		// `0 4px 16px rgba(…)` alike — and it keeps the unit, so the field never turns into a bare
		// number that CSS would refuse.
		input.addEventListener( 'keydown', function ( e ) {
			if ( 'ArrowUp' !== e.key && 'ArrowDown' !== e.key ) { return; }
			if ( ! /-?\d/.test( input.value ) ) { return; }

			e.preventDefault();

			var step = ( e.shiftKey ? 10 : 1 ) * ( 'ArrowUp' === e.key ? 1 : -1 );

			input.value = input.value.replace( /-?\d+(\.\d+)?/, function ( num ) {
				var next = parseFloat( num ) + ( num.indexOf( '.' ) >= 0 && ! e.shiftKey ? step / 10 : step );

				// Floating point: 1.5 + 0.1 is 1.6000000000000001, and that would be typed into the
				// field for everybody to see.
				return String( Math.round( next * 1000 ) / 1000 );
			} );

			input.dispatchEvent( new Event( 'input', { bubbles: true } ) );
		} );

		if ( revert ) {
			revert.addEventListener( 'click', function () {
				input.value = revert.getAttribute( 'data-default' );
				input.dispatchEvent( new Event( 'input', { bubbles: true } ) );
				input.focus();
			} );
		}

		// A token of one's own: the label rides along in a hidden field, because the visible input
		// is not the one that gets submitted (the declaration and the value are two options).
		if ( label ) {
			label.addEventListener( 'input', function () {
				var hidden = row.querySelector( '.aipb-tok-own-label' );
				if ( hidden ) { hidden.value = label.value; }
			} );
		}

		// The key is typed only while the token is being BORN. Afterwards it is set in stone: it is
		// the name of a CSS variable that pages already generated refer to, and renaming it here
		// would leave those references pointing at nothing.
		if ( keyIn ) {
			keyIn.addEventListener( 'input', function () {
				var clean  = keyIn.value.toLowerCase().replace( /[^a-z0-9-]+/g, '-' ).replace( /^-+|-+$/g, '' );
				var hidden = row.querySelector( '.aipb-tok-own-key' );

				if ( keyIn.value !== clean ) { keyIn.value = clean; }
				keyIn.size = Math.max( 4, clean.length || 4 );
				if ( hidden ) { hidden.value = clean; }

				// The value's field is only submitted once it has a name to be filed under.
				var tpl = input.getAttribute( 'data-name' );
				if ( tpl ) {
					if ( clean ) {
						input.name = tpl.replace( '__K__', clean );
					} else {
						input.removeAttribute( 'name' );
					}
				}

				row.classList.toggle( 'is-nameless', ! clean );
			} );
		}

		if ( drop ) {
			drop.addEventListener( 'click', function () {
				// Removing the row removes its fields from the form, and a declaration that is not
				// submitted is a declaration that no longer exists. Nothing is deleted until Save,
				// which is the same rule as everything else on this page.
				row.parentNode.removeChild( row );
				recount();
			} );
		}
	}

	rows().forEach( wire );

	// The history restores the grid by putting its markup back — which is the only way to bring back
	// a row that was deleted, or take away one that was added — and the listeners have to be tied on
	// again afterwards.
	window.AIPB_BRAND_API = window.AIPB_BRAND_API || {};
	window.AIPB_BRAND_API.gridHtml = function ( html ) {
		if ( undefined === html ) { return grid.innerHTML; }

		grid.innerHTML = html;
		rows().forEach( wire );
		rows().forEach( repaint );
		recount();
	};

	/* ------------------------------ Adding one ------------------------------ */

	Array.prototype.slice.call( grid.querySelectorAll( '.aipb-tok-add' ) ).forEach( function ( btn ) {
		btn.addEventListener( 'click', function () {
			var card = btn.closest( '.aipb-tok-card' );
			var tpl  = card && card.querySelector( '.aipb-tok-tpl' );
			var host = card && card.querySelector( '.aipb-tok-rows' );
			if ( ! tpl || ! host ) { return; }

			// The index has to be unique across the whole form, not just this card: all five groups
			// post into the same array.
			var frag = document.createElement( 'div' );
			frag.innerHTML = tpl.innerHTML.replace( /__I__/g, 'new' + ( born++ ) );

			var row = frag.firstElementChild;
			host.appendChild( row );
			wire( row );
			recount();

			var keyIn = row.querySelector( '.aipb-tok-key-input' );
			if ( keyIn ) { keyIn.focus(); }
		} );
	} );
}() );

/* ==========================================================================
   The specimen.

   Reads the state of the FORM — not of the database — and writes it into a <style> the stage
   inherits. So what is on screen is what pressing Save would produce, including whatever the mockup
   import has just poured in and nobody has committed yet.
   ========================================================================== */
( function () {
	'use strict';

	var stage = document.getElementById( 'aipb-spec' );
	var sheet = document.getElementById( 'aipb-spec-vars' );
	if ( ! stage || ! sheet ) { return; }

	var swatchHost = document.getElementById( 'aipb-spec-swatches' );
	var swatchRow  = document.getElementById( 'aipb-spec-palette' );
	var darkToggle = document.getElementById( 'aipb-spec-dark' );
	var queued     = false;

	/* Every token on the page, as `variable: value`. The variable's name is read off the row's own
	   <code>, which is the only place it exists in one piece — the prefix belongs to the group and
	   the key to the row. */
	function tokens() {
		var out = [];

		Array.prototype.slice.call( document.querySelectorAll( '.aipb-tok-row' ) ).forEach( function ( row ) {
			var code  = row.querySelector( '.aipb-tok-var' );
			var input = row.querySelector( '.aipb-tok-input' );
			var keyIn = row.querySelector( '.aipb-tok-key-input' );
			if ( ! code || ! input || ! input.value.trim() ) { return; }

			// On an editable row the <input> contributes nothing to textContent, so the two halves
			// of the name have to be put back together by hand.
			var name = code.textContent.trim() + ( keyIn ? keyIn.value.trim() : '' );
			if ( ! /^--aipb-[a-z0-9-]+$/.test( name ) ) { return; }

			out.push( name + ':' + input.value.trim() );
		} );

		return out;
	}

	/* The palette, plus the one decision the specimen has to make on its own: which colour a button
	   is. The AI picks that from name, hue and contrast; here the rule is simply the first colour
	   dark enough to carry white text, and failing that the first one — it is a specimen, not a
	   design. */
	function palette() {
		var api  = window.AIPB_BRAND_API || {};
		var list = api.getPalette ? api.getPalette() : [];
		var out  = [];

		list.forEach( function ( c ) {
			if ( c.slug ) { out.push( '--aipb-color-' + c.slug + ':' + c.value ); }
		} );

		var accent = null;
		list.forEach( function ( c ) {
			if ( ! accent && luminance( c.value ) < 0.5 ) { accent = c.value; }
		} );
		if ( ! accent && list.length ) { accent = list[ 0 ].value; }

		if ( accent ) {
			out.push( '--aipb-spec-accent:' + accent );
			out.push( '--aipb-spec-accent-ink:' + ( luminance( accent ) > 0.55 ? '#12141a' : '#ffffff' ) );
		}

		paintSwatches( list );

		return out;
	}

	function paintSwatches( list ) {
		if ( ! swatchHost || ! swatchRow ) { return; }

		swatchRow.hidden = ! list.length;
		swatchHost.innerHTML = '';

		list.forEach( function ( c ) {
			var box = document.createElement( 'div' );
			box.className = 'aipb-spec-sw';

			var chip = document.createElement( 'i' );
			chip.style.background = c.value;

			var name = document.createElement( 'b' );
			name.textContent = c.name || c.slug;

			var code = document.createElement( 'code' );
			code.textContent = c.value;

			box.appendChild( chip );
			box.appendChild( name );
			box.appendChild( code );
			swatchHost.appendChild( box );
		} );
	}

	/* The fonts, actually loaded. Naming a family that the browser has not got would show the
	   fallback and quietly lie about the design system. */
	function fonts() {
		var api  = window.AIPB_BRAND_API || {};
		var list = api.getFonts ? api.getFonts() : [];
		var out  = [];

		list.forEach( function ( f ) {
			if ( ! f.family ) { return; }
			if ( api.ensureFont ) { api.ensureFont( f.family, f.weights || [ 400 ] ); }

			var stack = "'" + f.family + "', " + ( api.fallbackFor ? api.fallbackFor( f.category ) : 'sans-serif' );
			if ( f.slug ) { out.push( '--aipb-font-' + f.slug + ':' + stack ); }
		} );

		// Which font is a heading and which is body is the AI's decision, not ours. The specimen
		// takes the order on the page: the first is the display voice, the second the reading one.
		if ( list.length ) {
			out.push( '--aipb-font-preview-heading:' + stackOf( list[ 0 ], api ) );
			out.push( '--aipb-font-preview-body:' + stackOf( list[ list.length > 1 ? 1 : 0 ], api ) );
		}

		return out;
	}

	function stackOf( f, api ) {
		return "'" + f.family + "', " + ( api.fallbackFor ? api.fallbackFor( f.category ) : 'sans-serif' );
	}

	/* Relative luminance, the cheap way — enough to answer "is this dark?". The proper WCAG
	   version lives in the contrast matrix, which is the place that has to be exact. */
	function luminance( color ) {
		var rgb = toRgb( color );
		if ( ! rgb ) { return 1; }

		return ( 0.2126 * rgb[ 0 ] + 0.7152 * rgb[ 1 ] + 0.0722 * rgb[ 2 ] ) / 255;
	}

	function toRgb( color ) {
		var hex = String( color || '' ).trim();

		if ( /^#[0-9a-f]{3}$/i.test( hex ) ) {
			hex = '#' + hex[ 1 ] + hex[ 1 ] + hex[ 2 ] + hex[ 2 ] + hex[ 3 ] + hex[ 3 ];
		}
		if ( /^#[0-9a-f]{6,8}$/i.test( hex ) ) {
			return [ parseInt( hex.substr( 1, 2 ), 16 ), parseInt( hex.substr( 3, 2 ), 16 ), parseInt( hex.substr( 5, 2 ), 16 ) ];
		}

		var m = hex.match( /rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i );

		return m ? [ parseFloat( m[ 1 ] ), parseFloat( m[ 2 ] ), parseFloat( m[ 3 ] ) ] : null;
	}


	function paint() {
		queued = false;
		sheet.textContent = '#aipb-spec{' + tokens().concat( palette(), fonts() ).join( ';' ) + '}';
	}

	/* One repaint per frame however many fields moved: dragging a colour picker fires 'input'
	   continuously, and rebuilding a stylesheet on each one is how a page starts to feel slow. */
	function schedule() {
		if ( queued ) { return; }
		queued = true;
		window.requestAnimationFrame( paint );
	}

	document.addEventListener( 'input', schedule );
	document.addEventListener( 'aipb:brand-change', schedule );

	if ( darkToggle ) {
		darkToggle.addEventListener( 'change', function () {
			stage.classList.toggle( 'is-dark', darkToggle.checked );
		} );
	}

	paint();
}() );

/* ==========================================================================
   The bar: unsaved changes, and not losing the work by walking away.
   ========================================================================== */
( function () {
	'use strict';

	var form = document.getElementById( 'aipb-brand-form' );
	var note = document.getElementById( 'aipb-dirty' );
	if ( ! form ) { return; }

	var dirty  = false;
	var saving = false;

	function touch() {
		if ( dirty ) { return; }

		dirty = true;
		if ( note ) {
			note.hidden = false;
			note.textContent = __( 'Unsaved changes' );
		}
	}

	// Everything editable is inside the form — the token grid, both editors, the direction — so one
	// delegated listener covers controls that did not exist when the page loaded.
	form.addEventListener( 'input', touch );
	form.addEventListener( 'change', touch );

	// A colour removed or a font added moves no field: the editors say so themselves.
	document.addEventListener( 'aipb:brand-change', function () {
		// Not on the first paint, which happens while the page is still building itself.
		if ( document.readyState === 'complete' ) { touch(); }
	} );

	form.addEventListener( 'submit', function () { saving = true; } );

	// The browser shows its own wording — no page may choose it — but it only shows anything at all
	// if there is something to lose.
	window.addEventListener( 'beforeunload', function ( e ) {
		if ( ! dirty || saving ) { return; }

		e.preventDefault();
		e.returnValue = '';

		return '';
	} );
}() );

/* ==========================================================================
   What the typography costs.

   Three families at six weights is most of a megabyte of render-blocking font, and the fonts panel
   showed no number at all — the weight chips look free, so people tick them all. The estimate is
   deliberately per-weight and per-family, because that is the shape of the decision being made.
   ========================================================================== */
( function () {
	'use strict';

	var box  = document.getElementById( 'aipb-fw' );
	var num  = document.getElementById( 'aipb-fw-num' );
	var lbl  = document.getElementById( 'aipb-fw-lbl' );
	var warn = document.getElementById( 'aipb-fw-warn' );
	if ( ! box || ! num || ! lbl ) { return; }

	// A latin-subset woff2 weight from Google runs 15–28 KB depending on the family's complexity;
	// 22 is the middle of what the common ones actually weigh. An italic is a separate file, which
	// is why the count is of FILES and not of families.
	var PER_WEIGHT = 22;

	// Past this, the fonts are a performance problem rather than a design decision.
	var LOUD = 200;

	function paint() {
		var api  = window.AIPB_BRAND_API || {};
		var list = api.getFonts ? api.getFonts() : [];

		if ( ! list.length ) { box.hidden = true; return; }
		box.hidden = false;

		var weights = 0;
		list.forEach( function ( f ) { weights += ( f.weights || [] ).length; } );

		var kb = weights * PER_WEIGHT;

		num.textContent = '≈ ' + kb + ' KB';
		lbl.textContent = _aipbSprintf(
			__( '%1$s, %2$s — downloaded by every visitor before the text can be read.' ),
			_aipbSprintf( list.length === 1 ? __( '%d family' ) : __( '%d families' ), list.length ),
			_aipbSprintf( weights === 1 ? __( '%d weight' ) : __( '%d weights' ), weights )
		);

		box.classList.toggle( 'is-loud', kb >= LOUD );

		if ( warn ) {
			warn.hidden = kb < LOUD;
			warn.textContent = __( 'That is a lot. Most designs live on two families and three or four weights in total: every extra weight is a separate file, and the ones nobody uses cost exactly as much as the ones that do.' );
		}
	}

	document.addEventListener( 'aipb:brand-change', function ( e ) {
		if ( ! e.detail || 'fonts' === e.detail.what ) { paint(); }
	} );

	paint();
}() );

/* ==========================================================================
   Undo / redo.

   A design system is judged by trying things — widen the container, look, put it back. Without a way
   back the only route was to remember the number you had, so in practice nobody tried anything: they
   typed once and hoped. This is the difference between a form and a tool.

   WHAT A STATE IS. The token grid goes in as MARKUP, not as a list of values: a step can add or
   delete a whole row (a token of your own), and no amount of setting values brings a deleted row
   back. The palette and the fonts go in as their models, because those two editors own theirs and
   rebuild their own DOM from it. The direction is a string.
   ========================================================================== */
( function () {
	'use strict';

	var form = document.getElementById( 'aipb-brand-form' );
	var undoBtn = document.getElementById( 'aipb-undo' );
	var redoBtn = document.getElementById( 'aipb-redo' );
	var noteEl  = document.getElementById( 'aipb-hist-note' );
	if ( ! form || ! undoBtn || ! redoBtn ) { return; }

	var api  = window.AIPB_BRAND_API || {};
	var dir  = document.getElementById( 'aipb-direction' );

	// Deep enough to walk back through an afternoon's fiddling, shallow enough that the markup of
	// the grid — the big part of a state — never adds up to anything a browser minds holding.
	var LIMIT = 60;

	var past = [];
	var future = [];
	var applying = false;
	var timer = null;
	var current = read();

	function read() {
		return {
			grid: api.gridHtml ? api.gridHtml() : '',
			palette: JSON.stringify( api.getPalette ? api.getPalette() : [] ),
			fonts: JSON.stringify( api.getFonts ? api.getFonts() : [] ),
			dir: dir ? dir.value : ''
		};
	}

	function same( a, b ) {
		return a.grid === b.grid && a.palette === b.palette && a.fonts === b.fonts && a.dir === b.dir;
	}

	/* One step per pause, not one per keystroke. Typing `24px` is one change to the person making
	   it, and an undo that walks back through `2`, `24`, `24p` is an undo nobody uses twice. */
	function schedule() {
		if ( applying ) { return; }

		window.clearTimeout( timer );
		timer = window.setTimeout( record, 450 );
	}

	function record() {
		if ( applying ) { return; }

		var now = read();
		if ( same( now, current ) ) { return; }

		past.push( current );
		if ( past.length > LIMIT ) { past.shift(); }

		current = now;
		future.length = 0; // a new change forks the timeline: what was undone is gone
		paint();
	}

	function apply( state ) {
		applying = true;

		if ( api.gridHtml ) { api.gridHtml( state.grid ); }
		if ( api.setPalette ) { api.setPalette( JSON.parse( state.palette ) ); }
		if ( api.setFonts ) { api.setFonts( JSON.parse( state.fonts ) ); }
		if ( dir ) { dir.value = state.dir; }

		current = state;
		applying = false;

		// The specimen reads the form, and nothing it listens to has fired.
		document.dispatchEvent( new CustomEvent( 'aipb:brand-change', { detail: { what: 'history' } } ) );
		paint();
	}

	function undo() {
		record(); // whatever is still inside the debounce window is a step of its own
		if ( ! past.length ) { return; }

		future.push( current );
		apply( past.pop() );
		say( __( 'Undone' ) );
	}

	function redo() {
		if ( ! future.length ) { return; }

		past.push( current );
		apply( future.pop() );
		say( __( 'Redone' ) );
	}

	var noteTimer = null;

	function say( text ) {
		if ( ! noteEl ) { return; }

		noteEl.hidden = false;
		noteEl.textContent = text;

		window.clearTimeout( noteTimer );
		noteTimer = window.setTimeout( function () { noteEl.hidden = true; }, 1400 );
	}

	function paint() {
		undoBtn.disabled = ! past.length;
		redoBtn.disabled = ! future.length;
	}

	undoBtn.addEventListener( 'click', undo );
	redoBtn.addEventListener( 'click', redo );

	document.addEventListener( 'input', schedule );
	document.addEventListener( 'change', schedule );
	document.addEventListener( 'aipb:brand-change', function ( e ) {
		if ( e.detail && 'history' === e.detail.what ) { return; }
		schedule();
	} );

	// Adding or deleting a token row moves no field and dispatches nothing of its own.
	form.addEventListener( 'click', function ( e ) {
		if ( e.target.closest && e.target.closest( '.aipb-tok-add, .aipb-tok-drop, .aipb-tok-revert' ) ) {
			schedule();
		}
	} );

	document.addEventListener( 'keydown', function ( e ) {
		if ( 'z' !== String( e.key || '' ).toLowerCase() || ! ( e.metaKey || e.ctrlKey ) ) { return; }

		// Inside a text field the browser's own undo is the better one — it works on the characters
		// being typed, which is what the gesture means there. Taking it over would be a regression
		// dressed as a feature.
		var el = document.activeElement;
		if ( el && ( 'INPUT' === el.tagName || 'TEXTAREA' === el.tagName ) && ! el.readOnly ) {
			var type = ( el.type || '' ).toLowerCase();
			if ( 'text' === type || 'url' === type || 'password' === type || 'search' === type || 'TEXTAREA' === el.tagName ) {
				return;
			}
		}

		e.preventDefault();
		if ( e.shiftKey ) { redo(); } else { undo(); }
	} );

	paint();
}() );

/* ==========================================================================
   The effects list: add and delete rows.

   The row itself is PHP's (the <template> in render_effects), so its shape is written once. The
   only thing this script decides is the input index of a row born in this session: it starts high
   so it can never collide with the indices PHP printed for the saved rows.
   ========================================================================== */
( function () {
	'use strict';

	var list = document.getElementById( 'aipb-fx-list' );
	var tpl  = document.getElementById( 'aipb-fx-tpl' );
	var add  = document.getElementById( 'aipb-add-effect' );
	if ( ! list || ! tpl || ! add ) { return; }

	var born = 1000;

	add.addEventListener( 'click', function () {
		var row = tpl.content.firstElementChild.cloneNode( true );

		Array.prototype.slice.call( row.querySelectorAll( '[name]' ) ).forEach( function ( field ) {
			field.name = field.name.replace( '__I__', String( born ) );
		} );
		born++;

		list.appendChild( row );

		var id = row.querySelector( '.aipb-fx-id-input' );
		if ( id ) { id.focus(); }

		// Nothing fired an input event, and the dirty tracking listens for exactly this.
		document.dispatchEvent( new CustomEvent( 'aipb:brand-change', { detail: { what: 'effects' } } ) );
	} );

	list.addEventListener( 'click', function ( e ) {
		var drop = e.target.closest ? e.target.closest( '.aipb-fx-drop' ) : null;
		if ( ! drop ) { return; }

		var row = drop.closest( '.aipb-fx-row' );
		if ( row ) {
			row.remove();
			document.dispatchEvent( new CustomEvent( 'aipb:brand-change', { detail: { what: 'effects' } } ) );
		}
	} );
}() );
