Docs with everything
Omniglass ships its documentation as part of the product. The docs are not an
afterthought in a separate wiki; they are Astro Starlight content under docs/, compiled
to a static site and published at docs.omniglass.hyperscaleav.com (and, in time, embedded
into the Go binary to serve at /docs). The architecture is
published ahead of the code, so the design is visible (and reviewable) before, or
alongside, the feature that implements it.
The rule
Section titled “The rule”A feature is not done until the docs that teach it ship in the same PR.
Concretely, a user-facing PR must do one of:
- change
docs/to add or update the page(s) that explain the new behavior, or - state a one-line justification in the PR body (pure refactor, internal-only change, etc.).
The docs-touched gate is enforced by the PR template checklist and the /ship-slice
pre-ship pass, not by a label-driven CI check (the
label taxonomy is closed, and no workflow reads a docs label). The
justification path exists so the gate never blocks a genuine internal change, not as a
routine escape hatch.
A mechanical layer backs the human gate: the docs lint suite (internal/docslint, run
by go test and so by make test and CI) checks the hand-written docs against the code.
The suite covers vocabulary (retired identifiers must not appear in current-tense
prose, in the docs, the operator strings, and the generated OpenAPI descriptions),
the decision log’s format, routes and permissions (both checked against the generated
spec, permissions in both directions), make targets, env vars, file paths, and
table.column references (against the generated schema facts), plus the badge-fence
coverage check and the docs-command-and-flag guard in internal/cli. The semantic
drift no lint can see gets the periodic /docs-audit pass (#429).
What “the docs” means here
Section titled “What “the docs” means here”- Architecture pages (
/architecture/) hold the model: the spine plus leaf documents, and the current decisions. Each official term is defined once in the glossary and not redefined in the leaves. - Guides (
/guides/) are how-to pages for someone using the product, split by audience: the operator guide (running the fleet from the console and the CLI), the admin guide (managing accounts, access, audit, and config), and deployment (standing the platform up). A slice that ships or changes a user-facing surface ships or updates its guide in the same PR, not just the architecture page, filed under the section that matches who does the task. The architecture page says how the surface is built; the guide says how to use it. - Concept and learning pages teach a concept interactively (see the learning-tool restriction). When a feature introduces a concept an operator must understand, the teaching surface ships with it.
- Contributor pages (
/contributing/) are this doctrine set.
So a feature that adds an operator surface usually touches two homes: the architecture page (the model) and a guide (the how-to). A purely internal change touches neither and states its one-line justification in the PR body.
Status moves with the code
Section titled “Status moves with the code”The architecture pages are written in the present tense as the target design, so build status is carried alongside the prose, not woven into it, and keeping it current is part of docs-with-everything. A slice that advances a page updates three surfaces in the same PR:
- the page’s status badge moves to its new floor (
DesigntoPartialtoBuilt), which the live grid on implementation status reads directly, so the grid never lies; - the build log gains the slice’s entry; and
- if the shipped code diverges from a page’s design, the page carries an inline note and a decision-log entry (an ADR) lands in the same PR.
Forward-looking intent that is not yet a slice lives in a GitHub epic and is indexed on the
roadmap; it is not written into a page as if built. This is the contract that
keeps the published design describing what exists: a built capability never sits behind a Design badge,
and a divergence is never silent.
The design fence
Section titled “The design fence”A badge is page-granular, and a page is not one thing: most architecture pages mix built behavior with target design. The design fence marks the unbuilt part structurally, at the section where it lives, instead of a hand-written “still design” sentence at the top that rots when a slice ships:
:::design[Target design, tracked in #434]The prose in here describes something that does not exist yet.:::The fence renders as a visually distinct aside (dashed, purple, labeled), and its label
must name the issue or ADR that tracks the gap; a fence with neither fails the docs
build, so unbuilt prose always has an owner. To nest a normal aside inside a fence, give
the fence more colons (::::design around a :::note), which is remark-directive’s
nesting rule.
The fence is also the boundary the docs lint suite keys on
(internal/docslint, the Regions split): the vocabulary lint runs everywhere,
fenced or not, because future design must still be written in current nouns, while the
existence lints (routes, permissions, tables, make targets, env vars, paths) run
only outside a fence, where prose claims to describe something that exists. Building a fenced section
means deleting the fence in the same PR, exactly like moving the badge.
Screenshots are generated, not pasted
Section titled “Screenshots are generated, not pasted”Screenshots embedded on a docs page are a generated resource, treated like the OpenAPI
spec or the CLI reference, never a static image dropped in by hand. A page declares what it
needs in screenshots frontmatter (the shot’s id, the console path, its alt, and any
interaction steps), and embeds it in the prose with a directive:
---title: Secretsscreenshots: - id: secrets path: /web/secrets alt: "The Secrets directory: type badges, owner scope, and masked field previews."---
::screenshot{#secrets}That frontmatter is the single source: make docs-shots reads it from every page, drives
the real console (the same binary an operator runs, never a mock), and writes
public/screenshots/<id>.png; the directive renders the figure from the same entry. So the
capture list and the embed cannot drift, a #id with no frontmatter entry (or no captured
image) fails the build, and adding a screenshot is a frontmatter edit, not a code change.
Because the images track the live UI, they are refreshed like any generated artifact: a change
to an operator surface re-runs make docs-shots and commits the new PNGs. Each capture
writes two renders of the same settled page: the clean shot under
docs/public/screenshots/, every region live, which is what the docs embed (a masked history
strip teaches nothing); and its masked twin under docs/screenshots/baseline/, where the
regions a capture cannot render deterministically (a v7-uuid id subtext, a seed-time
timestamp, a history strip whose weights divide by the capture’s own clock) are painted as
constant boxes. make docs-shots-check recaptures and diffs the baselines only, at zero
tolerance, the visual sibling of the make gen drift check: the pinned browser’s masked
rasters are byte-stable, so any diff is a real, un-recaptured UI change. The old percentage
ceiling passed exactly the changes the gate exists to catch, a renamed label or a collapsed
rail repainting fewer pixels than seed jitter (#398, #623). Two caveats: the comparison counts
the pixels pixelmatch judges different at its default perceptual threshold, so a change
confined to near-identical dark tones can still score zero (#774), and a real UI change that
falls entirely inside a masked region is invisible to the gate, which is the price of masking
and the reason a mask names the smallest element that moves.
A mask covers the cell, and says that it is there
Section titled “A mask covers the cell, and says that it is there”Playwright paints a mask over the element the selector resolves to, so what a mask entry
resolves to is the whole contract. A text=/…/ selector resolves to the innermost element
carrying the text, the value’s own <span>, so the painted box is the size of the value. The
console does not zero-pad everything it renders (the audit trail’s toLocaleString leaves the
hour bare, fmtTime leaves the day bare), which means the same unchanged table paints a box
seven pixels narrower at 9 o’clock than at 11, and the gate reads that as drift on a PR that
changed no UI. So a mask over text whose width can change names the cell, by appending
>> xpath=ancestor::td[1], and the cell’s width is pinned by its column descriptor. A mask
over text of fixed width (a uuid in a monospace face) stays on the text, which is also the
only correct answer when the enclosing cell holds something the shot exists to show (#773).
The fill is deliberately visible in the baseline: the console’s own neutral surface, one step lighter than the card behind it, so a human reading a diff artifact sees a block placed over a value rather than a column that failed to render. The published docs never show the fill; they embed the clean render.
Prove a mask holds by recapturing with the clock moved, never by capturing twice a few minutes apart: two captures in the same hour of the same day are structurally blind to exactly the jitter a mask exists to absorb.
make docs-shots-check # the gateDOCS_SHOTS_TZ=Asia/Kolkata make docs-shots-check # the same gate, an hour digit awayBoth halves of the contract (the selector resolves to the cell, and the raster survives a
one-character change) are pinned at the browser tier by web/e2e/shotmask.spec.ts, which flips
a digit on synthetic markup and so also covers the single-digit day a timezone shift cannot
reach. Every capture logs what each mask resolved to (mask 59x td 190x37 <- text=/…/), so a
mask that lands on the wrong element shows up in the capture output rather than in a raster
nobody diffs by hand.
- No em dashes. Use commas, colons, periods, or parentheses.
- No AI/assistant attribution.
- Write for someone learning the system, not someone who already built it. The same page serves the operator using the product and the contributor extending it.
Publishing
Section titled “Publishing”The docs site builds in CI on every PR that touches docs/** (the docs-build workflow,
.github/workflows/docs-build.yml, path-filtered), so a broken .mdx page, a dead import,
or invalid frontmatter fails the PR that introduces it. The published site is
docs.omniglass.hyperscaleav.com; in time, the same content is embedded into the Go binary
to serve at /docs.