// source --> https://briarmont-sl.com/wp-content/themes/briarmont/assets/js/nav.js?ver=1785213497 
/* Nav island: scrolled state, dropdown hover/click, burger menu.
   Mirrors the prototype Nav behavior (components.jsx): scrolled class at
   24px, 140ms dropdown close delay, click-to-toggle on touch. */
/* Ready-guard -- see briarmont_enqueue_island(). */
(function (island) {
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', island, { once: true });
	} else {
		island();
	}
})(function () {
	const nav = document.querySelector('[data-nav]');
	if (!nav) return;

	const isOver = nav.dataset.navOver === '1';
	const onScroll = () => {
		const scrolled = window.scrollY > 24;
		nav.classList.toggle('nav--scrolled', scrolled);
		if (isOver) nav.classList.toggle('nav--over', !scrolled);
	};
	window.addEventListener('scroll', onScroll, { passive: true });
	onScroll();

	const closeAll = (except) => {
		nav.querySelectorAll('[data-dd].is-open').forEach((dd) => {
			if (dd !== except) {
				dd.classList.remove('is-open');
				const link = dd.querySelector('.nav-link--dd');
				link.setAttribute('aria-expanded', 'false');
				link.querySelector('.dd-caret').classList.remove('is-open');
			}
		});
	};

	nav.querySelectorAll('[data-dd]').forEach((dd) => {
		const link = dd.querySelector('.nav-link--dd');
		const caret = link.querySelector('.dd-caret');
		let timer = null;
		const open = () => {
			clearTimeout(timer);
			closeAll(dd);
			dd.classList.add('is-open');
			link.setAttribute('aria-expanded', 'true');
			caret.classList.add('is-open');
		};
		const close = () => {
			timer = setTimeout(() => {
				dd.classList.remove('is-open');
				link.setAttribute('aria-expanded', 'false');
				caret.classList.remove('is-open');
			}, 140);
		};
		dd.addEventListener('mouseenter', open);
		dd.addEventListener('mouseleave', close);
		/* On touch, first tap opens the menu instead of navigating. */
		link.addEventListener('click', (e) => {
			if (window.matchMedia('(hover: none)').matches && !dd.classList.contains('is-open')) {
				e.preventDefault();
				open();
			}
		});
	});
	document.addEventListener('click', (e) => {
		if (!nav.contains(e.target)) closeAll();
	});

	const burger = nav.querySelector('[data-burger]');
	const mobile = nav.querySelector('[data-mobile-menu]');
	if (burger && mobile) {
		burger.addEventListener('click', () => {
			const openNow = mobile.hasAttribute('hidden');
			if (openNow) {
				mobile.removeAttribute('hidden');
			} else {
				mobile.setAttribute('hidden', '');
			}
			burger.setAttribute('aria-expanded', openNow ? 'true' : 'false');
		});
	}
});
// source --> https://briarmont-sl.com/wp-content/themes/briarmont/assets/js/media.js?ver=1785213497 
/**
 * Cover-image reveal. The theme renders every card/cover image (.ph-img) over a
 * striped placeholder; without this the image hard-swaps in the moment it
 * decodes, which reads as a "pop" over the filler. This fades each image in as
 * it finishes decoding, and reveals already-cached images synchronously so a
 * warm cache still shows them instantly (no fade, no delay).
 *
 * Progressive enhancement: the fade is gated behind the .js-mediafade root
 * class this module adds, so with JS off (or if this never runs) images render
 * fully opaque exactly as before. We add the class and reveal complete images
 * in the same synchronous pass, so an image that finished before we ran never
 * flashes to opacity:0.
 */
/* Ready-guard -- see briarmont_enqueue_island(). */
( ( island ) => {
	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', island, { once: true } );
	} else {
		island();
	}
} )( () => {
	const root = document.documentElement;

	const reveal = ( img ) => img.classList.add( 'is-loaded' );

	// Reveal every image that has already finished loading (cached / decoded
	// before this deferred module ran). Runs before the load listener attaches
	// so nothing between the two is missed -- both are synchronous.
	const revealComplete = () => {
		document.querySelectorAll( 'img.ph-img:not(.is-loaded)' ).forEach( ( img ) => {
			if ( img.complete && img.naturalWidth > 0 ) {
				reveal( img );
			}
		} );
	};

	root.classList.add( 'js-mediafade' );
	revealComplete();

	// load/error don't bubble -- listen in the capture phase. error reveals too
	// so a broken image can never stay stuck at opacity:0.
	const onSettle = ( e ) => {
		const t = e.target;
		if ( t && 'IMG' === t.tagName && t.classList.contains( 'ph-img' ) ) {
			reveal( t );
		}
	};
	document.addEventListener( 'load', onSettle, true );
	document.addEventListener( 'error', onSettle, true );

	// Catch any .ph-img added after this module ran (late template islands).
	document.addEventListener( 'DOMContentLoaded', revealComplete );
} );
// source --> https://briarmont-sl.com/wp-content/themes/briarmont/assets/js/search.js?ver=1785213497 
/**
 * Site-wide search island: the header dropdown (typeahead over
 * briarmont/v1/search) + the /search/ results page's chip filter. Both parts
 * are progressive enhancement -- the header form is a plain GET to /search/
 * without this, and the results page is complete server-rendered HTML.
 *
 * Security note: every result field comes from the REST response and is
 * rendered with document.createElement()/textContent ONLY -- never innerHTML
 * -- so nothing the API returns (titles, excerpts, book names, ...) can ever
 * be interpreted as markup.
 */
