feat(site): phase 1 — the foundation
Some checks failed
PR checks / checks (pull_request) Failing after 4m19s

Astro 7 with the Node adapter, Starlight mounted at /docs, the token file, both
self-hosted typefaces, the layout shell, and the two build-time checks from §12.

The palette's gold and cyan are sampled from runic-emblem.png rather than
guessed, per §11: 494,059 opaque pixels binned by hue, each value annotated with
its measured contrast against the ground, and restricted rather than brightened
where a ratio fails.

- checkTokens.mjs fails the build on any colour literal outside tokens.css,
  which is what keeps §7's "recolouring is a file copy" promise true.
- checkFacts.mjs re-reads all 14 externally-sourced facts from their authorities
  over the Gitea API and fails on disagreement. It also enforces D13: no email
  address in the source outside brand-default/brand.json.
- Both were negative-tested; neither has ever been allowed to pass by default.

§6 asks for output:'server' with per-page prerender=true. Astro 7 expresses the
same runtime shape as output:'static' with an adapter, opting individual routes
out — so the default is static rather than accidentally server-rendered.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:08:52 -05:00
parent 650ea21ad4
commit 66187dde5d
25 changed files with 10462 additions and 0 deletions

40
src/lib/brand.mjs Normal file
View File

@@ -0,0 +1,40 @@
import brandDefault from '../../brand-default/brand.json' with { type: 'json' };
/**
* The single accessor for brand text (§7). Every template reads brand through here and
* never imports `brand.json` directly, so phase 2 can change WHERE the values come from
* without touching a single call site.
*
* ---------------------------------------------------------------------------
* A tension phase 2 has to resolve, recorded here so it is not discovered late
* ---------------------------------------------------------------------------
* §7 promises that changing the site name, the Discord invite or the contact address is a
* file edit on the bind mount plus a restart — the same class of change as swapping a
* logo. But §6 prerenders the pages at build time, and a value read at build time is baked
* into the HTML, where no mounted file can reach it.
*
* Assets are fine: they are served by `GET /brand/*` at runtime, which reads the mount per
* request. Text is not, and phase 2 owns the fix. The options, in the order they are worth
* trying:
*
* 1. A response-time rewrite in the Node adapter's middleware, substituting a small set
* of placeholder tokens in the prerendered HTML. Keeps every page static and the
* mount authoritative. Costs one pass over the response body.
* 2. Mark the handful of pages that show brand text as `prerender = false`. Simple, but
* it spreads: the footer is on every page, so "the handful" is all of them.
* 3. Accept that text is build-time and only assets are mounted. Cheapest, and it
* contradicts the sentence in §7 that says otherwise — so it needs the org lead's
* agreement, not a quiet decision here.
*
* Until then this returns the stock values, which is the correct behaviour for an empty
* mount either way.
*/
export const brand = Object.freeze({ ...brandDefault });
/**
* `brand.json` carries `$comment` keys for the operator who opens the mounted copy. They
* are documentation, not fields, and must never reach a template.
*/
export function brandFields() {
return Object.fromEntries(Object.entries(brand).filter(([k]) => !k.startsWith('$')));
}