diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml
index db7e17d..65a77dc 100644
--- a/.forgejo/workflows/docs.yml
+++ b/.forgejo/workflows/docs.yml
@@ -29,6 +29,7 @@ jobs:
python --version
uv pip install zensical
uv pip install -e .
+ python3 main.py
zensical build --clean
- name: Deploy to pages branch
diff --git a/docs/javascripts/extra.js b/docs/javascripts/extra.js
index d3adc8b..7064227 100644
--- a/docs/javascripts/extra.js
+++ b/docs/javascripts/extra.js
@@ -111,3 +111,799 @@
boot();
}
})();
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ Action Points — Interactive Checklist
+ ═══════════════════════════════════════════════════════════════════════ */
+;(function () {
+ "use strict";
+
+ // ── Feature flag ───────────────────────────────────
+ // Disabled via zensical.toml: [project.extra.action_points] enabled = false
+ // (surfaced as window.TOA_ACTION_POINTS_ENABLED by overrides/main.html).
+ // When off, bail before ANY listeners, observers, storage, or per-click work
+ // are wired up — checkboxes fall back to Material's read-only rendering.
+ if (window.TOA_ACTION_POINTS_ENABLED === false) return;
+
+ // ── Constants ──────────────────────────────────────
+ var STORAGE_KEY = "toa-action-points";
+ var VERSION = 2;
+ var REDUCED_MOTION = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches;
+
+ function todayStr() {
+ return new Date().toISOString().slice(0, 10);
+ }
+
+ // ── Storage ────────────────────────────────────────
+ // In-memory write-through cache of the parsed store. A single checkbox toggle
+ // calls getStorage() ~15x; without this each call re-parsed the whole JSON
+ // blob (and saveStorage re-stringified it 3x). The cache keeps reads O(1) and
+ // is kept in sync on every save, so reflow — not parsing — dominates a toggle.
+ var _storeCache = null;
+
+ function getStorage() {
+ if (_storeCache) return _storeCache;
+ try {
+ var raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ _storeCache = _fresh();
+ return _storeCache;
+ }
+ var data = JSON.parse(raw);
+ if (data.version !== VERSION) {
+ _storeCache = _migrate(data);
+ return _storeCache;
+ }
+ _storeCache = data;
+ return _storeCache;
+ } catch (e) {
+ _storeCache = _fresh();
+ return _storeCache;
+ }
+ }
+
+ function _fresh() {
+ return { version: VERSION, pages: {} };
+ }
+
+ function _migrate(old) {
+ // v1 → v2: add completedDates tracking
+ var data = _fresh();
+ if (old.pages) {
+ Object.keys(old.pages).forEach(function (path) {
+ var op = old.pages[path];
+ data.pages[path] = {
+ lastUpdated: op.lastUpdated || null,
+ checked: op.checked || [],
+ completedDates: {},
+ _checked: op._checked,
+ _total: op._total,
+ };
+ // Seed completedDates from checked array (assume checked=today)
+ var td = todayStr();
+ (op.checked || []).forEach(function (label) {
+ data.pages[path].completedDates[label] = [td];
+ });
+ });
+ }
+ saveStorage(data);
+ return data;
+ }
+
+ function saveStorage(data) {
+ _storeCache = data; // keep the in-memory cache authoritative
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
+ } catch (e) {
+ /* quota exceeded — silently drop */
+ }
+ }
+
+ function getPagePath() {
+ var p = location.pathname;
+ if (p.slice(-1) !== "/") p += "/";
+ return p;
+ }
+
+ function getNormLabel(li) {
+ // Clone li, remove the task-list-control label, get remaining text
+ var clone = li.cloneNode(true);
+ var ctrl = clone.querySelector(".task-list-control");
+ if (ctrl) ctrl.remove();
+ return (clone.textContent || "").replace(/\s+/g, " ").trim().toLowerCase();
+ }
+
+ function getPageData(path) {
+ var store = getStorage();
+ if (!store.pages[path]) {
+ store.pages[path] = { checked: [], completedDates: {}, lastUpdated: null };
+ }
+ if (!store.pages[path].completedDates) {
+ store.pages[path].completedDates = {};
+ }
+ return store.pages[path];
+ }
+
+ function setPageChecked(path, label, checked) {
+ var store = getStorage();
+ if (!store.pages[path]) {
+ store.pages[path] = { checked: [], completedDates: {}, lastUpdated: null };
+ }
+ var page = store.pages[path];
+ if (!page.completedDates) page.completedDates = {};
+ if (!page.completedDates[label]) page.completedDates[label] = [];
+
+ var td = todayStr();
+ if (checked) {
+ if (page.checked.indexOf(label) === -1) page.checked.push(label);
+ if (page.completedDates[label].indexOf(td) === -1) {
+ page.completedDates[label].push(td);
+ }
+ } else {
+ page.checked = page.checked.filter(function (l) {
+ return l !== label;
+ });
+ page.completedDates[label] = page.completedDates[label].filter(function (d) {
+ return d !== td;
+ });
+ }
+ page.lastUpdated = new Date().toISOString();
+ saveStorage(store);
+ }
+
+ // Persist page summary (checked count, total) for nav aggregation
+ function savePageSummary(path, checkedCount, total) {
+ var store = getStorage();
+ if (!store.pages[path]) {
+ store.pages[path] = { checked: [], lastUpdated: null };
+ }
+ store.pages[path]._checked = checkedCount;
+ store.pages[path]._total = total;
+ // Don't bump lastUpdated for summary-only writes
+ saveStorage(store);
+ }
+
+ function getPageSummary(path) {
+ var store = getStorage();
+ var page = store.pages[path];
+ if (!page || page._total === undefined) return null;
+ return { checked: page._checked || 0, total: page._total || 0 };
+ }
+
+ // ── Build-time totals manifest ─────────────────────
+ // toa-totals.json maps page-URL -> task count, generated at build time
+ // (see main.py). Seeding _total for every page lets nav badges show
+ // correct totals on FIRST load, before the user has visited each page.
+ // Fetched once per session; cached by the browser thereafter.
+ var _totalsLoaded = false;
+
+ function seedTotalsFromManifest(cb) {
+ if (_totalsLoaded) {
+ cb && cb(false);
+ return;
+ }
+ _totalsLoaded = true; // mark immediately so concurrent navs don't double-fetch
+
+ // Resolve the manifest relative to the site root. The script tag for
+ // extra.js is loaded from /javascripts/extra.js, so derive the URL
+ // from document scripts to stay correct under any base_url.
+ var url = "javascripts/toa-totals.json";
+ try {
+ var scripts = document.querySelectorAll('script[src*="javascripts/extra.js"]');
+ if (scripts.length) {
+ url = scripts[0].src.replace(/extra\.js.*$/, "toa-totals.json");
+ } else {
+ // Fallback: resolve from origin
+ url = new URL("javascripts/toa-totals.json", location.origin + "/").href;
+ }
+ } catch (e) {
+ /* use relative fallback */
+ }
+
+ fetch(url, { cache: "force-cache" })
+ .then(function (r) {
+ return r.ok ? r.json() : null;
+ })
+ .then(function (totals) {
+ if (!totals) {
+ cb && cb(false);
+ return;
+ }
+ var store = getStorage();
+ var changed = false;
+ Object.keys(totals).forEach(function (p) {
+ var path = p.slice(-1) === "/" ? p : p + "/";
+ if (!store.pages[path]) {
+ store.pages[path] = { checked: [], completedDates: {}, lastUpdated: null };
+ }
+ // Only seed _total; never clobber _checked (real progress wins).
+ if (store.pages[path]._total !== totals[p]) {
+ store.pages[path]._total = totals[p];
+ changed = true;
+ }
+ if (store.pages[path]._checked === undefined) {
+ store.pages[path]._checked = (store.pages[path].checked || []).length;
+ }
+ });
+ if (changed) saveStorage(store);
+ cb && cb(true);
+ })
+ .catch(function () {
+ cb && cb(false);
+ });
+ }
+
+ // ── Checkbox initialization ────────────────────────
+ function initCheckboxes() {
+ var path = getPagePath();
+ var pageData = getPageData(path);
+
+ var items = document.querySelectorAll("li.task-list-item");
+ if (!items.length) return { checked: 0, total: 0 };
+
+ var total = items.length;
+ var checkedCount = 0;
+
+ items.forEach(function (li) {
+ var cb = li.querySelector('input[type="checkbox"]');
+ if (!cb) return;
+
+ var label = getNormLabel(li);
+ var isChecked = pageData.checked.indexOf(label) !== -1;
+
+ // Enable checkbox
+ cb.disabled = false;
+ cb.checked = isChecked;
+ if (isChecked) checkedCount++;
+
+ cb.addEventListener("change", function () {
+ var now = cb.checked;
+ setPageChecked(path, label, now);
+
+ // Recompute & re-render
+ var counts = computeHeadingCounts();
+ savePageSummary(path, counts.pageChecked, counts.pageTotal);
+ renderHeadingPills(counts);
+ updatePageHeading(counts);
+ updateNavBadges();
+ checkCompletions(counts);
+ });
+ });
+
+ return { checked: checkedCount, total: total };
+ }
+
+ // ── Heading counters ───────────────────────────────
+ function computeHeadingCounts() {
+ var article = document.querySelector("article");
+ if (!article) return { groups: [], pageChecked: 0, pageTotal: 0 };
+
+ var groups = [];
+ var current = null;
+
+ // Tree-walk: assign items to the nearest preceding h2/h3 in document order.
+ // This is immune to compareDocumentPosition quirks.
+ function walk(node) {
+ if (node.nodeType !== 1) return; // skip non-elements
+ var tag = node.tagName;
+ if (tag === "H2" || tag === "H3") {
+ current = node;
+ groups.push({ heading: node, items: [], level: parseInt(tag[1]) });
+ } else if (tag === "LI" && node.classList.contains("task-list-item")) {
+ if (current) {
+ groups[groups.length - 1].items.push(node);
+ }
+ }
+ // Recurse into children
+ var children = node.childNodes;
+ for (var i = 0; i < children.length; i++) {
+ walk(children[i]);
+ }
+ }
+
+ // Walk article's children (skip article itself to avoid h1)
+ var kids = article.childNodes;
+ for (var i = 0; i < kids.length; i++) {
+ walk(kids[i]);
+ }
+
+ // Compute checked counts
+ var pageChecked = 0;
+ var pageTotal = 0;
+ groups.forEach(function (g) {
+ var checked = 0;
+ g.items.forEach(function (li) {
+ var cb = li.querySelector('input[type="checkbox"]');
+ if (cb && cb.checked) checked++;
+ });
+ g.checked = checked;
+ g.total = g.items.length;
+ pageChecked += checked;
+ pageTotal += g.items.length;
+ });
+
+ return { groups: groups, pageChecked: pageChecked, pageTotal: pageTotal };
+ }
+
+ function renderHeadingPills(counts) {
+ // Clear previous data attributes from all article headings
+ document.querySelectorAll("article h2, article h3").forEach(function (h) {
+ h.removeAttribute("data-toa-count");
+ h.removeAttribute("data-toa-complete");
+ h.classList.remove("toa-heading--complete");
+ });
+
+ counts.groups.forEach(function (g) {
+ if (g.total === 0) return;
+ g.heading.setAttribute("data-toa-count", g.checked + "/" + g.total);
+ if (g.checked === g.total) {
+ g.heading.setAttribute("data-toa-complete", "true");
+ g.heading.classList.add("toa-heading--complete");
+ }
+ });
+ }
+
+ function updatePageHeading(counts) {
+ var h1 = document.querySelector("article h1");
+ if (!h1) return;
+ h1.removeAttribute("data-toa-count");
+ h1.removeAttribute("data-toa-complete");
+ h1.classList.remove("toa-heading--complete");
+
+ if (counts.pageTotal === 0) return;
+
+ h1.setAttribute("data-toa-count", counts.pageChecked + "/" + counts.pageTotal);
+ if (counts.pageChecked === counts.pageTotal) {
+ h1.setAttribute("data-toa-complete", "true");
+ h1.classList.add("toa-heading--complete");
+ }
+ }
+
+ // ── Completion detection ───────────────────────────
+ var _completedHeadings = {};
+ var _pageWasComplete = false;
+
+ function checkCompletions(counts) {
+ var path = getPagePath();
+
+ // Check heading completions
+ counts.groups.forEach(function (g) {
+ if (g.total === 0) return;
+ var id = g.heading.id || g.heading.textContent.replace(/\s+/g, "_");
+ var justCompleted = g.checked === g.total && !_completedHeadings[id];
+ _completedHeadings[id] = g.checked === g.total;
+
+ if (justCompleted) {
+ fireHeadingComplete();
+ }
+ });
+
+ // Check page completion
+ var pageComplete =
+ counts.pageTotal > 0 && counts.pageChecked === counts.pageTotal;
+ if (pageComplete && !_pageWasComplete) {
+ firePageComplete();
+ }
+ _pageWasComplete = pageComplete;
+
+ // Save summary for nav aggregation
+ savePageSummary(path, counts.pageChecked, counts.pageTotal);
+ }
+
+ // ── Confetti ───────────────────────────────────────
+ // Single shared canvas + one rAF loop. Multiple completions add particles
+ // to the same pool instead of stacking N fullscreen canvases (each of which
+ // would independently repaint the whole viewport at 60fps — the cause of
+ // click-induced slowdown). The loop fully stops when no particles remain,
+ // and the canvas is removed so there is no idle fullscreen layer.
+ var _confettiCanvas = null;
+ var _confettiCtx = null;
+ var _confettiParticles = [];
+ var _confettiRAF = 0;
+
+ var CONFETTI_COLORS = [
+ "#4a8262", "#a8c5b6", "#3d6b52", "#e9dfc6", "#92ad9e", "#d4e8dc",
+ ];
+
+ function _ensureConfettiCanvas() {
+ if (_confettiCanvas) return;
+ var canvas = document.createElement("canvas");
+ canvas.style.cssText =
+ "position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:99999;";
+ document.body.appendChild(canvas);
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+ _confettiCanvas = canvas;
+ _confettiCtx = canvas.getContext("2d");
+ }
+
+ function _destroyConfettiCanvas() {
+ if (_confettiCanvas) {
+ _confettiCanvas.remove();
+ _confettiCanvas = null;
+ _confettiCtx = null;
+ }
+ _confettiParticles = [];
+ _confettiRAF = 0;
+ }
+
+ function _confettiTick(now) {
+ var ctx = _confettiCtx;
+ var canvas = _confettiCanvas;
+ if (!ctx || !canvas) {
+ _confettiRAF = 0;
+ return;
+ }
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ var alive = 0;
+ for (var i = 0; i < _confettiParticles.length; i++) {
+ var p = _confettiParticles[i];
+ var elapsed = now - p.start;
+ var progress = elapsed / p.duration;
+ if (progress >= 1) continue; // dead — skip
+ alive++;
+
+ p.x += p.vx;
+ p.vy += 0.12;
+ p.y += p.vy;
+ p.rot += p.rotV;
+ var fade = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1;
+
+ ctx.save();
+ ctx.translate(p.x, p.y);
+ ctx.rotate((p.rot * Math.PI) / 180);
+ ctx.globalAlpha = fade;
+ ctx.fillStyle = p.color;
+ ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
+ ctx.restore();
+ }
+
+ if (alive === 0) {
+ _destroyConfettiCanvas();
+ return;
+ }
+
+ // Periodically compact the array so dead particles don't accumulate.
+ if (_confettiParticles.length > 400) {
+ _confettiParticles = _confettiParticles.filter(function (p) {
+ return now - p.start < p.duration;
+ });
+ }
+
+ _confettiRAF = requestAnimationFrame(_confettiTick);
+ }
+
+ function fireConfetti(intensity) {
+ if (REDUCED_MOTION) return;
+
+ _ensureConfettiCanvas();
+ var canvas = _confettiCanvas;
+
+ var count = intensity === "page" ? 140 : 55;
+ var duration = intensity === "page" ? 3200 : 2200;
+ var drift = intensity === "page" ? 1.8 : 1.2;
+ var start = performance.now();
+
+ for (var i = 0; i < count; i++) {
+ _confettiParticles.push({
+ x: Math.random() * canvas.width,
+ y: -(Math.random() * 60 + 10),
+ w: Math.random() * 7 + 2,
+ h: Math.random() * 4 + 1.5,
+ color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
+ vx: (Math.random() - 0.5) * drift,
+ vy: Math.random() * 2 + 1.2,
+ rot: Math.random() * 360,
+ rotV: (Math.random() - 0.5) * 8,
+ start: start,
+ duration: duration,
+ });
+ }
+
+ // Start the single loop only if it isn't already running.
+ if (!_confettiRAF) {
+ _confettiRAF = requestAnimationFrame(_confettiTick);
+ }
+ }
+
+ // Keep the shared canvas sized to the viewport.
+ window.addEventListener("resize", function () {
+ if (_confettiCanvas) {
+ _confettiCanvas.width = window.innerWidth;
+ _confettiCanvas.height = window.innerHeight;
+ }
+ });
+
+ // ── Sound ──────────────────────────────────────────
+ var _audioCtx = null;
+
+ function getAudioCtx() {
+ if (!_audioCtx) {
+ _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ if (_audioCtx.state === "suspended") {
+ _audioCtx.resume();
+ }
+ return _audioCtx;
+ }
+
+ function playTone(freq, duration, when) {
+ if (REDUCED_MOTION) return;
+ try {
+ var ctx = getAudioCtx();
+ var osc = ctx.createOscillator();
+ var gain = ctx.createGain();
+ var t = when || ctx.currentTime;
+
+ osc.type = "sine";
+ osc.frequency.setValueAtTime(freq, t);
+
+ gain.gain.setValueAtTime(0, t);
+ gain.gain.linearRampToValueAtTime(0.3, t + 0.04);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
+
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ osc.start(t);
+ osc.stop(t + duration);
+ } catch (e) {
+ /* Web Audio not available */
+ }
+ }
+
+ function fireHeadingComplete() {
+ playTone(432, 1.6);
+ fireConfetti("heading");
+ }
+
+ function firePageComplete() {
+ var ctx;
+ try {
+ ctx = getAudioCtx();
+ } catch (e) {
+ fireConfetti("page");
+ return;
+ }
+ // Ascending arpeggio centered on 528 Hz
+ var notes = [528 * 0.667, 528 * 0.75, 528 * 0.833, 528];
+ var t = ctx.currentTime;
+ notes.forEach(function (freq, i) {
+ playTone(freq, 0.7, t + i * 0.18);
+ });
+ fireConfetti("page");
+ }
+
+ // ── Navigation badges ──────────────────────────────
+ function updateNavBadges() {
+ // Pause observer during our own DOM changes to prevent feedback loops
+ if (_navObserver) _navObserver.disconnect();
+
+ var store = getStorage();
+ // Only badge PAGE links in the primary navigation. Exclude:
+ // - the secondary nav ("On this page" / table-of-contents), whose links
+ // are in-page #anchors and would all resolve to the current page total
+ // - any link whose href is/contains a # fragment (same reason)
+ var navLinks = document.querySelectorAll(
+ ".md-nav--primary .md-nav__link[href]"
+ );
+
+ navLinks.forEach(function (link) {
+ var href = link.getAttribute("href");
+ // Existing badge on this link, if any — we update in place rather than
+ // tearing down every badge in the sidebar each toggle (a full nav
+ // teardown/rebuild forces Material to relayout the whole sidebar, which
+ // was the dominant reflow cost per checkbox click).
+ var existing = link.querySelector(":scope > .toa-nav-badge");
+
+ // Skip in-page anchor links (TOC entries, heading permalinks).
+ if (!href || href.charAt(0) === "#" || href.indexOf("#") !== -1) {
+ if (existing) existing.remove();
+ return;
+ }
+ // Defensive: skip if this link lives in a secondary (TOC) nav.
+ if (link.closest(".md-nav--secondary")) {
+ if (existing) existing.remove();
+ return;
+ }
+
+ // Resolve relative URLs to absolute paths
+ var url;
+ try {
+ url = new URL(href, location.origin + location.pathname).pathname;
+ } catch (e) {
+ if (existing) existing.remove();
+ return;
+ }
+ if (url.slice(-1) !== "/") url += "/";
+
+ // Check if this is a section (index) page with child pages.
+ // NOTE: Material wraps the ACTIVE page's .md-nav__item with a nested
+ //