/* Ready-guard -- see briarmont_enqueue_island(). */
( ( island ) => {
	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', island, { once: true } );
	} else {
		island();
	}
} )( () => {
	/** Lowercase, de-duplicated, length >= 2 terms -- mirrors briarmont_search_tokenize(). */
	function buildTerms( q ) {
		const seen = new Set();
		const terms = [];
		( String( q || '' ).toLowerCase().match( /[\p{L}\p{N}]{2,}/gu ) || [] ).forEach( ( t ) => {
			if ( ! seen.has( t ) ) {
				seen.add( t );
				terms.push( t );
			}
		} );
		return terms.slice( 0, 8 );
	}

	function escapeRegExp( s ) {
		return s.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
	}

	/** Append `text` into `parent` as plain text nodes with matched terms
	 *  wrapped in <mark> elements -- built via DOM APIs only, never innerHTML. */
	function appendHighlighted( parent, text, terms ) {
		text = String( text || '' );
		if ( ! terms.length || ! text ) {
			parent.appendChild( document.createTextNode( text ) );
			return;
		}
		const sorted = terms.slice().sort( ( a, b ) => b.length - a.length );
		const re = new RegExp( '(' + sorted.map( escapeRegExp ).join( '|' ) + ')', 'ig' );
		let last = 0;
		let m;
		while ( ( m = re.exec( text ) ) ) {
			if ( m.index > last ) {
				parent.appendChild( document.createTextNode( text.slice( last, m.index ) ) );
			}
			const mark = document.createElement( 'mark' );
			mark.textContent = m[ 0 ];
			parent.appendChild( mark );
			last = m.index + m[ 0 ].length;
			if ( re.lastIndex === m.index ) {
				re.lastIndex++; // guard against a zero-length match looping forever
			}
		}
		if ( last < text.length ) {
			parent.appendChild( document.createTextNode( text.slice( last ) ) );
		}
	}

	function renderMessage( container, text ) {
		container.textContent = '';
		const p = document.createElement( 'p' );
		p.className = 'nav-search-empty';
		p.textContent = text;
		container.appendChild( p );
	}

	/** Render the typeahead result list; returns the flat, in-order list of
	 *  result <a> elements for arrow-key navigation. */
	function renderResults( container, data, terms ) {
		container.textContent = '';
		if ( ! data || ! data.groups || ! data.groups.length ) {
			renderMessage( container, data && 0 === data.total ? 'No results.' : 'Keep typing to search…' );
			return [];
		}
		const flat = [];
		data.groups.forEach( ( g ) => {
			const head = document.createElement( 'div' );
			head.className = 'nav-search-group-h mono';
			head.textContent = g.label || '';
			container.appendChild( head );

			const list = document.createElement( 'ul' );
			list.className = 'nav-search-list';
			list.setAttribute( 'role', 'presentation' ); // the real listbox semantics live on the container; this is just a grouping wrapper
			( g.items || [] ).forEach( ( item ) => {
				const li = document.createElement( 'li' );
				li.setAttribute( 'role', 'presentation' );
				const a = document.createElement( 'a' );
				a.className = 'nav-search-item';
				a.href = item.url || '#';
				a.id = 'nav-search-opt-' + flat.length; // unique id for aria-activedescendant
				a.setAttribute( 'role', 'option' );

				const title = document.createElement( 'span' );
				title.className = 'nav-search-item-title';
				appendHighlighted( title, item.title || '', terms );
				a.appendChild( title );

				if ( item.context ) {
					const ctx = document.createElement( 'span' );
					ctx.className = 'nav-search-item-ctx mono';
					ctx.textContent = item.context;
					a.appendChild( ctx );
				}
				if ( item.excerpt ) {
					const ex = document.createElement( 'span' );
					ex.className = 'nav-search-item-ex';
					appendHighlighted( ex, item.excerpt, terms );
					a.appendChild( ex );
				}
				li.appendChild( a );
				list.appendChild( li );
				flat.push( a );
			} );
			container.appendChild( list );
		} );
		return flat;
	}

	function initHeaderSearch() {
		const toggle = document.querySelector( '[data-search-toggle]' );
		const panel = document.querySelector( '[data-search-panel]' );
		if ( ! toggle || ! panel ) {
			return;
		}
		const input = panel.querySelector( '.nav-search-input' );
		const results = panel.querySelector( '[data-search-results]' );
		const status = panel.querySelector( '[data-search-status]' );
		const viewAll = panel.querySelector( '[data-search-viewall]' );
		const restBase = panel.dataset.searchRest || '';
		const searchPage = panel.dataset.searchPage || '';

		let items = [];
		let active = -1;
		let controller = null;
		let debounceTimer = null;

		function isOpen() {
			return ! panel.hasAttribute( 'hidden' );
		}
		function open() {
			// Mutual exclusion: the mobile burger menu and this panel share the
			// same fixed header real estate, so opening one must close the
			// other. Close via the burger's OWN click handler (nav.js) rather
			// than touching its state/aria directly here -- nav.js stays the
			// single owner of that state.
			const mobileMenu = document.querySelector( '[data-mobile-menu]' );
			if ( mobileMenu && ! mobileMenu.hasAttribute( 'hidden' ) ) {
				const burger = document.querySelector( '[data-burger]' );
				if ( burger ) {
					burger.click();
				}
			}
			panel.removeAttribute( 'hidden' );
			toggle.setAttribute( 'aria-expanded', 'true' );
			if ( input ) {
				input.setAttribute( 'aria-expanded', 'true' );
			}
			window.requestAnimationFrame( () => {
				if ( input ) {
					input.focus();
				}
			} );
		}
		function close() {
			panel.setAttribute( 'hidden', '' );
			toggle.setAttribute( 'aria-expanded', 'false' );
			if ( input ) {
				input.setAttribute( 'aria-expanded', 'false' );
			}
			active = -1;
		}
		function setActive( idx ) {
			items.forEach( ( a ) => a.classList.remove( 'is-active' ) );
			active = idx;
			if ( idx >= 0 && items[ idx ] ) {
				items[ idx ].classList.add( 'is-active' );
				items[ idx ].scrollIntoView( { block: 'nearest' } );
			}
			if ( input ) {
				input.setAttribute( 'aria-activedescendant', ( idx >= 0 && items[ idx ] ) ? items[ idx ].id : '' );
			}
		}
		/** Short summary only ("No results." / "N results.") -- the result
		 *  list itself is not a live region (see the aria-live churn note
		 *  above renderResults), so this is the one thing announced. */
		function updateStatus( data ) {
			if ( ! status ) {
				return;
			}
			status.textContent = data ? ( data.total ? data.total + ' results.' : 'No results.' ) : '';
		}

		toggle.addEventListener( 'click', ( e ) => {
			e.preventDefault(); // toggle is now a real <a href="/search/"> (Fix 8 no-JS fallback) -- with JS, open the panel instead of navigating
			if ( isOpen() ) {
				close();
			} else {
				open();
			}
		} );

		document.addEventListener( 'keydown', ( e ) => {
			// Only close+refocus when focus is actually within the search UI --
			// otherwise Escape pressed elsewhere (e.g. to close an unrelated
			// modal) would silently close a background search panel and yank
			// focus here.
			const focusInSearch = panel.contains( document.activeElement ) || document.activeElement === toggle;
			if ( 'Escape' === e.key && isOpen() && focusInSearch ) {
				close();
				toggle.focus();
				return;
			}
			if ( '/' === e.key && ! isOpen() ) {
				const el = document.activeElement;
				const tag = el && el.tagName ? el.tagName.toLowerCase() : '';
				const editable = !! ( el && el.isContentEditable );
				if ( 'input' !== tag && 'textarea' !== tag && 'select' !== tag && ! editable ) {
					e.preventDefault();
					open();
				}
			}
		} );

		document.addEventListener( 'click', ( e ) => {
			if ( isOpen() && ! panel.contains( e.target ) && e.target !== toggle && ! toggle.contains( e.target ) ) {
				close();
			}
		} );

		function updateViewAll( q ) {
			if ( ! viewAll ) {
				return;
			}
			const base = searchPage || viewAll.getAttribute( 'href' ) || '';
			const sep = base.indexOf( '?' ) >= 0 ? '&' : '?';
			viewAll.href = q ? base + sep + 'q=' + encodeURIComponent( q ) : base;
		}

		function runSearch( q ) {
			if ( controller ) {
				controller.abort();
			}
			if ( ! restBase || q.trim().length < 2 ) {
				results.textContent = '';
				items = [];
				return;
			}
			controller = new AbortController();
			const url = restBase + ( restBase.indexOf( '?' ) >= 0 ? '&' : '?' ) + 'q=' + encodeURIComponent( q ) + '&limit=5';
			fetch( url, { headers: { Accept: 'application/json' }, signal: controller.signal } )
				.then( ( r ) => ( r.ok ? r.json() : Promise.reject( r.status ) ) )
				.then( ( data ) => {
					items = renderResults( results, data, buildTerms( q ) );
					setActive( -1 );
					updateStatus( data );
				} )
				.catch( ( err ) => {
					if ( err && 'AbortError' === err.name ) {
						return; // superseded by a newer keystroke's fetch
					}
					items = [];
					renderMessage( results, 'Search is unavailable right now.' );
				} );
		}

		if ( input ) {
			input.addEventListener( 'input', () => {
				const q = input.value;
				updateViewAll( q.trim() );
				clearTimeout( debounceTimer );
				if ( q.trim().length < 2 ) {
					// Below the 2-char floor: clear immediately rather than after
					// the debounce, so a fast clear/backspace doesn't leave the
					// previous query's stale results flashing on screen.
					if ( controller ) {
						controller.abort();
					}
					results.textContent = '';
					items = [];
					updateStatus( null );
					return;
				}
				debounceTimer = setTimeout( () => runSearch( q ), 250 );
			} );
			input.addEventListener( 'keydown', ( e ) => {
				if ( 'ArrowDown' === e.key ) {
					if ( items.length ) {
						e.preventDefault();
						setActive( ( active + 1 ) % items.length );
					}
				} else if ( 'ArrowUp' === e.key ) {
					if ( items.length ) {
						e.preventDefault();
						setActive( ( active - 1 + items.length ) % items.length );
					}
				} else if ( 'Enter' === e.key ) {
					if ( active >= 0 && items[ active ] ) {
						e.preventDefault();
						window.location.href = items[ active ].href;
					}
					// else: fall through to the native form submission (GET to /search/).
				}
			} );
		}
	}

	/** Search results page: chip filter over the server-rendered groups.
	 *  Progressive enhancement -- every group is visible without this. */
	function initResultsPage() {
		const root = document.querySelector( '[data-search-page-root]' );
		if ( ! root ) {
			return;
		}
		const chips = Array.from( root.querySelectorAll( '[data-search-chip]' ) );
		const groups = Array.from( root.querySelectorAll( '[data-search-group]' ) );
		if ( ! chips.length || ! groups.length ) {
			return;
		}
		chips.forEach( ( chip ) => {
			chip.addEventListener( 'click', () => {
				chips.forEach( ( c ) => c.classList.remove( 'is-on' ) );
				chip.classList.add( 'is-on' );
				const key = chip.dataset.searchChip;
				groups.forEach( ( g ) => {
					const show = 'all' === key || g.dataset.searchGroup === key;
					if ( show ) {
						g.removeAttribute( 'hidden' );
					} else {
						g.setAttribute( 'hidden', '' );
					}
				} );
			} );
		} );
	}

	initHeaderSearch();
	initResultsPage();
} );
// source --> https://briarmont-sl.com/wp-content/themes/briarmont/assets/js/sync-clock.js?ver=1785213497 
/**
 * Footer sync clock — ticks the UTC time once a second.
 *
 * The clock is already correct when it arrives (PHP renders it), so this is
 * enhancement only: without JS the footer shows the time the page was built.
 * Ticks are aligned to the real second boundary rather than fired on a plain
 * 1000ms interval, which would drift and skip a digit every few minutes.
 */
