up/docs/javascripts/extra.js
Tolaria 9f179b1ead
All checks were successful
Deploy Docs / deploy (push) Successful in 14s
Pages and keep in touch updates
2026-06-04 20:22:49 +08:00

1250 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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();
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);
})();
/**
* ─────────────────────────────────────────────────────────────────────
* 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);
});
})();