feat(site): phase 1 — the foundation
Some checks failed
PR checks / checks (pull_request) Failing after 4m19s
Some checks failed
PR checks / checks (pull_request) Failing after 4m19s
Astro 7 with the Node adapter, Starlight mounted at /docs, the token file, both self-hosted typefaces, the layout shell, and the two build-time checks from §12. The palette's gold and cyan are sampled from runic-emblem.png rather than guessed, per §11: 494,059 opaque pixels binned by hue, each value annotated with its measured contrast against the ground, and restricted rather than brightened where a ratio fails. - checkTokens.mjs fails the build on any colour literal outside tokens.css, which is what keeps §7's "recolouring is a file copy" promise true. - checkFacts.mjs re-reads all 14 externally-sourced facts from their authorities over the Gitea API and fails on disagreement. It also enforces D13: no email address in the source outside brand-default/brand.json. - Both were negative-tested; neither has ever been allowed to pass by default. §6 asks for output:'server' with per-page prerender=true. Astro 7 expresses the same runtime shape as output:'static' with an adapter, opting individual routes out — so the default is static rather than accidentally server-rendered. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
23
src/assets/placeholder-mark.svg
Normal file
23
src/assets/placeholder-mark.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Runic Gateway">
|
||||
<!--
|
||||
A placeholder gateway glyph: concentric rings around an open portal, drawn from the
|
||||
emblem's geometry so the header is not empty before phase 2.
|
||||
|
||||
Phase 2 (PLAN.md §7, §11) replaces this with the real derivatives of runic-emblem.png —
|
||||
WebP and AVIF at header, hero and OG sizes, a multi-resolution favicon.ico, the 192/512
|
||||
PWA icons, and the horizontal lockup — all of them in /app/brand-default so the org lead
|
||||
can swap any of them with a file copy.
|
||||
|
||||
Everything is currentColor on purpose: no colour literal, so the mark inherits --gold
|
||||
from the token file and a mounted theme.css recolours it for free.
|
||||
-->
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round">
|
||||
<circle cx="32" cy="32" r="26" stroke-width="3" opacity="0.95" />
|
||||
<circle cx="32" cy="32" r="20" stroke-width="1.25" opacity="0.55" />
|
||||
<circle cx="32" cy="32" r="12.5" stroke-width="2" opacity="0.9" />
|
||||
<path d="M32 6v9M32 49v9M6 32h9M49 32h9" stroke-width="2.5" opacity="0.8" />
|
||||
<path d="M13.6 13.6l6.4 6.4M44 44l6.4 6.4M50.4 13.6L44 20M20 44l-6.4 6.4"
|
||||
stroke-width="1.25" opacity="0.4" />
|
||||
</g>
|
||||
<circle cx="32" cy="32" r="5.5" fill="currentColor" opacity="0.22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
54
src/components/DocsSiteTitle.astro
Normal file
54
src/components/DocsSiteTitle.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import Mark from '../assets/placeholder-mark.svg?raw';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* Overrides Starlight's `SiteTitle` so the documentation header carries the same lockup as
|
||||
* the marketing header. One product, two chromes, one mark.
|
||||
*
|
||||
* It exists because Starlight's `logo` option renders an `<img>`, and our mark is an
|
||||
* inline-only asset: it is drawn in `currentColor` so it inherits `--gold` and follows a
|
||||
* bind-mounted `theme.css` for free (§7). An SVG loaded through `<img>` is an independent
|
||||
* document — `currentColor` has nothing to inherit from there, and the mark renders black
|
||||
* on black. Inlining it is what makes the token reach the artwork.
|
||||
*
|
||||
* Phase 2 replaces the placeholder with the real emblem derivatives; this component keeps
|
||||
* working, because what it needs is markup rather than a file.
|
||||
*/
|
||||
const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
|
||||
---
|
||||
|
||||
<a href={siteTitleHref} class="site-title sl-flex">
|
||||
<span class="docs-mark" set:html={Mark} aria-hidden="true" />
|
||||
<span translate="no">{siteTitle || brand.siteName}</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
/* Layout only. The brand of this element — display face, weight, letter-spacing and
|
||||
colour — is set once in src/styles/starlight.css, next to the rest of the docs
|
||||
theming, so there is one place to change it. */
|
||||
.site-title {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: var(--sl-text-h4);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docs-mark {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
:global(:root[data-theme='light']) .docs-mark {
|
||||
color: var(--light-gold);
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
92
src/components/Footer.astro
Normal file
92
src/components/Footer.astro
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
/**
|
||||
* The footer is on every page, so it is the one place that must never quote a fact from
|
||||
* memory. The version chip reads `platform.json` (§12); the contact address and the links
|
||||
* read `brand.json` (§7, D13).
|
||||
*
|
||||
* `/privacy` and `/terms` are linked from every page (§9) — those pages land in phase 6,
|
||||
* which is why they are the only two entries deliberately left out of the columns below
|
||||
* until then.
|
||||
*/
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
heading: 'Product',
|
||||
links: [
|
||||
{ href: '/features/', label: 'Features' },
|
||||
{ href: '/architecture/', label: 'Architecture' },
|
||||
{ href: '/modules/', label: 'Modules' },
|
||||
{ href: '/app/', label: 'Android app' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Documentation',
|
||||
links: [
|
||||
{ href: '/docs/', label: 'Getting started' },
|
||||
{ href: '/docs/', label: 'Administration' },
|
||||
{ href: '/docs/', label: 'Building a module' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Project',
|
||||
links: [
|
||||
{ href: brand.giteaOrg, label: 'Source' },
|
||||
{ href: brand.discordInvite, label: 'Discord' },
|
||||
{ href: '/community/', label: 'Community' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const isExternal = (href: string) => href.startsWith('http');
|
||||
---
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="page">
|
||||
<div class="site-footer__cols">
|
||||
{
|
||||
columns.map((column) => (
|
||||
<section>
|
||||
<h2>{column.heading}</h2>
|
||||
<ul>
|
||||
{column.links.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
rel={isExternal(link.href) ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="site-footer__legal">
|
||||
<p>
|
||||
{brand.siteName} is free software under the{' '}
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html" rel="noopener noreferrer"
|
||||
>GPL-3.0-or-later</a
|
||||
>. © {year}.
|
||||
</p>
|
||||
<p class="site-footer__meta">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.site-footer__meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
49
src/components/Header.astro
Normal file
49
src/components/Header.astro
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import Mark from '../assets/placeholder-mark.svg?raw';
|
||||
|
||||
/**
|
||||
* The marketing header. The docs get Starlight's own header, themed to match in
|
||||
* `src/styles/starlight.css` — one site, two chromes, the same lockup.
|
||||
*
|
||||
* The nav names the routes §10 specifies. Phase 3 onwards fills them in; a link added
|
||||
* here before its page exists fails `checkLinks.mjs`, which is the order we want.
|
||||
*/
|
||||
const { pathname } = Astro.url;
|
||||
|
||||
const links = [
|
||||
{ href: '/features/', label: 'Features' },
|
||||
{ href: '/docs/', label: 'Docs' },
|
||||
{ href: '/app/', label: 'App' },
|
||||
{ href: '/community/', label: 'Community' },
|
||||
];
|
||||
|
||||
const isCurrent = (href: string) =>
|
||||
href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||
---
|
||||
|
||||
<header class="site-header">
|
||||
<div class="page site-header__inner">
|
||||
<a class="brand-lockup" href="/">
|
||||
<span class="brand-lockup__mark" set:html={Mark} />
|
||||
<span class="brand-lockup__name">{brand.siteName}</span>
|
||||
</a>
|
||||
|
||||
<nav class="site-nav" aria-label="Primary">
|
||||
{
|
||||
links.map((link) => (
|
||||
<a href={link.href} aria-current={isCurrent(link.href) ? 'page' : undefined}>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.brand-lockup__mark {
|
||||
display: inline-flex;
|
||||
color: var(--gold);
|
||||
}
|
||||
</style>
|
||||
78
src/config/sidebar.mjs
Normal file
78
src/config/sidebar.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The documentation sidebar — PLAN.md §10 "Documentation", five groups, ~37 pages.
|
||||
*
|
||||
* The pages themselves land in phases 7 and 8. This file exists in phase 1 because the
|
||||
* shape of the journey is a design decision that is already made, and because a sidebar
|
||||
* written up front is what stops the docs from being organised by repository. A reader
|
||||
* should never need to know that `link`, `servuo-plugins` and `installer` are three
|
||||
* repositories in order to connect a game server (§10).
|
||||
*
|
||||
* `autogenerate` is deliberately NOT used: the order of "Getting started" is the
|
||||
* installation path from §10, and alphabetical order would scramble it.
|
||||
*
|
||||
* Entries are added as their pages are written — Starlight fails the build on a link to a
|
||||
* page that does not exist, which is the behaviour we want.
|
||||
*/
|
||||
export const docsSidebar = [
|
||||
{
|
||||
label: 'Getting started',
|
||||
items: [{ label: 'What is Runic Gateway?', slug: 'docs' }],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The full planned tree, kept next to the live sidebar so phases 7 and 8 have their
|
||||
* checklist in the place they will be working. Not exported into the Starlight config —
|
||||
* it names pages that do not exist yet.
|
||||
*/
|
||||
export const plannedSidebar = {
|
||||
'Getting started': [
|
||||
'What is Runic Gateway?',
|
||||
'Requirements',
|
||||
'Install the site',
|
||||
'First run',
|
||||
'Install a game module',
|
||||
'Connect a game server',
|
||||
'Verify the whole stack',
|
||||
],
|
||||
Administration: [
|
||||
'Configuration',
|
||||
'Branding and theming',
|
||||
'Navigation and pages',
|
||||
'Users and roles',
|
||||
'Authentication',
|
||||
'Teams',
|
||||
'Moderation',
|
||||
'Notifications and email',
|
||||
'Managing modules',
|
||||
'The shard connection',
|
||||
'Maintenance and upgrades',
|
||||
'Troubleshooting',
|
||||
],
|
||||
Modules: [
|
||||
'The module system',
|
||||
'Installing modules',
|
||||
'Module lifecycle',
|
||||
'The module manifest',
|
||||
'The module API',
|
||||
'Building a module',
|
||||
'The Integration Kit',
|
||||
'Testing and release',
|
||||
],
|
||||
Architecture: [
|
||||
'System architecture',
|
||||
'The bridge',
|
||||
'Authentication architecture',
|
||||
'Teams architecture',
|
||||
'Protocol versions',
|
||||
],
|
||||
Reference: [
|
||||
'Environment variables',
|
||||
'Installer CLI',
|
||||
'sidecar.toml',
|
||||
'Bridge.cfg',
|
||||
'HTTP API',
|
||||
'Event catalog',
|
||||
'Canonical documents',
|
||||
],
|
||||
};
|
||||
16
src/content.config.ts
Normal file
16
src/content.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
/**
|
||||
* Starlight injects a root-level `[...slug]` route, so a page's URL is its path inside the
|
||||
* collection. The extra `docs/` level below is what mounts the documentation at `/docs/`
|
||||
* rather than at the site root, leaving `/`, `/features/` and the rest to the marketing
|
||||
* pages in `src/pages/` — which win, being more specific than a rest parameter.
|
||||
*
|
||||
* src/content/docs/docs/index.mdx -> /docs/
|
||||
* src/content/docs/docs/getting-started/… -> /docs/getting-started/…
|
||||
*/
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
58
src/content/docs/docs/index.mdx
Normal file
58
src/content/docs/docs/index.mdx
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: What is Runic Gateway?
|
||||
description: An overview of the platform, and where the documentation goes from here.
|
||||
---
|
||||
|
||||
import platform from '../../../data/platform.json';
|
||||
|
||||
Runic Gateway puts a private game server's live state on a public website without ever
|
||||
exposing the game to the internet.
|
||||
|
||||
The shard dials **out** to a small sidecar over loopback; the sidecar is the only
|
||||
network-facing component, and only the website's backend is allowed to talk to it. The
|
||||
website degrades gracefully when the game is down, and sensitive events never reach the
|
||||
public event stream.
|
||||
|
||||
:::note[This documentation is being written in phases]
|
||||
The scaffold, theme and sidebar are in place. The pages themselves land in phases 7 and 8,
|
||||
starting with the installation path — which is the priority of the whole project, because
|
||||
the repositories treat the site and the shard as separate deployments and nothing today
|
||||
presents them as one sequence.
|
||||
:::
|
||||
|
||||
## What the platform is on today
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>Wire protocol</td><td>{platform.protocol}</td></tr>
|
||||
<tr><td>Module API</td><td>{platform.moduleApi}</td></tr>
|
||||
<tr><td>Current bundle</td><td>{platform.bundle.tag}</td></tr>
|
||||
<tr><td>uo-link sidecar</td><td>{platform.releases.link}</td></tr>
|
||||
<tr><td>Plugin overlay</td><td>{platform.bundle.overlay}</td></tr>
|
||||
<tr><td>Installer</td><td>{platform.releases.installer}</td></tr>
|
||||
<tr><td>ServUO (minimum)</td><td>{platform.bundle.servuoMin}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Every value in that table is read from `src/data/platform.json` and re-checked against its
|
||||
source of truth on each build. None of it is typed into prose — including here.
|
||||
|
||||
## An install is two installs
|
||||
|
||||
Worth stating before anything else, because it is a real trap: a Runic Gateway install is
|
||||
two independent deployments.
|
||||
|
||||
1. **The site** is a Docker deployment — the website, a database, and a game module.
|
||||
2. **The shard side** is the installer binary, run on the game server's host. It sets up
|
||||
the plugin overlay and the sidecar, and it never contacts the website.
|
||||
|
||||
They meet at four values pasted into **Admin → Shard**, and at protocol {platform.protocol},
|
||||
which both sides check before they will pair.
|
||||
|
||||
## Where to go next
|
||||
|
||||
The canonical, normative documents live in the
|
||||
[`docs` repository](https://gitea.whitlocktech.com/RunicGateway/docs) and always win over
|
||||
anything written here. This site authors the *journey* — install, configure, administer,
|
||||
extend — which is the thing no existing document owns end to end, because the repositories
|
||||
are organised by component and an operator is not.
|
||||
67
src/layouts/Base.astro
Normal file
67
src/layouts/Base.astro
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import '../styles/tokens.css';
|
||||
import '../styles/global.css';
|
||||
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import { token } from '../lib/tokens.mjs';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Suppress the site name suffix — the homepage sets its own full title. */
|
||||
bareTitle?: boolean;
|
||||
}
|
||||
|
||||
const { title, description, bareTitle = false } = Astro.props;
|
||||
|
||||
const fullTitle = bareTitle ? title : `${title} — ${brand.siteName}`;
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={brand.siteName} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<!--
|
||||
No analytics, no third-party requests, no cookie banner (D9), and the fonts are
|
||||
self-hosted (§11) — so there is nothing here to preconnect to, and §6's
|
||||
`default-src 'self'` holds with no exception to argue about. The CSP header itself
|
||||
is set at the adapter in phase 10; this comment is here so nobody adds a CDN link
|
||||
in the meantime and quietly breaks the promise.
|
||||
|
||||
Favicons and the OG image are served from the brand mount (`/brand/*`) in phase 2.
|
||||
-->
|
||||
<meta name="theme-color" content={token('--bg')} />
|
||||
|
||||
<slot name="head" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<div class="site">
|
||||
<Header />
|
||||
|
||||
<main id="main">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
40
src/lib/brand.mjs
Normal file
40
src/lib/brand.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import brandDefault from '../../brand-default/brand.json' with { type: 'json' };
|
||||
|
||||
/**
|
||||
* The single accessor for brand text (§7). Every template reads brand through here and
|
||||
* never imports `brand.json` directly, so phase 2 can change WHERE the values come from
|
||||
* without touching a single call site.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* A tension phase 2 has to resolve, recorded here so it is not discovered late
|
||||
* ---------------------------------------------------------------------------
|
||||
* §7 promises that changing the site name, the Discord invite or the contact address is a
|
||||
* file edit on the bind mount plus a restart — the same class of change as swapping a
|
||||
* logo. But §6 prerenders the pages at build time, and a value read at build time is baked
|
||||
* into the HTML, where no mounted file can reach it.
|
||||
*
|
||||
* Assets are fine: they are served by `GET /brand/*` at runtime, which reads the mount per
|
||||
* request. Text is not, and phase 2 owns the fix. The options, in the order they are worth
|
||||
* trying:
|
||||
*
|
||||
* 1. A response-time rewrite in the Node adapter's middleware, substituting a small set
|
||||
* of placeholder tokens in the prerendered HTML. Keeps every page static and the
|
||||
* mount authoritative. Costs one pass over the response body.
|
||||
* 2. Mark the handful of pages that show brand text as `prerender = false`. Simple, but
|
||||
* it spreads: the footer is on every page, so "the handful" is all of them.
|
||||
* 3. Accept that text is build-time and only assets are mounted. Cheapest, and it
|
||||
* contradicts the sentence in §7 that says otherwise — so it needs the org lead's
|
||||
* agreement, not a quiet decision here.
|
||||
*
|
||||
* Until then this returns the stock values, which is the correct behaviour for an empty
|
||||
* mount either way.
|
||||
*/
|
||||
export const brand = Object.freeze({ ...brandDefault });
|
||||
|
||||
/**
|
||||
* `brand.json` carries `$comment` keys for the operator who opens the mounted copy. They
|
||||
* are documentation, not fields, and must never reach a template.
|
||||
*/
|
||||
export function brandFields() {
|
||||
return Object.fromEntries(Object.entries(brand).filter(([k]) => !k.startsWith('$')));
|
||||
}
|
||||
46
src/lib/tokens.mjs
Normal file
46
src/lib/tokens.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
// Vite inlines the file's text at build time. This is deliberately NOT a `readFileSync`
|
||||
// against `import.meta.url`: that works in dev and then throws ENOENT during prerender,
|
||||
// because the bundled chunk sits in `dist/server/.prerender/` and the CSS does not follow
|
||||
// it there. `?raw` puts the bytes in the bundle, where they are needed.
|
||||
import tokensCss from '../styles/tokens.css?raw';
|
||||
|
||||
/**
|
||||
* Reads `tokens.css` at build time and exposes its custom properties to JavaScript.
|
||||
*
|
||||
* This exists because a few values have to leave CSS: `<meta name="theme-color">`, the OG
|
||||
* card's background, an SVG diagram's stroke. Copying them into a template would be
|
||||
* exactly the drift §7 warns about — "one CSS file changes most of the appearance, and
|
||||
* then there is a hardcoded #0e1318 in the footer" — and `checkTokens.mjs` would fail the
|
||||
* build for it, correctly.
|
||||
*
|
||||
* So the token file stays the single source and this reads it, rather than the other way
|
||||
* round. Deliberately a plain regex over `--name: value;` and not a CSS parser: the file
|
||||
* it reads is one we own and keep flat, and a dependency here would be a dependency in the
|
||||
* build of every page.
|
||||
*
|
||||
* Note that this resolves the STOCK values. A bind-mounted `theme.css` overrides tokens in
|
||||
* the browser, at runtime, which is the whole point — anything derived through this module
|
||||
* is therefore build-time and will not follow a mounted theme. Keep that list short.
|
||||
*/
|
||||
function readTokens() {
|
||||
const withoutComments = tokensCss.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const out = {};
|
||||
for (const match of withoutComments.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/gi)) {
|
||||
out[match[1]] = match[2].trim();
|
||||
}
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
export const tokens = readTokens();
|
||||
|
||||
/** Throws rather than emitting `undefined` into a template. */
|
||||
export function token(name) {
|
||||
const value = tokens[name];
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
`Unknown design token "${name}". Every token is defined in src/styles/tokens.css; ` +
|
||||
`add it there rather than inlining a value at the call site.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
32
src/pages/404.astro
Normal file
32
src/pages/404.astro
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
|
||||
/**
|
||||
* Ours rather than Starlight's (`disable404Route: true` in astro.config.mjs). A reader who
|
||||
* mistypes a marketing URL should not land in documentation chrome with a sidebar of pages
|
||||
* they were not looking for.
|
||||
*/
|
||||
---
|
||||
|
||||
<Base title="Page not found" description="That page does not exist on this site.">
|
||||
<section class="page notfound">
|
||||
<p class="eyebrow">404</p>
|
||||
<h1>That page does not exist</h1>
|
||||
<p class="prose">
|
||||
The link may be out of date, or the page may not be written yet — this site is being
|
||||
built in phases.
|
||||
</p>
|
||||
<p><a href="/">Back to the homepage</a> · <a href="/docs/">Documentation</a></p>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.notfound {
|
||||
padding-block: clamp(3rem, 9vw, 6rem);
|
||||
}
|
||||
|
||||
.notfound h1 {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--gold);
|
||||
}
|
||||
</style>
|
||||
79
src/pages/index.astro
Normal file
79
src/pages/index.astro
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
/**
|
||||
* Phase 1 is the foundation, not the homepage — phase 3 builds the real one (hero, the
|
||||
* data-path diagram as inline SVG, the grouped capability sections, the reserved demo
|
||||
* slot). This page exists so the shell is provably assembled: layout, header, footer,
|
||||
* tokens, both typefaces, and a fact read from platform.json rather than typed.
|
||||
*
|
||||
* Everything it claims is from §2's verified state. Nothing here is marketing copy yet.
|
||||
*/
|
||||
---
|
||||
|
||||
<Base
|
||||
title={`${brand.siteName} — ${brand.tagline}`}
|
||||
description={brand.tagline}
|
||||
bareTitle
|
||||
>
|
||||
<section class="page hero">
|
||||
<p class="eyebrow">Foundation</p>
|
||||
<h1>{brand.siteName}</h1>
|
||||
<p class="hero__tagline prose">{brand.tagline}</p>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
<span class="chip chip--live">Verified {platform.verifiedOn}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page">
|
||||
<div class="panel prose">
|
||||
<h2>This is the phase 1 scaffold</h2>
|
||||
<p>
|
||||
The layout shell, the token file, the self-hosted typefaces, the documentation
|
||||
theme and the two build-time checks are in place. The homepage itself is phase 3;
|
||||
the marketing pages are phase 4; the documentation — the installation path, which
|
||||
is the priority of the whole project — is phase 7.
|
||||
</p>
|
||||
<p>
|
||||
Every version above was read from <code>src/data/platform.json</code>, and{' '}
|
||||
<code>scripts/checkFacts.mjs</code> re-reads each one from its authority on every
|
||||
build. No version number is written in prose anywhere on this site.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/docs/">Read the documentation</a> ·{' '}
|
||||
<a href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: clamp(3rem, 9vw, 6rem) 2rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.4rem, 7vw, 4rem);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.hero__tagline {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
</style>
|
||||
334
src/styles/global.css
Normal file
334
src/styles/global.css
Normal file
@@ -0,0 +1,334 @@
|
||||
/* ============================================================================
|
||||
runicgateway.com — the layout shell
|
||||
============================================================================
|
||||
Every value here is a token from tokens.css. No colour literal appears below
|
||||
this comment; `scripts/checkTokens.mjs` fails the build if one does.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
@import '@fontsource-variable/cinzel';
|
||||
@import '@fontsource-variable/inter';
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
color: var(--head);
|
||||
line-height: 1.2;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-bright);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--portal-bright);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ---- Page frame --------------------------------------------------------- */
|
||||
|
||||
.page {
|
||||
width: 100%;
|
||||
max-width: var(--page-max);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.site {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.site > main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* The keyboard escape hatch past the header nav. Visible only when focused. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: var(--gutter);
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
transform: translateY(-140%);
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-flat);
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
/* ---- Header ------------------------------------------------------------- */
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
min-height: var(--header-h);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: color-mix(in srgb, var(--bg-deep) 88%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.site-header__inner {
|
||||
display: flex;
|
||||
min-height: var(--header-h);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gutter);
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-lockup__mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.brand-lockup__name {
|
||||
font-family: var(--display);
|
||||
font-size: 1.12rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-nav a:hover {
|
||||
background: var(--panel-flat);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.site-nav a[aria-current='page'] {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* The nav collapses to the docs link alone until phase 3 gives it a real
|
||||
disclosure control; a hamburger with nothing behind it is worse than none. */
|
||||
@media (max-width: 720px) {
|
||||
.site-nav {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
padding-inline: 0.45rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Footer ------------------------------------------------------------- */
|
||||
|
||||
.site-footer {
|
||||
margin-top: 4rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
background: var(--bg-deep);
|
||||
padding-block: 2.5rem 2rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.site-footer__cols {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.site-footer h2 {
|
||||
margin: 0 0 0.7rem;
|
||||
color: var(--muted);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.site-footer ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.site-footer li + li {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.site-footer a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-footer a:hover {
|
||||
color: var(--accent-bright);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.site-footer__legal {
|
||||
margin-top: 2.25rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.site-footer__legal p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- Panels ------------------------------------------------------------- */
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--panel-grad);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--gold);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.prose {
|
||||
max-width: var(--measure);
|
||||
}
|
||||
|
||||
/* ---- Status chip -------------------------------------------------------- */
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--panel-flat);
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip--version {
|
||||
border-color: color-mix(in srgb, var(--gold-deep) 70%, transparent);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.chip--draft {
|
||||
border-color: color-mix(in srgb, var(--mode-maint) 55%, transparent);
|
||||
color: var(--mode-maint);
|
||||
}
|
||||
|
||||
.chip--live {
|
||||
border-color: color-mix(in srgb, var(--mode-live) 55%, transparent);
|
||||
color: var(--mode-live);
|
||||
}
|
||||
158
src/styles/starlight.css
Normal file
158
src/styles/starlight.css
Normal file
@@ -0,0 +1,158 @@
|
||||
/* ============================================================================
|
||||
Starlight theming — the docs half of §11
|
||||
============================================================================
|
||||
Starlight owns `/docs` and brings its own token set (`--sl-color-*`). This
|
||||
file is the bridge: it restates OUR tokens as Starlight's, so the docs and
|
||||
the marketing pages cannot drift, and so a bind-mounted `theme.css` that
|
||||
redefines `--gold` recolours both halves of the site at once.
|
||||
|
||||
Marketing is single-theme dark by design; the docs honour the reader's
|
||||
preference (§11), which is why this file has a light block and global.css
|
||||
does not. Starlight's own light theme is accessible as shipped, so the light
|
||||
block restates only what carries brand — the accents and the surfaces.
|
||||
|
||||
No colour literal appears here. Every value is a var() from tokens.css.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
/* ---- Dark (Starlight's default root) ------------------------------------ */
|
||||
:root {
|
||||
--sl-font: var(--sans);
|
||||
--sl-font-mono: var(--mono);
|
||||
|
||||
--sl-color-accent-low: var(--blue);
|
||||
--sl-color-accent: var(--accent);
|
||||
--sl-color-accent-high: var(--accent-bright);
|
||||
|
||||
--sl-color-white: var(--ink);
|
||||
--sl-color-gray-1: var(--head);
|
||||
--sl-color-gray-2: var(--text);
|
||||
--sl-color-gray-3: var(--muted);
|
||||
--sl-color-gray-4: var(--dim);
|
||||
--sl-color-gray-5: var(--line);
|
||||
--sl-color-gray-6: var(--line-soft);
|
||||
--sl-color-gray-7: var(--panel-flat);
|
||||
--sl-color-black: var(--bg);
|
||||
|
||||
--sl-color-bg: var(--bg);
|
||||
--sl-color-bg-nav: var(--bg-deep);
|
||||
--sl-color-bg-sidebar: var(--bg-deep);
|
||||
--sl-color-bg-inline-code: var(--panel-flat);
|
||||
--sl-color-hairline: var(--line-soft);
|
||||
--sl-color-hairline-light: var(--line);
|
||||
--sl-color-hairline-shade: var(--line-soft);
|
||||
|
||||
--sl-color-text: var(--text);
|
||||
--sl-color-text-accent: var(--accent);
|
||||
--sl-color-text-invert: var(--bg);
|
||||
|
||||
--sl-shadow-md: var(--shadow-card);
|
||||
--sl-shadow-lg: var(--shadow-raised);
|
||||
}
|
||||
|
||||
/* ---- Light -------------------------------------------------------------- */
|
||||
:root[data-theme='light'] {
|
||||
--sl-color-accent-low: var(--light-line);
|
||||
--sl-color-accent: var(--light-accent);
|
||||
--sl-color-accent-high: var(--light-ink);
|
||||
|
||||
--sl-color-white: var(--light-ink);
|
||||
--sl-color-gray-1: var(--light-ink);
|
||||
--sl-color-gray-2: var(--light-text);
|
||||
--sl-color-gray-3: var(--light-muted);
|
||||
--sl-color-gray-4: var(--light-muted);
|
||||
--sl-color-gray-5: var(--light-line);
|
||||
--sl-color-gray-6: var(--light-line);
|
||||
--sl-color-gray-7: var(--light-panel);
|
||||
--sl-color-black: var(--light-panel);
|
||||
|
||||
--sl-color-bg: var(--light-panel);
|
||||
--sl-color-bg-nav: var(--light-bg);
|
||||
--sl-color-bg-sidebar: var(--light-bg);
|
||||
--sl-color-bg-inline-code: var(--light-bg);
|
||||
--sl-color-hairline: var(--light-line);
|
||||
--sl-color-hairline-light: var(--light-line);
|
||||
--sl-color-hairline-shade: var(--light-line);
|
||||
|
||||
--sl-color-text: var(--light-text);
|
||||
--sl-color-text-accent: var(--light-accent);
|
||||
--sl-color-text-invert: var(--light-panel);
|
||||
}
|
||||
|
||||
/* ---- Brand details ------------------------------------------------------
|
||||
The docs are not a different product. The wordmark keeps the display face
|
||||
and the gold, and the site title in the docs header matches the marketing
|
||||
header exactly. */
|
||||
|
||||
.site-title {
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .site-title {
|
||||
color: var(--light-gold);
|
||||
}
|
||||
|
||||
/* Headings carry the display face only at h1, matching global.css — using it
|
||||
further down turns a reference page into a poster. */
|
||||
.sl-markdown-content h1 {
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Semantic hues -------------------------------------------------------
|
||||
Starlight colours asides and badges from five named scales rather than from the accent,
|
||||
so leaving these alone puts a stock indigo note box in the middle of our palette. The
|
||||
first attempt here overrode each `.starlight-aside--*` rule, which set the border and
|
||||
the heading but not the background — that comes from `--sl-color-<hue>-low`, and the box
|
||||
stayed indigo. Mapping the scales themselves fixes the asides and every other component
|
||||
that reaches for a semantic colour.
|
||||
|
||||
The `-low` step is a background wash, so it is mixed from the same token against the
|
||||
ground rather than picked separately: recolour `--portal` and the tip box follows. */
|
||||
|
||||
:root {
|
||||
--sl-color-blue-low: color-mix(in srgb, var(--accent) 16%, var(--bg));
|
||||
--sl-color-blue: var(--accent);
|
||||
--sl-color-blue-high: var(--accent-bright);
|
||||
|
||||
/* Starlight's "tip" is purple; ours is the portal. */
|
||||
--sl-color-purple-low: color-mix(in srgb, var(--portal) 14%, var(--bg));
|
||||
--sl-color-purple: var(--portal-deep);
|
||||
--sl-color-purple-high: var(--portal-bright);
|
||||
|
||||
--sl-color-orange-low: color-mix(in srgb, var(--mode-maint) 14%, var(--bg));
|
||||
--sl-color-orange: var(--mode-maint);
|
||||
--sl-color-orange-high: var(--mode-maint);
|
||||
|
||||
--sl-color-red-low: color-mix(in srgb, var(--danger) 14%, var(--bg));
|
||||
--sl-color-red: var(--danger);
|
||||
--sl-color-red-high: var(--danger);
|
||||
|
||||
--sl-color-green-low: color-mix(in srgb, var(--mode-live) 14%, var(--bg));
|
||||
--sl-color-green: var(--mode-live);
|
||||
--sl-color-green-high: var(--mode-live);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--sl-color-blue-low: color-mix(in srgb, var(--light-accent) 12%, var(--light-panel));
|
||||
--sl-color-blue: var(--light-accent);
|
||||
--sl-color-blue-high: var(--light-accent);
|
||||
|
||||
--sl-color-purple-low: color-mix(in srgb, var(--light-portal) 12%, var(--light-panel));
|
||||
--sl-color-purple: var(--light-portal);
|
||||
--sl-color-purple-high: var(--light-portal);
|
||||
|
||||
--sl-color-orange-low: color-mix(in srgb, var(--light-gold) 12%, var(--light-panel));
|
||||
--sl-color-orange: var(--light-gold);
|
||||
--sl-color-orange-high: var(--light-gold);
|
||||
|
||||
--sl-color-red-low: color-mix(in srgb, var(--danger) 12%, var(--light-panel));
|
||||
--sl-color-red: var(--danger);
|
||||
--sl-color-red-high: var(--danger);
|
||||
|
||||
--sl-color-green-low: color-mix(in srgb, var(--mode-live) 12%, var(--light-panel));
|
||||
--sl-color-green: var(--mode-live);
|
||||
--sl-color-green-high: var(--mode-live);
|
||||
}
|
||||
129
src/styles/tokens.css
Normal file
129
src/styles/tokens.css
Normal file
@@ -0,0 +1,129 @@
|
||||
/* ============================================================================
|
||||
runicgateway.com — design tokens
|
||||
============================================================================
|
||||
|
||||
THIS IS THE ONLY FILE IN THE SOURCE TREE ALLOWED TO CONTAIN A COLOUR LITERAL.
|
||||
|
||||
PLAN.md §7 promises that recolouring the site is a file copy and a container
|
||||
restart — never a rebuild. That promise holds only if every colour, radius,
|
||||
shadow and font in the stylesheet is a custom property defined here, so that
|
||||
the bind-mounted `theme.css` can redefine them and win.
|
||||
|
||||
`scripts/checkTokens.mjs` enforces it. Without the check, "one CSS file
|
||||
changes the appearance" decays into "one CSS file changes most of the
|
||||
appearance, and then there is a hardcoded #0e1318 in the footer".
|
||||
|
||||
Names deliberately match `website/client/src/styles/theme.css` where the
|
||||
concepts line up, so a theme written for a Runic Gateway deployment is
|
||||
legible here and vice versa (§7, §11).
|
||||
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
/* ---- Ground and panels -------------------------------------------------
|
||||
Taken unchanged from the product's token file. Same bytes, same names. */
|
||||
--bg: #0e1318;
|
||||
--bg-deep: #0b0f14;
|
||||
--panel-a: #192231;
|
||||
--panel-b: #141a21;
|
||||
--panel-flat: #11161d;
|
||||
--line: #2a3544;
|
||||
--line-soft: #1d2733;
|
||||
|
||||
/* ---- Interface and type ------------------------------------------------
|
||||
Also the product's, unchanged. `--accent` is the steel blue that carries
|
||||
links and interface emphasis across both sites. */
|
||||
--accent: #7f99bd;
|
||||
--accent-bright: #cdd9e8;
|
||||
--ink: #eef3f8;
|
||||
--head: #e6edf6;
|
||||
--text: #c4cdd8;
|
||||
--muted: #aeb8c4;
|
||||
--dim: #6f7d8e;
|
||||
--blue: #13243c;
|
||||
|
||||
/* ---- Status ------------------------------------------------------------
|
||||
Reused verbatim from the product so a status pill means the same thing on
|
||||
both sites (§11). */
|
||||
--mode-live: #5fb98a;
|
||||
--mode-maint: #e6c26a;
|
||||
|
||||
/* ---- The emblem's own palette ------------------------------------------
|
||||
§11: gold and cyan as the accent pair, "derived from the artwork by
|
||||
sampling, not guessed, and both held to WCAG AA against the ground".
|
||||
|
||||
Sampled from `runic-emblem.png` (1024x1024, 494,059 opaque pixels) by
|
||||
binning every saturated pixel by hue and taking the mean of each bin. The
|
||||
contrast ratio after each value is measured against `--bg` (#0e1318).
|
||||
AA wants 4.5:1 for body text and 3:1 for large text and UI boundaries, so
|
||||
the annotation is also the usage rule. Nothing here was nudged for taste;
|
||||
where a sampled value fails a ratio it is restricted, not brightened. */
|
||||
|
||||
/* Hue 25-45deg — the ring. 65% of the emblem's saturated pixels. */
|
||||
--gold-deep: #946b3c; /* 3.94:1 — rules, borders, UI edges. NEVER text. */
|
||||
--gold: #c8a368; /* 7.91:1 — emphasis text, headings, the mark. */
|
||||
--gold-bright: #e4cb90; /* 11.77:1 — highlights on gold surfaces. */
|
||||
|
||||
/* Hue 180-210deg — the portal and its glow. */
|
||||
--portal-deep: #0b6398; /* 2.89:1 — glow fills and gradients only. */
|
||||
--portal: #15b4de; /* 7.66:1 — the live-state signal, diagram lines. */
|
||||
--portal-bright: #1bd6f1; /* 10.61:1 — the portal core, focus rings. */
|
||||
|
||||
/* Hue 0deg — the ruby set into the ring. The only red in the artwork, so it
|
||||
is the honest source for a destructive/error colour. */
|
||||
--danger: #ff4e43; /* 5.71:1 */
|
||||
|
||||
/* ---- Type --------------------------------------------------------------
|
||||
Both self-hosted (§11), so §6's `default-src 'self'` needs no exception.
|
||||
Cinzel is the project's display face and is already the Android app's;
|
||||
it is confined to the wordmark and hero. Inter carries everything else. */
|
||||
--display: 'Cinzel Variable', Georgia, 'Times New Roman', serif;
|
||||
--sans: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
|
||||
|
||||
/* ---- Radius ------------------------------------------------------------
|
||||
Named by the kind of surface rather than the pixel value, matching the
|
||||
product's promotion of the same four tokens. */
|
||||
--radius-pill: 999px;
|
||||
--radius-panel: 12px;
|
||||
--radius-card: 10px;
|
||||
--radius-input: 8px;
|
||||
|
||||
/* ---- Elevation and surface treatments ---------------------------------- */
|
||||
--shadow-card: 0 14px 34px rgb(0 0 0 / 30%);
|
||||
--shadow-raised: 0 22px 48px rgb(0 0 0 / 38%);
|
||||
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
||||
--glow-portal: 0 0 32px rgb(21 180 222 / 22%);
|
||||
|
||||
/* ---- Layout ------------------------------------------------------------
|
||||
Here rather than in global.css so a theme can widen the measure without
|
||||
touching the stylesheet. */
|
||||
--measure: 68ch;
|
||||
--page-max: 1180px;
|
||||
--gutter: 24px;
|
||||
--header-h: 68px;
|
||||
}
|
||||
|
||||
/* ---- Light mode, docs only ----------------------------------------------
|
||||
§11: marketing pages are single-theme by design; the docs honour the
|
||||
reader's light/dark preference. Starlight ships an accessible light theme,
|
||||
so this is not a second palette — it is the four brand colours restated at
|
||||
the lightness a white ground needs, plus the surfaces Starlight tints.
|
||||
|
||||
Same hues as the dark set, darkened rather than re-picked, with the
|
||||
contrast against `--light-bg` measured the same way. They live here, in the
|
||||
token file, because that is the rule: a literal anywhere else fails
|
||||
`checkTokens.mjs`, and a bind-mounted `theme.css` must be able to reach
|
||||
these too. */
|
||||
:root {
|
||||
--light-bg: #f6f8fb;
|
||||
--light-panel: #ffffff;
|
||||
--light-line: #d6dee9;
|
||||
--light-ink: #16202c;
|
||||
--light-text: #33414f;
|
||||
--light-muted: #5a6875;
|
||||
|
||||
--light-accent: #3c5f8f; /* 6.12:1 — the steel blue, darkened for links */
|
||||
--light-gold: #7a5a24; /* 5.95:1 — the ring, darkened for emphasis */
|
||||
--light-portal: #0a5f80; /* 6.67:1 — the portal, darkened for diagrams */
|
||||
}
|
||||
Reference in New Issue
Block a user