/* Ready-guard -- see briarmont_enqueue_island(). */
( function ( island ) {
	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', island, { once: true } );
	} else {
		island();
	}
} )( function () {
	'use strict';

	var slot = document.querySelector( '[data-sync-clock] [data-sync-time]' );
	if ( ! slot ) {
		return;
	}
	var stamp = slot.closest( '[data-sync-clock]' );

	function pad( n ) {
		return n < 10 ? '0' + n : String( n );
	}

	function tick() {
		var now = new Date();
		slot.textContent = pad( now.getUTCHours() ) + ':' + pad( now.getUTCMinutes() ) + ':' + pad( now.getUTCSeconds() );
		if ( stamp ) {
			stamp.setAttribute( 'datetime', now.toISOString() );
		}
		// Re-aim at the next whole second (+15ms so we land just after it).
		setTimeout( tick, 1015 - now.getMilliseconds() );
	}

	tick();
} );
// source --> https://briarmont-sl.com/wp-content/themes/briarmont/assets/js/hero-video.js?ver=1785213497 
/**
 * Hero background video island. Two jobs, both embed-only:
 *
 *  - Loop cutoff: stop at data-hero-end and jump back to data-hero-start, to
 *    cut an outro or end titles. The native loop stays on as the fallback if
 *    the clip ever reaches its real end.
 *  - Poster fallback: bring the poster forward ONLY if the video never starts
 *    (autoplay blocked, or the embed / API failed to load).
 *
 * YouTube also gets captions forced off -- it shows them whenever the viewer's
 * account has them on, and no embed-URL parameter overrides that account
 * preference; the iframe Player API's unloadModule() does.
 *
 * Each provider is driven by its own official player API -- YouTube's iframe
 * Player API, Vimeo's player.js. 2.56.0 tried to speak Vimeo's postMessage
 * protocol directly to avoid the script load; the cutoff never fired on the live
 * hero, so that trade is off. Correctness beats one cached script.
 *
 * Both branches POLL the current time rather than trusting a timeupdate event:
 * it is the mechanism already proven on YouTube, and it does not care whether a
 * player in background mode emits progress events at all.
 */
