diff --git a/PLAN.md b/PLAN.md index 4722b3a..2c1494a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -519,14 +519,57 @@ Organised by what a reader is trying to do. A reader should never need to know t **Feature grouping**, using project terminology: - **Community** — Teams, Team forums, notifications, wiki, news and newsletter, player self-service -- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, player-vendor - marketplace, houses and IDOCs, character sheets, spawn atlas, champion boards, points leaderboards +- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, character + sheets, points and loyalty boards, player-vendor marketplace, houses and IDOCs, spawn atlas, + champion boards, guilds, city governors - **Administration** — roles, moderation and appeals, content reports, audit log, bot scoring and IP bans, module management, the shard connection - **Integration** — modules, the sidecar bridge, Discord (slash commands, notifications, voice), mobile and push, SSO - **Infrastructure** — self-hosted, Docker, prebuilt pull-only images, branding as data, OpenAPI +Guilds and city governors were added to Game intelligence in phase 3: `module-uo` declares them as +capabilities and the site was omitting two of the eight. That correction is now mechanical rather +than editorial — see D18. + +### How phase 3 built the homepage + +Three decisions taken before the page was written (org lead, 2026-08-20). + +**D17 — the data path is drawn generically, and captioned specifically.** The diagram's nodes read +"your game server", "sidecar", "Runic Gateway", "browser and app", because a reader should not have +to know this org's repository layout to understand the picture, and because the tagline promises a +platform. It does not hide what ships: the sub-labels and the caption name ServUO and uo-link +outright, since there is exactly one implementation of the shape today and §1 says the technical +truth wins. Rejected: naming the real components in the nodes (reads as a UO product), and omitting +UO entirely (advertises a generality one module proves). + +**D18 — all five groups on the homepage, named only.** Not three with a link out: Integration and +Infrastructure carry the module and self-hosted arguments, which are the differentiators, and hiding +them until phase 4 would have made the front page look smaller than the product. The per-capability +argument stays `/features/`'s job so there is one copy of it. + +The list is **data with a check behind it** (`src/data/capabilities.mjs`). Every Game-intelligence +item names the `module-uo` capability slug it comes from, and the build fails if the page and +`platform.json` disagree in either direction. Closing that loop needed a fifteenth fact in +`checkFacts.mjs`: §12 listed the capability list as an externally-sourced fact and nothing re-read +it, so the whole chain rested on someone remembering. Manifest → `platform.json` → page is now +checked end to end. + +**D19 — the hero leads with the emblem.** Chosen over a type-only hero: the mark is already the +site logo, the Android launcher icon and the Play listing, and showing it large is what makes the +three read as one product (D11). It costs what D16 already accepted — raster art a mounted +`theme.css` cannot recolour — but every size is derived from whichever `logo.png` is in force +(D14), so the hero, the header, the tab icon and the installed icon still change together from one +file. + +**A convention, not a decision:** the homepage links the final routes — `/features/`, +`/modules/`, `/integrations/` — which phases 4 to 6 have not written yet. The header and footer +already did this from phase 1. Nothing is deployed until phase 12, so no visitor meets a 404, and +nothing has to be rewritten later. Links *into the documentation* are the exception: they point at +`/docs/`, because phases 7 and 8 own those slugs and a guessed one would be a stale URL nothing +checks. + ### Documentation ``` @@ -636,6 +679,7 @@ a mechanism rather than diligence: | Protocol version | `link` `main:sidecar/src/main.rs` → `PROTOCOL_VERSION` | | Overlay protocol | `servuo-plugins` `main:overlay.toml` → `protocol` | | Module API | `website` `main:server/src/modules/version.js` → `MODULE_API_VERSION` | + | Module capability list | `Module-uo` `main:module.json` → `capabilities` (added in phase 3) | | Bundle + component pins | `installer` branch `bundles`, **root** `current.json` | | Release versions | Gitea releases API per repo | diff --git a/scripts/checkBrand.mjs b/scripts/checkBrand.mjs index 35cff65..ef6724d 100644 --- a/scripts/checkBrand.mjs +++ b/scripts/checkBrand.mjs @@ -216,6 +216,80 @@ if (brand) { } } +/* ======================================================================================= + 4. The demo slot's markup contract (§15 / D12) + ======================================================================================= + + `applyBrand.mjs` reveals the demo link by string-replacing an exact pair of empty + attributes in the built HTML. That is a contract between a script and a template that + share no code, and it fails in the quietest possible way: an attribute inserted between + the two, or `href` written after `data-demo-url`, produces a build where the demo URL is + set in the mount, the boot log says nothing, and the link is simply never there. + + Both halves are checked, and neither is retyped from memory — the literal is derived from + the same expression `applyBrand.mjs` uses, so the two cannot drift apart. */ + +const applyForCheck = existsSync(path.join(ROOT, 'scripts/applyBrand.mjs')) + ? readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8') + : ''; + +const attrTemplate = applyForCheck.match( + /`href="\$\{escapeHtml\(value\)\}" data-demo-url="\$\{escapeHtml\(value\)\}"`/ +); + +if (!attrTemplate) { + fail( + 'applyBrand.mjs no longer builds the demo attributes as `href="..." data-demo-url="..."`.\n' + + ' Update the expected pair below to match, and re-check every template that writes it.' + ); +} else { + // What the script will look for when the applied value is the stock empty string. + const EMPTY_PAIR = 'href="" data-demo-url=""'; + + let slots = 0; + const strays = []; + + for await (const file of walk(path.join(ROOT, 'src'))) { + if (path.extname(file) !== '.astro') continue; + + // Comments discuss the contract at length, including in the template that implements + // it. Scanning them would make the check fail on its own documentation. + // Blanked rather than removed: keeping every newline and every offset means the line + // numbers reported below are the ones in the file, not the ones in a shortened copy. + const blank = (match) => match.replace(/[^\n]/g, ' '); + const source = readFileSync(file, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, blank) + .replace(//g, blank); + + const relative = path.relative(ROOT, file); + + slots += source.split(EMPTY_PAIR).length - 1; + + for (const match of source.matchAll(/data-demo-url/g)) { + const start = match.index - EMPTY_PAIR.indexOf('data-demo-url'); + if (source.slice(start, start + EMPTY_PAIR.length) !== EMPTY_PAIR) { + strays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`); + } + } + } + + if (!slots) { + fail( + `no demo slot found in src/**/*.astro — expected the literal \`${EMPTY_PAIR}\`.\n` + + ' §15 reserves this slot so that gaining a demo instance is one line in the mounted\n' + + ' brand.json. Removing it makes that a rebuild.' + ); + } + + for (const site of strays) { + fail( + `${site} writes data-demo-url outside the exact pair \`${EMPTY_PAIR}\`.\n` + + ' applyBrand.mjs replaces that literal at boot; anything else is invisible to it and\n' + + ' the slot will never appear.' + ); + } +} + /* ======================================================================================= */ if (failures.length) { @@ -227,5 +301,5 @@ if (failures.length) { console.log( `checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` + - `and every rewritable string is safe to replace.` + `every rewritable string is safe to replace, and the demo slot matches its contract.` ); diff --git a/scripts/checkFacts.mjs b/scripts/checkFacts.mjs index 30e870e..da76f98 100644 --- a/scripts/checkFacts.mjs +++ b/scripts/checkFacts.mjs @@ -117,7 +117,29 @@ async function checkModuleApi() { } // --------------------------------------------------------------------------- -// 4. The current bundle +// 4. The capabilities the installed module actually declares +// +// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it +// was the one fact nothing re-read. That mattered from phase 3 onwards, because the +// homepage renders the list rather than merely storing it: `src/data/capabilities.mjs` +// asserts at build time that every declared slug is claimed by a named capability on the +// page and vice versa. Without this check that assertion was anchored to a local copy +// nobody was verifying, so the whole chain rested on someone remembering. +// +// Sorted before comparing: the manifest's order is the module's business, and a reordered +// array is not a changed capability set. A slug appearing or disappearing is. +// --------------------------------------------------------------------------- +async function checkModuleCapabilities() { + const authority = 'Module-uo main:module.json'; + const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main')); + const declared = [...(manifest.capabilities || [])].sort(); + const expected = [...platform.moduleUoCapabilities].sort(); + + record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority); +} + +// --------------------------------------------------------------------------- +// 5. The current bundle // // The manifests live at the ROOT of the `bundles` branch — `current.json`, // `bundle-.json` — not under `bundles/`. Fetching the directory 404s. @@ -141,7 +163,7 @@ async function checkBundle() { } // --------------------------------------------------------------------------- -// 5. Release versions, per repo +// 6. Release versions, per repo // --------------------------------------------------------------------------- async function checkReleases() { for (const [repo, expected] of Object.entries(platform.releases)) { @@ -152,7 +174,7 @@ async function checkReleases() { } // --------------------------------------------------------------------------- -// 6. `website` still publishes nothing +// 7. `website` still publishes nothing // // It ships as container images and is never tagged, so the site refers to the platform by // bundle tag and Module API version instead. The day that changes, this repo should notice @@ -165,7 +187,7 @@ async function checkWebsiteHasNoReleases() { } // --------------------------------------------------------------------------- -// 7. D13 — the contact address lives in exactly one file +// 8. D13 — the contact address lives in exactly one file // --------------------------------------------------------------------------- const CONTACT_CHECK = 'contact address (D13)'; @@ -244,6 +266,7 @@ async function main() { checkProtocol, checkOverlayProtocol, checkModuleApi, + checkModuleCapabilities, checkBundle, checkReleases, checkWebsiteHasNoReleases, diff --git a/src/components/home/Capabilities.astro b/src/components/home/Capabilities.astro new file mode 100644 index 0000000..23569c9 --- /dev/null +++ b/src/components/home/Capabilities.astro @@ -0,0 +1,167 @@ +--- +import platform from '../../data/platform.json'; +import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs'; + +/** + * The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for + * each one is `/features/`'s job in phase 4, and repeating it here would create a second + * copy to keep true. + * + * The call below is the point of the exercise: it throws, and therefore fails the build, if + * the "Game intelligence" list and the module's own declared capabilities have drifted + * apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest; + * this makes the page honest against `platform.json`, which is the half that was missing. + * + * The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features + * list is described there as "as load-bearing as the rest", and a homepage that lists only + * what exists while quietly omitting the well-known things that do not is the exact failure + * §1 is written to prevent. + */ +assertCapabilityCoverage(platform.moduleUoCapabilities); +--- + +
+

