feat(marketing): phase 4 — the marketing pages
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s

PLAN.md §13 phase 4: /features/, /architecture/, /modules/, /integrations/, and
/community/ — plus the two scope items the phase table never assigned to anyone.

Six decisions taken by the org lead before coding, recorded in PLAN.md §10 as
D20-D25:

- D20 /features/ is the homepage's list with a `detail` line, not a second list.
  One data file, two renderings, so they cannot disagree about what exists.
- D21 /architecture/ draws reasons, not reference: three new inline SVGs, one per
  boundary. No endpoint tables, no config keys — those are phase 8's and stay
  canonical in docs/.
- D22 The deliberate absences of §2 become one tagged data file, rendered on the
  three pages that promise them.
- D23 Phase 4 absorbs /community/ (specified in §10 and §14 N3, linked from the
  header since phase 1, built by no phase) and checkLinks.mjs.
- D24 `needsModule`: writing the Teams detail exposed a false claim phase 3
  shipped. Teams are module-sourced only — teams.module_id is NOT NULL, there is
  no create route, sync is gated on providerModuleId() — so the Community group
  no longer says a bare core does all of it.
- D25 The per-capability demo affordance brand.json had promised since phase 2 is
  a deep link, filled at boot from data-demo-path.

checkLinks.mjs reads the built HTML rather than src/, because half these links
are assembled from data files and template literals. Its PLANNED_ROUTES list is
checked in both directions, so it cannot rot into a permanent exemption.

applyBrand.mjs gained a pass that recomputes deep links from their immutable
path, making it idempotent and reversible; checkBrand.mjs lifts that pattern out
and runs it against the stock markup so the two cannot drift. Both proved
against a real mount, in both directions.

Fixes a cascade bug the checks could not see: [data-demo-url=''] and a scoped
component class are both specificity 0,1,0, so .demo-link's `display` beat the
hide rule and twelve links to a nonexistent demo rendered, each resolving to the
current page. The rule is now !important.

The four diagrams' shared SVG vocabulary moved to src/styles/diagram.css.

Verified from a clean checkout: npm ci, all five checks, astro check (0 errors),
production build, and a live browser pass at desktop and 390px.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 01:53:11 -05:00
parent d9d7a8d47f
commit 2d19ee4220
21 changed files with 3332 additions and 119 deletions

View File