/* Ready-guard -- see briarmont_enqueue_island(). */
( function ( island ) {
	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', island, { once: true } );
	} else {
		island();
	}
} )( function () {
const briarmontHeroFrame = document.querySelector( 'iframe[data-hero-youtube], iframe[data-hero-vimeo]' );

if ( briarmontHeroFrame ) {
	const isVimeo = briarmontHeroFrame.hasAttribute( 'data-hero-vimeo' );

	const endTime   = parseFloat( briarmontHeroFrame.dataset.heroEnd );
	const startTime = parseFloat( briarmontHeroFrame.dataset.heroStart ) || 0;
	const hasCutoff = ! isNaN( endTime ) && endTime > startTime;

	// ---- Poster fallback (shared) -----------------------------------------
	// The video shows immediately; a working one clears the timer, so in normal
	// use the poster is never brought forward.
	//
	// IMPORTANT: arming is deliberately per-provider. We may only raise the
	// poster over a video we can actually hear from -- otherwise a silent
	// control channel gets mistaken for a broken video and we cover a clip that
	// is playing perfectly well. Vimeo arms this helper once the player has
	// answered us at least once (see below).
	//
	// YouTube does NOT use this helper. It used to, arming at parse time, and
	// that was the 2.57.1 bug: it judged a bridge that had not loaded yet, and
	// its only disarm was a PLAYING transition that an already-playing clip
	// never emits. The YouTube branch now runs its own watchLife() poll that
	// asks the player directly and can un-cover a video it already covered.
	const heroContainer = briarmontHeroFrame.closest( '.hero-video--embed' );
	const heroPoster = heroContainer && heroContainer.querySelector( '.hero-video-poster' );
	let played = false;
	let failTimer = null;
	let armed = false;
	const armPosterFallback = () => {
		if ( armed || ! heroPoster ) {
			return;
		}
		armed = true;
		failTimer = setTimeout( () => {
			if ( ! played ) {
				heroContainer.classList.add( 'video-failed' );
			}
		}, 4000 );
	};
	const markPlaying = () => {
		played = true;
		if ( failTimer ) {
			clearTimeout( failTimer );
			failTimer = null;
		}
		if ( heroContainer ) {
			heroContainer.classList.remove( 'video-failed' );
		}
	};
	const showPoster = () => {
		if ( heroContainer && heroPoster ) {
			heroContainer.classList.add( 'video-failed' );
		}
	};

	if ( isVimeo ) {
		// ---- Vimeo: player.js --------------------------------------------
		// The SDK owns readiness, which is the part hand-rolled postMessage got
		// wrong: there is no reliable moment to fire "subscribe" at, and a missed
		// ready means silence forever.
		const driveVimeo = () => {
			if ( ! window.Vimeo || ! window.Vimeo.Player ) {
				return;
			}
			const player = new window.Vimeo.Player( briarmontHeroFrame );
			// Say why, don't swallow it. Four releases were debugged blind because
			// this was an empty function: a rejected setCurrentTime, or a play()
			// losing a race (player.js rejects that as PlayInterrupted), left no
			// trace at all. Still non-fatal -- the hero must never break on it --
			// but the name of the fault now reaches the console.
			const shrug = ( e ) => {
				if ( window.console && console.warn ) {
					console.warn( 'briarmont hero-video:', ( e && ( e.name || e.message ) ) || e );
				}
			};

			player.ready().then( () => {
				// The SDK is talking to the player, so silence from here IS
				// meaningful -- only now may the poster fallback arm.
				armPosterFallback();

				// The start point lives here rather than in a #t fragment on the
				// embed URL: the fragment fights Vimeo's own loop and stalls the
				// replay a few seconds in.
				if ( startTime > 0 ) {
					player.setCurrentTime( startTime ).catch( shrug );
				}

				player.on( 'play', markPlaying );
				player.on( 'playing', markPlaying );
				player.on( 'timeupdate', markPlaying );

				// setCurrentTime(0) hangs the Vimeo player indefinitely (player.js
				// issue #178); any non-zero value is fine. Only reachable when an end
				// time is set with no start time, which is a perfectly normal thing to
				// configure -- so clamp rather than trust it.
				const seekTarget = Math.max( startTime, 0.05 );

				// Native loop restarts at 0, which would skip the trimmed-in
				// opening on every pass after the first.
				player.on( 'ended', () => {
					player.setCurrentTime( seekTarget ).then( () => player.play() ).catch( shrug );
				} );

				// Is the trim worth a backwards seek at all?
				//
				// Every pass of a trimmed loop costs one long backwards seek, and a
				// backwards seek is the ONE thing that can stall this player -- the
				// clip plays perfectly right up to the cut. Vimeo's native loop, by
				// contrast, never stalls, because it restarts at a boundary it has
				// always got buffered.
				//
				// So weigh them. Trimming nine seconds off an eighty-five second clip
				// means seeking back seventy-six seconds, forever, to save nine: the
				// native loop is both smoother AND closer to what the owner wanted.
				// Trimming a sixty-second window out of a ten-minute film is a
				// different proposition and is worth the seek.
				//
				// Skip when the trim is marginal: under 15s removed, or under a
				// quarter of the clip. If the duration cannot be read we cut as
				// before -- never let a failed measurement silently disable a
				// deliberately configured trim.
				const decideCutoff = () => {
					if ( ! hasCutoff ) {
						return Promise.resolve( false );
					}
					// Promise.resolve().then(...) so a player that has no getDuration at
					// all throws INTO the chain rather than out of it -- a synchronous
					// throw here would take the whole cutoff feature down silently.
					return Promise.resolve().then( () => player.getDuration() ).then( ( dur ) => {
						if ( ! dur || dur <= 0 ) {
							return true;
						}
						const trimmed = dur - ( endTime - startTime );
						if ( trimmed < 15 || trimmed / dur < 0.25 ) {
							shrug( {
								name: 'HeroTrimSkipped',
								message: 'trim removes only ' + trimmed.toFixed( 1 ) + 's of ' + dur.toFixed( 1 )
									+ 's, which is not worth a ' + ( endTime - startTime ).toFixed( 1 )
									+ 's backwards seek every pass -- using the native loop instead.'
									+ ' To trim this hero, point it at a pre-trimmed clip and clear the timecodes.',
							} );
							return false;
						}
						return true;
					} ).catch( () => true );
				};

				decideCutoff().then( ( shouldCut ) => {
				if ( shouldCut ) {
					// One cut per pass, latched. setCurrentTime resolves as soon as the
					// seek is ACCEPTED, not once the clock has caught up -- so for a poll
					// or two afterwards getCurrentTime still reports the pre-seek time.
					// Clearing the guard on that promise therefore fires a second seek,
					// and a third, each restarting buffering: the clip appears to loop
					// correctly and then freeze a moment later. So stay latched until we
					// have SEEN the time come back below the cutoff.
					//
					// Even latched, a backwards seek is not free: Vimeo resumes "once
					// the video has buffered", and the trimmed-in start point has often
					// been evicted from the buffer by the time we jump back to it. That
					// re-fetch is a visible pause on every pass, and it is inherent to
					// trimming at playback time -- not something this island can code
					// away. So it watches the clip back up afterwards, and if the clip
					// does not actually get moving again it STOPS CUTTING for the rest
					// of the visit. A hero that ignores the end time is a nuisance; a
					// hero sitting frozen is a fault, and the native loop is the better
					// of the two failure modes.
					//
					// The real fix for a trimmed hero is a pre-trimmed source, which
					// loops with no seek at all.
					let cut = false;
					let cutAt = 0;
					let giveUp = false;

					// Giving up on cutting does NOT unfreeze a clip that is already
					// stuck -- the reported symptom is a hero sitting on a still frame
					// forever, which is exactly that gap. So escalate: nudge, then if it
					// is still not moving, reload the iframe. A reload always recovers,
					// because the embed is autoplay+muted+loop+background; the cost is a
					// brief flicker, which beats a dead hero. Once only, per page.
					let reloaded = false;

					const stillFrozen = ( mark ) => ( t ) => t <= mark + 0.05;

					const confirmResume = () => {
						let mark = null;
						setTimeout( () => {
							player.getCurrentTime().then( ( t ) => { mark = t; } ).catch( shrug );
						}, 1500 );
						setTimeout( () => {
							if ( null === mark ) {
								return;
							}
							player.getCurrentTime().then( ( t ) => {
								if ( ! stillFrozen( mark )( t ) ) {
									return; // moving again -- a slow re-buffer, not a stall
								}
								giveUp = true;                 // stop cutting for this visit
								player.play().catch( shrug );  // nudge it
								// Give the nudge a fair chance before the blunt instrument.
								setTimeout( () => {
									player.getCurrentTime().then( ( t2 ) => {
										if ( ! stillFrozen( mark )( t2 ) || reloaded ) {
											return;
										}
										reloaded = true;
										shrug( { name: 'HeroStalled', message: 'reloading the embed after a seek it never recovered from' } );
										briarmontHeroFrame.src = briarmontHeroFrame.src; // eslint-disable-line no-self-assign
									} ).catch( shrug );
								}, 3000 );
							} ).catch( shrug );
						}, 3000 );
					};

					setInterval( () => {
						if ( giveUp ) {
							return;
						}
						player.getCurrentTime().then( ( t ) => {
							if ( cut ) {
								// Re-arm on the real signal; the timeout is a self-heal so a
								// dropped seek cannot disable the cutoff for the whole visit.
								if ( t < endTime || Date.now() - cutAt > 6000 ) {
									cut = false;
								}
								return;
							}
							if ( t >= endTime ) {
								cut = true;
								cutAt = Date.now();
								// Background/looping players do not reliably resume after a
								// programmatic seek, so ask explicitly.
								player.setCurrentTime( seekTarget ).then( () => player.play() ).catch( shrug );
								confirmResume();
							}
						} ).catch( shrug );
					}, 250 );
				}
				} ).catch( shrug );
			} ).catch( shrug );
		};

		if ( window.Vimeo && window.Vimeo.Player ) {
			driveVimeo();
		} else {
			let tag = document.querySelector( 'script[src*="player.vimeo.com/api/player.js"]' );
			if ( ! tag ) {
				tag = document.createElement( 'script' );
				tag.src = 'https://player.vimeo.com/api/player.js';
				document.head.appendChild( tag );
			}
			// If the SDK never loads, nothing arms and nothing seeks: the hero is
			// exactly the plain looping embed it was before this island existed.
			tag.addEventListener( 'load', driveVimeo );
		}
	} else {
		// ---- YouTube: iframe Player API ----------------------------------
		// Everything below is the YouTube half of the hardening the Vimeo branch
		// got in 2.56.2-2.56.5. It was never ported, and all three faults were
		// reproducible against the shipped file: see tools/hero-yt-test.js.
		const shrug = ( e ) => {
			if ( window.console && console.warn ) {
				console.warn( 'briarmont hero-video:', ( e && ( e.name || e.message ) ) || e );
			}
		};

		let ytPlayer = null;

		const alive = () => {
			try {
				return 1 === ytPlayer.getPlayerState();   // PLAYING
			} catch ( e ) {
				return false;   // no player yet, or the bridge is gone
			}
		};

		// The strongest evidence that there is a working video back there is not a
		// state code at all -- it is the media clock moving. That is immune to the
		// missed-transition problem below, and unlike a BUFFERING state it cannot
		// be satisfied by a clip that is wedged and will never produce a frame.
		let lastSeen = null;
		const progressing = () => {
			try {
				const t = ytPlayer.getCurrentTime();
				if ( 'number' !== typeof t || isNaN( t ) ) {
					return false;
				}
				const moved = null !== lastSeen && t > lastSeen + 0.01;
				lastSeen = t;
				return moved;
			} catch ( e ) {
				return false;
			}
		};

		// Poster fallback, YouTube flavour.
		//
		// The shared arming starts a 4s timer the moment the island parses, and
		// decides on `played` alone. On YouTube that is wrong twice over:
		//
		//  1. The bridge may not exist yet. The iframe autoplays on its own while
		//     youtube.com/iframe_api is still downloading, so at the 4s mark there
		//     is no channel to ask -- and silence from a channel that does not
		//     exist is not evidence of failure. It was covering perfectly healthy
		//     video on any slow-ish load.
		//  2. Even once attached, `onStateChange` only fires on a TRANSITION. If
		//     the clip was already playing when the bridge came up, PLAYING never
		//     fires -- onReady does, and nothing else. `played` stayed false
		//     forever and the poster sat over a running video for the whole visit.
		//
		// So: ask the player instead of guessing, wait for the bridge before
		// judging, and keep re-checking afterwards so a late bridge UN-covers a
		// video the timer already gave up on.
		const POSTER_GRACE    = 4000;    // ...once we can actually ask the player
		const POSTER_DEADLINE = 9000;    // ...and regardless, if we never can
		const LIFE_WATCH      = 30000;   // keep second-guessing this long
		let armedAt = Date.now();
		let lastTick = armedAt;
		const watchLife = () => {
			const gap = Date.now() - lastTick;
			lastTick = Date.now();
			if ( played ) {
				return;
			}
			if ( alive() || progressing() ) {
				markPlaying();   // also clears the poster if we already raised it
				return;
			}
			// A hidden tab is not evidence of anything. The browser may have
			// paused the clip itself, timers are throttled, and nobody is looking
			// at the poster we would raise. So do not judge, and do not let the
			// deadline run down while we cannot see -- otherwise a tab left in the
			// background comes back showing the backup image over a fine video.
			if ( document.hidden ) {
				armedAt += gap;
				setTimeout( watchLife, 500 );
				return;
			}
			const waited = Date.now() - armedAt;
			if ( ( ytPlayer && waited >= POSTER_GRACE ) || waited >= POSTER_DEADLINE ) {
				showPoster();
			}
			if ( waited < LIFE_WATCH ) {
				setTimeout( watchLife, 500 );
			}
		};
		if ( heroPoster ) {
			setTimeout( watchLife, POSTER_GRACE );
		}

		const disableCaptions = ( player ) => {
			try {
				player.unloadModule( 'captions' );
				player.unloadModule( 'cc' );
			} catch ( e ) {
				/* module not present yet -- retried on state change */
			}
		};

		// ---- Loop cutoff, latched ----------------------------------------
		// seekTo() returns immediately but the clock does NOT jump with it: for
		// several hundred milliseconds afterwards getCurrentTime() still reports
		// the pre-seek position. The old unlatched poll therefore re-fired the
		// seek every 100ms until the first one landed -- 21 seekTo calls for one
		// cutoff, each restarting buffering, which is what wedged the player. The
		// clip looped, then froze a moment later. Exactly the stacked-seek fault
		// the Vimeo branch fixed in 2.56.2, on a poll three times as fast.
		//
		// So: one cut per pass, latched until we have SEEN the time come back
		// below the cutoff, with a timeout self-heal so a dropped seek cannot
		// disable the cutoff for the rest of the visit.
		let cutoffTimer = null;
		let cut = false;
		let cutAt = 0;
		let giveUp = false;
		let reloaded = false;

		// A backwards seek can still leave the player buffering forever, and that
		// is the other half of the report: a hero sitting on a still frame. Giving
		// up on cutting does not unfreeze a clip that is ALREADY stuck, so
		// escalate -- nudge it, then reload the embed. A reload always recovers,
		// because the iframe is autoplay+muted+loop; the cost is a brief flicker,
		// which beats a dead hero. Once only, per page.
		const confirmResume = ( player ) => {
			const read = () => {
				try {
					const t = player.getCurrentTime();
					return 'number' === typeof t && ! isNaN( t ) ? t : null;
				} catch ( e ) {
					return null;
				}
			};
			let mark = null;
			setTimeout( () => { mark = read(); }, 1500 );
			setTimeout( () => {
				const t = read();
				if ( null === mark || null === t || t > mark + 0.05 ) {
					return;   // moving again -- a slow re-buffer, not a stall
				}
				giveUp = true;                         // stop cutting for this visit
				try { player.playVideo(); } catch ( e ) { shrug( e ); }
				setTimeout( () => {
					const t2 = read();
					if ( null === t2 || t2 > mark + 0.05 || reloaded ) {
						return;
					}
					reloaded = true;
					shrug( { name: 'HeroStalled', message: 'reloading the embed after a seek it never recovered from' } );
					briarmontHeroFrame.src = briarmontHeroFrame.src; // eslint-disable-line no-self-assign
				}, 3000 );
			}, 3000 );
		};

		const watchCutoff = ( player ) => {
			if ( ! hasCutoff || cutoffTimer ) {
				return;
			}
			cutoffTimer = setInterval( () => {
				if ( giveUp ) {
					return;
				}
				let t;
				try {
					t = player.getCurrentTime();
				} catch ( e ) {
					return;   // player not ready
				}
				if ( 'number' !== typeof t || isNaN( t ) ) {
					return;
				}
				if ( cut ) {
					if ( t < endTime || Date.now() - cutAt > 6000 ) {
						cut = false;
					}
					return;
				}
				if ( t >= endTime ) {
					cut = true;
					cutAt = Date.now();
					try {
						player.seekTo( startTime, true );
					} catch ( e ) {
						shrug( e );
					}
					confirmResume( player );
				}
			}, 250 );
		};
		const stopCutoff = () => {
			if ( cutoffTimer ) {
				clearInterval( cutoffTimer );
				cutoffTimer = null;
			}
		};

		const boot = () => {
			if ( ! window.YT || ! window.YT.Player ) {
				return;
			}
			ytPlayer = new window.YT.Player( briarmontHeroFrame, {
				events: {
					onReady: ( e ) => {
						ytPlayer = e.target;
						disableCaptions( e.target );
						// The clip may well have been playing for seconds already,
						// in which case no PLAYING event is ever coming. Ask.
						if ( alive() ) {
							markPlaying();
							watchCutoff( e.target );
						}
					},
					// Re-assert on each play (covers the playlist loop reload).
					onStateChange: ( e ) => {
						if ( 1 === e.data || 3 === e.data ) {         // playing / buffering
							disableCaptions( e.target );
							watchCutoff( e.target );
							// Only PLAYING clears the poster. A clip that sits in
							// BUFFERING has not produced a frame, so the poster is
							// the right thing to show -- watchLife takes it back
							// down the moment the clock actually moves.
							if ( 1 === e.data ) {
								markPlaying();
							}
						} else if ( 2 === e.data || 0 === e.data ) {  // paused / ended
							stopCutoff();
						}
					},
					// Say why. The Vimeo branch stopped swallowing these in 2.56.4;
					// YouTube was still failing in total silence.
					onError: ( e ) => {
						shrug( { name: 'HeroPlayerError', message: 'YouTube player error ' + ( e && e.data ) } );
						if ( ! played ) {
							showPoster();   // a real failure, not a slow start
						}
					},
				},
			} );
		};

		if ( window.YT && window.YT.Player ) {
			boot();
		} else {
			// Chain onto any existing handler, then load the API once.
			const previous = window.onYouTubeIframeAPIReady;
			window.onYouTubeIframeAPIReady = () => {
				if ( typeof previous === 'function' ) {
					previous();
				}
				boot();
			};
			if ( ! document.querySelector( 'script[src*="youtube.com/iframe_api"]' ) ) {
				const tag = document.createElement( 'script' );
				tag.src = 'https://www.youtube.com/iframe_api';
				// A content blocker eating this script is the quietest way for the
				// hero to misbehave: the iframe still autoplays by itself, but we
				// lose captions-off, the cutoff, AND the ability to tell whether
				// anything is playing at all. Name it rather than wonder later.
				tag.addEventListener( 'error', () => shrug( {
					name: 'HeroApiBlocked',
					message: 'youtube.com/iframe_api did not load -- captions, loop cutoff and the poster fallback all go quiet',
				} ) );
				document.head.appendChild( tag );
			}
		}
	}
}
} );
// source --> https://briarmont-sl.com/wp-content/themes/briarmont/assets/js/video-lightbox.js?ver=1785213497 
/**
 * Video lightbox — click-to-play overlay for any [data-video-open] trigger.
 *
 * Nothing reaches YouTube/Vimeo until the visitor opens a video: the player is
 * created on open and removed on close (removal is also what stops playback —
 * pausing an iframe from outside is not possible without their JS API).
 *
 * Every trigger is a real link to the video, so this is pure enhancement: no JS
 * (or a failed load) leaves a working link to the platform. Like lightbox.js and
 * team-modal.js this is a plain [hidden]-toggled fixed overlay, NOT a native
 * <dialog> — showModal()'s top layer left a ghost frame after close in some
 * browsers. We own Esc, backdrop click, scroll-lock, focus-trap and restoration.
 */
