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