feat(home): phase 3 — the homepage
All checks were successful
PR checks / checks (pull_request) Successful in 49s

Replaces phase 1's scaffold with the real homepage: hero, the data path as
inline SVG, the self-hosted argument, all five capability groups, and the
get-started CTA. Three decisions the org lead took first are recorded in
PLAN.md as D17-D19.

The data path is drawn generically and captioned specifically (D17): the nodes
say "your game server" and "sidecar", the sub-labels and caption name ServUO and
uo-link. The SVG is aria-hidden because the four numbered steps beside it carry
the same path in prose — one telling, not two.

The capability list is data with a check behind it (D18). Every Game-intelligence
item names the module-uo capability slug it comes from, and the build fails if
the page and platform.json disagree either way. That needed a fifteenth fact in
checkFacts.mjs: §12 named the capability list as an externally-sourced fact and
nothing re-read it, so the chain rested on someone remembering. It also found
that the site was omitting two of the module's eight capabilities — guilds and
city governors are now listed, in the page and in §10.

The hero leads with the emblem (D19), derived from whichever logo.png is in
force so one file still changes the hero, header, tab icon and app icon
together.

Also here, both found by standing the build up rather than by review:

  - checkBrand.mjs now enforces the demo slot's markup contract. applyBrand.mjs
    reveals the demo link by replacing an exact pair of empty attributes; an
    attribute inserted between them produces a build where the mount sets a demo
    URL, the boot log says nothing and the link never appears. Both halves are
    checked and the literal is derived from the expression applyBrand.mjs uses,
    so they cannot drift.

  - The header nav overflowed at 390px — four links plus the lockup measured
    433px against a 390px viewport, so every phone got a horizontally scrolling
    page. Phase 1 left this to phase 3 expecting a disclosure control; it got a
    wrap instead, because with four links there is nothing to disclose and a
    hamburger costs state, script and duplicate markup.

