Source: context/code/doc-site.md

On this page

The documentation site — the arguments

scripts/build-doc-site.mjs turns the prose in context/ into the static site served at /docs/ on GitHub Pages. This is the reasoning behind what it publishes, what it deliberately does not, and why it is a hundred-odd lines of node rather than a static site generator (improvise-djoo).


#1. What is published, and what is not

The manifest is the SECTIONS array at the top of the script, and a document is published only by being named there. That is the point: a glob over context/** would publish the next working file somebody drops in, and the decision about what a stranger should read is not one to make by accident.

Published, in four sections:

SectionContentsWhy
Start heredocs/design-notes.md, context/design-principles.md, context/repo-map.md, context/gotchas.md, context/file-inventory.mdWhat the project is for, the doctrine, and the single best explanation of how it is built. The map is a digest; the last two are its long forms, published so a gotcha N citation and the per-file listing have somewhere to land.
Decision recordscontext/adr/*.mdAn ADR is written for an outside reader by construction — it states the options that were rejected, which is the part that is worth reading and the part that is nowhere else.
The arguments, by areacontext/code/*.mdThis is the reasoning that used to live in code comments and was moved out under the two-line cap (design-principles §6). It is the most valuable prose in the repository and it is addressed to exactly the reader a documentation site has.
Test evidencecontext/test-evidence/*.mdOne entry per test whose narrative is longer than its code.

Not published:

None of the three is referenced by any published document except as a row in the file inventory (file-inventory.md), which is inside a code fence, so leaving them out creates no dangling link. That was checked rather than assumed.

And "not published" is now derived rather than assumed (improvise-res9). The generator walks context/**/*.md plus docs/design-notes.md, subtracts the manifest, and fails unless the remainder is exactly the OMITTED map in the script — which carries the reason beside the name, so this section and the code cannot drift into disagreeing. An allowlist and not a count, because an omission is a decision that should have to be argued in code; and the map is checked in both directions, because an entry naming a file nobody can find asserts a reason for nothing, which is the failure a dead ticket id is.

