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,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>