From c8df55e91a80e6d26f35cf89f065796c031d92de Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Wed, 9 Sep 2026 05:27:02 +0000 Subject: [PATCH] Keep the contents sidebar where it was across page loads Every navigation is a full document load, so the sidebar came back at scrollTop 0 and revealCurrent() smooth-scrolled the current chapter into view: a jump on every click even though the link was already in view. app.js saves the panel's scrollTop to sessionStorage (large screens only, keyed by the issue) on link clicks and pagehide; theme.js, which already runs before first paint, restores it as soon as the parser has closed the nav. The load-time reveals (initial and the first scroll-tracking paint on the full issue page) are now instant; opening the mobile panel and tracking while scrolling stay smooth. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj --- src/web/static/app.js | 24 ++++++++++++++++++------ src/web/static/theme.js | 33 +++++++++++++++++++++++++++++++-- src/web/templates/_toc.html | 2 +- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/web/static/app.js b/src/web/static/app.js index 6c13d65..aa7639f 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -132,9 +132,19 @@ if (tocPanel) { const tocStatus = document.querySelector("[data-toc-status]"); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const largeScreen = window.matchMedia("(min-width: 64rem)"); + const saveScroll = () => { + if (!largeScreen.matches || !tocPanel.dataset.tocIssue) return; + try { + sessionStorage.setItem("toc-scroll", `${tocPanel.dataset.tocIssue}\n${tocPanel.scrollTop}`); + } catch (_) {} + }; + window.addEventListener("pagehide", saveScroll); + tocPanel.addEventListener("click", (event) => { + if (event.target.closest?.("a")) saveScroll(); + }); // Long issues overflow the sidebar; scroll just enough to show where we are. - const revealCurrent = () => { + const revealCurrent = (behavior = "smooth") => { const current = tocPanel.querySelector(".toc-link[aria-current]"); if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return; const stickyHeader = tocPanel.querySelector(":scope > div"); @@ -147,7 +157,7 @@ if (tocPanel) { const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - revealMargin; 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" }); + tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : behavior }); }; const sizeOpenPanel = () => { if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return; @@ -168,7 +178,7 @@ if (tocPanel) { } }; setOpen(false); - revealCurrent(); + revealCurrent("auto"); if (tocToggle) { tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true")); document.addEventListener("click", (event) => { @@ -196,7 +206,7 @@ if (tocPanel) { }); }; if (currentLink) markPassed(currentLink); - const setCurrent = (link) => { + const setCurrent = (link, behavior = "smooth") => { if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return; tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current")); markPassed(link); @@ -216,7 +226,7 @@ if (tocPanel) { delete bar.dataset.live; bar.value = position; }); - revealCurrent(); + revealCurrent(behavior); }; const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({ @@ -225,6 +235,7 @@ if (tocPanel) { })).filter(({ link }) => link); if (tocEntries.length) { let queued = false; + let firstPaint = true; const paintCurrent = () => { queued = false; const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3; @@ -232,7 +243,8 @@ if (tocPanel) { tocEntries.forEach(({ entry, link }) => { if (entry.getBoundingClientRect().top <= threshold) next = link; }); - setCurrent(next); + setCurrent(next, firstPaint ? "auto" : "smooth"); + firstPaint = false; }; const scheduleCurrent = () => { if (queued) return; diff --git a/src/web/static/theme.js b/src/web/static/theme.js index ccea0b5..bc4f834 100644 --- a/src/web/static/theme.js +++ b/src/web/static/theme.js @@ -31,9 +31,38 @@ } catch (_) {} }); }; + let tocPanel; + let tocRestored = false; + const findToc = (root) => { + if (tocPanel || root.nodeType !== Node.ELEMENT_NODE) return; + tocPanel = root.matches("[data-toc-panel]") ? root : root.querySelector("[data-toc-panel]"); + }; + const restoreToc = () => { + if (!tocPanel || tocRestored) return; + tocRestored = true; + if (!window.matchMedia("(min-width: 64rem)").matches) return; + try { + const saved = sessionStorage.getItem("toc-scroll"); + const separator = saved ? saved.lastIndexOf("\n") : -1; + if (separator < 0 || saved.slice(0, separator) !== tocPanel.dataset.tocIssue) return; + const scrollTop = Number(saved.slice(separator + 1)); + if (Number.isFinite(scrollTop)) tocPanel.scrollTop = scrollTop; + } catch (_) {} + }; const detailsObserver = new MutationObserver((records) => { - records.forEach((record) => record.addedNodes.forEach(restoreDetails)); + const addedNodes = []; + records.forEach((record) => record.addedNodes.forEach((node) => { + addedNodes.push(node); + restoreDetails(node); + findToc(node); + })); + // A following node means the parser has closed the nav and its list is complete. + if (tocPanel && addedNodes.some((node) => !tocPanel.contains(node) + && (tocPanel.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING))) restoreToc(); }); detailsObserver.observe(document.documentElement, { childList: true, subtree: true }); - document.addEventListener("DOMContentLoaded", () => detailsObserver.disconnect(), { once: true }); + document.addEventListener("DOMContentLoaded", () => { + restoreToc(); + detailsObserver.disconnect(); + }, { once: true }); })(); diff --git a/src/web/templates/_toc.html b/src/web/templates/_toc.html index c5fd16a..395016c 100644 --- a/src/web/templates/_toc.html +++ b/src/web/templates/_toc.html @@ -8,7 +8,7 @@ {{ toc.position }} / {{ toc.total }} -