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

165
src/data/notBuilt.mjs Normal file
View File

@@ -0,0 +1,165 @@
/**
* notBuilt.mjs — the deliberate absences of PLAN.md §2, as data (D22).
*
* ---------------------------------------------------------------------------------------
* WHY THIS IS A LIST AND NOT A PARAGRAPH
* ---------------------------------------------------------------------------------------
* §2 calls its absent-features list "as load-bearing as the rest", and the homepage already
* promises a reader they will find it on both `/features/` and `/integrations/`. Two pages
* each writing their own version of "what we did not build" is how the inconvenient half
* quietly stops being mentioned on one of them — the same failure `capabilities.mjs` exists
* to prevent, pointed the other way.
*
* So: one list, tagged with the pages that show it. `/modules/` reads it too, because the
* three absences a module author most needs to know about are all here.
*
* ---------------------------------------------------------------------------------------
* THE RULE FOR ADDING ONE
* ---------------------------------------------------------------------------------------
* An entry belongs here when a reasonable reader would assume the thing exists. That is a
* higher bar than "we have not built it" — the site is not an inventory of everything
* absent from it — and a lower bar than "someone asked for it". Matrix is here because the
* original brief for this site listed it as a feature; the installer's missing platforms
* are here because every other tool in the world ships a macOS build.
*
* Each entry says what it is, and then why not. The "why not" is the point: an absence with
* a reason reads as a decision, and an absence without one reads as a gap. Where the
* reasoning was written down somewhere in the open, the entry links to it on a BRANCH path
* — `scripts/checkLinks.mjs` fails a commit permalink, because a permalink is a fact frozen
* at a sha while the document keeps moving.
*
* `resolvedBy` is not decoration. D8 gives the Integration Kit's draft status a defined
* removal condition, and stating the exit condition on the others too is what stops this
* file becoming a list of permanent apologies.
*/
const GITEA = 'https://gitea.whitlocktech.com/RunicGateway';
/**
* `scope` — which pages render the entry.
*
* `features` /features/, under the capability groups
* `integrations` /integrations/, under the integrations that do exist
* `modules` /modules/, where a module author is deciding whether to start
*
* Typed rather than inferred, for the same reason `capabilities.mjs` is: `link` is present
* on four entries out of six, and an inferred union makes `entry.link` unreadable on the
* page that renders all of them.
*
* @typedef {object} Absence
* @property {string} id
* @property {string[]} scope
* @property {string} title
* @property {string} body
* @property {string} resolvedBy What would make this entry go away. Never optional.
* @property {{ href: string, label: string }} [link]
*
* @type {Absence[]}
*/
export const notBuilt = [
{
id: 'matrix',
scope: ['integrations'],
title: 'Matrix',
body:
'Researched properly and then declined. Matrix has no channel-with-overwrites, no ' +
'role object, no voice channel of its own — voice is an RTC session needing a media ' +
'server the homeserver does not ship — and no way to register a slash command. Of ' +
'the five things a shared chat interface would have to name, an honest Matrix ' +
'implementation could provide two. What came out of that work was a capability ' +
'contract rather than an integration.',
resolvedBy:
'Nothing planned. If the protocol grows the missing four, the contract is already ' +
'the shape a second platform would plug into.',
link: { href: `${GITEA}/docs/src/branch/main/website/TEAMS.md`, label: 'The research, in full' },
},
{
id: 'multi-module',
scope: ['features', 'integrations', 'modules'],
title: 'More than one game module at a time',
body:
'One active module per deployment. The database columns that would scope data to a ' +
'module exist and are populated, so the door is not nailed shut, but nothing ' +
'exercises them and no interface offers it. A community running two games runs two ' +
'deployments.',
resolvedBy:
'Someone needing it. The schema was shaped to keep it possible, which is a different ' +
'thing from planning it.',
},
{
id: 'second-module',
scope: ['integrations', 'modules'],
title: 'A second game module',
body:
'There is exactly one, and it is Ultima Online. A paper dry-run for a Rust module ' +
'exists and is deliberately unimplemented — it was written to test whether the ' +
'module contract generalises, not to ship. Until a second one exists, "any game" is ' +
'an argument about a shape rather than a demonstration.',
resolvedBy: 'The first module built for a game that is not Ultima Online.',
link: { href: `${GITEA}/docs/src/branch/main/modules/rust-dryrun.md`, label: 'The dry-run' },
},
{
id: 'integration-kit-draft',
scope: ['integrations', 'modules'],
title: 'A finished Integration Kit',
body:
'The kit that teaches you to put a different game on this platform describes itself ' +
'as a draft, and it is right to. It has four chapters, a working template and a CI ' +
'job that builds that template against a pinned core — but nobody outside this ' +
'project has yet followed it to a working module, which is the only test of a set of ' +
'instructions that counts.',
resolvedBy:
'Someone outside this project building a working module for a new game by following ' +
"it alone. That is the kit's own stated condition, not one invented here.",
link: { href: `${GITEA}/Integration-kit/src/branch/main/README.md`, label: 'The kit' },
},
{
id: 'installer-platforms',
scope: ['features'],
title: 'A macOS or Windows-on-ARM installer',
body:
'Linux and Windows, on x86-64, plus Linux on arm64. The missing builds are missing ' +
'on purpose: the installer runs on the machine the game server lives on, because the ' +
'game and the bridge have to share a host, and no game server anybody runs is on ' +
'either of those platforms.',
resolvedBy: 'A game server that runs there.',
link: { href: `${GITEA}/docs/src/branch/main/installer/INSTALL.md`, label: 'The operator guide' },
},
{
id: 'public-demo',
scope: ['features'],
title: 'A public demo you can click through',
body:
'Planned and out of scope today: a virtual machine running the whole stack including ' +
'a game server, with settings locked down and an hourly reset. Until it exists this ' +
'site does not link to one, and there is no screenshot here of something that is not ' +
'running somewhere.',
resolvedBy:
'The machine being stood up. The site is already built to gain it by way of one line ' +
'in a configuration file, rather than a rebuild.',
},
];
/** The entries a given page renders, in file order. */
export function notBuiltFor(scope) {
return notBuilt.filter((entry) => entry.scope.includes(scope));
}
/**
* Fails the build when a scope renders nothing.
*
* The homepage tells a reader in as many words that the absences are listed "on features
* and integrations". A tag typo, or an entry removed without checking who was showing it,
* turns that sentence into a promise the site does not keep — and an empty section is the
* one defect that looks deliberate, because a page with nothing under a heading reads as a
* page with nothing to admit.
*/
export function assertScopeNonEmpty(scope) {
if (notBuiltFor(scope).length) return;
throw new Error(
`src/data/notBuilt.mjs has no entry tagged "${scope}", but a page is rendering that scope.\n` +
`\nThe homepage promises this list appears on /features/ and /integrations/ (§2, D22).\n` +
`Tag an entry with "${scope}", or take the section off the page that asks for it.\n`
);
}