feat(site): phase 1 — the foundation
Some checks failed
PR checks / checks (pull_request) Failing after 4m19s
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:
54
.gitea/workflows/pr-checks.yml
Normal file
54
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: PR checks
|
||||||
|
|
||||||
|
# Gitea Actions caution, learned elsewhere in this org: never leave an empty
|
||||||
|
# template expression anywhere in a `run:` script, not even inside a comment.
|
||||||
|
# The runner silently SKIPS the whole step without failing the job, and the
|
||||||
|
# problem is invisible in the workflow list.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Design tokens
|
||||||
|
# PLAN.md §7 — no colour literal outside src/styles/tokens.css.
|
||||||
|
run: npm run check:tokens
|
||||||
|
|
||||||
|
- name: Types
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Production build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Platform facts
|
||||||
|
# PLAN.md §12 — every version, protocol number and bundle tag is re-read from
|
||||||
|
# its authority over the Gitea API and must agree with src/data/platform.json.
|
||||||
|
#
|
||||||
|
# This needs a token that can read the OTHER repositories in the org: link,
|
||||||
|
# servuo-plugins, website and installer. The automatic per-run token is scoped
|
||||||
|
# to this repository alone and will 404 on all four, so the job reads an
|
||||||
|
# org-level secret instead.
|
||||||
|
#
|
||||||
|
# It runs last, and it is the only step that touches the network, so a Gitea
|
||||||
|
# outage cannot mask a real failure in the build.
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.PLATFORM_READ_TOKEN }}
|
||||||
|
run: npm run check:facts
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Astro
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.output/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# The bind mounts (PLAN.md §6, §7). Neither belongs in the repo: `brand/` is the
|
||||||
|
# operator's override of the stock branding, and `data/` is the beta signup store.
|
||||||
|
# `brand-default/` IS committed — it is baked into the image and must always be complete.
|
||||||
|
brand/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Editors and OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
102
README.md
102
README.md
@@ -1,2 +1,104 @@
|
|||||||
# runicgateway.com
|
# runicgateway.com
|
||||||
|
|
||||||
|
The public marketing and documentation site for [Runic Gateway][org] — the platform that puts a
|
||||||
|
private game server's live state on a public website without ever exposing the game to the internet.
|
||||||
|
|
||||||
|
Two audiences: **server administrators** who want to understand and install the platform, and
|
||||||
|
**developers** who want to build modules and integrations for it. A third arrives with the Android
|
||||||
|
closed beta: **players**, who want the app.
|
||||||
|
|
||||||
|
**The design of record is [`PLAN.md`](PLAN.md).** It is not a sketch — it carries the verified
|
||||||
|
platform state, the org lead's decisions, the information architecture, and the build phases. Read
|
||||||
|
it before changing anything here.
|
||||||
|
|
||||||
|
**Status: phase 1 of 12 — the foundation.** The scaffold, the token file, the typography, the layout
|
||||||
|
shell and the two build-time checks are in place. The homepage is phase 3, the marketing pages
|
||||||
|
phase 4, and the documentation — the installation path, which is the priority of the whole project —
|
||||||
|
phase 7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:4321
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # → dist/ (prerendered pages + the Node server entry)
|
||||||
|
npm start # serve the built site
|
||||||
|
```
|
||||||
|
|
||||||
|
Node 22 LTS or newer.
|
||||||
|
|
||||||
|
## The checks, and why they are not optional
|
||||||
|
|
||||||
|
Two of them, both from `PLAN.md` §12. Neither is a linter; each one enforces a promise the site
|
||||||
|
makes that would otherwise decay quietly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check:tokens # no colour literal outside the token file
|
||||||
|
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
|
||||||
|
npm run check # astro check
|
||||||
|
npm run verify # all of the above, then a production build
|
||||||
|
```
|
||||||
|
|
||||||
|
**`checkFacts.mjs`** re-reads every version, protocol number and bundle tag in
|
||||||
|
`src/data/platform.json` from its source of truth over the Gitea API — the sidecar's
|
||||||
|
`PROTOCOL_VERSION`, the overlay's `overlay.toml`, the website's `MODULE_API_VERSION`, the installer's
|
||||||
|
published bundle manifest, and each repository's latest release — and fails on any disagreement.
|
||||||
|
|
||||||
|
When the platform moves, this repository goes red so that someone updates the site. **That failure is
|
||||||
|
the feature**, the same mechanism and the same intent as the Integration Kit's `checkCoreApi.js`. §1
|
||||||
|
of the plan records what it is guarding against: an earlier draft confidently stated the platform was
|
||||||
|
on protocol 3, because every checkout in the workspace sat on a feature branch whose local `main` had
|
||||||
|
never been fetched.
|
||||||
|
|
||||||
|
It needs a token — anonymous raw fetches fail on this Gitea instance, and a check that silently skips
|
||||||
|
itself is worse than no check at all.
|
||||||
|
|
||||||
|
**`checkTokens.mjs`** fails the build if a colour literal appears anywhere in `src/` outside
|
||||||
|
`src/styles/tokens.css`. §7 promises that recolouring the site is a file copy and a container
|
||||||
|
restart; a mounted `theme.css` can only redefine custom properties, so a literal in a component is a
|
||||||
|
piece of the site an operator can never reach. Without the check, "one CSS file changes the
|
||||||
|
appearance" becomes "one CSS file changes most of the appearance".
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
data/platform.json Every externally-sourced fact. No version is written in prose.
|
||||||
|
styles/tokens.css THE token file — the only place a colour literal may appear.
|
||||||
|
styles/global.css The layout shell, built entirely from tokens.
|
||||||
|
styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift.
|
||||||
|
layouts/, components/ The marketing chrome.
|
||||||
|
pages/ Marketing routes.
|
||||||
|
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
|
||||||
|
lib/brand.mjs The single accessor for brand text.
|
||||||
|
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
|
||||||
|
config/sidebar.mjs The documentation journey, and the planned tree behind it.
|
||||||
|
brand-default/ The stock brand, baked into the image and always complete.
|
||||||
|
scripts/ The build-time checks.
|
||||||
|
```
|
||||||
|
|
||||||
|
Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides
|
||||||
|
anything in `brand-default/` per file, and `data/` holds the beta signup store. See `PLAN.md` §6
|
||||||
|
and §7.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Branch from `main` (`feature/…`, `fix/…`, `docs/…`, `chore/…`) and use
|
||||||
|
[Conventional Commits](https://www.conventionalcommits.org/). Run `npm run verify` before opening a
|
||||||
|
pull request.
|
||||||
|
|
||||||
|
**AI-assisted contributions must be disclosed**, per org policy: tick the box in the pull request
|
||||||
|
template naming the tool, and mark AI-authored commits with a trailer such as
|
||||||
|
`Co-Authored-By: Claude <noreply@anthropic.com>`. Undisclosed AI-generated contributions may be
|
||||||
|
closed.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0-or-later, in common with every repository in the organisation. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
[org]: https://gitea.whitlocktech.com/RunicGateway
|
||||||
|
|||||||
59
astro.config.mjs
Normal file
59
astro.config.mjs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
import starlight from '@astrojs/starlight';
|
||||||
|
|
||||||
|
import { docsSidebar } from './src/config/sidebar.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN.md §6 calls this "Astro with the Node adapter, `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 with
|
||||||
|
* `export const prerender = false`. The runtime result is identical to what §6 describes —
|
||||||
|
* a container serving prerendered HTML, with a handful of routes executing per request —
|
||||||
|
* and this is the direction the framework supports, so the default is the safe one: a page
|
||||||
|
* added without thinking about it is static, not accidentally server-rendered.
|
||||||
|
*
|
||||||
|
* The two routes that will opt out live in phases 2 and 5: `GET /brand/*` (§7) and
|
||||||
|
* `POST /api/beta-signup` (§8).
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://runicgateway.com',
|
||||||
|
output: 'static',
|
||||||
|
adapter: node({ mode: 'standalone' }),
|
||||||
|
|
||||||
|
build: {
|
||||||
|
// Directory-style URLs, so every link in prose can end in a slash and mean it.
|
||||||
|
format: 'directory',
|
||||||
|
},
|
||||||
|
|
||||||
|
integrations: [
|
||||||
|
starlight({
|
||||||
|
title: 'Runic Gateway',
|
||||||
|
// Marketing owns the 404 (§10); a Starlight-chrome 404 on `/features/` would be wrong.
|
||||||
|
disable404Route: true,
|
||||||
|
// Starlight's own light/dark switch is deliberate: §11 keeps marketing single-theme
|
||||||
|
// but has the docs honour the reader's preference.
|
||||||
|
customCss: ['./src/styles/tokens.css', './src/styles/starlight.css'],
|
||||||
|
components: {
|
||||||
|
// Not the `logo` option: that renders an <img>, and our mark is drawn in
|
||||||
|
// currentColor so it inherits --gold and follows a mounted theme.css. An SVG
|
||||||
|
// loaded through <img> is a separate document with nothing to inherit from, so it
|
||||||
|
// renders black on black. The override inlines it instead — see the component.
|
||||||
|
SiteTitle: './src/components/DocsSiteTitle.astro',
|
||||||
|
},
|
||||||
|
credits: false,
|
||||||
|
sidebar: docsSidebar,
|
||||||
|
pagination: true,
|
||||||
|
lastUpdated: false,
|
||||||
|
editLink: {
|
||||||
|
baseUrl: 'https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/_edit/main/',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
prefetch: {
|
||||||
|
prefetchAll: true,
|
||||||
|
defaultStrategy: 'hover',
|
||||||
|
},
|
||||||
|
});
|
||||||
32
brand-default/brand.json
Normal file
32
brand-default/brand.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"$comment": [
|
||||||
|
"PLAN.md §7: the stock brand, baked into the image at /app/brand-default and always",
|
||||||
|
"complete. The bind mount at /app/brand may be empty, partial or full; every field and",
|
||||||
|
"every asset resolves against the mount first and these defaults second, per key, so an",
|
||||||
|
"empty mount produces exactly this.",
|
||||||
|
"",
|
||||||
|
"This file is also the ONLY place in the repository allowed to contain an email address",
|
||||||
|
"(D13). The org lead has no mailbox at the domain and chose to publish the existing",
|
||||||
|
"address rather than delay the beta; what makes that reversible is that moving to",
|
||||||
|
"privacy@ / security@ later is an edit to the mounted copy of this file and a container",
|
||||||
|
"restart — no rebuild, no PR. scripts/checkFacts.mjs fails the build if an address",
|
||||||
|
"appears anywhere else in the source, because the promise only survives while that",
|
||||||
|
"stays true."
|
||||||
|
],
|
||||||
|
|
||||||
|
"siteName": "Runic Gateway",
|
||||||
|
"tagline": "Put your game server on the web without putting it on the internet.",
|
||||||
|
|
||||||
|
"contactEmail": "whitlocktech@gmail.com",
|
||||||
|
|
||||||
|
"discordInvite": "https://discord.gg/t2Jav8yT4g",
|
||||||
|
"giteaOrg": "https://gitea.whitlocktech.com/RunicGateway",
|
||||||
|
|
||||||
|
"$comment_demo": [
|
||||||
|
"§15 / D12. A public demo instance is planned and out of scope today. The homepage's",
|
||||||
|
"'See it running' slot and /features/'s per-capability affordance render only when this",
|
||||||
|
"is a non-empty URL, so the site gains a working demo by way of one line in a mounted",
|
||||||
|
"file — no rebuild, consistent with §7."
|
||||||
|
],
|
||||||
|
"demoUrl": ""
|
||||||
|
}
|
||||||
8415
package-lock.json
generated
Normal file
8415
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "runicgateway.com",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"description": "The public marketing and documentation site for Runic Gateway",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"start": "node ./dist/server/entry.mjs",
|
||||||
|
"check": "astro check",
|
||||||
|
"check:facts": "node scripts/checkFacts.mjs",
|
||||||
|
"check:tokens": "node scripts/checkTokens.mjs",
|
||||||
|
"verify": "npm run check:tokens && npm run check:facts && npm run check && npm run build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/node": "^11.1.4",
|
||||||
|
"@astrojs/starlight": "^0.41.7",
|
||||||
|
"@fontsource-variable/cinzel": "^5.3.0",
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"astro": "^7.2.4",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.10",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
305
scripts/checkFacts.mjs
Normal file
305
scripts/checkFacts.mjs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* checkFacts.mjs — PLAN.md §12
|
||||||
|
*
|
||||||
|
* The site quotes versions, protocol numbers and capability lists. §1 records why that
|
||||||
|
* needs a mechanism rather than diligence: an earlier draft of the plan confidently stated
|
||||||
|
* the platform was on protocol 3, because every checkout in the workspace sat on a feature
|
||||||
|
* branch whose local `main` ref had never been fetched, and `git show main:<path>` answered
|
||||||
|
* from a months-old blob.
|
||||||
|
*
|
||||||
|
* So: every fact in `src/data/platform.json` is re-read here from its authority over the
|
||||||
|
* Gitea API — never from a working tree — and any disagreement fails the build. When the
|
||||||
|
* platform moves, this repo goes red so someone updates the site. That failure is the
|
||||||
|
* feature, the same as the Integration Kit's checkCoreApi.js.
|
||||||
|
*
|
||||||
|
* It also enforces one rule that is not a version: no email address may appear in the
|
||||||
|
* source outside `brand-default/brand.json` (D13). The published contact is a personal
|
||||||
|
* address that is meant to stay replaceable by a file copy, and that promise survives
|
||||||
|
* exactly as long as nobody types the address into a paragraph.
|
||||||
|
*
|
||||||
|
* GITEA_TOKEN=<token> node scripts/checkFacts.mjs
|
||||||
|
*
|
||||||
|
* Anonymous raw fetches fail on this instance, so the token is required rather than
|
||||||
|
* optional. A check that silently skips itself is worse than no check.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { readdir } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||||
|
|
||||||
|
const BASE = platform.gitea.base;
|
||||||
|
const ORG = platform.gitea.org;
|
||||||
|
const TOKEN = process.env.GITEA_TOKEN?.trim();
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
const checked = [];
|
||||||
|
|
||||||
|
function record(label, expected, actual, authority) {
|
||||||
|
const ok = String(expected) === String(actual);
|
||||||
|
(ok ? checked : failures).push({
|
||||||
|
label,
|
||||||
|
expected,
|
||||||
|
actual,
|
||||||
|
expectedLabel: 'platform.json says',
|
||||||
|
authority: `${authority} says`,
|
||||||
|
ok,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(pathname) {
|
||||||
|
const url = `${BASE}/api/v1/repos/${ORG}/${pathname}`;
|
||||||
|
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = async (repo, filePath, ref) =>
|
||||||
|
(await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text();
|
||||||
|
|
||||||
|
const json = async (pathname) => (await api(pathname)).json();
|
||||||
|
|
||||||
|
/** The first capture group of `re` in `text`, or a thrown error naming what was looked for. */
|
||||||
|
function extract(text, re, what, authority) {
|
||||||
|
const m = text.match(re);
|
||||||
|
if (!m) throw new Error(`Could not find ${what} in ${authority} — the file's shape changed.`);
|
||||||
|
return m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. The wire protocol, from the sidecar itself
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function checkProtocol() {
|
||||||
|
const authority = 'link main:sidecar/src/main.rs';
|
||||||
|
const src = await raw('link', 'sidecar/src/main.rs', 'main');
|
||||||
|
const value = extract(
|
||||||
|
src,
|
||||||
|
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
|
||||||
|
'PROTOCOL_VERSION',
|
||||||
|
authority
|
||||||
|
);
|
||||||
|
record('protocol (sidecar)', platform.protocol, Number(value), authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. The overlay's declared protocol — the third declaration site
|
||||||
|
//
|
||||||
|
// CLAUDE.md: a protocol bump has to land in overlay.toml in the same PR as the emitters,
|
||||||
|
// or the installer refuses to pair the sidecar with the overlay. If these two ever
|
||||||
|
// disagree, the site must not print either number.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function checkOverlayProtocol() {
|
||||||
|
const authority = 'servuo-plugins main:overlay.toml';
|
||||||
|
const toml = await raw('servuo-plugins', 'overlay.toml', 'main');
|
||||||
|
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
|
||||||
|
record('protocol (overlay)', platform.protocol, Number(value), authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. The Module API version
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function checkModuleApi() {
|
||||||
|
const authority = 'website main:server/src/modules/version.js';
|
||||||
|
const src = await raw('website', 'server/src/modules/version.js', 'main');
|
||||||
|
const value = extract(
|
||||||
|
src,
|
||||||
|
/MODULE_API_VERSION\s*=\s*['"]([^'"]+)['"]/,
|
||||||
|
'MODULE_API_VERSION',
|
||||||
|
authority
|
||||||
|
);
|
||||||
|
record('moduleApi', platform.moduleApi, value, authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. The current bundle
|
||||||
|
//
|
||||||
|
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
|
||||||
|
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function checkBundle() {
|
||||||
|
const authority = 'installer bundles:current.json';
|
||||||
|
const current = JSON.parse(await raw('installer', 'current.json', 'bundles'));
|
||||||
|
|
||||||
|
record('bundle.tag', platform.bundle.tag, current.bundle, authority);
|
||||||
|
record('bundle.sidecar', platform.bundle.sidecar, current.link?.tag, authority);
|
||||||
|
record('bundle.overlay', platform.bundle.overlay, current.overlay?.tag, authority);
|
||||||
|
record(
|
||||||
|
'bundle.servuoMin',
|
||||||
|
platform.bundle.servuoMin,
|
||||||
|
current.overlay?.servuo?.min_version,
|
||||||
|
authority
|
||||||
|
);
|
||||||
|
// The bundle is protocol-checked by CI when it is published, so this is a third
|
||||||
|
// independent read of the same number rather than a duplicate of check 1.
|
||||||
|
record('protocol (bundle)', platform.protocol, current.protocol, authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. Release versions, per repo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function checkReleases() {
|
||||||
|
for (const [repo, expected] of Object.entries(platform.releases)) {
|
||||||
|
const authority = `${repo} releases/latest`;
|
||||||
|
const release = await json(`${repo}/releases/latest`);
|
||||||
|
record(`release ${repo}`, expected, release.tag_name, authority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 6. `website` still publishes nothing
|
||||||
|
//
|
||||||
|
// It ships as container images and is never tagged, so the site refers to the platform by
|
||||||
|
// bundle tag and Module API version instead. The day that changes, this repo should notice
|
||||||
|
// rather than keep quietly omitting a version that now exists.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function checkWebsiteHasNoReleases() {
|
||||||
|
const authority = 'website releases (expected empty)';
|
||||||
|
const releases = await json('website/releases');
|
||||||
|
record('websiteHasReleases', platform.websiteHasReleases, releases.length > 0, authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 7. D13 — the contact address lives in exactly one file
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const CONTACT_CHECK = 'contact address (D13)';
|
||||||
|
|
||||||
|
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
||||||
|
|
||||||
|
const SCAN_DIRS = ['src', 'scripts'];
|
||||||
|
const SCAN_EXT = new Set([
|
||||||
|
'.astro', '.css', '.js', '.mjs', '.ts', '.tsx', '.json', '.md', '.mdx', '.svg', '.html',
|
||||||
|
]);
|
||||||
|
// Addresses that are not a contact route: the AI-disclosure trailer the org requires on
|
||||||
|
// every commit, and the noreply Gitea uses for the bot identity.
|
||||||
|
const ALLOWED_ADDRESSES = new Set(['noreply@anthropic.com', 'claude@whitlocktech.net']);
|
||||||
|
|
||||||
|
async function* walk(dir) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||||
|
yield* walk(full);
|
||||||
|
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
||||||
|
yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkContactAddressIsIsolated() {
|
||||||
|
const offenders = [];
|
||||||
|
for (const dir of SCAN_DIRS) {
|
||||||
|
for await (const file of walk(path.join(ROOT, dir))) {
|
||||||
|
const text = readFileSync(file, 'utf8');
|
||||||
|
for (const match of text.matchAll(EMAIL_RE)) {
|
||||||
|
if (ALLOWED_ADDRESSES.has(match[0].toLowerCase())) continue;
|
||||||
|
const line = text.slice(0, match.index).split('\n').length;
|
||||||
|
offenders.push(`${path.relative(ROOT, file)}:${line} — ${match[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (offenders.length) {
|
||||||
|
failures.push({
|
||||||
|
label: CONTACT_CHECK,
|
||||||
|
expectedLabel: 'PLAN.md D13 requires',
|
||||||
|
expected: 'no email address in src/ or scripts/',
|
||||||
|
authority: ' found',
|
||||||
|
actual: `${offenders.length}:\n ` + offenders.join('\n '),
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checked.push({ label: CONTACT_CHECK, expected: 'none', actual: 'none', ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!TOKEN) {
|
||||||
|
console.error(
|
||||||
|
'checkFacts: GITEA_TOKEN is not set.\n\n' +
|
||||||
|
' Anonymous raw fetches fail on this Gitea instance, and a fact check that skips\n' +
|
||||||
|
' itself is worse than no fact check — a stale version would ship silently.\n\n' +
|
||||||
|
' Locally: GITEA_TOKEN=$(grep -o "[^=]*$" ~/.gitea_token_claude) npm run check:facts\n' +
|
||||||
|
' In CI: set GITEA_TOKEN from the repository secret.\n'
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkContactAddressIsIsolated();
|
||||||
|
|
||||||
|
const network = [
|
||||||
|
checkProtocol,
|
||||||
|
checkOverlayProtocol,
|
||||||
|
checkModuleApi,
|
||||||
|
checkBundle,
|
||||||
|
checkReleases,
|
||||||
|
checkWebsiteHasNoReleases,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const check of network) {
|
||||||
|
try {
|
||||||
|
await check();
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({
|
||||||
|
label: check.name,
|
||||||
|
expected: 'a readable authority',
|
||||||
|
actual: error.message,
|
||||||
|
authority: 'Gitea API',
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of checked) {
|
||||||
|
console.log(` ok ${row.label.padEnd(28)} ${row.actual}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!failures.length) {
|
||||||
|
console.log(
|
||||||
|
`\ncheckFacts: ${checked.length} facts agree with their authorities ` +
|
||||||
|
`(platform.json verified ${platform.verifiedOn}).`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('\ncheckFacts: the platform has moved, or the site is wrong.\n');
|
||||||
|
for (const row of failures) {
|
||||||
|
console.error(` FAIL ${row.label}`);
|
||||||
|
console.error(` ${row.expectedLabel} : ${row.expected}`);
|
||||||
|
console.error(` ${row.authority} : ${row.actual}`);
|
||||||
|
console.error('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.some((row) => row.label === CONTACT_CHECK)) {
|
||||||
|
console.error(
|
||||||
|
'The contact address belongs in brand-default/brand.json and nowhere else. D13\n' +
|
||||||
|
'publishes a personal address on the promise that replacing it later costs one\n' +
|
||||||
|
'file copy, and an address typed into a page is a copy no mount can reach.\n' +
|
||||||
|
'Read it through src/lib/brand.mjs instead.\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.some((row) => row.label !== CONTACT_CHECK)) {
|
||||||
|
console.error(
|
||||||
|
'Update src/data/platform.json AND re-read every page that quotes the changed value.\n' +
|
||||||
|
'Do not edit a value here to make this pass — §1 exists because that is how the\n' +
|
||||||
|
'wrong protocol number got written down in the first place.\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
173
scripts/checkTokens.mjs
Normal file
173
scripts/checkTokens.mjs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* checkTokens.mjs — PLAN.md §7
|
||||||
|
*
|
||||||
|
* The promise: recolouring the site is a file copy and a container restart. Drop a
|
||||||
|
* `theme.css` into the brand mount, redefine some custom properties, restart — never a
|
||||||
|
* rebuild, never an image push.
|
||||||
|
*
|
||||||
|
* That holds only while every colour, radius, shadow and font in the stylesheet is a
|
||||||
|
* custom property defined in `src/styles/tokens.css`, because a mounted `theme.css` can
|
||||||
|
* only redefine properties — it cannot reach a value that was written directly into a
|
||||||
|
* rule. Without a check, "one CSS file changes the appearance" decays into "one CSS file
|
||||||
|
* changes most of the appearance, and then there is a hardcoded #0e1318 in the footer".
|
||||||
|
*
|
||||||
|
* So this fails the build when a colour literal appears anywhere in `src/` outside the
|
||||||
|
* token file. The check is the mechanism; diligence is not.
|
||||||
|
*
|
||||||
|
* node scripts/checkTokens.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { readdir } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
|
||||||
|
/** The one file allowed to hold literals — it is the definition site. */
|
||||||
|
const TOKEN_FILE = path.join('src', 'styles', 'tokens.css');
|
||||||
|
|
||||||
|
const SCAN_DIRS = ['src'];
|
||||||
|
const SCAN_EXT = new Set(['.css', '.astro', '.svg', '.ts', '.tsx', '.js', '.mjs', '.html']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately excludes `.md` and `.mdx`: documentation pages quote hex values as prose
|
||||||
|
* (an env var's default, a token's stock value in the theming guide) and that is content,
|
||||||
|
* not styling. If a docs page ever carries a real inline style, it is doing something the
|
||||||
|
* design system should own instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PATTERNS = [
|
||||||
|
{ name: 'hex colour', re: /#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b(?![0-9a-z_-])/gi },
|
||||||
|
{ name: 'rgb()/rgba()', re: /\brgba?\s*\(/gi },
|
||||||
|
{ name: 'hsl()/hsla()', re: /\bhsla?\s*\(/gi },
|
||||||
|
{ name: 'modern colour function', re: /\b(?:oklch|oklab|lab|lch|color)\s*\(/gi },
|
||||||
|
{
|
||||||
|
name: 'named colour',
|
||||||
|
// Only the ones a developer actually reaches for by accident. `transparent`,
|
||||||
|
// `currentColor` and `inherit` are keywords, not colours, and stay legal.
|
||||||
|
re: /(?<![\w-])(?:white|black|red|green|blue|gray|grey|silver|gold|orange|yellow|purple|navy|teal|cyan|magenta)(?![\w-])/gi,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip comments before scanning. The token file's own rationale, and this script's, both
|
||||||
|
* quote `#0e1318` in prose — a checker that flagged its own explanation would be its own
|
||||||
|
* first false positive.
|
||||||
|
*/
|
||||||
|
function stripComments(source, ext) {
|
||||||
|
let out = source.replace(/\/\*[\s\S]*?\*\//g, ' '); // CSS + JS block comments
|
||||||
|
out = out.replace(/<!--[\s\S]*?-->/g, ' '); // HTML/Astro/SVG comments
|
||||||
|
if (ext !== '.css' && ext !== '.svg') {
|
||||||
|
// Line comments, but not the `//` inside a URL.
|
||||||
|
out = out.replace(/(^|[^:\w])\/\/[^\n]*/g, '$1 ');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Astro frontmatter and component script are JavaScript, where a bare word like `black`
|
||||||
|
* is usually an identifier or a string of prose rather than a colour. Named-colour
|
||||||
|
* matching is therefore restricted to files and regions that are actually CSS.
|
||||||
|
*/
|
||||||
|
function scanText(text, isStyleRegion) {
|
||||||
|
const hits = [];
|
||||||
|
for (const { name, re } of PATTERNS) {
|
||||||
|
if (name === 'named colour' && !isStyleRegion) continue;
|
||||||
|
re.lastIndex = 0;
|
||||||
|
for (const match of text.matchAll(re)) {
|
||||||
|
hits.push({ name, value: match[0], index: match.index });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineOf(source, index) {
|
||||||
|
return source.slice(0, index).split('\n').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* walk(dir) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||||
|
yield* walk(full);
|
||||||
|
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
||||||
|
yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const offenders = [];
|
||||||
|
let scanned = 0;
|
||||||
|
|
||||||
|
for (const dir of SCAN_DIRS) {
|
||||||
|
for await (const file of walk(path.join(ROOT, dir))) {
|
||||||
|
const relative = path.relative(ROOT, file);
|
||||||
|
if (relative === TOKEN_FILE) continue;
|
||||||
|
|
||||||
|
const ext = path.extname(file);
|
||||||
|
const source = readFileSync(file, 'utf8');
|
||||||
|
const stripped = stripComments(source, ext);
|
||||||
|
scanned++;
|
||||||
|
|
||||||
|
const isCssFile = ext === '.css';
|
||||||
|
let hits = [];
|
||||||
|
|
||||||
|
if (ext === '.astro') {
|
||||||
|
// Only the <style> blocks are CSS; the rest is a component script and markup.
|
||||||
|
hits = hits.concat(scanText(stripped, false));
|
||||||
|
for (const block of stripped.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
|
||||||
|
const offset = block.index + block[0].indexOf(block[1]);
|
||||||
|
for (const hit of scanText(block[1], true)) {
|
||||||
|
hits.push({ ...hit, index: hit.index + offset });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// De-duplicate: the non-CSS pass already saw the style block's hex values.
|
||||||
|
const seen = new Set();
|
||||||
|
hits = hits.filter((hit) => {
|
||||||
|
const key = `${hit.index}:${hit.value}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
hits = scanText(stripped, isCssFile || ext === '.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hit of hits) {
|
||||||
|
offenders.push({
|
||||||
|
file: relative,
|
||||||
|
line: lineOf(stripped, hit.index),
|
||||||
|
kind: hit.name,
|
||||||
|
value: hit.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!offenders.length) {
|
||||||
|
console.log(
|
||||||
|
`checkTokens: ${scanned} files scanned, every colour comes from ${TOKEN_FILE}.`
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('\ncheckTokens: colour literals found outside the token file.\n');
|
||||||
|
for (const offender of offenders) {
|
||||||
|
console.error(` ${offender.file}:${offender.line} ${offender.kind} ${offender.value}`);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
`\nDefine the colour as a custom property in ${TOKEN_FILE} and reference it with var().\n` +
|
||||||
|
'PLAN.md §7: a bind-mounted theme.css can only redefine properties, so a literal here\n' +
|
||||||
|
'is a piece of the site that an operator can never recolour. Use currentColor in SVG,\n' +
|
||||||
|
'and src/lib/tokens.mjs where a value genuinely has to reach JavaScript.\n'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
23
src/assets/placeholder-mark.svg
Normal file
23
src/assets/placeholder-mark.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Runic Gateway">
|
||||||
|
<!--
|
||||||
|
A placeholder gateway glyph: concentric rings around an open portal, drawn from the
|
||||||
|
emblem's geometry so the header is not empty before phase 2.
|
||||||
|
|
||||||
|
Phase 2 (PLAN.md §7, §11) replaces this with the real derivatives of runic-emblem.png —
|
||||||
|
WebP and AVIF at header, hero and OG sizes, a multi-resolution favicon.ico, the 192/512
|
||||||
|
PWA icons, and the horizontal lockup — all of them in /app/brand-default so the org lead
|
||||||
|
can swap any of them with a file copy.
|
||||||
|
|
||||||
|
Everything is currentColor on purpose: no colour literal, so the mark inherits --gold
|
||||||
|
from the token file and a mounted theme.css recolours it for free.
|
||||||
|
-->
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round">
|
||||||
|
<circle cx="32" cy="32" r="26" stroke-width="3" opacity="0.95" />
|
||||||
|
<circle cx="32" cy="32" r="20" stroke-width="1.25" opacity="0.55" />
|
||||||
|
<circle cx="32" cy="32" r="12.5" stroke-width="2" opacity="0.9" />
|
||||||
|
<path d="M32 6v9M32 49v9M6 32h9M49 32h9" stroke-width="2.5" opacity="0.8" />
|
||||||
|
<path d="M13.6 13.6l6.4 6.4M44 44l6.4 6.4M50.4 13.6L44 20M20 44l-6.4 6.4"
|
||||||
|
stroke-width="1.25" opacity="0.4" />
|
||||||
|
</g>
|
||||||
|
<circle cx="32" cy="32" r="5.5" fill="currentColor" opacity="0.22" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
54
src/components/DocsSiteTitle.astro
Normal file
54
src/components/DocsSiteTitle.astro
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
import Mark from '../assets/placeholder-mark.svg?raw';
|
||||||
|
import { brand } from '../lib/brand.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides Starlight's `SiteTitle` so the documentation header carries the same lockup as
|
||||||
|
* the marketing header. One product, two chromes, one mark.
|
||||||
|
*
|
||||||
|
* It exists because Starlight's `logo` option renders an `<img>`, and our mark is an
|
||||||
|
* inline-only asset: it is drawn in `currentColor` so it inherits `--gold` and follows a
|
||||||
|
* bind-mounted `theme.css` for free (§7). An SVG loaded through `<img>` is an independent
|
||||||
|
* document — `currentColor` has nothing to inherit from there, and the mark renders black
|
||||||
|
* on black. Inlining it is what makes the token reach the artwork.
|
||||||
|
*
|
||||||
|
* Phase 2 replaces the placeholder with the real emblem derivatives; this component keeps
|
||||||
|
* working, because what it needs is markup rather than a file.
|
||||||
|
*/
|
||||||
|
const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
|
||||||
|
---
|
||||||
|
|
||||||
|
<a href={siteTitleHref} class="site-title sl-flex">
|
||||||
|
<span class="docs-mark" set:html={Mark} aria-hidden="true" />
|
||||||
|
<span translate="no">{siteTitle || brand.siteName}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Layout only. The brand of this element — display face, weight, letter-spacing and
|
||||||
|
colour — is set once in src/styles/starlight.css, next to the rest of the docs
|
||||||
|
theming, so there is one place to change it. */
|
||||||
|
.site-title {
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: var(--sl-text-h4);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: none;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme='light']) .docs-mark {
|
||||||
|
color: var(--light-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
span:last-child {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
92
src/components/Footer.astro
Normal file
92
src/components/Footer.astro
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
import { brand } from '../lib/brand.mjs';
|
||||||
|
import platform from '../data/platform.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The footer is on every page, so it is the one place that must never quote a fact from
|
||||||
|
* memory. The version chip reads `platform.json` (§12); the contact address and the links
|
||||||
|
* read `brand.json` (§7, D13).
|
||||||
|
*
|
||||||
|
* `/privacy` and `/terms` are linked from every page (§9) — those pages land in phase 6,
|
||||||
|
* which is why they are the only two entries deliberately left out of the columns below
|
||||||
|
* until then.
|
||||||
|
*/
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
heading: 'Product',
|
||||||
|
links: [
|
||||||
|
{ href: '/features/', label: 'Features' },
|
||||||
|
{ href: '/architecture/', label: 'Architecture' },
|
||||||
|
{ href: '/modules/', label: 'Modules' },
|
||||||
|
{ href: '/app/', label: 'Android app' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Documentation',
|
||||||
|
links: [
|
||||||
|
{ href: '/docs/', label: 'Getting started' },
|
||||||
|
{ href: '/docs/', label: 'Administration' },
|
||||||
|
{ href: '/docs/', label: 'Building a module' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Project',
|
||||||
|
links: [
|
||||||
|
{ href: brand.giteaOrg, label: 'Source' },
|
||||||
|
{ href: brand.discordInvite, label: 'Discord' },
|
||||||
|
{ href: '/community/', label: 'Community' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const isExternal = (href: string) => href.startsWith('http');
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="page">
|
||||||
|
<div class="site-footer__cols">
|
||||||
|
{
|
||||||
|
columns.map((column) => (
|
||||||
|
<section>
|
||||||
|
<h2>{column.heading}</h2>
|
||||||
|
<ul>
|
||||||
|
{column.links.map((link) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
rel={isExternal(link.href) ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="site-footer__legal">
|
||||||
|
<p>
|
||||||
|
{brand.siteName} is free software under the{' '}
|
||||||
|
<a href="https://www.gnu.org/licenses/gpl-3.0.html" rel="noopener noreferrer"
|
||||||
|
>GPL-3.0-or-later</a
|
||||||
|
>. © {year}.
|
||||||
|
</p>
|
||||||
|
<p class="site-footer__meta">
|
||||||
|
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||||
|
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.site-footer__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
src/components/Header.astro
Normal file
49
src/components/Header.astro
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
import { brand } from '../lib/brand.mjs';
|
||||||
|
import Mark from '../assets/placeholder-mark.svg?raw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The marketing header. The docs get Starlight's own header, themed to match in
|
||||||
|
* `src/styles/starlight.css` — one site, two chromes, the same lockup.
|
||||||
|
*
|
||||||
|
* The nav names the routes §10 specifies. Phase 3 onwards fills them in; a link added
|
||||||
|
* here before its page exists fails `checkLinks.mjs`, which is the order we want.
|
||||||
|
*/
|
||||||
|
const { pathname } = Astro.url;
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: '/features/', label: 'Features' },
|
||||||
|
{ href: '/docs/', label: 'Docs' },
|
||||||
|
{ href: '/app/', label: 'App' },
|
||||||
|
{ href: '/community/', label: 'Community' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isCurrent = (href: string) =>
|
||||||
|
href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="page site-header__inner">
|
||||||
|
<a class="brand-lockup" href="/">
|
||||||
|
<span class="brand-lockup__mark" set:html={Mark} />
|
||||||
|
<span class="brand-lockup__name">{brand.siteName}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="site-nav" aria-label="Primary">
|
||||||
|
{
|
||||||
|
links.map((link) => (
|
||||||
|
<a href={link.href} aria-current={isCurrent(link.href) ? 'page' : undefined}>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.brand-lockup__mark {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
78
src/config/sidebar.mjs
Normal file
78
src/config/sidebar.mjs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* The documentation sidebar — PLAN.md §10 "Documentation", five groups, ~37 pages.
|
||||||
|
*
|
||||||
|
* The pages themselves land in phases 7 and 8. This file exists in phase 1 because the
|
||||||
|
* shape of the journey is a design decision that is already made, and because a sidebar
|
||||||
|
* written up front is what stops the docs from being organised by repository. A reader
|
||||||
|
* should never need to know that `link`, `servuo-plugins` and `installer` are three
|
||||||
|
* repositories in order to connect a game server (§10).
|
||||||
|
*
|
||||||
|
* `autogenerate` is deliberately NOT used: the order of "Getting started" is the
|
||||||
|
* installation path from §10, and alphabetical order would scramble it.
|
||||||
|
*
|
||||||
|
* Entries are added as their pages are written — Starlight fails the build on a link to a
|
||||||
|
* page that does not exist, which is the behaviour we want.
|
||||||
|
*/
|
||||||
|
export const docsSidebar = [
|
||||||
|
{
|
||||||
|
label: 'Getting started',
|
||||||
|
items: [{ label: 'What is Runic Gateway?', slug: 'docs' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full planned tree, kept next to the live sidebar so phases 7 and 8 have their
|
||||||
|
* checklist in the place they will be working. Not exported into the Starlight config —
|
||||||
|
* it names pages that do not exist yet.
|
||||||
|
*/
|
||||||
|
export const plannedSidebar = {
|
||||||
|
'Getting started': [
|
||||||
|
'What is Runic Gateway?',
|
||||||
|
'Requirements',
|
||||||
|
'Install the site',
|
||||||
|
'First run',
|
||||||
|
'Install a game module',
|
||||||
|
'Connect a game server',
|
||||||
|
'Verify the whole stack',
|
||||||
|
],
|
||||||
|
Administration: [
|
||||||
|
'Configuration',
|
||||||
|
'Branding and theming',
|
||||||
|
'Navigation and pages',
|
||||||
|
'Users and roles',
|
||||||
|
'Authentication',
|
||||||
|
'Teams',
|
||||||
|
'Moderation',
|
||||||
|
'Notifications and email',
|
||||||
|
'Managing modules',
|
||||||
|
'The shard connection',
|
||||||
|
'Maintenance and upgrades',
|
||||||
|
'Troubleshooting',
|
||||||
|
],
|
||||||
|
Modules: [
|
||||||
|
'The module system',
|
||||||
|
'Installing modules',
|
||||||
|
'Module lifecycle',
|
||||||
|
'The module manifest',
|
||||||
|
'The module API',
|
||||||
|
'Building a module',
|
||||||
|
'The Integration Kit',
|
||||||
|
'Testing and release',
|
||||||
|
],
|
||||||
|
Architecture: [
|
||||||
|
'System architecture',
|
||||||
|
'The bridge',
|
||||||
|
'Authentication architecture',
|
||||||
|
'Teams architecture',
|
||||||
|
'Protocol versions',
|
||||||
|
],
|
||||||
|
Reference: [
|
||||||
|
'Environment variables',
|
||||||
|
'Installer CLI',
|
||||||
|
'sidecar.toml',
|
||||||
|
'Bridge.cfg',
|
||||||
|
'HTTP API',
|
||||||
|
'Event catalog',
|
||||||
|
'Canonical documents',
|
||||||
|
],
|
||||||
|
};
|
||||||
16
src/content.config.ts
Normal file
16
src/content.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineCollection } from 'astro:content';
|
||||||
|
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||||
|
import { docsSchema } from '@astrojs/starlight/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starlight injects a root-level `[...slug]` route, so a page's URL is its path inside the
|
||||||
|
* collection. The extra `docs/` level below is what mounts the documentation at `/docs/`
|
||||||
|
* rather than at the site root, leaving `/`, `/features/` and the rest to the marketing
|
||||||
|
* pages in `src/pages/` — which win, being more specific than a rest parameter.
|
||||||
|
*
|
||||||
|
* src/content/docs/docs/index.mdx -> /docs/
|
||||||
|
* src/content/docs/docs/getting-started/… -> /docs/getting-started/…
|
||||||
|
*/
|
||||||
|
export const collections = {
|
||||||
|
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||||
|
};
|
||||||
58
src/content/docs/docs/index.mdx
Normal file
58
src/content/docs/docs/index.mdx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
title: What is Runic Gateway?
|
||||||
|
description: An overview of the platform, and where the documentation goes from here.
|
||||||
|
---
|
||||||
|
|
||||||
|
import platform from '../../../data/platform.json';
|
||||||
|
|
||||||
|
Runic Gateway puts a private game server's live state on a public website without ever
|
||||||
|
exposing the game to the internet.
|
||||||
|
|
||||||
|
The shard dials **out** to a small sidecar over loopback; the sidecar is the only
|
||||||
|
network-facing component, and only the website's backend is allowed to talk to it. The
|
||||||
|
website degrades gracefully when the game is down, and sensitive events never reach the
|
||||||
|
public event stream.
|
||||||
|
|
||||||
|
:::note[This documentation is being written in phases]
|
||||||
|
The scaffold, theme and sidebar are in place. The pages themselves land in phases 7 and 8,
|
||||||
|
starting with the installation path — which is the priority of the whole project, because
|
||||||
|
the repositories treat the site and the shard as separate deployments and nothing today
|
||||||
|
presents them as one sequence.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## What the platform is on today
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Wire protocol</td><td>{platform.protocol}</td></tr>
|
||||||
|
<tr><td>Module API</td><td>{platform.moduleApi}</td></tr>
|
||||||
|
<tr><td>Current bundle</td><td>{platform.bundle.tag}</td></tr>
|
||||||
|
<tr><td>uo-link sidecar</td><td>{platform.releases.link}</td></tr>
|
||||||
|
<tr><td>Plugin overlay</td><td>{platform.bundle.overlay}</td></tr>
|
||||||
|
<tr><td>Installer</td><td>{platform.releases.installer}</td></tr>
|
||||||
|
<tr><td>ServUO (minimum)</td><td>{platform.bundle.servuoMin}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Every value in that table is read from `src/data/platform.json` and re-checked against its
|
||||||
|
source of truth on each build. None of it is typed into prose — including here.
|
||||||
|
|
||||||
|
## An install is two installs
|
||||||
|
|
||||||
|
Worth stating before anything else, because it is a real trap: a Runic Gateway install is
|
||||||
|
two independent deployments.
|
||||||
|
|
||||||
|
1. **The site** is a Docker deployment — the website, a database, and a game module.
|
||||||
|
2. **The shard side** is the installer binary, run on the game server's host. It sets up
|
||||||
|
the plugin overlay and the sidecar, and it never contacts the website.
|
||||||
|
|
||||||
|
They meet at four values pasted into **Admin → Shard**, and at protocol {platform.protocol},
|
||||||
|
which both sides check before they will pair.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
The canonical, normative documents live in the
|
||||||
|
[`docs` repository](https://gitea.whitlocktech.com/RunicGateway/docs) and always win over
|
||||||
|
anything written here. This site authors the *journey* — install, configure, administer,
|
||||||
|
extend — which is the thing no existing document owns end to end, because the repositories
|
||||||
|
are organised by component and an operator is not.
|
||||||
67
src/layouts/Base.astro
Normal file
67
src/layouts/Base.astro
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
import '../styles/tokens.css';
|
||||||
|
import '../styles/global.css';
|
||||||
|
|
||||||
|
import Header from '../components/Header.astro';
|
||||||
|
import Footer from '../components/Footer.astro';
|
||||||
|
import { brand } from '../lib/brand.mjs';
|
||||||
|
import { token } from '../lib/tokens.mjs';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Suppress the site name suffix — the homepage sets its own full title. */
|
||||||
|
bareTitle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, bareTitle = false } = Astro.props;
|
||||||
|
|
||||||
|
const fullTitle = bareTitle ? title : `${title} — ${brand.siteName}`;
|
||||||
|
const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
|
<title>{fullTitle}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content={brand.siteName} />
|
||||||
|
<meta property="og:title" content={fullTitle} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonical} />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
No analytics, no third-party requests, no cookie banner (D9), and the fonts are
|
||||||
|
self-hosted (§11) — so there is nothing here to preconnect to, and §6's
|
||||||
|
`default-src 'self'` holds with no exception to argue about. The CSP header itself
|
||||||
|
is set at the adapter in phase 10; this comment is here so nobody adds a CDN link
|
||||||
|
in the meantime and quietly breaks the promise.
|
||||||
|
|
||||||
|
Favicons and the OG image are served from the brand mount (`/brand/*`) in phase 2.
|
||||||
|
-->
|
||||||
|
<meta name="theme-color" content={token('--bg')} />
|
||||||
|
|
||||||
|
<slot name="head" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
|
|
||||||
|
<div class="site">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
src/lib/brand.mjs
Normal file
40
src/lib/brand.mjs
Normal 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('$')));
|
||||||
|
}
|
||||||
46
src/lib/tokens.mjs
Normal file
46
src/lib/tokens.mjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Vite inlines the file's text at build time. This is deliberately NOT a `readFileSync`
|
||||||
|
// against `import.meta.url`: that works in dev and then throws ENOENT during prerender,
|
||||||
|
// because the bundled chunk sits in `dist/server/.prerender/` and the CSS does not follow
|
||||||
|
// it there. `?raw` puts the bytes in the bundle, where they are needed.
|
||||||
|
import tokensCss from '../styles/tokens.css?raw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `tokens.css` at build time and exposes its custom properties to JavaScript.
|
||||||
|
*
|
||||||
|
* This exists because a few values have to leave CSS: `<meta name="theme-color">`, the OG
|
||||||
|
* card's background, an SVG diagram's stroke. Copying them into a template would be
|
||||||
|
* exactly the drift §7 warns about — "one CSS file changes most of the appearance, and
|
||||||
|
* then there is a hardcoded #0e1318 in the footer" — and `checkTokens.mjs` would fail the
|
||||||
|
* build for it, correctly.
|
||||||
|
*
|
||||||
|
* So the token file stays the single source and this reads it, rather than the other way
|
||||||
|
* round. Deliberately a plain regex over `--name: value;` and not a CSS parser: the file
|
||||||
|
* it reads is one we own and keep flat, and a dependency here would be a dependency in the
|
||||||
|
* build of every page.
|
||||||
|
*
|
||||||
|
* Note that this resolves the STOCK values. A bind-mounted `theme.css` overrides tokens in
|
||||||
|
* the browser, at runtime, which is the whole point — anything derived through this module
|
||||||
|
* is therefore build-time and will not follow a mounted theme. Keep that list short.
|
||||||
|
*/
|
||||||
|
function readTokens() {
|
||||||
|
const withoutComments = tokensCss.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
const out = {};
|
||||||
|
for (const match of withoutComments.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/gi)) {
|
||||||
|
out[match[1]] = match[2].trim();
|
||||||
|
}
|
||||||
|
return Object.freeze(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tokens = readTokens();
|
||||||
|
|
||||||
|
/** Throws rather than emitting `undefined` into a template. */
|
||||||
|
export function token(name) {
|
||||||
|
const value = tokens[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown design token "${name}". Every token is defined in src/styles/tokens.css; ` +
|
||||||
|
`add it there rather than inlining a value at the call site.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
32
src/pages/404.astro
Normal file
32
src/pages/404.astro
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ours rather than Starlight's (`disable404Route: true` in astro.config.mjs). A reader who
|
||||||
|
* mistypes a marketing URL should not land in documentation chrome with a sidebar of pages
|
||||||
|
* they were not looking for.
|
||||||
|
*/
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title="Page not found" description="That page does not exist on this site.">
|
||||||
|
<section class="page notfound">
|
||||||
|
<p class="eyebrow">404</p>
|
||||||
|
<h1>That page does not exist</h1>
|
||||||
|
<p class="prose">
|
||||||
|
The link may be out of date, or the page may not be written yet — this site is being
|
||||||
|
built in phases.
|
||||||
|
</p>
|
||||||
|
<p><a href="/">Back to the homepage</a> · <a href="/docs/">Documentation</a></p>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.notfound {
|
||||||
|
padding-block: clamp(3rem, 9vw, 6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notfound h1 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
src/pages/index.astro
Normal file
79
src/pages/index.astro
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { brand } from '../lib/brand.mjs';
|
||||||
|
import platform from '../data/platform.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1 is the foundation, not the homepage — phase 3 builds the real one (hero, the
|
||||||
|
* data-path diagram as inline SVG, the grouped capability sections, the reserved demo
|
||||||
|
* slot). This page exists so the shell is provably assembled: layout, header, footer,
|
||||||
|
* tokens, both typefaces, and a fact read from platform.json rather than typed.
|
||||||
|
*
|
||||||
|
* Everything it claims is from §2's verified state. Nothing here is marketing copy yet.
|
||||||
|
*/
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base
|
||||||
|
title={`${brand.siteName} — ${brand.tagline}`}
|
||||||
|
description={brand.tagline}
|
||||||
|
bareTitle
|
||||||
|
>
|
||||||
|
<section class="page hero">
|
||||||
|
<p class="eyebrow">Foundation</p>
|
||||||
|
<h1>{brand.siteName}</h1>
|
||||||
|
<p class="hero__tagline prose">{brand.tagline}</p>
|
||||||
|
|
||||||
|
<div class="chips">
|
||||||
|
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||||
|
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||||
|
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||||
|
<span class="chip chip--live">Verified {platform.verifiedOn}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page">
|
||||||
|
<div class="panel prose">
|
||||||
|
<h2>This is the phase 1 scaffold</h2>
|
||||||
|
<p>
|
||||||
|
The layout shell, the token file, the self-hosted typefaces, the documentation
|
||||||
|
theme and the two build-time checks are in place. The homepage itself is phase 3;
|
||||||
|
the marketing pages are phase 4; the documentation — the installation path, which
|
||||||
|
is the priority of the whole project — is phase 7.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Every version above was read from <code>src/data/platform.json</code>, and{' '}
|
||||||
|
<code>scripts/checkFacts.mjs</code> re-reads each one from its authority on every
|
||||||
|
build. No version number is written in prose anywhere on this site.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="/docs/">Read the documentation</a> ·{' '}
|
||||||
|
<a href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero {
|
||||||
|
padding-block: clamp(3rem, 9vw, 6rem) 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2.4rem, 7vw, 4rem);
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__tagline {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
334
src/styles/global.css
Normal file
334
src/styles/global.css
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
runicgateway.com — the layout shell
|
||||||
|
============================================================================
|
||||||
|
Every value here is a token from tokens.css. No colour literal appears below
|
||||||
|
this comment; `scripts/checkTokens.mjs` fails the build if one does.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
@import '@fontsource-variable/cinzel';
|
||||||
|
@import '@fontsource-variable/inter';
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
color: var(--head);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-bright);
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--portal-bright);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Page frame --------------------------------------------------------- */
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--page-max);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: var(--gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site > main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The keyboard escape hatch past the header nav. Visible only when focused. */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--gutter);
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateY(-140%);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border: 1px solid var(--gold-deep);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
background: var(--panel-flat);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Header ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
min-height: var(--header-h);
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
background: color-mix(in srgb, var(--bg-deep) 88%, transparent);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__inner {
|
||||||
|
display: flex;
|
||||||
|
min-height: var(--header-h);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-lockup {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-lockup__mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-lockup__name {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1.12rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a:hover {
|
||||||
|
background: var(--panel-flat);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a[aria-current='page'] {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The nav collapses to the docs link alone until phase 3 gives it a real
|
||||||
|
disclosure control; a hamburger with nothing behind it is worse than none. */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.site-nav {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
padding-inline: 0.45rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Footer ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: 4rem;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
padding-block: 2.5rem 2rem;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__cols {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer h2 {
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer li + li {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a:hover {
|
||||||
|
color: var(--accent-bright);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__legal {
|
||||||
|
margin-top: 2.25rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__legal p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Panels ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
background: var(--panel-grad);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Status chip -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--panel-flat);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--version {
|
||||||
|
border-color: color-mix(in srgb, var(--gold-deep) 70%, transparent);
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--draft {
|
||||||
|
border-color: color-mix(in srgb, var(--mode-maint) 55%, transparent);
|
||||||
|
color: var(--mode-maint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--live {
|
||||||
|
border-color: color-mix(in srgb, var(--mode-live) 55%, transparent);
|
||||||
|
color: var(--mode-live);
|
||||||
|
}
|
||||||
158
src/styles/starlight.css
Normal file
158
src/styles/starlight.css
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Starlight theming — the docs half of §11
|
||||||
|
============================================================================
|
||||||
|
Starlight owns `/docs` and brings its own token set (`--sl-color-*`). This
|
||||||
|
file is the bridge: it restates OUR tokens as Starlight's, so the docs and
|
||||||
|
the marketing pages cannot drift, and so a bind-mounted `theme.css` that
|
||||||
|
redefines `--gold` recolours both halves of the site at once.
|
||||||
|
|
||||||
|
Marketing is single-theme dark by design; the docs honour the reader's
|
||||||
|
preference (§11), which is why this file has a light block and global.css
|
||||||
|
does not. Starlight's own light theme is accessible as shipped, so the light
|
||||||
|
block restates only what carries brand — the accents and the surfaces.
|
||||||
|
|
||||||
|
No colour literal appears here. Every value is a var() from tokens.css.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* ---- Dark (Starlight's default root) ------------------------------------ */
|
||||||
|
:root {
|
||||||
|
--sl-font: var(--sans);
|
||||||
|
--sl-font-mono: var(--mono);
|
||||||
|
|
||||||
|
--sl-color-accent-low: var(--blue);
|
||||||
|
--sl-color-accent: var(--accent);
|
||||||
|
--sl-color-accent-high: var(--accent-bright);
|
||||||
|
|
||||||
|
--sl-color-white: var(--ink);
|
||||||
|
--sl-color-gray-1: var(--head);
|
||||||
|
--sl-color-gray-2: var(--text);
|
||||||
|
--sl-color-gray-3: var(--muted);
|
||||||
|
--sl-color-gray-4: var(--dim);
|
||||||
|
--sl-color-gray-5: var(--line);
|
||||||
|
--sl-color-gray-6: var(--line-soft);
|
||||||
|
--sl-color-gray-7: var(--panel-flat);
|
||||||
|
--sl-color-black: var(--bg);
|
||||||
|
|
||||||
|
--sl-color-bg: var(--bg);
|
||||||
|
--sl-color-bg-nav: var(--bg-deep);
|
||||||
|
--sl-color-bg-sidebar: var(--bg-deep);
|
||||||
|
--sl-color-bg-inline-code: var(--panel-flat);
|
||||||
|
--sl-color-hairline: var(--line-soft);
|
||||||
|
--sl-color-hairline-light: var(--line);
|
||||||
|
--sl-color-hairline-shade: var(--line-soft);
|
||||||
|
|
||||||
|
--sl-color-text: var(--text);
|
||||||
|
--sl-color-text-accent: var(--accent);
|
||||||
|
--sl-color-text-invert: var(--bg);
|
||||||
|
|
||||||
|
--sl-shadow-md: var(--shadow-card);
|
||||||
|
--sl-shadow-lg: var(--shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Light -------------------------------------------------------------- */
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
--sl-color-accent-low: var(--light-line);
|
||||||
|
--sl-color-accent: var(--light-accent);
|
||||||
|
--sl-color-accent-high: var(--light-ink);
|
||||||
|
|
||||||
|
--sl-color-white: var(--light-ink);
|
||||||
|
--sl-color-gray-1: var(--light-ink);
|
||||||
|
--sl-color-gray-2: var(--light-text);
|
||||||
|
--sl-color-gray-3: var(--light-muted);
|
||||||
|
--sl-color-gray-4: var(--light-muted);
|
||||||
|
--sl-color-gray-5: var(--light-line);
|
||||||
|
--sl-color-gray-6: var(--light-line);
|
||||||
|
--sl-color-gray-7: var(--light-panel);
|
||||||
|
--sl-color-black: var(--light-panel);
|
||||||
|
|
||||||
|
--sl-color-bg: var(--light-panel);
|
||||||
|
--sl-color-bg-nav: var(--light-bg);
|
||||||
|
--sl-color-bg-sidebar: var(--light-bg);
|
||||||
|
--sl-color-bg-inline-code: var(--light-bg);
|
||||||
|
--sl-color-hairline: var(--light-line);
|
||||||
|
--sl-color-hairline-light: var(--light-line);
|
||||||
|
--sl-color-hairline-shade: var(--light-line);
|
||||||
|
|
||||||
|
--sl-color-text: var(--light-text);
|
||||||
|
--sl-color-text-accent: var(--light-accent);
|
||||||
|
--sl-color-text-invert: var(--light-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Brand details ------------------------------------------------------
|
||||||
|
The docs are not a different product. The wordmark keeps the display face
|
||||||
|
and the gold, and the site title in the docs header matches the marketing
|
||||||
|
header exactly. */
|
||||||
|
|
||||||
|
.site-title {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] .site-title {
|
||||||
|
color: var(--light-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings carry the display face only at h1, matching global.css — using it
|
||||||
|
further down turns a reference page into a poster. */
|
||||||
|
.sl-markdown-content h1 {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Semantic hues -------------------------------------------------------
|
||||||
|
Starlight colours asides and badges from five named scales rather than from the accent,
|
||||||
|
so leaving these alone puts a stock indigo note box in the middle of our palette. The
|
||||||
|
first attempt here overrode each `.starlight-aside--*` rule, which set the border and
|
||||||
|
the heading but not the background — that comes from `--sl-color-<hue>-low`, and the box
|
||||||
|
stayed indigo. Mapping the scales themselves fixes the asides and every other component
|
||||||
|
that reaches for a semantic colour.
|
||||||
|
|
||||||
|
The `-low` step is a background wash, so it is mixed from the same token against the
|
||||||
|
ground rather than picked separately: recolour `--portal` and the tip box follows. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--sl-color-blue-low: color-mix(in srgb, var(--accent) 16%, var(--bg));
|
||||||
|
--sl-color-blue: var(--accent);
|
||||||
|
--sl-color-blue-high: var(--accent-bright);
|
||||||
|
|
||||||
|
/* Starlight's "tip" is purple; ours is the portal. */
|
||||||
|
--sl-color-purple-low: color-mix(in srgb, var(--portal) 14%, var(--bg));
|
||||||
|
--sl-color-purple: var(--portal-deep);
|
||||||
|
--sl-color-purple-high: var(--portal-bright);
|
||||||
|
|
||||||
|
--sl-color-orange-low: color-mix(in srgb, var(--mode-maint) 14%, var(--bg));
|
||||||
|
--sl-color-orange: var(--mode-maint);
|
||||||
|
--sl-color-orange-high: var(--mode-maint);
|
||||||
|
|
||||||
|
--sl-color-red-low: color-mix(in srgb, var(--danger) 14%, var(--bg));
|
||||||
|
--sl-color-red: var(--danger);
|
||||||
|
--sl-color-red-high: var(--danger);
|
||||||
|
|
||||||
|
--sl-color-green-low: color-mix(in srgb, var(--mode-live) 14%, var(--bg));
|
||||||
|
--sl-color-green: var(--mode-live);
|
||||||
|
--sl-color-green-high: var(--mode-live);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
--sl-color-blue-low: color-mix(in srgb, var(--light-accent) 12%, var(--light-panel));
|
||||||
|
--sl-color-blue: var(--light-accent);
|
||||||
|
--sl-color-blue-high: var(--light-accent);
|
||||||
|
|
||||||
|
--sl-color-purple-low: color-mix(in srgb, var(--light-portal) 12%, var(--light-panel));
|
||||||
|
--sl-color-purple: var(--light-portal);
|
||||||
|
--sl-color-purple-high: var(--light-portal);
|
||||||
|
|
||||||
|
--sl-color-orange-low: color-mix(in srgb, var(--light-gold) 12%, var(--light-panel));
|
||||||
|
--sl-color-orange: var(--light-gold);
|
||||||
|
--sl-color-orange-high: var(--light-gold);
|
||||||
|
|
||||||
|
--sl-color-red-low: color-mix(in srgb, var(--danger) 12%, var(--light-panel));
|
||||||
|
--sl-color-red: var(--danger);
|
||||||
|
--sl-color-red-high: var(--danger);
|
||||||
|
|
||||||
|
--sl-color-green-low: color-mix(in srgb, var(--mode-live) 12%, var(--light-panel));
|
||||||
|
--sl-color-green: var(--mode-live);
|
||||||
|
--sl-color-green-high: var(--mode-live);
|
||||||
|
}
|
||||||
129
src/styles/tokens.css
Normal file
129
src/styles/tokens.css
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
runicgateway.com — design tokens
|
||||||
|
============================================================================
|
||||||
|
|
||||||
|
THIS IS THE ONLY FILE IN THE SOURCE TREE ALLOWED TO CONTAIN A COLOUR LITERAL.
|
||||||
|
|
||||||
|
PLAN.md §7 promises that recolouring the site is a file copy and a container
|
||||||
|
restart — never a rebuild. That promise holds only if every colour, radius,
|
||||||
|
shadow and font in the stylesheet is a custom property defined here, so that
|
||||||
|
the bind-mounted `theme.css` can redefine them and win.
|
||||||
|
|
||||||
|
`scripts/checkTokens.mjs` enforces it. Without the check, "one CSS file
|
||||||
|
changes the appearance" decays into "one CSS file changes most of the
|
||||||
|
appearance, and then there is a hardcoded #0e1318 in the footer".
|
||||||
|
|
||||||
|
Names deliberately match `website/client/src/styles/theme.css` where the
|
||||||
|
concepts line up, so a theme written for a Runic Gateway deployment is
|
||||||
|
legible here and vice versa (§7, §11).
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ---- Ground and panels -------------------------------------------------
|
||||||
|
Taken unchanged from the product's token file. Same bytes, same names. */
|
||||||
|
--bg: #0e1318;
|
||||||
|
--bg-deep: #0b0f14;
|
||||||
|
--panel-a: #192231;
|
||||||
|
--panel-b: #141a21;
|
||||||
|
--panel-flat: #11161d;
|
||||||
|
--line: #2a3544;
|
||||||
|
--line-soft: #1d2733;
|
||||||
|
|
||||||
|
/* ---- Interface and type ------------------------------------------------
|
||||||
|
Also the product's, unchanged. `--accent` is the steel blue that carries
|
||||||
|
links and interface emphasis across both sites. */
|
||||||
|
--accent: #7f99bd;
|
||||||
|
--accent-bright: #cdd9e8;
|
||||||
|
--ink: #eef3f8;
|
||||||
|
--head: #e6edf6;
|
||||||
|
--text: #c4cdd8;
|
||||||
|
--muted: #aeb8c4;
|
||||||
|
--dim: #6f7d8e;
|
||||||
|
--blue: #13243c;
|
||||||
|
|
||||||
|
/* ---- Status ------------------------------------------------------------
|
||||||
|
Reused verbatim from the product so a status pill means the same thing on
|
||||||
|
both sites (§11). */
|
||||||
|
--mode-live: #5fb98a;
|
||||||
|
--mode-maint: #e6c26a;
|
||||||
|
|
||||||
|
/* ---- The emblem's own palette ------------------------------------------
|
||||||
|
§11: gold and cyan as the accent pair, "derived from the artwork by
|
||||||
|
sampling, not guessed, and both held to WCAG AA against the ground".
|
||||||
|
|
||||||
|
Sampled from `runic-emblem.png` (1024x1024, 494,059 opaque pixels) by
|
||||||
|
binning every saturated pixel by hue and taking the mean of each bin. The
|
||||||
|
contrast ratio after each value is measured against `--bg` (#0e1318).
|
||||||
|
AA wants 4.5:1 for body text and 3:1 for large text and UI boundaries, so
|
||||||
|
the annotation is also the usage rule. Nothing here was nudged for taste;
|
||||||
|
where a sampled value fails a ratio it is restricted, not brightened. */
|
||||||
|
|
||||||
|
/* Hue 25-45deg — the ring. 65% of the emblem's saturated pixels. */
|
||||||
|
--gold-deep: #946b3c; /* 3.94:1 — rules, borders, UI edges. NEVER text. */
|
||||||
|
--gold: #c8a368; /* 7.91:1 — emphasis text, headings, the mark. */
|
||||||
|
--gold-bright: #e4cb90; /* 11.77:1 — highlights on gold surfaces. */
|
||||||
|
|
||||||
|
/* Hue 180-210deg — the portal and its glow. */
|
||||||
|
--portal-deep: #0b6398; /* 2.89:1 — glow fills and gradients only. */
|
||||||
|
--portal: #15b4de; /* 7.66:1 — the live-state signal, diagram lines. */
|
||||||
|
--portal-bright: #1bd6f1; /* 10.61:1 — the portal core, focus rings. */
|
||||||
|
|
||||||
|
/* Hue 0deg — the ruby set into the ring. The only red in the artwork, so it
|
||||||
|
is the honest source for a destructive/error colour. */
|
||||||
|
--danger: #ff4e43; /* 5.71:1 */
|
||||||
|
|
||||||
|
/* ---- Type --------------------------------------------------------------
|
||||||
|
Both self-hosted (§11), so §6's `default-src 'self'` needs no exception.
|
||||||
|
Cinzel is the project's display face and is already the Android app's;
|
||||||
|
it is confined to the wordmark and hero. Inter carries everything else. */
|
||||||
|
--display: 'Cinzel Variable', Georgia, 'Times New Roman', serif;
|
||||||
|
--sans: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
--mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* ---- Radius ------------------------------------------------------------
|
||||||
|
Named by the kind of surface rather than the pixel value, matching the
|
||||||
|
product's promotion of the same four tokens. */
|
||||||
|
--radius-pill: 999px;
|
||||||
|
--radius-panel: 12px;
|
||||||
|
--radius-card: 10px;
|
||||||
|
--radius-input: 8px;
|
||||||
|
|
||||||
|
/* ---- Elevation and surface treatments ---------------------------------- */
|
||||||
|
--shadow-card: 0 14px 34px rgb(0 0 0 / 30%);
|
||||||
|
--shadow-raised: 0 22px 48px rgb(0 0 0 / 38%);
|
||||||
|
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
||||||
|
--glow-portal: 0 0 32px rgb(21 180 222 / 22%);
|
||||||
|
|
||||||
|
/* ---- Layout ------------------------------------------------------------
|
||||||
|
Here rather than in global.css so a theme can widen the measure without
|
||||||
|
touching the stylesheet. */
|
||||||
|
--measure: 68ch;
|
||||||
|
--page-max: 1180px;
|
||||||
|
--gutter: 24px;
|
||||||
|
--header-h: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Light mode, docs only ----------------------------------------------
|
||||||
|
§11: marketing pages are single-theme by design; the docs honour the
|
||||||
|
reader's light/dark preference. Starlight ships an accessible light theme,
|
||||||
|
so this is not a second palette — it is the four brand colours restated at
|
||||||
|
the lightness a white ground needs, plus the surfaces Starlight tints.
|
||||||
|
|
||||||
|
Same hues as the dark set, darkened rather than re-picked, with the
|
||||||
|
contrast against `--light-bg` measured the same way. They live here, in the
|
||||||
|
token file, because that is the rule: a literal anywhere else fails
|
||||||
|
`checkTokens.mjs`, and a bind-mounted `theme.css` must be able to reach
|
||||||
|
these too. */
|
||||||
|
:root {
|
||||||
|
--light-bg: #f6f8fb;
|
||||||
|
--light-panel: #ffffff;
|
||||||
|
--light-line: #d6dee9;
|
||||||
|
--light-ink: #16202c;
|
||||||
|
--light-text: #33414f;
|
||||||
|
--light-muted: #5a6875;
|
||||||
|
|
||||||
|
--light-accent: #3c5f8f; /* 6.12:1 — the steel blue, darkened for links */
|
||||||
|
--light-gold: #7a5a24; /* 5.95:1 — the ring, darkened for emphasis */
|
||||||
|
--light-portal: #0a5f80; /* 6.67:1 — the portal, darkened for diagrams */
|
||||||
|
}
|
||||||
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist", "node_modules"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user