From 69f69be5e4da11e9212a8c286b4bfd3847daa03e Mon Sep 17 00:00:00 2001 From: Tolaria Date: Wed, 3 Jun 2026 23:03:17 +0800 Subject: [PATCH 1/3] fix(action-points): correct nav badges, first-load totals, legibility - Fix nav badge mis-targeting: scope to .md-nav--primary, skip #anchor (TOC) links so 'On this page' entries no longer show the page total; detect active-leaf sections via md-nav__item--nested instead of the secondary (TOC) nav, so the active page gets its own badge. - Add build-time totals manifest (main.py -> docs/javascripts/toa-totals.json) so nav badges show correct totals on first load before a user has visited each page. Fetched once, browser-cached, zero runtime cost. Wired into CI before zensical build. - Exclude fenced code blocks when counting tasks, so the Markdown reference doc's example checkboxes don't produce a phantom 0/3 badge. - Force white text (#fff !important) on completed green pills/badges, incl. badge-inside-complete-link, to beat the nav link's green color. - Convert prose practice/strategy steps into interactive tasklist items. - Add action-points PRD under planning/. --- .forgejo/workflows/docs.yml | 1 + docs/javascripts/extra.js | 694 +++++++++++++++++++++++++ docs/javascripts/toa-totals.json | 4 + docs/practices/daily-sadhana.md | 23 +- docs/strategies/digital-sovereignty.md | 11 +- docs/stylesheets/extra.css | 115 ++++ main.py | 73 ++- planning/action-points-prd.md | 296 +++++++++++ 8 files changed, 1204 insertions(+), 13 deletions(-) create mode 100644 docs/javascripts/toa-totals.json create mode 100644 planning/action-points-prd.md diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml index 6f34b57..cdbfcb5 100644 --- a/.forgejo/workflows/docs.yml +++ b/.forgejo/workflows/docs.yml @@ -21,6 +21,7 @@ jobs: - run: | . /tmp/venv/bin/activate + python3 main.py zensical build --clean - name: Deploy to pages branch diff --git a/docs/javascripts/extra.js b/docs/javascripts/extra.js index cb44c1b..a63d27f 100644 --- a/docs/javascripts/extra.js +++ b/docs/javascripts/extra.js @@ -88,3 +88,697 @@ boot(); } })(); + +/* ═══════════════════════════════════════════════════════════════════════════ + Action Points — Interactive Checklist + ═══════════════════════════════════════════════════════════════════════ */ +;(function () { + "use strict"; + + // ── 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 ──────────────────────────────────────── + function getStorage() { + try { + var raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return _fresh(); + var data = JSON.parse(raw); + if (data.version !== VERSION) return _migrate(data); + return data; + } catch (e) { + return _fresh(); + } + } + + 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) { + 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 ─────────────────────────────────────── + function fireConfetti(intensity) { + if (REDUCED_MOTION) 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; + var ctx = canvas.getContext("2d"); + + var COLORS = ["#4a8262", "#a8c5b6", "#3d6b52", "#e9dfc6", "#92ad9e", "#d4e8dc"]; + var count = intensity === "page" ? 140 : 55; + var duration = intensity === "page" ? 3200 : 2200; + var drift = intensity === "page" ? 1.8 : 1.2; + + var particles = []; + for (var i = 0; i < count; i++) { + particles.push({ + x: Math.random() * canvas.width, + y: -(Math.random() * 60 + 10), + w: Math.random() * 7 + 2, + h: Math.random() * 4 + 1.5, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + vx: (Math.random() - 0.5) * drift, + vy: Math.random() * 2 + 1.2, + rot: Math.random() * 360, + rotV: (Math.random() - 0.5) * 8, + opacity: 1, + }); + } + + var start = performance.now(); + + function frame(now) { + var elapsed = now - start; + var progress = elapsed / duration; + if (progress >= 1) { + canvas.remove(); + return; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Fade out in last 30% + var fade = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1; + + particles.forEach(function (p) { + p.x += p.vx; + p.vy += 0.12; + p.y += p.vy; + p.rot += p.rotV; + p.opacity = fade; + + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate((p.rot * Math.PI) / 180); + ctx.globalAlpha = p.opacity; + ctx.fillStyle = p.color; + ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); + ctx.restore(); + }); + + requestAnimationFrame(frame); + } + + requestAnimationFrame(frame); + } + + // ── 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(); + + // Remove old badges + document.querySelectorAll(".toa-nav-badge").forEach(function (el) { + el.remove(); + }); + + 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"); + // Skip in-page anchor links (TOC entries, heading permalinks). + if (!href || href.charAt(0) === "#" || href.indexOf("#") !== -1) return; + // Defensive: skip if this link lives in a secondary (TOC) nav. + if (link.closest(".md-nav--secondary")) return; + + // Resolve relative URLs to absolute paths + var url; + try { + url = new URL(href, location.origin + location.pathname).pathname; + } catch (e) { + 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 + //