diff --git a/docs/javascripts/extra.js b/docs/javascripts/extra.js index 717012f..81b94fa 100644 --- a/docs/javascripts/extra.js +++ b/docs/javascripts/extra.js @@ -95,6 +95,13 @@ ;(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; @@ -107,15 +114,30 @@ } // ── 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) return _fresh(); + if (!raw) { + _storeCache = _fresh(); + return _storeCache; + } var data = JSON.parse(raw); - if (data.version !== VERSION) return _migrate(data); - return data; + if (data.version !== VERSION) { + _storeCache = _migrate(data); + return _storeCache; + } + _storeCache = data; + return _storeCache; } catch (e) { - return _fresh(); + _storeCache = _fresh(); + return _storeCache; } } @@ -148,6 +170,7 @@ } function saveStorage(data) { + _storeCache = data; // keep the in-memory cache authoritative try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) { @@ -636,11 +659,6 @@ // 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 @@ -652,16 +670,29 @@ 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) return; + 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")) return; + 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 += "/"; @@ -689,19 +720,34 @@ badge = getPageSummary(url); } - if (!badge || badge.total === 0) return; + if (!badge || badge.total === 0) { + if (existing) existing.remove(); + link.classList.remove("toa-nav--complete"); + return; + } - var span = document.createElement("span"); - span.className = "toa-nav-badge"; - if (badge.checked === badge.total) { - span.className += " toa-nav-badge--complete"; - span.textContent = "\u2713"; // ✓ - link.classList.add("toa-nav--complete"); - } else { - span.textContent = badge.checked + "/" + badge.total; + 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"); } - link.appendChild(span); }); // Resume observer diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 3b92068..7e63235 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -646,6 +646,10 @@ h2[data-toa-complete="true"]::after, h3[data-toa-complete="true"]::after { background: #3d6b52; color: #fff !important; + /* Headings set -webkit-text-fill-color (gradient/fill effect); pseudo-els + inherit it and it overrides `color`. Override it on the pill ONLY so the + count text is white on green — the heading's own text color is untouched. */ + -webkit-text-fill-color: #fff !important; border-color: #3d6b52; } @@ -678,6 +682,7 @@ h3[data-toa-complete="true"]::after { .toa-nav-badge--complete { background: #3d6b52; color: #fff !important; + -webkit-text-fill-color: #fff !important; border-color: #3d6b52; } @@ -686,6 +691,7 @@ h3[data-toa-complete="true"]::after { !important color. */ .md-nav__link.toa-nav--complete .toa-nav-badge--complete { color: #fff !important; + -webkit-text-fill-color: #fff !important; } .md-nav__link.toa-nav--complete { diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..df142f8 --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{# + Action Points feature flag. + + Exposes the zensical.toml setting `[project.extra.action_points] enabled` + to client-side JS as `window.TOA_ACTION_POINTS_ENABLED`. This block renders + in , before extra_javascript loads in {% block scripts %}, so the + action-points engine in docs/javascripts/extra.js can bail out immediately + when the feature is disabled. + + Set `enabled = false` in zensical.toml to turn the ENTIRE interactive + checklist feature off — no localStorage, no nav badges, no confetti, no + per-click work. Checkboxes then fall back to Material's default read-only + rendering. Defaults to ON when the key is absent. +#} +{% block extrahead %} + {{ super() }} + +{% endblock %} diff --git a/zensical.toml b/zensical.toml index 25db022..484c553 100644 --- a/zensical.toml +++ b/zensical.toml @@ -312,6 +312,17 @@ host = "https://cusdis.krystl.org" app_id = "d9ab4b66-d5f1-4514-a475-1a824f69530f" lang = "en" +# ---------------------------------------------------------------------------- +# Action Points — interactive checklist feature flag. +# Set enabled = false to disable the ENTIRE feature (localStorage progress, +# nav badges, heading pills, confetti, and all per-click JS). Checkboxes then +# render as Material's default read-only tasklist. The flag is exposed to the +# client via overrides/main.html as window.TOA_ACTION_POINTS_ENABLED. +# ---------------------------------------------------------------------------- +[project.extra.action_points] +enabled = true + + #[[project.extra.social]] #icon = "fontawesome/brands/github" #link = "https://github.com/user/repo"