Recipe: Evergreen product docs site for a Dazzle app¶
Status: Proven at AegisMark (live at https://docs.aegismark.ai). Landed as a Dazzle consumer guide (#1612 Stage A). Framework code extraction is optional and phased (see Upstream ladder).
Proof-of-shape: AegisMark docs-site/, pipeline/docsite/,
scripts/docs_ship.sh, tests/docs/, design
docs/superpowers/specs/2026-07-01-evergreen-docs-site-design.md.
Second-consumer signal: any multi-persona Dazzle product that needs user-facing documentation that stays true to the live DSL.
This is not Dazzle's developer docs (ADR-0001 / this MkDocs site). It is the
product docs path: docs.<product> for end users, fully decoupled from the
app process and tenant hosts.
Problem¶
A Dazzle app already knows its personas, workspaces, surfaces, and stories —
that is the AppSpec. Product documentation written by hand drifts the moment a
workspace is renamed or a persona is added. Meanwhile, framework developer docs
(MkDocs on GitHub Pages — ADR-0001) are the wrong place for end-user training
content, and the app process (Heroku / dazzle serve / tenant middleware) is the
wrong host for a public, unauthenticated docs site.
Every product team that wants evergreen user docs reimplements the same stack:
- A static docs project separate from internal
docs/ - Live labels pulled from the AppSpec at build time
- A structural drift gate against front-matter manifests
- Decoupled static hosting on a custom domain (
docs.<product>) - (Later) screen recordings that stay true to the real UI
AegisMark has run this end-to-end. The recipe below is what other Dazzle apps should copy.
Non-goals¶
- Replacing Dazzle's own developer docs site (ADR-0001).
- Serving product docs from the app process / tenant hosts.
- Auth-gated docs, versioned docs, or i18n (extensions, not prerequisites).
- Implementing the trace→video pipeline in v1 (roadmap phase; see below).
Design principles¶
| Principle | Choice |
|---|---|
| Host | Static site on Cloudflare Workers (static assets) — fully decoupled from the app process and tenant infra |
| Toolchain | MkDocs + Material + mkdocs-macros-plugin (Python-native, same stack as Dazzle's own docs) |
| Source of truth | Live AppSpec + stories.json via a small facts loader |
| Evergreen contract | YAML front-matter on every page declares the entities it documents; a tiered drift gate enforces currency |
| Build location | Build locally / in CI where Dazzle is installed, upload static output — macros need the pinned framework |
| Content shape | Getting Started + per-persona guides + cross-cutting how-tos (how-tos are the future video anchors) |
Note on Cloudflare product naming. Cloudflare's current default for a static site is a Worker with static assets, deployed with
wrangler deploy. Older docs say "Cloudflare Pages" +wrangler pages deploy. Functionally equivalent (pure static host, custom domain, Direct Upload). Prefer Workers-static-assets for new projects; the recipe works with either.
Recipe (follow this)¶
0. Prerequisites¶
- A Dazzle project that already loads (
dazzle.toml+ DSL + pinneddazzle-dsl). - A Cloudflare account that manages the product zone (or can CNAME into one).
- Node available for one-shot
npx wrangler(no permanent Node dependency). - Secrets in a gitignored root
.env:
1. Layout — keep product docs out of internal docs¶
docs-site/ # user-facing MkDocs project (NEW)
mkdocs.yml
main.py # macros bootstrap (sys.path → repo root)
wrangler.toml # Cloudflare Worker static assets
docs/
index.md # Getting started
guides/
<persona>.md # one guide per user-facing persona
how-to/
<task>.md # cross-cutting workflows (video anchors)
overrides/ # optional Material brand partials
site/ # build output (gitignored)
app/docsite/ (or pipeline/docsite/) # evergreen tooling (NEW)
facts.py # AppSpec + stories → Facts
drift.py # front-matter parse + tiered gate
macros.py # mkdocs-macros define_env
tests/docs/
test_facts.py
test_drift.py
test_build.py # mkdocs build --strict + macros resolve
scripts/docs_ship.sh # gate + strict build + wrangler deploy
Rules:
docs-site/is not the internaldocs/tree (ADRs, runbooks, QA).- The deploy path never touches Heroku,
dazzle serve, or tenant middleware. - Reserve the
docsslug in any multi-tenant host map so a strayHost: docs.<product>at the app process still 404s.
2. Dependency group (local-only)¶
In pyproject.toml:
Never install this group on the app host. Preview and build:
uv run --group docs mkdocs serve -f docs-site/mkdocs.yml # :8000
uv run --group docs mkdocs build --strict -f docs-site/mkdocs.yml
3. MkDocs config skeleton¶
docs-site/mkdocs.yml:
site_name: <Product> Documentation
site_url: https://docs.<product-domain>
docs_dir: docs
theme:
name: material
custom_dir: overrides # optional brand partials
palette:
- scheme: default
primary: indigo
accent: indigo
features:
- navigation.sections
- navigation.top
- content.code.copy
plugins:
- search
- macros:
module_name: main # docs-site/main.py
nav:
- Getting started: index.md
- Guides:
- Teacher: guides/teacher.md
# …one entry per persona
- How-to:
- Mark a cohort: how-to/mark-a-cohort.md
# …cross-cutting tasks
docs-site/main.py — put the repo root on sys.path and re-export
define_env from the facts package (MkDocs loads the module relative to
mkdocs.yml):
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.docsite.macros import define_env # noqa: E402,F401
4. Front-matter contract (the evergreen surface)¶
Every curated page declares the system entities it documents:
---
persona: teacher # required on guide pages; omit on how-to/index
workspaces: [teacher_workspace, class_view]
surfaces: [class_summary] # optional
stories: [ST-012, ST-018]
---
This declaration is what makes prose machine-checkable. Authors never hard-code persona/workspace labels in the body — they use live macros:
# A day as a {{ fact.persona('teacher').label }}
Your home is the **{{ fact.workspace('teacher_workspace').label }}** workspace.
Namespace macros under fact.*. Page front-matter keys are injected into the
Jinja context by mkdocs-macros and would shadow top-level macros with the same
name.
5. Facts loader (source of truth)¶
facts.py is a pure function:
Extract from the live system (no database):
| Fact | Source |
|---|---|
| personas | appspec.personas (exclude platform-only IDs such as super_admin) |
| workspaces | appspec.workspaces filtered to those reachable by ≥1 documented persona, via dazzle.core.access.workspace_allowed_personas |
| surfaces | appspec.surfaces |
| stories | .dazzle/stories/stories.json |
Load the AppSpec with the same path the project already uses
(BuildService(dazzle.toml).load_appspec() or load_project_appspec). The
macros and the drift gate share one load_facts() — no second source of truth.
Reference implementation: AegisMark pipeline/docsite/facts.py (~100 lines).
6. Drift gate (tiered, so it has teeth without being brittle)¶
drift.py pure functions:
parse_manifests(docs_dir) -> list[PageManifest]
check_drift(facts, manifests, coverage_mode="partial"|"complete") -> DriftReport
| Tier | Meaning | When blocking |
|---|---|---|
| 1 | A page references an entity that no longer exists | Always hard-fail |
| 2 | A persona has no guide, or a user-facing workspace is referenced by no guide | Hard-fail only when coverage_mode: complete |
| 3 | An accepted/done story referenced by no page | Advisory only |
Rollout:
- Pilot (
partial): ship the machine + 1–2 persona guides + 1 how-to. Tier 1 is live; Tier 2 gaps stay advisory so the pilot does not self-block. - Scale-out: author remaining persona guides + how-tos.
- Flip to
completewhen the set is full — full coverage becomes hard-fail from then on.
Negative-control test: feed a fabricated stale workspace id and assert Tier 1 rejects it (proves the gate has teeth).
Reference implementation: AegisMark pipeline/docsite/drift.py +
tests/docs/test_drift.py.
7. Honest boundary (text vs video)¶
The gate guarantees structural currency — entities exist, are covered, and labels are live. It does not verify that narrative steps ("click Mark, then confirm") match the real UI. That is the job of phase-2 videos generated from Playwright traces of the live product. Text and video divide the evergreen problem cleanly; how-to pages are the anchors videos attach to.
8. Cloudflare one-time setup¶
In the Cloudflare dashboard:
- Create a Worker (static assets) named e.g.
<product>-docs. - Attach custom domain
docs.<product-domain>(zone already on Cloudflare, or CNAME from the registrar). - Issue an API token with Workers/Pages Edit on the account.
docs-site/wrangler.toml:
# Worker-with-static-assets (Cloudflare's current default for static sites).
# Deploy with `wrangler deploy`, not `wrangler pages deploy`.
name = "<product>-docs"
compatibility_date = "2026-07-02"
# Custom domain is dashboard-managed — do not require a workers.dev subdomain.
workers_dev = false
[assets]
directory = "./site"
# Multi-page MkDocs site, not an SPA.
not_found_handling = "404-page"
No main Worker script is required — Cloudflare serves ./site directly.
9. Ship script¶
scripts/docs_ship.sh:
#!/usr/bin/env bash
# gate + strict build + wrangler deploy. Never touches the app process.
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ -f .env ]]; then set -a; . ./.env; set +a; fi
echo "==> drift gate"
.venv/bin/python -m pytest tests/docs -q
echo "==> build (strict)"
uv run --group docs mkdocs build --strict -f docs-site/mkdocs.yml
if [[ "${1:-}" == "--build-only" ]]; then
echo "==> site at docs-site/site (skipping deploy)"
exit 0
fi
: "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}"
( cd docs-site && npx --yes wrangler@3 deploy )
echo "==> live at https://docs.<product-domain>"
Fold pytest tests/docs (and optionally the ship script's build-only mode) into
the project's nightly / quality loop so drift is caught without requiring PR CI.
10. Content authoring method¶
- Start from stories: each persona's
actor-matched stories give "what they do";happy_path_outcomeis the success beat;given/when/thenseeds steps. - Ground non-obvious flows against the live UI with the project's QA harness
(AegisMark:
/qa-tenantmagic-link + Playwright MCP) — the same traces that become videos later. - Write warm, task-oriented prose. Prefer how-tos for workflows shared across personas; prefer guides for "a day in the life".
- On every how-to page, leave an explicit video slot (even as a one-line placeholder) so phase 2 has a home:
11. Checklist (first ship)¶
- [ ]
docsdependency group +uv lock - [ ]
docs-site/MkDocs project + brand overrides (optional) - [ ]
*/docsite/{facts,drift,macros}.py+tests/docs/green undercoverage_mode: partial - [ ] Getting Started + ≥1 persona guide + ≥1 how-to live
- [ ]
mkdocs build --strictsucceeds; macros resolve (no raw{{in HTML) - [ ] Cloudflare Worker + custom domain attached
- [ ]
CLOUDFLARE_*in.env;docs_ship.shdeploys - [ ]
docsreserved in tenant slug blocklist (if multi-tenant) - [ ] Drift gate wired into nightly / local quality loop
- [ ] Flip to
coverage_mode: completeonly when every user-facing persona has a guide and every user-facing workspace is referenced
Upstream ladder¶
Ship the recipe as documentation first. Framework code extraction is optional and should follow real second-consumer demand.
| Stage | What lands in Dazzle | When |
|---|---|---|
| A. Guide | This document as docs/guides/product-docs-site.md (+ nav entry) |
Now — zero risk, unblocks every app |
| B. Scaffold | dazzle docs site init writes the docs-site/ skeleton, wrangler.toml, ship script stubs, and a minimal docsite/ package |
When ≥2 apps adopt the guide |
| C. Library | Promote facts/drift/macros into dazzle.docsite (project package becomes a thin config: platform personas, coverage mode, docs path) |
When the AegisMark + second-consumer packages diverge only in config |
| D. Trace→video | Shared pipeline: Playwright trace.zip → narration script → TTS → MP4/WebM assets embedded on how-to pages; drift extends to "video asset present for every how-to with video: required" |
Separate design; depends on stable QA-trace capture in consumers |
Stage A is the ask of this draft. B–D are not blockers.
Suggested Stage B CLI shape¶
dazzle docs site init \
--domain docs.example.com \
--worker-name example-docs \
--coverage-mode partial
Writes the layout in §1, a starter index.md, and documents the Cloudflare
dashboard steps. Does not create the Cloudflare resource (operator concern,
same posture as dazzle deploy plan).
Suggested Stage C package surface¶
from dazzle.docsite import load_facts, check_drift, parse_manifests, define_env
facts = load_facts(
project_dir=".",
platform_personas={"super_admin"}, # project policy
)
report = check_drift(facts, parse_manifests(Path("docs-site/docs")),
coverage_mode="complete")
MkDocs macros keep working via define_env re-export.
Phase 2 sketch — Playwright traces → evergreen video¶
Not in v1. Captured here so the text recipe stays aligned with the long-term shape.
qa harness (persona walk) how-to page front-matter
│ │
▼ ▼
trace.zip ──► frame extract ──► AI narration ──► TTS
│ │ │ │
└──────────────┴───────────────────┴──────────────┘
│
▼
MP4/WebM + VTT captions
│
▼
docs-site/docs/how-to/<task>.md embeds asset
(built into static site; CDN-cached with the rest)
Invariants for later design work:
- Traces are the ground truth for UI steps, not the prose. Re-record when the happy path changes; regenerate video; re-ship docs.
- How-to pages are the only video anchors (not persona guides) — one workflow, one asset, re-used from every guide that links it.
- Narration is generated, not hand-edited forever — hand polish is fine, but regeneration must be cheap enough to re-run after every material UX change.
- Assets are build artefacts or versioned binaries under something like
docs-site/docs/assets/video/— never hot-linked from the app process. - Extend the drift gate, don't invent a second one: e.g.
video: requiredin how-to front-matter → Tier 1 fails if the asset is missing after the flip to complete coverage.
AegisMark already produces Playwright trace.zip from /qa-tenant and
/qa-flow; filmstrip extraction exists for QA reports. Phase 2 reuses that
capture path rather than inventing a second walker.
Filing checklist¶
- [x] Guide text ready (this file)
- [x] Filed: https://github.com/manwithacat/dazzle/issues/1612
- [x] Landed as
docs/guides/product-docs-site.md+mkdocs.ymlGuides nav - [x] Linked from
docs/reference/deployment.md - [ ] AegisMark keeps
pipeline/docsite/as the reference implementation until Stage C; no pin-bump required for Stage A - [ ] Stage B/C when second consumer appears (track on #1612 or a follow-up)
Alternatives considered (and rejected for the recipe)¶
| Alternative | Why not default |
|---|---|
| Serve docs from the Dazzle app / tenant host | Couples public docs to app deploys, auth, and tenant middleware; wrong failure domain |
| Cloudflare git-connected auto-build | Build needs the pinned Dazzle for macros; build-local + upload-static is robust without CI |
| Sphinx / Docusaurus | MkDocs Material is already Dazzle's docs stack (ADR-0001) and Python-native for the gate |
| Hand-maintained labels only (no macros) | Labels drift; macros make facts evergreen by construction |
| Hard-fail full coverage from day one | Pilots self-block; tiered coverage_mode is what made the first ship possible |
Reference map (AegisMark → recipe §)¶
| AegisMark path | Recipe section |
|---|---|
docs-site/ |
§1, §3 |
pipeline/docsite/facts.py |
§5 |
pipeline/docsite/drift.py |
§6 |
pipeline/docsite/macros.py |
§4 |
tests/docs/ |
§6, §9 |
scripts/docs_ship.sh |
§9 |
docs-site/wrangler.toml |
§8 |
pyproject.toml [dependency-groups].docs |
§2 |
Design spec docs/superpowers/specs/2026-07-01-evergreen-docs-site-design.md |
full rationale |
| Live site | https://docs.aegismark.ai |