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.
365 lines
12 KiB
Python
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))
|