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

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

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

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

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

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

View 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
View 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
View File

@@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View 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
View 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
View 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);

View 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

View 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>

View 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
>. &copy; {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>

View 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
View 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
View 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() }),
};

View 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
View 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
View File

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

46
src/lib/tokens.mjs Normal file
View 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
View 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> &middot; <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
View 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> &middot;{' '}
<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
View 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
View 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
View 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
View 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
}
}