up/docs/javascripts/extra.js

1251 lines
40 KiB
JavaScript
Raw Permalink Normal View History

/**
* Godspark Refined Zensical Light JS
* - Scroll reveal for `.gp-reveal`
* - Cursor glow via `[gp-glow]` attribute
* - Reduced-motion safe
*/
;(function () {
"use strict";
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
function initScrollReveal() {
if (prefersReducedMotion) return;
const nodes = document.querySelectorAll(".gp-reveal");
if (!nodes.length) return;
const thresholdRaw = getComputedStyle(document.documentElement)
.getPropertyValue("--gp-reveal-threshold")
.trim();
const threshold = Number.isFinite(parseFloat(thresholdRaw))
? parseFloat(thresholdRaw)
: 0.12;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold, rootMargin: "0px 0px -16px 0px" }
);
nodes.forEach((el) => observer.observe(el));
}
function initCursorGlow() {
const el = document.documentElement;
const enabled =
el.hasAttribute("gp-glow") || el.classList.contains("gp-glow-active");
if (!enabled) return;
if (prefersReducedMotion) return;
el.classList.add("gp-glow-active");
let raf = false;
function setGlow(x, y) {
el.style.setProperty("--gp-cursor-x", x + "px");
el.style.setProperty("--gp-cursor-y", y + "px");
}
function onMove(e) {
if (raf) return;
raf = true;
requestAnimationFrame(() => {
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
setGlow(cx, cy);
raf = false;
});
}
function onHide() { el.style.setProperty("--gp-glow-opacity", "0"); }
function onShow() { el.style.setProperty("--gp-glow-opacity", "1"); }
const passive = { passive: true };
document.addEventListener("mousemove", onMove, passive);
document.addEventListener("touchmove", onMove, passive);
document.addEventListener("mouseleave", onHide);
document.addEventListener("mouseenter", onShow);
}
function boot() {
initScrollReveal();
initCursorGlow();
initCusdisReload();
2026-06-04 12:22:49 +00:00
initExternalLinks();
}
/** Open all external links in a new tab. Runs on initial load and after
* every Zensical instant-navigation, so links on new pages are covered. */
function initExternalLinks() {
function fixLinks(root) {
var links = (root || document).querySelectorAll(
'a[href^="http"]:not([target])'
);
for (var i = 0; i < links.length; i++) {
var a = links[i];
// Only external links — leave same-origin alone
if (a.hostname && a.hostname !== location.hostname) {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
}
}
}
fixLinks(document);
// Re-run after each SPA navigation
if (typeof document$ !== "undefined") {
document$.subscribe(function () {
fixLinks(document);
});
}
}
/**
* Re-initialize Cusdis comment widget after Zensical instant-navigation
* page transitions (XHR-based SPA navigation). Without this, the widget
* only renders on hard-refresh / initial page load.
*/
function initCusdisReload() {
if (typeof document$ === "undefined") return; // not in Zensical context
document$.subscribe(function () {
var el = document.getElementById("cusdis_thread");
if (!el) return;
// Clear stale Cusdis iframe from previous page render
el.innerHTML = "";
// Re-create Cusdis widget for the new page
if (window.CUSDIS && typeof window.CUSDIS.renderTo === "function") {
window.CUSDIS.renderTo(el);
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
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);
})();
2026-06-04 12:22:49 +00:00
/**
*
* Keep In Touch subscribe widget (.toa-kit)
*
* Self-contained. Behaviour:
* - WhatsApp and email each submit independently. Typing a valid value and
* pressing Send (or blurring) captures that channel, fires a light
* celebration, and shows a thank-you notice.
* - Once BOTH whatsapp and email are captured, the name field is revealed
* and the widget asks for their name. Name submits the same way.
* - POSTs JSON to the endpoint from data-endpoint (config.extra.keep_in_touch
* .endpoint, also on window.TOA_KEEP_IN_TOUCH_ENDPOINT). Each submit sends
* the full known set of fields plus the page URL, so the server can upsert.
* - Re-binds on Zensical instant-navigation via document$.subscribe.
*
*/
;(function () {
"use strict";
var REDUCED_MOTION = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
var SUBSCRIBED_KEY = "toa-subscribed";
var CELEBRATE_COLORS = [
"#a8c5b6", "#b9a5d7", "#9ecbe6", "#f3d9a4", "#8db8a0", "#c8a8d8",
];
// ── Light celebration: a small one-shot confetti burst from an anchor ──
function celebrate(anchorEl) {
if (REDUCED_MOTION) return;
var rect = anchorEl.getBoundingClientRect();
var originX = rect.left + rect.width / 2;
var originY = rect.top + rect.height / 2;
var canvas = document.createElement("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.cssText =
"position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:2147483646;";
document.body.appendChild(canvas);
var ctx = canvas.getContext("2d");
var particles = [];
for (var i = 0; i < 48; i++) {
var ang = (Math.PI * 2 * i) / 48 + Math.random() * 0.4;
var speed = Math.random() * 4 + 2.5;
particles.push({
x: originX,
y: originY,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed - 2,
w: Math.random() * 7 + 3,
h: Math.random() * 4 + 2,
rot: Math.random() * 360,
rotV: (Math.random() - 0.5) * 12,
color: CELEBRATE_COLORS[Math.floor(Math.random() * CELEBRATE_COLORS.length)],
});
}
var start = performance.now();
var DURATION = 1400;
function tick(now) {
var t = now - start;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (t >= DURATION) {
canvas.remove();
return;
}
var fade = 1 - t / DURATION;
for (var j = 0; j < particles.length; j++) {
var p = particles[j];
p.vy += 0.12; // gravity
p.vx *= 0.99;
p.x += p.vx;
p.y += p.vy;
p.rot += p.rotV;
ctx.save();
ctx.globalAlpha = Math.max(0, fade);
ctx.translate(p.x, p.y);
ctx.rotate((p.rot * Math.PI) / 180);
ctx.fillStyle = p.color;
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.restore();
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
// ── Validators ──
function isValidEmail(v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
}
function isValidWhatsApp(v) {
// Lenient: international numbers, allow + and separators, require 7-15 digits.
var digits = v.replace(/[^\d]/g, "");
return digits.length >= 7 && digits.length <= 15;
}
function normalizeWhatsApp(v) {
var trimmed = v.trim().replace(/[^\d+]/g, "");
return trimmed;
}
function initKeepInTouch(root) {
var widgets = (root || document).querySelectorAll(".toa-kit");
widgets.forEach(function (widget) {
if (widget.dataset.kitBound === "1") return;
// Already subscribed in a previous session — hide immediately.
if (localStorage.getItem(SUBSCRIBED_KEY)) {
widget.classList.add("toa-kit--done");
return;
}
widget.dataset.kitBound = "1";
var form = widget.querySelector(".toa-kit__form");
var notice = widget.querySelector(".toa-kit__notice");
var sendBtn = widget.querySelector(".toa-kit__send");
var endpoint =
widget.getAttribute("data-endpoint") ||
window.TOA_KEEP_IN_TOUCH_ENDPOINT ||
"";
var fields = {
whatsapp: widget.querySelector('[data-kind="whatsapp"]'),
email: widget.querySelector('[data-kind="email"]'),
name: widget.querySelector('[data-kind="name"]'),
};
var inputs = {
whatsapp: fields.whatsapp.querySelector("input"),
email: fields.email.querySelector("input"),
name: fields.name.querySelector("input"),
};
// Track which channels have been captured this session.
var captured = { whatsapp: false, email: false, name: false };
function showNotice(msg, tone) {
notice.textContent = msg;
notice.setAttribute("data-tone", tone || "success");
notice.hidden = false;
}
function markField(kind, state) {
var f = fields[kind];
if (!f) return;
f.removeAttribute("data-valid");
f.removeAttribute("data-invalid");
if (state === "valid") f.setAttribute("data-valid", "true");
if (state === "invalid") f.setAttribute("data-invalid", "true");
}
function maybeRevealName() {
if (captured.whatsapp && captured.email && fields.name.hidden) {
fields.name.hidden = false;
inputs.name.focus();
showNotice(
"Lovely — one more thing: what should we call you?",
"success"
);
}
}
function buildPayload() {
return {
whatsapp: inputs.whatsapp.value
? normalizeWhatsApp(inputs.whatsapp.value)
: "",
email: inputs.email.value.trim(),
name: inputs.name.value.trim(),
page: window.location.pathname,
url: window.location.href,
ts: new Date().toISOString(),
};
}
// POST the current known set. Returns a promise resolving true on success.
function send(payload) {
if (!endpoint) {
// No backend wired yet — treat as success locally so the UX still
// works, but log a clear warning for the developer.
console.warn(
"[keep-in-touch] No endpoint configured " +
"(config.extra.keep_in_touch.endpoint). Captured locally only:",
payload
);
return Promise.resolve(true);
}
return fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "omit",
mode: "cors",
})
.then(function (res) {
return res.ok;
})
.catch(function (err) {
console.error("[keep-in-touch] submit failed:", err);
return false;
});
}
function handleSubmit() {
// Determine what's newly fillable / valid.
var wVal = inputs.whatsapp.value.trim();
var eVal = inputs.email.value.trim();
var nVal = inputs.name.value.trim();
var hasWhatsApp = wVal && isValidWhatsApp(wVal);
var hasEmail = eVal && isValidEmail(eVal);
// Validation feedback on filled-but-invalid fields.
if (wVal && !hasWhatsApp) { markField("whatsapp", "invalid"); }
if (eVal && !hasEmail) { markField("email", "invalid"); }
// Require at least one valid contact channel before first submit.
if (!hasWhatsApp && !hasEmail && !captured.whatsapp && !captured.email) {
showNotice(
"Add a WhatsApp number or email and well keep you posted.",
"error"
);
return;
}
if (hasWhatsApp) markField("whatsapp", "valid");
if (hasEmail) markField("email", "valid");
var payload = buildPayload();
sendBtn.disabled = true;
send(payload).then(function (ok) {
sendBtn.disabled = false;
if (!ok) {
showNotice(
"Hmm, that didnt go through. Please try again in a moment.",
"error"
);
return;
}
var firstWhatsApp = hasWhatsApp && !captured.whatsapp;
var firstEmail = hasEmail && !captured.email;
var firstName = nVal && !captured.name;
if (hasWhatsApp) captured.whatsapp = true;
if (hasEmail) captured.email = true;
if (nVal) captured.name = true;
// Celebrate on a fresh capture.
if (firstWhatsApp || firstEmail || firstName) {
celebrate(sendBtn);
}
if (firstName) {
showNotice(
"Thank you, " + nVal + " — you're all set. 🌱",
"success"
);
// Hide widget after a few seconds so the reader can see the message.
setTimeout(function () {
localStorage.setItem(SUBSCRIBED_KEY, "1");
widget.classList.add("toa-kit--done");
}, 3500);
} else if (captured.whatsapp && captured.email && fields.name.hidden === false && !captured.name) {
showNotice("Thanks! And your name, whenever you like.", "success");
} else {
showNotice(
"Thank you — well be in touch with updates.",
"success"
);
maybeRevealName();
}
});
}
form.addEventListener("submit", function (e) {
e.preventDefault();
handleSubmit();
});
// Clear invalid state as the user edits.
["whatsapp", "email"].forEach(function (kind) {
inputs[kind].addEventListener("input", function () {
fields[kind].removeAttribute("data-invalid");
});
});
});
}
function readyKit(fn) {
if (typeof document$ !== "undefined") {
document$.subscribe(function () {
fn();
});
} else if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", fn);
} else {
fn();
}
}
readyKit(function () {
initKeepInTouch(document);
});
})();