/* Ready-guard -- see briarmont_enqueue_island(). */
( function ( island ) {
	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', island, { once: true } );
	} else {
		island();
	}
} )( function () {
	'use strict';

	var triggers = document.querySelectorAll( '[data-video-open]' );
	if ( ! triggers.length ) {
		return;
	}

	// ---- Poster fallback ---------------------------------------------------
	// YouTube serves a 120x90 grey placeholder for maxresdefault when an upload
	// has no max-res thumbnail. hqdefault always exists, so swap on that tell.
	Array.prototype.forEach.call( document.querySelectorAll( '[data-video-poster]' ), function ( img ) {
		function check() {
			if ( img.naturalWidth && img.naturalWidth <= 120 && img.src.indexOf( 'maxresdefault' ) !== -1 ) {
				img.src = img.src.replace( 'maxresdefault', 'hqdefault' );
			}
		}
		if ( img.complete ) {
			check();
		} else {
			img.addEventListener( 'load', check );
		}
	} );

	// ---- Overlay (built once, on first open) -------------------------------
	var overlay = null;
	var els = {};
	var lastTrigger = null;

	function build() {
		overlay = document.createElement( 'div' );
		overlay.className = 'vlb';
		overlay.hidden = true;
		overlay.setAttribute( 'role', 'dialog' );
		overlay.setAttribute( 'aria-modal', 'true' );
		overlay.setAttribute( 'aria-label', 'Video player' );
		overlay.innerHTML =
			'<div class="vlb__backdrop" data-vlb-close></div>' +
			'<div class="vlb__box">' +
				'<div class="vlb__bar">' +
					'<h2 class="vlb__title"></h2>' +
					'<button class="vlb__close" type="button" aria-label="Close video" data-vlb-close>&times;</button>' +
				'</div>' +
				'<div class="vlb__stage"></div>' +
			'</div>';
		document.body.appendChild( overlay );
		els = {
			box:   overlay.querySelector( '.vlb__box' ),
			title: overlay.querySelector( '.vlb__title' ),
			stage: overlay.querySelector( '.vlb__stage' ),
			close: overlay.querySelector( '.vlb__close' )
		};
		Array.prototype.forEach.call( overlay.querySelectorAll( '[data-vlb-close]' ), function ( el ) {
			el.addEventListener( 'click', close );
		} );
	}

	function player( src, isFile, title ) {
		if ( isFile ) {
			var video = document.createElement( 'video' );
			video.className = 'vlb__player';
			video.src = src;
			video.controls = true;
			video.autoplay = true;
			video.playsInline = true;
			return video;
		}
		var frame = document.createElement( 'iframe' );
		frame.className = 'vlb__player';
		frame.src = src;
		frame.title = title || 'Video';
		frame.allow = 'autoplay; fullscreen; picture-in-picture; encrypted-media';
		frame.allowFullscreen = true;
		frame.setAttribute( 'frameborder', '0' );
		return frame;
	}

	function open( trigger ) {
		var src = trigger.getAttribute( 'data-video-src' );
		if ( ! src ) {
			return false;
		}
		if ( ! overlay ) {
			build();
		}
		var title = trigger.getAttribute( 'data-video-title' ) || '';
		lastTrigger = trigger;
		els.title.textContent = title;
		els.title.hidden = ! title;
		overlay.setAttribute( 'aria-label', title ? ( title + ' — video player' ) : 'Video player' );
		els.stage.textContent = '';
		els.stage.appendChild( player( src, '1' === trigger.getAttribute( 'data-video-file' ), title ) );
		overlay.hidden = false;
		document.body.style.overflow = 'hidden';
		els.close.focus();
		return true;
	}

	function close() {
		if ( ! overlay || overlay.hidden ) {
			return;
		}
		overlay.hidden = true;
		document.body.style.overflow = '';
		els.stage.textContent = ''; // destroying the player is what stops playback
		if ( lastTrigger ) {
			try { lastTrigger.focus( { preventScroll: true } ); }
			catch ( e ) { lastTrigger.focus(); }
			lastTrigger = null;
		}
	}

	Array.prototype.forEach.call( triggers, function ( trigger ) {
		trigger.addEventListener( 'click', function ( ev ) {
			// Let modified clicks (new tab/window, middle-click) follow the href —
			// the trigger is a real link to the video and that is the intent.
			if ( ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || ( ev.button && 0 !== ev.button ) ) {
				return;
			}
			if ( open( this ) ) {
				ev.preventDefault();
			}
		} );
	} );

	document.addEventListener( 'keydown', function ( ev ) {
		if ( ! overlay || overlay.hidden ) {
			return;
		}
		if ( 'Escape' === ev.key || 'Esc' === ev.key ) {
			ev.preventDefault();
			close();
		} else if ( 'Tab' === ev.key ) {
			trapTab( ev );
		}
	} );

	// Contain focus within the box. Both directions also catch focus having
	// slipped OUT (a click on the non-focusable stage blurs to <body>) and pull
	// it back, so Tab can never reach the page behind the still-open overlay.
	function trapTab( ev ) {
		var focusable = Array.prototype.filter.call(
			els.box.querySelectorAll( 'button, a[href], video, iframe' ),
			function ( el ) { return ! el.hidden && null !== el.offsetParent; }
		);
		if ( ! focusable.length ) {
			return;
		}
		var first = focusable[ 0 ];
		var last = focusable[ focusable.length - 1 ];
		var active = document.activeElement;
		var outside = ! els.box.contains( active );
		if ( ev.shiftKey && ( active === first || outside ) ) {
			ev.preventDefault();
			last.focus();
		} else if ( ! ev.shiftKey && ( active === last || outside ) ) {
			ev.preventDefault();
			first.focus();
		}
	}
} );
// source --> https://briarmont-sl.com/wp-content/themes/briarmont/assets/js/reader.js?ver=1785213497 
/* Reader island: overlays for the lore article reader (.reader), the tour
   lightbox (.lightbox), and the person modal (.pm-overlay). Esc, scrim
   click, and body scroll lock (pages.jsx / township-pages.jsx / help.jsx). */
