Merge action-points feature: interactive checklist w/ kill switch
All checks were successful
Deploy Docs / deploy (push) Successful in 14s
All checks were successful
Deploy Docs / deploy (push) Successful in 14s
Brings in the interactive action-points checklist (nav badges, heading pills, first-load totals manifest, confetti) with: - a zensical.toml kill switch ([project.extra.action_points] enabled) - perf fixes (store cache, in-place badge updates, pooled confetti) - white-on-green legibility fix Conflicts resolved below. # Conflicts: # .forgejo/workflows/docs.yml # docs/stylesheets/extra.css
This commit is contained in:
commit
4421378529
10 changed files with 1344 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <base>/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
|
||||
// <nav class="md-nav md-nav--secondary"> holding the "On this page" TOC.
|
||||
// A naive ".md-nav--nested, nav.md-nav" probe matches that TOC and
|
||||
// mis-classifies the active leaf page as a section. So we detect real
|
||||
// section items via Material's own marker class and explicitly ignore
|
||||
// any nested nav that is the secondary (TOC) nav.
|
||||
var parentNav = link.closest(".md-nav__item");
|
||||
var nestedNav = parentNav
|
||||
? parentNav.querySelector(":scope > nav.md-nav:not(.md-nav--secondary)")
|
||||
: null;
|
||||
var isSection =
|
||||
(parentNav && parentNav.classList.contains("md-nav__item--nested")) ||
|
||||
!!nestedNav;
|
||||
|
||||
var badge;
|
||||
if (isSection) {
|
||||
// Aggregate all sub-pages whose paths start with this URL
|
||||
badge = aggregateChildren(url, store);
|
||||
} else {
|
||||
badge = getPageSummary(url);
|
||||
}
|
||||
|
||||
if (!badge || badge.total === 0) {
|
||||
if (existing) existing.remove();
|
||||
link.classList.remove("toa-nav--complete");
|
||||
return;
|
||||
}
|
||||
|
||||
var isComplete = badge.checked === badge.total;
|
||||
var desiredText = isComplete ? "\u2713" : badge.checked + "/" + badge.total;
|
||||
var desiredClass = isComplete
|
||||
? "toa-nav-badge toa-nav-badge--complete"
|
||||
: "toa-nav-badge";
|
||||
|
||||
// Reuse the existing span; only write when something actually changed, so
|
||||
// unchanged links cause no DOM mutation and no style recalc.
|
||||
var span = existing;
|
||||
if (!span) {
|
||||
span = document.createElement("span");
|
||||
link.appendChild(span);
|
||||
}
|
||||
if (span.className !== desiredClass) span.className = desiredClass;
|
||||
if (span.textContent !== desiredText) span.textContent = desiredText;
|
||||
if (isComplete) {
|
||||
if (!link.classList.contains("toa-nav--complete")) {
|
||||
link.classList.add("toa-nav--complete");
|
||||
}
|
||||
} else if (link.classList.contains("toa-nav--complete")) {
|
||||
link.classList.remove("toa-nav--complete");
|
||||
}
|
||||
});
|
||||
|
||||
// Resume observer
|
||||
if (_navObserver) {
|
||||
var sb = document.querySelector(".md-sidebar--primary");
|
||||
if (sb) _navObserver.observe(sb, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateChildren(parentUrl, store) {
|
||||
var checked = 0;
|
||||
var total = 0;
|
||||
Object.keys(store.pages).forEach(function (path) {
|
||||
if (path === parentUrl) return;
|
||||
if (path.indexOf(parentUrl) === 0) {
|
||||
var s = store.pages[path];
|
||||
if (s && s._total !== undefined) {
|
||||
checked += s._checked || 0;
|
||||
total += s._total || 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (total === 0) return null;
|
||||
return { checked: checked, total: total };
|
||||
}
|
||||
|
||||
// ── Export ─────────────────────────────────────────
|
||||
function createExportLink() {
|
||||
// Only add if there's data to export
|
||||
var store = getStorage();
|
||||
var hasData = Object.keys(store.pages).length > 0;
|
||||
var existing = document.querySelector(".toa-export");
|
||||
if (existing) existing.remove();
|
||||
if (!hasData) return;
|
||||
|
||||
// Find a good spot — footer copyright area or near comments
|
||||
var container =
|
||||
document.querySelector(".md-copyright") ||
|
||||
document.querySelector("#__comments") ||
|
||||
document.querySelector("article");
|
||||
|
||||
if (!container) return;
|
||||
|
||||
var link = document.createElement("a");
|
||||
link.className = "toa-export";
|
||||
link.textContent = "Download my progress";
|
||||
link.href = "#";
|
||||
link.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var raw = localStorage.getItem(STORAGE_KEY) || "{}";
|
||||
var blob = new Blob([raw], { type: "application/json" });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "tree-of-ascension-progress.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
container.appendChild(link);
|
||||
}
|
||||
|
||||
// ── Main init ──────────────────────────────────────
|
||||
var _lastInitPath = null;
|
||||
|
||||
function init() {
|
||||
var path = getPagePath();
|
||||
if (_lastInitPath === path) return; // guard against duplicate instant-nav fires
|
||||
_lastInitPath = path;
|
||||
|
||||
_completedHeadings = {};
|
||||
_pageWasComplete = false;
|
||||
|
||||
var result = initCheckboxes();
|
||||
var counts = computeHeadingCounts();
|
||||
savePageSummary(path, result.checked, result.total);
|
||||
renderHeadingPills(counts);
|
||||
updatePageHeading(counts);
|
||||
updateNavBadges();
|
||||
createExportLink();
|
||||
|
||||
// Seed build-time totals so nav badges show correct numbers for pages
|
||||
// the user hasn't visited yet, then re-render badges once loaded.
|
||||
seedTotalsFromManifest(function (ok) {
|
||||
if (ok) updateNavBadges();
|
||||
});
|
||||
|
||||
// Mark existing completions so we don't re-trigger on page load
|
||||
counts.groups.forEach(function (g) {
|
||||
if (g.total === 0) return;
|
||||
var id = g.heading.id || g.heading.textContent.replace(/\s+/g, "_");
|
||||
_completedHeadings[id] = g.checked === g.total;
|
||||
if (g.checked === g.total) {
|
||||
g.heading.classList.add("toa-heading--complete");
|
||||
}
|
||||
});
|
||||
_pageWasComplete =
|
||||
counts.pageTotal > 0 && counts.pageChecked === counts.pageTotal;
|
||||
}
|
||||
|
||||
// ── Boot ───────────────────────────────────────────
|
||||
var _navObserver = null;
|
||||
|
||||
function setupNavObserver() {
|
||||
if (_navObserver) return;
|
||||
var sidebar = document.querySelector(".md-sidebar--primary");
|
||||
if (!sidebar) return;
|
||||
_navObserver = new MutationObserver(function () {
|
||||
updateNavBadges();
|
||||
});
|
||||
_navObserver.observe(sidebar, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function ready(fn) {
|
||||
if (typeof document$ !== "undefined") {
|
||||
document$.subscribe(function () {
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(fn);
|
||||
});
|
||||
setupNavObserver();
|
||||
});
|
||||
} else {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
fn();
|
||||
setupNavObserver();
|
||||
});
|
||||
} else {
|
||||
fn();
|
||||
setupNavObserver();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ready(init);
|
||||
})();
|
||||
|
|
|
|||
4
docs/javascripts/toa-totals.json
Normal file
4
docs/javascripts/toa-totals.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"/practices/daily-sadhana/": 13,
|
||||
"/strategies/digital-sovereignty/": 7
|
||||
}
|
||||
|
|
@ -15,7 +15,9 @@ icon: material/meditation
|
|||
|
||||
## Meditation
|
||||
|
||||
Sit. Breathe. That's the whole instruction.
|
||||
- [ ] Sit quietly for 5 minutes — a chair, a floor, anywhere
|
||||
- [ ] Notice when the mind wanders and gently return to breath
|
||||
- [ ] Do this every morning for one week
|
||||
|
||||
Start with five minutes. The mind will wander — that's what minds do. The practice is not emptying the mind but noticing when it has wandered and gently returning. Every return is a rep. Like any muscle, attention strengthens with use.
|
||||
|
||||
|
|
@ -25,7 +27,10 @@ No app needed. No special cushion. A chair, a floor, a patch of grass — anywhe
|
|||
|
||||
## Gratitude journal
|
||||
|
||||
Before bed or first thing in the morning, write down three things you are genuinely grateful for. Not generic blessings — specific moments. The warmth of sunlight through a window. A conversation that landed. The taste of a ripe fruit.
|
||||
- [ ] Before bed or first thing, write down 3 specific things you're grateful for
|
||||
- [ ] Do this every day for one week and notice the shift
|
||||
|
||||
Not generic blessings — specific moments. The warmth of sunlight through a window. A conversation that landed. The taste of a ripe fruit.
|
||||
|
||||
Gratitude is not toxic positivity. It is the deliberate practice of noticing what is already good, which rewires the brain away from its negativity bias over time.
|
||||
|
||||
|
|
@ -35,10 +40,10 @@ Gratitude is not toxic positivity. It is the deliberate practice of noticing wha
|
|||
|
||||
This is the inner work few want to do: asking honestly where you are still reactive.
|
||||
|
||||
- Where am I still carrying anger?
|
||||
- What am I afraid of?
|
||||
- How can I be more patient today?
|
||||
- Where could I replace judgment with kindness?
|
||||
- [ ] Ask yourself: Where am I still carrying anger?
|
||||
- [ ] Ask yourself: What am I afraid of?
|
||||
- [ ] Ask yourself: How can I be more patient today?
|
||||
- [ ] Replace one judgment with kindness today
|
||||
|
||||
Don't fix it all at once. Pick one thread and follow it. The goal is not to become perfect — it is to become less defended, more available, more unconditionally loving. Not toward everyone else first — toward yourself.
|
||||
|
||||
|
|
@ -53,6 +58,9 @@ The body needs two kinds of movement every day:
|
|||
|
||||
**Energy practice** — yoga, tai chi, qigong, or simple stretching with breath awareness. This moves *prana* (life force) through the channels of the body. Even ten minutes shifts the nervous system from fight-or-flight to rest-and-digest.
|
||||
|
||||
- [ ] Do 10 minutes of energy practice this morning (yoga, stretching, tai chi)
|
||||
- [ ] Do 20 minutes of cardio or strength work today
|
||||
|
||||
**Cardio and strength** — walking briskly, running, bodyweight exercises, lifting. The body evolved to move against resistance. Without it, the mind stagnates too.
|
||||
|
||||
Pair them. Energy practice in the morning to open; strength in the afternoon to ground.
|
||||
|
|
@ -61,7 +69,8 @@ Pair them. Energy practice in the morning to open; strength in the afternoon to
|
|||
|
||||
## Visualization: morning and night
|
||||
|
||||
Every morning and every night before sleep, spend two minutes visualizing the future you want — for yourself, your family, your community, your 'tribe.' See it as if it is real *now*. Feel the gratitude as if it has already arrived.
|
||||
- [ ] Spend 2 minutes each morning visualizing the future you want — see it as real *now*
|
||||
- [ ] Spend 2 minutes before sleep visualizing the same — feel the gratitude as if it arrived
|
||||
|
||||
This is not magical thinking. It is the practice of orienting the subconscious toward what you are building, so that during the day, you recognize the doors when they appear.
|
||||
|
||||
|
|
|
|||
|
|
@ -27,9 +27,10 @@ For phones, the path is similar:
|
|||
|
||||
## Network sovereignty
|
||||
|
||||
**Switch from WiFi to Ethernet** for your main computer. It's faster, more stable, and eliminates constant radio-frequency exposure at close range.
|
||||
|
||||
**On your phone**: switch 5G to 4G (lower frequency = less power = less exposure). Keep your phone on airplane mode when not actively needed. Keep it away from your body — not in a pocket, not against your head. Zero wearables (no smartwatch, no fitness tracker, no bluetooth earbuds).
|
||||
- [ ] Switch your main computer from WiFi to Ethernet
|
||||
- [ ] On your phone: switch 5G to 4G, use airplane mode when not needed
|
||||
- [ ] Install and use a trusted VPN with a clear no-logging policy
|
||||
- [ ] Keep your phone away from your body — not in a pocket, not against your head
|
||||
|
||||
**VPN**: A simple, trusted VPN prevents your internet provider from building a profile of every site you visit. Choose one with a clear no-logging policy and preferably based outside surveillance alliances.
|
||||
|
||||
|
|
@ -54,7 +55,9 @@ The principle is simple: **self-hosted first, local-first, privacy-first**. If y
|
|||
|
||||
## Passwords and keys
|
||||
|
||||
Use [KeePassXC](https://keepassxc.org/) — a local, offline, open-source password manager. Unlike browser-based password managers or online services, your password database lives on your device. You control the file. Nobody can leak what they don't have.
|
||||
- [ ] Install KeePassXC and create your first password database
|
||||
- [ ] Generate unique passwords for every important service
|
||||
- [ ] Get a hardware security key (like a YubiKey) where possible
|
||||
|
||||
Generate unique passwords for every service. Use a hardware security key (like a YubiKey) where possible. Treat your digital keys as carefully as the keys to your home.
|
||||
|
||||
|
|
|
|||
|
|
@ -858,4 +858,123 @@ article h1, article h2, article h3 {
|
|||
min-height: 150px;
|
||||
padding: 1.1rem 1.1rem 3.2rem 1.1rem;
|
||||
}
|
||||
/* ═════════════════════════════════════════════════════════════════
|
||||
Action Points — Interactive Checklist
|
||||
═════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Checkbox interaction ─────────────────────────── */
|
||||
li.task-list-item input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s var(--gp-ease, cubic-bezier(0.22, 0.61, 0.36, 1));
|
||||
}
|
||||
li.task-list-item input[type="checkbox"]:active {
|
||||
transform: scale(0.82);
|
||||
}
|
||||
li.task-list-item:has(input:checked) {
|
||||
text-decoration: line-through;
|
||||
color: var(--gp-text-muted);
|
||||
opacity: 0.72;
|
||||
transition: opacity 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Pill badges (via ::after pseudo-element — clean textContent) ── */
|
||||
h1[data-toa-count]::after,
|
||||
h2[data-toa-count]::after,
|
||||
h3[data-toa-count]::after {
|
||||
content: attr(data-toa-count);
|
||||
display: inline-block;
|
||||
float: right;
|
||||
background: var(--gp-card-bg, rgba(255,255,255,0.74));
|
||||
border: 1px solid var(--gp-card-border, rgba(122,170,150,0.22));
|
||||
border-radius: 999px;
|
||||
padding: 0.08em 0.62em;
|
||||
font-size: 0.62em;
|
||||
font-weight: 550;
|
||||
color: var(--gp-text-muted, #4e5e4b);
|
||||
margin-left: 0.5em;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.01em;
|
||||
transition:
|
||||
background 0.35s ease,
|
||||
color 0.35s ease,
|
||||
border-color 0.35s ease;
|
||||
}
|
||||
|
||||
h1[data-toa-count]::after {
|
||||
font-size: 0.48em;
|
||||
padding: 0.1em 0.7em;
|
||||
}
|
||||
|
||||
h1[data-toa-complete="true"]::after,
|
||||
h2[data-toa-complete="true"]::after,
|
||||
h3[data-toa-complete="true"]::after {
|
||||
background: #3d6b52;
|
||||
color: #fff !important;
|
||||
/* Headings set -webkit-text-fill-color (gradient/fill effect); pseudo-els
|
||||
inherit it and it overrides `color`. Override it on the pill ONLY so the
|
||||
count text is white on green — the heading's own text color is untouched. */
|
||||
-webkit-text-fill-color: #fff !important;
|
||||
border-color: #3d6b52;
|
||||
}
|
||||
|
||||
/* ── Heading completion ───────────────────────────── */
|
||||
.toa-heading--complete {
|
||||
color: #3d6b52 !important;
|
||||
transition: color 0.5s ease;
|
||||
}
|
||||
|
||||
/* ── Nav badges ───────────────────────────────────── */
|
||||
.toa-nav-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2em;
|
||||
background: var(--gp-card-bg, rgba(255,255,255,0.74));
|
||||
border: 1px solid var(--gp-card-border, rgba(122,170,150,0.22));
|
||||
border-radius: 999px;
|
||||
padding: 0 0.42em;
|
||||
font-size: 0.68em;
|
||||
font-weight: 550;
|
||||
color: var(--gp-text-muted, #4e5e4b);
|
||||
margin-left: 0.4em;
|
||||
line-height: 1.65;
|
||||
transition:
|
||||
background 0.35s ease,
|
||||
color 0.35s ease;
|
||||
}
|
||||
|
||||
.toa-nav-badge--complete {
|
||||
background: #3d6b52;
|
||||
color: #fff !important;
|
||||
-webkit-text-fill-color: #fff !important;
|
||||
border-color: #3d6b52;
|
||||
}
|
||||
|
||||
/* The complete nav link goes green (below). Make sure the badge text on its
|
||||
green background stays white and isn't dragged green by the link's
|
||||
!important color. */
|
||||
.md-nav__link.toa-nav--complete .toa-nav-badge--complete {
|
||||
color: #fff !important;
|
||||
-webkit-text-fill-color: #fff !important;
|
||||
}
|
||||
|
||||
.md-nav__link.toa-nav--complete {
|
||||
color: #3d6b52 !important;
|
||||
transition: color 0.35s ease;
|
||||
}
|
||||
|
||||
/* ── Export link ──────────────────────────────────── */
|
||||
.toa-export {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
color: var(--gp-text-muted, #4e5e4b);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--gp-secondary, #a8c5b6);
|
||||
margin-top: 0.6rem;
|
||||
transition: color 0.22s ease;
|
||||
}
|
||||
.toa-export:hover {
|
||||
color: var(--gp-primary, #4a8262);
|
||||
}
|
||||
|
|
|
|||
73
main.py
73
main.py
|
|
@ -1,5 +1,74 @@
|
|||
def main():
|
||||
print("Hello from up!")
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the Tree of Ascension action-points totals manifest.
|
||||
|
||||
Scans docs/**/*.md for Markdown tasklist items (`- [ ]` / `- [x]`), counts
|
||||
them per page, and writes docs/javascripts/toa-totals.json mapping each
|
||||
page's site URL -> total task count.
|
||||
|
||||
The action-points JS (docs/javascripts/extra.js) fetches this manifest once
|
||||
on first load so nav badges show correct totals BEFORE a user has visited
|
||||
every page. Counts are fully static (known from the Markdown), so this has
|
||||
zero runtime cost — it's a build-time artifact.
|
||||
|
||||
Run before `zensical build`:
|
||||
python3 main.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
DOCS = ROOT / "docs"
|
||||
OUT = DOCS / "javascripts" / "toa-totals.json"
|
||||
|
||||
# Matches a Markdown tasklist item: optional indent, list marker, [ ] or [x].
|
||||
TASK_RE = re.compile(r"^\s*[-*+]\s+\[[ xX]\]\s+", re.MULTILINE)
|
||||
|
||||
# Matches fenced code blocks (``` or ~~~), incl. language hint, non-greedy.
|
||||
# These contain tasklist *examples* (e.g. the Markdown reference doc) that render
|
||||
# as plain text, NOT interactive checkboxes — so we strip them before counting.
|
||||
FENCE_RE = re.compile(r"^([ \t]*)(`{3,}|~{3,}).*?\n.*?^\1\2[ \t]*$", re.MULTILINE | re.DOTALL)
|
||||
|
||||
|
||||
def count_tasks(text: str) -> int:
|
||||
"""Count real (interactive) tasklist items, excluding fenced code examples."""
|
||||
stripped = FENCE_RE.sub("", text)
|
||||
return len(TASK_RE.findall(stripped))
|
||||
|
||||
|
||||
def url_for(md_path: Path) -> str:
|
||||
"""Map a docs/*.md path to its built site URL (directory-style URLs).
|
||||
|
||||
docs/index.md -> /
|
||||
docs/practices/index.md -> /practices/
|
||||
docs/practices/daily-sadhana.md-> /practices/daily-sadhana/
|
||||
"""
|
||||
rel = md_path.relative_to(DOCS).with_suffix("")
|
||||
parts = list(rel.parts)
|
||||
if parts and parts[-1] == "index":
|
||||
parts = parts[:-1]
|
||||
path = "/" + "/".join(parts)
|
||||
if not path.endswith("/"):
|
||||
path += "/"
|
||||
return path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
totals: dict[str, int] = {}
|
||||
for md in sorted(DOCS.rglob("*.md")):
|
||||
text = md.read_text(encoding="utf-8")
|
||||
count = count_tasks(text)
|
||||
if count:
|
||||
totals[url_for(md)] = count
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(totals, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(f"toa-totals: wrote {len(totals)} page(s) -> {OUT.relative_to(ROOT)}")
|
||||
for url, n in sorted(totals.items()):
|
||||
print(f" {url} {n}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
23
overrides/main.html
Normal file
23
overrides/main.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{#
|
||||
Action Points feature flag.
|
||||
|
||||
Exposes the zensical.toml setting `[project.extra.action_points] enabled`
|
||||
to client-side JS as `window.TOA_ACTION_POINTS_ENABLED`. This block renders
|
||||
in <head>, before extra_javascript loads in {% block scripts %}, so the
|
||||
action-points engine in docs/javascripts/extra.js can bail out immediately
|
||||
when the feature is disabled.
|
||||
|
||||
Set `enabled = false` in zensical.toml to turn the ENTIRE interactive
|
||||
checklist feature off — no localStorage, no nav badges, no confetti, no
|
||||
per-click work. Checkboxes then fall back to Material's default read-only
|
||||
rendering. Defaults to ON when the key is absent.
|
||||
#}
|
||||
{% block extrahead %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
window.TOA_ACTION_POINTS_ENABLED =
|
||||
{{ "false" if config.extra.action_points and config.extra.action_points.enabled == false else "true" }};
|
||||
</script>
|
||||
{% endblock %}
|
||||
296
planning/action-points-prd.md
Normal file
296
planning/action-points-prd.md
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
# Action Points — Interactive Checklist PRD
|
||||
|
||||
**Status:** Draft
|
||||
**Author:** aja + Hermes
|
||||
**Date:** 2026-06-03
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The Tree of Ascension site contains practices and strategies that invite the reader to *do* something — meditate, journal, take a walk barefoot, migrate off a service. Right now those calls to action are flat text. A reader who returns tomorrow has no way to see what they've already tried or what remains.
|
||||
|
||||
We want to make the site *participatory*: let readers check off action items, see their progress, and feel a sense of momentum. Completion should be visible and celebratory — not gamified in a manipulative way, but genuinely satisfying.
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature Overview
|
||||
|
||||
| # | Feature | Summary |
|
||||
|---|---------|---------|
|
||||
| 1 | **Checkable action points** | Markdown `- [ ]` task lists that are clickable and persist checked state in `localStorage` |
|
||||
| 2 | **Heading counters** | Each heading with action items underneath shows a counter (e.g., `3/5`). When all items are checked, the heading changes color and confetti drops from the top of the page |
|
||||
| 3 | **Navigation badges** | Sidebar navigation items show a checkmark and color change when all action items on that page are checked |
|
||||
| 4 | **Page-level completion** | The top-of-page heading (h1) reflects overall page completion with a counter |
|
||||
|
||||
---
|
||||
|
||||
## 3. Detailed Specifications
|
||||
|
||||
### 3.1 Checkable Action Points
|
||||
|
||||
**What they are:**
|
||||
Any `- [ ]` Markdown task list item rendered on a page. The site already uses `pymdownx.tasklist` with `custom_checkbox = true`, which renders checkboxes. We layer interactivity + persistence on top.
|
||||
|
||||
**Behavior:**
|
||||
- Clicking a checkbox toggles its state (unchecked ↔ checked)
|
||||
- State is immediately persisted to `localStorage`
|
||||
- On page load (and on instant navigation), previously saved state is restored
|
||||
- If a checkbox's label text no longer matches any saved entry (content was edited), the stale entry is silently ignored
|
||||
|
||||
**Checkbox identification:**
|
||||
Each checkbox is identified by a key derived from:
|
||||
1. The page's canonical URL path (e.g., `/practices/daily-sadhana/`)
|
||||
2. The normalized label text (trimmed, lowercased, collapsed whitespace)
|
||||
|
||||
```
|
||||
Key = URL + "::" + normalized_label
|
||||
```
|
||||
|
||||
This is stable across reloads and survives minor whitespace changes but intentionally breaks on substantial rewrites (which should reset progress anyway).
|
||||
|
||||
**Visual style:**
|
||||
- Unchecked: current Zensical tasklist styling (green-tinted)
|
||||
- Checked: strikethrough + muted color, consistent with existing site palette
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Heading Counters & Completion Celebration
|
||||
|
||||
**Counter display:**
|
||||
For each heading (h2, h3, h4) that has one or more action items in its scope, a pill badge is floated to the right of the heading:
|
||||
```
|
||||
## Meditation [0/2]
|
||||
```
|
||||
When partially complete:
|
||||
```
|
||||
## Meditation [1/2]
|
||||
```
|
||||
When fully complete — the heading and badge both shift to the site's accent aurora purple (`--gp-accent`).
|
||||
|
||||
**Heading scope:**
|
||||
An action item belongs to the nearest preceding heading at or above its level. Concretely:
|
||||
- Items after an h2 but before the next h2 belong to that h2
|
||||
- Items after an h3 but before the next h2 or h3 belong to that h3
|
||||
|
||||
**Confetti trigger:**
|
||||
When the last unchecked item under a heading is checked (counter goes from `(n-1)/n` to `n/n`):
|
||||
- A confetti burst animates from the top of the viewport
|
||||
- Confetti uses the site's palette (greens, aurora purple, soft gold)
|
||||
- Animation lasts ~3 seconds
|
||||
- Respects `prefers-reduced-motion` (skips entirely)
|
||||
|
||||
**Confetti implementation:** Custom canvas animation (zero dependency).
|
||||
Two tiers:
|
||||
- **Heading completion:** Subtle aurora particle shower — ~2s, calm, particles in site palette. Accompanied by a soft 432 Hz sine chime (Web Audio API, gentle attack/decay envelope).
|
||||
- **Page-level completion:** More energetic burst — ~3s, denser particles. Accompanied by an ascending arpeggio centered on 528 Hz (Web Audio API, sine waves with smooth envelope).
|
||||
|
||||
Both respect `prefers-reduced-motion` (confetti skipped, sounds skipped).
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Navigation Integration
|
||||
|
||||
**Sidebar nav items:**
|
||||
Each link in the left sidebar that points to a page with action items:
|
||||
- Displays a pill badge next to the link text showing the completion count (e.g., `2/5`)
|
||||
- When all items on that page are complete, the badge becomes a ✓ checkmark and the link text shifts to the accent color
|
||||
|
||||
**Index page aggregation:**
|
||||
Section index pages (e.g., `/practices/`) aggregate completion across all their sub-pages. The badge shows the combined total — e.g., `12/28` summing all practice pages. Sub-page grouping is derived from URL structure: `/practices/daily-sadhana/` is a child of `/practices/`.
|
||||
|
||||
**How it works:**
|
||||
1. After a page renders and checkboxes are initialized, compute `{ checked, total }` for that page
|
||||
2. Store the summary in `localStorage` under the page URL
|
||||
3. Find the corresponding `<a>` element in the sidebar navigation and inject the badge/checkmark
|
||||
4. Re-run on every page navigation (instant or full)
|
||||
|
||||
**Edge cases:**
|
||||
- Pages with no action items: no badge, no color change
|
||||
- Deleted pages: stale localStorage entries don't render (no nav element to match)
|
||||
|
||||
---
|
||||
### 3.4 Page-Level Heading
|
||||
|
||||
The top-of-page `<h1>` gets a pill badge (right-aligned) showing overall page progress:
|
||||
```
|
||||
# Daily Sadhana [4/12]
|
||||
```
|
||||
Same color-change logic as section headings: when complete, the h1 turns accent-colored.
|
||||
|
||||
The h1 counter aggregates *all* action items on the page — it's the sum of all section counts.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Progress Export (trivial add-on)
|
||||
|
||||
A small "Download my progress" link in the page footer or near the comments section. Clicking it triggers a browser download of a JSON file containing the full localStorage state. This lets users:
|
||||
- Back up their progress before clearing browser data
|
||||
- Share their completion state (import could come later)
|
||||
|
||||
Implementation is ~10 lines: read `localStorage`, create a `Blob`, trigger download via a temporary `<a>` element.
|
||||
|
||||
---
|
||||
|
||||
## 4. Technical Design
|
||||
|
||||
### 4.1 localStorage Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"toa-action-points": {
|
||||
"version": 1,
|
||||
"pages": {
|
||||
"/practices/daily-sadhana/": {
|
||||
"lastUpdated": "2026-06-03T12:00:00.000Z",
|
||||
"checked": [
|
||||
"sit. breathe. that's the whole instruction.",
|
||||
"write down three things you are genuinely grateful for"
|
||||
]
|
||||
},
|
||||
"/strategies/digital-sovereignty/": {
|
||||
"lastUpdated": "2026-06-03T12:30:00.000Z",
|
||||
"checked": [
|
||||
"switch from wifi to ethernet for your main computer"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Keys are the site-relative URL path (without domain; works across environments)
|
||||
- `checked` is an array of normalized label strings
|
||||
- `version` allows future schema migrations
|
||||
- One key per page keeps reads/writes small
|
||||
|
||||
### 4.2 Instant Navigation Compatibility
|
||||
|
||||
The site uses `navigation.instant` (SPA-style page transitions). Zensical/Material for MkDocs exposes `document$` as an observable for navigation events.
|
||||
|
||||
Our JS hooks into:
|
||||
- `DOMContentLoaded` — for initial page load
|
||||
- `document$.subscribe` — for instant navigation transitions
|
||||
|
||||
On each navigation:
|
||||
1. Wait for DOM to settle (the new page content is rendered)
|
||||
2. Scan for tasklist items
|
||||
3. Restore saved state from localStorage
|
||||
4. Inject counters into headings and h1
|
||||
5. Update sidebar nav badges for *all* pages (not just current — because the current page's completion may have changed)
|
||||
|
||||
### 4.3 Storage Limit
|
||||
|
||||
`localStorage` is typically 5–10 MB per origin. Our schema is compact (a few KB even with many pages). No practical risk of hitting the limit.
|
||||
|
||||
### 4.4 Performance
|
||||
|
||||
- DOM scanning on each navigation: O(n) where n = number of tasklist items on a page (typically < 30)
|
||||
- Nav badge updates: O(sidebar links × pages with data), well under 1ms
|
||||
- confetti: canvas-based, runs off main thread via `requestAnimationFrame`
|
||||
|
||||
All work is < 5ms per navigation; no perceptible impact.
|
||||
|
||||
---
|
||||
|
||||
## 5. Future: Cross-Device Persistence (v2)
|
||||
|
||||
### 5.1 Problem
|
||||
`localStorage` is per-browser, per-device. A reader who checks items on their phone won't see them on their laptop.
|
||||
|
||||
### 5.2 Solution sketch
|
||||
- User optionally enters their email address
|
||||
- We store a mapping: `email → checked_items` on a lightweight server
|
||||
- On page load, if an email is stored in localStorage, fetch state from the server
|
||||
- On checkbox toggle, sync to server (debounced)
|
||||
- No authentication required — email is the identifier (like Cusdis comments)
|
||||
- Server can be a simple SQLite-backed Python/FastAPI service or even a Cloudflare Worker + D1
|
||||
|
||||
### 5.3 Why not now
|
||||
- Requires deploying and maintaining a server
|
||||
- Adds latency and failure modes
|
||||
- `localStorage` solves 80% of the value with 0% of the infrastructure cost
|
||||
- We ship v1, learn from usage, then decide if cross-device is worth it
|
||||
|
||||
---
|
||||
|
||||
## 6. Content Authoring Guide
|
||||
|
||||
For content authors, "action points" are simply `- [ ]` Markdown task lists. No special syntax, no frontmatter, no directives.
|
||||
|
||||
```markdown
|
||||
## Meditation
|
||||
|
||||
- [ ] Sit quietly for 5 minutes
|
||||
- [ ] Notice when the mind wanders and return to breath
|
||||
- [ ] Do this every morning for one week
|
||||
```
|
||||
|
||||
The JS layer automatically detects these and adds interactivity. To opt a page *out* of action-point tracking entirely (e.g., index pages with task lists that aren't meant to be checked off), add frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
action_points: false
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Questions
|
||||
|
||||
1. **Confetti style — subtle or playful?**
|
||||
A gentle aurora-colored particle shower that respects the site's calm aesthetic, or a more energetic celebration? The site's ethos ("not gamified in a manipulative way, but genuinely satisfying") points toward subtle — soft particles in the site palette, ~2-second duration, no sound.
|
||||
|
||||
2. **Should index pages (e.g., `/practices/`) track action items?**
|
||||
Index pages use grid cards, not task lists. Likely `action_points: false` by default for any page without `- [ ]` items. But if someone adds checklist items to an index, should they track? Probably yes — detection is automatic unless explicitly opted out.
|
||||
|
||||
3. **Counter placement on headings — inline badge or superscript?**
|
||||
`## Meditation [0/2]` (inline, after heading text) vs `## Meditation` with a small pill badge. Inline is simpler, less DOM manipulation, and works with existing heading anchors. I'd recommend inline.
|
||||
|
||||
4. **Navigation badges — all pages or only pages with action items?**
|
||||
Only pages with at least one action item should get a badge. Empty counters clutter the sidebar. This means we compute on first page visit and store the `total` count — pages with total=0 get no badge.
|
||||
|
||||
5. **When a user edits the Markdown source (adding/removing items), how should we handle stale localStorage entries?**
|
||||
Stale entries (label text no longer matches any rendered checkbox) are silently dropped. New items start unchecked. This is the least-surprising behavior — edits "reset" progress only for changed items, not the whole page.
|
||||
|
||||
6. **Should completion state be exportable/downloadable?**
|
||||
A "download my progress" button would let users save their state as a JSON file before clearing browser data. Nice-to-have for v1.1.
|
||||
|
||||
7. **Confetti: custom canvas or `canvas-confetti` library?**
|
||||
- Custom: ~60 lines of JS, full control over palette and behavior, zero dependency, but more code to maintain
|
||||
- `canvas-confetti`: battle-tested, good defaults, one function call, but adds ~7 KB dependency
|
||||
|
||||
Leaning custom — the palette matching and reduced-motion respect are trivial to implement and the site already values self-contained minimalism.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Plan (high-level)
|
||||
|
||||
| Phase | What | Files touched |
|
||||
|-------|------|--------------|
|
||||
| 1 | Core checkbox persistence (read/write localStorage) | `docs/javascripts/extra.js` |
|
||||
| 2 | Heading counters + color change | `docs/javascripts/extra.js`, `docs/stylesheets/extra.css` |
|
||||
| 3 | Confetti on completion | `docs/javascripts/extra.js` (custom canvas) |
|
||||
| 4 | Navigation badge injection | `docs/javascripts/extra.js`, `docs/stylesheets/extra.css` |
|
||||
| 5 | Page-level h1 counter | `docs/javascripts/extra.js`, `docs/stylesheets/extra.css` |
|
||||
| 6 | Opt-out frontmatter support | `docs/javascripts/extra.js` |
|
||||
| 7 | Test across all content pages + mobile | manual QA |
|
||||
|
||||
All JS goes in the existing `docs/javascripts/extra.js`. All CSS in `docs/stylesheets/extra.css`. No new files, no new dependencies, no build pipeline changes.
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Criteria
|
||||
|
||||
- [ ] Clicking a `- [ ]` checkbox toggles its visual state
|
||||
- [ ] Refreshing the page preserves checked state
|
||||
- [ ] Navigating to another page and back preserves checked state
|
||||
- [ ] Headings show accurate counters (e.g., `2/5`)
|
||||
- [ ] When a heading reaches full completion, it changes to accent color
|
||||
- [ ] Confetti plays when the last item under a heading is checked (and not on reduced-motion)
|
||||
- [ ] Navigation sidebar shows badges for pages with action items
|
||||
- [ ] Fully-completed pages show ✓ checkmark in nav
|
||||
- [ ] Page h1 shows overall completion counter
|
||||
- [ ] Zero console errors on any page
|
||||
- [ ] Works on mobile (touch events fire correctly on checkboxes)
|
||||
- [ ] No visual regression on pages without action items
|
||||
|
|
@ -312,6 +312,17 @@ host = "https://cusdis.krystl.org"
|
|||
app_id = "d9ab4b66-d5f1-4514-a475-1a824f69530f"
|
||||
lang = "en"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Action Points — interactive checklist feature flag.
|
||||
# Set enabled = false to disable the ENTIRE feature (localStorage progress,
|
||||
# nav badges, heading pills, confetti, and all per-click JS). Checkboxes then
|
||||
# render as Material's default read-only tasklist. The flag is exposed to the
|
||||
# client via overrides/main.html as window.TOA_ACTION_POINTS_ENABLED.
|
||||
# ----------------------------------------------------------------------------
|
||||
[project.extra.action_points]
|
||||
enabled = true
|
||||
|
||||
|
||||
#[[project.extra.social]]
|
||||
#icon = "fontawesome/brands/github"
|
||||
#link = "https://github.com/user/repo"
|
||||
|
|
|
|||
Loading…
Reference in a new issue