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