The check was latent when it was filed and live by the time it was written: context/code/kb-tool.md had been added after this table was drafted and was publishing nothing, with no argument for its absence anywhere. The table above says context/code/*.md is published; the manifest disagreed and nothing could see it. That is the whole failure mode in one file.

Test evidence is the one judgement call worth stating. The argument for leaving it out is that hundreds of entries about individual tests are noise to a reader who wanted to know what a pivot table is. The argument for keeping it is stronger: every one of the context/code/*.md documents points at its evidence file by name, so omitting it would break the published prose in exactly the way this site is otherwise careful not to; and the evidence file is the most distinctive artefact the repository has — a written record of the injected fault behind each load-bearing test is not a thing most projects can show. It is therefore published as its own section, last, described as a contributor reference rather than a tour, so nobody wanders into it expecting a narrative.

#2. Ticket ids resolve to a page, never to a URL that does not exist

The prose cites bead ids roughly 190 times — this paragraph deliberately spells none, because a document that gives one as an example makes itself a source of audit noise, and a placeholder is worse than a real id: it is ID-shaped and resolves to nothing. On a public site those citations are meaningless strings, and the obvious repair — link them to an issue tracker — is unavailable: beads keeps its issues in a Dolt database on a git ref, and there is no public issue URL to point at. Inventing one would produce 190 links that 404, which is worse than plain text because a link is a promise.

So the ids resolve inward. tickets.html is generated from .beads/issues.jsonl — already committed, already public — and carries id, status and title, nothing else; descriptions are internal working notes and are not the site's business. Each citation in the prose becomes a link to that page's anchor, with the title in a title= attribute so a hover answers the question without a navigation.

issues.jsonl is a passive export and lags the database, which is not a defect to work around but a fact to render honestly: an id the export does not carry is emitted as plain text with a dotted underline and no link, and the ticket page lists it as "not exported". The site therefore never links to an anchor it did not generate, whatever state the export is in. A generator that instead linked every id it saw would produce a page that is right today and broken the first time somebody cites a fresh ticket.

The page publishes every cited id, open bugs and their titles included, and that is a decision rather than a default (user ruling). The data is already public in .beads/issues.jsonl in a repository anyone can clone; the page only makes it legible. An honest open-bug list is a normal thing for an open-source project to have, and filtering one while the page's own lede links the raw JSONL would be theatre. Note the second-order reason to keep it unfiltered: a citation whose row was suppressed would be a link into a page that does not answer it, which is the failure this whole section exists to avoid.

Titles are tracker data and are escaped as attribute values. Five shipped bead titles contain a double quote; interpolated into title="…" by an esc that handled only &, < and >, they broke out of the attribute and the browser parsed the remains as three junk attributes. esc now escapes " and ' as well. Today's corpus being friendly is not a reason to interpolate untrusted-shaped text unescaped — the tracker is written by whoever files a bug.

A code span whose entire content is a repo path becomes a link — to the generated page when the path is one of the published documents, to GitHub otherwise. Two constraints make that safe. The whole span must be the path, so a sentence that merely mentions a directory is untouched; and the path must exist on disk at generation time, so a citation that has gone stale degrades to plain text rather than to a 404. The GitHub ref is the cache-bust token web/assemble.sh already receives, used only when it is a 40-hex sha and falling back to main otherwise — a local dev build must not emit blob/dev/….

The generator collects every anchor it emits and every internal href it writes, resolves them against each other, and exits non-zero if any href names a page it did not generate or a fragment that page does not carry. web/assemble.sh runs under set -eu, so a broken cross-reference fails the assembly rather than shipping.

This is not ceremony. It caught two real defects on its first two runs: a fence-close regex that compared a three-character marker against a one-character string, which swallowed every document from its first code block onward; and a list parser that ended the gotcha list at the first blank line, so 47 of the 48 gotcha anchors were never emitted while the "jump to gotcha N" strip linked to all of them. Both were invisible in a rendered page that otherwise looked fine.

It is proved by injection rather than asserted: renaming the emitted anchor ids makes it report 48 missing anchors, and pointing the ticket links at a page name that is never generated makes it report 763 missing pages — two different diagnoses, because "the page is absent" and "the page is there but the anchor is not" call for different repairs.

Links that leave the docs/ subtree — ../index.html, ../app/ — name files this script does not generate, so they are resolved against the filesystem instead. That is the whole reason assemble.sh runs the generator last: its siblings have to exist before they can be checked. An earlier version skipped those 48 links while still counting them as checked, behind a comment promising they were "checked below" when there was no below.

That ordering is itself checked now (improvise-res9). The generator reads web/assemble.sh and refuses to run unless its own invocation is that script's last command. PR #63 appended a loop and it happened to land above the generator; one landing below would have made the outward-link check pass against a half-built tree, silently and in the one direction this section says must not go unnoticed.

And the link check is not sufficient, which is the more important lesson. It answers one question — does every href resolve — and a defect outside that question is invisible to it however carefully it is run. The worked example is a nested <a>: a TOC entry wrapping a heading whose rendered text already contained a link produced <a href="#anchor"><a href="github…">…</a></a>, and the HTML parser closes the outer anchor early, so the entry became an empty unclickable stub and clicking it navigated to GitHub. Both hrefs resolved perfectly. It shipped past the link check, past the whole-site link check, and past a screenshot pass, and was caught by a reviewer clicking a link. The repair is therefore two things, not one: the TOC keeps a tag-free variant of each heading, and a separate check asserts no <a> appears inside another <a> — proved by reverting the first half and watching the second report 66.

The general form: when a check misses a defect, add the check that could have seen it rather than only fixing the defect, and ask what question the existing check was answering.

site/ is build output and is not tracked (improvise-1xfb.4). It is web/assemble.sh's default OUT_DIR, and site/docs is this script's. Every reader builds its own copy from source: CI's build job runs nix build .#site, which assembles into the store; the harness assembles into harness/serve/; scripts/build-web-release.sh assembles into a temporary stage. For a while 27 generated pages under site/docs were committed by accident. They went stale, still saying 12 categories after the cap was raised, and nothing read them.

#5. Why a node script and not a generator

The landing page is hand-written HTML with no build step and one pinned CDN script; the same posture applies here. A static site generator would add a dependency tree, a lockfile and a toolchain to a repository whose site is currently four files, in exchange for features this corpus does not use — the docs contain no front matter, no shortcodes and, as it happens, not a single markdown link. Every cross-reference in them is prose naming a path or an id, which is precisely why §2 and §3 are the interesting parts and Markdown rendering is the boring part.

So the renderer is a deliberately small subset covering exactly what the corpus contains: ATX headings, fenced code, pipe tables, blockquotes, ordered and unordered lists with indented continuations, thematic breaks, and inline code, strong, emphasis and links. Anything it does not recognise becomes paragraph text, so no input line is ever dropped — the failure mode is an ugly paragraph, never a missing one.

Two details in it are load-bearing and easy to undo. Code spans are replaced by placeholders before emphasis is matched, because this corpus routinely opens ** on one side of a code span and closes it on the other; matching emphasis per segment silently drops that bold, which is a fidelity loss with no visible symptom. And strong is matched before emphasis, with a body that may contain a single * pair, because [^*]+ cannot match **a *b* c** and the leftover * then pairs across the bold — which is how a single sentence in gotcha 33 came out as three italic paragraphs.

A third, added later and for the same reason: a table cell boundary is an unescaped pipe outside a code span, so the row is walked rather than split("|"). This corpus documents a file format whose own quoting syntax is |Time Period|, and a sentence containing one inside a table cell would have been shredded into extra columns — a row that still parses, so no column count could detect it. It was latent when fixed, which is the point: the trigger is one ordinary sentence away in a document whose subject is pipe quoting.

One deliberate divergence from GFM inside that rule: a \| inside a code span in a table cell keeps its backslash, where GFM would unescape it to a bare pipe. Splitting is suppressed inside code spans, so the escape is never needed there and never consumed. The corpus contains no instance; it is recorded as a decision rather than left to be rediscovered as a bug.

#6. Navigation

repo-map.md carries a digest of the numbered gotchas and gotchas.md the long form; the map was condensed because it was too long to put in every new agent's context. Splitting per gotcha was rejected: the gotchas cross-reference each other constantly ("see gotcha 35", "for the reason gotcha 39 watches…"), and a reader following those wants a scroll, not one page load per number. Each of the two stays one page, with an id on every list item and a compact strip of numbered links under the heading. The ids come from the list item's own number, not from a counter, so an inserted gotcha renumbers the anchors exactly as it renumbers the prose that cites them.

The strip's contents are derived from the anchors actually emitted, never from a count. A hardcoded 48 failed in only one direction: deleting a gotcha left a link to a missing anchor, which the link check catches, while adding one produced an unlinked gotcha and a clean build. The list only ever grows, so the hardcoded form was silent in precisely the direction it would be used — the same shape as an exclusion list that over-matches, failing in the direction that looks like success.

Long unbreakable headings need overflow-wrap. The test-evidence pages are hundreds of snake_case test names as headings; at phone width seven of them overflowed the viewport by up to 92%. The diagnosis is worth keeping because the first guess was wrong: getBoundingClientRect reported every element inside the viewport, because the text overflows its box without widening it. Only scrollWidth per element names the culprit. Measure the property that actually overflowed rather than the one that usually does.

#7. The phone nav is a checkbox, and that is not arbitrary

At phone width the sidebar's 25 links stacked above the content, so every page opened on roughly 1500px of nav. The wall was not inherited: all of this CSS is new, so it shipped for the first time with the site, which is the test for whether a defect is pre-existing and the one most easily got wrong in the direction of declining work you introduced.

<details> is the obvious control and it is wrong here, for a reason no spec-level reading predicts. The sidebar layout has to force the disclosure open at wide widths, and a closed <details> hides its content through ::details-content — which display cannot override. Measured in Chromium, the desktop sidebar came out collapsed to a single link with its toggle also hidden: unusable, and in the half nobody would think to re-check after a phone-only change. A hidden checkbox plus a <label> puts the hiding in our media query, so forcing it open is ordinary cascade. Anyone reading the markup will see a checkbox where <details> obviously belongs; this paragraph is why it must not be tidied into one.

And the first probe of the fix could not see the difference. Counting nav links whose getBoundingClientRect().height > 0 reported 26 visible in both the collapsed and the expanded state, because Chrome reports a height for content-visibility-hidden children — so it would have passed the broken <details> build. checkVisibility({contentVisibilityAuto: true, visibilityProperty: true}) discriminates.

That is the same instrument failing twice: §6's overflow fix also measured clean by getBoundingClientRect while the page still scrolled. A measurement that cannot distinguish the two states is not a measurement — the repo's signature defect, a test that passes while the thing it names is broken, arriving through the instrument rather than through the assertion. Before trusting a browser measurement, injure the thing being measured and check the number moves.

And the fix's own probe had the same shape one level up. Counting visible nav links answers "does operating the toggle reveal the links" — correct and complete for a mouse — and is silent on whether any non-mouse route exists. The first version of the checkbox carried hidden, which is display:none, which removes it from the tab order and from the accessibility tree: the label is not focusable on its own, so at ≤900px the navigation could be clicked and reached no other way, and the accessibility snapshot contained neither the checkbox nor any of the 25 links. A visibility count cannot see that, however sharp the visibility instrument. The toggle is therefore visually hidden but focusable at narrow widths and display:none at wide ones, and the check is tab order, .focus() and the accessibility snapshot — measured at 390, 900, 901 and 1280, because the regression lives on one side of a media query.

So the rule from §6 has a second half: a measurement of the wrong property is not a measurement either, however precise it is. checkVisibility fixed the visibility instrument; it does not turn a visibility count into an operability check. Ask what the defect would do to a user, then pick the instrument that observes that.

A correction, and the risk the original argument missed. The commit that added content: "\2630 " / "" said Firefox ignores the alt-text form and falls back to reading the content, so the change was "an improvement in two engines and a no-op in the third". That mechanism is wrong — Firefox shipped the syntax in 133, and all three engines parse it (measured: Chrome 141, Firefox 142, WebKit 26 each compute the declaration intact). The conclusion held; the reason given for it did not, and the reason was the load-bearing part, because it was what made the change look like a one-way door. It is one because every engine shipped to supports the syntax — not because the unsupported path is harmless. That commit message is superseded by this paragraph (design-principles §6: an argument must not live only in a commit message).

The unsupported path is not harmless, and that is the risk neither the change nor its first review named. / alt in an engine that does not know it is a parse error for the whole content declaration, so the glyph would not lose its alt text — it would vanish. A silent visual regression, the opposite of the assumed failure mode. The glyph is therefore verified painted in all three engines by pixel diff against ::before { content: none }, with the text starting 20px further right in each.

That check also produced this file's fourth instance of the wrong-property trap, and the sharpest, because it produced a confident false positive: the first probe measured the label's width with and without ::before and reported the glyph missing in all three engines. The label is display: block, so its width comes from the container and cannot see inline content at all. Right question, wrong property, and this time the wrong instrument accused working code rather than clearing broken code.