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,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 */
}