feat(site): phase 1 — the foundation #4

Merged
whitlocktech merged 3 commits from feat/phase-1-foundation into main 2026-08-20 02:06:31 +00:00
Member

Phase 1 of PLAN.md §13: the Astro + Node adapter scaffold, the token file, typography, the layout shell, header/footer, the documentation theme and sidebar, platform.json, and the two check scripts.

Stacked on #3 only conceptually — this branch is cut from main and merges independently in either order.

The palette is sampled, not invented

§11 asks for gold and cyan "derived from the artwork by sampling, not guessed, and both held to WCAG AA against the ground". So that is literally what happened: runic-emblem.png was decoded, its 494,059 opaque pixels binned by hue, and the mean of each bin taken.

The ring turns out to be 65% of the emblem's saturated pixels, across hue 25–45°. The portal and its glow sit at 180–210°. Every value in tokens.css carries its measured contrast against #0e1318:

Token Value Ratio What it may be used for
--gold-deep #946b3c 3.94:1 rules, borders — never text
--gold #c8a368 7.91:1 emphasis text, headings, the mark
--gold-bright #e4cb90 11.77:1 highlights
--portal-deep #0b6398 2.89:1 glow fills only
--portal #15b4de 7.66:1 live-state signal, diagram lines
--portal-bright #1bd6f1 10.61:1 portal core, focus rings
--danger #ff4e43 5.71:1 the ruby in the ring — the only red in the artwork

Where a sampled value fails a ratio it is restricted rather than brightened, which is why the annotation doubles as the usage rule. The ground, panels, steel-blue accent and the live/maintenance signal colours are the product's own tokens, byte for byte, so a theme written for a Runic Gateway deployment is legible here.

The two checks, and proof that they bite

A check that has never failed is not a check, so both were deliberately broken before being trusted.

checkTokens.mjs — fails on any colour literal in src/ outside the token file. Verified it catches a hex value and a named colour dropped into an .astro <style> block, and that it does not flag the #0e1318 that appears in its own explanatory comments (it strips comments per file type first — otherwise it would have been its own first false positive).

checkFacts.mjs — re-reads all 14 externally-sourced facts from their authorities over the Gitea API. All 14 currently agree:

protocol (sidecar) 4 · protocol (overlay) 4 · protocol (bundle) 4 · moduleApi 1.6.0
bundle.tag 2026.08.19 · bundle.sidecar v2.0.0 · bundle.overlay v1.0.0 · servuoMin 57.4
release link v2.0.0 · installer v0.1.0 · Module-uo v1.0.1 · Android-app v0.5.0
websiteHasReleases false · contact address (D13) none

Verified it fails when platform.json is edited to claim protocol 3 — which is exactly the mistake §1 was written about, so that is the one regression test worth having. It reads the protocol from three independent sites (the sidecar's PROTOCOL_VERSION, the overlay's overlay.toml, the published bundle manifest) because a bump that lands in only some of them is the failure CLAUDE.md warns about.

It also enforces D13: no email address anywhere in src/ or scripts/ — the address lives only in brand-default/brand.json, so replacing it later stays a file copy. Verified that one fires too, and it prints its own remedy rather than the version-mismatch advice.

The token requirement is deliberate: anonymous raw fetches fail on this Gitea instance, and a fact check that silently skips itself would ship a stale version quietly. CI maps the existing org-level REGISTRY_TOKEN into GITEA_TOKEN for that step — no new secret needed. The mapping lives in the workflow so the script keeps asking for what it actually wants, a Gitea token, rather than this org's particular secret name.

Things found while building, worth knowing

  • §6 says output: 'server' with per-page prerender = true. Astro 7 expresses that shape the other way round — output: 'static' with an adapter prerenders everything and lets individual routes opt out. Same runtime result, and the default is now the safe one: a page added without thinking is static, not accidentally server-rendered. The two routes that will opt out are phase 2's /brand/* and phase 5's /api/beta-signup. Flagging rather than silently deviating — say the word if you want the config written the other way.
  • src/lib/brand.mjs documents a tension phase 2 has to resolve. §7 promises that changing the site name or the contact address is a mounted-file edit; §6 prerenders the pages, and a value read at build time is baked into the HTML where no mount can reach it. Assets are fine — they are served per request. Text is not. The three options are written down at the call site, in the order they are worth trying, so phase 2 does not discover it late. One of them contradicts §7 and would need your agreement rather than a quiet decision.
  • The mark had to be inlined, not <img>. It is drawn in currentColor so it inherits --gold and follows a mounted theme.css for free — but an SVG loaded through <img> is a separate document with nothing to inherit from, and it rendered black on black in the docs header. Starlight's SiteTitle is overridden instead, which also means both chromes now carry one lockup.
  • Theming Starlight's asides needed the hue scales, not the aside rules. Overriding .starlight-aside--* set the border and heading but left the background stock indigo — that comes from --sl-color-<hue>-low. Mapping the five scales fixes asides and everything else semantic in one place.
  • The Node adapter binds IPv6 only by default127.0.0.1 refuses, localhost/::1 answer. Phase 12's container will need HOST=0.0.0.0 explicitly.

