UI pass: table-of-contents sidebar fixes and scroll sync

- The open hamburger panel now fills the viewport below the sticky bar
  (100dvh, measured bar offset) and locks the page behind it. Locking only
  the root element: locking body too made it its own scroll container and
  un-stuck the bar.
- Front-page scroll-spy: the current chapter marker, mobile label/counter,
  desktop "Chapter N of M" line and both progress bars follow the reader
  through "In This Issue"; passed chapters read in ink. The sidebar
  auto-scrolls to keep the current chapter in view.
- The sidebar scrollbar sits in a reserved gutter instead of over the text.
- Hamburger cross-fades to a close icon; 44px tap targets on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
This commit is contained in:
2026-09-04 17:05:39 +00:00
co-authored by Claude Fable 5.1
parent 560bbc13af
commit ad2e99eeb9
7 changed files with 291 additions and 54 deletions
+121 -37
View File
@@ -111,27 +111,51 @@ document.querySelectorAll("[data-refresh]").forEach((element) => {
const seconds = Number(element.dataset.refresh);
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
});
/* step 4: table-of-contents panel (below `lg`) and reading-progress bar */
/* 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("a[aria-current=page]");
const current = tocPanel.querySelector(".toc-link[aria-current]");
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
const margin = 24;
const top = current.offsetTop - margin;
const bottom = current.offsetTop + current.offsetHeight + margin;
if (bottom > tocPanel.scrollTop + tocPanel.clientHeight) {
tocPanel.scrollTop = bottom - tocPanel.clientHeight;
} else if (top < tocPanel.scrollTop) {
tocPanel.scrollTop = Math.max(0, top);
}
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";
if (tocToggle) tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
if (open) revealCurrent();
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();
@@ -147,33 +171,93 @@ if (tocPanel) {
setOpen(false);
tocToggle.focus();
});
window.matchMedia("(min-width: 64rem)").addEventListener("change", () => setOpen(false));
largeScreen.addEventListener("change", () => setOpen(false));
window.addEventListener("resize", sizeOpenPanel);
if (window.visualViewport) window.visualViewport.addEventListener("resize", sizeOpenPanel);
}
}
const tocBars = document.querySelectorAll("[data-toc-progress]");
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;
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);
});
};
const schedule = () => {
if (queued) return;
queued = true;
window.requestAnimationFrame(paint);
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();
};
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
schedule();
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();
}
}