Jump to content
Toggle menu
  • 12,3 E szócikkek
  • 21,5 E fájlok
  • 5 felhasználók
  • 63,9 E szerkesztések
SCWIKI
Toggle preferences menu
Toggle personal menu
Nem vagy bejelentkezve
Your IP address will be publicly visible if you make any edits.

Megjegyzés: közzététel után frissítened kell a böngésződ gyorsítótárát, hogy lásd a változásokat.

  • Firefox / Safari: tartsd lenyomva a Shift gombot és kattints a Frissítés gombra a címsorban, vagy használd a Ctrl–F5 vagy Ctrl–R (Macen ⌘–R) billentyűkombinációt
  • Google Chrome: használd a Ctrl–Shift–R (Macen ⌘–Shift–R) billentyűkombinációt
  • Edge: tartsd nyomva a Ctrl-t, és kattints a Frissítés gombra, vagy nyomj Ctrl–F5-öt
/*
 * Gadget: mainpage
 *
 * Progressive enhancements for the Main page. Server-rendered markup carries
 * the full no-JS experience; each feature here reads its context from
 * data-gadget-mainpage-<key> attributes and layers behavior on top. Features
 * are independent boot functions — add new ones to EARLY if they only rewrite
 * text the server already rendered, to FEATURES otherwise.
 *
 * Loaded via MediaWiki:Gadgets-definition, gated on
 * categories=Pages using main page gadget, so it ships only on pages that
 * opt in by carrying that category.
 *
 * Features:
 *   heroImage — defers the hero's full-bleed artwork off the critical path.
 *     Contract: <div class="home-hero" data-gadget-mainpage-hero-src="<url>"
 *     data-gadget-mainpage-hero-alt="<alt, optional>"> containing
 *     .home-hero__media. After window load (never competing with first paint),
 *     an <img> is created and faded in via the home-hero--loaded class.
 *     Clients that ask to save data keep the solid ground.
 *
 *   liveStats — turns server-rendered stat numbers into odometers that roll
 *     changed digits upward, and keeps them current from the wiki's own API.
 *     Contract: any element carrying
 *       data-gadget-mainpage-stat="<siteinfo statistics key>"
 *     (articles | edits | users | …) whose text is the rendered number, e.g.
 *       <dd class="home-hero__stat-value" data-gadget-mainpage-stat="edits">376,643</dd>
 *     The number is split into per-digit reels; the plain value is kept in a
 *     visually-hidden span so assistive tech reads a number, not a reel, and
 *     updates stay silent (no aria-live — a counter must not natter).
 *     Polling is same-origin and CDN-cacheable (see the api URL note below),
 *     pauses while the tab is hidden or the statline is scrolled off, and backs
 *     off on failure. Without JS the server-rendered number simply stands.
 *
 *   searchReel — rolls the tail of the search label through a few things worth
 *     searching for, then rests on the real label.
 *     Contract: inside the search trigger, the label's tail is its own element
 *       <span class="home-hero__search-label">Search <span
 *         class="home-hero__search-tail"
 *         data-gadget-mainpage-search-tails="a|b|c">the Star Citizen Wiki</span></span>
 *     The element's own text is the resting tail; the attribute lists the
 *     alternatives, pipe-separated, and is authored in wikitext so the
 *     suggestions can be changed without touching this gadget. The reel starts
 *     and ends on the resting tail, so the label is never left saying something
 *     other than what the box is. It runs three cycles and stops — perpetual
 *     motion would owe a pause control under WCAG 2.2.2 — begins only once the
 *     field is on screen, and does not run at all under prefers-reduced-motion.
 *     The reel is aria-hidden behind a visually-hidden copy of the real label,
 *     the same arrangement liveStats uses, so assistive tech reads one stable
 *     phrase. Without JS the server-rendered label simply stands.
 *
 *   countdown — turns an event's dates into a live clock counting days, hours
 *     and minutes, in whichever of the three tenses the dates put it in.
 *     Contract: an element from [[Module:Countdown]] carrying
 *       data-gadget-mainpage-countdown-end="<ISO 8601>"
 *       data-gadget-mainpage-countdown-start="<ISO 8601, optional>"
 *       data-gadget-mainpage-countdown-label="<override, optional>"
 *     whose only child is the server's date range. The module ships dates and
 *     never a duration, because a duration computed at parse time is frozen in
 *     the parser cache and goes quietly wrong; all the arithmetic is here,
 *     against the reader's own clock. Going live replaces the range with a
 *     label and three odometer units and adds t-countdown--live, plus one of
 *     t-countdown--upcoming / --running / --ended, re-asserted every tick so a
 *     card left open across an event's end stops claiming to be running. Those
 *     classes are the ONLY thing the tense publishes, and they go on the
 *     countdown element itself — never on the card around it — so a card can
 *     host a clock without learning what one is. The module's stylesheet owns
 *     what each state looks like, INCLUDING the room this will need: the range
 *     and the clock are different heights, so the stylesheet reserves the
 *     taller in both states and the swap costs no layout. It ticks on the
 *     minute rather than the second — a per-second clock is motion the reader
 *     cannot pause, which WCAG 2.2.2 would make us owe a control for — pauses
 *     with the tab, and rolls only once the card is on screen. Without JS the
 *     date range stands, and unlike a stale duration it never stops being true.
 *
 *   scrollFade — says whether a scrolling card has more above or below.
 *     Contract: a frame carrying
 *       data-gadget-mainpage-scrollfade="<selector for its scroller>"
 *     on which the CSS paints two gradients, both transparent until this says
 *     otherwise. The default is INVISIBLE on purpose: CSS cannot see how far a
 *     box is scrolled, so a fade it paints unconditionally covers the last line
 *     when the content fits exactly and still promises more at the end of a
 *     long list. Without JS there is no fade and the scrollbar is the cue.
 *     Frame and scroller are separate elements because a pseudo-element inside
 *     a scroller is positioned against the scrolled content and slides away
 *     with the rows it is covering; the offset between them is published as
 *     --home-fade-top / --home-fade-bottom so one CSS rule serves both cards.
 *
 *   recentActivity — keeps the recent-changes list current and turns its
 *     absolute timestamps into ages. Contract: a container carrying
 *       data-gadget-mainpage-activity
 *       data-gadget-mainpage-activity-limit="<rows>"
 *       data-gadget-mainpage-activity-namespace="<ns id>"
 *       data-gadget-mainpage-activity-exclude="<username to drop>"
 *     holding .home-act__row children, each with an <a>, a .home-act__by and a
 *     <time class="home-act__when" datetime="<ISO 8601>">. The server renders
 *     the rows with DPL and they stand on their own without JS, but they are
 *     FIRST PAINT and never the freshness mechanism: DPL pins the page's parser
 *     cache to an hour, so this polls list=recentchanges off a CDN-cacheable
 *     URL and keeps the list live while the reader is on the page. As with the
 *     countdown the server ships instants and never durations, because a
 *     duration in a parser cache stops being true without saying so.
 */
