PageSourceSearch

https://mmoculture.com/wp-content/themes/mmoculture-modern/assets/js/theme.js?ver=1790840238

js mmoculture.com collected 2026-10-02 04:56:06 UTC 32,462 bytes, 901 lines download raw bytes

1document.addEventListener("DOMContentLoaded", function () {
2  "use strict";
3
4  // The stylesheet's desktop split, so the phone-only ads cannot drift from it.
5  function isDesktopLayout() {
6    return window.matchMedia(
7      "(min-width: 900px), (min-width: 600px) and (orientation: landscape)",
8    ).matches;
9  }
10
11  // --- Dark mode ---
12  var root = document.documentElement;
13  var toggles = document.querySelectorAll(
14    "#mmoc-theme-toggle, #mmoc-theme-toggle-mobile",
15  );
16
17  // Dark is the default, so the attribute is only ever present to mark light —
18  // its absence means dark, not the other way round.
19  function currentTheme() {
20    return root.getAttribute("data-theme") === "light" ? "light" : "dark";
21  }
22
23  function applyToggleLabels() {
24    var isDark = currentTheme() === "dark";
25    toggles.forEach(function (btn) {
26      btn.setAttribute("aria-pressed", isDark ? "true" : "false");
27      var label = btn.querySelector(".mmoc-theme-toggle__label");
28      if (label) {
29        label.textContent = isDark ? "Light mode" : "Dark mode";
30      }
31    });
32  }
33
34  function setTheme(theme) {
35    if (theme === "light") {
36      root.setAttribute("data-theme", "light");
37    } else {
38      root.removeAttribute("data-theme");
39    }
40    try {
41      localStorage.setItem("mmoc-theme", theme);
42    } catch (e) {}
43    applyToggleLabels();
44  }
45
46  toggles.forEach(function (btn) {
47    btn.addEventListener("click", function () {
48      setTheme(currentTheme() === "dark" ? "light" : "dark");
49    });
50  });
51
52  applyToggleLabels();
53
54  // --- Scrollbar, shown only while scrolling ---
55  // CSS keeps the thumb transparent; this marks the window of time it is
56  // visible. Idle-based rather than scrollend, which Safari does not fire.
57  var scrollingTimer = null;
58
59  window.addEventListener(
60    "scroll",
61    function () {
62      root.setAttribute("data-scrolling", "");
63      if (scrollingTimer) {
64        clearTimeout(scrollingTimer);
65      }
66      scrollingTimer = setTimeout(function () {
67        root.removeAttribute("data-scrolling");
68        scrollingTimer = null;
69      }, 900);
70    },
71    { passive: true },
72  );
73
74  // Same reveal for the panes that scroll inside the page; their bars are
75  // zero-width until this marks them, so nothing reflows when one appears.
76  var markWhileScrolling = function (el) {
77    if (!el) {
78      return;
79    }
80
81    var timer = null;
82
83    el.addEventListener(
84      "scroll",
85      function () {
86        el.setAttribute("data-scrolling", "");
87        if (timer) {
88          clearTimeout(timer);
89        }
90        timer = setTimeout(function () {
91          el.removeAttribute("data-scrolling");
92          timer = null;
93        }, 900);
94      },
95      { passive: true },
96    );
97  };
98
99  markWhileScrolling(document.querySelector(".mmoc-sidebar"));
100  markWhileScrolling(document.querySelector(".mmoc-coderail__list"));
101
102  // --- Consent recall ---
103  // The CMP exposes a U.S. privacy UI only when a U.S. GPP section applies;
104  // every other visitor reopens the TCF consent UI. All calls are guarded so
105  // the footer action stays inert if the third-party CMP failed to load.
106  var reopenConsent = function () {
107    var openTcfUi = function () {
108      if (typeof window.__tcfapi === "function") {
109        window.__tcfapi("displayConsentUi", 2, function () {});
110      }
111    };
112
113    if (typeof window.__gpp !== "function") {
114      openTcfUi();
115      return;
116    }
117
118    try {
119      window.__gpp("ping", function (pingData, success) {
120        var sections =
121          pingData && Array.isArray(pingData.applicableSections)
122            ? pingData.applicableSections
123            : [];
124        var hasUsSection =
125          success &&
126          sections.some(function (section) {
127            return section >= 6 && section <= 12;
128          });
129
130        if (hasUsSection && typeof window.__uspapi === "function") {
131          window.__uspapi("displayUspUi", 1, function () {});
132          return;
133        }
134
135        openTcfUi();
136      });
137    } catch (e) {
138      openTcfUi();
139    }
140  };
141
142  document
143    .querySelectorAll("[data-mmoc-consent-reopen]")
144    .forEach(function (button) {
145      button.addEventListener("click", reopenConsent);
146    });
147
148  // --- Bottom sheet (More nav) ---
149  // Kept as a registry of one: the machinery is generic, and whatever sheet
150  // comes next inherits the same chrome, exits, and one-at-a-time rule.
151  var sheets = [];
152
153  // Assigned further down, once the header search exists. Declared here because
154  // a sheet opening has to be able to close it, and it has to be able to close
155  // the sheets — without this the two could both be open at once, which reads
156  // as two menus over one bar.
157  var closeHeaderSearch = function () {};
158
159  var closeSheets = function (except) {
160    sheets.forEach(function (sheet) {
161      if (sheet !== except) {
162        sheet.setOpen(false);
163      }
164    });
165  };
166
167  // A collapsed field is hidden — with visibility on a sheet, display on the
168  // header's inline bar — and focus() called before the browser
169  // has laid it out tests focusability against the hidden state and is
170  // silently dropped. How many frames that takes is not fixed — the first open
171  // after a page load needs more than a later one — so rather than guess a
172  // number, try, check whether it took, and retry for a few frames. Bounded
173  // well past the sheet's 240ms slide.
174  var focusWhenReady = function (el, tries) {
175    el.focus();
176
177    if (document.activeElement !== el && tries > 0) {
178      window.requestAnimationFrame(function () {
179        focusWhenReady(el, tries - 1);
180      });
181    }
182  };
183
184  var setupSheet = function (toggleId, panelId, closeId, focusSelector) {
185    var toggle = document.getElementById(toggleId);
186    var panel = document.getElementById(panelId);
187
188    if (!toggle || !panel) {
189      return;
190    }
191
192    var sheet = {
193      panel: panel,
194      toggle: toggle,
195      isOpen: function () {
196        return panel.classList.contains("is-open");
197      },
198      setOpen: function (open) {
199        panel.classList.toggle("is-open", open);
200        toggle.setAttribute("aria-expanded", open ? "true" : "false");
201
202        var target =
203          open && focusSelector ? panel.querySelector(focusSelector) : null;
204
205        if (target) {
206          focusWhenReady(target, 20);
207        }
208      },
209    };
210
211    toggle.addEventListener("click", function (event) {
212      // Without this the document listener below sees the same click and
213      // closes the sheet the button just opened.
214      event.stopPropagation();
215      var open = !sheet.isOpen();
216      closeSheets(sheet);
217
218      if (open) {
219        closeHeaderSearch();
220      }
221
222      sheet.setOpen(open);
223    });
224
225    var close = closeId ? document.getElementById(closeId) : null;
226
227    if (close) {
228      close.addEventListener("click", function () {
229        sheet.setOpen(false);
230        // Focus would otherwise land on a button that just left the
231        // screen, leaving the next Tab starting from nowhere.
232        toggle.focus();
233      });
234    }
235
236    sheets.push(sheet);
237  };
238
239  // No focus target for More: focusing a link would give a tap nothing and
240  // risks painting a focus ring on touch, and the sheet follows the nav in the
241  // DOM so Tab reaches it from the trigger anyway.
242  setupSheet("mmoc-more-toggle", "mmoc-more-sheet", "mmoc-more-close", null);
243
244  // Same reasoning as More: no focus target, since a tap has nowhere useful
245  // to send focus and would only risk a visible ring. One call per copy of
246  // the switcher — masthead and mobile header each render their own toggle
247  // and panel, with ids namespaced by mmoculture_modern_render_language_links().
248  setupSheet(
249    "mmoc-masthead__lang-toggle",
250    "mmoc-masthead__lang-menu",
251    null,
252    null
253  );
254  setupSheet(
255    "mmoc-mobile-header__lang-toggle",
256    "mmoc-mobile-header__lang-menu",
257    null,
258    null
259  );
260
261  // --- Mobile header search, expanded in place ---
262  // The same control opens and closes it: aria-expanded drives both the icon
263  // swap and the header's layout, so this only has to move the flag.
264  var headerSearchToggle = document.getElementById("mmoc-mobile-search-toggle");
265  var mobileHeader = document.querySelector(".mmoc-mobile-header");
266
267  if (headerSearchToggle && mobileHeader) {
268    var headerSearchField = document.getElementById("mmoc-mobile-search-field");
269
270    // returnFocus only when the reader closed the bar themselves, via the
271    // control or Escape: focus would otherwise follow the collapsing field
272    // back to a button they did not touch — a tap on More would end up
273    // focusing the search control it just replaced.
274    var setHeaderSearchOpen = function (open, returnFocus) {
275      mobileHeader.classList.toggle("is-searching", open);
276      headerSearchToggle.setAttribute("aria-expanded", open ? "true" : "false");
277
278      if (open) {
279        if (headerSearchField) {
280          focusWhenReady(headerSearchField, 20);
281        }
282      } else if (returnFocus) {
283        headerSearchToggle.focus();
284      }
285    };
286
287    closeHeaderSearch = function () {
288      if (mobileHeader.classList.contains("is-searching")) {
289        setHeaderSearchOpen(false, false);
290      }
291    };
292
293    headerSearchToggle.addEventListener("click", function () {
294      var open = !mobileHeader.classList.contains("is-searching");
295      // The bar and the More sheet both belong to the same chrome; leaving
296      // the sheet up under an open search field reads as two menus at once.
297      closeSheets(null);
298      setHeaderSearchOpen(open, true);
299    });
300
301    document.addEventListener("keydown", function (event) {
302      if (
303        "Escape" === event.key &&
304        mobileHeader.classList.contains("is-searching")
305      ) {
306        setHeaderSearchOpen(false, true);
307      }
308    });
309  }
310
311  if (sheets.length) {
312    // A sheet covers the page it was opened over, so give it the two exits a
313    // sheet is expected to have instead of only its own trigger again.
314    document.addEventListener("click", function (event) {
315      sheets.forEach(function (sheet) {
316        if (sheet.isOpen() && !sheet.panel.contains(event.target)) {
317          sheet.setOpen(false);
318        }
319      });
320    });
321
322    document.addEventListener("keydown", function (event) {
323      if ("Escape" !== event.key) {
324        return;
325      }
326      sheets.forEach(function (sheet) {
327        if (sheet.isOpen()) {
328          sheet.setOpen(false);
329          sheet.toggle.focus();
330        }
331      });
332    });
333  }
334
335  // --- Sticky anchor ad dismiss (session-persisted) ---
336  var anchorAd = document.getElementById("mmoc-anchor-ad");
337  var anchorDismiss = document.getElementById("mmoc-anchor-dismiss");
338
339  if (anchorAd) {
340    var dismissed = false;
341    try {
342      dismissed = sessionStorage.getItem("mmoc-anchor-dismissed") === "1";
343    } catch (e) {}
344
345    if (!dismissed && !isDesktopLayout()) {
346      anchorAd.classList.add("is-visible");
347    }
348
349    if (anchorDismiss) {
350      anchorDismiss.addEventListener("click", function () {
351        anchorAd.classList.remove("is-visible");
352        try {
353          sessionStorage.setItem("mmoc-anchor-dismissed", "1");
354        } catch (e) {}
355      });
356    }
357  }
358
359  // --- Sliding pill rows: show the selected pill on load ---
360  // The search filters mark their active pill server-side and the row opens at
361  // scrollLeft 0, so a reader who picked the last filter could not see their own
362  // selection. Centre it instead, clamped to the ends so the first and last sit
363  // flush rather than leaving a gap the row cannot scroll into.
364  //
365  // The home row needs nothing from this — its scroll spy reveals the active
366  // pill itself — but running here is harmless: its first pill clamps to 0.
367  document.querySelectorAll(".mmoc-pills--slide").forEach(function (row) {
368    var active = row.querySelector(".is-active");
369    var overflow = row.scrollWidth - row.clientWidth;
370
371    // Nothing to do where the row wraps instead of scrolling (desktop).
372    if (!active || overflow <= 0) {
373      return;
374    }
375
376    var target = active.offsetLeft - (row.clientWidth - active.offsetWidth) / 2;
377    row.scrollLeft = Math.max(0, Math.min(target, overflow));
378  });
379
380  // The homepage section pills and their scroll spy lived here. The home
381  // titlebar that carried that row is gone — the masthead's nav covers the
382  // categories and the sections are reached by scrolling — so the smooth
383  // scroll, the spy and revealPill() went with it. The generic pill-row
384  // centring above still serves the search page's filter row, which is the
385  // other .mmoc-pills--slide on the site.
386
387  // --- Deferred ad units ---
388  // Slots rendered with $defer carry their mapping as data-base-div instead of
389  // an inline refresh call, so nothing is requested until this decides it is
390  // worth requesting. That is what keeps the site's two ad sets from charging
391  // each other: the phone's units and the desktop rail's are each hidden at the
392  // other's widths, and a hidden unit would otherwise fetch an ad no one can
393  // see, which counts against viewability.
394  //
395  // offsetParent is null for anything with display: none anywhere up its
396  // ancestry, which is exactly the test needed here.
397  var activateDeferredAds = function (scope) {
398    var units = (scope || document).querySelectorAll("[data-base-div]");
399    var mappings = [];
400
401    units.forEach(function (unit) {
402      if (unit.offsetParent === null) {
403        return;
404      }
405
406      mappings.push({
407        divId: unit.id,
408        baseDivId: unit.getAttribute("data-base-div"),
409      });
410      // Claimed, so a later pass cannot request the same unit twice.
411      unit.removeAttribute("data-base-div");
412    });
413
414    if (!mappings.length) {
415      return;
416    }
417
418    window.tude = window.tude || { cmd: [] };
419    window.tude.cmd.push(function () {
420      window.tude.refreshAdsViaDivMappings(mappings);
421    });
422  };
423
424  activateDeferredAds(document);
425
426  // --- Article interstitial ---
427  // Shown every time the reader opens an article, at phone widths only. Uncapped
428  // by decision, not by omission: it was capped at once per session, then gated
429  // to `?ad_slots=true` QA runs, and both limits were lifted deliberately — do
430  // not reintroduce one without asking. The tradeoff is recorded at the slot
431  // definition in inc/ads.php.
432  //
433  // The width gate is new: the desktop layout carries its advertising in the
434  // left rail and takes no in-content units at all, so an overlay between a
435  // desktop reader and the article they clicked would be the one ad on that
436  // page and the most intrusive thing on the site. Read per click rather than
437  // once at load, so a window resized across the breakpoint behaves as the
438  // width it currently is.
439  var interstitial = document.getElementById("mmoc-interstitial");
440
441  if (interstitial && document.body.classList) {
442    // Every <a> in the theme that opens a post.
443    var ARTICLE_LINKS =
444      ".mmoc-featured-card, .mmoc-card, .mmoc-list-row, .mmoc-adjacent__card";
445
446    var interstitialClose = document.getElementById("mmoc-interstitial-close");
447    var interstitialContinue = document.getElementById(
448      "mmoc-interstitial-continue",
449    );
450    var pendingHref = "";
451    var returnFocusTo = null;
452
453    var closeInterstitial = function (goToArticle) {
454      interstitial.hidden = true;
455      document.body.classList.remove("is-interstitial-open");
456
457      if (goToArticle && pendingHref) {
458        window.location.href = pendingHref;
459        return;
460      }
461
462      if (returnFocusTo) {
463        returnFocusTo.focus();
464      }
465    };
466
467    document.addEventListener("click", function (event) {
468      if (!event.target.closest) {
469        return;
470      }
471
472      var link = event.target.closest(ARTICLE_LINKS);
473
474      if (!link || !link.getAttribute("href")) {
475        return;
476      }
477
478      // Leave the browser's own behaviour alone for anything that is not a
479      // plain left click: a modified click opens a tab the reader is not
480      // looking at, and interrupting it would strand the ad in the
481      // background and lose them the article.
482      if (
483        event.button !== 0 ||
484        event.metaKey ||
485        event.ctrlKey ||
486        event.shiftKey ||
487        event.altKey
488      ) {
489        return;
490      }
491
492      // Phone widths only — see the note at the top of this block.
493      if (isDesktopLayout()) {
494        return;
495      }
496
497      event.preventDefault();
498      pendingHref = link.href;
499      returnFocusTo = link;
500
501      // Whatever happens to the ad, this link reaches the article.
502      interstitialContinue.setAttribute("href", pendingHref);
503
504      interstitial.hidden = false;
505      document.body.classList.add("is-interstitial-open");
506      // Same reason as the sheets: hidden until now, so not yet focusable.
507      focusWhenReady(interstitialClose, 20);
508    });
509
510    if (interstitialClose) {
511      interstitialClose.addEventListener("click", function () {
512        closeInterstitial(true);
513      });
514    }
515
516    // Clicking the backdrop dismisses without going anywhere — the reader
517    // changed their mind about the article, not just the ad.
518    interstitial.addEventListener("click", function (event) {
519      if (event.target === interstitial) {
520        closeInterstitial(false);
521      }
522    });
523
524    document.addEventListener("keydown", function (event) {
525      if ("Escape" === event.key && !interstitial.hidden) {
526        closeInterstitial(false);
527      }
528    });
529  }
530
531  // --- Masthead search ---
532  // The mobile bar's expanding field at desktop widths, with the exits a
533  // pointer surface needs on top of the ones the bar has. The bar is a phone
534  // header where the only ways out are its own control and Escape; here the
535  // reader can also click elsewhere or tab away, and a field left open behind
536  // them would sit in the row holding 240px it is no longer using.
537  //
538  // Focus-out rather than mouseleave. Collapsing when the pointer leaves would
539  // close the field mid-sentence the moment the reader's hand drifted, so what
540  // closes it is the reader going somewhere else: focus leaving the group, a
541  // click outside it, Escape, or the control again. Whatever was typed stays in
542  // the input either way — it is collapsed, not cleared, so reopening resumes.
543  var mastheadSearch = document.querySelector(".mmoc-masthead__search");
544  var mastheadSearchToggle = document.getElementById(
545    "mmoc-masthead-search-toggle",
546  );
547
548  if (mastheadSearch && mastheadSearchToggle) {
549    var mastheadSearchField = document.getElementById(
550      "mmoc-masthead-search-field",
551    );
552
553    var isMastheadSearchOpen = function () {
554      return "true" === mastheadSearchToggle.getAttribute("aria-expanded");
555    };
556
557    // returnFocus only when the reader closed it deliberately — via the
558    // control or Escape. On a click elsewhere focus belongs to whatever they
559    // clicked, and on a tab-out to wherever they tabbed.
560    var setMastheadSearchOpen = function (open, returnFocus) {
561      mastheadSearchToggle.setAttribute(
562        "aria-expanded",
563        open ? "true" : "false",
564      );
565      // The attribute is the state assistive tech reads; the class is what
566      // the stylesheet draws from. Set together here so they cannot drift —
567      // see the note by .mmoc-masthead__search.is-open for why the CSS reads
568      // a class rather than :has() on the attribute.
569      mastheadSearch.classList.toggle("is-open", open);
570
571      if (open) {
572        if (mastheadSearchField) {
573          focusWhenReady(mastheadSearchField, 20);
574        }
575      } else if (returnFocus) {
576        mastheadSearchToggle.focus();
577      }
578    };
579
580    mastheadSearchToggle.addEventListener("click", function () {
581      setMastheadSearchOpen(!isMastheadSearchOpen(), true);
582    });
583
584    // Read after the move settles rather than from event.relatedTarget, which
585    // is where focus is *going* and is not always populated — a focus moved by
586    // script reported null and left the field open behind a reader who had
587    // tabbed away. One tick later document.activeElement is authoritative for
588    // every route out, script included.
589    //
590    // Two things it must not treat as leaving. Focus moving between the field
591    // and its own control is a move inside the group — hence the containment
592    // test. And focus landing on <body> is focus that went nowhere nameable:
593    // the open itself produces exactly that, because focusing an input that is
594    // still zero-wide takes focus off the control without putting it in the
595    // field, so a bare close here collapsed the field on the very click that
596    // opened it. A real departure always names an element; the two routes that
597    // legitimately end on <body> — a click on empty page, a click on chrome —
598    // are covered by the document click handler below and by nothing needing
599    // to happen at all.
600    mastheadSearch.addEventListener("focusout", function () {
601      if (!isMastheadSearchOpen()) {
602        return;
603      }
604
605      window.setTimeout(function () {
606        var active = document.activeElement;
607
608        if (!active || active === document.body) {
609          return;
610        }
611
612        if (isMastheadSearchOpen() && !mastheadSearch.contains(active)) {
613          setMastheadSearchOpen(false, false);
614        }
615      }, 0);
616    });
617
618    document.addEventListener("click", function (event) {
619      if (isMastheadSearchOpen() && !mastheadSearch.contains(event.target)) {
620        setMastheadSearchOpen(false, false);
621      }
622    });
623
624    document.addEventListener("keydown", function (event) {
625      if ("Escape" === event.key && isMastheadSearchOpen()) {
626        setMastheadSearchOpen(false, true);
627      }
628    });
629  }
630
631  // --- Sticky chrome: the masthead's scroll-up reveal ---
632  // 98px of header is worth its space when the reader is looking for it and not
633  // when they are reading, so it retreats going down the page and comes back on
634  // the first upward movement. The titlebar rides along — see the transform pair
635  // in style.css for why both move by transform rather than by their own `top`.
636  //
637  // The rail's trending card parks below both, so the titlebar's height is
638  // published as a custom property for CSS to add up. It is measured rather than
639  // declared because it is the one part of the stack that is not a fixed number:
640  // the home bar carries a pill row, the article bar a headline, and that
641  // headline condenses once the bar pins.
642  // The scroll position at which the reader has left the top of the page. Below
643  // it the header is simply part of the page and animating it away reads as a
644  // twitch; above it the header is chrome over content. Shared with the
645  // condensed article titlebar below, which needs the same distinction.
646  var CHROME_KEEP = 140;
647
648  var masthead = document.querySelector(".mmoc-masthead");
649  var titlebar = document.querySelector(".mmoc-titlebar");
650
651  var publishChromeHeights = function () {
652    var titlebarH = titlebar
653      ? Math.round(titlebar.getBoundingClientRect().height)
654      : 0;
655    document.documentElement.style.setProperty(
656      "--titlebar-h",
657      titlebarH + "px",
658    );
659
660    // The masthead is measured too, not just declared. Its stylesheet value is
661    // the unwrapped height, and between 900px and roughly 1400px the nav wraps
662    // to a second row and the bar is half again as tall — a fixed value there
663    // parked the titlebar over the wrapped row. Only written while the bar is
664    // actually displayed: below 900px it is display: none and would publish 0,
665    // which the titlebar's own mobile offset does not want.
666    if (masthead && masthead.offsetParent !== null) {
667      var mastheadH = Math.round(masthead.getBoundingClientRect().height);
668      document.documentElement.style.setProperty(
669        "--masthead-h",
670        mastheadH + "px",
671      );
672    }
673  };
674
675  publishChromeHeights();
676  window.addEventListener("resize", publishChromeHeights, { passive: true });
677  // The bar can hold a lazily-loaded avatar, so its height is not final at
678  // DOMContentLoaded.
679  window.addEventListener("load", publishChromeHeights);
680
681  if (masthead) {
682    // Movement in one direction needed before the header changes state, and
683    // the reason it is 32 rather than a pixel or two of deadband: crossing
684    // CHROME_KEEP condenses the article titlebar, which shortens that bar by
685    // 18px, which shortens the document, which makes the browser nudge
686    // scrollY back by the same amount. A frame-to-frame comparison read that
687    // nudge as the reader scrolling up and re-showed the header halfway
688    // through a downward scroll. Accumulating instead means a one-off shift
689    // smaller than this cannot flip the state on its own, while any real
690    // gesture clears it immediately. It absorbs trackpad and momentum jitter
691    // on the same principle.
692    var CHROME_TRIGGER = 32;
693
694    var lastChromeY =
695      window.pageYOffset || document.documentElement.scrollTop || 0;
696    var chromeTravel = 0;
697    var chromeHidden = false;
698
699    var setChromeHidden = function (hidden) {
700      // Written only on a change: this runs per scroll frame, and a class
701      // write per frame invalidates style on every one of them.
702      if (hidden === chromeHidden) {
703        return;
704      }
705      chromeHidden = hidden;
706      document.body.classList.toggle("is-chrome-hidden", hidden);
707    };
708
709    var syncChrome = function () {
710      var y = window.pageYOffset || document.documentElement.scrollTop || 0;
711      var dy = y - lastChromeY;
712
713      lastChromeY = y;
714
715      if (y <= CHROME_KEEP) {
716        setChromeHidden(false);
717        chromeTravel = 0;
718        return;
719      }
720
721      // A change of direction starts the count again, so travel is always
722      // distance moved one way and not a net figure that could sit near
723      // zero through a long scroll back and forth.
724      if (dy > 0 !== chromeTravel > 0) {
725        chromeTravel = 0;
726      }
727
728      chromeTravel += dy;
729
730      if (chromeTravel >= CHROME_TRIGGER) {
731        setChromeHidden(true);
732        chromeTravel = 0;
733      } else if (chromeTravel <= -CHROME_TRIGGER) {
734        setChromeHidden(false);
735        chromeTravel = 0;
736      }
737    };
738
739    window.addEventListener("scroll", syncChrome, { passive: true });
740    syncChrome();
741  }
742
743  // --- Condensed article titlebar ---
744  // The headline steps down once the bar is covering the article, so a long
745  // title only costs the reader viewport while it is actually in the way.
746  //
747  // The trigger is CHROME_KEEP, not "has the bar pinned". It used to be the
748  // latter — the bar stuck at top: 0 and its own layout offset was the exact
749  // scroll position where that happened. It no longer is: the bar now parks
750  // directly under the masthead, at the same offset it already sits at, so it
751  // is pinned from scroll 0 and the test was true on an unscrolled page —
752  // every article opened with its headline already condensed. What the class
753  // wants to know is whether content has begun passing under the bar, which is
754  // what leaving the top of the page means.
755  //
756  // The class is toggled at every width; below 900px the bar is static, but the
757  // rule that reads it is inside a min-width: 900px block and there is nothing
758  // to see there.
759  var articleBar = document.querySelector(".mmoc-titlebar--article");
760
761  if (articleBar) {
762    // A constant rather than a measurement, which is also what settles the
763    // flicker the old test had to carry a deadband for: the class shrinks the
764    // headline, a shorter headline shrinks the bar, and anything derived from
765    // the bar's own geometry fed that back into the threshold. Nothing this
766    // class does can move a fixed number.
767    var barStickPoint = CHROME_KEEP;
768    var barIsStuck = null;
769
770    var measureStickPoint = function () {
771      barStickPoint = CHROME_KEEP;
772    };
773
774    // Deadband. Measuring alone was not enough: the join still flickered
775    // across a few pixels, because the shorter bar shifts the content under
776    // it and the stick point is not perfectly stable while that settles. The
777    // bar engages exactly at its own offset and only lets go once the reader
778    // is a clear 24px above it, so the two thresholds can never trade on
779    // consecutive frames however the layout moves between them.
780    var BAR_RELEASE = 24;
781
782    var syncStuckBar = function () {
783      var scrolled =
784        window.pageYOffset || document.documentElement.scrollTop || 0;
785      var threshold = barIsStuck ? barStickPoint - BAR_RELEASE : barStickPoint;
786      var isStuck = scrolled >= threshold;
787      // Only touch the class list on a change: this runs on every scroll
788      // frame, and a write per frame invalidates style on every one of them.
789      if (isStuck === barIsStuck) {
790        return;
791      }
792      barIsStuck = isStuck;
793      articleBar.classList.toggle("is-stuck", isStuck);
794      // The class steps the headline down, so the bar the trending rail is
795      // parked under is now a different height.
796      publishChromeHeights();
797    };
798
799    var remeasureStickPoint = function () {
800      measureStickPoint();
801      syncStuckBar();
802    };
803
804    window.addEventListener("scroll", syncStuckBar, { passive: true });
805    window.addEventListener("resize", remeasureStickPoint, { passive: true });
806    // The leaderboard above the bar is a lazy image, so the bar's offset is
807    // not final at DOMContentLoaded.
808    window.addEventListener("load", remeasureStickPoint);
809    remeasureStickPoint();
810  }
811
812  // --- Reading progress (single posts only) ---
813  var article = document.getElementById("mmoc-article");
814  var progress = document.getElementById("mmoc-progress");
815
816  if (article && progress) {
817    var updateProgress = function () {
818      var rect = article.getBoundingClientRect();
819      // Fraction of the ARTICLE scrolled past, not the whole document.
820      var total = rect.height - window.innerHeight;
821      var done =
822        total > 0
823          ? -rect.top / total
824          : rect.bottom <= window.innerHeight
825            ? 1
826            : 0;
827      progress.style.width = Math.min(1, Math.max(0, done)) * 100 + "%";
828    };
829    window.addEventListener("scroll", updateProgress, { passive: true });
830    window.addEventListener("resize", updateProgress, { passive: true });
831    updateProgress();
832  }
833
834  // --- Copy buttons (codes table) ---
835  document.querySelectorAll(".mmoc-copy").forEach(function (btn) {
836    btn.addEventListener("click", function () {
837      var value = btn.getAttribute("data-copy") || "";
838      var done = function () {
839        var original = btn.getAttribute("data-label") || btn.textContent;
840        btn.setAttribute("data-label", original);
841        btn.textContent = "COPIED";
842        window.setTimeout(function () {
843          btn.textContent = btn.getAttribute("data-label") || "COPY";
844        }, 1500);
845      };
846      if (navigator.clipboard && navigator.clipboard.writeText) {
847        navigator.clipboard.writeText(value).then(done, done);
848      } else {
849        done();
850      }
851    });
852  });
853
854  // --- Archive: load more ---
855  var loadMore = document.getElementById("mmoc-load-more");
856  var archiveGrid = document.getElementById("mmoc-archive-grid");
857
858  if (loadMore && archiveGrid && window.mmocData) {
859    loadMore.addEventListener("click", function () {
860      var page = parseInt(loadMore.getAttribute("data-page"), 10) || 1;
861
862      loadMore.disabled = true;
863      loadMore.textContent = "Loading…";
864
865      var body = new URLSearchParams();
866      body.set("action", "mmoc_load_more");
867      body.set("nonce", window.mmocData.nonce);
868      body.set("cat", loadMore.getAttribute("data-cat"));
869      body.set("page", String(page));
870
871      var restore = function () {
872        loadMore.disabled = false;
873        loadMore.textContent = "Load more";
874      };
875
876      fetch(window.mmocData.ajaxUrl, {
877        method: "POST",
878        body: body,
879        credentials: "same-origin",
880      })
881        .then(function (res) {
882          return res.json();
883        })
884        .then(function (json) {
885          if (!json || !json.success || !json.data) {
886            restore();
887            return;
888          }
889          archiveGrid.insertAdjacentHTML("beforeend", json.data.html);
890          // The appended batch carries its own deferred in-feed slots.
891          loadMore.setAttribute("data-page", String(json.data.page));
892          if (json.data.has_more) {
893            restore();
894          } else {
895            loadMore.parentNode.removeChild(loadMore);
896          }
897        })
898        .catch(restore);
899    });
900  }
901});

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.