/**
 * 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 we’ll 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 didn’t 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 — we’ll 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);
  });
})();
