From 274ad6b2c62ec4cad781eca8db9977cfdc1b9bcb Mon Sep 17 00:00:00 2001
From: istos
Date: Fri, 31 Jul 2026 12:10:25 +0200
Subject: [PATCH] site: the landing page, on things bench really does
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Turn design 1b (Dockside) into `/`: a terminal hero, the claim README.md
already makes, six doors, and a strip at the foot. The layout is task
31's; what this commit is really about is that nothing factual on the
page is typed by hand.
- **Two facts are read, not written.** `site/build.py` reads the install
one-liner out of README.md's "Install into a repo" block and the
version out of `manager/core/VERSION`, and offers them to the template
as `$install_block` and `$version`. A renamed section, a missing
VERSION or an install section that lost its command block stops the
build, exactly as a renamed heading already did. `pages.json` loses
its `version` key, and a build refuses one if it comes back.
- **A dead internal link stops the build.** Every href a rendered page
emits — a door as much as a link inside a slice — must resolve to a
route in the manifest or a file in `static/`/`root/`. The check runs
after rendering and before writing, so a bad link leaves the last good
build standing rather than shipping a 404 with a nice typeface.
- **Six real doors.** `pages.json` grows the routes they open: install
and first run, the five stages, agents on the board, PRs and review,
team mode, the three-layer law. They are heading slices, i.e. the
stub routes task 33 expected and task 34 will re-cut.
- **The terminal is a transcript.** README.md's own command, then lines
install.py and board.py really print, with the abridgement declared in
the terminal's title bar. `tests/test_site_landing.py` holds every one
of those lines against the source that prints it, so a reworded prompt
fails the suite instead of quietly making the page fiction.
- **No fake telemetry.** Turn 1's "most opened this week" strip becomes
the version, read from VERSION, and a link to the releases.
The landing page no longer carries a generated body, so the tests that
read one from it now read `/concepts/stages/`, and the two scratch repos
in the suite copy VERSION alongside the markdown.
python3 -m unittest: 407 tests, OK.
Co-Authored-By: Claude Opus 5
---
site/README.md | 14 ++
site/build.py | 164 ++++++++++++++++++-
site/pages.json | 64 +++++++-
site/static/site.css | 47 ++++--
site/templates/home.html | 109 ++++++++-----
tests/test_site_build.py | 98 +++++++-----
tests/test_site_deploy.py | 5 +-
tests/test_site_landing.py | 316 +++++++++++++++++++++++++++++++++++++
8 files changed, 718 insertions(+), 99 deletions(-)
create mode 100644 tests/test_site_landing.py
diff --git a/site/README.md b/site/README.md
index 6409878..cbd78c4 100644
--- a/site/README.md
+++ b/site/README.md
@@ -11,6 +11,13 @@ 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.
+The landing page is the exception that proves it. Its words are authored
+in `templates/home.html` (`"source": null`), so the two facts it must
+never get subtly wrong are not written there at all: the install
+one-liner is read out of `README.md`'s "Install into a repo" block and
+the version out of `manager/core/VERSION`, and the template is offered
+them as `$install_block` and `$version`.
+
```bash
python3 -m pip install -r site/requirements.txt # once
python3 site/fetch-fonts.py # once, needs network
@@ -168,6 +175,13 @@ Each of these exits non-zero with a message naming the route:
- 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;
+- an internal link on any rendered page — a door on the landing page as
+ much as a link inside a slice — that resolves to nothing this build
+ writes;
+- a missing or empty `manager/core/VERSION`, or a `README.md` whose
+ "Install into a repo" section has lost its command block;
+- a `version` key in `pages.json`, which would be a second copy of a
+ number that has one home;
- a template placeholder the builder does not supply;
- a file in `root/` that a route would also write.
diff --git a/site/build.py b/site/build.py
index c3c8e6e..4977b14 100644
--- a/site/build.py
+++ b/site/build.py
@@ -29,6 +29,17 @@ 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.
+## What an authored page is still not allowed to type
+
+The landing page's words are authored in its template rather than sliced,
+which would be a hole in the promise above if it extended to facts. It
+does not: the install one-liner and the version reach the template as
+`$install_block` and `$version`, read from `README.md` and
+`manager/core/VERSION` (see `repo_facts`). And every internal link any
+page emits — a door on the landing page as much as a link inside a slice
+— must resolve to something this build writes, or the build stops
+(`check_links`).
+
## What the host needs from the build
Two things here exist for the way the site is served (site/wrangler.jsonc,
@@ -75,8 +86,19 @@ STAMPED = {"stylesheet": "static/site.css", "icon": "static/favicon.svg"}
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*\)""")
+LINK = re.compile(r"""(?:href|src)=["']([^"']+)["']""", re.IGNORECASE)
+COMMENT = re.compile(r"\s{2,}#")
EXTERNAL = ("http://", "https://", "//", "mailto:", "tel:", "data:")
+# The two facts the landing page must never carry a hand-typed copy of,
+# and the files that own them. A subtly wrong `curl` is worse than no
+# landing page at all, and a version the release does not have is a
+# support question; both are read, and a source that moved stops the
+# build exactly as a renamed heading does.
+VERSION_FILE = "manager/core/VERSION"
+INSTALL_SOURCE = "README.md"
+INSTALL_HEADING = "## Install into a repo"
+
class BuildError(Exception):
"""A failure a person must read and fix: a missing source, a renamed
@@ -172,6 +194,91 @@ def slice_section(text: str, page: dict, source: str) -> str:
return promote(body, level - 1)
+# ── facts read out of the repo ────────────────────────────────────────
+
+def read_version(repo: Path) -> str:
+ """`manager/core/VERSION`, which is the version `release.sh` tags."""
+ path = repo / VERSION_FILE
+ if not path.is_file():
+ raise BuildError(
+ f"the site shows the version from {VERSION_FILE}, which does "
+ f"not exist. It is the one place the version is written; the "
+ f"site does not keep a second copy.")
+ version = path.read_text(encoding="utf-8").strip()
+ if not version:
+ raise BuildError(f"{VERSION_FILE} is empty.")
+ return version
+
+
+def install_command(repo: Path) -> list:
+ """The lines of README.md's install one-liner, verbatim.
+
+ It is the command people paste, so the landing page reads it out of
+ the file that documents it rather than transcribing it. Editing the
+ README moves the page; renaming the section stops the build."""
+ path = repo / INSTALL_SOURCE
+ if not path.is_file():
+ raise BuildError(
+ f"the install command is read out of {INSTALL_SOURCE}, which "
+ f"does not exist.")
+ text = path.read_text(encoding="utf-8")
+ marks = list(headings(text))
+ start = find_heading(marks, INSTALL_HEADING)
+ if start is None:
+ raise BuildError(
+ f'{INSTALL_SOURCE} has no heading "{INSTALL_HEADING}" — the '
+ f"landing page reads the install command from that section. "
+ f"Fix the heading, or INSTALL_HEADING in site/build.py.")
+ start_line, level = start
+ 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())
+
+ block, fence = [], None
+ for line in text.splitlines()[start_line + 1:end_line]:
+ opener = FENCE.match(line)
+ if opener and fence is None:
+ fence = opener.group(1)
+ continue
+ if opener:
+ break
+ if fence is not None:
+ block.append(line)
+ if not block:
+ raise BuildError(
+ f'{INSTALL_SOURCE}: the "{INSTALL_HEADING}" section has no '
+ f"fenced command block. The landing page's terminal has "
+ f"nothing to show.")
+ return block
+
+
+def render_command(lines: list) -> str:
+ """A command block as terminal html: every line escaped and otherwise
+ verbatim, a prompt on the lines that begin a command — a line after
+ one ending in a backslash is a continuation, not a new command — and
+ a dim tail for a trailing comment."""
+ out, continued = [], False
+ for line in lines:
+ prompt = "" if continued else '$ '
+ continued = line.rstrip().endswith("\\")
+ found = COMMENT.search(line)
+ body, tail = (line[:found.start()], line[found.start():]) if found \
+ else (line, "")
+ html = escape(body)
+ if tail:
+ html += f'{escape(tail)}'
+ out.append(prompt + html)
+ return "\n".join(out)
+
+
+def repo_facts(repo: Path) -> dict:
+ """What the templates are offered instead of typing it themselves."""
+ return {
+ "version": read_version(repo),
+ "install_block": render_command(install_command(repo)),
+ }
+
+
# ── links ─────────────────────────────────────────────────────────────
def rewrite_link(href: str, *, page: dict, source: str, manifest: dict,
@@ -201,6 +308,42 @@ def rewrite_link(href: str, *, page: dict, source: str, manifest: dict,
return blob + rel + hash_mark + fragment
+def internal_targets(manifest: dict, site: Path) -> set:
+ """Every url the built site answers: one per route (and the file that
+ route is written to), plus everything copied verbatim out of static/
+ and root/."""
+ urls = set()
+ for page in manifest["pages"]:
+ urls.add(page["path"])
+ urls.add("/" + target_for(Path(), page["path"]).as_posix())
+ for base, prefix in ((site / "static", "/static/"), (site / ROOT, "/")):
+ if base.is_dir():
+ urls.update(prefix + path.relative_to(base).as_posix()
+ for path in base.rglob("*") if path.is_file())
+ return urls
+
+
+def check_links(html: str, page: dict, targets: set) -> None:
+ """A link on a rendered page that the build does not write is a 404
+ with a nice typeface. Markdown links out of a source file are already
+ resolved (rewrite_link); this is the other half — what the templates
+ themselves point at, which is where the landing page's doors live."""
+ for url in LINK.findall(html):
+ if not url or url.startswith("#") or url.startswith(EXTERNAL):
+ continue
+ if not url.startswith("/"):
+ raise BuildError(
+ f'{page["path"]}: links to "{url}". A page is written to '
+ f"its own directory, so a relative link resolves against "
+ f"that; write internal links from the root.")
+ if url.split("#")[0].split("?")[0] not in targets:
+ raise BuildError(
+ f'{page["path"]}: links to "{url}", which this build does '
+ f"not write. Every internal link must resolve to a route "
+ f"in site/pages.json or a file in site/static/ or "
+ f"site/{ROOT}/.")
+
+
# ── rendering ─────────────────────────────────────────────────────────
def slugify(text: str) -> str:
@@ -349,7 +492,7 @@ def stamp(site: Path) -> dict:
def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
- stamps: dict = None) -> str:
+ stamps: dict = None, facts: dict = None) -> str:
source = page.get("source")
if source:
body, contents = render_markdown(
@@ -363,6 +506,7 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
config = manifest["site"]
blob = config["blob_base"].rstrip("/") + "/"
stamps = stamps if stamps is not None else stamp(site)
+ facts = facts if facts is not None else repo_facts(repo)
fields = {
"stylesheet": stamps["stylesheet"],
"icon": stamps["icon"],
@@ -371,7 +515,8 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
or config.get("description", "")),
"site_title": escape(config["title"]),
"site_tagline": escape(config.get("tagline", "")),
- "version": escape(config.get("version", "")),
+ "version": escape(facts["version"]),
+ "install_block": facts["install_block"],
"body": body,
"toc": render_contents(contents),
"nav": render_nav(manifest, page),
@@ -380,6 +525,7 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
"section": escape(page.get("section") or ""),
"repo_url": config["repo_url"],
"issues_url": config.get("issues_url", config["repo_url"]),
+ "releases_url": config.get("releases_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"],
@@ -414,6 +560,11 @@ def load_manifest(site: Path) -> dict:
for key in ("title", "repo_url", "blob_base"):
if key not in manifest["site"]:
raise BuildError(f'pages.json: site has no "{key}" key')
+ if "version" in manifest["site"]:
+ raise BuildError(
+ f'pages.json: site has a "version" key, but the version the '
+ f"site shows is read from {VERSION_FILE}. Two copies of a "
+ f"version is one copy too many — remove it.")
seen = set()
for page in manifest["pages"]:
@@ -526,6 +677,7 @@ def build(*, repo: Path = REPO, site: Path = None, out: Path = None,
out = out or (site / "dist")
manifest = load_manifest(site)
stamps = stamp(site)
+ facts = repo_facts(repo)
# A file in root/ that a route also claims would be silently replaced
# by whichever is written last, so it is a build failure instead.
@@ -539,9 +691,15 @@ def build(*, repo: Path = REPO, site: Path = None, out: Path = None,
f"{relative}. Rename one — the build will not pick a winner.")
pages = [(page, render_page(page, manifest, site=site, repo=repo,
- stamps=stamps))
+ stamps=stamps, facts=facts))
for page in manifest["pages"]]
+ # After rendering, before writing: a dead internal link leaves the
+ # last good build standing, exactly as a renamed heading does.
+ targets = internal_targets(manifest, site)
+ for page, html in pages:
+ check_links(html, page, targets)
+
clear(out)
out.mkdir(parents=True)
(out / MARKER).write_text(
diff --git a/site/pages.json b/site/pages.json
index b96a063..335315a 100644
--- a/site/pages.json
+++ b/site/pages.json
@@ -2,11 +2,11 @@
"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",
+ "releases_url": "https://github.com/12vectors/bench/releases",
"blob_base": "https://github.com/12vectors/bench/blob/main/"
},
@@ -15,10 +15,28 @@
"pages": [
{
"path": "/",
- "title": "bench",
+ "title": "bench — a live kanban for coding-agent work",
"layout": "home",
"section": null,
- "description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.",
+ "description": "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.",
+ "source": null
+ },
+ {
+ "path": "/guides/install/",
+ "title": "Install and first run",
+ "layout": "article",
+ "section": "Guides",
+ "description": "Untar bench into .task-manager/, run start.sh, and answer the three questions the first run asks.",
+ "source": "README.md",
+ "from": "## Install into a repo",
+ "to": "## Update"
+ },
+ {
+ "path": "/concepts/stages/",
+ "title": "The five stages",
+ "layout": "article",
+ "section": "Concepts",
+ "description": "backlog, to-do, in-progress, review, done — the directory a task file sits in is its status, and there is no other source of truth.",
"source": "AGENTS.md",
"from": "## Stages",
"to": "## Moving a task"
@@ -33,6 +51,46 @@
"from": "## Claiming a card",
"to": "## Syncing boards"
},
+ {
+ "path": "/concepts/agents-on-the-board/",
+ "title": "Agents on the board",
+ "layout": "article",
+ "section": "Concepts",
+ "description": "Start work makes a worktree and a branch, runs the agent headless, and moves the card on the way it exits.",
+ "source": "AGENTS.md",
+ "from": "## Agents working the board",
+ "to": "## Pull requests"
+ },
+ {
+ "path": "/concepts/pull-requests/",
+ "title": "PRs and review",
+ "layout": "article",
+ "section": "Concepts",
+ "description": "A card entering review gets a PR opened for it by the board; then review PR, copilot and act on PR, until it settles.",
+ "source": "AGENTS.md",
+ "from": "## Pull requests",
+ "to": "## Stages"
+ },
+ {
+ "path": "/concepts/team-mode/",
+ "title": "Team mode",
+ "layout": "article",
+ "section": "Concepts",
+ "description": "BOARD_SYNC makes origin/main the truth and every board a converging replica: moves commit and push themselves, and a beat pulls what the other boards published.",
+ "source": "AGENTS.md",
+ "from": "## Syncing boards",
+ "to": "## Task file format"
+ },
+ {
+ "path": "/concepts/three-layer-law/",
+ "title": "The three-layer law",
+ "layout": "article",
+ "section": "Concepts",
+ "description": "Core knows tasks, worktrees, PRs and events. Drivers know apps, adapters know agent vendors, local/ knows your project.",
+ "source": "README.md",
+ "from": "## The three-layer law",
+ "to": "## License"
+ },
{
"path": "/404.html",
"title": "Not found",
diff --git a/site/static/site.css b/site/static/site.css
index d12d9ab..d4b1abe 100644
--- a/site/static/site.css
+++ b/site/static/site.css
@@ -248,26 +248,25 @@ a:hover{color:var(--text);text-decoration:underline}
.dot-alarm{background:var(--ink-alarm)}
.dot-calm{background:var(--ink-calm)}
.terminal-title{font-size:11px;color:var(--ink-dim);margin-left:6px}
+/* The transcript below is real output with the long stretches cut, and
+ the bar is where it says so — a claim about the block, not decoration. */
+.terminal-note{font-size:10.5px;color:var(--ink-dim);letter-spacing:.04em}
.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;
+ /* The release url is 72 characters and has to wrap rather than clip;
+ `anywhere` breaks it, and breaks a word only when a word is what
+ does not fit — the transcript's prose lines wrap normally. */
+ white-space:pre-wrap;overflow-wrap:anywhere;
}
.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;
-}
-
+/* Every door is a link to a page this site builds — the whole card is
+ the target, so the hover is the card's border rather than an
+ underline under three words of it. */
.doors{
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;
padding:0 44px 34px;
@@ -275,9 +274,11 @@ a:hover{color:var(--text);text-decoration:underline}
.door{
display:flex;flex-direction:column;gap:8px;padding:20px;
background:var(--surface);border:1px solid var(--border-soft);
- border-radius:12px;
+ border-radius:12px;color:var(--text);
}
+.door:hover{border-color:var(--accent);color:var(--text);text-decoration:none}
.door-dark{background:var(--text);border-color:var(--text);color:var(--canvas)}
+.door-dark:hover{border-color:var(--accent);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)}
@@ -285,6 +286,26 @@ a:hover{color:var(--text);text-decoration:underline}
.door-dark .door-text{color:#a9c4c9}
.door .mono{font-size:var(--t-code)}
+/* The strip at the foot of the landing page. It carries the version and
+ nothing else measurable, because the version is the only thing about
+ itself this site can measure. */
+.strip{
+ display:flex;align-items:center;gap:14px;flex-wrap:wrap;
+ padding:18px 44px;background:var(--sunken);
+ border-top:1px solid var(--border-soft);
+}
+.strip-label{
+ font-size:var(--t-micro);letter-spacing:.1em;text-transform:uppercase;
+ color:var(--dim);
+}
+.strip-version{
+ font-size:var(--t-code);padding:4px 10px;background:var(--surface);
+ border:1px solid var(--border);border-radius:99px;color:var(--accent);
+}
+.strip-text{font-size:12.5px;line-height:1.55;color:var(--muted);max-width:64ch}
+.strip-text .mono{font-size:var(--t-code)}
+.strip-link{font-size:12px;color:var(--accent)}
+
/* ── 404 (layout "notfound") ── */
.lost{
display:flex;flex-direction:column;align-items:flex-start;gap:18px;
@@ -304,7 +325,7 @@ a:hover{color:var(--text);text-decoration:underline}
.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}
+ .strip{padding:18px 24px}
}
@media (max-width:760px){
.shell{grid-template-columns:minmax(0,1fr)}
diff --git a/site/templates/home.html b/site/templates/home.html
index a41bc61..430e165 100644
--- a/site/templates/home.html
+++ b/site/templates/home.html
@@ -2,20 +2,37 @@
+ markup would have to be doubled. -->
-$site_title · $site_tagline
+$title
+
+
+
+
+
+
+
@@ -26,7 +43,7 @@
/bench
- $version
+ docs for $version
@@ -50,8 +67,8 @@ $nav
working in git worktrees, PRs opening on review, CI on the cards —
and never merges anything itself.
$install_block
- No .task-manager/manager/local/.env yet — a few questions and bench writes one.
- solo or team? [solo]:
- which agent adapter? [claude]:
- what command runs this project's tests? [python3 -m unittest]:
+No .task-manager/manager/local/.env yet — a few questions and bench writes one.
+Enter takes the default in [brackets]; Ctrl-D skips the rest.
- Task board for ~/your-repo/.task-manager/tasks
- http://127.0.0.1:26071/
- Ctrl-C to stop
-
-
+ solo or team? [solo]:
+ which agent adapter? [claude]:
+ what command runs this project's tests? [python3 -m unittest]:
-
-
-
The five stages
-
from $source_path — the
- section below is this file's, not a retelling of it
-$body
+Wrote .task-manager/manager/local/.env — every other setting is in there, commented; edit it any time.
+
+ …
+
+Task board for ~/your-repo/.task-manager/tasks
+ http://127.0.0.1:26071/
+ Ctrl-C to stop
+
+
+
+
+
+ Version
+ $version
+ read from manager/core/VERSION
+ when this page was built. update.sh replaces an
+ install with the latest release; BENCH_REF=v2
+ pins an exact one.
+
+ Releases ↗