Files
runicgateway.com/src/pages/modules.astro
wtclaude 2d19ee4220
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
feat(marketing): phase 4 — the marketing pages
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>
2026-08-24 01:53:11 -05:00

405 lines
13 KiB
Plaintext

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