What it does

+

A community site, and a window into the game

+

+ The core is game-agnostic: it does not know what a shard is. Everything that does arrives + as an installable module, which is why the same platform can carry + a different game without a fork. +

+ +
+ { + capabilityGroups.map((group) => ( +
8 && 'caps__group--wide']}> +
+

{group.title}

+ {group.moduleSupplied && Module-supplied} +
+ +

{group.summary}

+ +
    + {group.items.map((item) => ( +
  • {item.label}
  • + ))} +
+
+ )) + } +
+ +

+ Some things people reasonably expect are deliberately not built — a Matrix + integration, more than one game module active at once, a second game module. They are + listed rather than left out, on features and{' '} + integrations. +

+
+ + diff --git a/src/components/home/DataPath.astro b/src/components/home/DataPath.astro new file mode 100644 index 0000000..2583546 --- /dev/null +++ b/src/components/home/DataPath.astro @@ -0,0 +1,302 @@ +--- +import platform from '../../data/platform.json'; + +/** + * The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn + * geometry, used where it explains something, and no raster anywhere. + * + * --------------------------------------------------------------------------------------- + * THE LABELS ARE GENERIC, WITH UO AS THE CAPTION + * --------------------------------------------------------------------------------------- + * The org lead settled this before the diagram was drawn. The nodes say "your game server" + * and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader + * should never need to know that `link`, `servuo-plugins` and `installer` are three + * repositories in order to connect a game server — and because the tagline promises a + * platform, not a UO product. + * + * It does NOT hide what actually ships. The sub-labels and the caption name ServUO and + * uo-link outright, because §1 says the technical truth wins and today there is exactly one + * implementation of this shape. An operator running a shard has to see themselves in the + * picture on the first screen. + * + * --------------------------------------------------------------------------------------- + * WHY THE SVG IS aria-hidden + * --------------------------------------------------------------------------------------- + * Not because it is decorative — it is the opposite — but because the steps beside it carry + * the same four stages in full prose, at real font sizes, in reading order. A `role="img"` + * with a `` would make a screen reader read the same path twice, and the second + * telling would be the worse one. The picture is for people who can see it; the list is the + * canonical version and everyone gets it. + * + * That also means the diagram must never gain a fact the list does not have. + * + * The concentric rings behind the nodes are the emblem's own geometry, centred on the + * boundary line — the one place in the picture where the argument actually happens. + */ +--- + +
+
+

