whitepaper-html: left-rail TOC built at runtime from section ids

inject-whitepaper-css.py adds CSS for a fixed 280px left sidebar plus a
DOMContentLoaded script that walks `section[id] > h2, > h3` and builds a
chapter/section nav. IntersectionObserver tracks active section as you
scroll; the rail scrolls itself to keep the current entry in view. Below
1100px the rail hides behind a hamburger toggle with backdrop dismiss.

Cancels docutils responsive.css `body > *` blanket padding on the rail
via the same `all: revert` pattern already used for the uncloseai
floating button. 59/59 source sections present in rendered DOM.
This commit is contained in:
russell@unturf.com 2026-06-07 10:23:01 -04:00
parent 28a76631e2
commit d9f4e38222
No known key found for this signature in database
2 changed files with 446 additions and 0 deletions

View file

@ -76,6 +76,122 @@ main img, section img {{
object-fit: contain;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
}}
/* Left-rail table of contents. Cancels the same docutils responsive.css
`body > *` blanket rule that broke the uncloseai button (padding /
margin / max-width / background / line-height) and then lays the
sidebar out as a fixed left rail on wide viewports. Below 1100px the
sidebar collapses behind a toggle button so it cannot crowd the
single-column reading flow on phones and narrow laptops. */
body > .wp-toc {{
all: revert;
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
padding: 1.25rem 1rem 1.25rem 1.5rem;
margin: 0;
background: #fafafa;
border-right: 1px solid #e5e5e5;
overflow-y: auto;
overflow-x: hidden;
z-index: 50;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica,
Arial, sans-serif;
font-size: 0.82rem;
line-height: 1.4;
box-sizing: border-box;
}}
.wp-toc-title {{
font-family: 'chunkfive', Georgia, serif;
font-size: 1.15rem;
margin: 0 0 0.6rem 0;
color: #227842;
letter-spacing: 0.02em;
}}
.wp-toc-title .period {{ color: #227842; }}
.wp-toc ol {{
list-style: none;
padding: 0;
margin: 0;
}}
.wp-toc li {{
margin: 0;
}}
.wp-toc a {{
display: block;
padding: 0.22rem 0.4rem;
color: #333;
text-decoration: none;
border-left: 2px solid transparent;
border-radius: 0 3px 3px 0;
word-wrap: break-word;
}}
.wp-toc a:hover {{
background: #efefef;
color: #000;
}}
.wp-toc a.active {{
background: #e8f1ec;
color: #155c30;
border-left-color: #227842;
font-weight: 600;
}}
.wp-toc li.lvl-3 a {{
padding-left: 1.2rem;
font-size: 0.78rem;
color: #555;
}}
.wp-toc li.lvl-3 a.active {{
color: #155c30;
}}
/* Toggle button — hidden on wide viewports, shown when the sidebar
collapses. Same `all: revert` trick so docutils does not stretch it. */
body > .wp-toc-toggle {{
all: revert;
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 51;
width: 2.4rem;
height: 2.4rem;
border: 1px solid #cfcfcf;
border-radius: 4px;
background: #ffffff;
color: #227842;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
display: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}}
body > .wp-toc-backdrop {{
all: revert;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 49;
display: none;
}}
@media (min-width: 1100px) {{
/* Push the centered docutils column right so it never slides under
the rail. The blanket `body > *` rule already centers content with
huge calc() padding, so we just add a left margin to body. */
body {{ margin-left: 280px; }}
/* The uncloseai floating button is `position: fixed; left: ...`, so
it sits independent of body margin. No further adjustment. */
}}
@media (max-width: 1099px) {{
body > .wp-toc {{
transform: translateX(-100%);
transition: transform 0.2s ease-out;
width: min(85vw, 320px);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.12);
}}
body > .wp-toc.open {{ transform: translateX(0); }}
body > .wp-toc-toggle {{ display: block; }}
body > .wp-toc-backdrop.open {{ display: block; }}
}}
</style>
<script>
(function () {{
@ -103,6 +219,113 @@ main img, section img {{
}});
}});
}})();
(function () {{
if (window.__wpToc) return;
window.__wpToc = true;
document.addEventListener("DOMContentLoaded", function () {{
var main = document.querySelector("main");
if (!main) return;
// Pick up only the top two heading levels — section-id <section>s
// that wrap <h2> (chapters) and <h3> (sections). Anything deeper
// creates noise in a left rail. Skip the doc title <h1> since the
// sidebar header already labels the doc.
var sections = main.querySelectorAll("section[id] > h2, section[id] > h3");
if (sections.length < 3) return;
var nav = document.createElement("nav");
nav.className = "wp-toc";
nav.setAttribute("aria-label", "Table of contents");
var title = document.createElement("p");
title.className = "wp-toc-title";
title.innerHTML = 'lumbda<span class="period" aria-hidden="true">.</span>';
nav.appendChild(title);
var list = document.createElement("ol");
var byId = {{}};
sections.forEach(function (h) {{
var sec = h.parentElement;
var id = sec.id;
if (!id) return;
var lvl = h.tagName === "H2" ? 2 : 3;
var li = document.createElement("li");
li.className = "lvl-" + lvl;
var a = document.createElement("a");
a.href = "#" + id;
a.textContent = h.textContent.trim();
li.appendChild(a);
list.appendChild(li);
byId[id] = a;
}});
nav.appendChild(list);
var backdrop = document.createElement("div");
backdrop.className = "wp-toc-backdrop";
var toggle = document.createElement("button");
toggle.className = "wp-toc-toggle";
toggle.setAttribute("aria-label", "Open table of contents");
toggle.textContent = "☰"; // hamburger
function close() {{
nav.classList.remove("open");
backdrop.classList.remove("open");
}}
function open() {{
nav.classList.add("open");
backdrop.classList.add("open");
}}
toggle.addEventListener("click", function () {{
if (nav.classList.contains("open")) close(); else open();
}});
backdrop.addEventListener("click", close);
// Auto-close after navigating from a link on narrow viewports.
nav.addEventListener("click", function (e) {{
if (e.target.tagName === "A" && window.innerWidth < 1100) close();
}});
document.body.insertBefore(nav, document.body.firstChild);
document.body.appendChild(backdrop);
document.body.appendChild(toggle);
// Active-section tracking. IntersectionObserver picks the
// highest-up section currently in view; falls back to a scroll
// handler when IO is unavailable (very old browsers).
var active = null;
function setActive(id) {{
if (active === id) return;
if (active && byId[active]) byId[active].classList.remove("active");
active = id;
if (byId[id]) {{
byId[id].classList.add("active");
// Keep the active link in view inside the rail.
var rect = byId[id].getBoundingClientRect();
var navRect = nav.getBoundingClientRect();
if (rect.top < navRect.top || rect.bottom > navRect.bottom) {{
byId[id].scrollIntoView({{ block: "nearest" }});
}}
}}
}}
if ("IntersectionObserver" in window) {{
var visible = new Set();
var io = new IntersectionObserver(function (entries) {{
entries.forEach(function (e) {{
if (e.isIntersecting) visible.add(e.target.id);
else visible.delete(e.target.id);
}});
// Pick the visible section that appears earliest in the doc.
var first = null;
sections.forEach(function (h) {{
var sid = h.parentElement.id;
if (visible.has(sid) && first === null) first = sid;
}});
if (first) setActive(first);
}}, {{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }});
sections.forEach(function (h) {{ io.observe(h.parentElement); }});
}} else {{
window.addEventListener("scroll", function () {{
var y = window.scrollY + 100;
var current = null;
sections.forEach(function (h) {{
if (h.parentElement.offsetTop <= y) current = h.parentElement.id;
}});
if (current) setActive(current);
}}, {{ passive: true }});
}}
}});
}})();
</script>
"""

View file

@ -863,6 +863,122 @@ main img, section img {
object-fit: contain;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
}
/* Left-rail table of contents. Cancels the same docutils responsive.css
`body > *` blanket rule that broke the uncloseai button (padding /
margin / max-width / background / line-height) and then lays the
sidebar out as a fixed left rail on wide viewports. Below 1100px the
sidebar collapses behind a toggle button so it cannot crowd the
single-column reading flow on phones and narrow laptops. */
body > .wp-toc {
all: revert;
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
padding: 1.25rem 1rem 1.25rem 1.5rem;
margin: 0;
background: #fafafa;
border-right: 1px solid #e5e5e5;
overflow-y: auto;
overflow-x: hidden;
z-index: 50;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica,
Arial, sans-serif;
font-size: 0.82rem;
line-height: 1.4;
box-sizing: border-box;
}
.wp-toc-title {
font-family: 'chunkfive', Georgia, serif;
font-size: 1.15rem;
margin: 0 0 0.6rem 0;
color: #227842;
letter-spacing: 0.02em;
}
.wp-toc-title .period { color: #227842; }
.wp-toc ol {
list-style: none;
padding: 0;
margin: 0;
}
.wp-toc li {
margin: 0;
}
.wp-toc a {
display: block;
padding: 0.22rem 0.4rem;
color: #333;
text-decoration: none;
border-left: 2px solid transparent;
border-radius: 0 3px 3px 0;
word-wrap: break-word;
}
.wp-toc a:hover {
background: #efefef;
color: #000;
}
.wp-toc a.active {
background: #e8f1ec;
color: #155c30;
border-left-color: #227842;
font-weight: 600;
}
.wp-toc li.lvl-3 a {
padding-left: 1.2rem;
font-size: 0.78rem;
color: #555;
}
.wp-toc li.lvl-3 a.active {
color: #155c30;
}
/* Toggle button — hidden on wide viewports, shown when the sidebar
collapses. Same `all: revert` trick so docutils does not stretch it. */
body > .wp-toc-toggle {
all: revert;
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 51;
width: 2.4rem;
height: 2.4rem;
border: 1px solid #cfcfcf;
border-radius: 4px;
background: #ffffff;
color: #227842;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
display: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
body > .wp-toc-backdrop {
all: revert;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 49;
display: none;
}
@media (min-width: 1100px) {
/* Push the centered docutils column right so it never slides under
the rail. The blanket `body > *` rule already centers content with
huge calc() padding, so we just add a left margin to body. */
body { margin-left: 280px; }
/* The uncloseai floating button is `position: fixed; left: ...`, so
it sits independent of body margin. No further adjustment. */
}
@media (max-width: 1099px) {
body > .wp-toc {
transform: translateX(-100%);
transition: transform 0.2s ease-out;
width: min(85vw, 320px);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.12);
}
body > .wp-toc.open { transform: translateX(0); }
body > .wp-toc-toggle { display: block; }
body > .wp-toc-backdrop.open { display: block; }
}
</style>
<script>
(function () {
@ -890,6 +1006,113 @@ main img, section img {
});
});
})();
(function () {
if (window.__wpToc) return;
window.__wpToc = true;
document.addEventListener("DOMContentLoaded", function () {
var main = document.querySelector("main");
if (!main) return;
// Pick up only the top two heading levels — section-id <section>s
// that wrap <h2> (chapters) and <h3> (sections). Anything deeper
// creates noise in a left rail. Skip the doc title <h1> since the
// sidebar header already labels the doc.
var sections = main.querySelectorAll("section[id] > h2, section[id] > h3");
if (sections.length < 3) return;
var nav = document.createElement("nav");
nav.className = "wp-toc";
nav.setAttribute("aria-label", "Table of contents");
var title = document.createElement("p");
title.className = "wp-toc-title";
title.innerHTML = 'lumbda<span class="period" aria-hidden="true">.</span>';
nav.appendChild(title);
var list = document.createElement("ol");
var byId = {};
sections.forEach(function (h) {
var sec = h.parentElement;
var id = sec.id;
if (!id) return;
var lvl = h.tagName === "H2" ? 2 : 3;
var li = document.createElement("li");
li.className = "lvl-" + lvl;
var a = document.createElement("a");
a.href = "#" + id;
a.textContent = h.textContent.trim();
li.appendChild(a);
list.appendChild(li);
byId[id] = a;
});
nav.appendChild(list);
var backdrop = document.createElement("div");
backdrop.className = "wp-toc-backdrop";
var toggle = document.createElement("button");
toggle.className = "wp-toc-toggle";
toggle.setAttribute("aria-label", "Open table of contents");
toggle.textContent = "☰"; // hamburger
function close() {
nav.classList.remove("open");
backdrop.classList.remove("open");
}
function open() {
nav.classList.add("open");
backdrop.classList.add("open");
}
toggle.addEventListener("click", function () {
if (nav.classList.contains("open")) close(); else open();
});
backdrop.addEventListener("click", close);
// Auto-close after navigating from a link on narrow viewports.
nav.addEventListener("click", function (e) {
if (e.target.tagName === "A" && window.innerWidth < 1100) close();
});
document.body.insertBefore(nav, document.body.firstChild);
document.body.appendChild(backdrop);
document.body.appendChild(toggle);
// Active-section tracking. IntersectionObserver picks the
// highest-up section currently in view; falls back to a scroll
// handler when IO is unavailable (very old browsers).
var active = null;
function setActive(id) {
if (active === id) return;
if (active && byId[active]) byId[active].classList.remove("active");
active = id;
if (byId[id]) {
byId[id].classList.add("active");
// Keep the active link in view inside the rail.
var rect = byId[id].getBoundingClientRect();
var navRect = nav.getBoundingClientRect();
if (rect.top < navRect.top || rect.bottom > navRect.bottom) {
byId[id].scrollIntoView({ block: "nearest" });
}
}
}
if ("IntersectionObserver" in window) {
var visible = new Set();
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) visible.add(e.target.id);
else visible.delete(e.target.id);
});
// Pick the visible section that appears earliest in the doc.
var first = null;
sections.forEach(function (h) {
var sid = h.parentElement.id;
if (visible.has(sid) && first === null) first = sid;
});
if (first) setActive(first);
}, { rootMargin: "-80px 0px -60% 0px", threshold: 0 });
sections.forEach(function (h) { io.observe(h.parentElement); });
} else {
window.addEventListener("scroll", function () {
var y = window.scrollY + 100;
var current = null;
sections.forEach(function (h) {
if (h.parentElement.offsetTop <= y) current = h.parentElement.id;
});
if (current) setActive(current);
}, { passive: true });
}
});
})();
</script>
</head>
<body>