( function () {
	'use strict';

	const HERO_SRC_ATTR = 'data-gadget-mainpage-hero-src';
	const HERO_ALT_ATTR = 'data-gadget-mainpage-hero-alt';
	const STAT_ATTR = 'data-gadget-mainpage-stat';
	const TAILS_ATTR = 'data-gadget-mainpage-search-tails';
	const COUNTDOWN_START_ATTR = 'data-gadget-mainpage-countdown-start';
	const COUNTDOWN_END_ATTR = 'data-gadget-mainpage-countdown-end';
	const COUNTDOWN_LABEL_ATTR = 'data-gadget-mainpage-countdown-label';
	const ACTIVITY_ATTR = 'data-gadget-mainpage-activity';
	const ACTIVITY_LIMIT_ATTR = 'data-gadget-mainpage-activity-limit';
	const ACTIVITY_NS_ATTR = 'data-gadget-mainpage-activity-namespace';
	const ACTIVITY_EXCLUDE_ATTR = 'data-gadget-mainpage-activity-exclude';
	const SCROLLFADE_ATTR = 'data-gadget-mainpage-scrollfade';

	const POLL_MS = 60000;
	const MAX_BACKOFF_MS = 960000;
	const CACHE_S = 60;
	const FETCH_TIMEOUT_MS = 10000;
	const ROLL_MS = 520;
	const ROLL_STAGGER_MS = 45;
	const CYCLE = 10;
	const TAIL_STEP_EM = 1.5;
	const TAIL_HOLD_MS = 2600;
	const TAIL_CYCLES = 3;
	const MINUTE_MS = 60000;
	const HOUR_MS = 3600000;
	const DAY_MS = 86400000;
	const COUNTDOWN_UNITS = [ 'Days', 'Hours', 'Minutes' ];
	// 120s is the measured median gap between edits here; a longer TTL leaves
	// the card a burst behind however often it polls. The poll must never be
	// SLOWER than the TTL, or the shared entry expires unattended.
	const ACTIVITY_CACHE_S = 120;
	const ACTIVITY_POLL_MS = 60000;
	const ACTIVITY_POOL = 50;
	const ACTIVITY_ROOT_MARGIN = '400px';
	const TEMP_USER_RE = /^~\d{4}-/;
	// Trailing-edge throttle for re-measuring: coalesces a burst without
	// dropping the last event.
	const FADE_SETTLE_MS = 60;
	const RELATIVE_STEPS = [
		{ unit: 'year', span: 31536000000, short: 'y' },
		{ unit: 'month', span: 2592000000, short: 'mo' },
		{ unit: 'week', span: 604800000, short: 'w' },
		{ unit: 'day', span: 86400000, short: 'd' },
		{ unit: 'hour', span: 3600000, short: 'h' },
		{ unit: 'minute', span: 60000, short: 'm' }
	];

	function heroImage() {
		document.querySelectorAll( '.home-hero[' + HERO_SRC_ATTR + ']' ).forEach( ( hero ) => {
			const media = hero.querySelector( '.home-hero__media' );
			const src = hero.getAttribute( HERO_SRC_ATTR );
			if ( !media || !src ) {
				return;
			}
			const conn = navigator.connection;
			if ( conn && conn.saveData ) {
				return;
			}
			const img = document.createElement( 'img' );
			img.alt = hero.getAttribute( HERO_ALT_ATTR ) || '';
			img.decoding = 'async';
			img.addEventListener( 'load', () => hero.classList.add( 'home-hero--loaded' ) );
			img.src = src;
			media.appendChild( img );
		} );
	}

	/**
	 * One stat number, rendered as per-digit reels.
	 *
	 * At rest a digit is a single cell, not a hidden ten-or-more strip: three
	 * nodes per digit instead of thirty-two. The cells a roll travels through are
	 * built for the length of that roll and collapsed again afterwards, so the
	 * Main Page never carries hundreds of permanently invisible spans to animate
	 * a number that changes a couple of times an hour.
	 *
	 * Building the exact path also removes the index bookkeeping entirely: a reel
	 * always starts its roll at cell 0 and ends at cell `steps`, so there is no
	 * cycle to be parked in and no way for a rising value to travel backwards.
	 *
	 * The reels are structural, not the hero's: `.mainpage-odometer*` is styled
	 * by this gadget's own stylesheet and inherits size, weight and colour from
	 * whatever it was built inside, so the same function drives a 13px figure in
	 * the statline and a 3rem digit in an event countdown. Only the visually
	 * hidden readout's class is the caller's, because that one has to be styled
	 * by a stylesheet the caller's markup can count on.
	 *
	 * @param {HTMLElement} node element whose text is the rendered number
	 * @param {Object} options
	 * @param {boolean} options.reduced honour prefers-reduced-motion
	 * @param {Function} options.format number formatter
	 * @param {string} options.srClass class for the visually hidden readout
	 * @return {Object} odometer with node, settle() and set()
	 */
	function makeOdometer( node, options ) {
		const reduced = options.reduced;
		const format = options.format;
		let slots = [];
		let readout = null;
		let shape = '';
		let value = Number( String( node.textContent ).replace( /[^0-9]/g, '' ) ) || 0;
		let settled = false;

		function shapeOf( str ) {
			return str.replace( /[0-9]/g, '#' );
		}

		function cell( digit ) {
			const c = document.createElement( 'span' );
			c.textContent = String( digit );
			return c;
		}

		// Collapse a reel back to the single digit it now shows.
		function rest( slot ) {
			clearTimeout( slot.timer );
			slot.timer = null;
			const reel = slot.reel;
			reel.style.transition = 'none';
			reel.style.transitionDelay = '';
			reel.style.transform = 'translateY(0)';
			reel.textContent = '';
			reel.appendChild( cell( slot.digit ) );
			void reel.offsetHeight;
			reel.style.transition = '';
		}

		// Load a reel with a path of digits and run it. `from`/`to` are cell
		// indices; travelling to a higher index moves the reel upward.
		function run( slot, path, from, to, delay ) {
			if ( slot.timer ) {
				rest( slot );
			}
			const reel = slot.reel;
			reel.textContent = '';
			path.forEach( ( digit ) => reel.appendChild( cell( digit ) ) );
			reel.style.transition = 'none';
			reel.style.transform = 'translateY(-' + from + 'em)';
			void reel.offsetHeight;
			reel.style.transition = '';
			reel.style.transitionDelay = delay ? delay + 'ms' : '';
			reel.style.transform = 'translateY(-' + to + 'em)';
			slot.timer = setTimeout( () => rest( slot ), ROLL_MS + delay + 60 );
		}

		// Roll one digit to `next`, climbing when the number rose.
		function roll( slot, next, rising, delay ) {
			const steps = rising ?
				( next - slot.digit + CYCLE ) % CYCLE :
				( slot.digit - next + CYCLE ) % CYCLE;
			if ( !steps ) {
				return;
			}
			const first = rising ? slot.digit : next;
			const path = [];
			for ( let i = 0; i <= steps; i++ ) {
				path.push( ( first + i ) % CYCLE );
			}
			slot.digit = next;
			run( slot, path, rising ? 0 : steps, rising ? steps : 0, delay );
		}

		// A full turn that lands where it started — the reveal.
		function turn( slot, delay ) {
			const path = [];
			for ( let i = 0; i <= CYCLE; i++ ) {
				path.push( ( slot.digit + i ) % CYCLE );
			}
			run( slot, path, 0, CYCLE, delay );
		}

		function build( str ) {
			slots = [];
			node.textContent = '';

			readout = document.createElement( 'span' );
			readout.className = options.srClass;
			readout.textContent = str;
			node.appendChild( readout );

			const reelRow = document.createElement( 'span' );
			reelRow.className = 'mainpage-odometer';
			reelRow.setAttribute( 'aria-hidden', 'true' );

			str.split( '' ).forEach( ( ch ) => {
				if ( ch >= '0' && ch <= '9' ) {
					const window_ = document.createElement( 'span' );
					window_.className = 'mainpage-odometer__digit';
					const reel = document.createElement( 'span' );
					reel.className = 'mainpage-odometer__reel';
					reel.appendChild( cell( Number( ch ) ) );
					window_.appendChild( reel );
					reelRow.appendChild( window_ );
					slots.push( { reel: reel, digit: Number( ch ), timer: null } );
				} else {
					const sep = document.createElement( 'span' );
					sep.className = 'mainpage-odometer__sep';
					sep.textContent = ch;
					reelRow.appendChild( sep );
				}
			} );

			node.appendChild( reelRow );
			shape = shapeOf( str );
		}

		function render( next, animate ) {
			const str = format( next );

			if ( !slots.length || shapeOf( str ) !== shape ) {
				slots.forEach( ( slot ) => clearTimeout( slot.timer ) );
				build( str );
				value = next;
				return;
			}

			readout.textContent = str;

			const rising = next >= value;
			let cursor = 0;
			str.split( '' ).forEach( ( ch ) => {
				if ( ch < '0' || ch > '9' ) {
					return;
				}
				const slot = slots[ cursor++ ];
				const digit = Number( ch );
				if ( digit === slot.digit ) {
					return;
				}
				if ( !animate || reduced ) {
					slot.digit = digit;
					rest( slot );
					return;
				}
				// Carry propagates leftward: the ones column leads.
				roll( slot, digit, rising, ( slots.length - cursor ) * ROLL_STAGGER_MS );
			} );

			value = next;
		}

		// The reveal: every reel spins one full turn and lands on the digit it
		// started on. Starting from the true value means the resting number is
		// never wrong — only obviously in motion — so a hero that is already on
		// screen at boot never flashes a false figure.
		function settle() {
			if ( settled ) {
				return;
			}
			settled = true;
			if ( reduced ) {
				return;
			}
			slots.forEach( ( slot, i ) => turn( slot, i * ROLL_STAGGER_MS ) );
		}

		render( value, false );

		return {
			node: node,
			settle: settle,
			set: function ( next ) {
				if ( next !== value ) {
					render( next, settled );
				}
			}
		};
	}

	function liveStats() {
		const nodes = document.querySelectorAll( '[' + STAT_ATTR + ']' );
		if ( !nodes.length || typeof mw === 'undefined' ) {
			return;
		}

		const reduced = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
		const format = new Intl.NumberFormat( mw.config.get( 'wgContentLanguage' ) || undefined ).format;
		const meters = [];
		nodes.forEach( ( node ) => meters.push( makeOdometer( node, {
			reduced: reduced,
			format: format,
			srClass: 'home-hero__sr'
		} ) ) );

		// smaxage/maxage make this response public and CDN-cacheable: without them
		// api.php answers `private, must-revalidate, max-age=0` and every reader's
		// poll punches through to MediaWiki. With them the CDN collapses the whole
		// audience into roughly one origin request per window. credentials:'omit'
		// below is load-bearing for the same reason — see recentActivity's own
		// note, which spells out the mechanism.
		const api = mw.config.get( 'wgScriptPath' ) +
			'/api.php?action=query&meta=siteinfo&siprop=statistics&format=json&formatversion=2' +
			'&maxage=' + CACHE_S + '&smaxage=' + CACHE_S;
		let timer = null;
		let failures = 0;
		let inFlight = null;
		let onScreen = false;

		function schedule( ms ) {
			clearTimeout( timer );
			timer = setTimeout( poll, ms );
		}

		// A hidden tab and a statline scrolled off the top both keep the timer
		// alive but skip the request. The hero is the first thing past, so
		// without the second test this polls all day for a reader who is not
		// looking at it.
		function poll() {
			if ( document.hidden || !onScreen ) {
				schedule( POLL_MS );
				return;
			}
			// Returning to the tab calls poll() directly while a timer may also be
			// due, so guard against two reads racing. The abort keeps that guard
			// from wedging the loop if a request never settles.
			if ( inFlight ) {
				return;
			}
			const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
			inFlight = controller || true;
			const giveUp = controller && setTimeout( () => controller.abort(), FETCH_TIMEOUT_MS );
			const done = () => {
				clearTimeout( giveUp );
				inFlight = null;
			};
			fetch( api, { credentials: 'omit', signal: controller ? controller.signal : undefined } )
				.then( ( res ) => res.json() )
				.then( ( data ) => {
					const stats = data && data.query && data.query.statistics;
					if ( !stats ) {
						throw new Error( 'no statistics' );
					}
					failures = 0;
					done();
					meters.forEach( ( meter ) => {
						const key = meter.node.getAttribute( STAT_ATTR );
						if ( typeof stats[ key ] === 'number' ) {
							meter.set( stats[ key ] );
						}
					} );
					schedule( POLL_MS );
				} )
				.catch( () => {
					failures++;
					done();
					schedule( Math.min( POLL_MS * Math.pow( 2, failures ), MAX_BACKOFF_MS ) );
				} );
		}

		// One observer, two jobs: it rolls the digits in the first time they are
		// on screen, Stripe-style, and thereafter it is the on/off switch for
		// polling. settle() guards on its own flag, so re-entry is free.
		if ( 'IntersectionObserver' in window ) {
			new IntersectionObserver( ( entries ) => {
				entries.forEach( ( entry ) => {
					const was = onScreen;
					onScreen = entry.isIntersecting;
					if ( onScreen ) {
						meters.forEach( ( meter ) => meter.settle() );
						if ( !was ) {
							schedule( 0 );
						}
					}
				} );
			}, { threshold: 0.4 } ).observe( meters[ 0 ].node );
		} else {
			onScreen = true;
			meters.forEach( ( meter ) => meter.settle() );
		}

		document.addEventListener( 'visibilitychange', () => {
			if ( !document.hidden ) {
				poll();
			}
		} );

		// One read on arrival: {{NUMBEROFEDITS}} and friends are parser-cached and
		// can be hours stale, so this is the correction the reveal actually rolls.
		// Via the timer, so the observer above has settled onScreen first.
		schedule( 0 );
	}

	/**
	 * Roll the search label's tail through a short list of suggestions.
	 *
	 * The strip is built once and stepped with a single transform per roll —
	 * the same mechanism, easing and duration as the stat odometer directly
	 * above it, so the two read as one idea rather than two effects competing
	 * in the same corner of the hero.
	 */
	function searchReel() {
		const tail = document.querySelector( '[' + TAILS_ATTR + ']' );
		if ( !tail ) {
			return;
		}
		if ( window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ) {
			return;
		}

		const resting = tail.textContent.trim();
		const others = ( tail.getAttribute( TAILS_ATTR ) || '' )
			.split( '|' )
			.map( ( t ) => t.trim() )
			.filter( ( t ) => t && t !== resting );
		if ( !others.length ) {
			return;
		}

		// Start AND end on the real tail: the strip's last cell repeats the
		// first, so the roll always lands on the label and the snap back to
		// cell 0 is invisible.
		const path = [ resting ].concat( others ).concat( [ resting ] );

		// A visually-hidden copy carries the real label for assistive tech; it
		// sits outside the reel because the reel itself is aria-hidden.
		const readout = document.createElement( 'span' );
		readout.className = 'home-hero__sr';
		readout.textContent = resting;
		tail.parentNode.insertBefore( readout, tail );

		const reel = document.createElement( 'span' );
		reel.className = 'home-hero__search-reel';
		path.forEach( ( text ) => {
			const cell = document.createElement( 'span' );
			cell.textContent = text;
			reel.appendChild( cell );
		} );

		tail.setAttribute( 'aria-hidden', 'true' );
		tail.textContent = '';
		tail.appendChild( reel );

		let index = 0;
		let cycles = 0;
		let timer = null;

		function snap() {
			reel.style.transition = 'none';
			reel.style.transform = 'translateY(0)';
			index = 0;
			void reel.offsetHeight;
			reel.style.transition = '';
		}

		function step() {
			index += 1;
			reel.style.transform = 'translateY(-' + ( index * TAIL_STEP_EM ) + 'em)';
			if ( index >= path.length - 1 ) {
				cycles += 1;
				setTimeout( snap, ROLL_MS + 60 );
			}
		}

		function schedule() {
			if ( cycles >= TAIL_CYCLES ) {
				return;
			}
			timer = setTimeout( () => {
				step();
				schedule();
			}, TAIL_HOLD_MS );
		}

		// Only once it is actually on screen, so the three cycles are not spent
		// above the fold of someone who never looks.
		if ( 'IntersectionObserver' in window ) {
			const seen = new IntersectionObserver( ( entries ) => {
				entries.forEach( ( entry ) => {
					if ( entry.isIntersecting ) {
						schedule();
						seen.disconnect();
					}
				} );
			}, { threshold: 0.5 } );
			seen.observe( tail );
		} else {
			schedule();
		}

		document.addEventListener( 'visibilitychange', () => {
			if ( document.hidden && timer ) {
				clearTimeout( timer );
				timer = null;
			} else if ( !document.hidden && !timer ) {
				schedule();
			}
		} );
	}

	/**
	 * One event countdown, driven by the reader's own clock.
	 *
	 * The server hands over two dates and a date range; everything a reader sees
	 * as a duration is worked out here, on every tick, because a duration is the
	 * one thing a parser cache cannot hold without eventually lying about it.
	 *
	 * Which of the three tenses applies is a property of now, not of the markup,
	 * so it is recomputed each minute rather than latched at boot: a card left
	 * open across the end of an event rewrites itself from "Ends" to "Ended"
	 * without a reload, and one opened before the event starts counts down to
	 * the opening and then keeps going to the close.
	 *
	 * @param {HTMLElement} node element carrying the countdown attributes
	 * @param {boolean} reduced honour prefers-reduced-motion
	 */
	function makeCountdown( node, reduced ) {
		const ends = Date.parse( node.getAttribute( COUNTDOWN_END_ATTR ) );
		const startsAttr = node.getAttribute( COUNTDOWN_START_ATTR );
		const starts = startsAttr ? Date.parse( startsAttr ) : NaN;
		const override = node.getAttribute( COUNTDOWN_LABEL_ATTR );

		// An unreadable date leaves the server's range standing, which is the
		// right failure: a range nobody animated still tells the truth.
		if ( isNaN( ends ) ) {
			return;
		}

		const meters = [];
		let label = null;
		let timer = null;

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

		// Which side of the dates the reader is standing on. `past` is not only a
		// choice of words: it flips the clock from counting down to a moment to
		// counting up away from one, which is also what decides when the next
		// minute lands.
		//
		// The words are one syllable shorter than they read: "Ends" against
		// "Ended" is a tense pair, so the verb carries the direction the
		// preposition used to. That matters because this clock counts BOTH ways —
		// down while running, up once finished — and a label that only named the
		// state ("Active") would leave "02 DAYS" meaning either.
		function reading( now ) {
			const early = !isNaN( starts ) && now < starts;
			const at = early ? starts : ends;
			const past = !early && now >= ends;
			let text = 'Ends';
			if ( early ) {
				text = 'Coming';
			} else if ( past ) {
				text = 'Ended';
			}
			return {
				at: at,
				past: past,
				early: early,
				label: override || text,
				delta: Math.abs( at - now )
			};
		}

		function parts( delta ) {
			return [
				Math.floor( delta / DAY_MS ),
				Math.floor( ( delta % DAY_MS ) / HOUR_MS ),
				Math.floor( ( delta % HOUR_MS ) / MINUTE_MS )
			];
		}

		function build( state ) {
			const values = parts( state.delta );
			node.textContent = '';

			label = document.createElement( 'div' );
			label.className = 't-countdown__label';
			label.textContent = state.label;
			node.appendChild( label );

			COUNTDOWN_UNITS.forEach( ( name, i ) => {
				const unit = document.createElement( 'div' );
				unit.className = 't-countdown__unit';
				const value = document.createElement( 'span' );
				value.className = 't-countdown__value';
				// Seed the real figure, not a placeholder: the odometer reads its
				// starting value off this text, and a two-digit seed under a
				// three-digit count would make it rebuild itself immediately.
				value.textContent = pad( values[ i ] );
				const caption = document.createElement( 'span' );
				caption.className = 't-countdown__unit-label';
				caption.textContent = name;
				unit.appendChild( value );
				unit.appendChild( caption );
				node.appendChild( unit );
				meters.push( makeOdometer( value, {
					reduced: reduced,
					format: pad,
					srClass: 't-countdown__sr'
				} ) );
			} );

			// Last, so the grid is only asked to lay out a finished clock.
			node.classList.add( 't-countdown--live' );
		}

		function tick() {
			const state = reading( Date.now() );
			label.textContent = state.label;
			parts( state.delta ).forEach( ( value, i ) => meters[ i ].set( value ) );

			// Re-asserted every minute, not latched at boot: a card left open
			// across the end of an event has to stop claiming to be running. The
			// classes go on the countdown and nowhere else, so the card hosting it
			// needs to know nothing about clocks.
			node.classList.toggle( 't-countdown--upcoming', state.early );
			node.classList.toggle( 't-countdown--running', !state.early && !state.past );
			node.classList.toggle( 't-countdown--ended', state.past );

			// Sleep to the next boundary instead of polling for it. Counting
			// down, the display changes when the remainder runs out; counting up,
			// when it fills. One timer a minute either way, and re-reading the
			// clock each time means it cannot drift.
			let wait = state.past ?
				MINUTE_MS - ( state.delta % MINUTE_MS ) :
				state.delta % MINUTE_MS;
			if ( wait <= 0 ) {
				wait = MINUTE_MS;
			}
			timer = setTimeout( tick, wait );
		}

		build( reading( Date.now() ) );

		// Digits roll only once the card is actually on screen, the same rule the
		// statline follows, so the reveal is not spent above someone's fold.
		if ( 'IntersectionObserver' in window ) {
			const seen = new IntersectionObserver( ( entries ) => {
				entries.forEach( ( entry ) => {
					if ( entry.isIntersecting ) {
						meters.forEach( ( meter ) => meter.settle() );
						seen.disconnect();
					}
				} );
			}, { threshold: 0.4 } );
			seen.observe( node );
		} else {
			meters.forEach( ( meter ) => meter.settle() );
		}

		// A hidden tab holds no timer; coming back re-reads the clock rather than
		// resuming from where it was, so time passed away from the page is simply
		// caught up on.
		document.addEventListener( 'visibilitychange', () => {
			if ( document.hidden ) {
				clearTimeout( timer );
				timer = null;
			} else if ( !timer ) {
				tick();
			}
		} );

		tick();
	}

	function countdown() {
		const nodes = document.querySelectorAll( '[' + COUNTDOWN_END_ATTR + ']' );
		if ( !nodes.length ) {
			return;
		}
		const reduced = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
		nodes.forEach( ( node ) => makeCountdown( node, reduced ) );
	}

	/**
	 * The recent-activity list: what the wiki has been doing, in the last few
	 * minutes rather than the last hour.
	 *
	 * The rows are server-rendered by DPL and are the entire feature with JS off.
	 * What this adds is the two things a parser cache cannot do:
	 *
	 *   Freshness. DPL renders inside the page's parser cache and the CDN sits
	 *   on top of that, so the rows are FIRST PAINT and never the freshness
	 *   mechanism. Polling list=recentchanges is what keeps them true.
	 *
	 *   Relative time. The server ships an absolute ISO instant and never a
	 *   duration, for the reason the countdown does: a duration computed at
	 *   parse time is frozen in the cache and goes quietly wrong.
	 *
	 * It POLLS because of the edit pattern, not as a preference: this wiki
	 * averages an edit an hour, but the median gap between consecutive edits is
	 * two minutes. It is quiet for long stretches and then someone sits down and
	 * works, which is exactly when a one-shot read fails.
	 *
	 * Three rules about the URL, all load-bearing:
	 *
	 *   maxage/smaxage are what make it cacheable at all — without them api.php
	 *   answers `private` and the CDN reports BYPASS. They must be EQUAL.
	 *
	 *   Nothing reader-specific may enter it, and it must NEVER be cache-busted.
	 *   This CDN ignores the response's `Vary: Cookie` (measured: an anonymous
	 *   MISS populated the entry, a cookie-bearing request then got a HIT off
	 *   it), so ONE entry serves everybody — a per-reader URL would hand one
	 *   reader's body to another and turn a TTL-bound origin load into a
	 *   request-bound one.
	 *
	 *   credentials:'omit' stays for MediaWiki's reason, not the CDN's: a
	 *   credentialed api.php response is `private` and bypasses the cache.
	 *
	 * Deliberately NOT folded into the statline's poll — a combined query has to
	 * be re-fetched on the shortest cadence in it.
	 *
	 * Contract: a container carrying
	 *   data-gadget-mainpage-activity           (marks the list)
	 *   data-gadget-mainpage-activity-limit     (rows to show; matches DPL count)
	 *   data-gadget-mainpage-activity-namespace (namespace id to read)
	 *   data-gadget-mainpage-activity-exclude   (username to drop; see load())
	 * holding rows of
	 *   <div class="home-act__row"><a>Page</a>
	 *     <span class="home-act__by">User</span>
	 *     <time class="home-act__when" datetime="<ISO 8601>">…</time></div>
	 */
	function recentActivity() {
		const list = document.querySelector( '[' + ACTIVITY_ATTR + ']' );
		if ( !list || typeof mw === 'undefined' ) {
			return;
		}

		const limit = Number( list.getAttribute( ACTIVITY_LIMIT_ATTR ) ) || 8;
		const namespace = list.getAttribute( ACTIVITY_NS_ATTR ) || '0';
		const exclude = list.getAttribute( ACTIVITY_EXCLUDE_ATTR ) || '';
		const lang = mw.config.get( 'wgUserLanguage' ) ||
			mw.config.get( 'wgContentLanguage' ) || undefined;
		const relative = typeof Intl !== 'undefined' && Intl.RelativeTimeFormat ?
			new Intl.RelativeTimeFormat( lang, { numeric: 'auto' } ) : null;
		const absolute = typeof Intl !== 'undefined' && Intl.DateTimeFormat ?
			new Intl.DateTimeFormat( lang, { dateStyle: 'medium', timeStyle: 'short' } ) : null;
		let timer = null;
		let pollTimer = null;
		let failures = 0;
		let inFlight = null;
		let onScreen = false;

		/**
		 * Write one timestamp as an age.
		 *
		 * Compact on purpose. This column sits at the end of a single-line row
		 * and has to hold still: "4h" keeps its width from one minute to the
		 * next, where "4 hours ago" becoming "5 hours ago" would shift the row
		 * under the reader. The long form is not lost — it goes to aria-label,
		 * so a screen reader hears a phrase rather than "four h", and to title
		 * for a pointer, which also gets the absolute date.
		 *
		 * @param {HTMLElement} node the <time> element
		 * @param {number} now epoch ms to measure against
		 */
		function stamp( node, now ) {
			const iso = node.getAttribute( 'datetime' );
			const at = Date.parse( iso );
			if ( isNaN( at ) ) {
				return;
			}
			// Clamped at zero: a reader whose clock is a minute slow should see
			// "now", not a negative age counting the wrong way.
			const delta = Math.max( 0, now - at );
			let text = 'now';
			let phrase = 'just now';
			for ( let i = 0; i < RELATIVE_STEPS.length; i++ ) {
				const step = RELATIVE_STEPS[ i ];
				const n = Math.floor( delta / step.span );
				if ( n >= 1 ) {
					text = n + step.short;
					phrase = relative ? relative.format( -n, step.unit ) : text;
					break;
				}
			}
			if ( node.textContent === text ) {
				return;
			}
			node.textContent = text;
			node.setAttribute( 'aria-label', phrase );
			node.title = absolute ? phrase + ' — ' + absolute.format( at ) : iso;
		}

		// Re-read on the minute boundary rather than every sixty seconds from
		// boot, so the whole column turns over together instead of each row
		// drifting by whatever second it was first painted on. stamp() bails on
		// an unchanged label, so a list of week-old edits costs no DOM writes.
		function age() {
			const now = Date.now();
			list.querySelectorAll( 'time[datetime]' ).forEach( ( node ) => stamp( node, now ) );
			timer = setTimeout( age, MINUTE_MS - ( now % MINUTE_MS ) );
		}

		// Temporary accounts read as machine noise in a list whose whole point
		// is recognising people. The real name stays in title, so a change is
		// still traceable to whoever made it.
		function credit( node, user ) {
			if ( TEMP_USER_RE.test( user ) ) {
				node.textContent = 'anonymous';
				node.title = user;
			} else {
				node.textContent = user;
			}
		}

		// mw.util.wikiUrlencode, inlined rather than depended on: this gadget
		// otherwise needs no ResourceLoader modules, and a bare
		// encodeURIComponent would escape the slashes out of every subpage.
		function pageUrl( title ) {
			const encoded = encodeURIComponent( String( title ).replace( / /g, '_' ) )
				.replace( /'/g, '%27' )
				.replace( /%20/g, '_' )
				.replace( /%3B/g, ';' )
				.replace( /%40/g, '@' )
				.replace( /%24/g, '$' )
				.replace( /%2C/g, ',' )
				.replace( /%2F/g, '/' )
				.replace( /%3A/g, ':' );
			return mw.config.get( 'wgArticlePath' ).replace( '$1', encoded );
		}

		function buildRow( change ) {
			const row = document.createElement( 'div' );
			row.className = 'home-act__row';

			const link = document.createElement( 'a' );
			link.href = pageUrl( change.title );
			link.title = change.title;
			link.textContent = change.title;
			row.appendChild( link );

			const by = document.createElement( 'span' );
			by.className = 'home-act__by';
			credit( by, change.user || '' );
			row.appendChild( by );

			const when = document.createElement( 'time' );
			when.className = 'home-act__when';
			when.setAttribute( 'datetime', change.timestamp );
			row.appendChild( when );

			return row;
		}

		// What is on screen now, as page|instant pairs. Compared by parsed
		// instant and not by string: DPL writes `+00:00` where the API writes
		// `Z`, and two spellings of the same moment must not read as a change.
		function signature( rows ) {
			return rows.map( ( row ) => {
				const link = row.querySelector( 'a' );
				const when = row.querySelector( 'time[datetime]' );
				return ( link ? link.title || link.textContent : '' ) + '|' +
					( when ? Date.parse( when.getAttribute( 'datetime' ) ) : '' );
			} ).join( '\n' );
		}

		// `:hover` matches ancestors of the hovered node, so one matches() call
		// covers every row without a listener. Gated on a hover-capable pointer
		// because a tap leaves `:hover` stuck and would freeze the list for good.
		const canHover = window.matchMedia( '(hover: hover)' ).matches;

		function busy() {
			if ( canHover && list.matches( ':hover' ) ) {
				return true;
			}
			if ( list.contains( document.activeElement ) ) {
				return true;
			}
			const selection = window.getSelection();
			return !!( selection && !selection.isCollapsed && selection.anchorNode &&
				list.contains( selection.anchorNode ) );
		}

		function apply( changes ) {
			const seen = Object.create( null );
			const fresh = [];
			for ( let i = 0; i < changes.length && fresh.length < limit; i++ ) {
				const change = changes[ i ];
				// One row per page. A page edited four times this morning is one
				// thing that changed, not four; recentchanges is per revision, so
				// the collapse happens here, over a pool deliberately larger than
				// the list so eight distinct pages actually come out of it.
				if ( !change.title || seen[ change.title ] ) {
					continue;
				}
				seen[ change.title ] = true;
				fresh.push( change );
			}
			if ( !fresh.length ) {
				return;
			}

			const wanted = fresh
				.map( ( c ) => c.title + '|' + Date.parse( c.timestamp ) )
				.join( '\n' );
			// Nothing has happened since the page was rendered: leave the DOM
			// alone rather than swapping identical rows under the reader.
			if ( wanted === signature( Array.prototype.slice.call(
				list.querySelectorAll( '.home-act__row' )
			) ) ) {
				return;
			}

			// Nothing moves under a pointer, a caret or a focus ring; the next
			// poll carries the change instead.
			if ( busy() ) {
				return;
			}

			// KEYED, never `list.textContent = ''`: measured, reordering and
			// inserting and removing rows all keep focus, but destroying the
			// subtree blurs to <body> and hands a screen reader a new one.
			// appendChild MOVES a node that is already a child, so walking the
			// wanted order and appending lands everything in sequence.
			const rows = new Map();
			list.querySelectorAll( '.home-act__row' ).forEach( ( row ) => {
				const link = row.querySelector( 'a' );
				if ( link ) {
					rows.set( link.title || link.textContent, row );
				}
			} );

			fresh.forEach( ( change ) => {
				let row = rows.get( change.title );
				if ( row ) {
					rows.delete( change.title );
					// Same page, newer edit: correct in place. Clearing the text
					// is required — stamp() skips a node whose visible label
					// already reads right, leaving a stale aria-label and title.
					const when = row.querySelector( 'time' );
					if ( when ) {
						when.setAttribute( 'datetime', change.timestamp );
						when.textContent = '';
					}
					const by = row.querySelector( '.home-act__by' );
					if ( by ) {
						credit( by, change.user || '' );
					}
				} else {
					row = buildRow( change );
				}
				list.appendChild( row );
			} );

			// Whatever is left fell off the end of the list.
			rows.forEach( ( row ) => row.remove() );

			const now = Date.now();
			list.querySelectorAll( 'time[datetime]' ).forEach( ( node ) => stamp( node, now ) );
		}

		// `rcshow=!bot` is NOT the filter DPL uses: it only excludes a row whose
		// SAVE carried the flag, and 94 of the deploying account's rows in this
		// pool did not. Hence the by-name exclusion, with the name taken from
		// the markup so the two halves cannot drift.
		function load() {
			const api = mw.config.get( 'wgScriptPath' ) +
				'/api.php?action=query&list=recentchanges&format=json&formatversion=2' +
				'&rcprop=title%7Ctimestamp%7Cuser&rctype=edit%7Cnew&rcshow=%21bot' +
				'&rcnamespace=' + encodeURIComponent( namespace ) +
				( exclude ? '&rcexcludeuser=' + encodeURIComponent( exclude ) : '' ) +
				'&rclimit=' + ACTIVITY_POOL +
				'&maxage=' + ACTIVITY_CACHE_S + '&smaxage=' + ACTIVITY_CACHE_S;
			if ( inFlight ) {
				return;
			}
			const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
			inFlight = controller || true;
			const giveUp = controller && setTimeout( () => controller.abort(), FETCH_TIMEOUT_MS );
			const done = () => {
				clearTimeout( giveUp );
				inFlight = null;
			};
			fetch( api, {
				credentials: 'omit',
				signal: controller ? controller.signal : undefined
			} )
				.then( ( res ) => {
					// A 4xx/5xx still resolves, and its body is not the shape
					// below — without this the failure is silent and the backoff
					// never engages.
					if ( !res.ok ) {
						throw new Error( 'recentchanges ' + res.status );
					}
					return res.json();
				} )
				.then( ( data ) => {
					const changes = data && data.query && data.query.recentchanges;
					failures = 0;
					done();
					if ( changes && changes.length ) {
						apply( changes );
					}
					schedule( ACTIVITY_POLL_MS );
				} )
				// A failed read leaves the rows standing, which is the right
				// failure: an old list is still a true one.
				.catch( () => {
					failures++;
					done();
					schedule( Math.min( ACTIVITY_POLL_MS * Math.pow( 2, failures ), MAX_BACKOFF_MS ) );
				} );
		}

		// A hidden tab and an off-screen card keep the timer alive but skip the
		// request, so a reader who never scrolls down costs nothing.
		function poll() {
			if ( document.hidden || !onScreen ) {
				schedule( ACTIVITY_POLL_MS );
				return;
			}
			load();
		}

		function schedule( ms ) {
			clearTimeout( pollTimer );
			pollTimer = setTimeout( poll, ms );
		}

		// The server's own rows get the same treatment before anything is
		// fetched, so the raw ISO stamps and any temporary account names are
		// gone by first paint whether or not the read ever lands.
		list.querySelectorAll( '.home-act__by' ).forEach(
			( node ) => credit( node, node.textContent.trim() )
		);
		age();

		// Returning to the tab asks immediately rather than waiting out the rest
		// of an interval, so a reader back from lunch is not reading lunchtime.
		document.addEventListener( 'visibilitychange', () => {
			if ( document.hidden ) {
				clearTimeout( timer );
				timer = null;
				clearTimeout( pollTimer );
				pollTimer = null;
			} else {
				if ( !timer ) {
					age();
				}
				schedule( 0 );
			}
		} );

		// STAYS connected: it is the on/off switch for polling, not a one-shot
		// trigger. The margin means the first read lands before the card does.
		if ( 'IntersectionObserver' in window ) {
			new IntersectionObserver( ( entries ) => {
				entries.forEach( ( entry ) => {
					const was = onScreen;
					onScreen = entry.isIntersecting;
					if ( onScreen && !was ) {
						schedule( 0 );
					}
				} );
			}, { rootMargin: ACTIVITY_ROOT_MARGIN } ).observe( list );
		} else {
			onScreen = true;
		}

		schedule( 0 );
	}

	/**
	 * Tells the two scrolling cards' fades when they are true.
	 *
	 * Scrolling only toggles classes: no layout-affecting WRITE, so the reads
	 * stay cheap. The geometry is re-measured only when something could have
	 * moved it — a getBoundingClientRect per scroll frame would be a forced
	 * reflow on the busiest page here.
	 */
	function scrollFade() {
		document.querySelectorAll( '[' + SCROLLFADE_ATTR + ']' ).forEach( ( frame ) => {
			const scroller = frame.querySelector( frame.getAttribute( SCROLLFADE_ATTR ) );
			if ( !scroller ) {
				return;
			}

			// Published for the CSS; see the contract in the header.
			function measure() {
				const fr = frame.getBoundingClientRect();
				const sr = scroller.getBoundingClientRect();
				frame.style.setProperty( '--home-fade-top', ( sr.top - fr.top ) + 'px' );
				frame.style.setProperty( '--home-fade-bottom', ( fr.bottom - sr.bottom ) + 'px' );
			}

			// A pixel of tolerance: fractional scroll offsets are normal at
			// non-integer zoom, and without it the bottom fade flickers back on
			// at the end of every list.
			function state() {
				const top = scroller.scrollTop;
				const room = scroller.scrollHeight - scroller.clientHeight;
				frame.classList.toggle( 'home-scrollfade--up', top > 1 );
				frame.classList.toggle( 'home-scrollfade--down', room - top > 1 );
			}

			let settling = null;
			function settle() {
				if ( settling ) {
					return;
				}
				settling = setTimeout( () => {
					settling = null;
					measure();
					state();
				}, FADE_SETTLE_MS );
			}

			scroller.addEventListener( 'scroll', state, { passive: true } );

			if ( 'ResizeObserver' in window ) {
				const sizes = new ResizeObserver( settle );
				sizes.observe( frame );
				sizes.observe( scroller );
			} else {
				window.addEventListener( 'resize', settle, { passive: true } );
			}

			// A tabber swaps which panel is in the scroller: it toggles
			// hidden="until-found" on the panels and rewrites the section's
			// inline height. Nothing resizes, so only the mutation says the
			// list is now a different length.
			if ( 'MutationObserver' in window ) {
				new MutationObserver( settle ).observe( scroller, {
					subtree: true,
					childList: true,
					attributes: true,
					attributeFilter: [ 'class', 'style', 'hidden', 'aria-hidden' ]
				} );
			}

			// A webfont swap or an image loading inside a row changes how much
			// there is to scroll without mutating anything or resizing any box
			// the observers above are watching.
			if ( document.fonts && document.fonts.ready ) {
				document.fonts.ready.then( settle );
			}
			window.addEventListener( 'load', settle, { passive: true, once: true } );

			measure();
			state();
		} );
	}

	// Two boot points. Anything that only rewrites text the server already
	// rendered runs at DOM ready, so a reader is never left looking at the raw
	// value it is there to replace; anything that fetches, animates or pulls an
	// image waits for window load and stays off the critical path. The activity
	// list is in the first group and still costs nothing early — its one read is
	// gated on the list coming into view, not on this. The fade is there for the
	// same reason: an absent cue at first paint is as wrong as a stale number.
	//
	// The countdown is EARLY for that rule and not as an exception to it: it
	// swaps a date range for a duration and fetches nothing, and its one
	// animation is gated on the card coming into view rather than on boot.
	// window.load waits for every image on the page — the hero art, the event
	// photograph, the featured plate — so the swap landed a long way after
	// first paint and took the card's height with it.
	const EARLY = [ recentActivity, scrollFade, countdown ];
	const FEATURES = [ heroImage, liveStats, searchReel ];

	function run( features ) {
		features.forEach( ( feature ) => feature() );
	}

	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', () => run( EARLY ) );
	} else {
		run( EARLY );
	}

	if ( document.readyState === 'complete' ) {
		run( FEATURES );
	} else {
		window.addEventListener( 'load', () => run( FEATURES ) );
	}
}() );