PageSourceSearch

https://www.virtualrealms.us/site/templates/scripts/global.js?v=1786063951

js virtualrealms.us collected 2026-10-04 17:53:37 UTC 52,421 bytes, 1,648 lines download raw bytes

1/* =========================================================
2🔧 QUICK-EDIT SETTINGS
3========================================================= */
4const FAQS_PER_PAGE    = 6;    // how many FAQs per page
5const PRELOAD_OFFSET_Y = 50;  // px before/after viewport for lazy loading (vertical)
6const PRELOAD_OFFSET_X = 200;  // px left/right cushion for lazy loading (horizontal)
7
8// Expose for optional feature scripts (e.g., faqs.js)
9window.FAQS_PER_PAGE = FAQS_PER_PAGE;
10
11/* =========================================================
12Utilities
13========================================================= */
14function debounce(func, wait) {
15	let timeout;
16	return function executedFunction(...args) {
17		const later = () => { clearTimeout(timeout); func(...args); };
18		clearTimeout(timeout);
19		timeout = setTimeout(later, wait);
20	};
21}
22
23function isVisible(el) {
24	if (!el) return false;
25	const cs = getComputedStyle(el);
26	if (cs.display === "none" || cs.visibility === "hidden") return false;
27	const r = el.getBoundingClientRect();
28	return r.width > 0 && r.height > 0;
29}
30
31
32
33
34/* =========================================================
35Category Filters — live site (with sessionStorage + back/forward logic)
36========================================================= */
37(function () {
38	// If another script already owns filters, bail out.
39	if (window.__CATFILTERS_OWNER__) return;
40
41	const buttons = document.querySelectorAll(".catBtn");
42	const cards   = document.querySelectorAll(".gameCard");
43	if (!buttons.length || !cards.length) return;
44
45	// Claim ownership
46	window.__CATFILTERS_OWNER__ = "global.js";
47
48	// Per-path key so different pages/galleries don't clash
49	const STORAGE_KEY = "vr_live_cat_" + String(window.location.pathname || "").replace(/[^\w-]/g, "_");
50	const RETURN_KEY = "vr_live_return_" + String(window.location.pathname || "").replace(/[^\w-]/g, "_");
51
52	// --- Detect navigation type so we only restore filters on back/forward ---
53	let navType = "navigate";
54	try {
55		const navEntries = performance.getEntriesByType && performance.getEntriesByType("navigation");
56		if (navEntries && navEntries.length) {
57			navType = navEntries[0].type || "navigate"; // "navigate" | "reload" | "back_forward" | "prerender"
58		} else if (performance && performance.navigation) {
59			// Legacy API fallback
60			switch (performance.navigation.type) {
61				case 2: navType = "back_forward"; break;
62				case 1: navType = "reload";       break;
63				default: navType = "navigate";
64			}
65		}
66	} catch (_) {
67		// leave default
68	}
69	const isBackForward = navType === "back_forward";
70	let hasExplicitLibraryParams = false;
71	try {
72		const params = new URLSearchParams(window.location.search);
73		hasExplicitLibraryParams =
74			params.has("category") ||
75			params.has("freeroam") ||
76			params.has("free-roam") ||
77			params.has("players");
78	} catch (_) {
79		// Treat an unreadable query string like a plain library URL.
80	}
81	let hasReturnMarker = false;
82	if ("sessionStorage" in window) {
83		try {
84			hasReturnMarker = window.sessionStorage.getItem(RETURN_KEY) === "1";
85			if (hasReturnMarker) {
86				window.setTimeout(() => window.sessionStorage.removeItem(RETURN_KEY), 0);
87			}
88		} catch (_) {
89			// Ignore disabled or unavailable session storage.
90		}
91	}
92	const isLibraryReturn = isBackForward || (hasReturnMarker && !hasExplicitLibraryParams);
93	window.__VR_LIBRARY_RETURN__ = isLibraryReturn;
94
95	cards.forEach((card) => {
96		const gameLink = card.querySelector("a[href]");
97		if (!gameLink) return;
98		gameLink.addEventListener("click", () => {
99			if (!("sessionStorage" in window)) return;
100			try {
101				window.sessionStorage.setItem(RETURN_KEY, "1");
102			} catch (_) {
103				// Ignore disabled or unavailable session storage.
104			}
105		});
106	});
107
108	// Build set of real card classes (lowercased) for fallback lookups
109	const cardClassSet = new Set();
110	cards.forEach((card) => {
111		card.classList.forEach((tok) => {
112			const t = tok.toLowerCase();
113			if (t === "gamecard" || t === "card" || /^card\d+$/.test(t)) return;
114			cardClassSet.add(t);
115		});
116	});
117
118	function getFilterFromButton(btn) {
119		// 1) Prefer data-filter (lowercased)
120		if (btn.dataset && btn.dataset.filter) {
121			const f = btn.dataset.filter.trim().toLowerCase();
122			btn.dataset.filter = f;
123			return f;
124		}
125		// 2) Fallback: a button class that exists on a .gameCard
126		for (const tok of btn.classList) {
127			if (tok === "catBtn" || tok === "active") continue;
128			const t = tok.toLowerCase();
129			if (cardClassSet.has(t)) {
130				btn.dataset.filter = t; // cache it for next time
131				return t;
132			}
133		}
134		// 3) Nothing usable → null = show all
135		return null;
136	}
137
138	function setActive(btn) {
139		buttons.forEach((b) => b.classList.remove("active"));
140		if (btn) btn.classList.add("active");
141	}
142
143	function applyFilter(filter, opts) {
144		opts = opts || {};
145		const showAll         = !filter || filter === "all";
146		const isEditorsChoice = filter === "editors-choice";
147		let matchStateChanged = false;
148
149		cards.forEach((card) => {
150			const show = showAll ? true : card.classList.contains(filter);
151			const nextMatch = show ? "1" : "0";
152			if (card.dataset.catMatch !== nextMatch) {
153				card.dataset.catMatch = nextMatch;
154				matchStateChanged = true;
155			}
156			if (!opts.deferVisibility) {
157				const nextDisplay = show ? "block" : "none";
158				if (card.style.display !== nextDisplay) card.style.display = nextDisplay;
159			}
160
161			// Editors' choice tag visibility logic unchanged
162			if (!opts.deferVisibility || opts.updateEditorChoiceTags) {
163				const ect = card.querySelector(".editors-choice-tag");
164				if (ect) {
165					ect.style.display = isEditorsChoice ? "none" : "block";
166				}
167			}
168		});
169
170		// Persist last-used filter so Back from a game page restores it
171		if (!opts.skipSave && "sessionStorage" in window) {
172			try {
173				window.sessionStorage.setItem(STORAGE_KEY, showAll ? "all" : String(filter || "all"));
174			} catch (_) {
175				// ignore storage errors
176			}
177		}
178		if (!opts.skipSave && window.history && typeof window.history.replaceState === "function") {
179			try {
180				const entryState = window.history.state && typeof window.history.state === "object"
181					? window.history.state
182					: {};
183				window.history.replaceState(
184					Object.assign({}, entryState, {
185						vrLibraryCategory: showAll ? "all" : String(filter || "all"),
186					}),
187					""
188				);
189			} catch (_) {
190				// Ignore browsers that do not allow history-state updates.
191			}
192		}
193
194		// 🔄 Nudge lazy-loading: simulate a scroll so the global lazy loader
195		// re-checks which images are now visible after filter change.
196		if (!opts.skipNudge) {
197			// Reset card scroll position so the new category always starts at the beginning.
198			const wrapper = document.querySelector(".cardWrapper");
199			if (wrapper) wrapper.scrollLeft = 0;
200
201			try {
202				const evt = new Event("scroll");
203				document.dispatchEvent(evt);
204			} catch (_) {
205				// Older browsers that don't support Event() constructor gracefully skip this.
206			}
207		}
208
209		return matchStateChanged;
210	}
211
212	// Free-Roam intentionally has no category controls, so the play-area
213	// filter can use this to clear both the active button and saved category.
214	window.VRLibraryCategoryFilters = window.VRLibraryCategoryFilters || {};
215	window.VRLibraryCategoryFilters.clear = function (opts) {
216		opts = opts || {};
217		const activeButton = document.querySelector(".catBtn.active");
218		if (activeButton && getFilterFromButton(activeButton) === "editors-choice") {
219			opts.updateEditorChoiceTags = true;
220		}
221		setActive(null);
222		return applyFilter(null, opts);
223	};
224
225	// Click handling (supports <a> and <button>)
226	buttons.forEach((btn) => {
227		// Blur on mouse / touch down to avoid focus outlines sticking
228		btn.addEventListener("mousedown", () => btn.blur());
229		btn.addEventListener(
230			"pointerdown",
231			(e) => {
232				if (e.pointerType === "touch" || e.pointerType === "pen") btn.blur();
233			},
234			{ passive: true }
235		);
236
237		btn.addEventListener("click", (e) => {
238			if (btn.tagName === "A") e.preventDefault();
239
240			const wasActive = btn.classList.contains("active");
241
242			// Clicking an already-active button clears the filter → show all
243			if (wasActive) {
244				btn.classList.remove("active");
245				applyFilter(null); // show all + store "all"
246				btn.blur();
247				return;
248			}
249
250			// Activate this button and apply its filter
251			setActive(btn);
252			const filter = getFilterFromButton(btn);
253			applyFilter(filter);
254			btn.blur();
255		});
256	});
257
258	// Helper: resolve filter from ?category=foo if present
259	function resolveFilterFromUrl() {
260		try {
261			const params = new URLSearchParams(window.location.search);
262			if (!params.has("category")) return null;
263			const cat = (params.get("category") || "").trim().toLowerCase();
264			if (!cat || cat === "all") return { btn: null, filter: null };
265
266			const btn =
267				document.querySelector(`.catBtn[data-filter="${cat}"]`) ||
268				document.querySelector(`.catBtn.${cat}`);
269
270			if (!btn) return null;
271
272			return { btn, filter: getFilterFromButton(btn) || cat };
273		} catch (_) {
274			return null;
275		}
276	}
277
278	let initialFilter = null;
279	let initialBtn    = null;
280	let initialResolved = false;
281	let saveInitial = false;
282	const fromUrl = resolveFilterFromUrl();
283	let entryCategory = null;
284	try {
285		const entryState = window.history && window.history.state;
286		if (entryState && Object.prototype.hasOwnProperty.call(entryState, "vrLibraryCategory")) {
287			entryCategory = String(entryState.vrLibraryCategory || "all");
288		}
289	} catch (_) {
290		// Ignore unavailable history state.
291	}
292
293	// History state belongs to this exact library entry, so it is safer than a
294	// path-wide session value when several /games/ entries exist in one tab.
295	if (entryCategory !== null) {
296		if (entryCategory !== "all") {
297			const btn =
298				document.querySelector(`.catBtn[data-filter="${entryCategory}"]`) ||
299				document.querySelector(`.catBtn.${entryCategory}`);
300			if (btn) {
301				initialBtn = btn;
302				initialFilter = getFilterFromButton(btn) || entryCategory;
303				initialResolved = true;
304				saveInitial = true;
305			}
306		} else {
307			initialFilter = null;
308			initialResolved = true;
309			saveInitial = true;
310		}
311	}
312
313	// Back/Forward restores the visitor's latest choice even when this history
314	// entry was originally opened with an explicit ?category=... URL.
315	if (!initialResolved && isLibraryReturn && "sessionStorage" in window) {
316		try {
317			const saved = window.sessionStorage.getItem(STORAGE_KEY);
318			if (saved && saved !== "all") {
319				const btn =
320					document.querySelector(`.catBtn[data-filter="${saved}"]`) ||
321					document.querySelector(`.catBtn.${saved}`);
322				if (btn) {
323					initialBtn    = btn;
324					initialFilter = getFilterFromButton(btn) || saved;
325					initialResolved = true;
326					saveInitial = true;
327				}
328			} else if (saved === "all") {
329				initialFilter = null; // explicit "show all"
330				initialResolved = true;
331				saveInitial = true;
332			}
333		} catch (_) {
334			// ignore
335		}
336	}
337
338	// On a fresh visit, an explicit URL category wins, including category=all.
339	if (!initialResolved && fromUrl !== null) {
340		initialBtn = fromUrl.btn;
341		initialFilter = fromUrl.filter;
342		initialResolved = true;
343		saveInitial = true;
344	} else if (!isLibraryReturn && "sessionStorage" in window) {
345		// On a normal navigation (e.g., Home → Games, Terms → Games), clear stale filters
346		try {
347			window.sessionStorage.removeItem(STORAGE_KEY);
348		} catch (_) {
349			// ignore
350		}
351	}
352
353	// If neither URL nor history supplied state, respect active markup as a fallback.
354	if (!initialResolved) {
355		const activeMarkupBtn = document.querySelector(".catBtn.active");
356		if (activeMarkupBtn) {
357			initialBtn    = activeMarkupBtn;
358			initialFilter = getFilterFromButton(activeMarkupBtn) || null;
359			initialResolved = true;
360		}
361	}
362
363	// Default: apply whatever we resolved (URL → storage → markup → show all)
364	setActive(initialBtn);
365	applyFilter(initialFilter, { skipSave: !saveInitial, skipNudge: true });
366})();
367
368
369/* =========================================================
370Library Sliders - live site (combines with category filters)
371========================================================= */
372(function () {
373	const playerSlider = document.querySelector(".playerFilterSlider");
374	const ageSlider = document.querySelector(".ageFilterSlider");
375	const playerSliderWrap = playerSlider ? playerSlider.closest(".playerSlider") : null;
376	const playAreaOptions = document.querySelectorAll(".playAreaOption");
377	const playAreaToggle = document.querySelector(".playAreaToggle");
378	const categoryButtonsTransition = document.querySelector(".catBtnsTransition");
379	const libraryFilterControls = document.querySelector(".catFilterControls");
380	const cards = document.querySelectorAll(".gameCard");
381	const emptyState = document.querySelector(".libraryEmptyState");
382	const emptyCardWrapper = emptyState ? emptyState.closest(".cardWrapper") : null;
383	const cardsContainer = emptyCardWrapper ? emptyCardWrapper.querySelector(".cards") : document.querySelector(".cards");
384	const libraryCardWrapper = cardsContainer ? cardsContainer.closest(".cardWrapper") : null;
385	if ((!playerSlider && !ageSlider && !playAreaOptions.length) || !cards.length) return;
386	const PLAY_AREA_STORAGE_KEY = "vr_live_play_area_" + String(window.location.pathname || "").replace(/[^\w-]/g, "_");
387	const FILTER_STORAGE_KEY = "vr_live_filters_" + String(window.location.pathname || "").replace(/[^\w-]/g, "_");
388	const isBackForward = navigationType() === "back_forward";
389	const hasEntryFilterState = Boolean(
390		window.history &&
391		window.history.state &&
392		window.history.state.vrLibraryFilters &&
393		typeof window.history.state.vrLibraryFilters === "object"
394	);
395	const isLibraryReturn =
396		hasEntryFilterState || isBackForward || window.__VR_LIBRARY_RETURN__ === true;
397	const savedFilterState = readSavedFilterState();
398	// Slider positions have defaults for their visual controls, but a fresh
399	// library must be unfiltered. A restriction begins only after the visitor
400	// deliberately changes that slider.
401	let playerFilterActive = false;
402	let ageFilterActive = false;
403	let playAreaMode = resolveInitialPlayAreaMode();
404	let skipNextCategoryObserverPass = false;
405	let categoryObserverActive = false;
406	let lazyLoadNudgePending = false;
407	let playAreaSwitchTimer = null;
408	let playAreaSwitchToken = 0;
409
410	// Room-Scale uses raw values 1 = All, 2 = Single, then raw - 1 players.
411	// Free-Roam uses 1 = All and raw values 2 through 4 as actual party sizes.
412	const SINGLE_PLAYER_VALUE = 2;
413	const ROOM_SCALE_PLAYER_MAX = 9;
414	const FREE_ROAM_PLAYER_MAX = 4;
415	const PLAY_AREA_FADE_MS = 480;
416
417	function navigationType() {
418		try {
419			const entries = performance.getEntriesByType && performance.getEntriesByType("navigation");
420			if (entries && entries.length) return entries[0].type || "navigate";
421			if (performance && performance.navigation) {
422				if (performance.navigation.type === 2) return "back_forward";
423				if (performance.navigation.type === 1) return "reload";
424			}
425		} catch (_) {
426			// Use the normal-navigation fallback.
427		}
428		return "navigate";
429	}
430
431	function readSavedFilterState() {
432		try {
433			const entryState = window.history && window.history.state;
434			if (entryState && entryState.vrLibraryFilters && typeof entryState.vrLibraryFilters === "object") {
435				return entryState.vrLibraryFilters;
436			}
437		} catch (_) {
438			// Fall back to session storage.
439		}
440		if (!("sessionStorage" in window)) return null;
441		try {
442			const state = JSON.parse(window.sessionStorage.getItem(FILTER_STORAGE_KEY) || "null");
443			return state && typeof state === "object" ? state : null;
444		} catch (_) {
445			return null;
446		}
447	}
448
449	function playAreaModeFromUrl() {
450		try {
451			const params = new URLSearchParams(window.location.search);
452			const paramName = params.has("freeroam")
453				? "freeroam"
454				: params.has("free-roam")
455					? "free-roam"
456					: null;
457			if (!paramName) return null;
458			const value = (params.get(paramName) || "").trim().toLowerCase();
459			return value === "true" || value === "1"
460				? "free-roam"
461				: "room-scale";
462		} catch (_) {
463			return null;
464		}
465	}
466
467	function resolveInitialPlayAreaMode() {
468		if (!playAreaOptions.length) return "room-scale";
469
470		if (isLibraryReturn && savedFilterState) {
471			const savedMode = savedFilterState.playAreaMode;
472			if (savedMode === "free-roam" || savedMode === "room-scale") return savedMode;
473		}
474
475		const fromUrl = playAreaModeFromUrl();
476		if (fromUrl) return fromUrl;
477
478		if ("sessionStorage" in window) {
479			try {
480				const saved = window.sessionStorage.getItem(PLAY_AREA_STORAGE_KEY);
481				if (saved === "free-roam" || saved === "room-scale") return saved;
482			} catch (_) {
483				// Ignore disabled or unavailable session storage.
484			}
485		}
486
487		return "room-scale";
488	}
489
490	function savePlayAreaMode() {
491		if (!("sessionStorage" in window)) return;
492		try {
493			window.sessionStorage.setItem(PLAY_AREA_STORAGE_KEY, playAreaMode);
494		} catch (_) {
495			// Ignore disabled or unavailable session storage.
496		}
497	}
498
499	function saveFilterState() {
500		const state = {
501			playAreaMode: playAreaMode,
502			playerFilterActive: playerFilterActive,
503			playerValue: playerSlider ? playerSlider.value : null,
504			ageFilterActive: ageFilterActive,
505			ageValue: ageSlider ? ageSlider.value : null,
506		};
507		if ("sessionStorage" in window) {
508			try {
509				window.sessionStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(state));
510			} catch (_) {
511				// Ignore disabled or unavailable session storage.
512			}
513		}
514		if (window.history && typeof window.history.replaceState === "function") {
515			try {
516				const entryState = window.history.state && typeof window.history.state === "object"
517					? window.history.state
518					: {};
519				window.history.replaceState(
520					Object.assign({}, entryState, { vrLibraryFilters: state }),
521					""
522				);
523			} catch (_) {
524				// Ignore browsers that do not allow history-state updates.
525			}
526		}
527	}
528
529	function updatePlayAreaState() {
530		playAreaOptions.forEach((option) => {
531			const isActive = option.dataset.playArea === playAreaMode;
532			option.classList.toggle("active", isActive);
533			option.setAttribute("aria-pressed", isActive ? "true" : "false");
534		});
535		if (playAreaToggle) playAreaToggle.dataset.activeArea = playAreaMode;
536	}
537
538	function setCategoryButtonsAccessibility(hidden) {
539		if (!categoryButtonsTransition) return;
540		categoryButtonsTransition.toggleAttribute("inert", hidden);
541		categoryButtonsTransition.setAttribute("aria-hidden", hidden ? "true" : "false");
542	}
543
544	function syncCategoryVisibilityImmediately() {
545		if (!categoryButtonsTransition) return;
546		const hidden = playAreaMode === "free-roam";
547		categoryButtonsTransition.classList.remove("categoriesLeaving", "categoriesEntering");
548		categoryButtonsTransition.hidden = hidden;
549		setCategoryButtonsAccessibility(hidden);
550	}
551
552	function prepareCategoryTransition() {
553		if (!categoryButtonsTransition) return;
554		const hiding = playAreaMode === "free-roam";
555		categoryButtonsTransition.classList.remove("categoriesEntering");
556		if (hiding && !categoryButtonsTransition.hidden) {
557			categoryButtonsTransition.classList.add("categoriesLeaving");
558			setCategoryButtonsAccessibility(true);
559		} else if (!hiding) {
560			// Reversing a pending Room-Scale → Free-Roam transition can fade
561			// the still-present categories smoothly back to full opacity.
562			categoryButtonsTransition.classList.remove("categoriesLeaving");
563			if (!categoryButtonsTransition.hidden) setCategoryButtonsAccessibility(false);
564		}
565	}
566
567	function finalizeCategoryVisibilityForSwap() {
568		if (!categoryButtonsTransition) return;
569		const hidden = playAreaMode === "free-roam";
570		const wasHidden = categoryButtonsTransition.hidden;
571		categoryButtonsTransition.classList.remove("categoriesLeaving", "categoriesEntering");
572		categoryButtonsTransition.hidden = hidden;
573		setCategoryButtonsAccessibility(hidden);
574		if (!hidden && wasHidden) {
575			// The wrapper takes up space only after the old cards have faded.
576			// Its contents then fade in alongside the new Room-Scale grid.
577			categoryButtonsTransition.classList.add("categoriesEntering");
578		}
579	}
580
581	function clearCategoryForFreeRoam() {
582		if (playAreaMode !== "free-roam") return;
583		const categoryFilters = window.VRLibraryCategoryFilters;
584		if (categoryFilters && typeof categoryFilters.clear === "function") {
585			const matchStateChanged = categoryFilters.clear({
586				skipNudge: true,
587				deferVisibility: true,
588			}) === true;
589			skipNextCategoryObserverPass = categoryObserverActive && matchStateChanged;
590		}
591	}
592
593	function scheduleLazyLoadNudge() {
594		if (lazyLoadNudgePending) return;
595		lazyLoadNudgePending = true;
596		const afterPaint = (callback) => {
597			if (typeof window.requestAnimationFrame !== "function") {
598				window.setTimeout(callback, 0);
599				return;
600			}
601			// Two frames ensure the newly selected cards paint before image
602			// discovery/decoding work begins.
603			window.requestAnimationFrame(() => {
604				window.requestAnimationFrame(callback);
605			});
606		};
607		afterPaint(() => {
608			lazyLoadNudgePending = false;
609			const lazyLoader = window.VRLibraryLazyLoader;
610			if (lazyLoader && typeof lazyLoader.refresh === "function") {
611				lazyLoader.refresh();
612				return;
613			}
614			try {
615				document.dispatchEvent(new Event("scroll"));
616			} catch (_) {
617				// Older browsers can skip the lazy-load nudge.
618			}
619		});
620	}
621
622	function sliderNumber(slider, fallback) {
623		if (!slider) return fallback;
624		const value = parseInt(slider.value, 10);
625		return Number.isNaN(value) ? fallback : value;
626	}
627
628	function isSliderAtRest(slider) {
629		if (!slider) return true;
630		return parseFloat(slider.value) === parseFloat(slider.min);
631	}
632
633	function activePlayerRaw() {
634		return sliderNumber(playerSlider, 1);
635	}
636
637	function activePlayerCount(mode) {
638		if (!playerFilterActive) return null;
639		const raw = activePlayerRaw();
640		const currentMode = mode || playAreaMode;
641		if (currentMode === "free-roam") return raw >= 2 ? raw : null;
642		return raw === SINGLE_PLAYER_VALUE ? 1 : raw - 1;
643	}
644
645	function configurePlayerSlider(playerCountToPreserve) {
646		if (!playerSlider) return;
647		const isFreeRoam = playAreaMode === "free-roam";
648		playerSlider.max = String(isFreeRoam ? FREE_ROAM_PLAYER_MAX : ROOM_SCALE_PLAYER_MAX);
649		if (playerSliderWrap) playerSliderWrap.dataset.playArea = playAreaMode;
650
651		if (playerCountToPreserve == null) return;
652
653		const canPreserve = isFreeRoam
654			? playerCountToPreserve >= 2 && playerCountToPreserve <= 4
655			: playerCountToPreserve >= 1 && playerCountToPreserve <= 8;
656
657		if (!canPreserve) {
658			playerSlider.value = playerSlider.min;
659			playerFilterActive = false;
660			return;
661		}
662
663		playerSlider.value = String(
664			isFreeRoam
665				? playerCountToPreserve
666				: playerCountToPreserve === 1
667					? SINGLE_PLAYER_VALUE
668					: playerCountToPreserve + 1
669		);
670		playerFilterActive = true;
671	}
672
673	// Supports shareable library links such as /games/?category=escape&players=2.
674	// "all" clears the player filter; numeric values set the displayed player count.
675	// Room-Scale supports 1–8, while Free-Roam supports 2–4.
676	function playerCountFromUrl() {
677		try {
678			const params = new URLSearchParams(window.location.search);
679			if (!params.has("players")) return undefined;
680			const value = (params.get("players") || "").trim().toLowerCase();
681			if (!value || value === "all") return null;
682			if (!/^\d+$/.test(value)) return null;
683			const count = parseInt(value, 10);
684			const min = playAreaMode === "free-roam" ? 2 : 1;
685			const max = playAreaMode === "free-roam" ? 4 : 8;
686			return count >= min && count <= max ? count : null;
687		} catch (_) {
688			return undefined;
689		}
690	}
691
692	// Supports shareable library links such as /games/?age=12.
693	// "all" clears the age filter; numeric values set the age threshold.
694	function ageThresholdFromUrl() {
695		try {
696			const params = new URLSearchParams(window.location.search);
697			if (!params.has("age")) return undefined;
698			const value = (params.get("age") || "").trim().toLowerCase();
699			if (!value || value === "all") return null;
700			if (!/^\d+$/.test(value)) return null;
701			const threshold = parseInt(value, 10);
702			const min = ageSlider ? parseInt(ageSlider.min, 10) + 1 : 7;
703			const max = ageSlider ? parseInt(ageSlider.max, 10) : 18;
704			return threshold >= min && threshold <= max ? threshold : null;
705		} catch (_) {
706			return undefined;
707		}
708	}
709
710	function restoreSliderState() {
711		configurePlayerSlider();
712		const urlPlayerCount = playerCountFromUrl();
713		if (playerSlider && urlPlayerCount !== undefined) {
714			if (urlPlayerCount === null) {
715				playerSlider.value = playerSlider.min;
716				playerFilterActive = false;
717			} else {
718				configurePlayerSlider(urlPlayerCount);
719			}
720		} else if (playerSlider && isLibraryReturn && savedFilterState && savedFilterState.playerFilterActive) {
721			const value = parseInt(savedFilterState.playerValue, 10);
722			const min = parseInt(playerSlider.min, 10);
723			const max = parseInt(playerSlider.max, 10);
724			if (!Number.isNaN(value) && value >= min && value <= max) {
725				playerSlider.value = String(value);
726				playerFilterActive = value !== min;
727			}
728		} else if (playerSlider) {
729			playerSlider.value = playerSlider.min;
730		}
731
732		const urlAgeThreshold = ageThresholdFromUrl();
733		if (ageSlider && urlAgeThreshold !== undefined) {
734			if (urlAgeThreshold === null) {
735				ageSlider.value = ageSlider.min;
736				ageFilterActive = false;
737			} else {
738				ageSlider.value = String(urlAgeThreshold);
739				ageFilterActive = true;
740			}
741		} else if (ageSlider && isLibraryReturn && savedFilterState && savedFilterState.ageFilterActive) {
742			const value = parseInt(savedFilterState.ageValue, 10);
743			const min = parseInt(ageSlider.min, 10);
744			const max = parseInt(ageSlider.max, 10);
745			if (!Number.isNaN(value) && value >= min && value <= max) {
746				ageSlider.value = String(value);
747				ageFilterActive = value !== min;
748			}
749		} else if (ageSlider) {
750			ageSlider.value = ageSlider.min;
751		}
752	}
753
754	function isSinglePlayerCapable(card) {
755		const minPlayers = parseInt(card.dataset.minPlayers || "0", 10);
756		if (Number.isNaN(minPlayers) || minPlayers < 1) return true;
757		return minPlayers <= 1;
758	}
759
760	function supportsPlayerCount(card, playerCount) {
761		const minPlayers = parseInt(card.dataset.minPlayers || "0", 10);
762		const maxPlayers = parseInt(card.dataset.maxPlayers || "0", 10);
763		if (Number.isNaN(minPlayers) || Number.isNaN(maxPlayers) || minPlayers < 1 || maxPlayers < 1) {
764			return true;
765		}
766		return playerCount >= minPlayers && playerCount <= maxPlayers;
767	}
768
769	// The age slider's leftmost (min) raw value is a reserved "All ages" sentinel;
770	// every raw value above that is a real age threshold used directly (no offset).
771	function activeAgeThreshold() {
772		return sliderNumber(ageSlider, ageSlider ? parseInt(ageSlider.min, 10) + 1 : 7);
773	}
774
775	function sliderThumbX(value, min, max, inputWidth, thumbSize) {
776		const span = max - min;
777		const pct = span > 0 ? ((value - min) / span) * 100 : 0;
778		const clampedPct = Math.max(0, Math.min(100, pct));
779		return {
780			pct: clampedPct,
781			x: (thumbSize / 2) + ((inputWidth - thumbSize) * (clampedPct / 100)),
782		};
783	}
784
785	function positionSliderBubble(slider) {
786		if (!slider) return;
787		const wrap = slider.closest(".filterSlider");
788		if (!wrap) return;
789		const value = parseFloat(slider.value);
790		const min = parseFloat(slider.min || "0");
791		const max = parseFloat(slider.max || "100");
792		const inputWidth = slider.getBoundingClientRect().width;
793		const thumbSize = parseFloat(getComputedStyle(wrap).getPropertyValue("--slider-thumb-size")) || 0;
794		const pos = sliderThumbX(value, min, max, inputWidth, thumbSize);
795		wrap.style.setProperty("--slider-pct", pos.pct + "%");
796		wrap.style.setProperty("--slider-x", pos.x + "px");
797	}
798
799	// The rest position's CSS default (--slider-pct: 0%, --slider-x: thumb-size / 2) is
800	// exact and timing-safe. Computing it in JS instead risks reading --slider-thumb-size
801	// before its stylesheet is applied (e.g. thumbSize falls back to 0), landing the bubble
802	// too far left on first paint. Clearing any inline override restores that exact default.
803	function resetSliderBubblePosition(slider) {
804		if (!slider) return;
805		const wrap = slider.closest(".filterSlider");
806		if (!wrap) return;
807		wrap.style.removeProperty("--slider-pct");
808		wrap.style.removeProperty("--slider-x");
809	}
810
811	function setSliderValueText(slider, text) {
812		if (!slider) return;
813		const wrap = slider.closest(".filterSlider");
814		const valueEl = wrap ? wrap.querySelector(".sliderValue") : null;
815		if (valueEl) valueEl.textContent = text;
816	}
817
818	function updatePlayerSliderState() {
819		if (!playerSlider) return;
820		const raw = activePlayerRaw();
821		if (!playerFilterActive) {
822			resetSliderBubblePosition(playerSlider);
823			playerSlider.setAttribute("aria-valuetext", "All players");
824			setSliderValueText(playerSlider, "All players");
825			return;
826		}
827		positionSliderBubble(playerSlider);
828		if (playAreaMode === "free-roam") {
829			const isMax = raw === FREE_ROAM_PLAYER_MAX;
830			playerSlider.setAttribute(
831				"aria-valuetext",
832				isMax ? raw + " players" : raw + " or more players"
833			);
834			setSliderValueText(playerSlider, isMax ? String(raw) : raw + "+");
835		} else if (raw === SINGLE_PLAYER_VALUE) {
836			playerSlider.setAttribute("aria-valuetext", "Single player");
837			setSliderValueText(playerSlider, "Single Player");
838		} else {
839			const count = raw - 1;
840			const isMax = raw === parseInt(playerSlider.max, 10);
841			playerSlider.setAttribute("aria-valuetext", count + " player" + (count === 1 ? "" : "s"));
842			setSliderValueText(playerSlider, isMax ? String(count) : count + "+");
843		}
844	}
845
846	function updateAgeSliderState() {
847		if (!ageSlider) return;
848		if (!ageFilterActive) {
849			resetSliderBubblePosition(ageSlider);
850			ageSlider.setAttribute("aria-valuetext", "All ages");
851			setSliderValueText(ageSlider, "All ages");
852			return;
853		}
854		positionSliderBubble(ageSlider);
855		const value = activeAgeThreshold();
856		ageSlider.setAttribute("aria-valuetext", value + " and under");
857		setSliderValueText(ageSlider, value + "+");
858	}
859
860	// Kept for initial render and resize (both sliders' pixel positions can shift then).
861	// Slider "input" events must NOT call this — recomputing the untouched slider's
862	// bubble position on every keystroke risked a sub-pixel jitter in its rendered spot.
863	function updateSliderState() {
864		updatePlayerSliderState();
865		updateAgeSliderState();
866	}
867
868	function applySliderFilters(opts) {
869		opts = opts || {};
870		const playerRaw = activePlayerRaw();
871		const playerCount = activePlayerCount();
872		const ageThreshold = activeAgeThreshold();
873		let visibleCount = 0;
874
875		cards.forEach((card) => {
876			const catMatch = card.dataset.catMatch !== "0";
877			const cardAge = parseInt(card.dataset.ageRating || "0", 10);
878			const ageMatch = !ageFilterActive || Number.isNaN(cardAge) || cardAge <= ageThreshold;
879			const playAreaMatch = playAreaMode === "free-roam"
880				? card.dataset.freeRoam === "1"
881				: card.dataset.freeRoam !== "1";
882			let playerMatch = true;
883			if (playerFilterActive) {
884				playerMatch = playAreaMode === "room-scale" && playerRaw === SINGLE_PLAYER_VALUE
885					? isSinglePlayerCapable(card)
886					: supportsPlayerCount(card, playerCount);
887			}
888			const show = catMatch && playerMatch && ageMatch && playAreaMatch;
889
890			const nextDisplay = show ? "block" : "none";
891			if (card.style.display !== nextDisplay) card.style.display = nextDisplay;
892			if (show) visibleCount++;
893		});
894
895		if (emptyState) emptyState.hidden = visibleCount > 0;
896		if (cardsContainer) cardsContainer.hidden = visibleCount === 0;
897
898		if (!opts.skipLazyLoadNudge) scheduleLazyLoadNudge();
899	}
900
901	function finishPlayAreaSwitch(token) {
902		if (token !== playAreaSwitchToken) return;
903		playAreaSwitchTimer = null;
904		finalizeCategoryVisibilityForSwap();
905		clearCategoryForFreeRoam();
906		applySliderFilters();
907		saveFilterState();
908
909		const reveal = () => {
910			if (token !== playAreaSwitchToken) return;
911			if (libraryCardWrapper) {
912				libraryCardWrapper.classList.remove("playAreaSwitching");
913				libraryCardWrapper.removeAttribute("aria-busy");
914			}
915			if (categoryButtonsTransition) {
916				categoryButtonsTransition.classList.remove("categoriesEntering");
917			}
918			if (libraryFilterControls) {
919				libraryFilterControls.classList.remove("playAreaControlsSwitching");
920				libraryFilterControls.removeAttribute("inert");
921			}
922		};
923		if (typeof window.requestAnimationFrame === "function") {
924			// Let the new grid complete one layout/paint cycle before fading it in.
925			window.requestAnimationFrame(() => window.requestAnimationFrame(reveal));
926		} else {
927			window.setTimeout(reveal, 0);
928		}
929	}
930
931	function schedulePlayAreaSwitch() {
932		const token = ++playAreaSwitchToken;
933		if (playAreaSwitchTimer !== null) {
934			window.clearTimeout(playAreaSwitchTimer);
935		}
936
937		if (!libraryCardWrapper) {
938			finishPlayAreaSwitch(token);
939			return;
940		}
941
942		libraryCardWrapper.classList.add("playAreaSwitching");
943		libraryCardWrapper.setAttribute("aria-busy", "true");
944		if (libraryFilterControls) {
945			libraryFilterControls.classList.add("playAreaControlsSwitching");
946			libraryFilterControls.setAttribute("inert", "");
947		}
948		// Allow the old grid to fade away before triggering the expensive
949		// display/layout swap for the blurred cards.
950		playAreaSwitchTimer = window.setTimeout(
951			() => finishPlayAreaSwitch(token),
952			PLAY_AREA_FADE_MS
953		);
954	}
955
956	if (playerSlider) {
957		playerSlider.addEventListener("input", () => {
958			playerFilterActive = !isSliderAtRest(playerSlider);
959			updatePlayerSliderState();
960			applySliderFilters();
961			saveFilterState();
962		});
963	}
964
965	if (ageSlider) {
966		ageSlider.addEventListener("input", () => {
967			ageFilterActive = !isSliderAtRest(ageSlider);
968			updateAgeSliderState();
969			applySliderFilters();
970			saveFilterState();
971		});
972	}
973
974	playAreaOptions.forEach((option) => {
975		option.addEventListener("click", () => {
976			const nextMode = option.dataset.playArea === "free-roam" ? "free-roam" : "room-scale";
977			if (nextMode === playAreaMode) {
978				option.blur();
979				return;
980			}
981			const previousPlayerCount = activePlayerCount();
982			playAreaMode = nextMode;
983			configurePlayerSlider(previousPlayerCount);
984			savePlayAreaMode();
985			updatePlayAreaState();
986			updatePlayerSliderState();
987			prepareCategoryTransition();
988			schedulePlayAreaSwitch();
989			option.blur();
990		});
991	});
992
993	window.addEventListener("resize", updateSliderState);
994	window.addEventListener("pagehide", () => {
995		if (playAreaSwitchTimer !== null) {
996			window.clearTimeout(playAreaSwitchTimer);
997			playAreaSwitchTimer = null;
998			finalizeCategoryVisibilityForSwap();
999			clearCategoryForFreeRoam();
1000			applySliderFilters({ skipLazyLoadNudge: true });
1001		}
1002		if (libraryCardWrapper) {
1003			libraryCardWrapper.classList.remove("playAreaSwitching");
1004			libraryCardWrapper.removeAttribute("aria-busy");
1005		}
1006		if (libraryFilterControls) {
1007			libraryFilterControls.classList.remove("playAreaControlsSwitching");
1008			libraryFilterControls.removeAttribute("inert");
1009		}
1010		syncCategoryVisibilityImmediately();
1011		saveFilterState();
1012	});
1013
1014	const observer = new MutationObserver(() => {
1015		if (skipNextCategoryObserverPass) {
1016			skipNextCategoryObserverPass = false;
1017			return;
1018		}
1019		applySliderFilters();
1020	});
1021
1022	cards.forEach((card) => {
1023		observer.observe(card, {
1024			attributes: true,
1025			attributeFilter: ["data-cat-match"],
1026		});
1027	});
1028	categoryObserverActive = true;
1029
1030	restoreSliderState();
1031	updatePlayAreaState();
1032	syncCategoryVisibilityImmediately();
1033	clearCategoryForFreeRoam();
1034	savePlayAreaMode();
1035	updateSliderState();
1036	applySliderFilters({ skipLazyLoadNudge: true });
1037	saveFilterState();
1038})();
1039
1040
1041
1042
1043
1044
1045
1046/* =========================================================
1047Lazy Loading Logic for Images (<picture> + <img>)
1048— using PRELOAD_OFFSET_X *and* PRELOAD_OFFSET_Y directly
1049========================================================= */
1050(function () {
1051	// Short-circuit if nothing needs lazy loading
1052	if (!document.querySelector('picture[data-src], img[data-src], picture[data-preload-bidi][data-src]')) {
1053		return;
1054	}
1055
1056	// Normalize offsets once (avoid re-parsing every frame)
1057	const preloadOffsetY = (typeof PRELOAD_OFFSET_Y === "number" ? PRELOAD_OFFSET_Y | 0 : 0);
1058	const preloadOffsetX = (typeof PRELOAD_OFFSET_X === "number" ? PRELOAD_OFFSET_X | 0 : 0);
1059
1060	function loadPicture(pic) {
1061		if (!pic || pic.dataset.loaded === "1") return;
1062
1063		const dsrc = pic.getAttribute("data-src");
1064		if (dsrc) {
1065			// harmless on <picture>, useful for <img> if used that way
1066			pic.setAttribute("src", dsrc);
1067			pic.removeAttribute("data-src");
1068		}
1069
1070		pic.querySelectorAll("source[data-srcset]").forEach((source) => {
1071			source.srcset = source.dataset.srcset;
1072			source.removeAttribute("data-srcset");
1073		});
1074
1075		const img = pic.querySelector("img");
1076		if (img && img.hasAttribute("data-src")) {
1077			img.src = img.getAttribute("data-src");
1078			img.removeAttribute("data-src");
1079		}
1080
1081		pic.dataset.loaded = "1";
1082	}
1083
1084	function loadImg(img) {
1085		if (!img || img.dataset.loaded === "1") return;
1086		if (img.hasAttribute("data-src")) {
1087			img.src = img.getAttribute("data-src");
1088			img.removeAttribute("data-src");
1089		}
1090		img.dataset.loaded = "1";
1091	}
1092
1093	/* ---------------------------------------------
1094	   Gather lazy targets (<picture> + <img>)
1095	--------------------------------------------- */
1096	const lazyRecs = [];
1097
1098	// <picture data-src> (with optional <source> + <img>)
1099	document.querySelectorAll("picture[data-src]").forEach((pic) => {
1100		const innerImg = pic.querySelector("img") || pic;
1101		lazyRecs.push({
1102			el: innerImg,
1103			card: innerImg.closest ? innerImg.closest(".gameCard") : null,
1104			kind: "picture",
1105			handle: () => loadPicture(pic),
1106		});
1107	});
1108
1109	// <img data-src> (excluding explicit .lazy-gif)
1110	document.querySelectorAll("img[data-src]:not(.lazy-gif)").forEach((img) => {
1111		if (!lazyRecs.some((t) => t.el === img)) {
1112			lazyRecs.push({
1113				el: img,
1114				card: img.closest ? img.closest(".gameCard") : null,
1115				kind: "img",
1116				handle: () => loadImg(img),
1117			});
1118		}
1119	});
1120
1121	/* ---------------------------------------------
1122	   Helper: is element inside our expanded viewport
1123	   using PRELOAD_OFFSET_X and PRELOAD_OFFSET_Y
1124	--------------------------------------------- */
1125	function inPreloadZone(rect, vw, vh) {
1126		// Expanded viewport bounds
1127		const left   = -preloadOffsetX;
1128		const right  = vw + preloadOffsetX;
1129		const top    = -preloadOffsetY;
1130		const bottom = vh + preloadOffsetY;
1131
1132		const horizontallyIn = rect.right >= left && rect.left <= right;
1133		const verticallyIn   = rect.bottom >= top && rect.top <= bottom;
1134
1135		return horizontallyIn && verticallyIn;
1136	}
1137
1138	function processLazy(vw, vh) {
1139		if (!lazyRecs.length) return;
1140
1141		for (let i = lazyRecs.length - 1; i >= 0; i--) {
1142			const rec = lazyRecs[i];
1143			const el  = rec.el;
1144
1145			if (!el) {
1146				lazyRecs.splice(i, 1);
1147				continue;
1148			}
1149			if (el.dataset && el.dataset.loaded === "1") {
1150				lazyRecs.splice(i, 1);
1151				continue;
1152			}
1153			// Library filters use inline display:none. Avoid forcing layout for
1154			// those hidden cards, and do not mistake their zero rectangle for
1155			// an element at the viewport origin.
1156			if (rec.card && rec.card.style.display === "none") continue;
1157
1158			// A visible lazy image can legitimately start at zero height when it
1159			// has no src/intrinsic dimensions yet (notably section headings).
1160			// Reject only boxes that do not participate in layout; requiring a
1161			// positive height creates a deadlock where the image can never load.
1162			if (typeof el.getClientRects === "function" && el.getClientRects().length === 0) {
1163				continue;
1164			}
1165			let rect = el.getBoundingClientRect();
1166			if (rect.width <= 0 && rect.height <= 0) {
1167				// Some visible lazy assets have neither CSS nor HTML dimensions.
1168				// Use the first parent with a layout box to decide when to load.
1169				let host = el.parentElement;
1170				while (host && host !== document.body) {
1171					const hostRect = host.getBoundingClientRect();
1172					if (hostRect.width > 0 || hostRect.height > 0) {
1173						rect = hostRect;
1174						break;
1175					}
1176					host = host.parentElement;
1177				}
1178			}
1179			if (rect.width <= 0 && rect.height <= 0) continue;
1180			if (inPreloadZone(rect, vw, vh)) {
1181				rec.handle();
1182				lazyRecs.splice(i, 1);
1183			}
1184		}
1185	}
1186
1187	/* ---------------------------------------------
1188	   Bi-directional preload for section BG <picture>
1189	   (kept as in your original, mostly Y-based)
1190	--------------------------------------------- */
1191	const isSlideshowPic = (pic) =>
1192		!!pic.closest(".homeHeroWrapper, .promoGallery[data-role='slideshow']") ||
1193		!!pic.querySelector("img[data-slideshow-src], source[data-slideshow-srcset]");
1194
1195	let bidiPics = Array.from(
1196		document.querySelectorAll("picture[data-preload-bidi][data-src]")
1197	).filter((pic) => !pic.closest(".modal") && !isSlideshowPic(pic));
1198
1199	function pickupContainerFor(pic) {
1200		return (
1201			pic.closest(
1202				"[data-bg-section], section, .section, .homeSection, .promoSection, .block, .container, .mainPad"
1203			) || pic.parentElement || pic
1204		);
1205	}
1206
1207	function nearViewportFromEitherSideRect(rect, vh) {
1208		const nearTop = rect.top < vh * 0.6 && rect.bottom > 0;
1209		const nearBot = rect.bottom > vh * 0.4 && rect.top < vh;
1210		return nearTop || nearBot;
1211	}
1212
1213	function maybeLoadBidi(pic, vh) {
1214		if (!pic || pic.dataset.loaded === "1") return true;
1215
1216		const rPic = pic.getBoundingClientRect();
1217		const tiny = rPic.height < 24 || rPic.width < 24;
1218		let rect   = rPic;
1219
1220		if (tiny) {
1221			const host = pickupContainerFor(pic);
1222			rect = host.getBoundingClientRect();
1223		}
1224
1225		if (nearViewportFromEitherSideRect(rect, vh)) {
1226			loadPicture(pic);
1227			return true;
1228		}
1229		return false;
1230	}
1231
1232	/* ---------------------------------------------
1233	   Scroll/resize orchestration (one RAF loop)
1234	--------------------------------------------- */
1235	let ticking  = false;
1236	let attached = false;
1237
1238	// Cache viewport dimensions to avoid forced reflows on scroll
1239	let cachedVw = window.innerWidth || 0;
1240	let cachedVh = window.innerHeight || 0;
1241
1242	function updateViewportCache() {
1243		cachedVw = window.innerWidth || 0;
1244		cachedVh = window.innerHeight || 0;
1245	}
1246
1247	function tickAll() {
1248		// Use cached viewport dimensions (no forced reflow)
1249		const vw = cachedVw;
1250		const vh = cachedVh;
1251
1252		// Images in galleries / horizontally scrolling areas
1253		processLazy(vw, vh);
1254
1255		// Background section pictures (bidi)
1256		if (bidiPics.length) {
1257			bidiPics = bidiPics.filter((pic) => !maybeLoadBidi(pic, vh));
1258		}
1259
1260		// If nothing left to do, shut down listeners
1261		if (!lazyRecs.length && !bidiPics.length) {
1262			detach();
1263		}
1264	}
1265
1266	function onScrollThrottled() {
1267		if (ticking) return;
1268		ticking = true;
1269		requestAnimationFrame(() => {
1270			ticking = false;
1271			tickAll();
1272		});
1273	}
1274
1275	function onResizeThrottled() {
1276		updateViewportCache(); // Update cache on resize
1277		if (ticking) return;
1278		ticking = true;
1279		requestAnimationFrame(() => {
1280			ticking = false;
1281			tickAll();
1282		});
1283	}
1284
1285	// Separate handlers for scroll vs resize
1286	const onScroll     = onScrollThrottled;
1287	const onResize     = onResizeThrottled;
1288	const onHashChange = onScrollThrottled;
1289	function onPageShow(e) {
1290		if (e && e.persisted) onScrollThrottled();
1291	}
1292	window.VRLibraryLazyLoader = {
1293		refresh: onScrollThrottled,
1294	};
1295
1296	function attach() {
1297		if (attached) return;
1298		// Capture scroll so we see scroll events from *any* scroll container
1299		document.addEventListener("scroll", onScroll, { passive: true, capture: true });
1300		window.addEventListener("resize", onResize);
1301		window.addEventListener("hashchange", onHashChange);
1302		window.addEventListener("pageshow", onPageShow);
1303		attached = true;
1304	}
1305
1306	function detach() {
1307		if (!attached) return;
1308		document.removeEventListener("scroll", onScroll, { capture: true });
1309		window.removeEventListener("resize", onResize);
1310		window.removeEventListener("hashchange", onHashChange);
1311		window.removeEventListener("pageshow", onPageShow);
1312		attached = false;
1313	}
1314
1315	// Initial kick
1316	if (document.readyState === "complete") {
1317		tickAll();
1318	} else {
1319		window.addEventListener("load", tickAll, { once: true });
1320		requestAnimationFrame(tickAll);
1321	}
1322
1323	attach();
1324})();
1325
1326/* =========================================================
1327NavMenu Toggle — unified mobile/tablet/desktop (single delegated click)
1328========================================================= */
1329(() => {
1330	const nav   = document.getElementById("mainNav");
1331	const body  = document.body;
1332	const html  = document.documentElement;
1333	if (!nav) return;
1334
1335	// Lookup helpers (in case DOM shifts)
1336	const getHam   = () => document.querySelector(".toggleMenu");
1337	const getClose = () => document.querySelector(".toggleX");
1338
1339	// Your SCSS: .toggle.active { opacity: 0 }  => "active" = HIDDEN
1340	const setIconsClosed = () => {
1341		const ham = getHam();
1342		const x   = getClose();
1343		if (ham) ham.classList.remove("active");  // show hamburger
1344		if (x)   x.classList.add("active");       // hide X
1345	};
1346
1347	const setIconsOpen = () => {
1348		const ham = getHam();
1349		const x   = getClose();
1350		if (ham) ham.classList.add("active");     // hide hamburger
1351		if (x)   x.classList.remove("active");    // show X
1352	};
1353
1354	function openMenu() {
1355		nav.classList.add("active");
1356		body.classList.add("nav-open");
1357		html.classList.add("nav-open");
1358		body.style.overflow = "hidden";
1359		setIconsOpen();
1360	}
1361
1362	function closeMenu() {
1363		nav.classList.remove("active");
1364		body.classList.remove("nav-open");
1365		html.classList.remove("nav-open");
1366		body.style.overflow = "";
1367		setIconsClosed();
1368	}
1369
1370	function toggleMenu() {
1371		nav.classList.contains("active") ? closeMenu() : openMenu();
1372	}
1373
1374	// Expose (keeps any existing inline onclick working)
1375	window.toggleMenu = (e) => {
1376		e?.preventDefault?.();
1377		toggleMenu();
1378	};
1379	window.openMenu = (e) => {
1380		e?.preventDefault?.();
1381		openMenu();
1382	};
1383	window.closeMenu = (e) => {
1384		e?.preventDefault?.();
1385		closeMenu();
1386	};
1387
1388	// Hint to mobile browsers to avoid delays
1389	[getHam(), getClose()].forEach((btn) => {
1390		if (!btn) return;
1391		try {
1392			btn.style.touchAction = "manipulation";
1393		} catch {}
1394		try {
1395			btn.style.webkitTapHighlightColor = "transparent";
1396		} catch {}
1397	});
1398
1399	// --- Single delegated CLICK with capture, so nothing else eats it ---
1400	const TOGGLE_SELECTOR =
1401		".toggleMenu, .toggleX, .toggle[data-role='nav-toggle'], .toggleMenu *, .toggleX *";
1402
1403	let lastToggleAt        = 0;
1404	const TOGGLE_DEBOUNCE_MS = 300; // shields ghost double-fires across browsers
1405
1406	function onDocClickCapture(e) {
1407		// Find a toggle target (supports clicks on child <i>/<span>)
1408		const targetButton = e.target.closest?.(
1409			".toggleMenu, .toggleX, .toggle[data-role='nav-toggle']"
1410		);
1411		if (!targetButton) return;
1412
1413		// Hard stop any other handlers (backdrops, overlays)
1414		e.stopPropagation();
1415		e.stopImmediatePropagation();
1416		e.preventDefault();
1417
1418		// Debounce: ignore a second click within the window
1419		const now = Date.now();
1420		if (now - lastToggleAt < TOGGLE_DEBOUNCE_MS) return;
1421		lastToggleAt = now;
1422
1423		toggleMenu();
1424	}
1425
1426	// Capture-phase so we win before other listeners
1427	document.addEventListener("click", onDocClickCapture, true);
1428
1429	// Helper for breakpoint logic (separate from global isVisible on purpose)
1430	function isVisibleLocal(el) {
1431		if (!el) return false;
1432		const cs = getComputedStyle(el);
1433		if (cs.display === "none" || cs.visibility === "hidden" || +cs.opacity === 0) return false;
1434		const r = el.getBoundingClientRect();
1435		return r.width > 0 && r.height > 0;
1436	}
1437
1438	// Auto-collapse when hamburger not visible (desktop)
1439	const resync = (() => {
1440		let raf;
1441		return function () {
1442			if (raf) cancelAnimationFrame(raf);
1443			raf = requestAnimationFrame(() => {
1444				const hamVisible = isVisibleLocal(getHam());
1445				if (!hamVisible) {
1446					closeMenu();
1447				} else {
1448					nav.classList.contains("active") ? setIconsOpen() : setIconsClosed();
1449				}
1450			});
1451		};
1452	})();
1453
1454	// Initial state (match DOM)
1455	if (nav.classList.contains("active")) setIconsOpen();
1456	else setIconsClosed();
1457
1458	window.addEventListener("resize", resync);
1459	window.addEventListener("orientationchange", resync);
1460	window.addEventListener("pageshow", resync);
1461	resync();
1462})();
1463
1464/* =========================================================
1465Notification Modal
1466========================================================= */
1467(() => {
1468	const STORAGE_KEY      = "vrNoticeDismissals";
1469	const HIDE_AFTER_DATE  = ""; // e.g., "2025-11-15"
1470	const CHECKED_DISMISS_HOURS = 48; // checkbox checked; never suppress a notice permanently
1471
1472	const $modal    = document.getElementById("vr-notice");
1473	const $backdrop = document.getElementById("vr-notice-backdrop");
1474	const $panel    = document.getElementById("vrn-panel");
1475	const $ok       = document.getElementById("vrn-ok");
1476	const $dont     = document.getElementById("vrn-dont-show");
1477
1478	if (!$modal || !$backdrop || !$panel) return;
1479	// This value is generated from the ProcessWire notification content. A content
1480	// change gets a new key, so an old dismissal cannot hide the new message.
1481	const NOTICE_ID = $modal.dataset.noticeId || "vr-notice";
1482
1483	const now      = () => Date.now();
1484	const addHours = (ts, h) => ts + h * 60 * 60 * 1000;
1485
1486	function loadMap() {
1487		try {
1488			return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
1489		} catch {
1490			return {};
1491		}
1492	}
1493
1494	function saveMap(map) {
1495		localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
1496	}
1497
1498	function clearNotice() {
1499		const map = loadMap();
1500		delete map[NOTICE_ID];
1501		saveMap(map);
1502	}
1503
1504	function expiryFor(entry) {
1505		if (!entry || typeof entry.expiresAt !== "number") return null;
1506		// "Okay" no longer persists a dismissal. Treat saved entries from the
1507		// former 24-hour behavior as expired so they cannot hide this notice.
1508		if (entry.mode === "temp") return null;
1509
1510		// Existing "forever" entries used a 180-day expiry. Cap them too, so
1511		// visitors who dismissed the prior notice are not stuck waiting for it.
1512		if (entry.mode === "forever") {
1513			const dismissedAt = Date.parse(entry.dismissedAt);
1514			if (!Number.isFinite(dismissedAt)) return null;
1515			return Math.min(entry.expiresAt, addHours(dismissedAt, CHECKED_DISMISS_HOURS));
1516		}
1517
1518		return entry.expiresAt;
1519	}
1520
1521	function isPastISO(dateStr) {
1522		if (!dateStr) return false;
1523		const today = new Date();
1524		today.setHours(0, 0, 0, 0);
1525		const d = new Date(dateStr + "T00:00:00");
1526		return d <= today;
1527	}
1528
1529	const params  = new URLSearchParams(location.search);
1530	const devMode = params.get("vrn"); // "reset" | "show" | null
1531	if (devMode === "reset") clearNotice();
1532
1533	function applyHidden(el, hidden) {
1534		if (!el) return;
1535		if (hidden) {
1536			el.hidden = true;
1537			el.style.display = "none";
1538			el.style.pointerEvents = "none";
1539		} else {
1540			el.hidden = false;
1541			el.style.display = "";
1542			el.style.pointerEvents = "";
1543		}
1544	}
1545
1546	function show() {
1547		applyHidden($modal, false);
1548		applyHidden($backdrop, false);
1549		document.documentElement.classList.add("vrn-modal-open");
1550		document.documentElement.style.overflow = "hidden";
1551		// Make absolutely sure backdrop is interactive only while shown
1552		$backdrop.style.pointerEvents = "auto";
1553		setTimeout(() => {
1554			$panel?.focus();
1555		}, 30);
1556	}
1557
1558	function hide(persist) {
1559		applyHidden($modal, true);
1560		applyHidden($backdrop, true);
1561		document.documentElement.classList.remove("vrn-modal-open");
1562		document.documentElement.style.overflow = "";
1563		// Belt & suspenders: block any interaction when hidden
1564		$backdrop.style.pointerEvents = "none";
1565
1566		if (persist) {
1567			const map = loadMap();
1568			const t   = now();
1569			map[NOTICE_ID] = {
1570				dismissedAt: new Date(t).toISOString(),
1571				expiresAt: addHours(t, CHECKED_DISMISS_HOURS),
1572				mode: "checked"
1573			};
1574			saveMap(map);
1575		}
1576	}
1577
1578	const expiredGlobally = isPastISO(HIDE_AFTER_DATE);
1579	const entry           = loadMap()[NOTICE_ID];
1580	const entryExpires    = expiryFor(entry);
1581	let shouldShow        = true;
1582
1583	if (expiredGlobally) {
1584		shouldShow = false;
1585	} else if (devMode === "show") {
1586		shouldShow = true;
1587	} else if (entry && entryExpires) {
1588		if (now() < entryExpires) {
1589			shouldShow = false;
1590		} else {
1591			clearNotice();
1592			shouldShow = true;
1593		}
1594	} else if (entry) {
1595		clearNotice(); // legacy entry
1596		shouldShow = true;
1597	}
1598
1599	if (shouldShow) show();
1600
1601	function handleDismiss() {
1602		// Only an explicit selection of "Don't show this again" is remembered.
1603		hide(Boolean($dont?.checked));
1604	}
1605
1606	// Use bubbling (no capture) and keep handlers local to modal/backdrop only
1607	if ($ok) {
1608		$ok.addEventListener(
1609			"click",
1610			(e) => {
1611				e.preventDefault();
1612				handleDismiss();
1613			},
1614			false
1615		);
1616		$ok.addEventListener(
1617			"touchend",
1618			(e) => {
1619				e.preventDefault();
1620				handleDismiss();
1621			},
1622			false
1623		);
1624	}
1625
1626	if ($backdrop) {
1627		$backdrop.addEventListener(
1628			"click",
1629			(e) => {
1630				e.preventDefault();
1631				handleDismiss();
1632			},
1633			false
1634		);
1635		$backdrop.addEventListener(
1636			"touchend",
1637			(e) => {
1638				e.preventDefault();
1639				handleDismiss();
1640			},
1641			false
1642		);
1643	}
1644
1645	document.addEventListener("keydown", (e) => {
1646		if (e.key === "Escape" && !$modal.hidden) handleDismiss();
1647	});
1648})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.