How it works

+

One path, one direction

+

+ Everything the website knows about your game arrives the same way. There is no second + route in, and nothing on the internet can reach the game to ask. +

+
+ +
+
+ + +

+ Today that game server is a ServUO shard and that sidecar is uo-link. The shape is the + contract; the implementations are what plug into it. +

+
+ +
    +
  1. +

    Your game server

    +

    + A plugin inside the server dials out to the sidecar over loopback. + The game never listens for anything, so there is nothing on it to find. Events go + onto a bounded queue and the game moves on — a sidecar that is wedged or missing + cannot slow the world down. +

    +
  2. +
  3. +

    The sidecar

    +

    + A small service beside the game, and the only piece of the bridge anything else can + reach. It speaks a versioned wire protocol — protocol {platform.protocol} today — so + a mismatched pair is refused rather than misread, and it answers only your website's + backend, over an authenticated WebSocket and REST. +

    +
  4. +
  5. +

    Runic Gateway

    +

    + Your site ingests the live feed and fans it back out on two streams: a public one + carrying an allowlist of safe events, and a staff-only one carrying the rest. That + split is a security boundary, not a preference. When the game is down the site stays + up and shows it as offline. +

    +
  6. +
  7. +

    Browser and app

    +

    + The web client reads same-origin JSON and server-sent events. The Android app talks + to the same documented API with bearer tokens. Neither has any idea where the game + server is, because neither is ever told. +

    +
  8. +
