From 556dee73555399d71c3078e64dbd0b68c6042088 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 20 Aug 2026 00:03:04 -0500 Subject: [PATCH] =?UTF-8?q?feat(home):=20phase=203=20=E2=80=94=20the=20hom?= =?UTF-8?q?epage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces phase 1's scaffold with the real homepage: hero, the data path as inline SVG, the self-hosted argument, all five capability groups, and the get-started CTA. Three decisions the org lead took first are recorded in PLAN.md as D17-D19. The data path is drawn generically and captioned specifically (D17): the nodes say "your game server" and "sidecar", the sub-labels and caption name ServUO and uo-link. The SVG is aria-hidden because the four numbered steps beside it carry the same path in prose — one telling, not two. The capability list is data with a check behind it (D18). Every Game-intelligence item names the module-uo capability slug it comes from, and the build fails if the page and platform.json disagree either way. That needed a fifteenth fact in checkFacts.mjs: §12 named the capability list as an externally-sourced fact and nothing re-read it, so the chain rested on someone remembering. It also found that the site was omitting two of the module's eight capabilities — guilds and city governors are now listed, in the page and in §10. The hero leads with the emblem (D19), derived from whichever logo.png is in force so one file still changes the hero, header, tab icon and app icon together. Also here, both found by standing the build up rather than by review: - checkBrand.mjs now enforces the demo slot's markup contract. applyBrand.mjs reveals the demo link by replacing an exact pair of empty attributes; an attribute inserted between them produces a build where the mount sets a demo URL, the boot log says nothing and the link never appears. Both halves are checked and the literal is derived from the expression applyBrand.mjs uses, so they cannot drift. - The header nav overflowed at 390px — four links plus the lockup measured 433px against a 390px viewport, so every phone got a horizontally scrolling page. Phase 1 left this to phase 3 expecting a disclosure control; it got a wrap instead, because with four links there is nothing to disclose and a hamburger costs state, script and duplicate markup. Verified on a clean checkout of this commit: all four checks, astro check, a production build, a live /brand/* smoke, and a demo URL mounted and reverted. Co-Authored-By: Claude --- PLAN.md | 48 +++- scripts/checkBrand.mjs | 76 ++++++- scripts/checkFacts.mjs | 31 ++- src/components/home/Capabilities.astro | 167 ++++++++++++++ src/components/home/DataPath.astro | 302 +++++++++++++++++++++++++ src/components/home/GetStarted.astro | 117 ++++++++++ src/components/home/Hero.astro | 183 +++++++++++++++ src/components/home/SelfHosted.astro | 107 +++++++++ src/data/capabilities.mjs | 172 ++++++++++++++ src/lib/brandAssets.mjs | 7 + src/pages/index.astro | 93 ++------ src/styles/global.css | 108 ++++++++- 12 files changed, 1333 insertions(+), 78 deletions(-) create mode 100644 src/components/home/Capabilities.astro create mode 100644 src/components/home/DataPath.astro create mode 100644 src/components/home/GetStarted.astro create mode 100644 src/components/home/Hero.astro create mode 100644 src/components/home/SelfHosted.astro create mode 100644 src/data/capabilities.mjs 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); +} -- 2.49.1