feat(marketing): phase 4 — the marketing pages
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
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:
172
src/pages/architecture.astro
Normal file
172
src/pages/architecture.astro
Normal 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
288
src/pages/community.astro
Normal 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
223
src/pages/features.astro
Normal 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>
|
||||
301
src/pages/integrations.astro
Normal file
301
src/pages/integrations.astro
Normal 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
404
src/pages/modules.astro
Normal 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>
|
||||
Reference in New Issue
Block a user