lumbda/whitepaper/inject-whitepaper-css.py
russell@unturf.com d9f4e38222
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.
2026-06-07 10:23:01 -04:00

365 lines
12 KiB
Python

#!/usr/bin/env python3
# Inject whitepaper-specific CSS overrides into the rst2html5 output:
#
# 1. Embed the ChunkFive webfont (base64 woff2) and assign it to h1 /
# h2, so whitepaper titles read in the same voice as the homepage
# logo instead of docutils's default serif fallback.
#
# 2. Cancel docutils's responsive.css `body > *` rule for the
# uncloseai floating button — that rule slaps `padding: 0.5rem
# calc(29% - 7.2rem)` onto every direct child of <body>, which
# blows the 121px button up to ~40% of the viewport width. Scoped
# override plus `all: revert` so the button falls back to its own
# stylesheet's intended sizing.
#
# Usage: inject-whitepaper-css.py <html-file> <chunkfive-woff2-path>
import base64
import os
import sys
TEMPLATE = """\
<style>
@font-face {{
font-family: 'chunkfive';
src: url(data:font/woff2;base64,{woff2_b64}) format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}}
main > h1, main > h2, section > h2 {{
font-family: 'chunkfive', Georgia, serif;
font-weight: normal;
letter-spacing: 0.01em;
}}
/* Declarative period on the whitepaper title picks up the same brand
green (#227842) as the homepage "lumbda." period. */
h1.title .period {{
color: #227842;
}}
/* docutils's responsive.css applies a blanket rule to every `body > *`
that sets `background-color: white; line-height: 1.6; padding: 0.5rem
calc(29% - 7.2rem); margin: auto; max-width: 100rem`. The uncloseai
floating button gets appended to <body> and inherits those rules —
exploding it to nearly half the viewport and painting its background
white (which hides the black pill). Override just the damaged
properties so uncloseai's own stylesheet still supplies position,
width, height, background, border, and color. */
body > button#floating-ai-button,
body > .uncloseai-floating-button {{
padding: 0 !important;
margin: 0 !important;
max-width: calc(100vw - 20px) !important;
background-color: unset !important;
line-height: normal !important;
}}
/* Click-to-zoom lightbox for diagrams. */
main img, section img {{
cursor: zoom-in;
}}
.wp-lightbox {{
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.92);
display: none;
align-items: center;
justify-content: center;
z-index: 10001;
cursor: zoom-out;
padding: 1.5rem;
}}
.wp-lightbox.open {{ display: flex; }}
.wp-lightbox img {{
max-width: 100%;
max-height: 100%;
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 () {{
if (window.__wpLightbox) return;
window.__wpLightbox = true;
document.addEventListener("DOMContentLoaded", function () {{
var imgs = document.querySelectorAll("main img, section img");
if (!imgs.length) return;
var overlay = document.createElement("div");
overlay.className = "wp-lightbox";
var big = document.createElement("img");
big.alt = "";
overlay.appendChild(big);
document.body.appendChild(overlay);
function close() {{ overlay.classList.remove("open"); big.src = ""; }}
overlay.addEventListener("click", close);
document.addEventListener("keydown", function (e) {{
if (e.key === "Escape") close();
}});
imgs.forEach(function (img) {{
img.addEventListener("click", function () {{
big.src = img.currentSrc || img.src;
overlay.classList.add("open");
}});
}});
}});
}})();
(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>
"""
def main(argv):
if len(argv) != 3:
sys.stderr.write("usage: inject-whitepaper-css.py <html-file> <chunkfive-woff2-path>\n")
return 2
html_path, woff2_path = argv[1], argv[2]
if not os.path.isfile(woff2_path):
sys.stderr.write(f"inject-whitepaper-css: chunkfive missing: {woff2_path}\n")
return 1
with open(woff2_path, "rb") as f:
woff2_b64 = base64.b64encode(f.read()).decode()
style_block = TEMPLATE.format(woff2_b64=woff2_b64)
with open(html_path, "r") as f:
html = f.read()
if "</head>" not in html:
sys.stderr.write("inject-whitepaper-css: no </head> found\n")
return 1
html = html.replace("</head>", style_block + "</head>", 1)
# Wrap the trailing period on the `<h1 class="title">lumbda.</h1>`
# heading in a span so CSS can color just the period green. Docutils
# emits exactly this markup for the top-level RST title.
html = html.replace(
'<h1 class="title">lumbda.</h1>',
'<h1 class="title">lumbda<span class="period" aria-hidden="true">.</span></h1>',
1,
)
with open(html_path, "w") as f:
f.write(html)
print(f"inject-whitepaper-css: injected {len(style_block)} bytes into {html_path}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))