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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
This commit is contained in:
2026-09-09 05:27:02 +00:00
co-authored by Claude Fable 5.1
parent c707f432f2
commit c8df55e91a
3 changed files with 50 additions and 9 deletions
+18 -6
View File
@@ -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;
+31 -2
View File
@@ -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 });
})();
+1 -1
View File
@@ -8,7 +8,7 @@
<span class="shrink-0 font-sans text-xs tabular-nums text-muted" data-toc-count{% if toc.position == 0 %} hidden{% endif %}>{{ toc.position }} / {{ toc.total }}</span>
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
</div>
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 overflow-y-auto overscroll-contain border-b border-rule bg-paper pb-7 pl-4 pr-6 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:pl-6 sm:pr-8 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100dvh-3rem)] lg:border-0 lg:pb-10 lg:pl-0 lg:pr-3 lg:pt-0 lg:shadow-none" aria-label="Contents">
<nav id="toc" data-toc-panel data-toc-issue="{{ toc.issue_href }}" class="absolute inset-x-0 top-full z-30 overflow-y-auto overscroll-contain border-b border-rule bg-paper pb-7 pl-4 pr-6 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:pl-6 sm:pr-8 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100dvh-3rem)] lg:border-0 lg:pb-10 lg:pl-0 lg:pr-3 lg:pt-0 lg:shadow-none" aria-label="Contents">
<div class="hidden lg:sticky lg:top-0 lg:z-10 lg:block lg:bg-paper lg:pb-3">
<a class="block font-sans text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted no-underline hover:text-ink" href="{{ toc.issue_href }}">No. {{ toc.issue_number }} <span class="text-rule" aria-hidden="true">·</span> {{ toc.short_date }}</a>
<p class="mt-2 font-sans text-[0.72rem] text-ink-2" data-toc-status>{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>