Files
runicgateway.com/src/pages/integrations.astro
wtclaude 2d19ee4220
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
feat(marketing): phase 4 — the marketing pages
PLAN.md §13 phase 4: /features/, /architecture/, /modules/, /integrations/, and
/community/ — plus the two scope items the phase table never assigned to anyone.

Six decisions taken by the org lead before coding, recorded in PLAN.md §10 as
D20-D25:

- D20 /features/ is the homepage's list with a `detail` line, not a second list.
  One data file, two renderings, so they cannot disagree about what exists.
- D21 /architecture/ draws reasons, not reference: three new inline SVGs, one per
  boundary. No endpoint tables, no config keys — those are phase 8's and stay
  canonical in docs/.
- D22 The deliberate absences of §2 become one tagged data file, rendered on the
  three pages that promise them.
- D23 Phase 4 absorbs /community/ (specified in §10 and §14 N3, linked from the
  header since phase 1, built by no phase) and checkLinks.mjs.
- D24 `needsModule`: writing the Teams detail exposed a false claim phase 3
  shipped. Teams are module-sourced only — teams.module_id is NOT NULL, there is
  no create route, sync is gated on providerModuleId() — so the Community group
  no longer says a bare core does all of it.
- D25 The per-capability demo affordance brand.json had promised since phase 2 is
  a deep link, filled at boot from data-demo-path.

checkLinks.mjs reads the built HTML rather than src/, because half these links
are assembled from data files and template literals. Its PLANNED_ROUTES list is
checked in both directions, so it cannot rot into a permanent exemption.

applyBrand.mjs gained a pass that recomputes deep links from their immutable
path, making it idempotent and reversible; checkBrand.mjs lifts that pattern out
and runs it against the stock markup so the two cannot drift. Both proved
against a real mount, in both directions.

Fixes a cascade bug the checks could not see: [data-demo-url=''] and a scoped
component class are both specificity 0,1,0, so .demo-link's `display` beat the
hide rule and twelve links to a nonexistent demo rendered, each resolving to the
current page. The rule is now !important.

The four diagrams' shared SVG vocabulary moved to src/styles/diagram.css.

Verified from a clean checkout: npm ci, all five checks, astro check (0 errors),
production build, and a live browser pass at desktop and 390px.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 01:53:11 -05:00

302 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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