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,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 Discord’s own limit.',
},
],
caveat:
'Voice access is granted with a Discord role, and roles are visible on a member’s ' +
'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 site’s 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 somebody’s 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>