The bug CI caught, which local checks structurally could not

The first push went red, and it was a real defect rather than a CI quirk. .gitignore carried data/ without a leading slash, and git matches such a pattern at any depth — so it silently swallowed src/data/platform.json, the single file every page and checkFacts.mjs read.

The working tree still had the file, so npm run verify passed locally with all 14 facts green. CI cloned fresh and astro check failed on the missing module. Both bind-mount patterns are now anchored to the repository root, and the .gitignore comment says why.

It is fixed and re-verified the way it should have been the first time: git archive HEAD into a clean directory, npm ci, then the full CI sequence there — proving the commit rather than the tree that was hiding the problem. That is worth doing on any first commit that introduces a .gitignore.

Verified

npm run verify — tokens, facts, astro check (0 errors across 14 files), production build, both in the working tree and against a clean export of the commit. Then served the built output and walked it in a browser: /, /docs/ and a 404 all correct, Pagefind search index built, both typefaces served from our own origin, and no external requests of any kind in the emitted HTML, so §6's default-src 'self' holds with nothing to argue about (D9). Light and dark both checked on the docs.

Mobile layout and the accessibility pass are phase 10/11 and not claimed here.

AI-assisted contribution

Drafted by Claude (Claude Code), per the org's AI-usage disclosure policy. Commits carry the Co-Authored-By trailer.