/* Ready-guard -- see briarmont_enqueue_island(). */
(function (island) {
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', island, { once: true });
	} else {
		island();
	}
})(function () {
	let overlay = null;

	function lockScroll(on) {
		document.body.style.overflow = on ? 'hidden' : '';
	}

	function close() {
		if (overlay) { overlay.remove(); overlay = null; lockScroll(false); }
	}

	function open(el) {
		close();
		overlay = el;
		document.body.appendChild(overlay);
		lockScroll(true);
		overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
	}

	document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });

	function placeholderDiv(label, tone, fill, ratio) {
		const tones = {
			forest: ['oklch(0.42 0.05 142)', 'oklch(0.36 0.055 142)'],
			harbor: ['oklch(0.5 0.05 232)', 'oklch(0.43 0.05 232)'],
			madrone: ['oklch(0.5 0.07 46)', 'oklch(0.43 0.07 46)'],
			fog: ['oklch(0.78 0.012 120)', 'oklch(0.72 0.012 120)'],
		};
		const t = tones[tone] || tones.forest;
		const div = document.createElement('div');
		div.className = 'ph';
		div.style.background = 'repeating-linear-gradient(135deg, ' + t[0] + ' 0 14px, ' + t[1] + ' 14px 28px)';
		if (fill) {
			div.style.cssText += 'position:absolute;inset:0;width:100%;height:100%;border-radius:inherit;';
		} else if (ratio) {
			div.style.aspectRatio = ratio;
		}
		const tag = document.createElement('span');
		tag.className = 'ph-tag';
		tag.textContent = label || '';
		div.appendChild(tag);
		return div;
	}

	/* ---- Lore article reader ---- */
	document.querySelectorAll('[data-reader-slug]').forEach((card) => {
		const openArticle = () => {
			const slug = card.dataset.readerSlug;
			if (!slug || !window.BRIARMONT) return;
			fetch(window.BRIARMONT.rest + '/lore/' + encodeURIComponent(slug))
				.then((r) => (r.ok ? r.json() : null))
				.then((a) => { if (a) showArticle(a); })
				.catch(() => {});
		};
		card.addEventListener('click', (e) => { e.preventDefault(); openArticle(); });
		card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openArticle(); } });
	});

	function showArticle(a) {
		const wrap = document.createElement('div');
		wrap.className = 'reader';
		const sheet = document.createElement('article');
		sheet.className = 'reader-sheet';
		sheet.addEventListener('click', (e) => e.stopPropagation());

		const closeBtn = document.createElement('button');
		closeBtn.className = 'reader-close';
		closeBtn.setAttribute('aria-label', 'Close');
		closeBtn.innerHTML = '&#10005;';
		closeBtn.addEventListener('click', close);
		sheet.appendChild(closeBtn);

		const cover = placeholderDiv(a.coverLabel, 'madrone', false, '16 / 7');
		cover.classList.add('reader-cover');
		sheet.appendChild(cover);

		const pad = document.createElement('div');
		pad.className = 'reader-pad';

		const kicker = document.createElement('span');
		kicker.className = 'kicker';
		kicker.textContent = a.category + ' ';
		const src = document.createElement('span');
		src.className = 'src src-wa';
		src.innerHTML = '<span class="src-dot"></span> Briarmont Wiki';
		kicker.appendChild(src);
		pad.appendChild(kicker);

		const title = document.createElement('h1');
		title.className = 'reader-title';
		title.textContent = a.title;
		pad.appendChild(title);

		const meta = document.createElement('div');
		meta.className = 'reader-meta mono';
		meta.textContent = (a.words || 0).toLocaleString() + ' words · updated ' + (a.updated || '') + ' · /' + (a.slug || '');
		pad.appendChild(meta);

		const lead = document.createElement('p');
		lead.className = 'reader-lead';
		lead.textContent = a.excerpt || '';
		pad.appendChild(lead);

		const body = document.createElement('p');
		body.className = 'reader-body';
		body.textContent = a.body || '';
		pad.appendChild(body);

		if (Array.isArray(a.tags) && a.tags.length) {
			const tags = document.createElement('div');
			tags.className = 'reader-tags';
			a.tags.forEach((t) => {
				const tag = document.createElement('span');
				tag.className = 'tag tag--ghost';
				tag.textContent = '#' + t;
				tags.appendChild(tag);
			});
			pad.appendChild(tags);
		}

		const link = document.createElement('a');
		link.className = 'reader-link mono';
		link.href = a.externalUrl || '#';
		link.target = '_blank';
		link.rel = 'noreferrer';
		link.innerHTML = 'Read in the wiki &#8599;';
		pad.appendChild(link);

		sheet.appendChild(pad);
		wrap.appendChild(sheet);
		open(wrap);
	}

	/* ---- Tour lightbox ---- */
	document.querySelectorAll('[data-lightbox]').forEach((fig) => {
		fig.style.cursor = 'pointer';
		fig.addEventListener('click', () => {
			const wrap = document.createElement('div');
			wrap.className = 'lightbox';
			const inner = document.createElement('div');
			inner.className = 'lightbox-inner';
			inner.addEventListener('click', (e) => e.stopPropagation());

			const closeBtn = document.createElement('button');
			closeBtn.className = 'reader-close';
			closeBtn.setAttribute('aria-label', 'Close');
			closeBtn.innerHTML = '&#10005;';
			closeBtn.addEventListener('click', close);
			inner.appendChild(closeBtn);

			if (fig.dataset.lightboxSrc) {
				const media = document.createElement('div');
				media.className = 'ph';
				media.style.aspectRatio = '16 / 9';
				const img = document.createElement('img');
				img.src = fig.dataset.lightboxSrc;
				img.alt = '';
				img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;';
				media.appendChild(placeholderDiv(fig.dataset.lightboxLabel, fig.dataset.lightboxTone, true));
				media.appendChild(img);
				inner.appendChild(media);
			} else {
				inner.appendChild(placeholderDiv(fig.dataset.lightboxLabel, fig.dataset.lightboxTone, false, '16 / 9'));
			}

			const cap = document.createElement('div');
			cap.className = 'lightbox-cap';
			const h3 = document.createElement('h3');
			h3.textContent = fig.dataset.lightboxTitle || '';
			const p = document.createElement('p');
			p.textContent = fig.dataset.lightboxDesc || '';
			cap.appendChild(h3);
			cap.appendChild(p);
			inner.appendChild(cap);

			wrap.appendChild(inner);
			open(wrap);
		});
	});

	/* ---- Person modal (The Team) ---- */
	document.querySelectorAll('[data-person]').forEach((card) => {
		card.addEventListener('click', () => {
			const d = card.dataset;
			const first = (d.personName || '').split(' ')[0];
			const wrap = document.createElement('div');
			wrap.className = 'pm-overlay';
			const inner = document.createElement('div');
			inner.className = 'pm-card';
			inner.addEventListener('click', (e) => e.stopPropagation());

			const closeBtn = document.createElement('button');
			closeBtn.className = 'pm-close';
			closeBtn.setAttribute('aria-label', 'Close');
			closeBtn.innerHTML = '&#215;';
			closeBtn.addEventListener('click', close);
			inner.appendChild(closeBtn);

			const photo = document.createElement('div');
			photo.className = 'pm-photo';
			photo.appendChild(placeholderDiv('photo - ' + (d.personName || ''), 'forest', true));
			if (d.personPhoto) {
				const img = document.createElement('img');
				img.src = d.personPhoto;
				img.alt = '';
				img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;';
				photo.appendChild(img);
			}
			inner.appendChild(photo);

			const body = document.createElement('div');
			body.className = 'pm-body';

			const role = document.createElement('span');
			role.className = 'team-role mono';
			role.textContent = d.personRole || '';
			body.appendChild(role);

			const name = document.createElement('h2');
			name.className = 'pm-name';
			name.textContent = d.personName || '';
			body.appendChild(name);

			const bio = document.createElement('p');
			bio.className = 'pm-bio';
			bio.textContent = d.personBio || '';
			body.appendChild(bio);

			let roles = [];
			try { roles = JSON.parse(d.personRoles || '[]'); } catch (e) { /* noop */ }
			if (roles.length) {
				const rolesWrap = document.createElement('div');
				rolesWrap.className = 'pm-roles';
				const label = document.createElement('span');
				label.className = 'pm-roles-label mono';
				label.textContent = 'What ' + first + ' does';
				rolesWrap.appendChild(label);
				const list = document.createElement('div');
				list.className = 'pm-roles-list';
				roles.forEach((r) => {
					const chip = document.createElement('span');
					chip.className = 'pm-role-chip mono';
					chip.textContent = r;
					list.appendChild(chip);
				});
				rolesWrap.appendChild(list);
				body.appendChild(rolesWrap);
			}

			if (d.personProfile) {
				const link = document.createElement('a');
				link.className = 'btn btn--primary pm-profile';
				link.href = d.personProfile;
				link.target = '_blank';
				link.rel = 'noreferrer';
				link.innerHTML = 'View full profile on Munibase &#8599;';
				body.appendChild(link);
			}

			inner.appendChild(body);
			wrap.appendChild(inner);
			open(wrap);
		});
	});
});