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

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>