@@ -0,0 +1,132 @@
---
import { notBuiltFor, assertScopeNonEmpty } from '../data/notBuilt.mjs';
/**
* The deliberate absences (PLAN.md §2, D22), rendered for one page's scope.
*
* §2 describes its absent-features list as "as load-bearing as the rest", and this is the
* component that makes that true on a page rather than in a plan. It reads the shared list
* so `/features/`, `/integrations/` and `/modules/` cannot drift into telling three
* different stories about the same six things.
*
* ---------------------------------------------------------------------------------------
* WHY IT LOOKS LIKE THE REST OF THE PAGE
* ---------------------------------------------------------------------------------------
* Not a warning box, not a muted footnote, not an accordion. D8's "understated honesty" is
* a house style with a specific consequence here: a section that is visually apologetic
* teaches a reader that absences are embarrassing, and a section that is visually hidden
* teaches them to go looking for the ones you did not mention. These are decisions with
* reasons, so they are set as decisions with reasons — the same panels as everything else,
* in the same place in the rhythm.
*
* The one visual difference is the `resolvedBy` line, which every entry carries. An absence
* with an exit condition is a position; an absence without one is a hole. D8 gives the
* Integration Kit's draft status a defined removal condition and this generalises it.
*/
interface Props {
/** Which page is asking: `features`, `integrations` or `modules`. */
scope: string;
/** Section heading. Each page frames the same list for its own reader. */
title: string;
}
const { scope, title } = Astro.props;
assertScopeNonEmpty(scope);
const entries = notBuiltFor(scope);
---
<section class="page section notbuilt">
<p class="eyebrow">Not built</p>
<h2>{title}</h2>
<p class="prose notbuilt__lede">
Every one of these is a decision rather than a backlog item, so each says why. Where the
reasoning was written down in the open, it is linked.
</p>
<ul class="notbuilt__grid">
{
entries.map((entry) => (
<li class="panel notbuilt__item">
<h3>{entry.title}</h3>
<p class="notbuilt__body">{entry.body}</p>
<p class="notbuilt__resolved">
<span class="notbuilt__resolved-label">What would change it</span>
{entry.resolvedBy}
</p>
{entry.link && (
<p class="notbuilt__link">
<a href={entry.link.href} rel="noopener noreferrer">
{entry.link.label}
</a>
</p>
)}
</li>
))
}
</ul>
</section>
<style>
.notbuilt h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.notbuilt__lede {
margin: 0;
color: var(--muted);
}
.notbuilt__grid {
display: grid;
gap: 1rem;
margin: 2.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
}
.notbuilt__item {
display: flex;
flex-direction: column;
}
.notbuilt__item h3 {
margin: 0 0 0.6rem;
color: var(--gold);
font-size: 1.02rem;
}
/* Takes the slack, so the exit condition sits at the foot of every card in a row
rather than immediately under a body of whatever length — the same kind of
statement in the same place on each, which is what makes them readable as a row. */
.notbuilt__body {
flex: 1;
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
.notbuilt__resolved {
margin: 1rem 0 0;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.88rem;
}
.notbuilt__resolved-label {
display: block;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.notbuilt__link {
margin: 0.85rem 0 0;
font-size: 0.88rem;
}
</style>

View File

@@ -0,0 +1,55 @@
---
/**
* The opening of every marketing page except the homepage — eyebrow, `<h1>`, lede.
*
* A component rather than four copies of the same three elements, because phase 4 writes
* five pages and phases 5 and 6 write four more. The homepage is deliberately not one of
* them: its `<h1>` is the tagline inside the hero, set against the emblem, and pulling that
* into a shared header would either flatten the hero or push its layout in here (D19).
*
* The `<h1>` is the page's own name, not the product's, and `Base` appends the site name to
* the document title — so a page sets a short `title` and gets "Features — Runic Gateway"
* in the tab and "Features" on the page.
*/
interface Props {
/** Small uppercase line above the title. What kind of page this is. */
eyebrow: string;
title: string;
}
const { eyebrow, title } = Astro.props;
---
<header class="page section pagehead">
<p class="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
<div class="prose pagehead__lede">
<slot />
</div>
</header>
<style>
/* The section rhythm gives generous space below; the header wants less, because the
first section under it is part of the same thought. */
.pagehead {
padding-bottom: clamp(1rem, 2.5vw, 1.75rem);
}
.pagehead h1 {
margin: 0 0 1rem;
font-size: clamp(2rem, 5vw, 2.9rem);
}
.pagehead__lede {
color: var(--muted);
font-size: 1.06rem;
}
.pagehead__lede :global(p) {
margin: 0 0 0.85rem;
}
.pagehead__lede :global(p:last-child) {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,142 @@
---
/**
* "What reaches the public" — the second of `/architecture/`'s three diagrams (D21).
*
* The homepage states the split in one sentence inside the data-path walk ("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"). This is the page where that sentence has to
* become a picture, because it is the single design decision a technical evaluator is most
* entitled to be suspicious of: a live feed of a game world contains things that must never
* be published, and "we filter it" is a claim, not a mechanism.
*
* So the diagram draws the shape of the mechanism — one stream in, one decision, two streams
* out — and the notes say where the decision lives and what happens when it is wrong in
* either direction. What it deliberately does NOT do is enumerate event kinds: that is the
* catalog's job in the docs, it changes with the protocol, and a marketing page holding a
* copy of it would be a copy that goes stale (§1).
*
* The rings sit behind the filter rather than behind the whole picture, on the phase-3
* principle that they mark the one place the argument actually happens.
*/
---
<section class="page section diagram" id="allowlist">
<div class="diagram__head">
<p class="eyebrow">What reaches the public</p>
<h2>One feed in, two feeds out</h2>
<p class="prose">
A live game world emits things that are fine on a front page and things that are not:
who logged in from which address, what the cheat detector flagged, what a staff member
did to whom. Both arrive on the same connection, so something has to divide them.
</p>
</div>
<div class="diagram__body">
<div class="diagram__figure">
<svg viewBox="0 0 380 470" class="flow" aria-hidden="true" focusable="false">
<!-- Centred on the filter: the one place in the picture where the argument is. -->
<g class="rings">
<circle cx="190" cy="178" r="96" />
<circle cx="190" cy="178" r="136" />
<circle cx="190" cy="178" r="176" />
</g>
<rect class="node" x="20" y="12" width="340" height="60" rx="10" />
<text class="node-title" x="40" y="38">Everything the game emits</text>
<text class="node-sub" x="40" y="58">one authenticated stream, from the sidecar</text>
<path class="spine spine--live" d="M190 80 V132" />
<path class="arrow arrow--live" d="M190 140 l-6 -10 h12 Z" />
<rect class="node node--self" x="20" y="142" width="340" height="72" rx="10" />
<text class="node-title" x="40" y="172">Your site decides</text>
<text class="node-sub" x="40" y="192">one allowlist, in one place, on your server</text>
<!-- Diverging: the public leg in cyan because it is still a live feed; the staff
leg in gold because it is the privileged one. -->
<path class="spine spine--live" d="M120 222 C120 268 96 268 96 306" />
<path class="arrow arrow--live" d="M96 314 l-6 -10 h12 Z" />
<path class="spine" d="M260 222 C260 268 284 268 284 306" />
<path class="arrow" d="M284 314 l-6 -10 h12 Z" />
<rect class="node" x="8" y="316" width="176" height="128" rx="10" />
<text class="node-title" x="26" y="344">Public pages</text>
<text class="node-sub" x="26" y="366">an allowlist of event</text>
<text class="node-sub" x="26" y="382">kinds, and nothing</text>
<text class="node-sub" x="26" y="398">outside it</text>
<text class="node-audience" x="26" y="424">anyone at all</text>
<rect class="node" x="196" y="316" width="176" height="128" rx="10" />
<text class="node-title" x="214" y="344">Staff console</text>
<text class="node-sub" x="214" y="366">the rest: audit trail,</text>
<text class="node-sub" x="214" y="382">login attempts,</text>
<text class="node-sub" x="214" y="398">addresses, cheat flags</text>
<text class="node-audience" x="214" y="424">signed-in staff only</text>
</svg>
<p class="diagram__caption">
The allowlist is the security boundary. A new kind of event is invisible to the public
until somebody adds it, which is the safe direction to fail in.
</p>
</div>
<div class="diagram__notes">
<section>
<h3>It is an allowlist, not a blocklist</h3>
<p>
The public stream carries the kinds of event that are named as safe; everything else
goes to the staff stream by default. That ordering is the whole point. A blocklist
fails open — the day the game emits something new, it is already published — and an
allowlist fails closed, so the worst case is a page that is missing something rather
than a page that has published an address.
</p>
</section>
<section>
<h3>The decision lives on your server</h3>
<p>
Not in the sidecar and not in the game. The bridge is a deliberately dumb forwarder:
it moves what the game emits and makes no judgements about audience. Everything
about who may see what is decided by the site you run, in one place, where you can
read it — and where changing it does not mean redeploying anything on the game host.
</p>
</section>
<section>
<h3>More than two audiences, in practice</h3>
<p>
Two streams is the transport. Above it sits a configurable audience model — logged
out, signed in, linked to a game account, staff — that decides how much of a given
surface each of those sees. The public stream is the floor of that, and it is the
one that is a boundary rather than a setting.
</p>
</section>
<section>
<h3>When the game is down</h3>
<p>
Nothing arrives, and the site carries on. Live surfaces say the server is offline
and everything that does not depend on it — the wiki, the news, accounts, the forums
— is unaffected. A site that goes down with the game it reports on is not much of a
status page.
</p>
</section>
</div>
</div>
</section>
<style>
/* Each outcome node ends with a line naming its audience, set apart from the
description above it rather than reading as another line of it.
Its own class, not `:nth-last-of-type`: an index into a list of `<text>`
siblings is correct only until somebody adds a label, and it fails by
styling the wrong words rather than by failing. */
.node-audience {
fill: var(--muted);
font-family: var(--sans);
font-size: 11.5px;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,166 @@
---
import platform from '../../data/platform.json';
/**
* "Where the game stops and the platform starts" — the third of `/architecture/`'s diagrams
* (D21).
*
* The other two draw runtime shapes. This one draws a code boundary, and it is here because
* it is the claim the whole project rests on: that a community platform can be built once
* and pointed at any game. An evaluator has every reason to read that as marketing, so the
* page draws the seam and then says plainly what does and does not prove it — one module
* exists, the second is a paper exercise, and the exit criterion for calling the contract
* proven is written down (§2, and the entries `/modules/` renders from `notBuilt.mjs`).
*
* The Module API version is read from `platform.json` like every other number on this site
* (§12). It is the one place a version genuinely belongs in this diagram: the seam is
* literally a version check, and a module whose declared range does not match refuses to
* load rather than half-loading.
*/
---
<section class="page section diagram" id="module-seam">
<div class="diagram__head">
<p class="eyebrow">Where the game stops</p>
<h2>A seam, with a version on it</h2>
<p class="prose">
The core site does not know what a shard is, what a guild is, or that Ultima Online
exists. Everything that does lives in an installable module on the other side of a
declared interface — which is what makes "put your game on it" a shape rather than a
slogan.
</p>
</div>
<div class="diagram__body">
<div class="diagram__figure">
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
<!-- Core: what ships in the image, on every deployment, module or not. -->
<rect class="host" x="8" y="8" width="364" height="186" rx="14" />
<text class="host-title" x="28" y="42">Runic Gateway core</text>
<text class="host-sub" x="28" y="62">game-agnostic; the same image everywhere</text>
<rect class="node node--self" x="28" y="80" width="156" height="46" rx="10" />
<text class="node-title" x="46" y="108">Accounts</text>
<rect class="node node--self" x="196" y="80" width="156" height="46" rx="10" />
<text class="node-title" x="214" y="108">Teams</text>
<rect class="node node--self" x="28" y="134" width="156" height="46" rx="10" />
<text class="node-title" x="46" y="162">Wiki and posts</text>
<rect class="node node--self" x="196" y="134" width="156" height="46" rx="10" />
<text class="node-title" x="214" y="162">Admin and API</text>
<!-- The seam. Both boundary lines and the label between them: this is the one
thing in the picture that is neither core nor module. -->
<path class="boundary" d="M8 224 H372" />
<text class="seam-label" x="190" y="252" text-anchor="middle">
Module API {platform.moduleApi}
</text>
<path class="boundary" d="M8 272 H372" />
<!-- Registers upward; is asked downward. Two arrows, opposite directions, because
the traffic across a seam is not one-way and drawing it as one-way is what
makes people think a module is a plugin that only listens. -->
<path class="spine" d="M120 300 V206" />
<path class="arrow" d="M120 198 l-6 10 h12 Z" />
<text class="seam-arrow" x="136" y="216">registers</text>
<path class="spine" d="M260 200 V294" />
<path class="arrow" d="M260 302 l-6 -10 h12 Z" />
<text class="seam-arrow" x="244" y="290" text-anchor="end">calls</text>
<!-- The module: everything that knows a game exists. -->
<rect class="host" x="8" y="306" width="364" height="186" rx="14" />
<text class="host-title" x="28" y="340">Game module</text>
<text class="host-sub" x="28" y="360">one per deployment; UO today</text>
<rect class="node" x="28" y="378" width="156" height="46" rx="10" />
<text class="node-title" x="46" y="406">Routes</text>
<rect class="node" x="196" y="378" width="156" height="46" rx="10" />
<text class="node-title" x="214" y="406">Screens</text>
<rect class="node" x="28" y="432" width="156" height="46" rx="10" />
<text class="node-title" x="46" y="460">Its own tables</text>
<rect class="node" x="196" y="432" width="156" height="46" rx="10" />
<text class="node-title" x="214" y="460">Nav rows</text>
</svg>
<p class="diagram__caption">
A module declares which versions of the interface it speaks. If that does not match
what the site offers, it refuses to load and the site comes up without it.
</p>
</div>
<div class="diagram__notes">
<section>
<h3>The module brings its own everything</h3>
<p>
Not just screens: its routes, its database tables, its navigation rows, its slice of
the OpenAPI spec and its own prebuilt client bundle. Installing it is a paste in the
admin panel or a line in your environment — never a build step, because production
runs an image you pulled, and an operator who has to compile something has been
handed a maintenance job rather than a feature.
</p>
</section>
<section>
<h3>Failure is contained by design</h3>
<p>
A module that will not load is marked as failed and the site starts without it.
Disabling one is a kill switch, not a visibility flag — its routes stop answering
and its live connections close. Uninstalling keeps the data, and destroying the data
is a separate, deliberate choice made in its own dialog.
</p>
</section>
<section>
<h3>Teams is the shape of the contract</h3>
<p>
Core owns the Teams primitive — the roster, the forum, the notifications, the voice
channel — and does not own the <em>word</em>. A Team cannot be created in core at
all; it arrives from the module, which is why the UO module calls them guilds and
builds those pages itself. That is the pattern the whole interface is built on: core
supplies the machinery, the module supplies the meaning.
</p>
</section>
<section>
<h3>What this does not yet prove</h3>
<p>
One module exists and it is Ultima Online. A second, for a different game, is a
written dry-run that was deliberately never implemented — it exists to test whether
the contract generalises on paper. Until somebody builds the second one, the seam is
a well-argued design rather than a demonstrated one, and this site says so wherever
it comes up.
</p>
</section>
</div>
</div>
</section>
<style>
/* The seam label sits between the two boundary rules rather than beside them: it is
the name of the gap, not an annotation on either side of it. Gold, because it is
the one contract in the picture. */
.seam-label {
fill: var(--gold);
font-family: var(--sans);
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* Two words, because two arrows crossing a boundary in opposite directions is
ambiguous without them — and the ambiguity is the exact misreading this diagram
exists to prevent, that a module is something core talks at. */
.seam-arrow {
fill: var(--dim);
font-family: var(--sans);
font-size: 11px;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,122 @@
---
/**
* "What you actually deploy" — the first of `/architecture/`'s three diagrams (D21).
*
* This one exists because of a specific, repeated misunderstanding that §10 names and the
* homepage's CTA already spends two sentences on: a Runic Gateway install is two
* independent installs, on two machines, and neither installs the other. The homepage says
* it; this page draws it, because an evaluator deciding whether to run the software is
* doing capacity planning, and "how many machines is this" is the first question they have.
*
* Drawn generically for the same reason the homepage's diagram is (D17) — "your game host",
* not "your ServUO box" — with the prose beside it naming the real components. The boundary
* is the one drawn argument: everything above it is reachable because you published it, and
* everything below it is not reachable at all.
*
* The vocabulary and the layout are `src/styles/diagram.css`; only the geometry is here.
*/
---
<section class="page section diagram" id="two-hosts">
<div class="diagram__head">
<p class="eyebrow">What you deploy</p>
<h2>Two hosts, two installs</h2>
<p class="prose">
Almost everyone gets this wrong once. The website and the game-side bridge are separate
deployments on separate machines, and neither one installs the other — so a "Runic
Gateway install" is really two, done in that order.
</p>
</div>
<div class="diagram__body">
<div class="diagram__figure">
<svg viewBox="0 0 380 546" class="flow" aria-hidden="true" focusable="false">
<!-- The web host, and everything that runs on it. -->
<rect class="host" x="8" y="8" width="364" height="232" rx="14" />
<text class="host-title" x="28" y="42">Your web host</text>
<text class="host-sub" x="28" y="62">a VPS, a home server, anything running Docker</text>
<rect class="node node--self" x="28" y="80" width="324" height="60" rx="10" />
<text class="node-title" x="46" y="106">Runic Gateway</text>
<text class="node-sub" x="46" y="126">one container, pulled not built</text>
<rect class="node" x="28" y="150" width="156" height="60" rx="10" />
<text class="node-title" x="46" y="176">Game module</text>
<text class="node-sub" x="46" y="196">installed, not built</text>
<rect class="node" x="196" y="150" width="156" height="60" rx="10" />
<text class="node-title" x="214" y="176">Database</text>
<text class="node-sub" x="214" y="196">your data, your disk</text>
<!-- The one hop between them, and the only one. Two arrowheads because the traffic
genuinely goes both ways: the site calls the sidecar for point-in-time reads,
and the sidecar pushes the live feed back up. -->
<path class="spine spine--live" d="M190 248 V312" />
<path class="arrow arrow--live" d="M190 240 l-6 10 h12 Z" />
<path class="arrow arrow--live" d="M190 320 l-6 -10 h12 Z" />
<path class="boundary" d="M8 280 H372" />
<text class="boundary-label" x="372" y="273" text-anchor="end">the network</text>
<!-- The game host. Nothing here is reachable from outside except the sidecar. -->
<rect class="host" x="8" y="320" width="364" height="214" rx="14" />
<text class="host-title" x="28" y="354">Your game host</text>
<text class="host-sub" x="28" y="374">where the game server already runs</text>
<rect class="node" x="28" y="392" width="324" height="60" rx="10" />
<text class="node-title" x="46" y="418">Sidecar</text>
<text class="node-sub" x="46" y="438">the only part of this with a port open</text>
<rect class="node" x="28" y="462" width="324" height="60" rx="10" />
<text class="node-title" x="46" y="488">Game server</text>
<text class="node-sub" x="46" y="508">dials out over loopback; listens for nothing</text>
</svg>
<p class="diagram__caption">
Today the game server is a ServUO shard and the sidecar is uo-link. Two machines is
the minimum and also the maximum — nothing here scales by adding a third.
</p>
</div>
<div class="diagram__notes">
<section>
<h3>The web host</h3>
<p>
A Docker Compose deployment: the site, its database, and whichever game module you
installed. Images are pulled rather than built, so nothing compiles here and an
upgrade is a pull and a restart. This is the only machine anybody points a browser
at, and the only one that needs a certificate.
</p>
</section>
<section>
<h3>The game host</h3>
<p>
The machine your game server is already on. One installer binary puts the plugin
into the server's tree, installs the sidecar beside it and registers the service —
then prints four values. It never contacts your website; you paste those four
values into the admin panel yourself, and that is the moment the two halves meet.
</p>
</section>
<section>
<h3>Why they share a host</h3>
<p>
The game talks to the sidecar over loopback, on the same machine, and dials
<em>out</em> to do it. That is what lets the game server open no port at all — and it
is also why there is no macOS installer build. The pair has to sit together, and no
game server anybody runs is on one.
</p>
</section>
<section>
<h3>What crosses between them</h3>
<p>
One authenticated connection, in both directions: a WebSocket carrying the live feed
up, and REST calls going down for point-in-time questions. Nothing else on either
machine talks to the other, and the sidecar answers your site and nobody else.
</p>
</section>
</div>
</div>
</section>

View File

@@ -159,12 +159,6 @@ import platform from '../../data/platform.json';
top: calc(var(--header-h) + 1.5rem);
}
.flow {
display: block;
width: 100%;
max-width: 380px;
}
.datapath__caption {
margin: 1rem 0 0;
max-width: 380px;
@@ -172,75 +166,14 @@ import platform from '../../data/platform.json';
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;
}
/* The SVG vocabulary this diagram draws with -- .node, .spine, .arrow,
.boundary, .rings -- now lives in src/styles/diagram.css, shared with
/architecture/'s three. It was duplicated in four files the moment the
second diagram existed, and the rules it holds are decisions about what a
diagram on this site looks like rather than about this one.
.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 layout below stays here: the right-hand column is a numbered walk,
not the notes column .diagram__body assumes. */
/* ---- The list ---------------------------------------------------------- */
.datapath__steps {

View File

@@ -4,10 +4,16 @@
* ---------------------------------------------------------------------------------------
* 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 homepage names these groups, `/features/` 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.
*
* Phase 4 added the `detail` line rather than writing `/features/` as prose (D20). The two
* pages are then one list rendered twice — `/` takes the label, `/features/` takes the
* label and the detail — and they cannot disagree about what exists, only about how much
* they say. `assertDetailCoverage()` below is what stops the next capability being added to
* the homepage without an argument to go with it.
*
* ---------------------------------------------------------------------------------------
* THE PART THAT IS A CHECK, NOT A LIST
@@ -27,11 +33,74 @@
* 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.
*
* ---------------------------------------------------------------------------------------
* `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE
* ---------------------------------------------------------------------------------------
* A group is `moduleSupplied` or it is not, and phase 3 shipped the Community group saying
* "everything here works on a deployment with no game module installed at all". Writing the
* `/features/` detail for Teams is what exposed that as false, and the tree says so plainly
* on `main`: `teams.module_id` is `NOT NULL`, there is no create route anywhere under
* `/api/v1/admin/teams`, and `teamSync` is gated on `teamProvider.providerModuleId()`.
*
* The truth is neither of the two states the file had. Core owns the whole Team machinery —
* the tables, the roster resolver, the forums, the notification streams, the Discord bridge,
* the voice channels, the activity feed and `/admin/teams` — and cannot *originate* a Team.
* They arrive from the installed module, which is exactly the point: core does not own the
* word for a Team, so `module-uo` calls them guilds and builds the pages, and a future
* module can call them something else on the same primitive.
*
* So `needsModule` marks an item that is core machinery a module has to populate. On a bare
* core it is present, correct and permanently empty. `/` renders the requalified group
* summary; `/features/` renders the marker and says why (D24).
*
* ---------------------------------------------------------------------------------------
* `demoPath` — DEEP LINKS THAT ONLY EXIST WHEN A DEMO DOES
* ---------------------------------------------------------------------------------------
* §15/D12 keeps the public demo out of scope while requiring the site to gain one by way of
* a line in a mounted `brand.json`. `brand.json`'s own comment promised `/features/` a
* "per-capability affordance" that had never been defined; D25 defines it as a deep link
* per capability that has a stable public route, appended to the mounted `demoUrl` by
* `applyBrand.mjs` at boot and hidden by the same `[data-demo-url='']` rule as the
* homepage's slot.
*
* Only some capabilities have one, and that asymmetry is honest rather than unfinished:
* character sheets are reachable only by the account they belong to, and a Team forum lives
* behind a Team id no static page can know. Paths are read from the real route tables —
* core's `client/src/App.jsx` and `module-uo`'s `client/src/entry.jsx` on `main` — never
* guessed. Note the module's public pages are namespaced under its own id, so a UO route is
* `/uo/…`; a deployment running a different module would deep-link somewhere else, which is
* why these sit beside the `caps` slugs on the module-supplied items.
*/
/**
* Community — core, game-agnostic. Everything here works on a deployment with no game
* module installed at all.
* The shape of a capability, written out because TypeScript otherwise infers it per group
* from whichever fields that group's items happen to use — and then `/features/` cannot
* read `demoPath` off an Administration item, because no Administration item has one.
* `astro check` catches that, correctly: the union of five literal shapes is not the shape
* the page is written against.
*
* @typedef {object} Capability
* @property {string} label What it is called, on every page that lists it.
* @property {string} detail The argument for it. `/features/` only; see D20.
* @property {string[]} [caps] Module capability slugs, on module-supplied items only.
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
*
* @typedef {object} CapabilityGroup
* @property {string} id
* @property {string} title
* @property {string} summary
* @property {boolean} moduleSupplied
* @property {Capability[]} items
*/
/**
* Community — core machinery. Everything here ships with the site itself and none of it
* knows what game you run; two of the six still need a module to put anything in them,
* which is what `needsModule` says.
*
* @type {CapabilityGroup}
*/
const community = {
id: 'community',
@@ -43,20 +112,66 @@ const community = {
*/
moduleSupplied: false,
title: 'Community',
summary: 'The site your players actually use, none of which knows what game you run.',
summary:
'The site your players actually use, none of which knows what game you run — though ' +
'Teams arrive from the installed module rather than being created here.',
items: [
{ label: 'Teams' },
{ label: 'Team forums' },
{ label: 'Notifications' },
{ label: 'Wiki' },
{ label: 'News and newsletter' },
{ label: 'Player self-service' },
{
label: 'Teams',
needsModule: true,
demoPath: '/uo/guilds',
detail:
'A roster, a leader, a private forum, its own notification streams and a Discord ' +
'voice channel, all hanging off one group. Core owns every part of that machinery ' +
'and deliberately cannot create a Team: they arrive from the installed module, ' +
'which is how a guild inside the game becomes a Team on the site — and why a ' +
'different game can call them something else without core learning a new word.',
},
{
label: 'Team forums',
needsModule: true,
detail:
'Announcements, discussion threads and replies, with an edit window, post ' +
'moderation, and abuse reports a member can raise without going through staff ' +
'first. Forums are an admin switch for the whole deployment, and image uploads ' +
'stay off until someone deliberately turns them on.',
},
{
label: 'Notifications',
detail:
'Web, push and email, chosen per stream by each person rather than per person by ' +
'you. Push arrives by default and can be switched off; email only ever arrives if ' +
'it was asked for.',
},
{
label: 'Wiki',
demoPath: '/wiki',
detail:
'For the things that outlive a news post — rules, guides, the lore nobody wants to ' +
'retype in chat. Written in the admin panel, published on the public site.',
},
{
label: 'News and newsletter',
demoPath: '/site/news',
detail:
'Four kinds of post — news, five-on-friday, newsletter issues and screenshots — ' +
'plus CMS pages and a page builder for everything that is not a post at all.',
},
{
label: 'Player self-service',
detail:
'An account area every signed-in person gets, whatever their role: their profile, ' +
'their linked game accounts, their own characters, their devices and sessions. ' +
'Staff are players too, so it is the same area for everyone.',
},
],
};
/**
* Game intelligence — module-supplied. The `caps` arrays are the contract with
* `platform.json`; see `assertCapabilityCoverage` below.
*
* @type {CapabilityGroup}
*/
const gameIntelligence = {
id: 'game-intelligence',
@@ -66,59 +181,225 @@ const gameIntelligence = {
'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'] },
{
label: 'Live server status',
caps: ['shard'],
demoPath: '/uo/shard',
detail:
'Whether the server is up, who is on it, and how long ago the site last heard from ' +
'it. When the game is down this page is the thing that says so — the site does not ' +
'go down with it.',
},
{
label: 'Economy and activity',
caps: ['shard'],
demoPath: '/uo/shard/activity',
detail:
'A live feed of what is happening in the world, and the economy underneath it. ' +
'Every event passes an allowlist before it can reach a public page; staff read a ' +
'second stream carrying the rest.',
},
{
label: 'Character sheets',
caps: ['shard'],
detail:
'Skills, stats and equipment, drawn from the live world rather than from a form ' +
'somebody filled in. Reachable by the account the character is linked to, and by ' +
'staff — not by the public.',
},
{
label: 'Points and loyalty boards',
caps: ['shard'],
demoPath: '/uo/leaderboards',
detail:
'The leaderboards the game already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday.',
},
{
label: 'Player-vendor marketplace',
caps: ['market', 'cliloc'],
demoPath: '/uo/market',
detail:
'Every player vendor on the server and what is on it, searchable without logging ' +
'in to the game. Item names arrive from the world as numeric ids and are resolved ' +
"against the game's own string table, so they read as names rather than numbers.",
},
{
label: 'Houses and IDOC decay',
caps: ['houses'],
demoPath: '/uo/houses',
detail:
'Who owns what and where it stands, including which houses are decaying — ' +
'published while it is still information rather than after it has become a rumour.',
},
{
label: 'Spawn atlas',
caps: ['atlas'],
demoPath: '/uo/atlas',
detail:
"A bestiary and spawn map built by reading your shard's own spawn tables, so it " +
"describes your server rather than someone else's idea of the game. Regions, " +
'landmarks and champion altars come with it.',
},
{
label: 'Champion boards',
caps: ['champs'],
demoPath: '/uo/champs',
detail: 'Which altars are running, how far along they are, and what turned up.',
},
{
label: 'Guilds',
caps: ['guilds'],
demoPath: '/uo/guilds',
detail:
'Guild rosters and standings, kept in step with the game. This is also what fills ' +
'the Teams primitive above: a guild in the world becomes a Team on the site, with ' +
"the forum, the notifications and the voice channel that core attaches to one.",
},
{
label: 'City governors',
caps: ['governors'],
demoPath: '/uo/governors',
detail: 'Who holds which city, and what they did with it.',
},
],
};
/** @type {CapabilityGroup} */
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' },
{
label: 'Roles and permissions',
detail:
'Admin, moderator and player. Admin access is re-checked against the database on ' +
'every request rather than trusted from whatever the session was issued with, so ' +
'demoting someone takes effect on their next click and not at their next login.',
},
{
label: 'Moderation and appeals',
detail:
'Decisions carry a written reason, and the person on the receiving end has a ' +
'documented way to answer rather than a direct message to whoever is awake.',
},
{
label: 'Content reports',
detail:
'Anything a member writes can be reported by another member, into a staff queue ' +
'with the context attached.',
},
{
label: 'Append-only audit log',
detail:
'Staff actions are recorded, and nothing in the panel can edit or delete the ' +
'record afterwards. That is worth having on the day you need to prove what did ' +
'not happen.',
},
{
label: 'Bot scoring and IP bans',
detail:
'Login attempts are scored on behaviour rather than on a puzzle a real person has ' +
'to solve, and a bad enough score bans the address by itself. The panel is a read ' +
'view with an emergency unban, deliberately — it is not somewhere to tune a ' +
'threshold at three in the morning.',
},
{
label: 'Module management',
detail:
'Install, disable, uninstall and purge a module from the panel. Uninstalling keeps ' +
'the data and reinstalling picks it up where it was; deleting it is a separate, ' +
'opt-in choice.',
},
{
label: 'The game-server connection',
detail:
"The bridge's address, token and protocol version live in the panel rather than in " +
'an environment file, so connecting a server is not a redeploy. The token is ' +
'encrypted at rest and write-only in the API — it is never returned to any client, ' +
'including yours.',
},
],
};
/** @type {CapabilityGroup} */
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' },
{
label: 'Modules',
detail:
'The whole game-specific half of a deployment is an installable module: routes, ' +
'screens, tables and nav rows, versioned against a declared core API. Installing ' +
'one is a paste in the admin panel or a line in your environment, never a build.',
},
{
label: 'The sidecar bridge',
detail:
'A small service beside the game server, speaking a versioned wire protocol to the ' +
'site and a loopback socket to the game. It is the only part of the bridge anything ' +
'can reach over a network, and the game never listens at all.',
},
{
label: 'Discord: slash commands, notifications, voice',
detail:
'A bot for the guild you already have. Commands answer from your site, ' +
'notifications bridge into channels, and a Team can be granted a voice channel ' +
'that maintains its own membership.',
},
{
label: 'Mobile and push',
detail:
'A native Android app against the same documented API the website uses, with push ' +
'delivered through your own ntfy server rather than a vendor in the middle.',
},
{
label: 'SSO over OAuth2 / OIDC',
detail:
'Google, Discord, or any OIDC provider you run. Link-only by policy: an external ' +
'identity has to be attached to an account that already exists, and signing in ' +
'with one never creates a user.',
},
],
};
/** @type {CapabilityGroup} */
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' },
{
label: 'Self-hosted, start to finish',
detail:
'There is no hosted tier and no account with us. Every part of this runs on ' +
'hardware you control, which is the only arrangement under which the rest of the ' +
'claims on this page mean anything.',
},
{
label: 'Docker, with prebuilt pull-only images',
detail:
'Compose up, compose down. Images are pulled rather than built, so nothing ' +
'compiles on your server and an upgrade is a pull and a restart.',
},
{
label: 'Branding as data, not a rebuild',
detail:
'Name, colours, logo and contact address are a mounted file. The same image runs ' +
'as any community — including this site, which is built the same way.',
},
{
label: 'OpenAPI 3.0 for the whole API',
detail:
'The spec ships with the server and an installed module merges its own routes into ' +
'it, so the API you build against is the API that is actually running.',
},
],
};
@@ -130,6 +411,27 @@ export const capabilityGroups = [
infrastructure,
];
/**
* One group by id, or a build failure naming the id that was asked for.
*
* `capabilityGroups.find(...)` returns `CapabilityGroup | undefined`, so every page that
* wants one group has to either handle an impossible undefined or assert past it — and the
* assertion is what would eventually ship a blank section after somebody renamed an id.
* Failing here instead means a renamed group is caught by the first page that reads it.
*
* @param {string} id
* @returns {CapabilityGroup}
*/
export function capabilityGroup(id) {
const group = capabilityGroups.find((candidate) => candidate.id === id);
if (group) return group;
throw new Error(
`src/data/capabilities.mjs has no group with id "${id}", but a page asked for it.\n` +
`Known ids: ${capabilityGroups.map((candidate) => candidate.id).join(', ')}.\n`
);
}
/**
* Fails the build when the module's declared capabilities and this page's list disagree.
*
@@ -170,3 +472,32 @@ export function assertCapabilityCoverage(declared) {
`\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n`
);
}
/**
* Fails the build when a capability has no `detail`.
*
* The homepage renders labels, so a capability added with nothing else still looks correct
* there — and `/features/` would silently render a heading with no argument under it. The
* asymmetry between the two renderings is the whole of D20, and this is what keeps the
* thinner one from being the only one anybody notices.
*
* Called from `/features/` for the same reason `assertCapabilityCoverage` is called from
* the homepage: the build error should name the page that would have shipped wrong.
*/
export function assertDetailCoverage() {
const missing = [];
for (const group of capabilityGroups) {
for (const item of group.items) {
if (!item.detail?.trim()) missing.push(`${group.title}${item.label}`);
}
}
if (!missing.length) return;
throw new Error(
`src/data/capabilities.mjs has ${missing.length} capabilit${missing.length === 1 ? 'y' : 'ies'} with no detail:\n` +
missing.map((entry) => ` - ${entry}`).join('\n') +
`\n\n/features/ renders the detail line (D20). A capability without one is a heading\n` +
`with nothing under it — write the sentence, or take the capability off the list.\n`
);
}

165
src/data/notBuilt.mjs Normal file
View File

@@ -0,0 +1,165 @@
/**
* notBuilt.mjs — the deliberate absences of PLAN.md §2, as data (D22).
*
* ---------------------------------------------------------------------------------------
* WHY THIS IS A LIST AND NOT A PARAGRAPH
* ---------------------------------------------------------------------------------------
* §2 calls its absent-features list "as load-bearing as the rest", and the homepage already
* promises a reader they will find it on both `/features/` and `/integrations/`. Two pages
* each writing their own version of "what we did not build" is how the inconvenient half
* quietly stops being mentioned on one of them — the same failure `capabilities.mjs` exists
* to prevent, pointed the other way.
*
* So: one list, tagged with the pages that show it. `/modules/` reads it too, because the
* three absences a module author most needs to know about are all here.
*
* ---------------------------------------------------------------------------------------
* THE RULE FOR ADDING ONE
* ---------------------------------------------------------------------------------------
* An entry belongs here when a reasonable reader would assume the thing exists. That is a
* higher bar than "we have not built it" — the site is not an inventory of everything
* absent from it — and a lower bar than "someone asked for it". Matrix is here because the
* original brief for this site listed it as a feature; the installer's missing platforms
* are here because every other tool in the world ships a macOS build.
*
* Each entry says what it is, and then why not. The "why not" is the point: an absence with
* a reason reads as a decision, and an absence without one reads as a gap. Where the
* reasoning was written down somewhere in the open, the entry links to it on a BRANCH path
* — `scripts/checkLinks.mjs` fails a commit permalink, because a permalink is a fact frozen
* at a sha while the document keeps moving.
*
* `resolvedBy` is not decoration. D8 gives the Integration Kit's draft status a defined
* removal condition, and stating the exit condition on the others too is what stops this
* file becoming a list of permanent apologies.
*/
const GITEA = 'https://gitea.whitlocktech.com/RunicGateway';
/**
* `scope` — which pages render the entry.
*
* `features` /features/, under the capability groups
* `integrations` /integrations/, under the integrations that do exist
* `modules` /modules/, where a module author is deciding whether to start
*
* Typed rather than inferred, for the same reason `capabilities.mjs` is: `link` is present
* on four entries out of six, and an inferred union makes `entry.link` unreadable on the
* page that renders all of them.
*
* @typedef {object} Absence
* @property {string} id
* @property {string[]} scope
* @property {string} title
* @property {string} body
* @property {string} resolvedBy What would make this entry go away. Never optional.
* @property {{ href: string, label: string }} [link]
*
* @type {Absence[]}
*/
export const notBuilt = [
{
id: 'matrix',
scope: ['integrations'],
title: 'Matrix',
body:
'Researched properly and then declined. Matrix has no channel-with-overwrites, no ' +
'role object, no voice channel of its own — voice is an RTC session needing a media ' +
'server the homeserver does not ship — and no way to register a slash command. Of ' +
'the five things a shared chat interface would have to name, an honest Matrix ' +
'implementation could provide two. What came out of that work was a capability ' +
'contract rather than an integration.',
resolvedBy:
'Nothing planned. If the protocol grows the missing four, the contract is already ' +
'the shape a second platform would plug into.',
link: { href: `${GITEA}/docs/src/branch/main/website/TEAMS.md`, label: 'The research, in full' },
},
{
id: 'multi-module',
scope: ['features', 'integrations', 'modules'],
title: 'More than one game module at a time',
body:
'One active module per deployment. The database columns that would scope data to a ' +
'module exist and are populated, so the door is not nailed shut, but nothing ' +
'exercises them and no interface offers it. A community running two games runs two ' +
'deployments.',
resolvedBy:
'Someone needing it. The schema was shaped to keep it possible, which is a different ' +
'thing from planning it.',
},
{
id: 'second-module',
scope: ['integrations', 'modules'],
title: 'A second game module',
body:
'There is exactly one, and it is Ultima Online. A paper dry-run for a Rust module ' +
'exists and is deliberately unimplemented — it was written to test whether the ' +
'module contract generalises, not to ship. Until a second one exists, "any game" is ' +
'an argument about a shape rather than a demonstration.',
resolvedBy: 'The first module built for a game that is not Ultima Online.',
link: { href: `${GITEA}/docs/src/branch/main/modules/rust-dryrun.md`, label: 'The dry-run' },
},
{
id: 'integration-kit-draft',
scope: ['integrations', 'modules'],
title: 'A finished Integration Kit',
body:
'The kit that teaches you to put a different game on this platform describes itself ' +
'as a draft, and it is right to. It has four chapters, a working template and a CI ' +
'job that builds that template against a pinned core — but nobody outside this ' +
'project has yet followed it to a working module, which is the only test of a set of ' +
'instructions that counts.',
resolvedBy:
'Someone outside this project building a working module for a new game by following ' +
"it alone. That is the kit's own stated condition, not one invented here.",
link: { href: `${GITEA}/Integration-kit/src/branch/main/README.md`, label: 'The kit' },
},
{
id: 'installer-platforms',
scope: ['features'],
title: 'A macOS or Windows-on-ARM installer',
body:
'Linux and Windows, on x86-64, plus Linux on arm64. The missing builds are missing ' +
'on purpose: the installer runs on the machine the game server lives on, because the ' +
'game and the bridge have to share a host, and no game server anybody runs is on ' +
'either of those platforms.',
resolvedBy: 'A game server that runs there.',
link: { href: `${GITEA}/docs/src/branch/main/installer/INSTALL.md`, label: 'The operator guide' },
},
{
id: 'public-demo',
scope: ['features'],
title: 'A public demo you can click through',
body:
'Planned and out of scope today: a virtual machine running the whole stack including ' +
'a game server, with settings locked down and an hourly reset. Until it exists this ' +
'site does not link to one, and there is no screenshot here of something that is not ' +
'running somewhere.',
resolvedBy:
'The machine being stood up. The site is already built to gain it by way of one line ' +
'in a configuration file, rather than a rebuild.',
},
];
/** The entries a given page renders, in file order. */
export function notBuiltFor(scope) {
return notBuilt.filter((entry) => entry.scope.includes(scope));
}
/**
* Fails the build when a scope renders nothing.
*
* The homepage tells a reader in as many words that the absences are listed "on features
* and integrations". A tag typo, or an entry removed without checking who was showing it,
* turns that sentence into a promise the site does not keep — and an empty section is the
* one defect that looks deliberate, because a page with nothing under a heading reads as a
* page with nothing to admit.
*/
export function assertScopeNonEmpty(scope) {
if (notBuiltFor(scope).length) return;
throw new Error(
`src/data/notBuilt.mjs has no entry tagged "${scope}", but a page is rendering that scope.\n` +
`\nThe homepage promises this list appears on /features/ and /integrations/ (§2, D22).\n` +
`Tag an entry with "${scope}", or take the section off the page that asks for it.\n`
);
}

View File

@@ -0,0 +1,172 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import TwoHosts from '../components/architecture/TwoHosts.astro';
import Allowlist from '../components/architecture/Allowlist.astro';
import ModuleSeam from '../components/architecture/ModuleSeam.astro';
import platform from '../data/platform.json';
import { brand } from '../lib/brand.mjs';
/**
* `/architecture/` — PLAN.md §13 phase 4, built to D21.
*
* ---------------------------------------------------------------------------------------
* WHAT THIS PAGE IS FOR, AND WHAT IT DELIBERATELY IS NOT
* ---------------------------------------------------------------------------------------
* §10 gives it one audience: "a technical evaluator deciding whether to run it". That is a
* narrower job than "explain the system", and the narrowness is what keeps this page from
* becoming a worse copy of the Architecture section in the documentation, which phases 7
* and 8 write.
*
* So the page answers four questions an evaluator actually has, in the order they have
* them — what am I deploying, what leaves my server, what is core and what is a module,
* and what happens when a part of it dies — and it answers them with drawings and reasons.
* It carries no endpoint tables, no configuration keys, no schema and no event catalog.
* Those exist, they are canonical elsewhere, and a second copy here would be a copy that
* goes stale (§1). Every one of them is a link out.
*
* The three diagrams are §11's motif doing actual work rather than decoration: each one
* draws a boundary, and the boundary is the argument in all three cases. The vocabulary
* they share lives in `src/styles/diagram.css`.
*
* ---------------------------------------------------------------------------------------
* LINKS OUT GO TO `/docs/`, NOT TO A GUESSED SLUG
* ---------------------------------------------------------------------------------------
* The same convention phase 3 set for the homepage: phases 7 and 8 own the documentation
* slugs, so linking `/docs/architecture/the-bridge/` today would put a URL in this file
* that nothing checks and a later phase would have to remember to fix. Links into the
* repositories are different — those are real paths that exist now, and `checkLinks.mjs`
* holds them to a branch path rather than a commit permalink.
*/
const title = 'Architecture';
const description =
'How Runic Gateway is put together: what you deploy, what crosses the network, and where ' +
'the game-specific half stops.';
const docs = `${platform.gitea.base}/${platform.gitea.org}/docs/src/branch/main`;
---
<Base title={title} description={description}>
<PageHeader eyebrow="How it is built" title="The parts, and the lines between them">
<p>
Three boundaries decide almost everything about how this software behaves: the one
between your two machines, the one between what the public sees and what staff see, and
the one between the platform and the game. Each is drawn below, with the reasoning
rather than the reference.
</p>
<p>
Nothing here is a specification. Where a real one exists it is linked — the protocol,
the module contract and the operator guide are all documents in the open, and they are
the authority when this page and one of them disagree.
</p>
</PageHeader>
<TwoHosts />
<Allowlist />
<ModuleSeam />
<section class="page section deeper">
<div class="panel deeper__panel">
<p class="eyebrow">Going deeper</p>
<h2>The documents this page is a summary of</h2>
<p class="prose deeper__lede">
Everything above is an argument about shapes. These are the things that specify them,
and they are what a module author, an integrator or an operator should be reading.
</p>
<ul class="deeper__list">
<li>
<a href={`${docs}/link/INTEGRATION.md`} rel="noopener noreferrer">
The bridge protocol
</a>
<span
>What the game and the sidecar say to each other, and what the sidecar publishes.
Protocol {platform.protocol} today, and versioned so a mismatched pair is refused
rather than misread.</span
>
</li>
<li>
<a href={`${docs}/website/MODULE_API.md`} rel="noopener noreferrer">
The module contract
</a>
<span
>The normative interface between core and a module — currently
{platform.moduleApi}. This is the document that decides whether your module
loads.</span
>
</li>
<li>
<a href={`${docs}/installer/INSTALL.md`} rel="noopener noreferrer">
The operator guide
</a>
<span
>Setting the game side up end to end, including the failure modes and what each
step should look like when it worked.</span
>
</li>
<li>
<a href="/docs/">The documentation on this site</a>
<span
>The same ground as a guided path rather than a specification, starting from an
empty server.</span
>
</li>
</ul>
<div class="deeper__actions">
<a class="btn btn--primary" href="/docs/">Start the install guide</a>
<a class="btn btn--ghost" href="/modules/">How modules work</a>
<a class="btn btn--ghost" href={brand.giteaOrg} rel="noopener noreferrer">
Read the source
</a>
</div>
</div>
</section>
</Base>
<style>
.deeper__panel {
padding: clamp(1.5rem, 4vw, 2.75rem);
}
.deeper h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.5rem, 3vw, 2rem);
}
.deeper__lede {
margin: 0;
color: var(--muted);
}
.deeper__list {
margin: 1.75rem 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.deeper__list li {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-left: 0.9rem;
border-left: 2px solid var(--gold-deep);
}
.deeper__list span {
color: var(--dim);
font-size: 0.9rem;
}
.deeper__actions {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 2rem;
}
</style>

288
src/pages/community.astro Normal file
View File

@@ -0,0 +1,288 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import platform from '../data/platform.json';
import { brand } from '../lib/brand.mjs';
/**
* `/community/` — PLAN.md §10 and §14 N3, built in phase 4.
*
* ---------------------------------------------------------------------------------------
* WHY IT IS IN THIS PHASE AT ALL
* ---------------------------------------------------------------------------------------
* §13's phase table never assigned it one. §10 specifies the page and §14 N3 specifies its
* contents, and the header nav and footer have both linked it since phase 1 — so it was a
* page the site pointed at and no phase built. The org lead folded it into phase 4 on
* 2026-08-20 rather than leaving it to be discovered by the link checker (D23). It is a
* marketing page with no new machinery, so this is where it fits.
*
* ---------------------------------------------------------------------------------------
* THE HONEST SPLIT, WHICH IS THE WHOLE POINT OF THE PAGE
* ---------------------------------------------------------------------------------------
* §14 N3 is explicit: this page describes a split rather than a single channel, because the
* obvious sentence — "found a bug? open an issue" — is currently false. Gitea registration
* is disabled on this instance, so the code is publicly readable and nobody outside the org
* can file anything against it. Discord is therefore the front door in fact, not just in
* preference (D10), and saying so is cheaper for a reader than letting them find the
* sign-up page and its refusal.
*
* N3 also records that this page is written the same way whether or not registration is
* later reopened — only one sentence changes. That sentence is marked below, so whoever
* changes the Gitea configuration can find it without rereading the page.
*
* The security address comes from `brand.json` and appears nowhere in this file. D13
* publishes a personal address on the understanding that moving to a role address later is
* an edit to a mounted file, and `checkFacts.mjs` fails the build if an address is typed
* into any source file — including this one, which is the file most likely to want to.
*/
const title = 'Community';
const description =
'Where to ask, where the code is, and how to report a security problem.';
const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
---
<Base title={title} description={description}>
<PageHeader eyebrow="Getting in touch" title="Three doors, and which one to use">
<p>
This is a small project run by people with day jobs. There is no support desk and no
ticket queue, which is worth knowing before you choose where to put a question — one of
these channels answers in minutes and one of them may not answer at all.
</p>
</PageHeader>
<section class="page section chan">
<ul class="chan__grid">
<li class="panel chan__card chan__card--primary">
<div class="chan__head">
<h2>Discord</h2>
<span class="chip chip--live">The front door</span>
</div>
<p class="chan__lede">
Questions, bug reports, help getting an install working, and where the Android beta
is announced. No account with us to make, nothing to be approved for, and the
fastest way to reach somebody who has run this software.
</p>
<p class="chan__use">
<span class="chan__use-label">Use it for</span>
Anything you would otherwise open an issue for, and everything you would not.
</p>
<a class="btn btn--primary" href={brand.discordInvite} rel="noopener noreferrer">
Join the Discord
</a>
</li>
<li class="panel chan__card">
<div class="chan__head">
<h2>The code</h2>
<span class="chip">Read freely</span>
</div>
<p class="chan__lede">
Every repository is public and readable without signing in to anything — the
website, the bridge, the game plugin, the installer, the module, the app and all of
the documentation. Clone it, read it, run it.
</p>
<p class="chan__use">
<span class="chan__use-label">One caveat</span>
{/*
THE SENTENCE §14 N3 SAYS WILL CHANGE. If Gitea registration is reopened —
manual confirm, Turnstile, no repository creation by default — this becomes
"issues and pull requests are open to anyone with an account", and nothing else
on the page moves.
*/}
Registration on our Gitea is closed at the moment, so filing an issue needs an
account we would have to create for you. Ask on Discord and it will reach the same
place.
</p>
<a class="btn btn--ghost" href={gitea} rel="noopener noreferrer">Browse the source</a>
</li>
<li class="panel chan__card">
<div class="chan__head">
<h2>Security</h2>
<span class="chip chip--draft">Private</span>
</div>
<p class="chan__lede">
If you have found something that should not be discussed in a public channel, email
it. You will get a human, not a form, and there is no bounty programme to game —
just an acknowledgement and a fix.
</p>
<p class="chan__use">
<span class="chan__use-label">Use it for</span>
Anything that would let somebody reach a deployment, an account or a game server
they should not.
</p>
<a class="btn btn--ghost" href={`mailto:${brand.contactEmail}`}>{brand.contactEmail}</a>
</li>
</ul>
</section>
<section class="page section contrib">
<div class="panel contrib__panel">
<p class="eyebrow">Contributing</p>
<h2>What is useful, in order</h2>
<ol class="contrib__list">
<li>
<h3>Run it and say what broke</h3>
<p>
The install path is the priority of this whole project, and the most valuable
thing anyone outside it can do is walk it on a machine we have never seen and
report where it stopped making sense.
</p>
</li>
<li>
<h3>Build a module for another game</h3>
<p>
There is one module and it is Ultima Online, so the claim that this platform is
game-agnostic is currently an argument rather than a demonstration. The
<a href="/modules/">Integration Kit</a> exists to be followed by somebody outside
this project — and it stays marked draft until it has been.
</p>
</li>
<li>
<h3>Fix the documentation</h3>
<p>
Documentation is versioned alongside the code it describes and a change is not
finished until the docs match it. If something you read was wrong, that is a bug
of the same kind as any other.
</p>
</li>
</ol>
<p class="contrib__note">
All of it is free software under the GPL-3.0-or-later, and contributions carry one
house rule worth knowing before you start: work done with AI assistance has to say so
— a box on the pull request and a trailer on the commit. Undisclosed AI-generated
contributions get closed. Every repository's <code>CONTRIBUTING.md</code> has the
details.
</p>
</div>
</section>
</Base>
<style>
.chan__grid {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
}
.chan__card {
display: flex;
flex-direction: column;
padding: clamp(1.25rem, 3vw, 1.75rem);
}
/* The one channel that actually answers gets the portal edge — the live signal, used
here for the same reason it is used on a running shard. */
.chan__card--primary {
border-color: color-mix(in srgb, var(--portal) 40%, transparent);
}
.chan__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.85rem;
}
.chan__card h2 {
margin: 0;
font-size: 1.25rem;
}
.chan__lede {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
}
/* Takes the slack so the button sits at the foot of every card in the row. */
.chan__use {
flex: 1;
margin: 1rem 0 1.5rem;
color: var(--dim);
font-size: 0.9rem;
}
.chan__use-label {
display: block;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.chan__card .btn {
align-self: flex-start;
}
.contrib__panel {
padding: clamp(1.5rem, 4vw, 2.75rem);
}
.contrib h2 {
margin: 0 0 1.5rem;
font-size: clamp(1.5rem, 3vw, 2rem);
}
.contrib__list {
margin: 0;
padding: 0;
list-style: none;
counter-reset: item;
}
.contrib__list li {
position: relative;
padding-left: 3.25rem;
counter-increment: item;
}
.contrib__list li + li {
margin-top: 1.5rem;
}
.contrib__list li::before {
content: counter(item);
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;
}
.contrib__list h3 {
margin: 0.3rem 0 0.4rem;
font-size: 1.06rem;
}
.contrib__list p {
margin: 0;
max-width: var(--measure);
color: var(--muted);
}
.contrib__note {
margin: 2rem 0 0;
padding-top: 1.25rem;
border-top: 1px solid var(--line-soft);
max-width: var(--measure);
color: var(--dim);
font-size: 0.9rem;
}
</style>

223
src/pages/features.astro Normal file
View File

@@ -0,0 +1,223 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import platform from '../data/platform.json';
import {
capabilityGroups,
assertCapabilityCoverage,
assertDetailCoverage,
} from '../data/capabilities.mjs';
/**
* `/features/` — PLAN.md §13 phase 4, built to D20.
*
* ---------------------------------------------------------------------------------------
* THE SAME LIST THE HOMEPAGE HAS, WITH THE ARGUMENT ATTACHED
* ---------------------------------------------------------------------------------------
* D18 put all five groups on the homepage named only, and left the per-capability argument
* here. This page is therefore not a second list: it is the same `capabilities.mjs` data
* rendered with the `detail` line the homepage drops. That is the whole of D20, and it is
* what makes "the site advertises something that was removed" a build failure rather than
* a thing somebody has to notice.
*
* Both assertions below run at build time and both name this page in their message.
* `assertCapabilityCoverage` is the module contract the homepage also runs — repeated here
* deliberately, since either page can be built alone and each should fail on its own.
* `assertDetailCoverage` is this page's own: a capability with no detail renders as a
* heading with nothing under it, and nothing else in the repo would notice.
*
* ---------------------------------------------------------------------------------------
* THREE THINGS THE MARKUP SAYS THAT THE HOMEPAGE DOES NOT
* ---------------------------------------------------------------------------------------
* 1. WHERE A CAPABILITY COMES FROM. Every group states whether core supplies it or the
* installed module does. The homepage carries one chip on one group; here it is a full
* sentence on all five, because this is the page a reader arrives at wanting to know
* what they get on a deployment with no module at all.
*
* 2. WHAT NEEDS A MODULE TO FILL IT. Teams and Team forums are core machinery that cannot
* originate a Team — see the `needsModule` note in `capabilities.mjs` for what the tree
* actually says. That is neither "core" nor "module-supplied", and a page that offered
* only those two words would have to lie in one direction or the other (D24).
*
* 3. WHERE TO SEE IT RUNNING. Capabilities with a stable public route carry a deep link
* into the demo, hidden until a `demoUrl` is mounted (D25). The markup contract is
* exact and `scripts/checkBrand.mjs` enforces it:
*
* href="" data-demo-url="" data-demo-path="/uo/market"
*
* `applyBrand.mjs` recomputes all three attributes at boot. Do not reorder them, do not
* insert anything between them, and do not write a path into the `href` — the rewrite
* matches bytes, and a stock build hides every one of these links, so a mistake here is
* invisible until the day somebody configures a demo.
*/
assertCapabilityCoverage(platform.moduleUoCapabilities);
assertDetailCoverage();
const title = 'Features';
const description =
'What a Runic Gateway deployment does — core, and what the installed game module adds.';
---
<Base title={title} description={description}>
<PageHeader eyebrow="What you get" title="Everything the platform does">
<p>
Grouped the way the software is actually divided, because that division is the thing
most worth understanding before you install it: the core site is game-agnostic and does
not know what a shard is, and everything that does arrives as an <a href="/modules/"
>installable module</a
>.
</p>
<p>
Today there is one module and it covers Ultima Online, so the second group below is
what a UO deployment gets. On a deployment with no module, that group is simply absent
and the other four are unchanged.
</p>
</PageHeader>
{
capabilityGroups.map((group) => (
<section class="page section group" id={group.id}>
<div class="group__head">
<h2>{group.title}</h2>
<span class:list={['chip', group.moduleSupplied && 'chip--module']}>
{group.moduleSupplied ? 'From the installed module' : 'Core'}
</span>
</div>
<p class="prose group__summary">{group.summary}</p>
<ul class="group__items">
{group.items.map((item) => (
<li class="panel group__item">
<div class="group__item-head">
<h3>{item.label}</h3>
{item.needsModule && <span class="chip chip--needs">Needs a module</span>}
</div>
<p class="group__detail">{item.detail}</p>
{item.demoPath && (
<a
class="demo-link"
href="" data-demo-url="" data-demo-path={item.demoPath}
rel="noopener noreferrer"
>
See it running
</a>
)}
</li>
))}
</ul>
</section>
))
}
<NotBuilt scope="features" title="Things a reader could reasonably expect, that are not here" />
</Base>
<style>
.group__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.75rem;
}
.group h2 {
margin: 0;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.group__summary {
margin: 0.85rem 0 0;
color: var(--muted);
}
.group__items {
display: grid;
gap: 1rem;
margin: 1.75rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
}
.group__item {
display: flex;
flex-direction: column;
}
/* The chip is taller than the heading's line box, so a card that has one starts its
body a few pixels lower than the card beside it. Reserving the chip's height on
every head lines the row up whether or not the marker is there. */
.group__item-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
min-height: 1.75rem;
margin-bottom: 0.6rem;
}
.group__item h3 {
margin: 0;
color: var(--gold);
font-size: 1.04rem;
}
.group__detail {
flex: 1;
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
/* The module-supplied chip takes the portal colour rather than gold: it is the same
distinction the data-path diagram draws in cyan on the homepage — the parts that
know about a game — and using one colour for one idea across the site is cheaper
for a reader than two decorative ones. */
.chip--module {
border-color: color-mix(in srgb, var(--portal) 45%, transparent);
color: var(--portal);
}
/* Not a warning. It says which of the two halves supplies the thing, on the two
capabilities where the answer is "both" — core builds it, a module fills it. */
.chip--needs {
border-color: color-mix(in srgb, var(--portal) 30%, transparent);
color: var(--dim);
font-size: 0.72rem;
}
/* Set as a link rather than a `.btn`: there is one of these per capability and a row
of buttons inside a card grid would read as the primary action of the page, which
it is not — the primary action is reading the list. `.demo-cta` in global.css stays
the button treatment, for the homepage's single slot.
It is the flex item itself rather than a paragraph wrapping one, so that
`global.css`'s `[data-demo-url=''] { display: none }` takes the margin away with
it. A wrapper would survive its hidden child and leave a 1rem gap at the foot of
every card in a stock build — and hiding the wrapper with `:has()` would have put a
second `data-demo-url` in the file, which `checkBrand.mjs` reads as a demo slot
written outside its contract. The check is right to: it cannot tell a selector from
an attribute, and it should not have to guess. */
.demo-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: 1rem;
color: var(--portal);
font-size: 0.88rem;
text-decoration-color: color-mix(in srgb, var(--portal) 40%, transparent);
}
.demo-link:hover {
color: var(--portal-bright);
}
.demo-link::after {
content: '\2197'; /* north-east arrow: this leaves the site */
}
</style>

View File

@@ -0,0 +1,301 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import platform from '../data/platform.json';
/**
* `/integrations/` — PLAN.md §13 phase 4.
*
* §10 gives it Discord, mobile and push, SSO, "with an explicit 'not built' list". The
* explicit list is the reason this page is worth writing carefully: an integrations page is
* the one a reader scans for the name of the thing they already use, and the honest answer
* for several of those names is no. §2 calls the absent-features list as load-bearing as the
* rest, and `NotBuilt` at the foot of this page is where that lands.
*
* ---------------------------------------------------------------------------------------
* THE TRADE-OFFS ARE ON THE PAGE
* ---------------------------------------------------------------------------------------
* Each integration carries a `caveat` — the thing you would find out in week two. Discord
* voice channels make Team membership visible on a member's Discord profile, because they
* are granted by role; the mobile app has no server of ours to point at; SSO will not create
* an account. None of those is a defect and all three change whether someone wants the
* feature, so leaving them for the documentation would be the dishonest kind of brevity.
* That is D8's "understated honesty" doing something other than adjusting adjectives.
*
* No version numbers are typed here. The app version and the platform's own numbers come
* from `platform.json` (§12), which `checkFacts.mjs` re-reads from each repository's
* authority on every build.
*/
const title = 'Integrations';
const description =
'What Runic Gateway connects to — Discord, mobile push, single sign-on — and what it ' +
'deliberately does not.';
const integrations = [
{
id: 'discord',
name: 'Discord',
summary:
'A bot for the server your community is already sitting in, doing three separate jobs.',
points: [
{
title: 'Slash commands',
body:
'Commands registered with your guild that answer from your site — so the thing ' +
'somebody wants to look up is available where the conversation is happening, ' +
'rather than one tab away.',
},
{
title: 'Notifications into channels',
body:
'News and Team activity bridged into the channels you choose, with a per-Team ' +
'override so one group can route its own notifications somewhere else. Delivery ' +
'is best-effort and one-shot: a Discord outage never backs anything up on your ' +
'site.',
},
{
title: 'A voice channel per Team',
body:
'A Team can be granted its own voice channel, with membership maintained by the ' +
'bot rather than by whoever is online. The bot creates the category, and the ' +
'panel reports how many roles your guild has left before Discords own limit.',
},
],
caveat:
'Voice access is granted with a Discord role, and roles are visible on a members ' +
'profile — so a Team with a voice channel is a Team anyone in your guild can see the ' +
'membership of. That was a deliberate trade for a limit that counts per guild rather ' +
'than per channel, and it is the right one for most communities, but it is not private.',
},
{
id: 'mobile',
name: 'Mobile and push',
summary:
'A native Android app against the same documented API the website uses, with push ' +
'through a server you run.',
points: [
{
title: 'The same API, not a second one',
body:
'The app is a client of the API your deployment already publishes, authenticated ' +
'with short-lived tokens and rotated, revocable refresh tokens. There is no ' +
'mobile-only backend to keep in step.',
},
{
title: 'Push through your own ntfy',
body:
'Notifications are delivered by a self-hosted ntfy server rather than a vendor in ' +
'the middle. Each person chooses which streams reach them; push arrives by ' +
'default and can be switched off entirely.',
},
{
title: 'Trusted devices and two-factor',
body:
'The app shares the sites account model, including time-based two-factor ' +
'codes, recovery codes, and devices you can mark as trusted and revoke later.',
},
],
caveat:
'The app points at no server of ours: the person installing it types the address of ' +
'the deployment they belong to. That is what makes one app work for every community ' +
'running this software, and it means the app is useless until somebody gives them a ' +
'URL — which is a thing worth putting in your welcome message.',
},
{
id: 'sso',
name: 'Single sign-on',
summary:
'OAuth2 and OIDC, against Google, Discord, or any provider you already run.',
points: [
{
title: 'Any OIDC provider',
body:
'Google and Discord are configured by name; anything else that speaks OIDC is ' +
'configured generically. Client secrets are encrypted at rest and never returned ' +
'to any client.',
},
{
title: 'It signs people in, not up',
body:
'An external identity has to be linked to an account that already exists on your ' +
'site. Signing in with a provider never creates a user — which means the way ' +
'someone joins your community stays a decision you make, not one Google makes.',
},
{
title: 'It respects the rest of the login rules',
body:
'Two-factor, trusted devices and bans all still apply. An identity provider ' +
'proves who someone is; it does not decide whether they may come in.',
},
],
caveat:
'Link-only is a policy, not a limitation to be worked around. If you were expecting ' +
'to open registration by turning on Google sign-in, this will not do that, and it is ' +
'not configurable.',
},
];
---
<Base title={title} description={description}>
<PageHeader eyebrow="What it connects to" title="The things it talks to, and the things it does not">
<p>
Three integrations exist and are in use. Each one below says what it does, and then the
thing you would otherwise discover in week two — because an integrations page that only
lists the good half is how somebody ends up rebuilding their community around an
assumption.
</p>
<p>
Everything here is configured on your own deployment, against services you already run
or already have an account with. Nothing routes through us; there is no us to route
through.
</p>
</PageHeader>
{
integrations.map((integration) => (
<section class="page section integ" id={integration.id}>
<h2>{integration.name}</h2>
<p class="prose integ__summary">{integration.summary}</p>
<ul class="integ__grid">
{integration.points.map((point) => (
<li class="panel">
<h3>{point.title}</h3>
<p>{point.body}</p>
</li>
))}
</ul>
<div class="panel integ__caveat">
<p class="integ__caveat-label">Worth knowing first</p>
<p>{integration.caveat}</p>
</div>
</section>
))
}
<section class="page section integ" id="email">
<h2>Email, deliberately quiet</h2>
<p class="prose integ__summary">
Your deployment can send email — Team notifications and newsletters, through an account
you connect — and it only ever sends to someone who asked for it. Email is the one
channel that is opt-in rather than opt-out, because an unwanted push notification is an
annoyance and an unwanted email is a complaint to somebodys provider.
</p>
<p class="prose integ__note">
This website is a separate matter: <em>runicgateway.com</em> sends no email at all, has
no mailbox behind it and no account to make. The address in the footer is a human being.
</p>
</section>
<NotBuilt scope="integrations" title="Integrations that do not exist" />
<section class="page section integ" id="build">
<div class="panel integ__build">
<p class="eyebrow">If you need another one</p>
<h2>The API is the integration point</h2>
<p class="prose">
The whole backend is described by an OpenAPI 3.0 specification that ships with the
server, and an installed module merges its own routes into it — so whatever you build
against is documented by the thing that is actually running, at
{' '}Module API {platform.moduleApi}. The bridge to a game server is a documented wire
protocol on the same principle, currently protocol {platform.protocol}.
</p>
<div class="integ__actions">
<a class="btn btn--primary" href="/modules/">How modules work</a>
<a class="btn btn--ghost" href="/architecture/">The architecture</a>
<a class="btn btn--ghost" href="/docs/">The documentation</a>
</div>
</div>
</section>
</Base>
<style>
.integ h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.integ__summary {
margin: 0;
color: var(--muted);
}
.integ__note {
margin: 0.85rem 0 0;
color: var(--dim);
font-size: 0.94rem;
}
.integ__grid {
display: grid;
gap: 1rem;
margin: 2rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
.integ__grid h3 {
margin: 0 0 0.5rem;
color: var(--gold);
font-size: 1.02rem;
}
.integ__grid p {
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
/* The caveat is a panel like the others rather than a warning box. It is information
of the same kind and the same weight — the difference is that it is the half a
reader is not expecting, which is a reason to give it its own line, not a reason
to make it look like an error message. */
.integ__caveat {
margin-top: 1rem;
border-left: 3px solid var(--gold-deep);
}
.integ__caveat p {
margin: 0;
max-width: var(--measure);
color: var(--muted);
font-size: 0.94rem;
}
.integ__caveat-label {
color: var(--gold);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.integ__caveat .integ__caveat-label {
margin-bottom: 0.5rem;
}
.integ__build {
padding: clamp(1.5rem, 4vw, 2.75rem);
}
.integ__build h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.5rem, 3vw, 2rem);
}
.integ__build .prose {
margin: 0;
color: var(--muted);
}
.integ__actions {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 1.75rem;
}
</style>

404
src/pages/modules.astro Normal file
View File

@@ -0,0 +1,404 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import platform from '../data/platform.json';
import { capabilityGroup } from '../data/capabilities.mjs';
/**
* `/modules/` — PLAN.md §13 phase 4.
*
* §10 gives this page four jobs: what a module is, `module-uo` as the worked example,
* writing your own, and the Integration Kit with its draft badge (D8). They are in that
* order because they are increasing commitment — a reader deciding whether to install one,
* a reader wondering what they get, a reader considering building one.
*
* ---------------------------------------------------------------------------------------
* THE WORKED EXAMPLE READS ITS OWN CAPABILITIES
* ---------------------------------------------------------------------------------------
* The `module-uo` section lists what the module publishes, and it takes that list from
* `capabilities.mjs` rather than retyping it — the same list the homepage names and
* `/features/` expands, which is already checked against the module's own manifest through
* `platform.json` (§12). A third hand-maintained copy on this page is exactly the failure
* that machinery exists to prevent, and this is the page where it would be least visible.
*
* ---------------------------------------------------------------------------------------
* THE DRAFT CHIP IS A DECISION, NOT A DISCLAIMER
* ---------------------------------------------------------------------------------------
* D8 marks the Integration Kit draft until a second module is successfully built against
* it by somebody outside this project, and requires that status to carry its removal
* condition. Both are here: the chip, and the sentence that says what takes it down. The
* same absence appears in `notBuilt.mjs`, so a reader who scrolls past the chip meets it
* again in the list of things that do not exist.
*/
const title = 'Modules';
const description =
'What a module is, what the Ultima Online module publishes, and what it takes to write ' +
'one for another game.';
const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
const docs = `${gitea}/docs/src/branch/main`;
const gameIntelligence = capabilityGroup('game-intelligence');
/** The three ways a module reaches a running deployment. None of them is a build. */
const installPaths = [
{
name: 'From the admin panel',
body:
'Paste the URL of a release manifest into Admin → Modules and press restart when it ' +
'asks. The site downloads the artifact, verifies the checksum the manifest declares, ' +
'inspects the whole archive before writing a single file, and unpacks it.',
fits: 'The click path, for a host you have no shell on.',
},
{
name: 'From your environment',
body:
'Name the module and its version in one environment variable and the container ' +
'resolves it at every start. Already at that version means no network call at all, so ' +
'a restart with the internet down comes up unchanged.',
fits: 'A compose-managed host, where the running set should be a line you version-control.',
},
{
name: 'By hand',
body:
'Unpack the tarball into the modules directory and restart. The bundle is already ' +
'assembled — the client half is prebuilt and its one runtime dependency ships inside.',
fits: 'Development, and any host where the other two do not fit.',
},
];
---
<Base title={title} description={description}>
<PageHeader eyebrow="The extension model" title="One platform, whichever game you run">
<p>
A module is the entire game-specific half of a deployment, packaged: its routes, its
screens, its database tables, its navigation rows and its slice of the API
documentation. The core site holds accounts, Teams, the wiki, posts, moderation and the
admin panel, and knows nothing about any game at all.
</p>
<p>
That division is not an aspiration bolted on afterwards. The Ultima Online support was
extracted out of the site into a module, and every URL it had before the move it still
has — which is the only version of this claim worth making.
</p>
</PageHeader>
<section class="page section mod" id="what">
<p class="eyebrow">What you get</p>
<h2>What installing one actually does</h2>
<ul class="mod__grid">
<li class="panel">
<h3>It brings its own everything</h3>
<p>
Server routes, React screens, tables, nav rows and an OpenAPI fragment the site
merges into its own spec. Nothing about it is a patch to the core site, so
upgrading either half does not involve reconciling the other.
</p>
</li>
<li class="panel">
<h3>You never build it</h3>
<p>
The client half ships prebuilt and the artifact is verified against a published
checksum before anything is written to disk. Production runs an image you pulled;
an operator who has to compile something has been handed a maintenance job.
</p>
</li>
<li class="panel">
<h3>It cannot take the site down</h3>
<p>
A module whose declared interface version does not match is marked failed and the
site starts without it — loudly, rather than half-loading. Disabling one closes its
connections and stops its routes answering.
</p>
</li>
<li class="panel">
<h3>Your data outlives it</h3>
<p>
Uninstalling removes the module and keeps its tables, so reinstalling picks up
exactly where it was. Destroying the data is a separate, opt-in choice made in its
own dialog, and it says what it is about to do.
</p>
</li>
</ul>
</section>
<section class="page section mod" id="installing">
<p class="eyebrow">Installing</p>
<h2>Three ways in, and none of them is a build</h2>
<p class="prose mod__lede">
Which one you use is a question about your host, not about the module. All three end
the same way: a restart, and the module's screens appear in the navigation.
</p>
<ol class="mod__paths">
{
installPaths.map((path) => (
<li class="panel">
<h3>{path.name}</h3>
<p>{path.body}</p>
<p class="mod__fits">{path.fits}</p>
</li>
))
}
</ol>
</section>
<section class="page section mod" id="module-uo">
<div class="mod__head">
<p class="eyebrow">The worked example</p>
<h2>module-uo</h2>
<span class="chip chip--version">{platform.releases['Module-uo']}</span>
</div>
<p class="prose mod__lede">
The Ultima Online module, and the reference every module that follows is measured
against. It is what turns a general-purpose community site into something that knows
what a shard is — and it is the proof that the seam described on
<a href="/architecture/">the architecture page</a> is real, because the code on the far
side of it was moved there rather than designed there.
</p>
<div class="mod__example">
<div class="panel mod__caps">
<h3>What it publishes</h3>
<ul>
{gameIntelligence.items.map((item) => <li>{item.label}</li>)}
</ul>
<p class="mod__caps-note">
The same list <a href="/features/">features</a> expands, read from one file that is
checked against the module's own manifest on every build.
</p>
</div>
<div class="mod__facts">
<section>
<h3>It connects to a real server</h3>
<p>
The module talks to the sidecar beside your game server, not to the game. You
deploy that side with the installer and paste four values into the admin panel;
nothing here requires the game to exist, and with no server configured the site
renders normally and shows it offline.
</p>
</section>
<section>
<h3>It owns its own tables</h3>
<p>
Its schema is applied by the site on every boot and its data is its own. The
module declares which versions of the core interface it speaks — the site runs
{' '}{platform.moduleApi} — and refuses to load against one it does not.
</p>
</section>
<section>
<h3>It is a separate release</h3>
<p>
Versioned, tagged and published on its own cadence, independently of the site.
Upgrading one does not mean upgrading the other, as long as the declared interface
range still holds.
</p>
</section>
</div>
</div>
</section>
<section class="page section mod" id="writing">
<div class="mod__head">
<p class="eyebrow">Writing your own</p>
<h2>The Integration Kit</h2>
<span class="chip chip--draft">Draft</span>
</div>
<p class="prose mod__lede">
A four-chapter book on putting a different game on this platform — the module, the
sidecar beside your game server, the plugin inside it — plus a template module that
continuous integration builds against a pinned version of the core site, so the
instructions cannot quietly stop working.
</p>
<div class="panel mod__draft">
<h3>Why it says draft</h3>
<p>
Because nobody outside this project has yet followed it to a working module, and that
is the only test of a set of instructions that counts. The badge comes off when
somebody does — that is the stated condition, not a mood, and it is written down so a
future reader knows when to take it down.
</p>
<p>
Everything it teaches is real and in use. What is untested is whether it is
<em>sufficient</em>: whether someone with no access to this project's context can get
from an empty repository to a running module using it alone.
</p>
</div>
<div class="mod__links">
<a class="btn btn--primary" href={`${gitea}/Integration-kit`} rel="noopener noreferrer">
Read the Integration Kit
</a>
<a class="btn btn--ghost" href={`${docs}/website/MODULE_API.md`} rel="noopener noreferrer">
The module contract
</a>
<a class="btn btn--ghost" href={`${docs}/modules/uo/README.md`} rel="noopener noreferrer">
module-uo in depth
</a>
</div>
</section>
<NotBuilt scope="modules" title="What the module system does not do" />
</Base>
<style>
.mod h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.mod__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.75rem;
}
.mod__head .eyebrow {
flex-basis: 100%;
margin-bottom: 0;
}
.mod__head h2 {
margin: 0;
}
.mod__lede {
margin: 0.85rem 0 0;
color: var(--muted);
}
.mod__grid,
.mod__paths {
display: grid;
gap: 1rem;
margin: 2rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
.mod__grid h3,
.mod__paths h3,
.mod__caps h3,
.mod__facts h3,
.mod__draft h3 {
margin: 0 0 0.5rem;
color: var(--gold);
font-size: 1.02rem;
}
.mod__grid p,
.mod__paths p {
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
.mod__paths li {
display: flex;
flex-direction: column;
}
/* Which host each path suits, set apart from what it does — a reader is choosing
between three, so the distinguishing line should not be buried in the paragraph. */
.mod__fits {
margin-top: auto;
padding-top: 0.85rem;
color: var(--dim);
font-size: 0.88rem;
font-style: italic;
}
.mod__example {
display: grid;
gap: 1rem;
margin-top: 2rem;
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
align-items: start;
}
.mod__caps ul {
margin: 0;
padding: 0;
list-style: none;
font-size: 0.94rem;
}
.mod__caps li {
position: relative;
padding-left: 1.1rem;
color: var(--text);
}
.mod__caps li + li {
margin-top: 0.3rem;
}
/* The same drawn marker the homepage's capability lists use, so a reader who has
seen this list once recognises it as the same list. */
.mod__caps 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;
}
.mod__caps-note {
margin: 1rem 0 0;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.85rem;
}
.mod__facts section + section {
margin-top: 1.4rem;
}
.mod__facts p {
margin: 0;
max-width: var(--measure);
color: var(--muted);
}
.mod__draft {
margin-top: 2rem;
}
.mod__draft p {
margin: 0;
max-width: var(--measure);
color: var(--muted);
font-size: 0.94rem;
}
.mod__draft p + p {
margin-top: 0.85rem;
}
.mod__links {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 1.75rem;
}
@media (max-width: 860px) {
.mod__example {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

194
src/styles/diagram.css Normal file
View File

@@ -0,0 +1,194 @@
/* ============================================================================
The diagram vocabulary
============================================================================
§11 makes hand-drawn SVG the site's motif, "used where it explains something".
Phase 3 drew the first one on the homepage; phase 4 drew three more on
`/architecture/`, at which point the same fifteen rules existed in four files.
Two things live here and nothing else does:
1. The SVG vocabulary — what a node, a spine, an arrow and the boundary look
like. Shared by name, so a diagram is markup and the drawing is one
decision. `DataPath.astro` reads these too; it keeps its own layout,
because its right-hand column is a numbered walk rather than notes.
2. The `.diagram` layout — figure beside prose on a wide screen, figure
above prose on a narrow one.
Every colour is a class rather than a presentation attribute, and that is not
a style preference: `var()` is only substituted in style declarations, so
`fill="var(--line)"` on an element parses and draws nothing at all. It is also
what keeps `checkTokens.mjs` green, since no literal ever reaches the markup.
The two rules every diagram here follows, learned in phase 3:
- An inline SVG cannot reflow. A tall, ~380px-wide viewBox with only node
titles inside it is legible on a phone AND useful at 1440px; a wide
horizontal diagram is neither.
- The picture is `aria-hidden` because the prose beside it says the same
thing better. The consequence is a rule: a diagram must never carry a fact
the prose does not.
-------------------------------------------------------------------------- */
/* ---- Layout ------------------------------------------------------------- */
.diagram__head h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.diagram__head .prose {
margin: 0;
color: var(--muted);
}
.diagram__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;
}
.diagram__figure {
position: sticky;
top: calc(var(--header-h) + 1.5rem);
}
.diagram__caption {
margin: 1rem 0 0;
max-width: 380px;
color: var(--dim);
font-size: 0.85rem;
}
.diagram__notes section + section {
margin-top: 1.5rem;
}
.diagram__notes h3 {
margin: 0 0 0.4rem;
color: var(--gold);
font-size: 1.06rem;
}
.diagram__notes p {
margin: 0;
max-width: var(--measure);
color: var(--muted);
}
@media (max-width: 900px) {
.diagram__body {
grid-template-columns: minmax(0, 1fr);
}
/* Sticky is a wide-screen affordance only. Once the figure sits above the
prose rather than beside it, pinning it would cover the thing it explains. */
.diagram__figure {
position: static;
justify-self: center;
}
}
/* ---- The drawing -------------------------------------------------------- */
.flow {
display: block;
width: 100%;
max-width: 380px;
}
/* An outer grouping: a machine, a process boundary, a side of a contract. Sits
under the nodes it contains, so it reads as the thing they are inside. */
.host {
fill: var(--panel-flat);
stroke: var(--line-soft);
stroke-width: 1;
}
.host-title {
fill: var(--head);
font-family: var(--sans);
font-size: 16px;
font-weight: 600;
}
.host-sub {
fill: var(--dim);
font-family: var(--sans);
font-size: 11.5px;
}
.node {
fill: var(--panel-b);
stroke: var(--line);
stroke-width: 1;
}
/* The one node that is the reader's own site. Gold edge, because gold is
emphasis everywhere else on the site too. */
.node--self {
fill: var(--panel-a);
stroke: var(--gold-deep);
}
.node-title {
fill: var(--head);
font-family: var(--sans);
font-size: 15px;
font-weight: 600;
}
.node-sub {
fill: var(--dim);
font-family: var(--sans);
font-size: 11.5px;
}
.spine {
fill: none;
stroke: var(--gold-deep);
stroke-width: 2;
}
/* Cyan is the live signal everywhere on this site — the same colour the portal
in the emblem is, and the same one the homepage draws the event feed in. A
spine in this colour means data actually moving, not a relationship. */
.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;
}
/* The emblem's concentric rings, used as a ground behind the one place a
diagram's argument actually happens. */
.rings {
fill: none;
stroke: var(--gold-deep);
stroke-width: 1;
opacity: 0.16;
}

View File

@@ -8,6 +8,12 @@
@import '@fontsource-variable/cinzel';
@import '@fontsource-variable/inter';
/* The SVG diagram vocabulary and the figure-beside-prose layout, shared by the
homepage's data path and `/architecture/`'s three. Its own file because it is
a self-contained language rather than part of the shell — see its header for
the two rules every diagram on this site follows. */
@import './diagram.css';
*,
*::before,
*::after {
@@ -437,8 +443,25 @@ svg {
Written here, before phase 3 writes that markup, because the rule and the
rewrite have to agree and they live in different files. */
/* `!important`, and it is earning its keep rather than papering over something.
This selector is specificity 0,1,0. So is a class — including the scoped class an
Astro component puts on the very same element — and a component's styles are emitted
AFTER this file, so any component that gives one of these elements a `display` wins on
source order alone. Phase 4 did exactly that: `/features/`'s `.demo-link` set
`display: inline-flex` for its arrow, and twelve links to a demo that does not exist
appeared on the page, each one pointing at `href=""` — which a browser resolves to the
page it is already on.
Nothing caught it. checkBrand.mjs verifies the ATTRIBUTES, and they were perfect; the
defect was three files away in the cascade. It was found by looking at the rendered
page, which is not a mechanism.
So the rule is stated as one: while there is no demo, these elements do not render, and
no component style may overrule that by accident. A component that genuinely needs to
lay one of these out sets every property except `display`. */
[data-demo-url=''] {
display: none;
display: none !important;
}
/* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button