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

@@ -4,10 +4,16 @@
* ---------------------------------------------------------------------------------------
* WHY THIS IS DATA AND NOT MARKUP
* ---------------------------------------------------------------------------------------
* The homepage names these groups, `/features/` (phase 4) expands them, and `/modules/`
* explains the core/module split they encode. Three pages listing the same capabilities in
* three hand-maintained lists is how a site ends up advertising something that was removed,
* which §1 forbids. One list, read by all three.
* The homepage names these groups, `/features/` expands them, and `/modules/` explains the
* core/module split they encode. Three pages listing the same capabilities in three
* hand-maintained lists is how a site ends up advertising something that was removed, which
* §1 forbids. One list, read by all three.
*
* Phase 4 added the `detail` line rather than writing `/features/` as prose (D20). The two
* pages are then one list rendered twice — `/` takes the label, `/features/` takes the
* label and the detail — and they cannot disagree about what exists, only about how much
* they say. `assertDetailCoverage()` below is what stops the next capability being added to
* the homepage without an argument to go with it.
*
* ---------------------------------------------------------------------------------------
* THE PART THAT IS A CHECK, NOT A LIST
@@ -27,11 +33,74 @@
* separate user-facing capabilities, and the marketplace draws on `market` and `cliloc`
* together (item names arrive as cliloc ids and are resolved against the shard's own
* string table). The check is coverage in both directions, not a bijection.
*
* ---------------------------------------------------------------------------------------
* `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE
* ---------------------------------------------------------------------------------------
* A group is `moduleSupplied` or it is not, and phase 3 shipped the Community group saying
* "everything here works on a deployment with no game module installed at all". Writing the
* `/features/` detail for Teams is what exposed that as false, and the tree says so plainly
* on `main`: `teams.module_id` is `NOT NULL`, there is no create route anywhere under
* `/api/v1/admin/teams`, and `teamSync` is gated on `teamProvider.providerModuleId()`.
*
* The truth is neither of the two states the file had. Core owns the whole Team machinery —
* the tables, the roster resolver, the forums, the notification streams, the Discord bridge,
* the voice channels, the activity feed and `/admin/teams` — and cannot *originate* a Team.
* They arrive from the installed module, which is exactly the point: core does not own the
* word for a Team, so `module-uo` calls them guilds and builds the pages, and a future
* module can call them something else on the same primitive.
*
* So `needsModule` marks an item that is core machinery a module has to populate. On a bare
* core it is present, correct and permanently empty. `/` renders the requalified group
* summary; `/features/` renders the marker and says why (D24).
*
* ---------------------------------------------------------------------------------------
* `demoPath` — DEEP LINKS THAT ONLY EXIST WHEN A DEMO DOES
* ---------------------------------------------------------------------------------------
* §15/D12 keeps the public demo out of scope while requiring the site to gain one by way of
* a line in a mounted `brand.json`. `brand.json`'s own comment promised `/features/` a
* "per-capability affordance" that had never been defined; D25 defines it as a deep link
* per capability that has a stable public route, appended to the mounted `demoUrl` by
* `applyBrand.mjs` at boot and hidden by the same `[data-demo-url='']` rule as the
* homepage's slot.
*
* Only some capabilities have one, and that asymmetry is honest rather than unfinished:
* character sheets are reachable only by the account they belong to, and a Team forum lives
* behind a Team id no static page can know. Paths are read from the real route tables —
* core's `client/src/App.jsx` and `module-uo`'s `client/src/entry.jsx` on `main` — never
* guessed. Note the module's public pages are namespaced under its own id, so a UO route is
* `/uo/…`; a deployment running a different module would deep-link somewhere else, which is
* why these sit beside the `caps` slugs on the module-supplied items.
*/
/**
* Community — core, game-agnostic. Everything here works on a deployment with no game
* module installed at all.
* The shape of a capability, written out because TypeScript otherwise infers it per group
* from whichever fields that group's items happen to use — and then `/features/` cannot
* read `demoPath` off an Administration item, because no Administration item has one.
* `astro check` catches that, correctly: the union of five literal shapes is not the shape
* the page is written against.
*
* @typedef {object} Capability
* @property {string} label What it is called, on every page that lists it.
* @property {string} detail The argument for it. `/features/` only; see D20.
* @property {string[]} [caps] Module capability slugs, on module-supplied items only.
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
*
* @typedef {object} CapabilityGroup
* @property {string} id
* @property {string} title
* @property {string} summary
* @property {boolean} moduleSupplied
* @property {Capability[]} items
*/
/**
* Community — core machinery. Everything here ships with the site itself and none of it
* knows what game you run; two of the six still need a module to put anything in them,
* which is what `needsModule` says.
*
* @type {CapabilityGroup}
*/
const community = {
id: 'community',
@@ -43,20 +112,66 @@ const community = {
*/
moduleSupplied: false,
title: 'Community',
summary: 'The site your players actually use, none of which knows what game you run.',
summary:
'The site your players actually use, none of which knows what game you run — though ' +
'Teams arrive from the installed module rather than being created here.',
items: [
{ label: 'Teams' },
{ label: 'Team forums' },
{ label: 'Notifications' },
{ label: 'Wiki' },
{ label: 'News and newsletter' },
{ label: 'Player self-service' },
{
label: 'Teams',
needsModule: true,
demoPath: '/uo/guilds',
detail:
'A roster, a leader, a private forum, its own notification streams and a Discord ' +
'voice channel, all hanging off one group. Core owns every part of that machinery ' +
'and deliberately cannot create a Team: they arrive from the installed module, ' +
'which is how a guild inside the game becomes a Team on the site — and why a ' +
'different game can call them something else without core learning a new word.',
},
{
label: 'Team forums',
needsModule: true,
detail:
'Announcements, discussion threads and replies, with an edit window, post ' +
'moderation, and abuse reports a member can raise without going through staff ' +
'first. Forums are an admin switch for the whole deployment, and image uploads ' +
'stay off until someone deliberately turns them on.',
},
{
label: 'Notifications',
detail:
'Web, push and email, chosen per stream by each person rather than per person by ' +
'you. Push arrives by default and can be switched off; email only ever arrives if ' +
'it was asked for.',
},
{
label: 'Wiki',
demoPath: '/wiki',
detail:
'For the things that outlive a news post — rules, guides, the lore nobody wants to ' +
'retype in chat. Written in the admin panel, published on the public site.',
},
{
label: 'News and newsletter',
demoPath: '/site/news',
detail:
'Four kinds of post — news, five-on-friday, newsletter issues and screenshots — ' +
'plus CMS pages and a page builder for everything that is not a post at all.',
},
{
label: 'Player self-service',
detail:
'An account area every signed-in person gets, whatever their role: their profile, ' +
'their linked game accounts, their own characters, their devices and sessions. ' +
'Staff are players too, so it is the same area for everyone.',
},
],
};
/**
* Game intelligence — module-supplied. The `caps` arrays are the contract with
* `platform.json`; see `assertCapabilityCoverage` below.
*
* @type {CapabilityGroup}
*/
const gameIntelligence = {
id: 'game-intelligence',
@@ -66,59 +181,225 @@ const gameIntelligence = {
'Supplied by the installed game module, not by the core site. Today that module is ' +
'module-uo, and this is what it publishes from a live shard.',
items: [
{ label: 'Live server status', caps: ['shard'] },
{ label: 'Economy and activity', caps: ['shard'] },
{ label: 'Character sheets', caps: ['shard'] },
{ label: 'Points and loyalty boards', caps: ['shard'] },
{ label: 'Player-vendor marketplace', caps: ['market', 'cliloc'] },
{ label: 'Houses and IDOC decay', caps: ['houses'] },
{ label: 'Spawn atlas', caps: ['atlas'] },
{ label: 'Champion boards', caps: ['champs'] },
{ label: 'Guilds', caps: ['guilds'] },
{ label: 'City governors', caps: ['governors'] },
{
label: 'Live server status',
caps: ['shard'],
demoPath: '/uo/shard',
detail:
'Whether the server is up, who is on it, and how long ago the site last heard from ' +
'it. When the game is down this page is the thing that says so — the site does not ' +
'go down with it.',
},
{
label: 'Economy and activity',
caps: ['shard'],
demoPath: '/uo/shard/activity',
detail:
'A live feed of what is happening in the world, and the economy underneath it. ' +
'Every event passes an allowlist before it can reach a public page; staff read a ' +
'second stream carrying the rest.',
},
{
label: 'Character sheets',
caps: ['shard'],
detail:
'Skills, stats and equipment, drawn from the live world rather than from a form ' +
'somebody filled in. Reachable by the account the character is linked to, and by ' +
'staff — not by the public.',
},
{
label: 'Points and loyalty boards',
caps: ['shard'],
demoPath: '/uo/leaderboards',
detail:
'The leaderboards the game already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday.',
},
{
label: 'Player-vendor marketplace',
caps: ['market', 'cliloc'],
demoPath: '/uo/market',
detail:
'Every player vendor on the server and what is on it, searchable without logging ' +
'in to the game. Item names arrive from the world as numeric ids and are resolved ' +
"against the game's own string table, so they read as names rather than numbers.",
},
{
label: 'Houses and IDOC decay',
caps: ['houses'],
demoPath: '/uo/houses',
detail:
'Who owns what and where it stands, including which houses are decaying — ' +
'published while it is still information rather than after it has become a rumour.',
},
{
label: 'Spawn atlas',
caps: ['atlas'],
demoPath: '/uo/atlas',
detail:
"A bestiary and spawn map built by reading your shard's own spawn tables, so it " +
"describes your server rather than someone else's idea of the game. Regions, " +
'landmarks and champion altars come with it.',
},
{
label: 'Champion boards',
caps: ['champs'],
demoPath: '/uo/champs',
detail: 'Which altars are running, how far along they are, and what turned up.',
},
{
label: 'Guilds',
caps: ['guilds'],
demoPath: '/uo/guilds',
detail:
'Guild rosters and standings, kept in step with the game. This is also what fills ' +
'the Teams primitive above: a guild in the world becomes a Team on the site, with ' +
"the forum, the notifications and the voice channel that core attaches to one.",
},
{
label: 'City governors',
caps: ['governors'],
demoPath: '/uo/governors',
detail: 'Who holds which city, and what they did with it.',
},
],
};
/** @type {CapabilityGroup} */
const administration = {
id: 'administration',
moduleSupplied: false,
title: 'Administration',
summary: 'Running the place, with a record of who did what.',
items: [
{ label: 'Roles and permissions' },
{ label: 'Moderation and appeals' },
{ label: 'Content reports' },
{ label: 'Append-only audit log' },
{ label: 'Bot scoring and IP bans' },
{ label: 'Module management' },
{ label: 'The game-server connection' },
{
label: 'Roles and permissions',
detail:
'Admin, moderator and player. Admin access is re-checked against the database on ' +
'every request rather than trusted from whatever the session was issued with, so ' +
'demoting someone takes effect on their next click and not at their next login.',
},
{
label: 'Moderation and appeals',
detail:
'Decisions carry a written reason, and the person on the receiving end has a ' +
'documented way to answer rather than a direct message to whoever is awake.',
},
{
label: 'Content reports',
detail:
'Anything a member writes can be reported by another member, into a staff queue ' +
'with the context attached.',
},
{
label: 'Append-only audit log',
detail:
'Staff actions are recorded, and nothing in the panel can edit or delete the ' +
'record afterwards. That is worth having on the day you need to prove what did ' +
'not happen.',
},
{
label: 'Bot scoring and IP bans',
detail:
'Login attempts are scored on behaviour rather than on a puzzle a real person has ' +
'to solve, and a bad enough score bans the address by itself. The panel is a read ' +
'view with an emergency unban, deliberately — it is not somewhere to tune a ' +
'threshold at three in the morning.',
},
{
label: 'Module management',
detail:
'Install, disable, uninstall and purge a module from the panel. Uninstalling keeps ' +
'the data and reinstalling picks it up where it was; deleting it is a separate, ' +
'opt-in choice.',
},
{
label: 'The game-server connection',
detail:
"The bridge's address, token and protocol version live in the panel rather than in " +
'an environment file, so connecting a server is not a redeploy. The token is ' +
'encrypted at rest and write-only in the API — it is never returned to any client, ' +
'including yours.',
},
],
};
/** @type {CapabilityGroup} */
const integration = {
id: 'integration',
moduleSupplied: false,
title: 'Integration',
summary: 'The seams that let other things reach in — and one game reach out.',
items: [
{ label: 'Modules' },
{ label: 'The sidecar bridge' },
{ label: 'Discord: slash commands, notifications, voice' },
{ label: 'Mobile and push' },
{ label: 'SSO over OAuth2 / OIDC' },
{
label: 'Modules',
detail:
'The whole game-specific half of a deployment is an installable module: routes, ' +
'screens, tables and nav rows, versioned against a declared core API. Installing ' +
'one is a paste in the admin panel or a line in your environment, never a build.',
},
{
label: 'The sidecar bridge',
detail:
'A small service beside the game server, speaking a versioned wire protocol to the ' +
'site and a loopback socket to the game. It is the only part of the bridge anything ' +
'can reach over a network, and the game never listens at all.',
},
{
label: 'Discord: slash commands, notifications, voice',
detail:
'A bot for the guild you already have. Commands answer from your site, ' +
'notifications bridge into channels, and a Team can be granted a voice channel ' +
'that maintains its own membership.',
},
{
label: 'Mobile and push',
detail:
'A native Android app against the same documented API the website uses, with push ' +
'delivered through your own ntfy server rather than a vendor in the middle.',
},
{
label: 'SSO over OAuth2 / OIDC',
detail:
'Google, Discord, or any OIDC provider you run. Link-only by policy: an external ' +
'identity has to be attached to an account that already exists, and signing in ' +
'with one never creates a user.',
},
],
};
/** @type {CapabilityGroup} */
const infrastructure = {
id: 'infrastructure',
moduleSupplied: false,
title: 'Infrastructure',
summary: 'How it runs, and who it answers to.',
items: [
{ label: 'Self-hosted, start to finish' },
{ label: 'Docker, with prebuilt pull-only images' },
{ label: 'Branding as data, not a rebuild' },
{ label: 'OpenAPI 3.0 for the whole API' },
{
label: 'Self-hosted, start to finish',
detail:
'There is no hosted tier and no account with us. Every part of this runs on ' +
'hardware you control, which is the only arrangement under which the rest of the ' +
'claims on this page mean anything.',
},
{
label: 'Docker, with prebuilt pull-only images',
detail:
'Compose up, compose down. Images are pulled rather than built, so nothing ' +
'compiles on your server and an upgrade is a pull and a restart.',
},
{
label: 'Branding as data, not a rebuild',
detail:
'Name, colours, logo and contact address are a mounted file. The same image runs ' +
'as any community — including this site, which is built the same way.',
},
{
label: 'OpenAPI 3.0 for the whole API',
detail:
'The spec ships with the server and an installed module merges its own routes into ' +
'it, so the API you build against is the API that is actually running.',
},
],
};
@@ -130,6 +411,27 @@ export const capabilityGroups = [
infrastructure,
];
/**
* One group by id, or a build failure naming the id that was asked for.
*
* `capabilityGroups.find(...)` returns `CapabilityGroup | undefined`, so every page that
* wants one group has to either handle an impossible undefined or assert past it — and the
* assertion is what would eventually ship a blank section after somebody renamed an id.
* Failing here instead means a renamed group is caught by the first page that reads it.
*
* @param {string} id
* @returns {CapabilityGroup}
*/
export function capabilityGroup(id) {
const group = capabilityGroups.find((candidate) => candidate.id === id);
if (group) return group;
throw new Error(
`src/data/capabilities.mjs has no group with id "${id}", but a page asked for it.\n` +
`Known ids: ${capabilityGroups.map((candidate) => candidate.id).join(', ')}.\n`
);
}
/**
* Fails the build when the module's declared capabilities and this page's list disagree.
*
@@ -170,3 +472,32 @@ export function assertCapabilityCoverage(declared) {
`\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n`
);
}
/**
* Fails the build when a capability has no `detail`.
*
* The homepage renders labels, so a capability added with nothing else still looks correct
* there — and `/features/` would silently render a heading with no argument under it. The
* asymmetry between the two renderings is the whole of D20, and this is what keeps the
* thinner one from being the only one anybody notices.
*
* Called from `/features/` for the same reason `assertCapabilityCoverage` is called from
* the homepage: the build error should name the page that would have shipped wrong.
*/
export function assertDetailCoverage() {
const missing = [];
for (const group of capabilityGroups) {
for (const item of group.items) {
if (!item.detail?.trim()) missing.push(`${group.title}${item.label}`);
}
}
if (!missing.length) return;
throw new Error(
`src/data/capabilities.mjs has ${missing.length} capabilit${missing.length === 1 ? 'y' : 'ies'} with no detail:\n` +
missing.map((entry) => ` - ${entry}`).join('\n') +
`\n\n/features/ renders the detail line (D20). A capability without one is a heading\n` +
`with nothing under it — write the sentence, or take the capability off the list.\n`
);
}