+
+
+ + diff --git a/src/components/home/GetStarted.astro b/src/components/home/GetStarted.astro new file mode 100644 index 0000000..8b9da28 --- /dev/null +++ b/src/components/home/GetStarted.astro @@ -0,0 +1,117 @@ +--- +import { brand } from '../../lib/brand.mjs'; + +/** + * The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's + * "installation path": a "Runic Gateway install" is two independent installs. The installer + * binary sets up the shard side only and never contacts the website; the website is a + * separate Docker deployment. + * + * That belongs on the homepage rather than being saved for the docs. It is the single + * misunderstanding most likely to make an evaluator think the software is broken, it costs + * two sentences to prevent, and §13 calls the installation path the priority of the whole + * project. Saying it here is what makes the docs a confirmation rather than a surprise. + * + * The two halves are ordered site-first because that is the order they must be done in: the + * shard side ends by pasting four values into the site's admin panel, which has to exist. + * + * Both "read the docs" links point at `/docs/` rather than at a page inside the journey. + * Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in + * this file that nothing checks and that a later phase would have to remember to fix. + */ +--- + +
+
+

Getting started

+

An install is two installs

+

+ This trips up almost everyone once. The website and the game-side bridge are separate + deployments on separate machines, and neither one installs the other. Doing them in + order takes an evening. +

+ +
+
+

1 The site

+

+ A Docker Compose deployment on whatever host serves your community — a small VPS is + plenty. Pull the images, bring it up, create the first admin, then install a game + module from the admin panel. +