Verified on a clean checkout of this commit: all four checks, astro check, a
production build, a live /brand/* smoke, and a demo URL mounted and reverted.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-20 00:03:04 -05:00
parent bb06f1de44
commit 556dee7355
12 changed files with 1333 additions and 78 deletions

48
PLAN.md
View File

@@ -519,14 +519,57 @@ Organised by what a reader is trying to do. A reader should never need to know t
**Feature grouping**, using project terminology:
- **Community** — Teams, Team forums, notifications, wiki, news and newsletter, player self-service
- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, player-vendor
marketplace, houses and IDOCs, character sheets, spawn atlas, champion boards, points leaderboards
- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, character
sheets, points and loyalty boards, player-vendor marketplace, houses and IDOCs, spawn atlas,
champion boards, guilds, city governors
- **Administration** — roles, moderation and appeals, content reports, audit log, bot scoring and IP
bans, module management, the shard connection
- **Integration** — modules, the sidecar bridge, Discord (slash commands, notifications, voice),
mobile and push, SSO
- **Infrastructure** — self-hosted, Docker, prebuilt pull-only images, branding as data, OpenAPI
Guilds and city governors were added to Game intelligence in phase 3: `module-uo` declares them as
capabilities and the site was omitting two of the eight. That correction is now mechanical rather
than editorial — see D18.
### How phase 3 built the homepage
Three decisions taken before the page was written (org lead, 2026-08-20).
**D17 — the data path is drawn generically, and captioned specifically.** The diagram's nodes read
"your game server", "sidecar", "Runic Gateway", "browser and app", because a reader should not have
to know this org's repository layout to understand the picture, and because the tagline promises a
platform. It does not hide what ships: the sub-labels and the caption name ServUO and uo-link
outright, since there is exactly one implementation of the shape today and §1 says the technical
truth wins. Rejected: naming the real components in the nodes (reads as a UO product), and omitting
UO entirely (advertises a generality one module proves).
**D18 — all five groups on the homepage, named only.** Not three with a link out: Integration and
Infrastructure carry the module and self-hosted arguments, which are the differentiators, and hiding
them until phase 4 would have made the front page look smaller than the product. The per-capability
argument stays `/features/`'s job so there is one copy of it.
The list is **data with a check behind it** (`src/data/capabilities.mjs`). Every Game-intelligence
item names the `module-uo` capability slug it comes from, and the build fails if the page and
`platform.json` disagree in either direction. Closing that loop needed a fifteenth fact in
`checkFacts.mjs`: §12 listed the capability list as an externally-sourced fact and nothing re-read
it, so the whole chain rested on someone remembering. Manifest → `platform.json` → page is now
checked end to end.
**D19 — the hero leads with the emblem.** Chosen over a type-only hero: the mark is already the
site logo, the Android launcher icon and the Play listing, and showing it large is what makes the
three read as one product (D11). It costs what D16 already accepted — raster art a mounted
`theme.css` cannot recolour — but every size is derived from whichever `logo.png` is in force
(D14), so the hero, the header, the tab icon and the installed icon still change together from one
file.
**A convention, not a decision:** the homepage links the final routes — `/features/`,
`/modules/`, `/integrations/` — which phases 4 to 6 have not written yet. The header and footer
already did this from phase 1. Nothing is deployed until phase 12, so no visitor meets a 404, and
nothing has to be rewritten later. Links *into the documentation* are the exception: they point at
`/docs/`, because phases 7 and 8 own those slugs and a guessed one would be a stale URL nothing
checks.
### Documentation
```
@@ -636,6 +679,7 @@ a mechanism rather than diligence:
| Protocol version | `link` `main:sidecar/src/main.rs``PROTOCOL_VERSION` |
| Overlay protocol | `servuo-plugins` `main:overlay.toml``protocol` |
| Module API | `website` `main:server/src/modules/version.js``MODULE_API_VERSION` |
| Module capability list | `Module-uo` `main:module.json``capabilities` (added in phase 3) |
| Bundle + component pins | `installer` branch `bundles`, **root** `current.json` |
| Release versions | Gitea releases API per repo |

View File

@@ -216,6 +216,80 @@ if (brand) {
}
}
/* =======================================================================================
4. The demo slot's markup contract (§15 / D12)
=======================================================================================
`applyBrand.mjs` reveals the demo link by string-replacing an exact pair of empty
attributes in the built HTML. That is a contract between a script and a template that
share no code, and it fails in the quietest possible way: an attribute inserted between
the two, or `href` written after `data-demo-url`, produces a build where the demo URL is
set in the mount, the boot log says nothing, and the link is simply never there.
Both halves are checked, and neither is retyped from memory — the literal is derived from
the same expression `applyBrand.mjs` uses, so the two cannot drift apart. */
const applyForCheck = existsSync(path.join(ROOT, 'scripts/applyBrand.mjs'))
? readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8')
: '';
const attrTemplate = applyForCheck.match(
/`href="\$\{escapeHtml\(value\)\}" data-demo-url="\$\{escapeHtml\(value\)\}"`/
);
if (!attrTemplate) {
fail(
'applyBrand.mjs no longer builds the demo attributes as `href="..." data-demo-url="..."`.\n' +
' Update the expected pair below to match, and re-check every template that writes it.'
);
} else {
// What the script will look for when the applied value is the stock empty string.
const EMPTY_PAIR = 'href="" data-demo-url=""';
let slots = 0;
const strays = [];
for await (const file of walk(path.join(ROOT, 'src'))) {
if (path.extname(file) !== '.astro') continue;
// Comments discuss the contract at length, including in the template that implements
// it. Scanning them would make the check fail on its own documentation.
// Blanked rather than removed: keeping every newline and every offset means the line
// numbers reported below are the ones in the file, not the ones in a shortened copy.
const blank = (match) => match.replace(/[^\n]/g, ' ');
const source = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, blank)
.replace(/<!--[\s\S]*?-->/g, blank);
const relative = path.relative(ROOT, file);
slots += source.split(EMPTY_PAIR).length - 1;
for (const match of source.matchAll(/data-demo-url/g)) {
const start = match.index - EMPTY_PAIR.indexOf('data-demo-url');
if (source.slice(start, start + EMPTY_PAIR.length) !== EMPTY_PAIR) {
strays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
}
}
}
if (!slots) {
fail(
`no demo slot found in src/**/*.astro — expected the literal \`${EMPTY_PAIR}\`.\n` +
' §15 reserves this slot so that gaining a demo instance is one line in the mounted\n' +
' brand.json. Removing it makes that a rebuild.'
);
}
for (const site of strays) {
fail(
`${site} writes data-demo-url outside the exact pair \`${EMPTY_PAIR}\`.\n` +
' applyBrand.mjs replaces that literal at boot; anything else is invisible to it and\n' +
' the slot will never appear.'
);
}
}
/* ======================================================================================= */
if (failures.length) {
@@ -227,5 +301,5 @@ if (failures.length) {
console.log(
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
`and every rewritable string is safe to replace.`
`every rewritable string is safe to replace, and the demo slot matches its contract.`
);

View File

@@ -117,7 +117,29 @@ async function checkModuleApi() {
}
// ---------------------------------------------------------------------------
// 4. The current bundle
// 4. The capabilities the installed module actually declares
//
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the
// homepage renders the list rather than merely storing it: `src/data/capabilities.mjs`
// asserts at build time that every declared slug is claimed by a named capability on the
// page and vice versa. Without this check that assertion was anchored to a local copy
// nobody was verifying, so the whole chain rested on someone remembering.
//
// Sorted before comparing: the manifest's order is the module's business, and a reordered
// array is not a changed capability set. A slug appearing or disappearing is.
// ---------------------------------------------------------------------------
async function checkModuleCapabilities() {
const authority = 'Module-uo main:module.json';
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main'));
const declared = [...(manifest.capabilities || [])].sort();
const expected = [...platform.moduleUoCapabilities].sort();
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
}
// ---------------------------------------------------------------------------
// 5. 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.
@@ -141,7 +163,7 @@ async function checkBundle() {
}
// ---------------------------------------------------------------------------
// 5. Release versions, per repo
// 6. Release versions, per repo
// ---------------------------------------------------------------------------
async function checkReleases() {
for (const [repo, expected] of Object.entries(platform.releases)) {
@@ -152,7 +174,7 @@ async function checkReleases() {
}
// ---------------------------------------------------------------------------
// 6. `website` still publishes nothing
// 7. `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
@@ -165,7 +187,7 @@ async function checkWebsiteHasNoReleases() {
}
// ---------------------------------------------------------------------------
// 7. D13 — the contact address lives in exactly one file
// 8. D13 — the contact address lives in exactly one file
// ---------------------------------------------------------------------------
const CONTACT_CHECK = 'contact address (D13)';
@@ -244,6 +266,7 @@ async function main() {
checkProtocol,
checkOverlayProtocol,
checkModuleApi,
checkModuleCapabilities,
checkBundle,
checkReleases,
checkWebsiteHasNoReleases,

View File

@@ -0,0 +1,167 @@
---
import platform from '../../data/platform.json';
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs';
/**
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
* each one is `/features/`'s job in phase 4, and repeating it here would create a second
* copy to keep true.
*
* The call below is the point of the exercise: it throws, and therefore fails the build, if
* the "Game intelligence" list and the module's own declared capabilities have drifted
* apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
* this makes the page honest against `platform.json`, which is the half that was missing.
*
* The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features
* list is described there as "as load-bearing as the rest", and a homepage that lists only
* what exists while quietly omitting the well-known things that do not is the exact failure
* §1 is written to prevent.
*/
assertCapabilityCoverage(platform.moduleUoCapabilities);
---
<section class="page section caps">
<p class="eyebrow">What it does</p>
<h2>A community site, and a window into the game</h2>
<p class="prose caps__lede">
The core is game-agnostic: it does not know what a shard is. Everything that does arrives
as an installable <a href="/modules/">module</a>, which is why the same platform can carry
a different game without a fork.
</p>
<div class="caps__grid">
{
capabilityGroups.map((group) => (
<section class:list={['panel', 'caps__group', group.items.length > 8 && 'caps__group--wide']}>
<header class="caps__group-head">
<h3>{group.title}</h3>
{group.moduleSupplied && <span class="chip">Module-supplied</span>}
</header>
<p class="caps__summary">{group.summary}</p>
<ul class="caps__items">
{group.items.map((item) => (
<li>{item.label}</li>
))}
</ul>
</section>
))
}
</div>
<p class="caps__foot prose">
Some things people reasonably expect are <strong>deliberately not built</strong> — a Matrix
integration, more than one game module active at once, a second game module. They are
listed rather than left out, on <a href="/features/">features</a> and{' '}
<a href="/integrations/">integrations</a>.
</p>
</section>
<style>
.caps h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.caps__lede {
margin: 0;
color: var(--muted);
}
.caps__grid {
display: grid;
gap: 1rem;
margin-top: 2.25rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
.caps__group {
display: flex;
flex-direction: column;
}
.caps__group-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
.caps__group h3 {
margin: 0;
color: var(--gold);
font-size: 1.06rem;
}
.caps__summary {
margin: 0.6rem 0 1rem;
color: var(--dim);
font-size: 0.88rem;
}
.caps__items {
margin: 0;
padding: 0;
list-style: none;
font-size: 0.94rem;
}
.caps__items li {
position: relative;
padding-left: 1.1rem;
color: var(--text);
}
.caps__items li + li {
margin-top: 0.3rem;
}
/* A drawn marker rather than a list bullet: it takes the portal colour, so it
tracks a mounted theme the way a `list-style` glyph would not. */
.caps__items li::before {
content: '';
position: absolute;
left: 0;
top: 0.62em;
width: 5px;
height: 5px;
border-radius: var(--radius-pill);
background: var(--portal);
opacity: 0.75;
}
/* Five groups in a three-column grid leaves a hole, and the one group that is
twice the length of the others is the obvious thing to put in it. Game
intelligence takes both remaining slots on the top row and sets its items
in two columns, which fills the row and gives the module-supplied group the
prominence it has earned by being the only one that is module-supplied.
The width is read from the content — a group long enough to need it gets it
— rather than named, so a future group of that size lands the same way.
Guarded by a width query because `span 2` in a grid that is only one column
wide is an overflow, not a layout. */
@media (min-width: 62rem) {
.caps__group--wide {
grid-column: span 2;
}
.caps__group--wide .caps__items {
columns: 2;
column-gap: 1.75rem;
}
/* `columns` would otherwise break an item across the column boundary, and a
capability split over two columns reads as two capabilities. */
.caps__group--wide .caps__items li {
break-inside: avoid;
}
}
.caps__foot {
margin: 2rem 0 0;
color: var(--dim);
font-size: 0.94rem;
}
</style>

View File

@@ -0,0 +1,302 @@
---
import platform from '../../data/platform.json';
/**
* The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn
* geometry, used where it explains something, and no raster anywhere.
*
* ---------------------------------------------------------------------------------------
* THE LABELS ARE GENERIC, WITH UO AS THE CAPTION
* ---------------------------------------------------------------------------------------
* The org lead settled this before the diagram was drawn. The nodes say "your game server"
* and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader
* should never need to know that `link`, `servuo-plugins` and `installer` are three
* repositories in order to connect a game server — and because the tagline promises a
* platform, not a UO product.
*
* It does NOT hide what actually ships. The sub-labels and the caption name ServUO and
* uo-link outright, because §1 says the technical truth wins and today there is exactly one
* implementation of this shape. An operator running a shard has to see themselves in the
* picture on the first screen.
*
* ---------------------------------------------------------------------------------------
* WHY THE SVG IS aria-hidden
* ---------------------------------------------------------------------------------------
* Not because it is decorative — it is the opposite — but because the steps beside it carry
* the same four stages in full prose, at real font sizes, in reading order. A `role="img"`
* with a `<desc>` would make a screen reader read the same path twice, and the second
* telling would be the worse one. The picture is for people who can see it; the list is the
* canonical version and everyone gets it.
*
* That also means the diagram must never gain a fact the list does not have.
*
* The concentric rings behind the nodes are the emblem's own geometry, centred on the
* boundary line — the one place in the picture where the argument actually happens.
*/
---
<section class="page section datapath">
<div class="datapath__head">
<p class="eyebrow">How it works</p>
<h2>One path, one direction</h2>
<p class="prose">
Everything the website knows about your game arrives the same way. There is no second
route in, and nothing on the internet can reach the game to ask.
</p>
</div>
<div class="datapath__body">
<div class="datapath__figure">
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
<!-- The emblem's concentric rings, centred on the boundary. Drawn first so the
panels sit over them. -->
<g class="rings">
<circle cx="190" cy="252" r="112" />
<circle cx="190" cy="252" r="158" />
<circle cx="190" cy="252" r="204" />
</g>
<!-- Loopback hop: same host, no network involved. -->
<path class="spine" d="M190 92 V140" />
<path class="arrow" d="M190 148 l-6 -10 h12 Z" />
<!-- The network hop, and the only one. Drawn in the portal colour because this is
the live feed, and the live signal is cyan everywhere on the site. -->
<path class="spine spine--live" d="M190 224 V272" />
<path class="arrow arrow--live" d="M190 280 l-6 -10 h12 Z" />
<path class="spine" d="M190 356 V404" />
<path class="arrow" d="M190 412 l-6 -10 h12 Z" />
<!-- The boundary the whole design exists to draw. -->
<path class="boundary" d="M8 252 H372" />
<text class="boundary-label" x="372" y="245" text-anchor="end">the network</text>
<rect class="node" x="20" y="16" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="50">Your game server</text>
<text class="node-sub" x="42" y="72">ServUO today · opens no inbound port</text>
<rect class="node" x="20" y="148" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="182">Sidecar</text>
<text class="node-sub" x="42" y="204">uo-link · the only network-facing part</text>
<rect class="node node--self" x="20" y="280" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="314">Runic Gateway</text>
<text class="node-sub" x="42" y="336">your public website</text>
<rect class="node" x="20" y="412" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="446">Browser and app</text>
<text class="node-sub" x="42" y="468">anyone you choose to let in</text>
</svg>
<p class="datapath__caption">
Today that game server is a ServUO shard and that sidecar is uo-link. The shape is the
contract; the implementations are what plug into it.
</p>
</div>
<ol class="datapath__steps">
<li>
<h3>Your game server</h3>
<p>
A plugin inside the server dials <strong>out</strong> to the sidecar over loopback.
The game never listens for anything, so there is nothing on it to find. Events go
onto a bounded queue and the game moves on — a sidecar that is wedged or missing
cannot slow the world down.
</p>
</li>
<li>
<h3>The sidecar</h3>
<p>
A small service beside the game, and the only piece of the bridge anything else can
reach. It speaks a versioned wire protocol — protocol {platform.protocol} today — so
a mismatched pair is refused rather than misread, and it answers only your website's
backend, over an authenticated WebSocket and REST.
</p>
</li>
<li>
<h3>Runic Gateway</h3>
<p>
Your site ingests the live feed and fans it back out on two streams: a public one
carrying an allowlist of safe events, and a staff-only one carrying the rest. That
split is a security boundary, not a preference. When the game is down the site stays
up and shows it as offline.
</p>
</li>
<li>
<h3>Browser and app</h3>
<p>
The web client reads same-origin JSON and server-sent events. The Android app talks
to the same documented API with bearer tokens. Neither has any idea where the game
server is, because neither is ever told.
</p>
</li>
</ol>
</div>
</section>
<style>
.datapath__head h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.datapath__head .prose {
margin: 0;
color: var(--muted);
}
.datapath__body {
display: grid;
gap: clamp(1.75rem, 4vw, 3rem);
margin-top: 2.5rem;
grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
align-items: start;
}
.datapath__figure {
position: sticky;
top: calc(var(--header-h) + 1.5rem);
}
.flow {
display: block;
width: 100%;
max-width: 380px;
}
.datapath__caption {
margin: 1rem 0 0;
max-width: 380px;
color: var(--dim);
font-size: 0.85rem;
}
/* ---- The drawing ------------------------------------------------------
SVG presentation attributes cannot take a var(), so every colour here is
set as a CSS property on a class instead. That is also what keeps
checkTokens.mjs satisfied: no literal reaches the markup. */
.node {
fill: var(--panel-b);
stroke: var(--line);
stroke-width: 1;
}
.node--self {
fill: var(--panel-a);
stroke: var(--gold-deep);
}
.node-title {
fill: var(--head);
font-family: var(--sans);
font-size: 17px;
font-weight: 600;
}
.node-sub {
fill: var(--dim);
font-family: var(--sans);
font-size: 12.5px;
}
.spine {
fill: none;
stroke: var(--gold-deep);
stroke-width: 2;
}
.spine--live {
stroke: var(--portal);
filter: drop-shadow(0 0 6px var(--portal-deep));
}
.arrow {
fill: var(--gold-deep);
stroke: none;
}
.arrow--live {
fill: var(--portal);
}
.boundary {
fill: none;
stroke: var(--line);
stroke-width: 1;
stroke-dasharray: 4 5;
}
.boundary-label {
fill: var(--dim);
font-family: var(--sans);
font-size: 11px;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.rings {
fill: none;
stroke: var(--gold-deep);
stroke-width: 1;
opacity: 0.16;
}
/* ---- The list ---------------------------------------------------------- */
.datapath__steps {
margin: 0;
padding: 0;
list-style: none;
counter-reset: step;
}
.datapath__steps li {
position: relative;
padding-left: 3.25rem;
counter-increment: step;
}
.datapath__steps li + li {
margin-top: 1.75rem;
}
.datapath__steps li::before {
content: counter(step);
position: absolute;
left: 0;
top: 0;
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid var(--gold-deep);
border-radius: var(--radius-pill);
color: var(--gold);
font-family: var(--display);
font-size: 1rem;
}
.datapath__steps h3 {
margin: 0.3rem 0 0.4rem;
font-size: 1.08rem;
}
.datapath__steps p {
margin: 0;
max-width: var(--measure);
color: var(--muted);
}
@media (max-width: 900px) {
.datapath__body {
grid-template-columns: minmax(0, 1fr);
}
/* Sticky is a wide-screen affordance: the figure should scroll away with
everything else once it is above the list rather than beside it. */
.datapath__figure {
position: static;
justify-self: center;
}
}
</style>

View File

@@ -0,0 +1,117 @@
---
import { brand } from '../../lib/brand.mjs';
/**
* The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's
* "installation path": a "Runic Gateway install" is two independent installs. The installer
* binary sets up the shard side only and never contacts the website; the website is a
* separate Docker deployment.
*
* That belongs on the homepage rather than being saved for the docs. It is the single
* misunderstanding most likely to make an evaluator think the software is broken, it costs
* two sentences to prevent, and §13 calls the installation path the priority of the whole
* project. Saying it here is what makes the docs a confirmation rather than a surprise.
*
* The two halves are ordered site-first because that is the order they must be done in: the
* shard side ends by pasting four values into the site's admin panel, which has to exist.
*
* Both "read the docs" links point at `/docs/` rather than at a page inside the journey.
* Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in
* this file that nothing checks and that a later phase would have to remember to fix.
*/
---
<section class="page section start">
<div class="panel start__panel">
<p class="eyebrow">Getting started</p>
<h2>An install is two installs</h2>
<p class="start__lede prose">
This trips up almost everyone once. The website and the game-side bridge are separate
deployments on separate machines, and neither one installs the other. Doing them in
order takes an evening.
</p>
<div class="start__halves">
<div class="start__half">
<h3><span class="start__num">1</span> The site</h3>
<p>
A Docker Compose deployment on whatever host serves your community — a small VPS is
plenty. Pull the images, bring it up, create the first admin, then install a game
module from the admin panel.
</p>
</div>
<div class="start__half">
<h3><span class="start__num">2</span> The game side</h3>
<p>
One binary, run on the machine the game server already lives on. It syncs the plugin,
installs the sidecar as a service, and prints four values. You paste those into
Admin → Shard, and the two halves find each other.
</p>
</div>
</div>
<div class="start__actions">
<a class="btn btn--primary" href="/docs/">Read the install guide</a>
<a class="btn btn--ghost" href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
<a class="btn btn--ghost" href={brand.discordInvite} rel="noopener noreferrer">Ask on Discord</a>
</div>
</div>
</section>
<style>
.start__panel {
padding: clamp(1.5rem, 4vw, 2.75rem);
}
.start h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.5rem, 3vw, 2rem);
}
.start__lede {
margin: 0;
color: var(--muted);
}
.start__halves {
display: grid;
gap: 1.5rem;
margin-top: 2rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
}
.start__half h3 {
display: flex;
align-items: center;
gap: 0.65rem;
margin: 0 0 0.5rem;
font-size: 1.05rem;
}
.start__num {
display: grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
flex: none;
border: 1px solid var(--gold-deep);
border-radius: var(--radius-pill);
color: var(--gold);
font-family: var(--display);
font-size: 0.92rem;
}
.start__half p {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
}
.start__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 2.25rem;
}
</style>

View File

@@ -0,0 +1,183 @@
---
import { brand } from '../../lib/brand.mjs';
import platform from '../../data/platform.json';
/**
* The hero (PLAN.md §13 phase 3).
*
* The org lead chose an emblem hero over a type-only one: the mark carries recognition
* across the site, the Android launcher icon and the Play listing, and showing it large is
* what makes those three read as one product (D11, §11).
*
* It costs what D16 already accepted — the emblem is raster illustration, so a mounted
* `theme.css` recolours everything around it and not the mark itself. Replacing the mark
* means replacing `logo.png`, and because every size here is derived on request from
* whichever `logo.png` is in force (D14), that one file changes the hero, the header, the
* tab icon and the installed app icon together.
*
* The glow behind it is drawn in CSS from the portal tokens, so it DOES follow a mounted
* theme. That is deliberate: the part that can track the operator's palette does.
*
* The <h1> is the tagline rather than the product name. The name is in the header, in the
* page title and in the footer; a visitor who has just arrived needs the sentence more than
* the noun. Both strings are brand fields, rewritten at boot by `applyBrand.mjs` (D15).
*/
---
<section class="hero">
<div class="page hero__inner">
<div class="hero__copy">
<p class="eyebrow">Self-hosted community platform</p>
<h1>{brand.tagline}</h1>
<p class="hero__lede">
{brand.siteName} is a community website for a game server — accounts, teams, forums, a
wiki, news and a full admin panel — with a one-way bridge that puts the server's live
world on the public site. The game itself never listens on the internet.
</p>
<div class="hero__actions">
<a class="btn btn--primary" href="/docs/">Install it</a>
<a class="btn btn--ghost" href="/features/">See what it does</a>
{/*
The demo slot (§15 / D12). `global.css` hides `[data-demo-url='']`, so a stock
build renders nothing here; `applyBrand.mjs` fills both attributes at boot when a
mounted `brand.json` sets `demoUrl`, and the link appears.
The attribute pair is a literal contract with that script — `href` immediately
followed by `data-demo-url`, both empty, in this order. Astro preserves attribute
order, so what is written here is what ends up in the HTML it searches for. Do not
insert an attribute between them.
*/}
<a class="btn demo-cta" href="" data-demo-url="">See it running</a>
</div>
<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>
</div>
</div>
<div class="hero__mark">
{/*
`alt=""` because the emblem is the product's mark sitting beside the product's own
sentence — announcing it would add nothing a reader of the <h1> does not have.
Sizes are on `brandAssets.mjs`'s allowlist; `checkBrand.mjs` puts every URL below
through the route's own classifier, so a plausible-but-underivable size fails the
build rather than 404ing in production.
*/}
<picture>
<source
type="image/avif"
srcset="/brand/logo-256.avif 256w, /brand/logo-384.avif 384w, /brand/logo-512.avif 512w"
sizes="(max-width: 900px) 176px, 320px"
/>
<img
src="/brand/logo-384.webp"
srcset="/brand/logo-256.webp 256w, /brand/logo-384.webp 384w, /brand/logo-512.webp 512w"
sizes="(max-width: 900px) 176px, 320px"
width="384"
height="384"
alt=""
fetchpriority="high"
decoding="async"
/>
</picture>
</div>
</div>
</section>
<style>
.hero {
position: relative;
overflow: hidden;
padding-block: clamp(2.5rem, 7vw, 5rem) clamp(2rem, 5vw, 3.5rem);
}
.hero__inner {
display: grid;
align-items: center;
gap: clamp(1.5rem, 5vw, 3.5rem);
grid-template-columns: minmax(0, 1fr) auto;
}
.hero__copy {
max-width: 40rem;
}
.hero h1 {
margin: 0;
color: var(--gold);
font-size: clamp(2.1rem, 5.2vw, 3.35rem);
}
.hero__lede {
margin: 1.15rem 0 0;
max-width: var(--measure);
color: var(--muted);
font-size: clamp(1rem, 1.6vw, 1.13rem);
}
.hero__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.9rem;
}
.hero .chips {
margin-top: 1.75rem;
}
/* ---- The mark ---------------------------------------------------------
The glow is a radial gradient mixed from the portal tokens rather than a
literal, so a mounted theme.css moves it with the rest of the palette.
It is behind the emblem and outside the flow, so it costs no layout. */
.hero__mark {
position: relative;
display: grid;
place-items: center;
}
.hero__mark::before {
content: '';
position: absolute;
z-index: 0;
inset: 50% auto auto 50%;
translate: -50% -50%;
width: 150%;
aspect-ratio: 1;
border-radius: var(--radius-pill);
background: radial-gradient(
circle,
color-mix(in srgb, var(--portal-deep) 34%, transparent) 0%,
color-mix(in srgb, var(--portal-deep) 8%, transparent) 45%,
transparent 68%
);
}
.hero__mark img {
position: relative;
z-index: 1;
display: block;
width: clamp(176px, 26vw, 320px);
height: auto;
}
@media (max-width: 900px) {
.hero__inner {
grid-template-columns: minmax(0, 1fr);
justify-items: start;
}
/* The mark leads on a narrow screen: it is the fastest thing to recognise,
and stacking it under the copy would push it below the fold entirely. */
.hero__mark {
order: -1;
}
}
</style>

View File

@@ -0,0 +1,107 @@
---
/**
* The self-hosted argument (PLAN.md §10, the `/` row).
*
* Every claim below is from §2's verified state, and each is deliberately the kind of thing
* that can be checked by running the software rather than by trusting the page. Where a
* claim would need a qualifier, the qualifier is on the card — "understated honesty" (D8)
* is a house style, and a hedge in small print is the opposite of it.
*
* Nothing here is a version or a number, so nothing here needs `platform.json`. If a card
* ever gains one, it reads it from there like everything else (§12).
*/
const points = [
{
title: 'It runs on your box',
body:
'Docker Compose, with prebuilt images that are pulled rather than built — nothing ' +
'compiles on your server. One command up, one command back.',
},
{
title: 'The game stays off the internet',
body:
'The game host opens no inbound port. The sidecar beside it is the only exposed ' +
'part of the bridge, and it answers exactly one caller: your website.',
},
{
title: 'Branding is data, not a rebuild',
body:
'Name, colours, logo and contact address live in a mounted file. The same image ' +
'runs as any community — including this site, which is built the same way.',
},
{
title: 'No analytics, anywhere',
body:
'This site has no trackers, no third-party requests and no cookie banner, because ' +
'it collects nothing. Your deployment talks to the services you configure, and to ' +
'nothing you did not.',
},
{
title: 'Documented, not just working',
body:
'The whole backend is described by an OpenAPI 3.0 spec that ships with it, so the ' +
'API you build against is the API that is actually there.',
},
{
title: 'Free software',
body:
'GPL-3.0-or-later, every repository in the open. If this project stops, what you ' +
'are running does not.',
},
];
---
<section class="page section selfhosted">
<p class="eyebrow">Why self-hosted</p>
<h2>Your server, your data, your rules</h2>
<p class="prose selfhosted__lede">
There is no hosted tier and no account with us. The whole thing is software you run,
which is the only arrangement under which "the game is not on the internet" can mean
anything.
</p>
<ul class="selfhosted__grid">
{
points.map((point) => (
<li class="panel">
<h3>{point.title}</h3>
<p>{point.body}</p>
</li>
))
}
</ul>
</section>
<style>
.selfhosted h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.selfhosted__lede {
margin: 0;
color: var(--muted);
}
.selfhosted__grid {
display: grid;
gap: 1rem;
margin: 2.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}
.selfhosted__grid h3 {
margin: 0 0 0.5rem;
color: var(--gold);
font-size: 1.02rem;
}
.selfhosted__grid p {
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
</style>

172
src/data/capabilities.mjs Normal file
View File

@@ -0,0 +1,172 @@
/**
* capabilities.mjs — the five capability groups of PLAN.md §10, as data.
*
* ---------------------------------------------------------------------------------------
* WHY THIS IS DATA AND NOT MARKUP
* ---------------------------------------------------------------------------------------
* The homepage names these groups, `/features/` (phase 4) expands them, and `/modules/`
* explains the core/module split they encode. Three pages listing the same capabilities in
* three hand-maintained lists is how a site ends up advertising something that was removed,
* which §1 forbids. One list, read by all three.
*
* ---------------------------------------------------------------------------------------
* THE PART THAT IS A CHECK, NOT A LIST
* ---------------------------------------------------------------------------------------
* "Game intelligence" is the only group core does not supply — it comes from whichever
* module is installed, and today that is `module-uo`. Its items therefore carry the
* capability slugs the module actually declares in its `module.json`, and
* `assertCapabilityCoverage()` fails the build if the two lists drift apart.
*
* That closes a real gap. `platform.json` holds `moduleUoCapabilities` and
* `scripts/checkFacts.mjs` re-reads it from the module's manifest on every build — so the
* day `module-uo` gains a capability, the JSON goes red and someone updates it. Before this
* function, updating the JSON was the end of it and the page kept the old list. Now the
* page is what goes red next.
*
* Note that slugs are NOT one-per-item in either direction: `shard` is the source of four
* separate user-facing capabilities, and the marketplace draws on `market` and `cliloc`
* together (item names arrive as cliloc ids and are resolved against the shard's own
* string table). The check is coverage in both directions, not a bijection.
*/
/**
* Community — core, game-agnostic. Everything here works on a deployment with no game
* module installed at all.
*/
const community = {
id: 'community',
/**
* Core, not module-supplied. Stated on every group rather than only on the one that is
* true, so the shape of a group is uniform — the homepage reads this field on all five,
* and an inferred union that carries it on one member is an error waiting for the next
* template that touches it.
*/
moduleSupplied: false,
title: 'Community',
summary: 'The site your players actually use, none of which knows what game you run.',
items: [
{ label: 'Teams' },
{ label: 'Team forums' },
{ label: 'Notifications' },
{ label: 'Wiki' },
{ label: 'News and newsletter' },
{ label: 'Player self-service' },
],
};
/**
* Game intelligence — module-supplied. The `caps` arrays are the contract with
* `platform.json`; see `assertCapabilityCoverage` below.
*/
const gameIntelligence = {
id: 'game-intelligence',
title: 'Game intelligence',
moduleSupplied: true,
summary:
'Supplied by the installed game module, not by the core site. Today that module is ' +
'module-uo, and this is what it publishes from a live shard.',
items: [
{ label: 'Live server status', caps: ['shard'] },
{ label: 'Economy and activity', caps: ['shard'] },
{ label: 'Character sheets', caps: ['shard'] },
{ label: 'Points and loyalty boards', caps: ['shard'] },
{ label: 'Player-vendor marketplace', caps: ['market', 'cliloc'] },
{ label: 'Houses and IDOC decay', caps: ['houses'] },
{ label: 'Spawn atlas', caps: ['atlas'] },
{ label: 'Champion boards', caps: ['champs'] },
{ label: 'Guilds', caps: ['guilds'] },
{ label: 'City governors', caps: ['governors'] },
],
};
const administration = {
id: 'administration',
moduleSupplied: false,
title: 'Administration',
summary: 'Running the place, with a record of who did what.',
items: [
{ label: 'Roles and permissions' },
{ label: 'Moderation and appeals' },
{ label: 'Content reports' },
{ label: 'Append-only audit log' },
{ label: 'Bot scoring and IP bans' },
{ label: 'Module management' },
{ label: 'The game-server connection' },
],
};
const integration = {
id: 'integration',
moduleSupplied: false,
title: 'Integration',
summary: 'The seams that let other things reach in — and one game reach out.',
items: [
{ label: 'Modules' },
{ label: 'The sidecar bridge' },
{ label: 'Discord: slash commands, notifications, voice' },
{ label: 'Mobile and push' },
{ label: 'SSO over OAuth2 / OIDC' },
],
};
const infrastructure = {
id: 'infrastructure',
moduleSupplied: false,
title: 'Infrastructure',
summary: 'How it runs, and who it answers to.',
items: [
{ label: 'Self-hosted, start to finish' },
{ label: 'Docker, with prebuilt pull-only images' },
{ label: 'Branding as data, not a rebuild' },
{ label: 'OpenAPI 3.0 for the whole API' },
],
};
export const capabilityGroups = [
community,
gameIntelligence,
administration,
integration,
infrastructure,
];
/**
* Fails the build when the module's declared capabilities and this page's list disagree.
*
* Called from the component rather than from a check script on purpose: the failure needs
* to reach whoever is editing the page, and an Astro build error names the component. It
* also means the rule cannot be skipped by running `astro build` without `npm run verify`.
*/
export function assertCapabilityCoverage(declared) {
const claimed = new Set();
for (const item of gameIntelligence.items) {
for (const cap of item.caps || []) claimed.add(cap);
}
const known = new Set(declared);
const unlisted = declared.filter((cap) => !claimed.has(cap));
const invented = [...claimed].filter((cap) => !known.has(cap));
if (!unlisted.length && !invented.length) return;
const lines = [];
if (unlisted.length) {
lines.push(
`the installed module declares ${unlisted.map((c) => `"${c}"`).join(', ')}, which no ` +
`capability on the homepage claims — the site is under-selling what it can show.`
);
}
if (invented.length) {
lines.push(
`the homepage claims ${invented.map((c) => `"${c}"`).join(', ')}, which the module no ` +
`longer declares — the site is advertising something that is gone (§1).`
);
}
throw new Error(
`src/data/capabilities.mjs disagrees with platform.json's moduleUoCapabilities:\n` +
lines.map((line) => ` - ${line}`).join('\n') +
`\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n`
);
}

View File

@@ -327,6 +327,13 @@ export function warmCache() {
'favicon-32.png',
'favicon.ico',
'apple-touch-icon.png',
// The homepage hero (phase 3). Its <picture> offers 256/384/512 in both formats and
// the browser picks one, so warming all six would be five wasted encodes; these are
// the two the common viewport-and-DPR combinations resolve to, plus the WebP the
// `src` attribute names for anything without AVIF.
'logo-384.avif',
'logo-512.avif',
'logo-384.webp',
];
return Promise.allSettled(names.map((name) => resolveBrandFile(name)));
}

View File

@@ -1,79 +1,34 @@
---
import Base from '../layouts/Base.astro';
import { brand } from '../lib/brand.mjs';
import platform from '../data/platform.json';
import Hero from '../components/home/Hero.astro';
import DataPath from '../components/home/DataPath.astro';
import SelfHosted from '../components/home/SelfHosted.astro';
import Capabilities from '../components/home/Capabilities.astro';
import GetStarted from '../components/home/GetStarted.astro';
/**
* 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.
* The homepage — PLAN.md §13 phase 3.
*
* Everything it claims is from §2's verified state. Nothing here is marketing copy yet.
* Ordered as an argument rather than as a brochure: what it is (hero), how the hard part
* works (the data path), why you would want it on your own hardware, what you actually get,
* and how to start. The data path comes second on purpose — it is the claim in the tagline,
* and a visitor who does not believe it has no reason to read the feature list.
*
* The page itself holds no copy and no facts. Each section reads versions from
* `platform.json` and brand text from `brand.mjs`, so nothing on this route can go stale
* without a check going red first (§12).
*
* `bareTitle` because the hero's own <h1> is the tagline: the default suffix would render
* "Runic Gateway — Put your … — Runic Gateway".
*/
---
<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 title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
<Hero />
<DataPath />
<SelfHosted />
<Capabilities />
<GetStarted />
</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>

View File

@@ -204,11 +204,33 @@ svg {
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. */
/* Phase 1 left the mobile nav to phase 3, expecting a disclosure control. It got
a wrap instead, and deliberately: with four links there is nothing to disclose.
The lockup keeps the first row, the links take the second, and the whole thing
stays four keyboard stops with no state, no script and no duplicate markup —
all three of which a hamburger would have cost.
Phase 3 found the bug this fixes by rendering the homepage in a 390px frame:
the four links plus the lockup measured 433px against a 390px viewport, so
every phone got a horizontally scrolling page. Shrinking the type further was
the tempting fix and would only have moved the failure to the next narrow
screen. */
@media (max-width: 720px) {
.site-header__inner {
flex-wrap: wrap;
justify-content: flex-start;
row-gap: 0.15rem;
padding-block: 0.55rem;
min-height: 0;
}
.site-nav {
flex-wrap: wrap;
width: 100%;
gap: 0;
/* Pull the first link's own padding back to the gutter so the row of links
lines up with the lockup above it rather than sitting indented. */
margin-left: -0.45rem;
}
.site-nav a {
@@ -333,6 +355,73 @@ svg {
color: var(--mode-live);
}
/* ---- Sections ------------------------------------------------------------
The vertical rhythm every marketing page is built from. Here rather than in
a component because phases 4 to 6 add pages that must sit on the same grid,
and a per-page `padding-block` is how that stops being true. */
.section {
padding-block: clamp(2.5rem, 6vw, 4.5rem);
}
.section + .section {
padding-top: 0;
}
/* ---- Buttons -------------------------------------------------------------
Three variants, all the same box: solid for the one action a page wants,
outlined for the alternatives, and the demo's own below. Anchors, not
buttons — every one of them navigates, and the site runs no client JS. */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.62rem 1.15rem;
border: 1px solid var(--line);
border-radius: var(--radius-input);
background: var(--panel-flat);
color: var(--ink);
font-size: 0.96rem;
font-weight: 500;
line-height: 1.3;
text-decoration: none;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}
.btn:hover {
border-color: var(--gold-deep);
color: var(--ink);
}
/* The primary action reads as gold-on-dark rather than a filled gold slab: at
7.91:1 the token is a text colour, and `--gold-deep` is explicitly annotated
"rules, borders, UI edges. NEVER text." — so it carries the edge and the
wash, and the gold carries the label. */
.btn--primary {
border-color: var(--gold);
background: color-mix(in srgb, var(--gold-deep) 18%, transparent);
color: var(--gold-bright);
}
.btn--primary:hover {
background: color-mix(in srgb, var(--gold-deep) 30%, transparent);
color: var(--gold-bright);
}
.btn--ghost {
background: transparent;
color: var(--muted);
}
.btn--ghost:hover {
color: var(--ink);
}
/* ---- The demo slot -------------------------------------------------------
PLAN.md §15 / D12. A public demo instance is planned and out of scope, but
the site is built so that gaining one is a line in the mounted brand.json
@@ -351,3 +440,18 @@ svg {
[data-demo-url=''] {
display: none;
}
/* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button
like its neighbours and takes the portal colour — the live signal, for the
one link on the site that leads to something actually running. */
.demo-cta {
border-color: var(--portal);
background: color-mix(in srgb, var(--portal-deep) 22%, transparent);
color: var(--portal-bright);
}
.demo-cta:hover {
border-color: var(--portal-bright);
background: color-mix(in srgb, var(--portal-deep) 34%, transparent);
color: var(--portal-bright);
}