Round 2 of review. Three notes and the mobile-nav call. THE NAV, and the correction that prompted it. I had called the 25-link sidebar wall pre-existing and declined to fix it. That was wrong: the entire CSS is new in this PR, so the wall ships for the first time here and it is mine. Every phone page opened on ~1500px of nav before any content. The mechanism is a checkbox and not <details>, and the reason is measured rather than assumed. <details> was the obvious control, and it collapses correctly on a phone -- but the sidebar layout has to force it OPEN, and a closed <details> hides its content through ::details-content, which `display` cannot override. Chromium showed the desktop sidebar collapsed to one link with its toggle hidden: unusable. So the hiding is ours, in a media query, where overriding it is just cascade. Measured at both widths: 390px shows 1 link and the toggle, with the h1 at 189px instead of ~1500; 1280px shows all 26 links with no toggle and the h1 at 40px, unchanged; and the phone toggle really expands 1 -> 26 on click. Note the first probe was wrong -- getBoundingClientRect reports a height for content-visibility-hidden children, so it read 26 visible in both states and would have passed the broken version. checkVisibility() is what separates them. The same argument applies to the table of contents, which is equally new here: repo-map opened on 1397px of TOC on a phone. Capped at 45vh so it scrolls; 1397 -> 924, desktop identical at 652. THE HEADER COMMENT. The generator stopped running standalone when the link check began resolving ../index.html and ../app/ on disk, and its header still advertised a default OUT_DIR that now exits 1. That is gotcha 48's third paragraph exactly -- a fact stated in two places and updated in one, with the copy that was updated being doc-site.md and the copy someone about to run the script actually reads being the header. It now says so, keeps the strictness (the hole it replaced was counting those links as checked while skipping them), and records the other thing the reorder changed: the DIRECTION of a failure, from an obviously-broken tree with no app/ to a complete-looking one with a partial docs/. And one documented subset: a \| inside a code span in a table cell keeps its backslash where GFM would unescape it. Splitting is suppressed inside code spans, so the escape is never needed there and never consumed. No corpus instance; recorded as a decision rather than left to be rediscovered. Gates: 1378 rust, 26 node --test, 16 playwright (3 skipped, webkit OPFS), all three check-comment-runs modes, 3460 site links / 0 broken, 0 nested anchors, 0 of 23 pages overflow at 390 and 1280. improvise-djoo Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 KiB
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:
| Section | Contents | Why |
|---|---|---|
| Start here | docs/design-notes.md, context/design-principles.md, context/repo-map.md |
What the project is for, the doctrine, and the single best explanation of how it is built. |
| Decision records | context/adr/*.md |
An 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 area | context/code/*.md |
This 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 evidence | context/test-evidence/*.md |
501 entries, one per test whose narrative is longer than its code. |
Not published:
context/plan.mdis a prompt: it opens "You are working in theimproviserepository… Your job is to prepare this repository for a public Show HN launch", and it instructs a reader to audit acontext/SPEC.mdthat no longer exists. It is an instruction to an agent about a launch, not a description of the program, and publishing a launch plan on the site the launch points at inverts the two.context/cloud-agents.mdis operational notes for agents running in a cloud session — tracker hydration,bdpitfalls, what the sandbox blocks. It is true and it is useful and it is addressed to nobody who visits a website.
Neither is referenced by any published document except as a row in
repo-map.md's file inventory, which is inside a code fence, so leaving them
out creates no dangling link. That was checked rather than assumed.
Test evidence is the one judgement call worth stating. The argument for
leaving it out is that 501 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 seven 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.
3. Source paths link only where the file exists
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/….
4. The link check is the test, and it is part of the build
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.
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.
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 is one long document whose gotcha list is numbered 1 to 48, and
splitting it 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 48 page loads. It 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.