const themeToggle = document.querySelector("[data-theme-toggle]"); if (themeToggle) { const media = window.matchMedia("(prefers-color-scheme: dark)"); const readTheme = () => { try { const saved = localStorage.getItem("theme"); return saved === "light" || saved === "dark" ? saved : "system"; } catch (_) { return "system"; } }; // Icons and label are picked by CSS from `html[data-theme]`, which theme.js sets // before first paint, so nothing here repaints the toggle after load. const renderTheme = (theme) => themeToggle.setAttribute("aria-label", `Theme: ${theme}`); const setTheme = (theme) => { if (theme === "system") document.documentElement.removeAttribute("data-theme"); else document.documentElement.dataset.theme = theme; const scheme = document.querySelector('meta[name="color-scheme"]'); if (scheme) scheme.content = theme === "system" ? "light dark" : theme; try { if (theme === "system") localStorage.removeItem("theme"); else localStorage.setItem("theme", theme); } catch (_) {} renderTheme(theme); }; renderTheme(readTheme()); themeToggle.addEventListener("click", () => { const current = readTheme(); setTheme(current === "system" ? "light" : current === "light" ? "dark" : "system"); }); media.addEventListener("change", () => { if (readTheme() === "system") { document.documentElement.removeAttribute("data-theme"); renderTheme("system"); } }); } document.addEventListener("submit", (event) => { const form = event.target; if (form.matches("form.rating") && event.submitter) { event.preventDefault(); const submitted = event.submitter; const body = new URLSearchParams(new FormData(form)); body.set(submitted.name, submitted.value); fetch(form.action, { method: "POST", body, credentials: "same-origin", headers: { Accept: "application/json" }, }).then((response) => { if (!response.ok) throw new Error("rating request failed"); return response.json(); }).then((result) => { form.querySelectorAll("button[data-label]").forEach((button) => { const active = button.dataset.label === result.label; button.classList.toggle("active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); }); }).catch(() => { const label = document.createElement("input"); label.type = "hidden"; label.name = submitted.name; label.value = submitted.value; form.appendChild(label); form.submit(); }); return; } const message = form.dataset.confirm; if (message && !window.confirm(message)) event.preventDefault(); }); document.querySelectorAll("details[id]").forEach((details) => { try { const key = "details:" + details.id; const saved = localStorage.getItem(key); if (saved === "open" || saved === "closed") details.open = saved === "open"; details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed")); } catch (_) {} }); /* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */ document.querySelectorAll("table[data-filter]").forEach((table) => { const rows = table.querySelectorAll("tbody tr"); const host = table.closest(".scroll-x") || table; // The template renders the input (shown only under `.has-js`) so the table does // not jump when this script runs after first paint; create one if it is missing. let input = host.previousElementSibling; if (!(input && input.matches("input[data-table-filter]"))) { if (rows.length < 2) return; input = document.createElement("input"); input.type = "search"; input.className = "table-filter"; input.placeholder = "Filter rows on this page"; input.setAttribute("aria-label", "Filter rows on this page"); host.parentNode.insertBefore(input, host); } input.addEventListener("input", () => { const needle = input.value.trim().toLowerCase(); rows.forEach((row) => { row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle); }); }); }); /* step 5: settings — "reset to default" fills the field with its default */ document.addEventListener("click", (event) => { const button = event.target.closest("button[data-reset]"); if (!button) return; const input = document.getElementById(button.dataset.reset); if (!input) return; input.value = button.dataset.default; }); /* step 6: reload a job page every N seconds while its job is requested/running */ document.querySelectorAll("[data-refresh]").forEach((element) => { const seconds = Number(element.dataset.refresh); if (seconds <= 0) return; const schedule = () => setTimeout(() => window.location.reload(), seconds * 1000); if (document.prerendering) { document.addEventListener("prerenderingchange", schedule, { once: true }); } else { schedule(); } }); /* step 4: table-of-contents panel, current chapter, and reading progress */ const tocPanel = document.querySelector("[data-toc-panel]"); const tocToggle = document.querySelector("[data-toc-toggle]"); if (tocPanel) { const tocBar = document.querySelector("[data-toc-bar]"); const tocLinks = Array.from(tocPanel.querySelectorAll(".toc-link")); const tocBars = document.querySelectorAll("[data-toc-progress]"); const tocLabel = document.querySelector("[data-toc-label]"); const tocCount = document.querySelector("[data-toc-count]"); const tocStatus = document.querySelector("[data-toc-status]"); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const largeScreen = window.matchMedia("(min-width: 64rem)"); // Long issues overflow the sidebar; scroll just enough to show where we are. const revealCurrent = () => { const current = tocPanel.querySelector(".toc-link[aria-current]"); if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return; const stickyHeader = tocPanel.querySelector(":scope > div"); const topMargin = (stickyHeader && stickyHeader.offsetHeight > 0 ? stickyHeader.offsetHeight : 0) + 24; const panelRect = tocPanel.getBoundingClientRect(); const currentRect = current.getBoundingClientRect(); const top = tocPanel.scrollTop + currentRect.top - panelRect.top; const visibleTop = tocPanel.scrollTop + topMargin; const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - 24; if (top >= visibleTop && top + currentRect.height <= visibleBottom) return; const target = Math.max(0, top - Math.max(topMargin, (tocPanel.clientHeight - currentRect.height) / 2)); tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : "smooth" }); }; const sizeOpenPanel = () => { if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return; tocPanel.style.setProperty("--toc-panel-top", `${Math.max(0, tocBar.getBoundingClientRect().bottom)}px`); }; const setOpen = (open) => { tocPanel.dataset.open = open ? "true" : "false"; document.documentElement.classList.toggle("toc-panel-open", open && !largeScreen.matches); if (tocToggle) { tocToggle.setAttribute("aria-expanded", open ? "true" : "false"); tocToggle.setAttribute("aria-label", open ? "Close contents" : "Contents"); } if (open) { sizeOpenPanel(); revealCurrent(); } else { tocPanel.style.removeProperty("--toc-panel-top"); } }; setOpen(false); revealCurrent(); if (tocToggle) { tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true")); document.addEventListener("click", (event) => { if (tocPanel.dataset.open !== "true" || tocToggle.contains(event.target)) return; // A tap on a chapter closes the panel; so does a tap anywhere outside it. if (!tocPanel.contains(event.target) || event.target.closest("a")) setOpen(false); }); document.addEventListener("keydown", (event) => { if (event.key !== "Escape" || tocPanel.dataset.open !== "true") return; setOpen(false); tocToggle.focus(); }); largeScreen.addEventListener("change", () => setOpen(false)); window.addEventListener("resize", sizeOpenPanel); if (window.visualViewport) window.visualViewport.addEventListener("resize", sizeOpenPanel); } let currentLink = tocPanel.querySelector(".toc-link[aria-current]"); // Chapters above the current one read as "already passed" in ink rather than ink-2. const markPassed = (link) => { let passed = true; tocLinks.forEach((candidate) => { if (candidate === link) passed = false; candidate.toggleAttribute("data-passed", passed); }); }; if (currentLink) markPassed(currentLink); const setCurrent = (link) => { if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return; tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current")); markPassed(link); link.setAttribute("aria-current", "location"); currentLink = link; const position = Math.max(0, Number(link.dataset.tocPosition)); const total = tocBars.length ? Number(tocBars[0].max) : 0; const isEnd = link.hasAttribute("data-toc-end"); const label = link.querySelector("[data-toc-link-label]"); if (tocLabel && label) tocLabel.textContent = label.textContent; if (tocCount) { tocCount.hidden = position === 0 || isEnd; tocCount.textContent = `${position} / ${total}`; } if (tocStatus) tocStatus.textContent = isEnd ? "End of issue" : position > 0 ? `Chapter ${position} of ${total}` : "Front page"; tocBars.forEach((bar) => { delete bar.dataset.live; bar.value = position; }); revealCurrent(); }; const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({ entry, link: tocLinks.find((candidate) => candidate.getAttribute("href") === entry.dataset.tocEntry), })).filter(({ link }) => link); if (tocEntries.length) { let queued = false; const paintCurrent = () => { queued = false; const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3; let next = tocEntries[0].link; tocEntries.forEach(({ entry, link }) => { if (entry.getBoundingClientRect().top <= threshold) next = link; }); setCurrent(next); }; const scheduleCurrent = () => { if (queued) return; queued = true; window.requestAnimationFrame(paintCurrent); }; window.addEventListener("scroll", scheduleCurrent, { passive: true }); window.addEventListener("resize", scheduleCurrent); scheduleCurrent(); } const tocChapter = document.querySelector("[data-toc-scroll]"); if (tocBars.length && tocChapter) { // "Chapter N" is worth position N once finished; show N-1 plus how far down we are. const base = Math.max(0, Number(tocBars[0].getAttribute("value")) - 1); let queued = false; const paint = () => { queued = false; const start = window.scrollY + tocChapter.getBoundingClientRect().top; const end = start + tocChapter.offsetHeight - window.innerHeight; const read = end > start ? (window.scrollY - start) / (end - start) : 1; const value = base + Math.min(1, Math.max(0, read)); tocBars.forEach((bar) => { // The CSS transition is for navigation, not for tracking a finger. bar.dataset.live = "true"; bar.value = value; }); }; const schedule = () => { if (queued) return; queued = true; window.requestAnimationFrame(paint); }; window.addEventListener("scroll", schedule, { passive: true }); window.addEventListener("resize", schedule); schedule(); } }