/** * 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(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } })(); /* ═══════════════════════════════════════════════════════════════════════════ Action Points — Interactive Checklist ═══════════════════════════════════════════════════════════════════════ */ ;(function () { "use strict"; // ── Constants ────────────────────────────────────── var STORAGE_KEY = "toa-action-points"; var VERSION = 2; var REDUCED_MOTION = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; function todayStr() { return new Date().toISOString().slice(0, 10); } // ── Storage ──────────────────────────────────────── function getStorage() { try { var raw = localStorage.getItem(STORAGE_KEY); if (!raw) return _fresh(); var data = JSON.parse(raw); if (data.version !== VERSION) return _migrate(data); return data; } catch (e) { return _fresh(); } } function _fresh() { return { version: VERSION, pages: {} }; } function _migrate(old) { // v1 → v2: add completedDates tracking var data = _fresh(); if (old.pages) { Object.keys(old.pages).forEach(function (path) { var op = old.pages[path]; data.pages[path] = { lastUpdated: op.lastUpdated || null, checked: op.checked || [], completedDates: {}, _checked: op._checked, _total: op._total, }; // Seed completedDates from checked array (assume checked=today) var td = todayStr(); (op.checked || []).forEach(function (label) { data.pages[path].completedDates[label] = [td]; }); }); } saveStorage(data); return data; } function saveStorage(data) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) { /* quota exceeded — silently drop */ } } function getPagePath() { var p = location.pathname; if (p.slice(-1) !== "/") p += "/"; return p; } function getNormLabel(li) { // Clone li, remove the task-list-control label, get remaining text var clone = li.cloneNode(true); var ctrl = clone.querySelector(".task-list-control"); if (ctrl) ctrl.remove(); return (clone.textContent || "").replace(/\s+/g, " ").trim().toLowerCase(); } function getPageData(path) { var store = getStorage(); if (!store.pages[path]) { store.pages[path] = { checked: [], completedDates: {}, lastUpdated: null }; } if (!store.pages[path].completedDates) { store.pages[path].completedDates = {}; } return store.pages[path]; } function setPageChecked(path, label, checked) { var store = getStorage(); if (!store.pages[path]) { store.pages[path] = { checked: [], completedDates: {}, lastUpdated: null }; } var page = store.pages[path]; if (!page.completedDates) page.completedDates = {}; if (!page.completedDates[label]) page.completedDates[label] = []; var td = todayStr(); if (checked) { if (page.checked.indexOf(label) === -1) page.checked.push(label); if (page.completedDates[label].indexOf(td) === -1) { page.completedDates[label].push(td); } } else { page.checked = page.checked.filter(function (l) { return l !== label; }); page.completedDates[label] = page.completedDates[label].filter(function (d) { return d !== td; }); } page.lastUpdated = new Date().toISOString(); saveStorage(store); } // Persist page summary (checked count, total) for nav aggregation function savePageSummary(path, checkedCount, total) { var store = getStorage(); if (!store.pages[path]) { store.pages[path] = { checked: [], lastUpdated: null }; } store.pages[path]._checked = checkedCount; store.pages[path]._total = total; // Don't bump lastUpdated for summary-only writes saveStorage(store); } function getPageSummary(path) { var store = getStorage(); var page = store.pages[path]; if (!page || page._total === undefined) return null; return { checked: page._checked || 0, total: page._total || 0 }; } // ── Build-time totals manifest ───────────────────── // toa-totals.json maps page-URL -> task count, generated at build time // (see main.py). Seeding _total for every page lets nav badges show // correct totals on FIRST load, before the user has visited each page. // Fetched once per session; cached by the browser thereafter. var _totalsLoaded = false; function seedTotalsFromManifest(cb) { if (_totalsLoaded) { cb && cb(false); return; } _totalsLoaded = true; // mark immediately so concurrent navs don't double-fetch // Resolve the manifest relative to the site root. The script tag for // extra.js is loaded from /javascripts/extra.js, so derive the URL // from document scripts to stay correct under any base_url. var url = "javascripts/toa-totals.json"; try { var scripts = document.querySelectorAll('script[src*="javascripts/extra.js"]'); if (scripts.length) { url = scripts[0].src.replace(/extra\.js.*$/, "toa-totals.json"); } else { // Fallback: resolve from origin url = new URL("javascripts/toa-totals.json", location.origin + "/").href; } } catch (e) { /* use relative fallback */ } fetch(url, { cache: "force-cache" }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (totals) { if (!totals) { cb && cb(false); return; } var store = getStorage(); var changed = false; Object.keys(totals).forEach(function (p) { var path = p.slice(-1) === "/" ? p : p + "/"; if (!store.pages[path]) { store.pages[path] = { checked: [], completedDates: {}, lastUpdated: null }; } // Only seed _total; never clobber _checked (real progress wins). if (store.pages[path]._total !== totals[p]) { store.pages[path]._total = totals[p]; changed = true; } if (store.pages[path]._checked === undefined) { store.pages[path]._checked = (store.pages[path].checked || []).length; } }); if (changed) saveStorage(store); cb && cb(true); }) .catch(function () { cb && cb(false); }); } // ── Checkbox initialization ──────────────────────── function initCheckboxes() { var path = getPagePath(); var pageData = getPageData(path); var items = document.querySelectorAll("li.task-list-item"); if (!items.length) return { checked: 0, total: 0 }; var total = items.length; var checkedCount = 0; items.forEach(function (li) { var cb = li.querySelector('input[type="checkbox"]'); if (!cb) return; var label = getNormLabel(li); var isChecked = pageData.checked.indexOf(label) !== -1; // Enable checkbox cb.disabled = false; cb.checked = isChecked; if (isChecked) checkedCount++; cb.addEventListener("change", function () { var now = cb.checked; setPageChecked(path, label, now); // Recompute & re-render var counts = computeHeadingCounts(); savePageSummary(path, counts.pageChecked, counts.pageTotal); renderHeadingPills(counts); updatePageHeading(counts); updateNavBadges(); checkCompletions(counts); }); }); return { checked: checkedCount, total: total }; } // ── Heading counters ─────────────────────────────── function computeHeadingCounts() { var article = document.querySelector("article"); if (!article) return { groups: [], pageChecked: 0, pageTotal: 0 }; var groups = []; var current = null; // Tree-walk: assign items to the nearest preceding h2/h3 in document order. // This is immune to compareDocumentPosition quirks. function walk(node) { if (node.nodeType !== 1) return; // skip non-elements var tag = node.tagName; if (tag === "H2" || tag === "H3") { current = node; groups.push({ heading: node, items: [], level: parseInt(tag[1]) }); } else if (tag === "LI" && node.classList.contains("task-list-item")) { if (current) { groups[groups.length - 1].items.push(node); } } // Recurse into children var children = node.childNodes; for (var i = 0; i < children.length; i++) { walk(children[i]); } } // Walk article's children (skip article itself to avoid h1) var kids = article.childNodes; for (var i = 0; i < kids.length; i++) { walk(kids[i]); } // Compute checked counts var pageChecked = 0; var pageTotal = 0; groups.forEach(function (g) { var checked = 0; g.items.forEach(function (li) { var cb = li.querySelector('input[type="checkbox"]'); if (cb && cb.checked) checked++; }); g.checked = checked; g.total = g.items.length; pageChecked += checked; pageTotal += g.items.length; }); return { groups: groups, pageChecked: pageChecked, pageTotal: pageTotal }; } function renderHeadingPills(counts) { // Clear previous data attributes from all article headings document.querySelectorAll("article h2, article h3").forEach(function (h) { h.removeAttribute("data-toa-count"); h.removeAttribute("data-toa-complete"); h.classList.remove("toa-heading--complete"); }); counts.groups.forEach(function (g) { if (g.total === 0) return; g.heading.setAttribute("data-toa-count", g.checked + "/" + g.total); if (g.checked === g.total) { g.heading.setAttribute("data-toa-complete", "true"); g.heading.classList.add("toa-heading--complete"); } }); } function updatePageHeading(counts) { var h1 = document.querySelector("article h1"); if (!h1) return; h1.removeAttribute("data-toa-count"); h1.removeAttribute("data-toa-complete"); h1.classList.remove("toa-heading--complete"); if (counts.pageTotal === 0) return; h1.setAttribute("data-toa-count", counts.pageChecked + "/" + counts.pageTotal); if (counts.pageChecked === counts.pageTotal) { h1.setAttribute("data-toa-complete", "true"); h1.classList.add("toa-heading--complete"); } } // ── Completion detection ─────────────────────────── var _completedHeadings = {}; var _pageWasComplete = false; function checkCompletions(counts) { var path = getPagePath(); // Check heading completions counts.groups.forEach(function (g) { if (g.total === 0) return; var id = g.heading.id || g.heading.textContent.replace(/\s+/g, "_"); var justCompleted = g.checked === g.total && !_completedHeadings[id]; _completedHeadings[id] = g.checked === g.total; if (justCompleted) { fireHeadingComplete(); } }); // Check page completion var pageComplete = counts.pageTotal > 0 && counts.pageChecked === counts.pageTotal; if (pageComplete && !_pageWasComplete) { firePageComplete(); } _pageWasComplete = pageComplete; // Save summary for nav aggregation savePageSummary(path, counts.pageChecked, counts.pageTotal); } // ── Confetti ─────────────────────────────────────── function fireConfetti(intensity) { if (REDUCED_MOTION) return; var canvas = document.createElement("canvas"); canvas.style.cssText = "position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:99999;"; document.body.appendChild(canvas); canvas.width = window.innerWidth; canvas.height = window.innerHeight; var ctx = canvas.getContext("2d"); var COLORS = ["#4a8262", "#a8c5b6", "#3d6b52", "#e9dfc6", "#92ad9e", "#d4e8dc"]; var count = intensity === "page" ? 140 : 55; var duration = intensity === "page" ? 3200 : 2200; var drift = intensity === "page" ? 1.8 : 1.2; var particles = []; for (var i = 0; i < count; i++) { particles.push({ x: Math.random() * canvas.width, y: -(Math.random() * 60 + 10), w: Math.random() * 7 + 2, h: Math.random() * 4 + 1.5, color: COLORS[Math.floor(Math.random() * COLORS.length)], vx: (Math.random() - 0.5) * drift, vy: Math.random() * 2 + 1.2, rot: Math.random() * 360, rotV: (Math.random() - 0.5) * 8, opacity: 1, }); } var start = performance.now(); function frame(now) { var elapsed = now - start; var progress = elapsed / duration; if (progress >= 1) { canvas.remove(); return; } ctx.clearRect(0, 0, canvas.width, canvas.height); // Fade out in last 30% var fade = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1; particles.forEach(function (p) { p.x += p.vx; p.vy += 0.12; p.y += p.vy; p.rot += p.rotV; p.opacity = fade; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate((p.rot * Math.PI) / 180); ctx.globalAlpha = p.opacity; ctx.fillStyle = p.color; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); ctx.restore(); }); requestAnimationFrame(frame); } requestAnimationFrame(frame); } // ── Sound ────────────────────────────────────────── var _audioCtx = null; function getAudioCtx() { if (!_audioCtx) { _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (_audioCtx.state === "suspended") { _audioCtx.resume(); } return _audioCtx; } function playTone(freq, duration, when) { if (REDUCED_MOTION) return; try { var ctx = getAudioCtx(); var osc = ctx.createOscillator(); var gain = ctx.createGain(); var t = when || ctx.currentTime; osc.type = "sine"; osc.frequency.setValueAtTime(freq, t); gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.3, t + 0.04); gain.gain.exponentialRampToValueAtTime(0.001, t + duration); osc.connect(gain); gain.connect(ctx.destination); osc.start(t); osc.stop(t + duration); } catch (e) { /* Web Audio not available */ } } function fireHeadingComplete() { playTone(432, 1.6); fireConfetti("heading"); } function firePageComplete() { var ctx; try { ctx = getAudioCtx(); } catch (e) { fireConfetti("page"); return; } // Ascending arpeggio centered on 528 Hz var notes = [528 * 0.667, 528 * 0.75, 528 * 0.833, 528]; var t = ctx.currentTime; notes.forEach(function (freq, i) { playTone(freq, 0.7, t + i * 0.18); }); fireConfetti("page"); } // ── Navigation badges ────────────────────────────── function updateNavBadges() { // Pause observer during our own DOM changes to prevent feedback loops if (_navObserver) _navObserver.disconnect(); // Remove old badges document.querySelectorAll(".toa-nav-badge").forEach(function (el) { el.remove(); }); var store = getStorage(); // Only badge PAGE links in the primary navigation. Exclude: // - the secondary nav ("On this page" / table-of-contents), whose links // are in-page #anchors and would all resolve to the current page total // - any link whose href is/contains a # fragment (same reason) var navLinks = document.querySelectorAll( ".md-nav--primary .md-nav__link[href]" ); navLinks.forEach(function (link) { var href = link.getAttribute("href"); // Skip in-page anchor links (TOC entries, heading permalinks). if (!href || href.charAt(0) === "#" || href.indexOf("#") !== -1) return; // Defensive: skip if this link lives in a secondary (TOC) nav. if (link.closest(".md-nav--secondary")) return; // Resolve relative URLs to absolute paths var url; try { url = new URL(href, location.origin + location.pathname).pathname; } catch (e) { return; } if (url.slice(-1) !== "/") url += "/"; // Check if this is a section (index) page with child pages. // NOTE: Material wraps the ACTIVE page's .md-nav__item with a nested //