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.