Phase 1 of `PLAN.md` §13: the Astro + Node adapter scaffold, the token file, typography, the layout shell, header/footer, the documentation theme and sidebar, `platform.json`, and the two check scripts. Stacked on [#3](https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/pulls/3) only conceptually — this branch is cut from `main` and merges independently in either order. ## The palette is sampled, not invented §11 asks for gold and cyan "derived from the artwork by sampling, not guessed, and both held to WCAG AA against the ground". So that is literally what happened: `runic-emblem.png` was decoded, its 494,059 opaque pixels binned by hue, and the mean of each bin taken. The ring turns out to be 65% of the emblem's saturated pixels, across hue 25–45°. The portal and its glow sit at 180–210°. Every value in `tokens.css` carries its **measured** contrast against `#0e1318`: | Token | Value | Ratio | What it may be used for | |---|---|---|---| | `--gold-deep` | `#946b3c` | 3.94:1 | rules, borders — **never text** | | `--gold` | `#c8a368` | 7.91:1 | emphasis text, headings, the mark | | `--gold-bright` | `#e4cb90` | 11.77:1 | highlights | | `--portal-deep` | `#0b6398` | 2.89:1 | glow fills only | | `--portal` | `#15b4de` | 7.66:1 | live-state signal, diagram lines | | `--portal-bright` | `#1bd6f1` | 10.61:1 | portal core, focus rings | | `--danger` | `#ff4e43` | 5.71:1 | the ruby in the ring — the only red in the artwork | Where a sampled value fails a ratio it is **restricted rather than brightened**, which is why the annotation doubles as the usage rule. The ground, panels, steel-blue accent and the live/maintenance signal colours are the product's own tokens, byte for byte, so a theme written for a Runic Gateway deployment is legible here. ## The two checks, and proof that they bite A check that has never failed is not a check, so both were deliberately broken before being trusted. **`checkTokens.mjs`** — fails on any colour literal in `src/` outside the token file. Verified it catches a hex value *and* a named colour dropped into an `.astro` `<style>` block, and that it does not flag the `#0e1318` that appears in its own explanatory comments (it strips comments per file type first — otherwise it would have been its own first false positive). **`checkFacts.mjs`** — re-reads all 14 externally-sourced facts from their authorities over the Gitea API. All 14 currently agree: ``` protocol (sidecar) 4 · protocol (overlay) 4 · protocol (bundle) 4 · moduleApi 1.6.0 bundle.tag 2026.08.19 · bundle.sidecar v2.0.0 · bundle.overlay v1.0.0 · servuoMin 57.4 release link v2.0.0 · installer v0.1.0 · Module-uo v1.0.1 · Android-app v0.5.0 websiteHasReleases false · contact address (D13) none ``` Verified it fails when `platform.json` is edited to claim protocol 3 — which is exactly the mistake §1 was written about, so that is the one regression test worth having. It reads the protocol from **three independent sites** (the sidecar's `PROTOCOL_VERSION`, the overlay's `overlay.toml`, the published bundle manifest) because a bump that lands in only some of them is the failure CLAUDE.md warns about. It also enforces **D13**: no email address anywhere in `src/` or `scripts/` — the address lives only in `brand-default/brand.json`, so replacing it later stays a file copy. Verified that one fires too, and it prints its own remedy rather than the version-mismatch advice. The token requirement is deliberate: anonymous raw fetches fail on this Gitea instance, and a fact check that silently skips itself would ship a stale version quietly. CI maps the existing org-level **`REGISTRY_TOKEN`** into `GITEA_TOKEN` for that step — no new secret needed. The mapping lives in the workflow so the script keeps asking for what it actually wants, a Gitea token, rather than this org's particular secret name. ## Things found while building, worth knowing - **§6 says `output: 'server'` with per-page `prerender = true`.** Astro 7 expresses that shape the other way round — `output: 'static'` with an adapter prerenders everything and lets individual routes opt out. Same runtime result, and the default is now the safe one: a page added without thinking is static, not accidentally server-rendered. The two routes that will opt out are phase 2's `/brand/*` and phase 5's `/api/beta-signup`. **Flagging rather than silently deviating** — say the word if you want the config written the other way. - **`src/lib/brand.mjs` documents a tension phase 2 has to resolve.** §7 promises that changing the site name or the contact address is a mounted-file edit; §6 prerenders the pages, and a value read at build time is baked into the HTML where no mount can reach it. Assets are fine — they are served per request. Text is not. The three options are written down at the call site, in the order they are worth trying, so phase 2 does not discover it late. One of them contradicts §7 and would need your agreement rather than a quiet decision. - **The mark had to be inlined, not `<img>`.** It is drawn in `currentColor` so it inherits `--gold` and follows a mounted `theme.css` for free — but an SVG loaded through `<img>` is a separate document with nothing to inherit from, and it rendered black on black in the docs header. Starlight's `SiteTitle` is overridden instead, which also means both chromes now carry one lockup. - **Theming Starlight's asides needed the hue scales, not the aside rules.** Overriding `.starlight-aside--*` set the border and heading but left the background stock indigo — that comes from `--sl-color-<hue>-low`. Mapping the five scales fixes asides and everything else semantic in one place. - **The Node adapter binds IPv6 only by default** — `127.0.0.1` refuses, `localhost`/`::1` answer. Phase 12's container will need `HOST=0.0.0.0` explicitly. ## The bug CI caught, which local checks structurally could not The first push went red, and it was a real defect rather than a CI quirk. `.gitignore` carried `data/` **without a leading slash**, and git matches such a pattern at any depth — so it silently swallowed `src/data/platform.json`, the single file every page and `checkFacts.mjs` read. The working tree still had the file, so `npm run verify` passed locally with all 14 facts green. CI cloned fresh and `astro check` failed on the missing module. Both bind-mount patterns are now anchored to the repository root, and the `.gitignore` comment says why. It is fixed and **re-verified the way it should have been the first time**: `git archive HEAD` into a clean directory, `npm ci`, then the full CI sequence there — proving the commit rather than the tree that was hiding the problem. That is worth doing on any first commit that introduces a `.gitignore`. ## Verified `npm run verify` — tokens, facts, `astro check` (0 errors across 14 files), production build, both in the working tree and against a clean export of the commit. Then served the built output and walked it in a browser: `/`, `/docs/` and a 404 all correct, Pagefind search index built, both typefaces served from our own origin, and **no external requests of any kind** in the emitted HTML, so §6's `default-src 'self'` holds with nothing to argue about (D9). Light and dark both checked on the docs. Mobile layout and the accessibility pass are phase 10/11 and not claimed here. ## AI-assisted contribution Drafted by Claude (Claude Code), per the org's AI-usage disclosure policy. Commits carry the `Co-Authored-By` trailer.
wtclaude added 1 commit 2026-08-20 00:09:47 +00:00
feat(site): phase 1 — the foundation
Some checks failed
PR checks / checks (pull_request) Failing after 4m19s
66187dde5d
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>
wtclaude added 1 commit 2026-08-20 01:41:47 +00:00
fix(site): anchor the bind-mount ignores, and commit platform.json
Some checks failed
PR checks / checks (pull_request) Failing after 1m1s
d4ab453361
`data/` without a leading slash matches a directory of that name at any
depth, so it silently swallowed src/data/platform.json -- the single file
every page and checkFacts.mjs reads.

The working tree still had it, so `npm run verify` passed locally with all
14 facts green. CI cloned fresh and `astro check` failed on the missing
module. Anchored both patterns to the repository root.

Verified the way it should have been the first time: exported HEAD to a
clean directory, npm ci, and ran the checks there rather than in the tree
that was hiding the problem.

Co-Authored-By: Claude <noreply@anthropic.com>
wtclaude added 1 commit 2026-08-20 01:46:31 +00:00
ci(facts): use the existing org-level REGISTRY_TOKEN
All checks were successful
PR checks / checks (pull_request) Successful in 9m11s
b287728c19
checkFacts needs to read link, servuo-plugins, website and installer, and
the automatic per-run token is scoped to this repo alone. Rather than mint
a new secret, the workflow uses REGISTRY_TOKEN, which already exists at the
org level with the right permissions.

The secret is named for the registry and the script reads GITEA_TOKEN; the
mapping stays in the workflow so the script keeps asking for what it
actually wants -- a Gitea token -- rather than this org's secret name.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit dae7964ca6 into main 2026-08-20 02:06:31 +00:00
whitlocktech deleted branch feat/phase-1-foundation 2026-08-20 02:06:32 +00:00
Sign in to join this conversation.
No description provided.