diff --git a/site/README.md b/site/README.md
new file mode 100644
index 0000000..4358569
--- /dev/null
+++ b/site/README.md
@@ -0,0 +1,84 @@
+# site/ — bench.12vectors.com
+
+A static minisite whose content is *generated* from the files that
+already define bench. Nothing here is transcribed prose: every page body
+is a heading slice of `AGENTS.md`, `README.md`,
+`manager/core/.env.example` or `manager/core/adapters/README.md`, and
+`pages.json` is the only place that mapping is written down.
+
+That is the point. Rename a section in `AGENTS.md` and this build stops,
+naming the route and the heading it can no longer find. A documented
+behaviour and the behaviour itself cannot drift apart when one is cut
+from the other.
+
+```bash
+python3 -m pip install -r site/requirements.txt # once
+python3 site/fetch-fonts.py # once, needs network
+python3 site/build.py # → site/dist/
+```
+
+`site/dist/` is gitignored — it is output, rebuilt on every deploy.
+Deployment is task 32's; this directory only has to produce the files.
+
+`site/` never reaches a host repo: releases ship exactly what
+`../manager/core/release-manifest` lists, and it does not list this
+directory. `tests/test_release_artifact.py` asserts that out loud rather
+than leaving it to inference.
+
+## What is where
+
+| Path | What it is |
+| --- | --- |
+| `pages.json` | The manifest: one entry per route, and the site's whole IA |
+| `build.py` | The generator — slicing, drift detection, rendering, links |
+| `templates/` | One `string.Template` per layout (`$name`, `$$` for a literal dollar) |
+| `static/` | Copied to `dist/static/` verbatim: the stylesheet, the icon, the fonts |
+| `requirements.txt` | `markdown-it-py`, pinned. The only dependency |
+| `fetch-fonts.py` | Downloads the self-hosted woff2 files, once |
+
+## A manifest entry
+
+```json
+{
+ "path": "/concepts/claiming-a-card/",
+ "title": "Claiming a card",
+ "layout": "article",
+ "section": "Concepts",
+ "description": "…",
+ "source": "AGENTS.md",
+ "from": "## Claiming a card",
+ "to": "## Syncing boards"
+}
+```
+
+- **`path`** starts and ends with `/`; `/x/y/` is written to
+ `dist/x/y/index.html`.
+- **`layout`** names a file in `templates/`.
+- **`section`** groups the page in the nav and the sidebar. The IA is
+ read out of this file in this file's order — nothing is derived from
+ the directory layout.
+- **`source`** is repo-relative, or `null` for a landing page whose body
+ is authored in its template rather than sliced.
+- **`from`** is the heading the slice starts at. It is matched on the
+ heading's text; writing the `#`s (`## Stages`) pins the level too.
+ Headings inside fenced code blocks never match.
+- **`to`** is optional. Without it the slice runs to the next heading of
+ the same level or shallower.
+
+The `from` heading itself is dropped — the layout renders the page title
+— and what remains is promoted by `level - 1`, so a section's `###`
+sub-headings land as the page's `
`s.
+
+## What fails the build
+
+Each of these exits non-zero with a message naming the route:
+
+- a `source` file that no longer exists;
+- a `from` or `to` heading the source no longer contains;
+- a slice that comes out empty;
+- a markdown link to a repo path that does not exist, or that escapes
+ the repo — a dead relative link must never reach the site;
+- a template placeholder the builder does not supply.
+
+Repo-relative links that *do* resolve are rewritten: to a site route if
+`link_routes` maps the file to one, otherwise to the file on GitHub.
diff --git a/site/build.py b/site/build.py
new file mode 100644
index 0000000..7780a0a
--- /dev/null
+++ b/site/build.py
@@ -0,0 +1,492 @@
+#!/usr/bin/env python3
+"""Generate the bench minisite from the repo's own markdown.
+
+ python3 site/build.py # writes site/dist/
+ python3 site/build.py --out /tmp/x # somewhere else
+
+Nothing here is transcribed. Every page body is a heading slice of a file
+that already documents bench — AGENTS.md, README.md, the settings example,
+the adapter contract — and site/pages.json is the only place that mapping
+is written down. That is the whole point: renaming a section in AGENTS.md
+must break this build, loudly, naming the route and the heading it can no
+longer find, rather than quietly emitting a page with an empty body.
+
+The stdlib-only law binds `manager/core/` — the tool people install. This
+directory is neither shipped nor installed (see manager/core/release-manifest,
+"Anything not listed here does not ship"), so it may depend on a real
+markdown parser: markdown-it-py, pinned in site/requirements.txt.
+
+## How a slice becomes a page
+
+Given `"from": "## Claiming a card"` the builder takes the lines after
+that heading up to the next heading of the same level or shallower (or to
+an explicit `"to"` heading), then promotes what is left by `level - 1` so
+the section's own `###` sub-headings land as the page's `
`s. The
+`from` heading itself is dropped: the layout renders the page title from
+the manifest, and a body that repeated it would say it twice.
+
+Templates are `string.Template`, so placeholders are `$name` and a literal
+dollar is `$$` — `str.format` was not an option with a stylesheet's worth
+of braces in play.
+"""
+
+import argparse
+import json
+import os
+import re
+import shutil
+import sys
+from html import escape
+from pathlib import Path
+from string import Template
+
+SITE = Path(__file__).resolve().parent
+REPO = SITE.parent
+
+# Written into every output directory the builder owns, so a later run
+# knows the tree is its own before removing it. --out pointed at
+# something else refuses rather than deleting a stranger's files.
+MARKER = ".bench-site"
+
+ATX = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$")
+FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
+CSS_URL = re.compile(r"""url\(\s*["']?([^"')]+)["']?\s*\)""")
+EXTERNAL = ("http://", "https://", "//", "mailto:", "tel:", "data:")
+
+
+class BuildError(Exception):
+ """A failure a person must read and fix: a missing source, a renamed
+ heading, a dead link, a manifest that does not make sense."""
+
+
+# ── reading markdown ──────────────────────────────────────────────────
+
+def headings(text: str):
+ """(line index, level, text) for every ATX heading *outside* a code
+ fence. The fence tracking is not a nicety: AGENTS.md fences a task
+ file template whose first line is `# Task title`, and matching that
+ would slice the document in half."""
+ fence = None
+ for index, line in enumerate(text.splitlines()):
+ opener = FENCE.match(line)
+ if opener:
+ marker = opener.group(1)
+ if fence is None:
+ fence = marker
+ elif marker[0] == fence[0] and len(marker) >= len(fence) \
+ and not line.strip().strip(marker[0]):
+ fence = None
+ continue
+ if fence is not None:
+ continue
+ found = ATX.match(line)
+ if found:
+ yield index, len(found.group(1)), found.group(2).strip()
+
+
+def wanted(value: str):
+ """A manifest heading as (level or None, text). `## Stages` pins the
+ level too; a bare `Stages` matches the heading wherever it sits."""
+ text = value.lstrip("#")
+ level = len(value) - len(text)
+ return (level or None), text.strip()
+
+
+def find_heading(marks: list, value: str, after: int = -1):
+ level, text = wanted(value)
+ for index, found_level, found_text in marks:
+ if index <= after:
+ continue
+ if found_text == text and (level is None or level == found_level):
+ return index, found_level
+ return None
+
+
+def promote(text: str, by: int) -> str:
+ """Shift every heading in a slice `by` levels shallower, so a section
+ lifted out of a larger document keeps its internal hierarchy while
+ starting at
under the page's own
."""
+ if by <= 0:
+ return text
+ lines = text.splitlines()
+ for index, level, _ in list(headings(text)):
+ found = ATX.match(lines[index])
+ lines[index] = "#" * max(1, level - by) + " " + found.group(2).strip()
+ return "\n".join(lines)
+
+
+def slice_section(text: str, page: dict, source: str) -> str:
+ """The body of one page, or a BuildError naming what drifted."""
+ route = page["path"]
+ marks = list(headings(text))
+ start = find_heading(marks, page["from"])
+ if start is None:
+ raise BuildError(
+ f'{route}: {source} has no heading "{page["from"]}" — the '
+ f"section was renamed, moved or deleted. Fix the heading or "
+ f"update the entry in site/pages.json.")
+ start_line, level = start
+
+ if page.get("to"):
+ end = find_heading(marks, page["to"], after=start_line)
+ if end is None:
+ raise BuildError(
+ f'{route}: {source} has no heading "{page["to"]}" after '
+ f'"{page["from"]}" — the slice has no end. Fix the heading '
+ f"or update the entry in site/pages.json.")
+ end_line = end[0]
+ else:
+ following = [i for i, found_level, _ in marks
+ if i > start_line and found_level <= level]
+ end_line = following[0] if following else len(text.splitlines())
+
+ body = "\n".join(text.splitlines()[start_line + 1:end_line]).strip("\n")
+ if not body.strip():
+ raise BuildError(
+ f'{route}: the slice of {source} from "{page["from"]}" is '
+ f"empty. A page with no body is a drift, not a page.")
+ return promote(body, level - 1)
+
+
+# ── links ─────────────────────────────────────────────────────────────
+
+def rewrite_link(href: str, *, page: dict, source: str, manifest: dict,
+ repo: Path) -> str:
+ """A repo-relative link out of a markdown file is a dead path on the
+ web. Send it to the site route that covers it, or to the file on
+ GitHub — and refuse to emit anything else."""
+ if not href or href.startswith("#") or href.startswith(EXTERNAL):
+ return href
+ target, hash_mark, fragment = href.partition("#")
+ if not target:
+ return href
+ rel = os.path.normpath(os.path.join(os.path.dirname(source), target))
+ if rel.startswith(".."):
+ raise BuildError(
+ f'{page["path"]}: {source} links to "{href}", which is outside '
+ f"the repository. Only links within the repo can be rewritten.")
+ if not (repo / rel).exists():
+ raise BuildError(
+ f'{page["path"]}: {source} links to "{href}", which does not '
+ f"exist ({rel}). A dead link in a source file is a dead link "
+ f"on the site.")
+ route = manifest.get("link_routes", {}).get(rel)
+ if route:
+ return route + hash_mark + fragment
+ blob = manifest["site"]["blob_base"].rstrip("/") + "/"
+ return blob + rel + hash_mark + fragment
+
+
+# ── rendering ─────────────────────────────────────────────────────────
+
+def slugify(text: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
+ return slug or "section"
+
+
+def render_markdown(body: str, *, page: dict, source: str, manifest: dict,
+ repo: Path):
+ """(html, [(slug, text)] for the h2s) — heading ids and rewritten
+ links are done on the token stream, not with regexes over HTML."""
+ try:
+ from markdown_it import MarkdownIt
+ except ImportError as missing: # pragma: no cover - environment
+ raise BuildError(
+ "markdown-it-py is not installed. It is the site's only "
+ "dependency: python3 -m pip install -r site/requirements.txt"
+ ) from missing
+
+ renderer = MarkdownIt("commonmark").enable(["table", "strikethrough"])
+ tokens = renderer.parse(body)
+ contents, seen = [], {}
+ for index, token in enumerate(tokens):
+ if token.type == "heading_open":
+ text = tokens[index + 1].content
+ slug = slugify(text)
+ seen[slug] = seen.get(slug, 0) + 1
+ if seen[slug] > 1:
+ slug = f"{slug}-{seen[slug]}"
+ token.attrSet("id", slug)
+ if token.tag == "h2":
+ contents.append((slug, text))
+ elif token.type == "inline":
+ for child in token.children or []:
+ if child.type == "link_open":
+ child.attrSet("href", rewrite_link(
+ child.attrGet("href"), page=page, source=source,
+ manifest=manifest, repo=repo))
+ elif child.type == "image":
+ child.attrSet("src", rewrite_link(
+ child.attrGet("src"), page=page, source=source,
+ manifest=manifest, repo=repo))
+ return renderer.renderer.render(tokens, renderer.options, {}), contents
+
+
+def read_source(page: dict, repo: Path) -> str:
+ source = page["source"]
+ path = repo / source
+ if not path.is_file():
+ raise BuildError(
+ f'{page["path"]}: source file {source} does not exist. The '
+ f"file was renamed or moved; update site/pages.json.")
+ return path.read_text(encoding="utf-8")
+
+
+# ── the shell around a body ───────────────────────────────────────────
+
+def sections(manifest: dict) -> list:
+ """The IA, read out of the manifest in manifest order: the sections
+ that have pages, each with its pages. Nothing is derived from the
+ directory layout — pages.json is where the site's shape is written."""
+ groups: list = []
+ for page in manifest["pages"]:
+ name = page.get("section")
+ if not name:
+ continue
+ for group in groups:
+ if group["name"] == name:
+ group["pages"].append(page)
+ break
+ else:
+ groups.append({"name": name, "pages": [page]})
+ return groups
+
+
+def render_nav(manifest: dict, current: dict) -> str:
+ out = []
+ for group in sections(manifest):
+ first = group["pages"][0]
+ active = " nav-here" if current.get("section") == group["name"] else ""
+ out.append(f''
+ f'{escape(group["name"])}')
+ return "\n".join(out)
+
+
+def render_sidebar(manifest: dict, current: dict) -> str:
+ out = []
+ for group in sections(manifest):
+ out.append('
')
+ out.append(f'{escape(group["name"])}')
+ for page in group["pages"]:
+ here = " side-here" if page["path"] == current["path"] else ""
+ out.append(f''
+ f'{escape(page["title"])}')
+ out.append("
")
+ return "\n".join(out)
+
+
+def render_contents(contents: list) -> str:
+ if not contents:
+ return ""
+ out = ['On this page']
+ for slug, text in contents:
+ out.append(f'{escape(text)}')
+ return "\n".join(out)
+
+
+def render_breadcrumb(page: dict) -> str:
+ if not page.get("section"):
+ return ""
+ return (f'{escape(page["section"])}›'
+ f'{escape(page["title"])}')
+
+
+def load_template(name: str, site: Path) -> Template:
+ path = site / "templates" / f"{name}.html"
+ if not path.is_file():
+ available = sorted(p.stem for p in (site / "templates").glob("*.html"))
+ raise BuildError(
+ f'no template for layout "{name}". Templates present: '
+ f'{", ".join(available) or "none"}.')
+ return Template(path.read_text(encoding="utf-8"))
+
+
+def render_page(page: dict, manifest: dict, *, site: Path, repo: Path) -> str:
+ source = page.get("source")
+ if source:
+ body, contents = render_markdown(
+ slice_section(read_source(page, repo), page, source),
+ page=page, source=source, manifest=manifest, repo=repo)
+ else:
+ # An authored landing page says so with "source": null. Its words
+ # live in the template, so there is no slice and nothing to drift.
+ body, contents = "", []
+
+ config = manifest["site"]
+ blob = config["blob_base"].rstrip("/") + "/"
+ fields = {
+ "title": escape(page["title"]),
+ "description": escape(page.get("description")
+ or config.get("description", "")),
+ "site_title": escape(config["title"]),
+ "site_tagline": escape(config.get("tagline", "")),
+ "version": escape(config.get("version", "")),
+ "body": body,
+ "toc": render_contents(contents),
+ "nav": render_nav(manifest, page),
+ "sidebar": render_sidebar(manifest, page),
+ "breadcrumb": render_breadcrumb(page),
+ "section": escape(page.get("section") or ""),
+ "repo_url": config["repo_url"],
+ "issues_url": config.get("issues_url", config["repo_url"]),
+ "source_url": (blob + source) if source else config["repo_url"],
+ "source_path": escape(source or ""),
+ "canonical": config.get("base_url", "").rstrip("/") + page["path"],
+ }
+ try:
+ return load_template(page["layout"], site).substitute(fields)
+ except KeyError as unknown:
+ raise BuildError(
+ f'{page["layout"]}.html uses an unknown placeholder '
+ f"${unknown.args[0]}. Known: "
+ f'{", ".join("$" + k for k in sorted(fields))}.') from unknown
+ except ValueError as bad:
+ raise BuildError(
+ f"{page['layout']}.html: {bad}. A literal dollar sign in a "
+ f"template must be written $$.") from bad
+
+
+# ── the manifest ──────────────────────────────────────────────────────
+
+def load_manifest(site: Path) -> dict:
+ path = site / "pages.json"
+ if not path.is_file():
+ raise BuildError(f"no manifest at {path}")
+ try:
+ manifest = json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as broken:
+ raise BuildError(f"pages.json is not valid JSON: {broken}") from broken
+
+ for key in ("site", "pages"):
+ if key not in manifest:
+ raise BuildError(f'pages.json has no "{key}" key')
+ for key in ("title", "repo_url", "blob_base"):
+ if key not in manifest["site"]:
+ raise BuildError(f'pages.json: site has no "{key}" key')
+
+ seen = set()
+ for page in manifest["pages"]:
+ for key in ("path", "title", "layout"):
+ if not page.get(key):
+ raise BuildError(f'pages.json: an entry has no "{key}": '
+ f"{json.dumps(page)}")
+ route = page["path"]
+ if not route.startswith("/") or not route.endswith("/"):
+ raise BuildError(f'pages.json: route "{route}" must start and '
+ f'end with "/"')
+ if route in seen:
+ raise BuildError(f'pages.json: route "{route}" appears twice')
+ seen.add(route)
+ if "source" not in page:
+ raise BuildError(
+ f'{route}: no "source". A page generated from a file names '
+ f'it; an authored page says "source": null.')
+ if page["source"] and not page.get("from"):
+ raise BuildError(
+ f'{route}: "source" is {page["source"]} but there is no '
+ f'"from" heading to slice from.')
+ if not page["source"] and (page.get("from") or page.get("to")):
+ raise BuildError(
+ f'{route}: "source" is null, so "from"/"to" have nothing '
+ f"to slice. Remove them or name a source.")
+ return manifest
+
+
+# ── output ────────────────────────────────────────────────────────────
+
+def clear(out: Path) -> None:
+ """Empty the output directory — but only one this builder made. A
+ --out pointed at something else stops the build instead."""
+ if not out.exists():
+ return
+ if not out.is_dir():
+ raise BuildError(f"{out} is not a directory")
+ if any(out.iterdir()) and not (out / MARKER).exists():
+ raise BuildError(
+ f"{out} is not empty and was not written by this builder "
+ f"(no {MARKER}). Refusing to delete it.")
+ shutil.rmtree(out)
+
+
+def copy_static(site: Path, out: Path) -> None:
+ static = site / "static"
+ if static.is_dir():
+ shutil.copytree(static, out / "static",
+ ignore=shutil.ignore_patterns(".DS_Store"))
+
+
+def missing_assets(out: Path) -> list:
+ """Every same-origin url() a stylesheet asks for that is not in the
+ output. The fonts are self-hosted on purpose — the shipped site makes
+ no third-party request — so an absent woff2 is worth saying out loud
+ even though the page still renders on the fallback stack."""
+ absent = []
+ for sheet in sorted((out / "static").rglob("*.css")):
+ for url in CSS_URL.findall(sheet.read_text(encoding="utf-8")):
+ if url.startswith(EXTERNAL):
+ continue
+ target = (out / url.lstrip("/")) if url.startswith("/") \
+ else (sheet.parent / url)
+ if not target.exists():
+ absent.append(url)
+ return sorted(set(absent))
+
+
+def build(*, repo: Path = REPO, site: Path = None, out: Path = None,
+ log=print) -> list:
+ site = site or (repo / "site")
+ out = out or (site / "dist")
+ manifest = load_manifest(site)
+
+ pages = [(page, render_page(page, manifest, site=site, repo=repo))
+ for page in manifest["pages"]]
+
+ clear(out)
+ out.mkdir(parents=True)
+ (out / MARKER).write_text(
+ "written by site/build.py; safe to delete\n", encoding="utf-8")
+ copy_static(site, out)
+
+ written = []
+ for page, html in pages:
+ target = out / page["path"].strip("/") / "index.html"
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(html, encoding="utf-8")
+ written.append(target)
+ log(f" {page['path']:<34} {target.relative_to(out)}")
+ return written
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(
+ description="Build the bench minisite into site/dist/.")
+ parser.add_argument("--repo", type=Path, default=REPO,
+ help="repository root (default: the one above site/)")
+ parser.add_argument("--site", type=Path, default=None,
+ help="site directory (default: /site)")
+ parser.add_argument("--out", type=Path, default=None,
+ help="output directory (default: /dist)")
+ parser.add_argument("-q", "--quiet", action="store_true")
+ args = parser.parse_args(argv)
+
+ repo = args.repo.resolve()
+ site = (args.site or repo / "site").resolve()
+ out = (args.out or site / "dist").resolve()
+ log = (lambda *a: None) if args.quiet else print
+
+ try:
+ written = build(repo=repo, site=site, out=out, log=log)
+ except BuildError as failure:
+ print(f"error: {failure}", file=sys.stderr)
+ return 1
+
+ for url in missing_assets(out):
+ print(f"warning: {url} is referenced by the stylesheet but is not "
+ f"in the build — see site/static/fonts/README.md",
+ file=sys.stderr)
+ log(f"{len(written)} pages → {out}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/site/fetch-fonts.py b/site/fetch-fonts.py
new file mode 100644
index 0000000..0b807c9
--- /dev/null
+++ b/site/fetch-fonts.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+"""Download the site's woff2 files into site/static/fonts/.
+
+ python3 site/fetch-fonts.py # fetch what is missing
+ python3 site/fetch-fonts.py --force # fetch everything again
+
+The site self-hosts its type: no page bench serves may make a request to
+a third party, and a linked font CDN is exactly that request. This script
+is the one moment the fonts come from Google — at build time, on someone's
+machine — after which the files are the site's own.
+
+It asks the CSS API for each face on its own, so the reply names exactly
+one woff2 per request and there is nothing to guess about which URL is
+which weight. The `latin` subset is what that API already serves.
+
+Stdlib only, network required. If you are offline, copy the files in by
+hand; site/static/fonts/README.md lists the seven names.
+"""
+
+import argparse
+import re
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+FONTS = Path(__file__).resolve().parent / "static" / "fonts"
+API = "https://fonts.googleapis.com/css2"
+# A modern browser UA is what makes the API answer in woff2 rather than
+# in one of the older formats it keeps for old clients.
+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/120.0 Safari/537.36")
+WOFF2 = re.compile(r"url\((https://[^)]+\.woff2)\)")
+
+# (output name, family, css spec) — the css spec is the `family=` value.
+FACES = [
+ ("IBMPlexSans-Regular.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@0,400"),
+ ("IBMPlexSans-Italic.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@1,400"),
+ ("IBMPlexSans-Medium.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@0,500"),
+ ("IBMPlexSans-SemiBold.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@0,600"),
+ ("IBMPlexMono-Regular.woff2", "IBM Plex Mono", "IBM+Plex+Mono:ital,wght@0,400"),
+ ("IBMPlexMono-Medium.woff2", "IBM Plex Mono", "IBM+Plex+Mono:ital,wght@0,500"),
+ ("ZillaSlab-SemiBold.woff2", "Zilla Slab", "Zilla+Slab:wght@600"),
+]
+
+
+def get(url: str, *, binary: bool = False):
+ request = urllib.request.Request(url, headers={"User-Agent": UA})
+ with urllib.request.urlopen(request, timeout=30) as reply:
+ raw = reply.read()
+ return raw if binary else raw.decode("utf-8")
+
+
+def fetch(face: tuple, force: bool) -> str:
+ name, family, spec = face
+ target = FONTS / name
+ if target.exists() and not force:
+ return f" kept {name}"
+ css = get(f"{API}?family={spec}&display=swap&subset=latin")
+ urls = WOFF2.findall(css)
+ if not urls:
+ raise RuntimeError(
+ f"{family}: the CSS API answered with no woff2 url. It may have "
+ f"changed its reply for this User-Agent; fetch by hand.")
+ # The API lists subsets in order; latin is the one the site needs and
+ # is the last block it emits for these families.
+ target.write_bytes(get(urls[-1], binary=True))
+ return f" wrote {name} ({len(urls)} subsets offered)"
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--force", action="store_true",
+ help="re-download faces that are already here")
+ args = parser.parse_args(argv)
+
+ FONTS.mkdir(parents=True, exist_ok=True)
+ print(f"fonts → {FONTS}")
+ for face in FACES:
+ try:
+ print(fetch(face, args.force))
+ except (urllib.error.URLError, RuntimeError) as failure:
+ print(f"error: {face[0]}: {failure}", file=sys.stderr)
+ return 1
+ print("done — the site now serves its own type, same-origin.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/site/pages.json b/site/pages.json
new file mode 100644
index 0000000..97cd9f6
--- /dev/null
+++ b/site/pages.json
@@ -0,0 +1,37 @@
+{
+ "site": {
+ "title": "bench",
+ "tagline": "docs",
+ "version": "0.2-alpha",
+ "description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.",
+ "base_url": "https://bench.12vectors.com",
+ "repo_url": "https://github.com/12vectors/bench",
+ "issues_url": "https://github.com/12vectors/bench/issues",
+ "blob_base": "https://github.com/12vectors/bench/blob/main/"
+ },
+
+ "link_routes": {},
+
+ "pages": [
+ {
+ "path": "/",
+ "title": "bench",
+ "layout": "home",
+ "section": null,
+ "description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.",
+ "source": "AGENTS.md",
+ "from": "## Stages",
+ "to": "## Moving a task"
+ },
+ {
+ "path": "/concepts/claiming-a-card/",
+ "title": "Claiming a card",
+ "layout": "article",
+ "section": "Concepts",
+ "description": "Claiming is moving: taking a card towards work is the commitment, and that is where ownership is recorded.",
+ "source": "AGENTS.md",
+ "from": "## Claiming a card",
+ "to": "## Syncing boards"
+ }
+ ]
+}
diff --git a/site/requirements.txt b/site/requirements.txt
new file mode 100644
index 0000000..3a26846
--- /dev/null
+++ b/site/requirements.txt
@@ -0,0 +1,11 @@
+# The minisite's only dependency, pinned.
+#
+# The stdlib-only law binds manager/core/ — the tool people install. This
+# directory is neither shipped nor installed (manager/core/release-manifest:
+# "Anything not listed here does not ship"), and AGENTS.md leans on fenced
+# code, nested lists, inline code and tables, which is a bad place to spend
+# risk on a hand-rolled parser.
+#
+# python3 -m pip install -r site/requirements.txt
+#
+markdown-it-py==4.0.0
diff --git a/site/static/favicon.svg b/site/static/favicon.svg
new file mode 100644
index 0000000..5e581bf
--- /dev/null
+++ b/site/static/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/site/static/fonts/README.md b/site/static/fonts/README.md
new file mode 100644
index 0000000..5ce4bf4
--- /dev/null
+++ b/site/static/fonts/README.md
@@ -0,0 +1,42 @@
+# The site's fonts live here
+
+Reading bench's documentation must not require a request to anyone else,
+so the site links no font CDN. `site/static/site.css` declares its faces
+against files in this directory, and everything the built pages fetch is
+same-origin.
+
+Seven files, exactly these names — they are what the `@font-face` rules
+in `site.css` ask for:
+
+| File | Family | Weight | Style |
+| --- | --- | --- | --- |
+| `IBMPlexSans-Regular.woff2` | IBM Plex Sans | 400 | normal |
+| `IBMPlexSans-Italic.woff2` | IBM Plex Sans | 400 | italic |
+| `IBMPlexSans-Medium.woff2` | IBM Plex Sans | 500 | normal |
+| `IBMPlexSans-SemiBold.woff2` | IBM Plex Sans | 600 | normal |
+| `IBMPlexMono-Regular.woff2` | IBM Plex Mono | 400 | normal |
+| `IBMPlexMono-Medium.woff2` | IBM Plex Mono | 500 | normal |
+| `ZillaSlab-SemiBold.woff2` | Zilla Slab | 600 | normal |
+
+## Fetching them
+
+```bash
+python3 site/fetch-fonts.py
+```
+
+The script asks Google Fonts for the CSS these faces would need, reads
+the `woff2` URLs out of the reply, and writes the files here under the
+names above. What it downloads is already the `latin` subset Google
+serves — the site's copy of the font, not a link to Google's.
+
+Both families are licensed for this: IBM Plex under the SIL Open Font
+License 1.1, Zilla Slab likewise. Keeping the downloaded `*.LICENSE.txt`
+beside the woff2 files satisfies the licence's one obligation.
+
+## If they are absent
+
+`site/build.py` prints a `warning:` line naming every file the
+stylesheet wants and the build does not have, and carries on. The pages
+render on the fallback stack (`system-ui`, `ui-monospace`, `Georgia`)
+and still make no third-party request — the typography is wrong, the
+privacy promise is not.
diff --git a/site/static/site.css b/site/static/site.css
new file mode 100644
index 0000000..0b377d9
--- /dev/null
+++ b/site/static/site.css
@@ -0,0 +1,303 @@
+/* ── Bench docs: the Daylight register of the board's own system ───────
+ The four state tokens are named exactly as manager/core/board.html
+ names them — --accent (surf), --calm (pine), --alarm (terracotta),
+ --idle (driftwood) — because the site and the board are one system,
+ and colour in both only ever means state. Their values are the board's
+ light theme; the neutrals are the docs design's paper register.
+
+ Fonts are self-hosted: reading bench's docs must not require a request
+ to anyone else. site/static/fonts/README.md says which files these
+ @font-face rules want and how to fetch them.
+ ------------------------------------------------------------------- */
+
+@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexSans-Regular.woff2") format("woff2")}
+@font-face{font-family:'IBM Plex Sans';font-style:italic;font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexSans-Italic.woff2") format("woff2")}
+@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexSans-Medium.woff2") format("woff2")}
+@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url("/static/fonts/IBMPlexSans-SemiBold.woff2") format("woff2")}
+@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2")}
+@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexMono-Medium.woff2") format("woff2")}
+@font-face{font-family:'Zilla Slab';font-style:normal;font-weight:600;font-display:swap;src:url("/static/fonts/ZillaSlab-SemiBold.woff2") format("woff2")}
+
+:root{
+ /* paper */
+ --bg:#c9dde1; --canvas:#eaf3f4; --surface:#ffffff; --sunken:#e0eef1;
+ --border:#c5d7db; --border-soft:#d9e6e9;
+ --text:#12323b; --muted:#4d6e77; --dim:#87a1a8;
+ /* state — the same four the board carries */
+ --accent:#0d6e8c; --calm:#5f7f33; --alarm:#b1543a; --idle:#93a8ac;
+ --on-accent:#ffffff;
+ /* dark ink: code, terminals, chrome */
+ --ink:#0c1a20; --ink-deep:#0a161b; --ink-line:#1c343a;
+ --ink-text:#e9f3f3; --ink-muted:#95afb4; --ink-dim:#62828a;
+ --ink-accent:#56c2d8; --ink-calm:#a6c96f; --ink-alarm:#e08a63;
+ /* tinted callouts, both state colours */
+ --calm-wash:#f4f8e6; --calm-edge:#d6e2a8; --calm-ink:#4a6329;
+ --alarm-wash:#fbf1ec; --alarm-edge:#ecc9b8; --alarm-ink:#7a3c2a;
+
+ --sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;
+ --mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
+ --display:'Zilla Slab',Georgia,'Times New Roman',serif;
+
+ /* type scale, straight off the design */
+ --t-hero:52px; --t-title:40px; --t-h2:23px; --t-h3:17px;
+ --t-lede:17px; --t-body:15px; --t-ui:13.5px; --t-code:12.5px;
+ --t-micro:10.5px;
+
+ --radius:11px; --shadow:0 20px 50px -34px rgba(18,50,59,.55);
+ --shadow-ink:0 18px 40px -30px rgba(12,26,32,.9);
+}
+
+*{box-sizing:border-box}
+html,body{margin:0;padding:0}
+body{
+ background:var(--canvas); color:var(--text);
+ font:var(--t-body)/1.68 var(--sans);
+ -webkit-font-smoothing:antialiased;
+}
+a{color:var(--accent);text-decoration:none}
+a:hover{color:var(--text);text-decoration:underline}
+::selection{background:var(--accent);color:var(--on-accent)}
+.mono{font-family:var(--mono)}
+.dim{color:var(--dim)}
+.spacer{flex:1}
+.rule{height:1px;background:var(--border-soft);margin:12px 0}
+@media (prefers-reduced-motion: reduce){
+ *,*::before,*::after{animation:none !important;transition:none !important}
+}
+
+/* ── chrome ── */
+.topbar{
+ display:flex;align-items:center;gap:10px;height:30px;padding:0 18px;
+ background:var(--text);color:#a9c4c9;
+ font:11px/1 var(--mono);
+}
+.topbar-org{color:var(--canvas);font-weight:500}
+.topbar-sep{color:var(--muted)}
+.topbar-repo{color:var(--ink-accent)}
+
+.masthead{
+ display:flex;align-items:center;gap:20px;padding:14px 30px;
+ background:var(--surface);border-bottom:1px solid var(--border);
+}
+.masthead-flat{background:var(--canvas);border-bottom:0}
+.wordmark{display:flex;align-items:baseline;gap:9px}
+.wordmark:hover{text-decoration:none}
+.wordmark-name{
+ font:600 21px/1 var(--display);letter-spacing:-.015em;color:var(--text);
+}
+.wordmark-tag{font:12px/1 var(--mono);color:var(--dim)}
+
+.nav{display:flex;gap:18px;font-size:var(--t-ui)}
+.nav-link{color:var(--muted);padding-bottom:2px;border-bottom:2px solid transparent}
+.nav-link:hover{color:var(--text);text-decoration:none}
+.nav-here{color:var(--text);font-weight:600;border-bottom-color:var(--accent)}
+
+.button{
+ display:inline-block;padding:7px 13px;font:500 var(--t-ui)/1.4 var(--sans);
+ color:var(--text);background:transparent;
+ border:1px solid var(--border);border-radius:8px;
+}
+.button:hover{border-color:var(--accent);color:var(--accent);text-decoration:none}
+.button-solid{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
+.button-solid:hover{background:var(--text);border-color:var(--text);color:var(--on-accent)}
+.button-lg{padding:11px 18px;font-size:14px}
+
+.footer{
+ display:flex;align-items:flex-end;gap:26px;padding:22px 26px;
+ background:var(--text);color:#a9c4c9;margin-top:40px;
+}
+.footer-mark{margin:0;font:9.5px/1.25 var(--mono);color:var(--muted)}
+.footer-id{display:flex;flex-direction:column;gap:4px}
+.footer-name{font:600 15px/1 var(--display);color:var(--canvas)}
+.footer-id .mono,.footer-repo{font-size:11px}
+.footer-repo{color:#a9c4c9}
+
+/* ── article layout (1a Harbour) ── */
+.shell{
+ display:grid;grid-template-columns:236px minmax(0,1fr) 208px;
+ background:var(--surface);
+}
+.side{
+ border-right:1px solid var(--border-soft);
+ padding:22px 16px 40px;display:flex;flex-direction:column;gap:22px;
+}
+.side-group{display:flex;flex-direction:column;gap:7px}
+.side-label{
+ font:var(--t-micro)/1 var(--mono);letter-spacing:.1em;
+ text-transform:uppercase;color:var(--dim);padding-left:9px;
+}
+.side-link{font-size:var(--t-ui);color:var(--muted);padding:4px 9px;border-radius:0 6px 6px 0}
+.side-link:hover{color:var(--text);text-decoration:none}
+.side-here{
+ font-weight:600;color:var(--text);background:var(--sunken);
+ border-left:2px solid var(--accent);
+}
+.side-note{
+ display:flex;flex-direction:column;gap:5px;margin-top:6px;
+ padding:11px 12px;background:var(--canvas);
+ border:1px solid var(--border-soft);border-radius:10px;
+ font-size:12.5px;line-height:1.45;color:var(--muted);
+}
+.side-note .mono{font-size:var(--t-micro)}
+
+.gutter{border-left:1px solid var(--border-soft);padding:30px 18px}
+.toc{display:flex;flex-direction:column;gap:9px;position:sticky;top:20px}
+.toc-label{
+ font:var(--t-micro)/1 var(--mono);letter-spacing:.1em;
+ text-transform:uppercase;color:var(--dim);margin-bottom:2px;
+}
+.toc-link{
+ font-size:12.5px;color:var(--muted);
+ border-left:2px solid var(--sunken);padding-left:9px;
+}
+.toc-link:hover{color:var(--accent);border-left-color:var(--accent);text-decoration:none}
+.gutter-link{font-size:12.5px;line-height:1.5;color:var(--muted)}
+.marginalia{
+ margin-top:36px;font:italic 11px/1.55 var(--mono);color:var(--alarm);
+ transform:rotate(-1.2deg);transform-origin:left top;
+}
+.marginalia .mono{font-size:11px}
+
+.crumbs{
+ display:flex;align-items:center;gap:8px;margin-bottom:16px;
+ font:11.5px/1 var(--mono);color:var(--dim);
+}
+.crumb-here{color:var(--muted)}
+
+/* ── generated prose ── */
+.page-article .prose{padding:30px 40px 44px;min-width:0}
+.prose h1{
+ font:600 var(--t-title)/1.1 var(--display);letter-spacing:-.02em;
+ margin:0 0 12px;
+}
+.prose h2{
+ font:600 var(--t-h2)/1.3 var(--display);letter-spacing:-.01em;
+ margin:34px 0 10px;scroll-margin-top:20px;
+}
+.prose h3{font:600 var(--t-h3)/1.4 var(--sans);margin:26px 0 8px}
+.prose h1 + p,.prose > p:first-child{
+ font-size:var(--t-lede);line-height:1.6;color:var(--muted);max-width:60ch;
+}
+.prose p{margin:0 0 16px;color:var(--muted);max-width:64ch;text-wrap:pretty}
+.prose strong{color:var(--text);font-weight:600}
+.prose ul,.prose ol{margin:0 0 18px;padding-left:22px;max-width:64ch;color:var(--muted)}
+.prose li{margin:0 0 7px}
+.prose li > ul,.prose li > ol{margin:7px 0 0}
+.prose hr{border:0;border-top:1px solid var(--border-soft);margin:30px 0}
+.prose blockquote{
+ margin:0 0 20px;padding:14px 16px;background:var(--canvas);
+ border:1px solid var(--border-soft);border-left:3px solid var(--accent);
+ border-radius:0 10px 10px 0;color:var(--text);
+}
+.prose blockquote p:last-child{margin:0}
+
+.prose code{
+ font:var(--t-code)/1.5 var(--mono);
+ background:var(--canvas);border-radius:4px;padding:1px 5px;
+ color:var(--text);
+}
+.prose pre{
+ margin:0 0 24px;padding:16px 18px;overflow-x:auto;
+ background:var(--ink);color:var(--ink-text);
+ font:var(--t-code)/1.75 var(--mono);
+ border-radius:var(--radius);box-shadow:var(--shadow-ink);
+}
+.prose pre code{background:transparent;padding:0;color:inherit;font-size:inherit}
+
+.prose table{
+ border-collapse:separate;border-spacing:0;margin:0 0 26px;width:100%;
+ background:var(--surface);border:1px solid var(--border-soft);
+ border-radius:10px;overflow:hidden;font-size:var(--t-ui);
+}
+.prose th{
+ text-align:left;padding:9px 14px;background:var(--canvas);
+ border-bottom:1px solid var(--border-soft);
+ font:var(--t-micro)/1.4 var(--mono);letter-spacing:.08em;
+ text-transform:uppercase;color:var(--dim);
+}
+.prose td{padding:11px 14px;border-bottom:1px solid var(--canvas);color:var(--muted);vertical-align:top}
+.prose tr:last-child td{border-bottom:0}
+
+/* ── home layout (1b Dockside) ── */
+.hero{
+ display:grid;grid-template-columns:1.05fr .95fr;gap:44px;
+ padding:44px 44px 40px;align-items:center;
+}
+.hero-copy{display:flex;flex-direction:column;gap:20px}
+.eyebrow{
+ font:11px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
+ color:var(--accent);
+}
+.hero h1{
+ margin:0;font:600 var(--t-hero)/1.05 var(--display);
+ letter-spacing:-.025em;text-wrap:balance;
+}
+.lede{
+ margin:0;max-width:44ch;font-size:var(--t-lede);line-height:1.62;
+ color:var(--muted);text-wrap:pretty;
+}
+.hero-actions{display:flex;gap:10px;align-items:center}
+.aside-note{font-size:11.5px;font-style:italic;color:var(--dim)}
+
+.terminal{border-radius:12px;overflow:hidden;box-shadow:0 24px 46px -30px rgba(12,26,32,.85)}
+.terminal-bar{
+ display:flex;align-items:center;gap:8px;padding:9px 14px;
+ background:var(--ink-deep);
+}
+.dot{width:8px;height:8px;border-radius:99px}
+.dot-alarm{background:var(--ink-alarm)}
+.dot-calm{background:var(--ink-calm)}
+.terminal-title{font-size:11px;color:var(--ink-dim);margin-left:6px}
+.terminal-body{
+ margin:0;padding:18px;background:var(--ink);color:var(--ink-text);
+ font:var(--t-code)/1.85 var(--mono);
+ white-space:pre-wrap;word-break:break-all;
+}
+.t-calm{color:var(--ink-calm)}
+.t-accent{color:var(--ink-accent)}
+.t-muted{color:var(--ink-muted)}
+.t-dim{color:var(--ink-dim)}
+
+.generated{padding:0 44px 34px}
+.page-home .prose{
+ background:var(--surface);border:1px solid var(--border-soft);
+ border-radius:12px;padding:28px 32px 12px;
+}
+.generated-heading{margin-top:0 !important}
+.generated-note{
+ margin:0 0 20px;font-size:11px;color:var(--dim);max-width:none;
+}
+
+.doors{
+ display:grid;grid-template-columns:repeat(3,1fr);gap:14px;
+ padding:0 44px 34px;
+}
+.door{
+ display:flex;flex-direction:column;gap:8px;padding:20px;
+ background:var(--surface);border:1px solid var(--border-soft);
+ border-radius:12px;
+}
+.door-dark{background:var(--text);border-color:var(--text);color:var(--canvas)}
+.door-index{font-size:11px;color:var(--dim)}
+.door-dark .door-index{color:#7fa0a8}
+.door-title{font:600 19px/1.2 var(--display)}
+.door-text{font-size:var(--t-ui);line-height:1.6;color:var(--muted)}
+.door-dark .door-text{color:#a9c4c9}
+.door .mono{font-size:var(--t-code)}
+
+/* ── narrow ── */
+@media (max-width:1080px){
+ .shell{grid-template-columns:220px minmax(0,1fr)}
+ .gutter{display:none}
+ .hero{grid-template-columns:1fr;gap:28px;padding:32px 24px}
+ .doors{grid-template-columns:repeat(2,1fr);padding:0 24px 28px}
+ .generated{padding:0 24px 28px}
+}
+@media (max-width:760px){
+ .shell{grid-template-columns:minmax(0,1fr)}
+ .side{display:none}
+ .page-article .prose{padding:24px 22px 36px}
+ .doors{grid-template-columns:1fr}
+ .hero h1{font-size:38px}
+ .masthead{padding:12px 18px;gap:12px}
+}
diff --git a/site/templates/article.html b/site/templates/article.html
new file mode 100644
index 0000000..c6e2460
--- /dev/null
+++ b/site/templates/article.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+$title · $site_title
+
+
+
+
+
+
+
+
Task files in stage directories are the only source of
+ truth. A stdlib-only board narrates what happens to them — agents
+ working in git worktrees, PRs opening on review, CI on the cards —
+ and never merges anything itself.