diff --git a/AGENTS.md b/AGENTS.md
index fd577a0..0f6f3b5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -67,14 +67,19 @@ Headless jobs run through an adapter (`BOARD_AGENT_ADAPTER`, default
a directory with `run` (execute one job: `AGENT_PROMPT` + `AGENT_MODE`
work|act-pr|review + `AGENT_COMMANDS` in, stdout = the log, markers parsed
from it) and `wire` (idempotently give the host project live-session
-visibility). Headless jobs answer no permission prompts, so each intent is
-granted exactly the side effects its prompt demands — commit and test for
-work, push for act-pr, posting PR verdicts for review — with the project's
-own runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral
-prefixes each adapter renders in its vendor's rule syntax. Adapters
-translate their vendor's events into the board's normalized schema at the
-edge — core never sees vendor payloads. The full contract, including the
-event schema, lives in `core/adapters/README.md`.
+visibility).
+
+Headless jobs answer no permission prompts, so each intent is granted
+exactly the side effects its prompt demands — commit and test for work,
+push for act-pr, posting PR verdicts for review — with the project's own
+runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral prefixes
+each adapter renders in its vendor's rule syntax.
+
+Adapters translate their vendor's events into the board's normalized
+schema at the edge — core never sees vendor payloads. That is what keeps
+the board's own code free of any one vendor: swapping adapters swaps the
+binary that does the work, not the board. The full contract, including
+the event schema, lives in `manager/core/adapters/README.md`.
## Drives
@@ -291,12 +296,14 @@ is a review question, not a state, so the chip takes no colour. A launch
that inherited the vendor default, and a session replayed from disk, wear
no chip at all — the board says nothing rather than guessing.
-**▸ start work** launches a headless `claude -p` on the task. It exists only
-on `in-progress/` cards: moving a card to in-progress is the commitment, and
-only then does work start — the server refuses launches from anywhere else.
-In team mode it also refuses a card someone else holds, naming them; the
-action reads **▸ take over** there, and firing it is the deliberate
-reassignment. An unclaimed card claims itself on launch.
+**▸ start work** launches a headless agent run on the task, through the
+configured adapter. It exists only on `in-progress/` cards: moving a card
+to in-progress is the commitment, and only then does work start — the
+server refuses launches from anywhere else. In team mode it also refuses a
+card someone else holds, naming them; the action reads **▸ take over**
+there, and firing it is the deliberate reassignment. An unclaimed card
+claims itself on launch. One agent per task at a time, and a work agent's
+worktree must not already exist when it starts.
1. The board creates a git worktree at `.worktrees//` on a new
branch `task/` from the newest main it can see: with an
@@ -393,13 +400,14 @@ branch — additively, never rebasing or force-pushing — in a dedicated
resolution commit, and refuses semantic ones, naming the collision for a
human to settle. GitHub computes mergeability lazily, so an UNKNOWN
reading keeps the chip's last state rather than flapping.
-Tool chips (CI, copilot, PR, drive)
-are destinations, not statuses: they live in the card's footer row, never
+
+The card wears that verdict in the design system's state colours:
+approved → pine (`--calm`) border and an `approved` pill; changes asked →
+terracotta (`--alarm`) and a `changes asked` pill; otherwise it stays the
+neutral `waiting on you`. Tool chips (CI, copilot, PR, drive) are
+destinations, not statuses: they live in the card's footer row, never
squeezed into the author row — `CI ✓` (pine), `CI ✕` (terracotta), `◌`
-while in flight, with hover actions staying in the status pill's slot. The card wears it in the design
-system's state colours: approved → pine (`--calm`) border and an
-`approved` pill; changes asked → terracotta (`--alarm`) and a
-`changes asked` pill; otherwise it stays the neutral `waiting on you`.
+while in flight — with hover actions staying in the status pill's slot.
Merging remains yours — the board never merges.
The agent's first duty is to judge whether the task is actionable. If the
@@ -438,9 +446,8 @@ clicks needs merge rights on the repo, not just push rights, and a branch
without a PR is refused with a pointer to **↑ open PR** — there is nothing
for origin to merge otherwise. Single-player merges locally, exactly as
above.
-One agent per task at a time; a work agent's worktree must not already
-exist when starting.
+## Stages
The flow is linear:
@@ -448,8 +455,6 @@ The flow is linear:
backlog → to-do → in-progress → review → done
```
-## Stages
-
### backlog/
Where new tasks are written and where they wait. A backlog task may be rough,
incomplete, or fully specified — what it has in common with its neighbours is
@@ -485,7 +490,7 @@ Supporting documents that tasks can link to — external specs, API documentatio
research notes, screenshots, competitive analysis, regulatory references, etc.
These don't move through the workflow; they're stable resources. Reference them
from task files using relative links — two levels up from a stage directory
-(e.g. `[IVASS spec](../../reference/ivass-document-requirements.md)`).
+(e.g. `[the payments API spec](../../reference/payments-api.md)`).
## Moving a task
@@ -529,10 +534,10 @@ messaged `board: → ()`, staged by pathspec so
unrelated staged work is neither committed nor unstaged (hooks are skipped —
this is bookkeeping, not code). Pushing is not part of it: those commits sit
on your local `main` until you push it (or until `BOARD_SYNC` pushes them —
-see below), which the PR guard above will tell you about if you forget. The
-setting is off by default: a single-player
-board neither writes nor clears the assignee and makes no commits, exactly
-as before, and `tasks/` is committed by hand. The gate governs only whether
+see "Syncing boards"), which the guard in "Pull requests" will tell you
+about if you forget. The setting is off by default: a single-player
+board neither writes nor clears the assignee and makes no commits, and
+`tasks/` is committed by hand. The gate governs only whether
a *move* writes the line — an **Assignee:** added to a file by hand is still
read and shown on the card whether the gate is on or off.
@@ -634,6 +639,22 @@ and keep the status in step with the directory the file sits in. Priority may
carry a short justification after the level
(e.g. `Medium — foundational for any real environment`).
+Every header field, and who writes it:
+
+| Field | Required | Value | Written by |
+| --- | --- | --- | --- |
+| **Status** | yes | `Backlog` · `To Do` · `In Progress` · `Review` · `Done` (`Archived` for a card in `tasks/archive/`) | you, or the board on a move |
+| **Priority** | yes | `High` · `Medium` · `Low`, optionally followed by a short justification | you |
+| **Type** | no | `Discovery` · `Bug` · `Feature` · `Refactor` · `Chore` | you |
+| **Assignee** | no | a name, taken from `git config user.name` | the board on a claiming move, or you by hand |
+| **Depends on** | no | task numbers or external preconditions, comma-separated | you |
+| **PR** | no | the pull request url | the board when it opens one |
+
+**Status** is the field the board holds you to: a header that disagrees
+with the directory the file sits in is flagged `status drift`.
+**Assignee** and **PR** it writes and reads itself. The rest are for
+whoever picks the next card.
+
An optional **Type** line can record what kind of work the task is, when that
isn't obvious from the title:
diff --git a/README.md b/README.md
index 4f59c4e..0b3ffd3 100644
--- a/README.md
+++ b/README.md
@@ -70,9 +70,15 @@ install one-liner above depends on.
## The three-layer law
Core knows about tasks, worktrees, PRs and events. It knows nothing about
-any particular app (drivers do: `local/driver/start`), agent vendor
-(adapters do: `core/adapters/`), or project (`local/` does). Full docs in
-AGENTS.md; the adapter contract in `manager/core/adapters/README.md`.
+any particular app (drivers do: `manager/local/driver/start`), agent
+vendor (adapters do: `manager/core/adapters/`), or project
+(`manager/local/` does).
+
+The consequence is what makes an update safe: `update.sh` replaces
+`manager/core/` wholesale, and everything a project taught bench about
+itself lives outside it. Full docs in [AGENTS.md](AGENTS.md); the adapter
+contract in
+[manager/core/adapters/README.md](manager/core/adapters/README.md).
## License
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('