+
+ +
+

2 The game side

+

+ One binary, run on the machine the game server already lives on. It syncs the plugin, + installs the sidecar as a service, and prints four values. You paste those into + Admin → Shard, and the two halves find each other. +

+
+
+ + +
+
+ + diff --git a/src/components/home/Hero.astro b/src/components/home/Hero.astro new file mode 100644 index 0000000..265ef75 --- /dev/null +++ b/src/components/home/Hero.astro @@ -0,0 +1,183 @@ +--- +import { brand } from '../../lib/brand.mjs'; +import platform from '../../data/platform.json'; + +/** + * The hero (PLAN.md §13 phase 3). + * + * The org lead chose an emblem hero over a type-only one: the mark carries recognition + * across the site, the Android launcher icon and the Play listing, and showing it large is + * what makes those three read as one product (D11, §11). + * + * It costs what D16 already accepted — the emblem is raster illustration, so a mounted + * `theme.css` recolours everything around it and not the mark itself. Replacing the mark + * means replacing `logo.png`, and because every size here is derived on request from + * whichever `logo.png` is in force (D14), that one file changes the hero, the header, the + * tab icon and the installed app icon together. + * + * The glow behind it is drawn in CSS from the portal tokens, so it DOES follow a mounted + * theme. That is deliberate: the part that can track the operator's palette does. + * + * The

is the tagline rather than the product name. The name is in the header, in the + * page title and in the footer; a visitor who has just arrived needs the sentence more than + * the noun. Both strings are brand fields, rewritten at boot by `applyBrand.mjs` (D15). + */ +--- + +
+
+
+

Self-hosted community platform

+ +

{brand.tagline}

+ +

+ {brand.siteName} is a community website for a game server — accounts, teams, forums, a + wiki, news and a full admin panel — with a one-way bridge that puts the server's live + world on the public site. The game itself never listens on the internet. +

+ +
+ Install it + See what it does + + {/* + The demo slot (§15 / D12). `global.css` hides `[data-demo-url='']`, so a stock + build renders nothing here; `applyBrand.mjs` fills both attributes at boot when a + mounted `brand.json` sets `demoUrl`, and the link appears. + + The attribute pair is a literal contract with that script — `href` immediately + followed by `data-demo-url`, both empty, in this order. Astro preserves attribute + order, so what is written here is what ends up in the HTML it searches for. Do not + insert an attribute between them. + */} + See it running +
+ +
+ Protocol {platform.protocol} + Module API {platform.moduleApi} + Bundle {platform.bundle.tag} +
+
+ +
+ {/* + `alt=""` because the emblem is the product's mark sitting beside the product's own + sentence — announcing it would add nothing a reader of the

does not have. + + Sizes are on `brandAssets.mjs`'s allowlist; `checkBrand.mjs` puts every URL below + through the route's own classifier, so a plausible-but-underivable size fails the + build rather than 404ing in production. + */} + + + + +

+
+
+ + diff --git a/src/components/home/SelfHosted.astro b/src/components/home/SelfHosted.astro new file mode 100644 index 0000000..ad360a6 --- /dev/null +++ b/src/components/home/SelfHosted.astro @@ -0,0 +1,107 @@ +--- +/** + * The self-hosted argument (PLAN.md §10, the `/` row). + * + * Every claim below is from §2's verified state, and each is deliberately the kind of thing + * that can be checked by running the software rather than by trusting the page. Where a + * claim would need a qualifier, the qualifier is on the card — "understated honesty" (D8) + * is a house style, and a hedge in small print is the opposite of it. + * + * Nothing here is a version or a number, so nothing here needs `platform.json`. If a card + * ever gains one, it reads it from there like everything else (§12). + */ + +const points = [ + { + title: 'It runs on your box', + body: + 'Docker Compose, with prebuilt images that are pulled rather than built — nothing ' + + 'compiles on your server. One command up, one command back.', + }, + { + title: 'The game stays off the internet', + body: + 'The game host opens no inbound port. The sidecar beside it is the only exposed ' + + 'part of the bridge, and it answers exactly one caller: your website.', + }, + { + title: 'Branding is data, not a rebuild', + body: + 'Name, colours, logo and contact address live in a mounted file. The same image ' + + 'runs as any community — including this site, which is built the same way.', + }, + { + title: 'No analytics, anywhere', + body: + 'This site has no trackers, no third-party requests and no cookie banner, because ' + + 'it collects nothing. Your deployment talks to the services you configure, and to ' + + 'nothing you did not.', + }, + { + title: 'Documented, not just working', + body: + 'The whole backend is described by an OpenAPI 3.0 spec that ships with it, so the ' + + 'API you build against is the API that is actually there.', + }, + { + title: 'Free software', + body: + 'GPL-3.0-or-later, every repository in the open. If this project stops, what you ' + + 'are running does not.', + }, +]; +--- + +
+

