From 7ab45f49fdced0b7261cbe4b6456ace152cf68e7 Mon Sep 17 00:00:00 2001
From: istos
Date: Fri, 31 Jul 2026 14:29:49 +0200
Subject: [PATCH] site: the guides and concept pages, in the 1a Harbour layout
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fills the middle of the site. Nine routes, every body a heading slice of
AGENTS.md or README.md, and the article layout given the furniture the
design calls for.
The manifest gains the two concepts nothing covered: /concepts/task-files/
(the header format, from AGENTS.md's own section) and /concepts/adapters/
(the adapter summary, which is the other half of the three-layer law).
/concepts/stages/ now runs through "Moving a task", because the five
directories and moving between them are one idea.
The layout:
- A lede under the title — the one sentence an article authors, taken
from the manifest's `description` or an explicit `lede` where the two
want different words. A slice starts mid-document; a reader arriving
from the nav is owed a line saying what they are looking at.
- Prev/next at the foot, walking the sidebar's own order so the arrows
and the rail cannot disagree. Pages with no section (the landing page,
the 404) are not on the flow.
- "Edit this page on GitHub" anchors to the section the page was cut
from, built from the same `from` heading the slice starts at.
Two bugs the new pages found:
- string.Template substitutes inside HTML comments, so a comment naming
the body placeholder emitted the whole body twice and closed itself
early on the first `-->` in it.
- Promotion could produce a second
. A slice that deliberately runs
past its own section carries headings at the `from` level, and those
promoted to h1 on a page that already had one. Promotion now stops at
h2, where they read as peers — which is what putting them on one page
said in the first place.
tests/test_site_pages.py covers the furniture on the real built site:
the routes, the layout, the sidebar marking one page, the contents list
being exactly the body's own h2s in order, the prev/next chain end to
end, the edit link's anchor, the six landing-page doors, and a table, a
fenced block and a nested list surviving the renderer. The scratch-repo
helper now copies every file a slice links to, since the builder checks
those exist.
Co-Authored-By: Claude Opus 5
---
site/README.md | 30 ++-
site/build.py | 88 ++++++++-
site/pages.json | 22 ++-
site/static/site.css | 24 +++
site/templates/article.html | 6 +
tests/test_site_build.py | 22 ++-
tests/test_site_deploy.py | 10 +-
tests/test_site_pages.py | 355 ++++++++++++++++++++++++++++++++++++
8 files changed, 537 insertions(+), 20 deletions(-)
create mode 100644 tests/test_site_pages.py
diff --git a/site/README.md b/site/README.md
index cbd78c4..a8c8d4f 100644
--- a/site/README.md
+++ b/site/README.md
@@ -18,6 +18,13 @@ 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`.
+An article page authors one sentence of its own — the lede under the
+title — because a slice starts mid-document and a reader arriving from
+the nav is owed a line saying what they are looking at. That is the whole
+allowance. When a section reads badly on the web, the fix is the section:
+edit `AGENTS.md` so it reads well in both places rather than forking the
+prose into this directory.
+
```bash
python3 -m pip install -r site/requirements.txt # once
python3 site/fetch-fonts.py # once, needs network
@@ -150,21 +157,32 @@ answers rather than files:
written to exactly that path — `/404.html` is the only one, and it
exists because the host looks for that literal filename.
- **`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. A `null` section keeps the page out of both,
- which is how the 404 page stays off the nav.
+- **`section`** groups the page in the nav and the sidebar, and puts it
+ on the reading order prev/next walks. The IA is read out of this file
+ in this file's order — nothing is derived from the directory layout. A
+ `null` section keeps the page out of the nav, the sidebar and the flow,
+ which is how the 404 page stays off all three.
- **`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.
+ Headings inside fenced code blocks never match. It is also what
+ "Edit this page on GitHub" anchors to, so the link opens the section
+ rather than the top of a 700-line file.
- **`to`** is optional. Without it the slice runs to the next heading of
the same level or shallower.
+- **`description`** is the page's meta description, and doubles as the
+ visible lede under the title.
+- **`lede`** is optional, and only worth setting when the sentence a
+ reader should see differs from the one a search engine should. It is
+ the only prose a page may author.
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.
+sub-headings land as the page's `
`s. Promotion stops at `
`: a
+slice that deliberately runs past its own section ("Stages" through
+"Moving a task") carries headings at the `from` level, and those become
+`
` peers rather than a second `
` on a page that already has one.
## What fails the build
diff --git a/site/build.py b/site/build.py
index 4977b14..97f2932 100644
--- a/site/build.py
+++ b/site/build.py
@@ -40,6 +40,13 @@ 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`).
+An article page authors exactly one sentence of its own: the lede under
+the title (`$lede`, the manifest's `lede` or its `description`). A slice
+begins mid-document, so a reader arriving from the nav is owed a line
+saying what they are looking at — but that is the whole allowance, and a
+manifest entry that tried to carry a body would still have nowhere to put
+it.
+
## What the host needs from the build
Two things here exist for the way the site is served (site/wrangler.jsonc,
@@ -151,13 +158,20 @@ def find_heading(marks: list, value: str, after: int = -1):
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
."""
+ starting at
under the page's own
.
+
+ Never above
. A slice that runs past the end of its own section —
+ "Stages" through "Moving a task", one page about one idea — carries
+ headings at the `from` heading's own level, and those would promote to
+ a second
on a page that already has one. They land beside the
+ section's children as
instead: on the page they are peers, which
+ is what putting them on one page said."""
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()
+ lines[index] = "#" * max(2, level - by) + " " + found.group(2).strip()
return "\n".join(lines)
@@ -281,6 +295,17 @@ def repo_facts(repo: Path) -> dict:
# ── links ─────────────────────────────────────────────────────────────
+def github_anchor(heading: str) -> str:
+ """GitHub's own anchor for a heading, so "Edit this page" lands on the
+ section the page was cut from rather than at the top of a 700-line
+ file. GitHub lowercases, drops punctuation that is not a hyphen or an
+ underscore, and turns spaces into hyphens — which is not quite
+ slugify()'s rule (that one collapses runs), so it is written out here
+ rather than shared."""
+ text = heading.lstrip("#").strip().lower()
+ return re.sub(r"[^\w\- ]", "", text).replace(" ", "-")
+
+
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
@@ -442,6 +467,43 @@ def render_sidebar(manifest: dict, current: dict) -> str:
return "\n".join(out)
+def flow(manifest: dict) -> list:
+ """The pages in reading order — the sidebar, flattened. Prev/next walks
+ this list, so what the arrows do and what the sidebar shows cannot
+ disagree. A page with no section (the landing page, the 404) is not on
+ the flow and gets no arrows."""
+ return [page for group in sections(manifest) for page in group["pages"]]
+
+
+def render_flow(manifest: dict, current: dict) -> str:
+ """The two arrows at the foot of an article. Absent neighbours keep
+ their slot as an empty span, so `next` stays on the right on the first
+ page exactly as it does on every other."""
+ order = flow(manifest)
+ here = next((index for index, page in enumerate(order)
+ if page["path"] == current["path"]), None)
+ if here is None:
+ return ""
+ neighbours = (
+ (order[here - 1] if here > 0 else None, "prev", "← previous"),
+ (order[here + 1] if here + 1 < len(order) else None, "next", "next →"),
+ )
+ if not any(page for page, _, _ in neighbours):
+ return ""
+ out = ['")
+ return "\n".join(out)
+
+
def render_contents(contents: list) -> str:
if not contents:
return ""
@@ -507,12 +569,31 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
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)
+
+ # "Edit this page" is a promise that the reader lands on the thing that
+ # is wrong. For a sliced page that is the section, not the file: an
+ # anchor built from the same `from` heading the slice starts at, so the
+ # two cannot point at different places.
+ source_url = config["repo_url"]
+ if source:
+ source_url = blob + source
+ anchor = github_anchor(page["from"])
+ if anchor:
+ source_url += "#" + anchor
+
fields = {
"stylesheet": stamps["stylesheet"],
"icon": stamps["icon"],
"title": escape(page["title"]),
"description": escape(page.get("description")
or config.get("description", "")),
+ # The design's lede. It is the one sentence a page is allowed to
+ # author, because a slice starts mid-document and a reader arriving
+ # from the nav needs to be told what they are looking at; the
+ # manifest's own `description` says that already, so `lede` only
+ # exists for the pages where the two want different words.
+ "lede": escape(page.get("lede") or page.get("description")
+ or config.get("description", "")),
"site_title": escape(config["title"]),
"site_tagline": escape(config.get("tagline", "")),
"version": escape(facts["version"]),
@@ -521,12 +602,13 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
"toc": render_contents(contents),
"nav": render_nav(manifest, page),
"sidebar": render_sidebar(manifest, page),
+ "flow": render_flow(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"]),
"releases_url": config.get("releases_url", config["repo_url"]),
- "source_url": (blob + source) if source else config["repo_url"],
+ "source_url": source_url,
"source_path": escape(source or ""),
"canonical": config.get("base_url", "").rstrip("/") + page["path"],
}
diff --git a/site/pages.json b/site/pages.json
index 335315a..a3ee4a5 100644
--- a/site/pages.json
+++ b/site/pages.json
@@ -39,7 +39,16 @@
"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"
+ "to": "## Claiming a card"
+ },
+ {
+ "path": "/concepts/task-files/",
+ "title": "Task files",
+ "layout": "article",
+ "section": "Concepts",
+ "description": "A task is a markdown file with a numbered name and a short header. Status is the only field the board enforces.",
+ "source": "AGENTS.md",
+ "from": "## Task file format"
},
{
"path": "/concepts/claiming-a-card/",
@@ -91,6 +100,17 @@
"from": "## The three-layer law",
"to": "## License"
},
+ {
+ "path": "/concepts/adapters/",
+ "title": "Agent adapters",
+ "layout": "article",
+ "section": "Concepts",
+ "description": "Headless jobs run through an adapter, so the board works with coding agents other than Claude Code — and never sees a vendor's payloads.",
+ "lede": "The adapter is the layer that knows a coding agent. It launches one headless job, and translates that vendor's events into the board's own schema — which is what keeps every other line of core free of any particular agent.",
+ "source": "AGENTS.md",
+ "from": "## Agent adapters",
+ "to": "## Drives"
+ },
{
"path": "/404.html",
"title": "Not found",
diff --git a/site/static/site.css b/site/static/site.css
index d4b1abe..d832bb2 100644
--- a/site/static/site.css
+++ b/site/static/site.css
@@ -166,6 +166,13 @@ a:hover{color:var(--text);text-decoration:underline}
/* ── generated prose ── */
.page-article .prose{padding:30px 40px 44px;min-width:0}
+/* The lede is the article's own sentence; everything after it is the
+ slice. It reads wider and quieter than body copy, as in the design. */
+.prose-lede{
+ margin:0 0 26px;max-width:60ch;
+ font-size:var(--t-lede);line-height:1.6;color:var(--muted);
+ text-wrap:pretty;
+}
.prose h1{
font:600 var(--t-title)/1.1 var(--display);letter-spacing:-.02em;
margin:0 0 12px;
@@ -218,6 +225,23 @@ a:hover{color:var(--text);text-decoration:underline}
.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}
+/* Prev/next along the same order the sidebar shows. An absent neighbour
+ leaves a .spacer in its slot, so `next →` stays right-hand on the first
+ page exactly as it does on every other. */
+.flow{
+ display:flex;gap:14px;margin-top:44px;padding-top:22px;
+ border-top:1px solid var(--border-soft);
+}
+.flow-link{
+ display:flex;flex-direction:column;gap:5px;flex:0 1 auto;max-width:46%;
+ padding:12px 16px;background:var(--surface);
+ border:1px solid var(--border-soft);border-radius:11px;color:var(--text);
+}
+.flow-link:hover{border-color:var(--accent);color:var(--text);text-decoration:none}
+.flow-next{margin-left:auto;text-align:right}
+.flow-dir{font-size:var(--t-micro);letter-spacing:.08em;color:var(--dim)}
+.flow-title{font:600 15px/1.3 var(--display)}
+
/* ── home layout (1b Dockside) ── */
.hero{
display:grid;grid-template-columns:1.05fr .95fr;gap:44px;
diff --git a/site/templates/article.html b/site/templates/article.html
index b4d17a2..38e198c 100644
--- a/site/templates/article.html
+++ b/site/templates/article.html
@@ -51,7 +51,13 @@ $sidebar
$breadcrumb
$title
+
+
$lede
$body
+$flow
', re.S)
+
+
+class BuiltSite(unittest.TestCase):
+ """The real manifest, built once into a scratch directory."""
+
+ @classmethod
+ def setUpClass(cls):
+ if not HAS_MARKDOWN_IT:
+ raise unittest.SkipTest("markdown-it-py is not installed")
+ cls.out = Path(tempfile.mkdtemp(prefix="bench-pages-")).resolve()
+ cls.result = run_build(REPO, cls.out)
+ if cls.result.returncode != 0: # not assert: must survive python -O
+ raise RuntimeError(
+ f"site/build.py failed:\n{cls.result.stdout}"
+ f"{cls.result.stderr}")
+ cls.manifest = json.loads(
+ (SITE / "pages.json").read_text(encoding="utf-8"))
+ cls.flow = BUILDER.flow(cls.manifest)
+
+ @classmethod
+ def tearDownClass(cls):
+ if hasattr(cls, "out"):
+ shutil.rmtree(cls.out, ignore_errors=True)
+
+ def page(self, route: str) -> str:
+ return BUILDER.target_for(self.out, route).read_text("utf-8")
+
+ def articles(self) -> list:
+ """Every entry on the flow — the guides and the concepts, which
+ are exactly the pages this task built."""
+ return list(self.flow)
+
+
+class EveryRouteRendersInTheArticleLayout(BuiltSite):
+ """Acceptance: the routes render in 1a, and every body on them is a
+ slice rather than something a person typed into the site."""
+
+ def test_the_seven_concepts_and_the_install_guide_are_all_there(self):
+ """Named one by one rather than counted: a route quietly dropped
+ from the manifest is exactly the failure this catches."""
+ routes = {page["path"] for page in self.articles()}
+ for route in ("/guides/install/",
+ "/concepts/stages/",
+ "/concepts/task-files/",
+ "/concepts/claiming-a-card/",
+ "/concepts/agents-on-the-board/",
+ "/concepts/pull-requests/",
+ "/concepts/team-mode/",
+ "/concepts/three-layer-law/",
+ "/concepts/adapters/"):
+ self.assertIn(route, routes)
+ self.assertTrue(BUILDER.target_for(self.out, route).is_file(),
+ f"{route} produced no page")
+
+ def test_each_one_is_the_three_column_layout(self):
+ for entry in self.articles():
+ html = self.page(entry["path"])
+ self.assertEqual("article", entry["layout"], entry["path"])
+ for furniture in ('class="page-article"', 'class="side"',
+ 'class="prose"', 'class="gutter"',
+ 'class="crumbs"'):
+ self.assertIn(furniture, html,
+ f'{entry["path"]} is missing {furniture}')
+
+ def test_no_body_is_authored_twice(self):
+ """Every article names a source file and a heading to cut from.
+ The lede is the single exception, and it is one sentence in the
+ manifest — not a body."""
+ for entry in self.articles():
+ self.assertTrue(entry.get("source"),
+ f'{entry["path"]} has no source')
+ self.assertTrue(entry.get("from"),
+ f'{entry["path"]} has no from heading')
+
+ def test_the_lede_is_present_and_is_the_manifests_own_sentence(self):
+ for entry in self.articles():
+ found = LEDE.search(self.page(entry["path"]))
+ self.assertIsNotNone(found, f'{entry["path"]} has no lede')
+ wanted = entry.get("lede") or entry["description"]
+ self.assertEqual(wanted.strip(),
+ found.group(1).replace("'", "'").strip())
+
+
+class TheSidebarAndTheContentsFollowThePage(BuiltSite):
+ """The left rail says where you are in the site; the right rail says
+ where you are in the page. Neither is authored: one is the manifest,
+ the other is the promoted slice's own h2s."""
+
+ def test_the_sidebar_marks_exactly_the_page_you_are_on(self):
+ for entry in self.articles():
+ here = SIDE_HERE.findall(self.page(entry["path"]))
+ self.assertEqual([entry["path"]], here,
+ f'{entry["path"]} does not mark itself in the '
+ f"sidebar")
+
+ def test_the_sidebar_lists_every_other_page_too(self):
+ html = self.page("/concepts/stages/")
+ for entry in self.articles():
+ self.assertIn(f'href="{entry["path"]}"', html,
+ f'the sidebar has no link to {entry["path"]}')
+
+ def test_the_header_nav_marks_the_section(self):
+ self.assertIn('class="nav-link nav-here"',
+ self.page("/concepts/stages/"))
+
+ def test_on_this_page_is_the_bodys_own_h2s_in_order(self):
+ """Not a subset and not a superset: the same anchors, the same
+ order. A slice that grows a sub-heading grows a contents entry
+ with nobody editing the site."""
+ for entry in self.articles():
+ html = self.page(entry["path"])
+ self.assertEqual(HEADING_ID.findall(html), TOC_LINK.findall(html),
+ f'{entry["path"]}: the contents list and the '
+ f"headings disagree")
+
+ def test_a_page_with_sub_headings_really_has_a_contents_list(self):
+ """Guards the test above against passing on two empty lists."""
+ html = self.page("/concepts/stages/")
+ self.assertIn("On this page", html)
+ self.assertIn('href="#backlog"', html)
+ self.assertIn('href="#moving-a-task"', html)
+
+
+class PrevAndNextWalkTheFlow(BuiltSite):
+ """The arrows follow the sidebar's order, because a reader who used
+ one and then the other must not be sent somewhere else."""
+
+ def links(self, route: str) -> dict:
+ return dict((direction, target) for direction, target
+ in FLOW_LINK.findall(self.page(route)))
+
+ def test_each_page_points_at_its_neighbours(self):
+ order = [entry["path"] for entry in self.flow]
+ for index, route in enumerate(order):
+ found = self.links(route)
+ self.assertEqual(order[index - 1] if index else None,
+ found.get("prev"), f"{route}: wrong previous")
+ self.assertEqual(order[index + 1] if index + 1 < len(order)
+ else None, found.get("next"),
+ f"{route}: wrong next")
+
+ def test_the_ends_of_the_flow_have_one_arrow_each(self):
+ first, last = self.flow[0]["path"], self.flow[-1]["path"]
+ self.assertNotIn("prev", self.links(first))
+ self.assertIn("next", self.links(first))
+ self.assertIn("prev", self.links(last))
+ self.assertNotIn("next", self.links(last))
+
+ def test_an_absent_neighbour_keeps_its_slot(self):
+ """`next →` sits on the right on the first page as on every
+ other, which is a spacer in the markup rather than a rule in the
+ stylesheet."""
+ html = self.page(self.flow[0]["path"])
+ flow = html[html.index('