diff --git a/docs/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md
similarity index 100%
rename from docs/RELEASE_CHECKLIST.md
rename to RELEASE_CHECKLIST.md
diff --git a/docs/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md
similarity index 100%
rename from docs/RELEASE_RUNBOOK.md
rename to RELEASE_RUNBOOK.md
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 00000000..b59504cf
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,9 @@
+# Generated / local-only artifacts.
+.venv/
+site/
+
+# Doc pages generated from the codebase by gen_pages.py (real files on disk now,
+# since Zensical builds from disk rather than a plugin's virtual filesystem).
+content/reference/
+content/guides/
+content/frontend/
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..3caba189
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,63 @@
+# docs
+
+A self-contained, local-only documentation generator built with
+[**Zensical**](https://zensical.org/) (the Material for MkDocs team's Rust-based
+successor to MkDocs). It builds a static site that **mirrors the codebase** —
+pages are generated from docstrings, READMEs, and the frontend source on every
+run, so the docs stay in sync as the code changes. Same "drop-in folder, re-run
+to refresh" spirit as `dependency-graph/`.
+
+## Use it
+
+```bash
+./docs/run.sh # build the site and open it
+./docs/run.sh --serve # live-reloading dev server (great while writing docstrings)
+```
+
+The first run creates an isolated `docs/.venv` and installs the doc tooling
+there. Output lands in `docs/site/` (git-ignored).
+
+## What it pulls in
+
+| Source | Becomes (`content/…`) |
+| --- | --- |
+| `backend/**/*.py` docstrings | `reference/` — one [mkdocstrings](https://mkdocstrings.github.io/) page per module. |
+| `frontend/src` (TSDoc) | `frontend/` — [TypeDoc](https://typedoc.org/) reference (best-effort; needs Node + one-time network). |
+| Every `README.md`, `implementation_plan.md`, `relevant_context.md`, `frontend/DESIGN.md` | `guides/` — copied verbatim. |
+
+Add a module or a README anywhere and it appears on the next run — Zensical
+**infers the navigation from the directory tree**, so there's no nav to maintain.
+
+## How it differs from a plain Zensical site
+
+Zensical doesn't run MkDocs plugins (no `mkdocs-gen-files` / `mkdocs-literate-nav`).
+So instead of generating pages inside the build, `gen_pages.py` runs **before** the
+build as a plain script and writes **real Markdown files** into `content/reference/`,
+`content/guides/`, and `content/frontend/`. Those three directories are wiped and rebuilt
+each run and are git-ignored; only `content/index.md` is hand-written.
+
+## Files
+
+| file | role |
+| --- | --- |
+| `run.sh` | bootstraps the venv, runs TypeDoc, generates pages, builds/serves, opens the site. |
+| `zensical.toml` | site config (theme, markdown extensions, mkdocstrings options). |
+| `gen_pages.py` | the engine: walks the repo and writes the `content/` page tree (pure stdlib). |
+| `content/index.md` | the one hand-written page (the landing page). |
+| `.venv/`, `site/`, `content/{reference,guides,frontend}/` | generated, git-ignored. |
+
+## Knobs
+
+- **Docstring style**: `zensical.toml` → `[project.plugins.mkdocstrings.handlers.python.options]` → `docstring_style` (currently `google`).
+- **Skip directories / featured loose docs**: `SKIP_DIRS` and the `curated` list in `gen_pages.py`.
+- **Theme & navigation features**: `[project.theme]` in `zensical.toml`.
+- **Markdown extensions**: the `[project.markdown_extensions.*]` tables.
+
+## Notes
+
+- The **HTTP API** reference is no longer baked into this site — Zensical can't run
+ the Swagger plugin yet. Use FastAPI's built-in `/docs` (Swagger) or `/redoc`.
+- mkdocstrings support in Zensical is **preliminary** (no cross-references/backlinks
+ yet); the rest renders normally. Track progress at
+ [zensical.org/docs/setup/extensions/mkdocstrings](https://zensical.org/docs/setup/extensions/mkdocstrings/).
+- The site is a static snapshot — re-run `run.sh` to refresh.
diff --git a/docs/content/assets/logo.png b/docs/content/assets/logo.png
new file mode 100644
index 00000000..87747611
Binary files /dev/null and b/docs/content/assets/logo.png differ
diff --git a/docs/content/index.md b/docs/content/index.md
new file mode 100644
index 00000000..62c82bdb
--- /dev/null
+++ b/docs/content/index.md
@@ -0,0 +1,31 @@
+# Product Analytics — Documentation
+
+This site is **auto-generated from the codebase** and built with
+[Zensical](https://zensical.org/). Nothing here is written by hand except this
+landing page — every other page is generated from the sources below before each
+build, so the docs stay in sync as the code changes.
+
+Re-run `./docs/run.sh` (or `zensical serve -o` from `docs/` for a
+live preview) to refresh.
+
+## What's in here
+
+- **reference** — one page per Python module under `backend/`, rendered from the
+ module/class/function docstrings via mkdocstrings.
+- **frontend** — TypeDoc reference for the React/TypeScript app under `frontend/`
+ (present only when TypeDoc ran during generation).
+- **guides** — every Markdown doc in the repo: the root `README`, nested
+ `README.md` files, `implementation_plan.md`, `relevant_context.md`, and
+ `frontend/DESIGN.md`.
+
+## HTTP API
+
+The interactive HTTP API reference is served by the backend itself — run it and
+open **`/docs`** (FastAPI's built-in Swagger UI) or **`/redoc`**.
+
+## How it works
+
+`gen_pages.py` walks the repository on each build and writes real Markdown files
+into `content/reference/`, `content/guides/`, and `content/frontend/`. Zensical then infers
+the navigation from that directory tree. See `docs/README.md` for the full
+layout and knobs.
diff --git a/docs/content/javascripts/search-suggestions.js b/docs/content/javascripts/search-suggestions.js
new file mode 100644
index 00000000..12efae2c
--- /dev/null
+++ b/docs/content/javascripts/search-suggestions.js
@@ -0,0 +1,204 @@
+/*
+ * Default search suggestions.
+ *
+ * Zensical's search modal (a Preact component living in an open shadow root on a
+ *
appended to ) shows nothing until you type. This sprinkles a small
+ * curated list of "suggested pages" into the modal whenever the query is empty —
+ * the command-palette behaviour you get from most search modals.
+ *
+ * It only ever *adds* nodes (reusing the modal's own class names so they inherit
+ * its native styling) and re-attaches them after Preact re-renders. Everything is
+ * wrapped in try/catch so a future Zensical change can, at worst, fall back to the
+ * stock empty state — it can never break the real search.
+ */
+(function () {
+ "use strict";
+
+ // Curated entries shown on an empty query. `href` is resolved against the
+ // site's base, so these work from any (including deeply nested) page.
+ var SUGGESTIONS = [
+ { title: "Documentation home", path: ["Home"], href: "index.html" },
+ { title: "API Reference", path: ["reference"], href: "reference/index.html" },
+ { title: "Guides", path: ["guides"], href: "guides/index.html" },
+ { title: "Frontend reference", path: ["frontend"], href: "frontend/index.html" },
+ { title: "Implementation plan", path: ["guides"], href: "guides/implementation_plan.html" }
+ ];
+
+ var MARK = "data-os-suggest";
+
+ function siteBase() {
+ try {
+ var cfg = JSON.parse(document.getElementById("__config").textContent);
+ return String(cfg.base || ".").replace(/\/?$/, "/");
+ } catch (e) {
+ return "./";
+ }
+ }
+ var BASE = siteBase();
+
+ function resolve(p) {
+ try {
+ return new URL(BASE + p, location.href).href;
+ } catch (e) {
+ return p;
+ }
+ }
+
+ function el(doc, tag, cls) {
+ var n = doc.createElement(tag);
+ if (cls) n.className = cls;
+ n.setAttribute(MARK, "");
+ return n;
+ }
+
+ // Mirror the result-item markup the bundle emits:
of
+ // title
+ // .
+ function buildNodes(doc) {
+ var frag = doc.createDocumentFragment();
+
+ var heading = el(doc, "h3", "A");
+ heading.style.opacity = "0.6";
+ heading.textContent = "Suggested pages";
+ frag.appendChild(heading);
+
+ var ol = el(doc, "ol", "b");
+ SUGGESTIONS.forEach(function (s) {
+ var li = el(doc, "li");
+ var a = el(doc, "a", "a");
+ a.href = resolve(s.href);
+
+ var wrap = el(doc, "div", "B");
+ var h2 = el(doc, "h2", "x");
+ h2.textContent = s.title;
+ var menu = el(doc, "menu", "t");
+ (s.path || []).forEach(function (seg) {
+ var pli = el(doc, "li");
+ pli.textContent = seg;
+ menu.appendChild(pli);
+ });
+
+ wrap.appendChild(h2);
+ wrap.appendChild(menu);
+ a.appendChild(wrap);
+ li.appendChild(a);
+ ol.appendChild(li);
+ });
+ frag.appendChild(ol);
+ return frag;
+ }
+
+ function wire(host) {
+ var root = host.shadowRoot;
+ if (!root || root.__osSuggestWired) return;
+ var input = root.querySelector("input[role=combobox]");
+ if (!input) return;
+ root.__osSuggestWired = true;
+
+ var doc = host.ownerDocument || document;
+ var observer;
+ var scheduled = false;
+
+ // `.e` is the modal's dialog container; its last child is the scrollable
+ // results body where real results render, so we slot suggestions in there.
+ function dialog() {
+ return root.querySelector(".e");
+ }
+ function body() {
+ var d = dialog();
+ return (d && d.lastElementChild) || d;
+ }
+ function isEmpty() {
+ return !input.value || !input.value.trim();
+ }
+
+ function apply() {
+ try {
+ var parent = body();
+ if (!parent) return;
+ var present = root.querySelector("ol[" + MARK + "]");
+ if (isEmpty()) {
+ if (!present) {
+ if (observer) observer.disconnect();
+ parent.appendChild(buildNodes(doc));
+ reobserve();
+ }
+ } else if (present) {
+ if (observer) observer.disconnect();
+ root.querySelectorAll("[" + MARK + "]").forEach(function (n) {
+ if (n.tagName !== "STYLE") n.remove();
+ });
+ reobserve();
+ }
+ } catch (e) {
+ /* never break native search */
+ }
+ }
+
+ function schedule() {
+ if (scheduled) return;
+ scheduled = true;
+ requestAnimationFrame(function () {
+ scheduled = false;
+ apply();
+ });
+ }
+
+ function reobserve() {
+ var d = dialog();
+ if (!observer || !d) return;
+ observer.observe(d, { childList: true, subtree: true });
+ }
+
+ // A little extra room so the suggestion-only state reads as intentional.
+ var style = doc.createElement("style");
+ style.setAttribute(MARK, "");
+ style.textContent =
+ "h3[" + MARK + "]{margin:0;padding:.5em .8em .25em;font-weight:600}" +
+ "[" + MARK + "] a{cursor:pointer}";
+ root.appendChild(style);
+
+ input.addEventListener("input", schedule, true);
+
+ observer = new MutationObserver(schedule);
+ reobserve();
+
+ schedule();
+ setTimeout(apply, 60);
+ setTimeout(apply, 300);
+ }
+
+ function scan() {
+ try {
+ var kids = document.body ? document.body.children : [];
+ for (var i = 0; i < kids.length; i++) {
+ var node = kids[i];
+ if (node.shadowRoot && node.shadowRoot.querySelector &&
+ node.shadowRoot.querySelector("input[role=combobox]")) {
+ wire(node);
+ }
+ }
+ } catch (e) {
+ /* ignore */
+ }
+ }
+
+ function start() {
+ scan();
+ // The overlay is created during bundle init; watch for it (cheap:
+ // direct-children mutations only) and retry a few times as a safety net.
+ try {
+ new MutationObserver(scan).observe(document.body, { childList: true });
+ } catch (e) {
+ /* ignore */
+ }
+ setTimeout(scan, 300);
+ setTimeout(scan, 1200);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", start);
+ } else {
+ start();
+ }
+})();
diff --git a/docs/content/stylesheets/openswarm.css b/docs/content/stylesheets/openswarm.css
new file mode 100644
index 00000000..a47ef073
--- /dev/null
+++ b/docs/content/stylesheets/openswarm.css
@@ -0,0 +1,135 @@
+/*
+ * Anthropic-inspired theme for the Zensical (Material) docs site.
+ *
+ * Brand palette: a warm clay/coral accent on a soft ivory canvas in light mode,
+ * and the same clay against a warm charcoal in dark mode. Editorial serif
+ * headings (Fraunces) echo Anthropic's display type; the body keeps a clean
+ * humanist sans. Both schemes are driven by `primary = "custom"` in
+ * zensical.toml, so the values below are what actually paint the UI.
+ */
+
+@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600&display=swap");
+
+:root {
+ /* Core brand tones (shared across schemes). */
+ --anthropic-clay: #d97757;
+ --anthropic-clay-dark: #bd5d3a;
+ --anthropic-clay-light: #e6a085;
+ --anthropic-ivory: #faf9f5;
+ --anthropic-cloud: #f0eee6;
+ --anthropic-ink: #1a1915;
+ --anthropic-slate-bg: #262624;
+ --anthropic-slate-surface: #30302d;
+ --anthropic-slate-border: #3d3d39;
+
+ --md-text-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+/* ----------------------------------------------------------------------------
+ * Light scheme (default)
+ * ------------------------------------------------------------------------- */
+[data-md-color-scheme="default"] {
+ --md-primary-fg-color: var(--anthropic-clay);
+ --md-primary-fg-color--light: var(--anthropic-clay-light);
+ --md-primary-fg-color--dark: var(--anthropic-clay-dark);
+ --md-primary-bg-color: #ffffff;
+ --md-primary-bg-color--light: hsla(0, 0%, 100%, 0.72);
+
+ --md-accent-fg-color: var(--anthropic-clay-dark);
+ --md-accent-fg-color--transparent: hsla(15, 63%, 54%, 0.1);
+
+ --md-default-bg-color: var(--anthropic-ivory);
+ --md-default-fg-color: hsla(48, 12%, 9%, 0.92);
+ --md-default-fg-color--light: hsla(48, 12%, 9%, 0.62);
+ --md-default-fg-color--lighter: hsla(48, 12%, 9%, 0.34);
+ --md-default-fg-color--lightest: hsla(48, 12%, 9%, 0.1);
+
+ --md-typeset-a-color: var(--anthropic-clay-dark);
+ --md-code-bg-color: var(--anthropic-cloud);
+ --md-code-fg-color: #4a3a2e;
+ --md-footer-bg-color: #211f1a;
+ --md-footer-bg-color--dark: #1a1915;
+}
+
+/* ----------------------------------------------------------------------------
+ * Dark scheme (slate) — warmed toward Anthropic's charcoal
+ * ------------------------------------------------------------------------- */
+[data-md-color-scheme="slate"] {
+ /* Warm the derived greys away from Material's default blueish slate. */
+ --md-hue: 40;
+
+ --md-primary-fg-color: var(--anthropic-clay);
+ --md-primary-fg-color--light: var(--anthropic-clay-light);
+ --md-primary-fg-color--dark: var(--anthropic-clay-dark);
+ --md-primary-bg-color: #f7f4ee;
+ --md-primary-bg-color--light: hsla(40, 30%, 96%, 0.72);
+
+ --md-accent-fg-color: var(--anthropic-clay-light);
+
+ --md-default-bg-color: var(--anthropic-slate-bg);
+ --md-default-fg-color: hsla(44, 22%, 92%, 0.9);
+ --md-default-fg-color--light: hsla(44, 22%, 92%, 0.58);
+ --md-default-fg-color--lighter: hsla(44, 22%, 92%, 0.32);
+ --md-default-fg-color--lightest: hsla(44, 22%, 92%, 0.12);
+
+ --md-typeset-a-color: var(--anthropic-clay-light);
+ --md-code-bg-color: var(--anthropic-slate-surface);
+ --md-code-fg-color: #e8e2d6;
+ --md-footer-bg-color: #1c1b18;
+ --md-footer-bg-color--dark: #161512;
+}
+
+/* ----------------------------------------------------------------------------
+ * Typography — editorial serif headings, refined site title
+ * ------------------------------------------------------------------------- */
+.md-typeset h1,
+.md-typeset h2,
+.md-typeset h3,
+.md-header__topic > .md-ellipsis {
+ font-family: "Fraunces", Georgia, "Times New Roman", serif;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+}
+
+.md-typeset h1 {
+ font-weight: 600;
+ color: var(--md-default-fg-color);
+}
+
+/* ----------------------------------------------------------------------------
+ * Header & tabs — subtle depth and a hairline divider
+ * ------------------------------------------------------------------------- */
+.md-header {
+ box-shadow: 0 1px 0 hsla(40, 12%, 50%, 0.16);
+}
+
+.md-header--shadow {
+ box-shadow: 0 2px 12px hsla(40, 20%, 10%, 0.16);
+}
+
+/* ----------------------------------------------------------------------------
+ * Links, code, admonitions — small clay accents
+ * ------------------------------------------------------------------------- */
+.md-typeset a {
+ text-underline-offset: 0.15em;
+}
+
+.md-typeset code {
+ border-radius: 0.3rem;
+}
+
+.md-typeset pre > code {
+ border-radius: 0.4rem;
+}
+
+/* Active nav item gets the clay accent bar. */
+.md-nav__link--active,
+.md-nav__item .md-nav__link--active {
+ color: var(--md-typeset-a-color);
+ font-weight: 600;
+}
+
+/* Search input rounded to match the soft brand feel. */
+.md-search__form {
+ border-radius: 0.4rem;
+}
diff --git a/docs/gen_pages.py b/docs/gen_pages.py
new file mode 100644
index 00000000..f88d7f41
--- /dev/null
+++ b/docs/gen_pages.py
@@ -0,0 +1,144 @@
+"""Generate the doc-source tree from the repository (standalone pre-build step).
+
+Zensical doesn't run MkDocs plugins (no ``mkdocs-gen-files`` / ``literate-nav``),
+so instead of synthesizing virtual pages we write **real** Markdown files into
+``content/`` before ``zensical build`` runs. Zensical then infers the navigation
+from the directory structure, so the site still mirrors the codebase on every run.
+
+Pure standard library — run it with any Python ≥3.9:
+
+ python docs/gen_pages.py
+
+Sources:
+ 1. ``backend/**/*.py`` -> ``content/reference/...`` (``::: module`` for mkdocstrings)
+ 2. repo Markdown -> ``content/guides/...`` (READMEs + loose docs, verbatim)
+ 3. ``frontend/.typedoc`` -> ``content/frontend/...`` (TypeDoc output, if present)
+
+The three generated directories are wiped and rebuilt each run; ``content/index.md``
+(the hand-written landing page) is left untouched.
+"""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+REPO_ROOT = HERE.parent
+DOCS_DIR = HERE / "content"
+
+REFERENCE_DIR = DOCS_DIR / "reference"
+GUIDES_DIR = DOCS_DIR / "guides"
+FRONTEND_DIR = DOCS_DIR / "frontend"
+
+# Directory names we never walk into when collecting Markdown.
+SKIP_DIRS = {
+ ".git", ".venv", "venv", "node_modules", "__pycache__", "site",
+ ".pytest_cache", ".mypy_cache", "dist", "build", ".typedoc",
+}
+
+
+def _skipped(path: Path) -> bool:
+ return any(part in SKIP_DIRS for part in path.parts)
+
+
+def _write(dest: Path, text: str) -> None:
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_text(text, encoding="utf-8")
+
+
+def clean() -> None:
+ for d in (REFERENCE_DIR, GUIDES_DIR, FRONTEND_DIR):
+ if d.exists():
+ shutil.rmtree(d)
+
+
+# --- 1. Backend Python API -> mkdocstrings pages --------------------------
+
+def gen_backend_reference() -> int:
+ backend = REPO_ROOT / "backend"
+ if not backend.is_dir():
+ return 0
+ count = 0
+ for py in sorted(backend.rglob("*.py")):
+ if _skipped(py):
+ continue
+ parts = list(py.relative_to(REPO_ROOT).with_suffix("").parts) # backend, apps, ...
+ is_package = parts[-1] == "__init__"
+ if is_package:
+ parts = parts[:-1]
+ module = ".".join(parts) # e.g. backend.apps.export.snapshot
+ rel = parts[1:] # drop the leading "backend" for a flatter nav
+
+ if is_package:
+ # Package → section landing page (works with navigation.indexes).
+ dest = REFERENCE_DIR.joinpath(*rel, "index.md") if rel else REFERENCE_DIR / "index.md"
+ else:
+ dest = REFERENCE_DIR.joinpath(*rel).with_suffix(".md")
+
+ # No hand-written H1: mkdocstrings renders the heading, and the nav label
+ # is derived from the file/dir name (clean, short labels).
+ _write(dest, f"::: {module}\n")
+ count += 1
+ return count
+
+
+# --- 2. Repo Markdown (READMEs + loose docs) ------------------------------
+
+def gen_guides() -> int:
+ curated = [
+ REPO_ROOT / "README.md",
+ REPO_ROOT / "implementation_plan.md",
+ REPO_ROOT / "relevant_context.md",
+ REPO_ROOT / "frontend" / "DESIGN.md",
+ ]
+ nested_readmes = sorted(REPO_ROOT.rglob("README.md"))
+
+ seen: set[Path] = set()
+ count = 0
+ for md in [*curated, *nested_readmes]:
+ if not md.is_file() or md in seen or _skipped(md):
+ continue
+ if DOCS_DIR in md.parents: # never re-ingest our own generated tree
+ continue
+ seen.add(md)
+
+ rel = md.relative_to(REPO_ROOT)
+ # Map the project root README to the Guides landing page.
+ if rel == Path("README.md"):
+ dest = GUIDES_DIR / "index.md"
+ else:
+ dest = GUIDES_DIR / rel
+ _write(dest, md.read_text(encoding="utf-8"))
+ count += 1
+ return count
+
+
+# --- 3. Frontend TypeDoc output -------------------------------------------
+
+def gen_frontend() -> int:
+ typedoc = REPO_ROOT / "frontend" / ".typedoc"
+ if not typedoc.is_dir():
+ return 0
+ count = 0
+ for md in sorted(typedoc.rglob("*.md")):
+ rel = md.relative_to(typedoc)
+ if any(p in SKIP_DIRS for p in rel.parts):
+ continue
+ _write(FRONTEND_DIR / rel, md.read_text(encoding="utf-8"))
+ count += 1
+ return count
+
+
+def main() -> int:
+ clean()
+ n_api = gen_backend_reference()
+ n_guides = gen_guides()
+ n_front = gen_frontend()
+ print(f"gen_pages: {n_api} API pages, {n_guides} guides, {n_front} frontend pages "
+ f"written under {DOCS_DIR}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/docs/overrides/partials/header.html b/docs/overrides/partials/header.html
new file mode 100644
index 00000000..80c99e19
--- /dev/null
+++ b/docs/overrides/partials/header.html
@@ -0,0 +1,77 @@
+{#-
+ Local override of Zensical's partials/header.html.
+
+ The only change from the stock template is element order: the search block is
+ emitted *before* the palette (theme toggle) block, so the toggle renders to the
+ right of the search bar. Everything else mirrors the packaged partial — keep it
+ in sync if you upgrade Zensical (see
+ docs/.venv/.../zensical/templates/partials/header.html).
+-#}
+{% set class = "md-header" %}
+{% if "navigation.tabs.sticky" in features %}
+ {% set class = class ~ " md-header--shadow md-header--lifted" %}
+{% elif "navigation.tabs" not in features %}
+ {% set class = class ~ " md-header--shadow" %}
+{% endif %}
+
+
+ {% if "navigation.tabs.sticky" in features %}
+ {% if "navigation.tabs" in features %}
+ {% include "partials/tabs.html" %}
+ {% endif %}
+ {% endif %}
+
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 00000000..4633bc91
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,6 @@
+# Doc-site dependencies. Installed into docs/.venv by run.sh.
+# These never touch backend/.venv — docs tooling stays fully isolated.
+zensical # static site generator (Material for MkDocs team)
+mkdocstrings # API-reference engine (preliminary Zensical support)
+mkdocstrings-python # the Python handler (Griffe-based, ships separately)
+black # lets mkdocstrings pretty-format function signatures
diff --git a/docs/run.sh b/docs/run.sh
new file mode 100755
index 00000000..3ade5582
--- /dev/null
+++ b/docs/run.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+#
+# Build the documentation site and open it. Drop-in, local-only — same spirit as
+# dependency-graph/generate.sh. From the repo root (or anywhere), run:
+#
+# ./docs/run.sh # live-reloading dev server (default)
+# ./docs/run.sh --build # one-time build + open instead
+#
+# Built with Zensical (the Material for MkDocs team's successor to MkDocs).
+# Everything is isolated in docs/.venv.
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(dirname "$HERE")"
+cd "$REPO_ROOT"
+
+VENV="$HERE/.venv"
+PY="$VENV/bin/python"
+ZENSICAL="$VENV/bin/zensical"
+
+# 1. Bootstrap an isolated venv for the doc tooling.
+# Zensical/mkdocstrings need Python >= 3.10; pick the newest available.
+pick_python() {
+ for c in python3.13 python3.12 python3.11 python3.10 python3; do
+ local bin
+ bin="$(command -v "$c" 2>/dev/null)" || continue
+ if "$bin" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 10) else 1)' 2>/dev/null; then
+ echo "$bin"; return 0
+ fi
+ done
+ return 1
+}
+
+if [[ ! -x "$PY" ]]; then
+ BOOT_PY="$(pick_python)" || {
+ echo "docs: need Python >= 3.10 on PATH to build the docs" >&2
+ exit 1
+ }
+ echo "docs: creating venv at $VENV (using $BOOT_PY)"
+ "$BOOT_PY" -m venv "$VENV"
+fi
+echo "docs: installing/upgrading doc dependencies"
+"$PY" -m pip install -q --upgrade pip
+"$PY" -m pip install -q -r "$HERE/requirements.txt"
+
+# 2. Generate frontend TypeDoc markdown (best-effort; needs node + network once).
+if command -v npx >/dev/null 2>&1 && [[ -f "$REPO_ROOT/frontend/package.json" ]]; then
+ echo "docs: generating frontend TypeDoc reference"
+ ( cd "$REPO_ROOT/frontend" \
+ && npx -y -p typedoc -p typedoc-plugin-markdown typedoc \
+ --plugin typedoc-plugin-markdown \
+ --entryPointStrategy expand \
+ --readme none \
+ --skipErrorChecking \
+ --out .typedoc \
+ src \
+ ) >/dev/null 2>&1 || echo "docs: TypeDoc step failed/skipped; frontend section omitted" >&2
+else
+ echo "docs: npx/frontend not available; frontend section omitted" >&2
+fi
+
+# 3. Generate the doc-source tree from the codebase (pure stdlib; writes real files).
+echo "docs: generating pages from the codebase"
+"$PY" "$HERE/gen_pages.py"
+
+# 4. Build (or serve) the site. Run from the config dir so relative paths resolve.
+if [[ "${1:-}" == "--build" ]]; then
+ echo "docs: building site"
+ ( cd "$HERE" && "$ZENSICAL" build )
+
+ INDEX="$HERE/site/index.html"
+ echo "docs: built $INDEX"
+ if command -v open >/dev/null 2>&1; then open "$INDEX" || true
+ elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$INDEX" || true
+ else echo "docs: open $INDEX in a browser to view the site"
+ fi
+ exit 0
+fi
+
+# Default: live-reloading dev server.
+exec sh -c "cd '$HERE' && exec '$ZENSICAL' serve -o"
diff --git a/docs/zensical.toml b/docs/zensical.toml
new file mode 100644
index 00000000..8728ea0c
--- /dev/null
+++ b/docs/zensical.toml
@@ -0,0 +1,111 @@
+# Zensical configuration. Build/preview via ./docs/run.sh, or directly:
+# ( cd docs && .venv/bin/zensical build )
+# ( cd docs && .venv/bin/zensical serve -o )
+#
+# Navigation is INFERRED from the directory tree under content/ (Zensical doesn't
+# run the mkdocs-literate-nav plugin). gen_pages.py writes that tree on each run,
+# so the nav mirrors the codebase automatically — top-level dirs become sections:
+# content/reference/ -> "reference" (backend API, from docstrings via mkdocstrings)
+# content/guides/ -> "guides" (every README + loose Markdown doc)
+# content/frontend/ -> "frontend" (TypeDoc reference, when present)
+
+[project]
+site_name = "Open Swarm Analytics — Docs"
+site_description = "Auto-generated docs sourced from docstrings, READMEs, and the frontend."
+docs_dir = "content"
+site_dir = "site"
+use_directory_urls = false
+
+# Open Swarm - inspired theming (clay accent, ivory/charcoal canvases, serif
+# headings). The actual colors live in content/stylesheets/openswarm.css, keyed off
+# the "custom" palette entries below.
+extra_css = ["stylesheets/openswarm.css"]
+
+# Seed the search modal with a few "suggested pages" while the query is empty, so
+# it isn't blank on open (command-palette style). See the script header for how it
+# slots into the modal's shadow DOM.
+extra_javascript = ["javascripts/search-suggestions.js"]
+
+# Drop the "Made with Zensical" attribution from the footer (copyright.html
+# skips the generator block when this is false).
+[project.extra]
+generator = false
+
+[project.theme]
+# Template overrides (resolved relative to this file → docs/overrides/).
+# Currently just reorders the header so the theme toggle sits to the right of
+# the search bar; see overrides/partials/header.html.
+custom_dir = "overrides"
+# Brand logo (header, top-left) and browser-tab favicon. Both resolve relative
+# to docs_dir → content/assets/logo.png (copied in, not wiped by gen_pages.py).
+logo = "assets/logo.png"
+favicon = "assets/logo.png"
+features = [
+ "navigation.sections",
+ "navigation.indexes",
+ "navigation.top",
+ "navigation.tracking",
+ "navigation.footer",
+ "toc.follow",
+ "content.code.copy",
+ "search.suggest",
+ "search.highlight",
+]
+
+# Light/dark palettes with a toggle button in the header. Each entry follows the
+# system preference by default (`media`) and can be flipped manually via the
+# toggle, which persists the choice. `primary`/`accent` are "custom" so the clay
+# brand colors from openswarm.css take over.
+[[project.theme.palette]]
+media = "(prefers-color-scheme: light)"
+scheme = "default"
+primary = "custom"
+accent = "custom"
+[project.theme.palette.toggle]
+icon = "material/brightness-7"
+name = "Switch to dark mode"
+
+[[project.theme.palette]]
+media = "(prefers-color-scheme: dark)"
+scheme = "slate"
+primary = "custom"
+accent = "custom"
+[project.theme.palette.toggle]
+icon = "material/brightness-4"
+name = "Switch to light mode"
+
+# --- Markdown extensions (each table = one enabled extension) ----------------
+[project.markdown_extensions.admonition]
+[project.markdown_extensions.attr_list]
+[project.markdown_extensions.md_in_html]
+[project.markdown_extensions.tables]
+
+[project.markdown_extensions.toc]
+permalink = true
+
+[project.markdown_extensions.pymdownx.highlight]
+anchor_linenums = true
+
+[project.markdown_extensions.pymdownx.superfences]
+[project.markdown_extensions.pymdownx.inlinehilite]
+[project.markdown_extensions.pymdownx.details]
+
+# --- API reference from docstrings (mkdocstrings, preliminary in Zensical) ----
+# The Python handler ships separately (mkdocstrings-python, see requirements.txt).
+[project.plugins.mkdocstrings.handlers.python]
+# Relative to this config file → repo root, so `import backend` resolves under
+# Griffe's static analysis. (External paths aren't watched for live reload yet.)
+paths = [".."]
+inventories = ["https://docs.python.org/3/objects.inv"]
+
+[project.plugins.mkdocstrings.handlers.python.options]
+docstring_style = "google"
+show_source = true
+show_root_heading = true
+show_root_full_path = true
+show_if_no_docstring = true
+members_order = "source"
+separate_signature = true
+show_signature_annotations = true
+merge_init_into_class = true
+filters = ["!^_[^_]"]
diff --git a/linter/checks/ruff.py b/linter/checks/ruff.py
index 0b3dfbeb..896f2b93 100644
--- a/linter/checks/ruff.py
+++ b/linter/checks/ruff.py
@@ -56,7 +56,7 @@ def run_ruff(
"--select", select,
"--output-format", "concise",
"--no-fix",
- "--exclude", ".venv,__pycache__,data,uv-bin,webapp_template",
+ "--exclude", ".venv,__pycache__,data,uv-bin,webapp_template,.runner-venv",
]
try:
diff --git a/linter/checks/vulture.py b/linter/checks/vulture.py
index b66bd2cc..3234b53e 100644
--- a/linter/checks/vulture.py
+++ b/linter/checks/vulture.py
@@ -130,7 +130,7 @@ def run_vulture(
cmd.append(str(whitelist))
cmd.extend([
"--min-confidence", str(min_confidence),
- "--exclude", ".venv,__pycache__,data,uv-bin",
+ "--exclude", ".venv,.runner-venv,__pycache__,data,uv-bin",
"--ignore-decorators", "@*.router.*,@*.websocket,@app.*,@pytest.fixture,@pytest.fixture*",
"--ignore-names", "cls",
])