Why self-hosted

+

Your server, your data, your rules

+

+ There is no hosted tier and no account with us. The whole thing is software you run, + which is the only arrangement under which "the game is not on the internet" can mean + anything. +

+ +
    + { + points.map((point) => ( +
  • +

    {point.title}

    +

    {point.body}

    +
  • + )) + } +
+
+ + diff --git a/src/data/capabilities.mjs b/src/data/capabilities.mjs new file mode 100644 index 0000000..041b354 --- /dev/null +++ b/src/data/capabilities.mjs @@ -0,0 +1,172 @@ +/** + * capabilities.mjs — the five capability groups of PLAN.md §10, as data. + * + * --------------------------------------------------------------------------------------- + * 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 PART THAT IS A CHECK, NOT A LIST + * --------------------------------------------------------------------------------------- + * "Game intelligence" is the only group core does not supply — it comes from whichever + * module is installed, and today that is `module-uo`. Its items therefore carry the + * capability slugs the module actually declares in its `module.json`, and + * `assertCapabilityCoverage()` fails the build if the two lists drift apart. + * + * That closes a real gap. `platform.json` holds `moduleUoCapabilities` and + * `scripts/checkFacts.mjs` re-reads it from the module's manifest on every build — so the + * day `module-uo` gains a capability, the JSON goes red and someone updates it. Before this + * function, updating the JSON was the end of it and the page kept the old list. Now the + * page is what goes red next. + * + * Note that slugs are NOT one-per-item in either direction: `shard` is the source of four + * 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. + */ + +/** + * Community — core, game-agnostic. Everything here works on a deployment with no game + * module installed at all. + */ +const community = { + id: 'community', + /** + * Core, not module-supplied. Stated on every group rather than only on the one that is + * true, so the shape of a group is uniform — the homepage reads this field on all five, + * and an inferred union that carries it on one member is an error waiting for the next + * template that touches it. + */ + moduleSupplied: false, + title: 'Community', + summary: 'The site your players actually use, none of which knows what game you run.', + items: [ + { label: 'Teams' }, + { label: 'Team forums' }, + { label: 'Notifications' }, + { label: 'Wiki' }, + { label: 'News and newsletter' }, + { label: 'Player self-service' }, + ], +}; + +/** + * Game intelligence — module-supplied. The `caps` arrays are the contract with + * `platform.json`; see `assertCapabilityCoverage` below. + */ +const gameIntelligence = { + id: 'game-intelligence', + title: 'Game intelligence', + moduleSupplied: true, + summary: + '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'] }, + ], +}; + +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' }, + ], +}; + +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' }, + ], +}; + +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' }, + ], +}; + +export const capabilityGroups = [ + community, + gameIntelligence, + administration, + integration, + infrastructure, +]; + +/** + * Fails the build when the module's declared capabilities and this page's list disagree. + * + * Called from the component rather than from a check script on purpose: the failure needs + * to reach whoever is editing the page, and an Astro build error names the component. It + * also means the rule cannot be skipped by running `astro build` without `npm run verify`. + */ +export function assertCapabilityCoverage(declared) { + const claimed = new Set(); + for (const item of gameIntelligence.items) { + for (const cap of item.caps || []) claimed.add(cap); + } + + const known = new Set(declared); + + const unlisted = declared.filter((cap) => !claimed.has(cap)); + const invented = [...claimed].filter((cap) => !known.has(cap)); + + if (!unlisted.length && !invented.length) return; + + const lines = []; + if (unlisted.length) { + lines.push( + `the installed module declares ${unlisted.map((c) => `"${c}"`).join(', ')}, which no ` + + `capability on the homepage claims — the site is under-selling what it can show.` + ); + } + if (invented.length) { + lines.push( + `the homepage claims ${invented.map((c) => `"${c}"`).join(', ')}, which the module no ` + + `longer declares — the site is advertising something that is gone (§1).` + ); + } + + throw new Error( + `src/data/capabilities.mjs disagrees with platform.json's moduleUoCapabilities:\n` + + lines.map((line) => ` - ${line}`).join('\n') + + `\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n` + ); +} diff --git a/src/lib/brandAssets.mjs b/src/lib/brandAssets.mjs index e7d31ec..45a98d6 100644 --- a/src/lib/brandAssets.mjs +++ b/src/lib/brandAssets.mjs @@ -327,6 +327,13 @@ export function warmCache() { 'favicon-32.png', 'favicon.ico', 'apple-touch-icon.png', + // The homepage hero (phase 3). Its offers 256/384/512 in both formats and + // the browser picks one, so warming all six would be five wasted encodes; these are + // the two the common viewport-and-DPR combinations resolve to, plus the WebP the + // `src` attribute names for anything without AVIF. + 'logo-384.avif', + 'logo-512.avif', + 'logo-384.webp', ]; return Promise.allSettled(names.map((name) => resolveBrandFile(name))); } diff --git a/src/pages/index.astro b/src/pages/index.astro index f9b292e..800efbc 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,79 +1,34 @@ --- import Base from '../layouts/Base.astro'; import { brand } from '../lib/brand.mjs'; -import platform from '../data/platform.json'; + +import Hero from '../components/home/Hero.astro'; +import DataPath from '../components/home/DataPath.astro'; +import SelfHosted from '../components/home/SelfHosted.astro'; +import Capabilities from '../components/home/Capabilities.astro'; +import GetStarted from '../components/home/GetStarted.astro'; /** - * Phase 1 is the foundation, not the homepage — phase 3 builds the real one (hero, the - * data-path diagram as inline SVG, the grouped capability sections, the reserved demo - * slot). This page exists so the shell is provably assembled: layout, header, footer, - * tokens, both typefaces, and a fact read from platform.json rather than typed. + * The homepage — PLAN.md §13 phase 3. * - * Everything it claims is from §2's verified state. Nothing here is marketing copy yet. + * Ordered as an argument rather than as a brochure: what it is (hero), how the hard part + * works (the data path), why you would want it on your own hardware, what you actually get, + * and how to start. The data path comes second on purpose — it is the claim in the tagline, + * and a visitor who does not believe it has no reason to read the feature list. + * + * The page itself holds no copy and no facts. Each section reads versions from + * `platform.json` and brand text from `brand.mjs`, so nothing on this route can go stale + * without a check going red first (§12). + * + * `bareTitle` because the hero's own

is the tagline: the default suffix would render + * "Runic Gateway — Put your … — Runic Gateway". */ --- - -
-

Foundation

-

{brand.siteName}

-

{brand.tagline}

- -
- Protocol {platform.protocol} - Module API {platform.moduleApi} - Bundle {platform.bundle.tag} - Verified {platform.verifiedOn} -
-
- -
-
-

This is the phase 1 scaffold

-

- The layout shell, the token file, the self-hosted typefaces, the documentation - theme and the two build-time checks are in place. The homepage itself is phase 3; - the marketing pages are phase 4; the documentation — the installation path, which - is the priority of the whole project — is phase 7. -

-

- Every version above was read from src/data/platform.json, and{' '} - scripts/checkFacts.mjs re-reads each one from its authority on every - build. No version number is written in prose anywhere on this site. -

-

- Read the documentation ·{' '} - Browse the source -

-
-
+ + + + + + - - diff --git a/src/styles/global.css b/src/styles/global.css index 214abc8..3a84cf8 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -204,11 +204,33 @@ svg { color: var(--gold); } -/* The nav collapses to the docs link alone until phase 3 gives it a real - disclosure control; a hamburger with nothing behind it is worse than none. */ +/* Phase 1 left the mobile nav to phase 3, expecting a disclosure control. It got + a wrap instead, and deliberately: with four links there is nothing to disclose. + The lockup keeps the first row, the links take the second, and the whole thing + stays four keyboard stops with no state, no script and no duplicate markup — + all three of which a hamburger would have cost. + + Phase 3 found the bug this fixes by rendering the homepage in a 390px frame: + the four links plus the lockup measured 433px against a 390px viewport, so + every phone got a horizontally scrolling page. Shrinking the type further was + the tempting fix and would only have moved the failure to the next narrow + screen. */ @media (max-width: 720px) { + .site-header__inner { + flex-wrap: wrap; + justify-content: flex-start; + row-gap: 0.15rem; + padding-block: 0.55rem; + min-height: 0; + } + .site-nav { + flex-wrap: wrap; + width: 100%; gap: 0; + /* Pull the first link's own padding back to the gutter so the row of links + lines up with the lockup above it rather than sitting indented. */ + margin-left: -0.45rem; } .site-nav a { @@ -333,6 +355,73 @@ svg { color: var(--mode-live); } +/* ---- Sections ------------------------------------------------------------ + The vertical rhythm every marketing page is built from. Here rather than in + a component because phases 4 to 6 add pages that must sit on the same grid, + and a per-page `padding-block` is how that stops being true. */ + +.section { + padding-block: clamp(2.5rem, 6vw, 4.5rem); +} + +.section + .section { + padding-top: 0; +} + +/* ---- Buttons ------------------------------------------------------------- + Three variants, all the same box: solid for the one action a page wants, + outlined for the alternatives, and the demo's own below. Anchors, not + buttons — every one of them navigates, and the site runs no client JS. */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.62rem 1.15rem; + border: 1px solid var(--line); + border-radius: var(--radius-input); + background: var(--panel-flat); + color: var(--ink); + font-size: 0.96rem; + font-weight: 500; + line-height: 1.3; + text-decoration: none; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; +} + +.btn:hover { + border-color: var(--gold-deep); + color: var(--ink); +} + +/* The primary action reads as gold-on-dark rather than a filled gold slab: at + 7.91:1 the token is a text colour, and `--gold-deep` is explicitly annotated + "rules, borders, UI edges. NEVER text." — so it carries the edge and the + wash, and the gold carries the label. */ +.btn--primary { + border-color: var(--gold); + background: color-mix(in srgb, var(--gold-deep) 18%, transparent); + color: var(--gold-bright); +} + +.btn--primary:hover { + background: color-mix(in srgb, var(--gold-deep) 30%, transparent); + color: var(--gold-bright); +} + +.btn--ghost { + background: transparent; + color: var(--muted); +} + +.btn--ghost:hover { + color: var(--ink); +} + /* ---- The demo slot ------------------------------------------------------- PLAN.md §15 / D12. A public demo instance is planned and out of scope, but the site is built so that gaining one is a line in the mounted brand.json @@ -351,3 +440,18 @@ svg { [data-demo-url=''] { display: none; } + +/* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button + like its neighbours and takes the portal colour — the live signal, for the + one link on the site that leads to something actually running. */ +.demo-cta { + border-color: var(--portal); + background: color-mix(in srgb, var(--portal-deep) 22%, transparent); + color: var(--portal-bright); +} + +.demo-cta:hover { + border-color: var(--portal-bright); + background: color-mix(in srgb, var(--portal-deep) 34%, transparent); + color: var(--portal-bright); +}