Merge pull request 'feat(site): phase 1 — the foundation' (#4) from feat/phase-1-foundation into main
All checks were successful
PR checks / checks (push) Successful in 9m12s
All checks were successful
PR checks / checks (push) Successful in 9m12s
Reviewed-on: #4
This commit is contained in:
58
.gitea/workflows/pr-checks.yml
Normal file
58
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
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 404s on all four, so the job uses the org-level
|
||||
# REGISTRY_TOKEN, which already exists and already carries the right scope.
|
||||
#
|
||||
# The secret is named for the registry; the script reads GITEA_TOKEN. Mapping it
|
||||
# here rather than renaming either side keeps the script's interface honest — it
|
||||
# wants a Gitea token, not this org's particular secret.
|
||||
#
|
||||
# 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.REGISTRY_TOKEN }}
|
||||
run: npm run check:facts
|
||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# 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.
|
||||
#
|
||||
# Both patterns are anchored with a leading slash, and that is load-bearing: an
|
||||
# unanchored `data/` matches a directory of that name at ANY depth, so it silently
|
||||
# swallowed `src/data/platform.json` — the one file the whole accuracy machinery reads.
|
||||
# The working tree still had it, so every local check passed; CI, cloning fresh, failed
|
||||
# on the missing module. Anchor anything meant to match only the repository root.
|
||||
/brand/
|
||||
/data/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editors and OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
npm-debug.log*
|
||||
104
README.md
104
README.md
@@ -1,2 +1,106 @@
|
||||
# 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. It must be able to read the other repositories in the
|
||||
organisation, not just this one. CI already has this: the workflow maps the org-level
|
||||
`REGISTRY_TOKEN` secret into `GITEA_TOKEN` for that step.
|
||||
|
||||
**`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"
|
||||
}
|
||||
}
|
||||
306
scripts/checkFacts.mjs
Normal file
306
scripts/checkFacts.mjs
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/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=<a token that can read the org> npm run check:facts\n' +
|
||||
' In CI: already wired — .gitea/workflows/pr-checks.yml maps the org-level\n' +
|
||||
' REGISTRY_TOKEN secret into GITEA_TOKEN for this step.\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.
|
||||
60
src/data/platform.json
Normal file
60
src/data/platform.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$comment": [
|
||||
"PLAN.md §12: one file holding every externally-sourced fact. Every page reads from",
|
||||
"here, and NO version number is ever hardcoded in prose.",
|
||||
"",
|
||||
"`scripts/checkFacts.mjs` fetches the authority for each value below and fails the build",
|
||||
"on any disagreement. When the platform moves, this repo goes red so someone updates the",
|
||||
"site — that failure is the feature, exactly as in the Integration Kit's checkCoreApi.js.",
|
||||
"",
|
||||
"Do not edit a value here to make the check pass without also re-reading the page that",
|
||||
"quotes it. §1's process rule exists because a stale local `main` is what produced the",
|
||||
"wrong protocol number in the first place."
|
||||
],
|
||||
|
||||
"verifiedOn": "2026-08-19",
|
||||
|
||||
"protocol": 4,
|
||||
|
||||
"moduleApi": "1.6.0",
|
||||
|
||||
"bundle": {
|
||||
"tag": "2026.08.19",
|
||||
"sidecar": "v2.0.0",
|
||||
"overlay": "v1.0.0",
|
||||
"servuoMin": "57.4"
|
||||
},
|
||||
|
||||
"releases": {
|
||||
"link": "v2.0.0",
|
||||
"installer": "v0.1.0",
|
||||
"Module-uo": "v1.0.1",
|
||||
"Android-app": "v0.5.0"
|
||||
},
|
||||
|
||||
"$comment_website": [
|
||||
"`website` publishes no releases at all — it ships as container images and is never",
|
||||
"tagged. The site must never print a 'website version'; it refers to the platform by",
|
||||
"bundle tag and Module API version instead. checkFacts.mjs asserts the releases list is",
|
||||
"still empty, so the day that changes, this repo notices."
|
||||
],
|
||||
"websiteHasReleases": false,
|
||||
|
||||
"moduleUoCapabilities": [
|
||||
"shard",
|
||||
"atlas",
|
||||
"market",
|
||||
"governors",
|
||||
"guilds",
|
||||
"houses",
|
||||
"champs",
|
||||
"cliloc"
|
||||
],
|
||||
|
||||
"androidApplicationId": "com.runicgateway.app",
|
||||
|
||||
"gitea": {
|
||||
"base": "https://gitea.whitlocktech.com",
|
||||
"org": "RunicGateway"
|
||||
}
|
||||
}
|
||||
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