diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index e3f7fa6..9ec3222 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -44,6 +44,19 @@ jobs: - name: Production build run: npm run build + - name: Links + # PLAN.md §12 — every internal link resolves, and every outbound link into a + # RunicGateway repository points at a branch path rather than a commit permalink. + # + # It runs AFTER the build, and that ordering is the design rather than a + # convenience: it reads the built HTML, so links assembled from data files and + # template literals are checked as the strings they actually become. A source scan + # would see an expression and skip most of what phase 4 added. + # + # No network: the outbound rule is about the shape of a URL, and a build that + # fails because some other host is slow is a check people learn to ignore. + run: npm run check:links + - name: Platform facts # PLAN.md §12 — every version, protocol number and bundle tag is re-read from # its authority over the Gitea API and must agree with src/data/platform.json. diff --git a/PLAN.md b/PLAN.md index 2c1494a..dfe8cdc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -234,6 +234,16 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r | **D12** | **A public demo instance is planned but out of scope today** — a Proxmox VM running the full stack including ServUO, with restricted settings and an hourly automatic reset. | §15. The IA reserves the slot so it lands later without a restructure. | | **D13** | **Publish the existing address.** No mailbox has been created at the domain and the org lead elected not to wait for one: `whitlocktech@gmail.com` is the published contact wherever the site needs one — `/privacy`, `/terms`, `/community`, the Play listing — and `docs/SECURITY.md` keeps the address it already carries. | Taken 2026-08-19, superseding §14 N2 as a blocker. The address lives **only** in `brand.json` (§7), never in prose, so moving to `privacy@`/`security@` later is a file edit and a restart. Phase 0's PR 0.10 is withdrawn, not deferred. | +**Decisions after D13 are recorded where they were taken**, in the section describing the phase that +raised them, rather than appended here — a decision is only re-litigated when its reasoning is +somewhere other than the thing it decided. The count of record is **twenty-five**: + +| # | Where | What it settled | +|---|---|---| +| D14–D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem | +| D17–D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero | +| D20–D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links | + --- ## 6. Runtime shape @@ -532,6 +542,11 @@ Guilds and city governors were added to Game intelligence in phase 3: `module-uo capabilities and the site was omitting two of the eight. That correction is now mechanical rather than editorial — see D18. +**Community is core machinery, but two of its six need a module to fill them.** Teams and Team +forums are marked as such (D24). Core owns every part of the Team machinery and cannot create a +Team: they arrive from the installed module, so on a deployment with no module the feature is +present and permanently empty. The group's summary says so; `/features/` says why. + ### How phase 3 built the homepage Three decisions taken before the page was written (org lead, 2026-08-20). @@ -570,6 +585,87 @@ nothing has to be rewritten later. Links *into the documentation* are the except `/docs/`, because phases 7 and 8 own those slugs and a guessed one would be a stale URL nothing checks. +### How phase 4 built the marketing pages + +Six decisions taken before coding (org lead, 2026-08-20), plus two scope items the phase table had +never assigned to anyone. + +**D20 — `/features/` is the homepage's list with a `detail` line, not a second list.** Every +capability in `src/data/capabilities.mjs` gained a sentence or two of argument; `/` renders the +label, `/features/` renders the label and the detail. Rejected: slicing the page by reader +(players / staff / operators / builders), which reads better but makes the same capability appear +twice and breaks the one-to-one mapping the coverage check depends on; and deep-diving only the +differentiators, which would have left the page looking smaller than the homepage promised. +`assertDetailCoverage()` fails the build on a capability with no detail — the homepage would still +look right, and `/features/` would render a heading with nothing under it. + +**D21 — `/architecture/` draws reasons, not reference.** Three new inline SVGs, each drawing one +boundary: two hosts and two installs, the public/staff allowlist, and the core/module seam. It +carries no endpoint tables, no configuration keys, no schema and no event catalog — phase 8 owns +those, they are canonical in `docs/`, and a second copy here is a copy that goes stale (§1). +Rejected: reusing the homepage's data-path diagram larger (a visitor arriving from `/` meets the +same picture twice), and adding a component/version table (starts becoming the Reference section). +The vocabulary the four diagrams now share moved to `src/styles/diagram.css`. + +**D22 — the deliberate absences are one data file.** `src/data/notBuilt.mjs`, each entry tagged with +the pages that render it, because the homepage already promises a reader they will find the list on +both `/features/` and `/integrations/` and two hand-written copies is how the inconvenient half +stops appearing on one of them. Every entry carries a `resolvedBy`: an absence with an exit +condition is a position, an absence without one is a hole. That generalises what D8 already required +of the Integration Kit's draft badge. + +**D23 — phase 4 absorbs `/community/` and `checkLinks.mjs`.** Neither had a phase. §10 specifies the +page and §14 N3 specifies its contents, and the header and footer have linked it since phase 1 — a +page the site pointed at that no phase built. `checkLinks.mjs` is specified in §12 and phase 4 is +what makes it load-bearing: it roughly quadrupled the internal link count and added the first +outbound links into the repositories. + +**D24 — `needsModule`, because "core" and "module-supplied" were not enough.** Writing the +`/features/` detail for Teams exposed a claim phase 3 had shipped: the Community group said +"everything here works on a deployment with no game module installed at all", and that is false. +`teams.module_id` is `NOT NULL` on `website` `main`, there is no create route anywhere under +`/api/v1/admin/teams`, and sync is gated on `teamProvider.providerModuleId()`. Core owns the whole +Team machinery — tables, roster resolver, forums, notification streams, Discord bridge, voice, +activity feed, `/admin/teams` — and deliberately cannot *originate* a Team, because core does not +own the word for one. On a bare core the feature is present, correct and permanently empty. Teams +and Team forums are marked; the group summary was requalified; the homepage changed by one sentence +and kept D18's five named groups. Rejected: a sixth group for Teams (says it loudest, costs the +five-group grid phase 3 tuned), and fixing only the wording (leaves the distinction one sentence +deep and unguarded). + +**D25 — the demo affordance on `/features/` is a per-capability deep link.** `brand.json` had +promised one since phase 2 without defining it. Capabilities with a stable public route carry a link +appended to the mounted `demoUrl`; the rest carry nothing, and that asymmetry is honest — a +character sheet is reachable only by the account it belongs to, and a Team forum lives behind an id +no static page can know. Paths are read from the real route tables on `main`, never guessed, which +also means they are the *module's* routes: a deployment running a different module deep-links +somewhere else. + +That needed the branding pipeline extended, because the phase-3 slot could not express it. The slot +is a literal swap of a whole URL, so it can only ever put the demo's root in an `href`, and +reversing it would not even find a deep link — whose `href` is the root plus a path, matching no +literal the script knows. `applyBrand.mjs` gained a second pass that **recomputes** all three +attributes from the immutable `data-demo-path`, making it idempotent and exactly reversible, and +`checkBrand.mjs` gained a guard that lifts the pattern out of `applyBrand.mjs` and runs it against +the stock markup, so the two cannot drift. Both directions were proved against a real mount. + +**One thing the checks caught about each other.** A scoped `:has([data-demo-url=''])` rule, added to +hide the wrapper around a hidden demo link, made `checkBrand.mjs` fail: it cannot tell a CSS selector +from an attribute, and it should not have to. The right fix was to delete the wrapper and let the +link be the flex item, so the existing hide rule takes the margin with it — a case where the check +being blunt pointed at simpler markup rather than at a needed exemption. + +**And one thing no check caught.** `[data-demo-url=''] { display: none }` is specificity 0,1,0, and +so is the scoped class Astro puts on the same element — so a component that sets `display` wins on +source order, because component styles are emitted after `global.css`. `/features/`'s `.demo-link` +set `display: inline-flex` for its arrow, and twelve links to a demo that does not exist rendered on +the page, each pointing at `href=""` — which a browser resolves to the page it is already on. +`checkBrand.mjs` was green throughout: the attributes were perfect and the defect was three files +away, in the cascade. It was found by looking at the rendered page at 390px, which is not a +mechanism, and it is the argument for keeping the live browser pass in every phase. The rule is now +`!important` and says why in the stylesheet: while there is no demo these elements do not render, +and no component may overrule that by accident. + ### Documentation ``` @@ -692,7 +788,23 @@ a mechanism rather than diligence: into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the mechanism, diligence is not. - **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a - `RunicGateway` repo points at a branch path, not a commit permalink. + `RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23), + and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from + data files and template literals, and a source scan sees an expression rather than a URL. It runs + after the build for that reason, in `verify` and in CI. It fetches nothing — the outbound rule is + about the shape of a URL, and a check that fails when someone else's host is slow is a check + people learn to ignore. + + It carries one exemption list, `PLANNED_ROUTES`, because §10's convention is that the header, + footer and homepage link the *final* routes rather than growing links phase by phase. That is safe + only because the list is checked in both directions: a link to a route that is neither built nor + listed fails, **and an entry whose route has since been built also fails**, so the list cannot rot + into a permanent exemption once the page arrives. + +- **`scripts/checkBrand.mjs`** also guards the demo slot and, since phase 4, the per-capability deep + links (D25) — lifting the pattern out of `applyBrand.mjs` and running it against the stock markup, + so a template and a script that share no code cannot drift apart. Both are invisible in a stock + build, which is exactly why they need a check rather than a look. - **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7). - `astro check` plus a production build, in CI on every PR. @@ -706,14 +818,14 @@ a mechanism rather than diligence: | **1** | Foundation: Astro + Node adapter scaffold, the token file, typography, layout shell, header/footer, docs theming and sidebar, `platform.json` + `checkFacts.mjs` + `checkTokens.mjs` | | **2** | Branding pipeline (§7): `/brand/*` resolution, `brand-default` contents, the emblem's web derivatives and lockup, `brand.json` wiring | | **3** | Homepage: hero, the data-path diagram as inline SVG, grouped capability sections, CTA, the reserved demo slot | -| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/` | +| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/`, **and `/community/`** — plus `checkLinks.mjs`, the capability `detail` lines, `notBuilt.mjs` and the demo deep links. See D20–D25 | | **5** | The app and the beta: `/app/`, `/beta/`, the signup endpoint, the SQLite store, rate limiting, the export CLI (§8) | | **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) | | **7** | Docs — the journey: Getting started (7) + Administration (12). **The installation path is the priority of the whole project** | | **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) | | **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components | | **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers | -| **11** | Validation: `astro check`, production build, all four check scripts, mobile layout verified in a real browser, a signup walked end to end | +| **11** | Validation: `astro check`, production build, **all five check scripts** (tokens, brand, links, facts, types), mobile layout verified in a real browser, a signup walked end to end | | **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) | Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and diff --git a/package.json b/package.json index a399810..e06f697 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ "check:facts": "node scripts/checkFacts.mjs", "check:tokens": "node scripts/checkTokens.mjs", "check:brand": "node scripts/checkBrand.mjs", + "check:links": "node scripts/checkLinks.mjs", "brand:assets": "node scripts/buildBrandAssets.mjs", - "verify": "npm run check:tokens && npm run check:brand && npm run check:facts && npm run check && npm run build" + "verify": "npm run check:tokens && npm run check:brand && npm run check && npm run build && npm run check:links && npm run check:facts" }, "dependencies": { "@astrojs/node": "^11.1.4", diff --git a/scripts/applyBrand.mjs b/scripts/applyBrand.mjs index 530710a..e068052 100644 --- a/scripts/applyBrand.mjs +++ b/scripts/applyBrand.mjs @@ -175,6 +175,34 @@ if (demoFrom !== demoTo) { replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(demoTo) }); } +/** + * The demo's DEEP links (§15 / D25), which `/features/` writes one of per capability that + * has a stable public route: + * + * see it live + * + * The slot above cannot express these. It is a literal string swap of a whole URL, so it + * can only ever put the demo's root in an `href` — and reversing it would not even find a + * deep link, whose `href` is the root plus a path and therefore matches no literal the + * script knows. + * + * This pass is a different shape on purpose: it does not replace a previous value, it + * RECOMPUTES both attributes from `data-demo-path`, which never changes. That makes it + * idempotent and exactly reversible, so it runs unconditionally in the loop below rather + * than only when the demo URL moved. `data-demo-url` is still filled with the bare root + * because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for + * both kinds of link, and only the `href` differs. + */ +const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g; + +const deepLinkTo = (demoPath) => { + const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : ''; + return ( + `href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` + + `data-demo-path="${demoPath}"` + ); +}; + if (!replacements.length) { console.log('[brand] mount matches what is already applied; nothing to rewrite.'); process.exit(0); @@ -198,6 +226,7 @@ function* walk(dir) { } const counts = new Map(replacements.map((r) => [r.field, 0])); +counts.set('demoDeep', 0); let filesTouched = 0; for (const file of walk(CLIENT)) { @@ -210,6 +239,15 @@ for (const file of walk(CLIENT)) { after = after.split(from).join(to); } + // After the literal swaps, never before: the plain-slot replacement also matches the + // first two attributes of a deep link, so it runs first and this pass corrects the + // `href` it just wrote. Recomputing rather than replacing is what makes that safe. + after = after.replace(DEEP_LINK, (whole, demoPath) => { + const rebuilt = deepLinkTo(demoPath); + if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1); + return rebuilt; + }); + if (after !== before) { writeFileSync(file, after); filesTouched++; @@ -226,6 +264,11 @@ for (const { field, from, to } of replacements) { if (demoFrom !== demoTo) { console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`); } +if (counts.get('demoDeep')) { + console.log( + ` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)` + ); +} // Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename // applied here reaches the pages but not the search results. Worth fixing when search diff --git a/scripts/checkBrand.mjs b/scripts/checkBrand.mjs index ef6724d..9201229 100644 --- a/scripts/checkBrand.mjs +++ b/scripts/checkBrand.mjs @@ -290,6 +290,87 @@ if (!attrTemplate) { } } +/* ======================================================================================= + 5. The demo DEEP-link contract (§15 / D25) + ======================================================================================= + + `/features/` links individual capabilities into the demo, which the slot in §4 cannot + express — it swaps a whole URL, so it can only ever produce the demo's root. Those links + carry a third attribute and `applyBrand.mjs` recomputes all three from it. + + Same failure mode as §4 and the same reason to check it: a template and a script with no + shared code, agreeing on an exact byte sequence, where disagreement is silent. This one + is worse in one respect — a broken deep link is INVISIBLE in a stock build, because the + stock build hides every demo link. It would first appear on the day the org lead sets + `demoUrl` and finds the new links pointing at the demo's front page, or at nothing. + + The regex is not retyped here either: it is lifted out of `applyBrand.mjs` and run + against the stock literal, so this fails if the script's pattern stops matching what the + templates write — whichever side moved. */ + +const deepPattern = applyForCheck.match(/const DEEP_LINK = \/(.*)\/g;/); +const EMPTY_DEEP_PREFIX = 'href="" data-demo-url="" '; +let deepLinkCount = 0; + +if (!deepPattern) { + fail( + 'applyBrand.mjs no longer defines DEEP_LINK as a single /…/g literal.\n' + + ' §15/D25 relies on it to fill the per-capability demo links. Update this check to\n' + + ' match the new shape rather than deleting it.' + ); +} else { + // Does the script's own pattern still match what a template writes in a stock build? + const sample = `${EMPTY_DEEP_PREFIX}data-demo-path="/example"`; + let matches = false; + try { + matches = new RegExp(deepPattern[1]).test(sample); + } catch (error) { + fail(`applyBrand.mjs's DEEP_LINK is not a usable pattern: ${error.message}`); + } + + if (!matches) { + fail( + `applyBrand.mjs's DEEP_LINK no longer matches the stock markup \`${sample}\`.\n` + + ' Every per-capability demo link would be left empty and hidden, on a deployment\n' + + ' that has a demo configured — which is the one place nobody would look.' + ); + } + + const deepStrays = []; + let deepLinks = 0; + + for await (const file of walk(path.join(ROOT, 'src'))) { + if (path.extname(file) !== '.astro') continue; + + // Blanked, not stripped — same reason as §4: the line numbers reported have to be the + // ones in the file. + 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); + + for (const match of source.matchAll(/data-demo-path/g)) { + deepLinks++; + const start = match.index - EMPTY_DEEP_PREFIX.length; + if (start < 0 || source.slice(start, match.index) !== EMPTY_DEEP_PREFIX) { + deepStrays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`); + } + } + } + + for (const site of deepStrays) { + fail( + `${site} writes data-demo-path without the exact prefix \`${EMPTY_DEEP_PREFIX}\`.\n` + + ' applyBrand.mjs matches all three attributes together and in that order; anything\n' + + ' else is invisible to it and the link will never point anywhere.' + ); + } + + deepLinkCount = deepLinks; +} + /* ======================================================================================= */ if (failures.length) { @@ -301,5 +382,6 @@ if (failures.length) { console.log( `checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` + - `every rewritable string is safe to replace, and the demo slot matches its contract.` + `every rewritable string is safe to replace, and the demo slot plus ${deepLinkCount} ` + + `deep link(s) match their contracts.` ); diff --git a/scripts/checkLinks.mjs b/scripts/checkLinks.mjs new file mode 100644 index 0000000..2b0d7d6 --- /dev/null +++ b/scripts/checkLinks.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node +/** + * checkLinks.mjs — PLAN.md §12 + * + * Two rules, both of which §12 states and neither of which had a check until phase 4: + * + * 1. Every internal link resolves. + * 2. Every outbound link into a RunicGateway repository points at a BRANCH path, never a + * commit permalink. + * + * --------------------------------------------------------------------------------------- + * WHY IT READS THE BUILD AND NOT THE SOURCE + * --------------------------------------------------------------------------------------- + * The obvious implementation greps `href="…"` out of `src/**` and resolves it against the + * file tree. It would have missed most of what phase 4 added. Half the links on these pages + * are built from data — `capabilityGroups`, `notBuilt.mjs`, a template literal over + * `platform.gitea.base` — and a source scan sees an expression rather than a URL. A link + * that is wrong in a data file is exactly as broken as one that is wrong in markup, and it + * is harder to spot by eye, so it is the one that most needs checking. + * + * So this runs against `dist/client` after a build, where every link is a real string. The + * cost is that the check needs a build first, which is why it sits after `npm run build` in + * `verify` and in CI. A stale `dist` would check stale links, and that is the one failure + * mode worth knowing about — running it by hand after editing a page means building first. + * + * --------------------------------------------------------------------------------------- + * WHAT IT DELIBERATELY DOES NOT CHECK + * --------------------------------------------------------------------------------------- + * `/brand/*` — those URLs are served by a route that derives them on request from whatever + * is mounted, so nothing corresponding exists in `dist/client` to point at. They are not + * unchecked: `scripts/checkBrand.mjs` already resolves every one of them against that + * route's own allowlist, which is a stronger check than file existence. + * + * Off-site URLs are not fetched. A build that fails because gnu.org is slow is a build + * that teaches people to ignore this check. The one outbound rule here is about the SHAPE + * of a URL, which is decidable without the network. + * + * In-page fragments (`#main`) are not resolved against the ids on the page. It would be a + * fair check to add; it is not one §12 asks for, and the site has exactly one of them. + * + * node scripts/checkLinks.mjs [--dist ] + */ + +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); + +const distArg = process.argv.indexOf('--dist'); +const DIST = + distArg !== -1 && process.argv[distArg + 1] + ? path.resolve(process.argv[distArg + 1]) + : path.join(ROOT, 'dist', 'client'); + +const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8')); + +/** `gitea.whitlocktech.com`, from the same place every page reads it. */ +const GITEA_HOST = new URL(platform.gitea.base).host; + +/** + * Prefixes served by a route rather than by a file in the build. A link starting with one + * of these is somebody else's check — see the header. + */ +const RUNTIME_PREFIXES = ['/brand/']; + +/** + * Routes the site links today that a later phase builds. + * + * This exists because of a convention phase 3 recorded and phase 1 started: the header, + * the footer and the homepage link the FINAL routes of §10 rather than growing links phase + * by phase. Nothing is deployed until phase 12, so no visitor ever meets one of these + * 404s, and no page has to be revisited later to add a link that was always going to be + * there. That convention and rule 1 of this check are in direct tension, and this is where + * the tension is resolved — explicitly, with a phase against each entry, rather than by + * weakening the rule. + * + * It is self-cleaning in both directions, which is the only reason it is safe to have: + * + * - a link to a route that is neither built nor listed here FAILS, so the list cannot be + * used by accident; + * - an entry here whose route HAS since been built also fails, so the list cannot rot + * into a permanent exemption after the page arrives. + * + * Adding to it is a deliberate act. If a route is not in §10, it does not belong here. + */ +const PLANNED_ROUTES = new Map([ + ['/app/', 'phase 5 — the Android app page'], + ['/beta/', 'phase 5 — the closed-beta signup'], + ['/privacy/', 'phase 6 — the privacy policy'], + ['/terms/', 'phase 6 — the terms'], +]); + +/** Planned routes actually linked from somewhere, so the reverse check can be reported. */ +const plannedSeen = new Set(); + +const failures = []; +let linksChecked = 0; +let outboundChecked = 0; + +function fail(file, line, message) { + failures.push({ file, line, message }); +} + +async function* walk(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* walk(full); + else if (path.extname(entry.name) === '.html') yield full; + } +} + +const lineOf = (source, index) => source.slice(0, index).split('\n').length; + +/** + * Does a site-absolute path correspond to something the build will serve? + * + * Astro is configured with `format: 'directory'`, so `/features/` is + * `dist/client/features/index.html`. The other shapes are accepted because a route can + * legitimately be a file — `/manifest.webmanifest` is one, and `/404.html` is another. + */ +function resolvesInBuild(pathname) { + const clean = pathname.replace(/[?#].*$/, ''); + const relative = decodeURIComponent(clean).replace(/^\/+/, ''); + const base = path.join(DIST, relative); + + const candidates = [ + path.join(base, 'index.html'), + `${base.replace(/[\\/]+$/, '')}.html`, + base.replace(/[\\/]+$/, ''), + ]; + + return candidates.some((candidate) => { + if (!existsSync(candidate)) return false; + // A bare directory that has no index.html is not a page anybody can open. + return statSync(candidate).isFile(); + }); +} + +if (!existsSync(DIST)) { + console.error( + `\ncheckLinks: no build at ${path.relative(ROOT, DIST)}.\n\n` + + ' This check reads the built HTML rather than the source, so that links written by\n' + + ' data files and template literals are checked as the strings they become. Run\n' + + ' `npm run build` first — `npm run verify` already does.\n' + ); + process.exit(1); +} + +/* ======================================================================================= + 1. Internal links resolve + ======================================================================================= */ + +for await (const file of walk(DIST)) { + const relative = path.relative(ROOT, file); + const source = readFileSync(file, 'utf8'); + + for (const match of source.matchAll(/(?:href|src)="([^"]*)"/g)) { + const value = match[1]; + + // Off-site, protocol-relative, and the non-navigational schemes. `mailto:` addresses + // are checkFacts.mjs's business (D13) and are not links to anywhere on this site. + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value)) continue; + + // Fragments and query-only links stay on the page they are already on. + if (!value || value.startsWith('#') || value.startsWith('?')) continue; + + // Relative links. Astro emits site-absolute paths for everything the site itself + // writes; a relative one is almost certainly a mistake, but resolving it correctly + // needs the emitting page's directory, so it is reported rather than guessed at. + if (!value.startsWith('/')) { + fail( + relative, + lineOf(source, match.index), + `relative link "${value}" — write it site-absolute, starting with "/", so it means ` + + `the same thing from every page that renders the component` + ); + continue; + } + + if (RUNTIME_PREFIXES.some((prefix) => value.startsWith(prefix))) continue; + + // The demo slot and its deep links ship empty and hidden in a stock build (§15/D25); + // `href=""` is the contract, not a broken link. checkBrand.mjs owns their shape. + if (value === '') continue; + + linksChecked++; + + if (resolvesInBuild(value)) continue; + + const planned = PLANNED_ROUTES.get(value.replace(/[?#].*$/, '')); + if (planned) { + plannedSeen.add(value.replace(/[?#].*$/, '')); + continue; + } + + fail( + relative, + lineOf(source, match.index), + `"${value}" does not resolve — nothing in the build serves it.\n` + + ` If a later phase builds it, add it to PLANNED_ROUTES in this script with the\n` + + ` phase that does. If not, the link is wrong.` + ); + } + + /* ===================================================================================== + 2. Outbound repository links point at a branch, not a commit + ===================================================================================== + + §12's rule, and the reason for it: a commit permalink is a fact frozen at a sha while + the document it names keeps moving. Every link on this site into one of these + repositories is meant to show a reader the CURRENT state of something — the module + contract, the operator guide, the protocol — and a permalink quietly stops doing that + the day after it is written, without ever 404ing. It is the failure mode a link + checker would otherwise call healthy. + + Gitea writes both shapes as `///src///…`, so the kind segment + is what decides it, and a 40-character hex ref is caught even when the kind segment + says branch — which is what a "branch" named after a sha actually is. */ + + for (const match of source.matchAll(/https?:\/\/[^\s"'<>)]+/g)) { + const raw = match[1] ?? match[0]; + let url; + try { + url = new URL(raw); + } catch { + continue; + } + if (url.host !== GITEA_HOST) continue; + + outboundChecked++; + + const segments = url.pathname.split('/').filter(Boolean); + // /////… + const kind = segments[2]; + const refKind = segments[3]; + const ref = segments[4]; + + if (!['src', 'raw', 'media'].includes(kind)) continue; + + if (refKind === 'commit' || refKind === 'tag') { + fail( + relative, + lineOf(source, match.index), + `${raw}\n points at a ${refKind}, not a branch. §12 requires branch paths, so a ` + + `reader always\n sees the document as it is now rather than as it was.` + ); + continue; + } + + if (ref && /^[0-9a-f]{40}$/i.test(ref)) { + fail( + relative, + lineOf(source, match.index), + `${raw}\n names a commit sha as its ref. Use a branch name — "main" for anything ` + + `canonical.` + ); + } + } +} + +/* ======================================================================================= + 3. The planned-route list has not rotted + ======================================================================================= + + The half that makes an exemption list safe. Once a phase builds one of these, the entry + stops being a promise and starts being a hole in rule 1 — so the build fails until it is + deleted. Reported per route, with the phase that was waiting for it, because the person + who just built the page is the person who should remove the line. */ + +const selfSource = readFileSync(path.join(ROOT, 'scripts/checkLinks.mjs'), 'utf8'); + +for (const [route, owner] of PLANNED_ROUTES) { + if (!resolvesInBuild(route)) continue; + const entry = selfSource.indexOf(`['${route}'`); + fail( + 'scripts/checkLinks.mjs', + entry === -1 ? 1 : lineOf(selfSource, entry), + `PLANNED_ROUTES still lists "${route}" (${owner}), but the build now serves it.\n` + + ` Delete the entry: every link to it is checked properly from here on.` + ); +} + +if (failures.length) { + console.error('\ncheckLinks: broken or non-canonical links.\n'); + for (const failure of failures) { + console.error(` ${failure.file}:${failure.line}\n ${failure.message}\n`); + } + process.exit(1); +} + +const pending = [...plannedSeen].sort(); + +console.log( + `checkLinks: ${linksChecked} internal link(s) resolve and ${outboundChecked} repository ` + + `link(s) point at a branch.` +); + +if (pending.length) { + console.log( + ` ${pending.length} link(s) point at a planned route: ` + + `${pending.join(', ')} — allowed until the phase that builds it.` + ); +} diff --git a/src/components/NotBuilt.astro b/src/components/NotBuilt.astro new file mode 100644 index 0000000..9150f05 --- /dev/null +++ b/src/components/NotBuilt.astro @@ -0,0 +1,132 @@ +--- +import { notBuiltFor, assertScopeNonEmpty } from '../data/notBuilt.mjs'; + +/** + * The deliberate absences (PLAN.md §2, D22), rendered for one page's scope. + * + * §2 describes its absent-features list as "as load-bearing as the rest", and this is the + * component that makes that true on a page rather than in a plan. It reads the shared list + * so `/features/`, `/integrations/` and `/modules/` cannot drift into telling three + * different stories about the same six things. + * + * --------------------------------------------------------------------------------------- + * WHY IT LOOKS LIKE THE REST OF THE PAGE + * --------------------------------------------------------------------------------------- + * Not a warning box, not a muted footnote, not an accordion. D8's "understated honesty" is + * a house style with a specific consequence here: a section that is visually apologetic + * teaches a reader that absences are embarrassing, and a section that is visually hidden + * teaches them to go looking for the ones you did not mention. These are decisions with + * reasons, so they are set as decisions with reasons — the same panels as everything else, + * in the same place in the rhythm. + * + * The one visual difference is the `resolvedBy` line, which every entry carries. An absence + * with an exit condition is a position; an absence without one is a hole. D8 gives the + * Integration Kit's draft status a defined removal condition and this generalises it. + */ +interface Props { + /** Which page is asking: `features`, `integrations` or `modules`. */ + scope: string; + /** Section heading. Each page frames the same list for its own reader. */ + title: string; +} + +const { scope, title } = Astro.props; + +assertScopeNonEmpty(scope); +const entries = notBuiltFor(scope); +--- + +
+

Not built

+

{title}

+

+ Every one of these is a decision rather than a backlog item, so each says why. Where the + reasoning was written down in the open, it is linked. +

+ +
    + { + entries.map((entry) => ( +
  • +

    {entry.title}

    +

    {entry.body}

    +

    + What would change it + {entry.resolvedBy} +

    + {entry.link && ( + + )} +
  • + )) + } +
+
+ + diff --git a/src/components/PageHeader.astro b/src/components/PageHeader.astro new file mode 100644 index 0000000..c5ec298 --- /dev/null +++ b/src/components/PageHeader.astro @@ -0,0 +1,55 @@ +--- +/** + * The opening of every marketing page except the homepage — eyebrow, `

`, lede. + * + * A component rather than four copies of the same three elements, because phase 4 writes + * five pages and phases 5 and 6 write four more. The homepage is deliberately not one of + * them: its `

` is the tagline inside the hero, set against the emblem, and pulling that + * into a shared header would either flatten the hero or push its layout in here (D19). + * + * The `

` is the page's own name, not the product's, and `Base` appends the site name to + * the document title — so a page sets a short `title` and gets "Features — Runic Gateway" + * in the tab and "Features" on the page. + */ +interface Props { + /** Small uppercase line above the title. What kind of page this is. */ + eyebrow: string; + title: string; +} + +const { eyebrow, title } = Astro.props; +--- + +
+

{eyebrow}

+

{title}

+
+ +
+
+ + diff --git a/src/components/architecture/Allowlist.astro b/src/components/architecture/Allowlist.astro new file mode 100644 index 0000000..ee86a58 --- /dev/null +++ b/src/components/architecture/Allowlist.astro @@ -0,0 +1,142 @@ +--- +/** + * "What reaches the public" — the second of `/architecture/`'s three diagrams (D21). + * + * The homepage states the split in one sentence inside the data-path walk ("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"). This is the page where that sentence has to + * become a picture, because it is the single design decision a technical evaluator is most + * entitled to be suspicious of: a live feed of a game world contains things that must never + * be published, and "we filter it" is a claim, not a mechanism. + * + * So the diagram draws the shape of the mechanism — one stream in, one decision, two streams + * out — and the notes say where the decision lives and what happens when it is wrong in + * either direction. What it deliberately does NOT do is enumerate event kinds: that is the + * catalog's job in the docs, it changes with the protocol, and a marketing page holding a + * copy of it would be a copy that goes stale (§1). + * + * The rings sit behind the filter rather than behind the whole picture, on the phase-3 + * principle that they mark the one place the argument actually happens. + */ +--- + +
+
+

What reaches the public

+

One feed in, two feeds out

+

+ A live game world emits things that are fine on a front page and things that are not: + who logged in from which address, what the cheat detector flagged, what a staff member + did to whom. Both arrive on the same connection, so something has to divide them. +

+
+ +
+
+ + +

+ The allowlist is the security boundary. A new kind of event is invisible to the public + until somebody adds it, which is the safe direction to fail in. +

+
+ +
+
+

It is an allowlist, not a blocklist

+

+ The public stream carries the kinds of event that are named as safe; everything else + goes to the staff stream by default. That ordering is the whole point. A blocklist + fails open — the day the game emits something new, it is already published — and an + allowlist fails closed, so the worst case is a page that is missing something rather + than a page that has published an address. +

+
+ +
+

The decision lives on your server

+

+ Not in the sidecar and not in the game. The bridge is a deliberately dumb forwarder: + it moves what the game emits and makes no judgements about audience. Everything + about who may see what is decided by the site you run, in one place, where you can + read it — and where changing it does not mean redeploying anything on the game host. +

+
+ +
+

More than two audiences, in practice

+

+ Two streams is the transport. Above it sits a configurable audience model — logged + out, signed in, linked to a game account, staff — that decides how much of a given + surface each of those sees. The public stream is the floor of that, and it is the + one that is a boundary rather than a setting. +

+
+ +
+

When the game is down

+

+ Nothing arrives, and the site carries on. Live surfaces say the server is offline + and everything that does not depend on it — the wiki, the news, accounts, the forums + — is unaffected. A site that goes down with the game it reports on is not much of a + status page. +

+
+
+
+
+ + diff --git a/src/components/architecture/ModuleSeam.astro b/src/components/architecture/ModuleSeam.astro new file mode 100644 index 0000000..f8f90b7 --- /dev/null +++ b/src/components/architecture/ModuleSeam.astro @@ -0,0 +1,166 @@ +--- +import platform from '../../data/platform.json'; + +/** + * "Where the game stops and the platform starts" — the third of `/architecture/`'s diagrams + * (D21). + * + * The other two draw runtime shapes. This one draws a code boundary, and it is here because + * it is the claim the whole project rests on: that a community platform can be built once + * and pointed at any game. An evaluator has every reason to read that as marketing, so the + * page draws the seam and then says plainly what does and does not prove it — one module + * exists, the second is a paper exercise, and the exit criterion for calling the contract + * proven is written down (§2, and the entries `/modules/` renders from `notBuilt.mjs`). + * + * The Module API version is read from `platform.json` like every other number on this site + * (§12). It is the one place a version genuinely belongs in this diagram: the seam is + * literally a version check, and a module whose declared range does not match refuses to + * load rather than half-loading. + */ +--- + +
+
+

Where the game stops

+

A seam, with a version on it

+

+ The core site does not know what a shard is, what a guild is, or that Ultima Online + exists. Everything that does lives in an installable module on the other side of a + declared interface — which is what makes "put your game on it" a shape rather than a + slogan. +

+
+ +
+
+ + +

+ A module declares which versions of the interface it speaks. If that does not match + what the site offers, it refuses to load and the site comes up without it. +

+
+ +
+
+

The module brings its own everything

+

+ Not just screens: its routes, its database tables, its navigation rows, its slice of + the OpenAPI spec and its own prebuilt client bundle. Installing it is a paste in the + admin panel or a line in your environment — never a build step, because production + runs an image you pulled, and an operator who has to compile something has been + handed a maintenance job rather than a feature. +

+
+ +
+

Failure is contained by design

+

+ A module that will not load is marked as failed and the site starts without it. + Disabling one is a kill switch, not a visibility flag — its routes stop answering + and its live connections close. Uninstalling keeps the data, and destroying the data + is a separate, deliberate choice made in its own dialog. +

+
+ +
+

Teams is the shape of the contract

+

+ Core owns the Teams primitive — the roster, the forum, the notifications, the voice + channel — and does not own the word. A Team cannot be created in core at + all; it arrives from the module, which is why the UO module calls them guilds and + builds those pages itself. That is the pattern the whole interface is built on: core + supplies the machinery, the module supplies the meaning. +

+
+ +
+

What this does not yet prove

+

+ One module exists and it is Ultima Online. A second, for a different game, is a + written dry-run that was deliberately never implemented — it exists to test whether + the contract generalises on paper. Until somebody builds the second one, the seam is + a well-argued design rather than a demonstrated one, and this site says so wherever + it comes up. +

+
+
+
+
+ + diff --git a/src/components/architecture/TwoHosts.astro b/src/components/architecture/TwoHosts.astro new file mode 100644 index 0000000..06101a3 --- /dev/null +++ b/src/components/architecture/TwoHosts.astro @@ -0,0 +1,122 @@ +--- +/** + * "What you actually deploy" — the first of `/architecture/`'s three diagrams (D21). + * + * This one exists because of a specific, repeated misunderstanding that §10 names and the + * homepage's CTA already spends two sentences on: a Runic Gateway install is two + * independent installs, on two machines, and neither installs the other. The homepage says + * it; this page draws it, because an evaluator deciding whether to run the software is + * doing capacity planning, and "how many machines is this" is the first question they have. + * + * Drawn generically for the same reason the homepage's diagram is (D17) — "your game host", + * not "your ServUO box" — with the prose beside it naming the real components. The boundary + * is the one drawn argument: everything above it is reachable because you published it, and + * everything below it is not reachable at all. + * + * The vocabulary and the layout are `src/styles/diagram.css`; only the geometry is here. + */ +--- + +
+
+

What you deploy

+

Two hosts, two installs

+

+ Almost everyone gets this wrong once. The website and the game-side bridge are separate + deployments on separate machines, and neither one installs the other — so a "Runic + Gateway install" is really two, done in that order. +

+
+ +
+
+ + +

+ Today the game server is a ServUO shard and the sidecar is uo-link. Two machines is + the minimum and also the maximum — nothing here scales by adding a third. +

+
+ +
+
+

The web host

+

+ A Docker Compose deployment: the site, its database, and whichever game module you + installed. Images are pulled rather than built, so nothing compiles here and an + upgrade is a pull and a restart. This is the only machine anybody points a browser + at, and the only one that needs a certificate. +

+
+ +
+

The game host

+

+ The machine your game server is already on. One installer binary puts the plugin + into the server's tree, installs the sidecar beside it and registers the service — + then prints four values. It never contacts your website; you paste those four + values into the admin panel yourself, and that is the moment the two halves meet. +

+
+ +
+

Why they share a host

+

+ The game talks to the sidecar over loopback, on the same machine, and dials + out to do it. That is what lets the game server open no port at all — and it + is also why there is no macOS installer build. The pair has to sit together, and no + game server anybody runs is on one. +

+
+ +
+

What crosses between them

+

+ One authenticated connection, in both directions: a WebSocket carrying the live feed + up, and REST calls going down for point-in-time questions. Nothing else on either + machine talks to the other, and the sidecar answers your site and nobody else. +

+
+
+
+
diff --git a/src/components/home/DataPath.astro b/src/components/home/DataPath.astro index 2583546..9461072 100644 --- a/src/components/home/DataPath.astro +++ b/src/components/home/DataPath.astro @@ -159,12 +159,6 @@ import platform from '../../data/platform.json'; top: calc(var(--header-h) + 1.5rem); } - .flow { - display: block; - width: 100%; - max-width: 380px; - } - .datapath__caption { margin: 1rem 0 0; max-width: 380px; @@ -172,75 +166,14 @@ import platform from '../../data/platform.json'; font-size: 0.85rem; } - /* ---- The drawing ------------------------------------------------------ - SVG presentation attributes cannot take a var(), so every colour here is - set as a CSS property on a class instead. That is also what keeps - checkTokens.mjs satisfied: no literal reaches the markup. */ - .node { - fill: var(--panel-b); - stroke: var(--line); - stroke-width: 1; - } + /* The SVG vocabulary this diagram draws with -- .node, .spine, .arrow, + .boundary, .rings -- now lives in src/styles/diagram.css, shared with + /architecture/'s three. It was duplicated in four files the moment the + second diagram existed, and the rules it holds are decisions about what a + diagram on this site looks like rather than about this one. - .node--self { - fill: var(--panel-a); - stroke: var(--gold-deep); - } - - .node-title { - fill: var(--head); - font-family: var(--sans); - font-size: 17px; - font-weight: 600; - } - - .node-sub { - fill: var(--dim); - font-family: var(--sans); - font-size: 12.5px; - } - - .spine { - fill: none; - stroke: var(--gold-deep); - stroke-width: 2; - } - - .spine--live { - stroke: var(--portal); - filter: drop-shadow(0 0 6px var(--portal-deep)); - } - - .arrow { - fill: var(--gold-deep); - stroke: none; - } - - .arrow--live { - fill: var(--portal); - } - - .boundary { - fill: none; - stroke: var(--line); - stroke-width: 1; - stroke-dasharray: 4 5; - } - - .boundary-label { - fill: var(--dim); - font-family: var(--sans); - font-size: 11px; - letter-spacing: 0.09em; - text-transform: uppercase; - } - - .rings { - fill: none; - stroke: var(--gold-deep); - stroke-width: 1; - opacity: 0.16; - } + The layout below stays here: the right-hand column is a numbered walk, + not the notes column .diagram__body assumes. */ /* ---- The list ---------------------------------------------------------- */ .datapath__steps { diff --git a/src/data/capabilities.mjs b/src/data/capabilities.mjs index 041b354..ba1d000 100644 --- a/src/data/capabilities.mjs +++ b/src/data/capabilities.mjs @@ -4,10 +4,16 @@ * --------------------------------------------------------------------------------------- * WHY THIS IS DATA AND NOT MARKUP * --------------------------------------------------------------------------------------- - * The homepage names these groups, `/features/` (phase 4) expands them, and `/modules/` - * explains the core/module split they encode. Three pages listing the same capabilities in - * three hand-maintained lists is how a site ends up advertising something that was removed, - * which §1 forbids. One list, read by all three. + * The homepage names these groups, `/features/` expands them, and `/modules/` explains the + * core/module split they encode. Three pages listing the same capabilities in three + * hand-maintained lists is how a site ends up advertising something that was removed, which + * §1 forbids. One list, read by all three. + * + * Phase 4 added the `detail` line rather than writing `/features/` as prose (D20). The two + * pages are then one list rendered twice — `/` takes the label, `/features/` takes the + * label and the detail — and they cannot disagree about what exists, only about how much + * they say. `assertDetailCoverage()` below is what stops the next capability being added to + * the homepage without an argument to go with it. * * --------------------------------------------------------------------------------------- * THE PART THAT IS A CHECK, NOT A LIST @@ -27,11 +33,74 @@ * separate user-facing capabilities, and the marketplace draws on `market` and `cliloc` * together (item names arrive as cliloc ids and are resolved against the shard's own * string table). The check is coverage in both directions, not a bijection. + * + * --------------------------------------------------------------------------------------- + * `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE + * --------------------------------------------------------------------------------------- + * A group is `moduleSupplied` or it is not, and phase 3 shipped the Community group saying + * "everything here works on a deployment with no game module installed at all". Writing the + * `/features/` detail for Teams is what exposed that as false, and the tree says so plainly + * on `main`: `teams.module_id` is `NOT NULL`, there is no create route anywhere under + * `/api/v1/admin/teams`, and `teamSync` is gated on `teamProvider.providerModuleId()`. + * + * The truth is neither of the two states the file had. Core owns the whole Team machinery — + * the tables, the roster resolver, the forums, the notification streams, the Discord bridge, + * the voice channels, the activity feed and `/admin/teams` — and cannot *originate* a Team. + * They arrive from the installed module, which is exactly the point: core does not own the + * word for a Team, so `module-uo` calls them guilds and builds the pages, and a future + * module can call them something else on the same primitive. + * + * So `needsModule` marks an item that is core machinery a module has to populate. On a bare + * core it is present, correct and permanently empty. `/` renders the requalified group + * summary; `/features/` renders the marker and says why (D24). + * + * --------------------------------------------------------------------------------------- + * `demoPath` — DEEP LINKS THAT ONLY EXIST WHEN A DEMO DOES + * --------------------------------------------------------------------------------------- + * §15/D12 keeps the public demo out of scope while requiring the site to gain one by way of + * a line in a mounted `brand.json`. `brand.json`'s own comment promised `/features/` a + * "per-capability affordance" that had never been defined; D25 defines it as a deep link + * per capability that has a stable public route, appended to the mounted `demoUrl` by + * `applyBrand.mjs` at boot and hidden by the same `[data-demo-url='']` rule as the + * homepage's slot. + * + * Only some capabilities have one, and that asymmetry is honest rather than unfinished: + * character sheets are reachable only by the account they belong to, and a Team forum lives + * behind a Team id no static page can know. Paths are read from the real route tables — + * core's `client/src/App.jsx` and `module-uo`'s `client/src/entry.jsx` on `main` — never + * guessed. Note the module's public pages are namespaced under its own id, so a UO route is + * `/uo/…`; a deployment running a different module would deep-link somewhere else, which is + * why these sit beside the `caps` slugs on the module-supplied items. */ /** - * Community — core, game-agnostic. Everything here works on a deployment with no game - * module installed at all. + * The shape of a capability, written out because TypeScript otherwise infers it per group + * from whichever fields that group's items happen to use — and then `/features/` cannot + * read `demoPath` off an Administration item, because no Administration item has one. + * `astro check` catches that, correctly: the union of five literal shapes is not the shape + * the page is written against. + * + * @typedef {object} Capability + * @property {string} label What it is called, on every page that lists it. + * @property {string} detail The argument for it. `/features/` only; see D20. + * @property {string[]} [caps] Module capability slugs, on module-supplied items only. + * @property {boolean} [needsModule] Core machinery a module has to populate (D24). + * @property {string} [demoPath] A stable public route, deep-linked into a demo (D25). + * + * @typedef {object} CapabilityGroup + * @property {string} id + * @property {string} title + * @property {string} summary + * @property {boolean} moduleSupplied + * @property {Capability[]} items + */ + +/** + * Community — core machinery. Everything here ships with the site itself and none of it + * knows what game you run; two of the six still need a module to put anything in them, + * which is what `needsModule` says. + * + * @type {CapabilityGroup} */ const community = { id: 'community', @@ -43,20 +112,66 @@ const community = { */ moduleSupplied: false, title: 'Community', - summary: 'The site your players actually use, none of which knows what game you run.', + summary: + 'The site your players actually use, none of which knows what game you run — though ' + + 'Teams arrive from the installed module rather than being created here.', items: [ - { label: 'Teams' }, - { label: 'Team forums' }, - { label: 'Notifications' }, - { label: 'Wiki' }, - { label: 'News and newsletter' }, - { label: 'Player self-service' }, + { + label: 'Teams', + needsModule: true, + demoPath: '/uo/guilds', + detail: + 'A roster, a leader, a private forum, its own notification streams and a Discord ' + + 'voice channel, all hanging off one group. Core owns every part of that machinery ' + + 'and deliberately cannot create a Team: they arrive from the installed module, ' + + 'which is how a guild inside the game becomes a Team on the site — and why a ' + + 'different game can call them something else without core learning a new word.', + }, + { + label: 'Team forums', + needsModule: true, + detail: + 'Announcements, discussion threads and replies, with an edit window, post ' + + 'moderation, and abuse reports a member can raise without going through staff ' + + 'first. Forums are an admin switch for the whole deployment, and image uploads ' + + 'stay off until someone deliberately turns them on.', + }, + { + label: 'Notifications', + detail: + 'Web, push and email, chosen per stream by each person rather than per person by ' + + 'you. Push arrives by default and can be switched off; email only ever arrives if ' + + 'it was asked for.', + }, + { + label: 'Wiki', + demoPath: '/wiki', + detail: + 'For the things that outlive a news post — rules, guides, the lore nobody wants to ' + + 'retype in chat. Written in the admin panel, published on the public site.', + }, + { + label: 'News and newsletter', + demoPath: '/site/news', + detail: + 'Four kinds of post — news, five-on-friday, newsletter issues and screenshots — ' + + 'plus CMS pages and a page builder for everything that is not a post at all.', + }, + { + label: 'Player self-service', + detail: + 'An account area every signed-in person gets, whatever their role: their profile, ' + + 'their linked game accounts, their own characters, their devices and sessions. ' + + 'Staff are players too, so it is the same area for everyone.', + }, ], }; /** * Game intelligence — module-supplied. The `caps` arrays are the contract with * `platform.json`; see `assertCapabilityCoverage` below. + * + * @type {CapabilityGroup} */ const gameIntelligence = { id: 'game-intelligence', @@ -66,59 +181,225 @@ const gameIntelligence = { 'Supplied by the installed game module, not by the core site. Today that module is ' + 'module-uo, and this is what it publishes from a live shard.', items: [ - { label: 'Live server status', caps: ['shard'] }, - { label: 'Economy and activity', caps: ['shard'] }, - { label: 'Character sheets', caps: ['shard'] }, - { label: 'Points and loyalty boards', caps: ['shard'] }, - { label: 'Player-vendor marketplace', caps: ['market', 'cliloc'] }, - { label: 'Houses and IDOC decay', caps: ['houses'] }, - { label: 'Spawn atlas', caps: ['atlas'] }, - { label: 'Champion boards', caps: ['champs'] }, - { label: 'Guilds', caps: ['guilds'] }, - { label: 'City governors', caps: ['governors'] }, + { + label: 'Live server status', + caps: ['shard'], + demoPath: '/uo/shard', + detail: + 'Whether the server is up, who is on it, and how long ago the site last heard from ' + + 'it. When the game is down this page is the thing that says so — the site does not ' + + 'go down with it.', + }, + { + label: 'Economy and activity', + caps: ['shard'], + demoPath: '/uo/shard/activity', + detail: + 'A live feed of what is happening in the world, and the economy underneath it. ' + + 'Every event passes an allowlist before it can reach a public page; staff read a ' + + 'second stream carrying the rest.', + }, + { + label: 'Character sheets', + caps: ['shard'], + detail: + 'Skills, stats and equipment, drawn from the live world rather than from a form ' + + 'somebody filled in. Reachable by the account the character is linked to, and by ' + + 'staff — not by the public.', + }, + { + label: 'Points and loyalty boards', + caps: ['shard'], + demoPath: '/uo/leaderboards', + detail: + 'The leaderboards the game already keeps, published without anyone exporting a ' + + 'spreadsheet on a Sunday.', + }, + { + label: 'Player-vendor marketplace', + caps: ['market', 'cliloc'], + demoPath: '/uo/market', + detail: + 'Every player vendor on the server and what is on it, searchable without logging ' + + 'in to the game. Item names arrive from the world as numeric ids and are resolved ' + + "against the game's own string table, so they read as names rather than numbers.", + }, + { + label: 'Houses and IDOC decay', + caps: ['houses'], + demoPath: '/uo/houses', + detail: + 'Who owns what and where it stands, including which houses are decaying — ' + + 'published while it is still information rather than after it has become a rumour.', + }, + { + label: 'Spawn atlas', + caps: ['atlas'], + demoPath: '/uo/atlas', + detail: + "A bestiary and spawn map built by reading your shard's own spawn tables, so it " + + "describes your server rather than someone else's idea of the game. Regions, " + + 'landmarks and champion altars come with it.', + }, + { + label: 'Champion boards', + caps: ['champs'], + demoPath: '/uo/champs', + detail: 'Which altars are running, how far along they are, and what turned up.', + }, + { + label: 'Guilds', + caps: ['guilds'], + demoPath: '/uo/guilds', + detail: + 'Guild rosters and standings, kept in step with the game. This is also what fills ' + + 'the Teams primitive above: a guild in the world becomes a Team on the site, with ' + + "the forum, the notifications and the voice channel that core attaches to one.", + }, + { + label: 'City governors', + caps: ['governors'], + demoPath: '/uo/governors', + detail: 'Who holds which city, and what they did with it.', + }, ], }; +/** @type {CapabilityGroup} */ const administration = { id: 'administration', moduleSupplied: false, title: 'Administration', summary: 'Running the place, with a record of who did what.', items: [ - { label: 'Roles and permissions' }, - { label: 'Moderation and appeals' }, - { label: 'Content reports' }, - { label: 'Append-only audit log' }, - { label: 'Bot scoring and IP bans' }, - { label: 'Module management' }, - { label: 'The game-server connection' }, + { + label: 'Roles and permissions', + detail: + 'Admin, moderator and player. Admin access is re-checked against the database on ' + + 'every request rather than trusted from whatever the session was issued with, so ' + + 'demoting someone takes effect on their next click and not at their next login.', + }, + { + label: 'Moderation and appeals', + detail: + 'Decisions carry a written reason, and the person on the receiving end has a ' + + 'documented way to answer rather than a direct message to whoever is awake.', + }, + { + label: 'Content reports', + detail: + 'Anything a member writes can be reported by another member, into a staff queue ' + + 'with the context attached.', + }, + { + label: 'Append-only audit log', + detail: + 'Staff actions are recorded, and nothing in the panel can edit or delete the ' + + 'record afterwards. That is worth having on the day you need to prove what did ' + + 'not happen.', + }, + { + label: 'Bot scoring and IP bans', + detail: + 'Login attempts are scored on behaviour rather than on a puzzle a real person has ' + + 'to solve, and a bad enough score bans the address by itself. The panel is a read ' + + 'view with an emergency unban, deliberately — it is not somewhere to tune a ' + + 'threshold at three in the morning.', + }, + { + label: 'Module management', + detail: + 'Install, disable, uninstall and purge a module from the panel. Uninstalling keeps ' + + 'the data and reinstalling picks it up where it was; deleting it is a separate, ' + + 'opt-in choice.', + }, + { + label: 'The game-server connection', + detail: + "The bridge's address, token and protocol version live in the panel rather than in " + + 'an environment file, so connecting a server is not a redeploy. The token is ' + + 'encrypted at rest and write-only in the API — it is never returned to any client, ' + + 'including yours.', + }, ], }; +/** @type {CapabilityGroup} */ const integration = { id: 'integration', moduleSupplied: false, title: 'Integration', summary: 'The seams that let other things reach in — and one game reach out.', items: [ - { label: 'Modules' }, - { label: 'The sidecar bridge' }, - { label: 'Discord: slash commands, notifications, voice' }, - { label: 'Mobile and push' }, - { label: 'SSO over OAuth2 / OIDC' }, + { + label: 'Modules', + detail: + 'The whole game-specific half of a deployment is an installable module: routes, ' + + 'screens, tables and nav rows, versioned against a declared core API. Installing ' + + 'one is a paste in the admin panel or a line in your environment, never a build.', + }, + { + label: 'The sidecar bridge', + detail: + 'A small service beside the game server, speaking a versioned wire protocol to the ' + + 'site and a loopback socket to the game. It is the only part of the bridge anything ' + + 'can reach over a network, and the game never listens at all.', + }, + { + label: 'Discord: slash commands, notifications, voice', + detail: + 'A bot for the guild you already have. Commands answer from your site, ' + + 'notifications bridge into channels, and a Team can be granted a voice channel ' + + 'that maintains its own membership.', + }, + { + label: 'Mobile and push', + detail: + 'A native Android app against the same documented API the website uses, with push ' + + 'delivered through your own ntfy server rather than a vendor in the middle.', + }, + { + label: 'SSO over OAuth2 / OIDC', + detail: + 'Google, Discord, or any OIDC provider you run. Link-only by policy: an external ' + + 'identity has to be attached to an account that already exists, and signing in ' + + 'with one never creates a user.', + }, ], }; +/** @type {CapabilityGroup} */ const infrastructure = { id: 'infrastructure', moduleSupplied: false, title: 'Infrastructure', summary: 'How it runs, and who it answers to.', items: [ - { label: 'Self-hosted, start to finish' }, - { label: 'Docker, with prebuilt pull-only images' }, - { label: 'Branding as data, not a rebuild' }, - { label: 'OpenAPI 3.0 for the whole API' }, + { + label: 'Self-hosted, start to finish', + detail: + 'There is no hosted tier and no account with us. Every part of this runs on ' + + 'hardware you control, which is the only arrangement under which the rest of the ' + + 'claims on this page mean anything.', + }, + { + label: 'Docker, with prebuilt pull-only images', + detail: + 'Compose up, compose down. Images are pulled rather than built, so nothing ' + + 'compiles on your server and an upgrade is a pull and a restart.', + }, + { + label: 'Branding as data, not a rebuild', + detail: + 'Name, colours, logo and contact address are a mounted file. The same image runs ' + + 'as any community — including this site, which is built the same way.', + }, + { + label: 'OpenAPI 3.0 for the whole API', + detail: + 'The spec ships with the server and an installed module merges its own routes into ' + + 'it, so the API you build against is the API that is actually running.', + }, ], }; @@ -130,6 +411,27 @@ export const capabilityGroups = [ infrastructure, ]; +/** + * One group by id, or a build failure naming the id that was asked for. + * + * `capabilityGroups.find(...)` returns `CapabilityGroup | undefined`, so every page that + * wants one group has to either handle an impossible undefined or assert past it — and the + * assertion is what would eventually ship a blank section after somebody renamed an id. + * Failing here instead means a renamed group is caught by the first page that reads it. + * + * @param {string} id + * @returns {CapabilityGroup} + */ +export function capabilityGroup(id) { + const group = capabilityGroups.find((candidate) => candidate.id === id); + if (group) return group; + + throw new Error( + `src/data/capabilities.mjs has no group with id "${id}", but a page asked for it.\n` + + `Known ids: ${capabilityGroups.map((candidate) => candidate.id).join(', ')}.\n` + ); +} + /** * Fails the build when the module's declared capabilities and this page's list disagree. * @@ -170,3 +472,32 @@ export function assertCapabilityCoverage(declared) { `\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n` ); } + +/** + * Fails the build when a capability has no `detail`. + * + * The homepage renders labels, so a capability added with nothing else still looks correct + * there — and `/features/` would silently render a heading with no argument under it. The + * asymmetry between the two renderings is the whole of D20, and this is what keeps the + * thinner one from being the only one anybody notices. + * + * Called from `/features/` for the same reason `assertCapabilityCoverage` is called from + * the homepage: the build error should name the page that would have shipped wrong. + */ +export function assertDetailCoverage() { + const missing = []; + for (const group of capabilityGroups) { + for (const item of group.items) { + if (!item.detail?.trim()) missing.push(`${group.title} → ${item.label}`); + } + } + + if (!missing.length) return; + + throw new Error( + `src/data/capabilities.mjs has ${missing.length} capabilit${missing.length === 1 ? 'y' : 'ies'} with no detail:\n` + + missing.map((entry) => ` - ${entry}`).join('\n') + + `\n\n/features/ renders the detail line (D20). A capability without one is a heading\n` + + `with nothing under it — write the sentence, or take the capability off the list.\n` + ); +} diff --git a/src/data/notBuilt.mjs b/src/data/notBuilt.mjs new file mode 100644 index 0000000..545b363 --- /dev/null +++ b/src/data/notBuilt.mjs @@ -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` + ); +} diff --git a/src/pages/architecture.astro b/src/pages/architecture.astro new file mode 100644 index 0000000..086f4d6 --- /dev/null +++ b/src/pages/architecture.astro @@ -0,0 +1,172 @@ +--- +import Base from '../layouts/Base.astro'; +import PageHeader from '../components/PageHeader.astro'; + +import TwoHosts from '../components/architecture/TwoHosts.astro'; +import Allowlist from '../components/architecture/Allowlist.astro'; +import ModuleSeam from '../components/architecture/ModuleSeam.astro'; + +import platform from '../data/platform.json'; +import { brand } from '../lib/brand.mjs'; + +/** + * `/architecture/` — PLAN.md §13 phase 4, built to D21. + * + * --------------------------------------------------------------------------------------- + * WHAT THIS PAGE IS FOR, AND WHAT IT DELIBERATELY IS NOT + * --------------------------------------------------------------------------------------- + * §10 gives it one audience: "a technical evaluator deciding whether to run it". That is a + * narrower job than "explain the system", and the narrowness is what keeps this page from + * becoming a worse copy of the Architecture section in the documentation, which phases 7 + * and 8 write. + * + * So the page answers four questions an evaluator actually has, in the order they have + * them — what am I deploying, what leaves my server, what is core and what is a module, + * and what happens when a part of it dies — and it answers them with drawings and reasons. + * It carries no endpoint tables, no configuration keys, no schema and no event catalog. + * Those exist, they are canonical elsewhere, and a second copy here would be a copy that + * goes stale (§1). Every one of them is a link out. + * + * The three diagrams are §11's motif doing actual work rather than decoration: each one + * draws a boundary, and the boundary is the argument in all three cases. The vocabulary + * they share lives in `src/styles/diagram.css`. + * + * --------------------------------------------------------------------------------------- + * LINKS OUT GO TO `/docs/`, NOT TO A GUESSED SLUG + * --------------------------------------------------------------------------------------- + * The same convention phase 3 set for the homepage: phases 7 and 8 own the documentation + * slugs, so linking `/docs/architecture/the-bridge/` today would put a URL in this file + * that nothing checks and a later phase would have to remember to fix. Links into the + * repositories are different — those are real paths that exist now, and `checkLinks.mjs` + * holds them to a branch path rather than a commit permalink. + */ +const title = 'Architecture'; +const description = + 'How Runic Gateway is put together: what you deploy, what crosses the network, and where ' + + 'the game-specific half stops.'; + +const docs = `${platform.gitea.base}/${platform.gitea.org}/docs/src/branch/main`; +--- + + + +

+ Three boundaries decide almost everything about how this software behaves: the one + between your two machines, the one between what the public sees and what staff see, and + the one between the platform and the game. Each is drawn below, with the reasoning + rather than the reference. +

+

+ Nothing here is a specification. Where a real one exists it is linked — the protocol, + the module contract and the operator guide are all documents in the open, and they are + the authority when this page and one of them disagree. +

+
+ + + + + +
+
+

Going deeper

+

The documents this page is a summary of

+

+ Everything above is an argument about shapes. These are the things that specify them, + and they are what a module author, an integrator or an operator should be reading. +

+ +
    +
  • + + The bridge protocol + + What the game and the sidecar say to each other, and what the sidecar publishes. + Protocol {platform.protocol} today, and versioned so a mismatched pair is refused + rather than misread. +
  • +
  • + + The module contract + + The normative interface between core and a module — currently + {platform.moduleApi}. This is the document that decides whether your module + loads. +
  • +
  • + + The operator guide + + Setting the game side up end to end, including the failure modes and what each + step should look like when it worked. +
  • +
  • + The documentation on this site + The same ground as a guided path rather than a specification, starting from an + empty server. +
  • +
+ + +
+
+ + + diff --git a/src/pages/community.astro b/src/pages/community.astro new file mode 100644 index 0000000..1a92edd --- /dev/null +++ b/src/pages/community.astro @@ -0,0 +1,288 @@ +--- +import Base from '../layouts/Base.astro'; +import PageHeader from '../components/PageHeader.astro'; + +import platform from '../data/platform.json'; +import { brand } from '../lib/brand.mjs'; + +/** + * `/community/` — PLAN.md §10 and §14 N3, built in phase 4. + * + * --------------------------------------------------------------------------------------- + * WHY IT IS IN THIS PHASE AT ALL + * --------------------------------------------------------------------------------------- + * §13's phase table never assigned it one. §10 specifies the page and §14 N3 specifies its + * contents, and the header nav and footer have both linked it since phase 1 — so it was a + * page the site pointed at and no phase built. The org lead folded it into phase 4 on + * 2026-08-20 rather than leaving it to be discovered by the link checker (D23). It is a + * marketing page with no new machinery, so this is where it fits. + * + * --------------------------------------------------------------------------------------- + * THE HONEST SPLIT, WHICH IS THE WHOLE POINT OF THE PAGE + * --------------------------------------------------------------------------------------- + * §14 N3 is explicit: this page describes a split rather than a single channel, because the + * obvious sentence — "found a bug? open an issue" — is currently false. Gitea registration + * is disabled on this instance, so the code is publicly readable and nobody outside the org + * can file anything against it. Discord is therefore the front door in fact, not just in + * preference (D10), and saying so is cheaper for a reader than letting them find the + * sign-up page and its refusal. + * + * N3 also records that this page is written the same way whether or not registration is + * later reopened — only one sentence changes. That sentence is marked below, so whoever + * changes the Gitea configuration can find it without rereading the page. + * + * The security address comes from `brand.json` and appears nowhere in this file. D13 + * publishes a personal address on the understanding that moving to a role address later is + * an edit to a mounted file, and `checkFacts.mjs` fails the build if an address is typed + * into any source file — including this one, which is the file most likely to want to. + */ +const title = 'Community'; +const description = + 'Where to ask, where the code is, and how to report a security problem.'; + +const gitea = `${platform.gitea.base}/${platform.gitea.org}`; +--- + + + +

+ This is a small project run by people with day jobs. There is no support desk and no + ticket queue, which is worth knowing before you choose where to put a question — one of + these channels answers in minutes and one of them may not answer at all. +

+
+ +
+
    +
  • +
    +

    Discord

    + The front door +
    +

    + Questions, bug reports, help getting an install working, and where the Android beta + is announced. No account with us to make, nothing to be approved for, and the + fastest way to reach somebody who has run this software. +

    +

    + Use it for + Anything you would otherwise open an issue for, and everything you would not. +

    + + Join the Discord + +
  • + +
  • +
    +

    The code

    + Read freely +
    +

    + Every repository is public and readable without signing in to anything — the + website, the bridge, the game plugin, the installer, the module, the app and all of + the documentation. Clone it, read it, run it. +

    +

    + One caveat + {/* + THE SENTENCE §14 N3 SAYS WILL CHANGE. If Gitea registration is reopened — + manual confirm, Turnstile, no repository creation by default — this becomes + "issues and pull requests are open to anyone with an account", and nothing else + on the page moves. + */} + Registration on our Gitea is closed at the moment, so filing an issue needs an + account we would have to create for you. Ask on Discord and it will reach the same + place. +

    + Browse the source +
  • + +
  • +
    +

    Security

    + Private +
    +

    + If you have found something that should not be discussed in a public channel, email + it. You will get a human, not a form, and there is no bounty programme to game — + just an acknowledgement and a fix. +

    +

    + Use it for + Anything that would let somebody reach a deployment, an account or a game server + they should not. +

    + {brand.contactEmail} +
  • +
+
+ +
+
+

Contributing

+

What is useful, in order

+ +
    +
  1. +

    Run it and say what broke

    +

    + The install path is the priority of this whole project, and the most valuable + thing anyone outside it can do is walk it on a machine we have never seen and + report where it stopped making sense. +

    +
  2. +
  3. +

    Build a module for another game

    +

    + There is one module and it is Ultima Online, so the claim that this platform is + game-agnostic is currently an argument rather than a demonstration. The + Integration Kit exists to be followed by somebody outside + this project — and it stays marked draft until it has been. +

    +
  4. +
  5. +

    Fix the documentation

    +

    + Documentation is versioned alongside the code it describes and a change is not + finished until the docs match it. If something you read was wrong, that is a bug + of the same kind as any other. +

    +
  6. +
+ +

+ All of it is free software under the GPL-3.0-or-later, and contributions carry one + house rule worth knowing before you start: work done with AI assistance has to say so + — a box on the pull request and a trailer on the commit. Undisclosed AI-generated + contributions get closed. Every repository's CONTRIBUTING.md has the + details. +

+
+
+ + + diff --git a/src/pages/features.astro b/src/pages/features.astro new file mode 100644 index 0000000..09405ee --- /dev/null +++ b/src/pages/features.astro @@ -0,0 +1,223 @@ +--- +import Base from '../layouts/Base.astro'; +import PageHeader from '../components/PageHeader.astro'; +import NotBuilt from '../components/NotBuilt.astro'; + +import platform from '../data/platform.json'; +import { + capabilityGroups, + assertCapabilityCoverage, + assertDetailCoverage, +} from '../data/capabilities.mjs'; + +/** + * `/features/` — PLAN.md §13 phase 4, built to D20. + * + * --------------------------------------------------------------------------------------- + * THE SAME LIST THE HOMEPAGE HAS, WITH THE ARGUMENT ATTACHED + * --------------------------------------------------------------------------------------- + * D18 put all five groups on the homepage named only, and left the per-capability argument + * here. This page is therefore not a second list: it is the same `capabilities.mjs` data + * rendered with the `detail` line the homepage drops. That is the whole of D20, and it is + * what makes "the site advertises something that was removed" a build failure rather than + * a thing somebody has to notice. + * + * Both assertions below run at build time and both name this page in their message. + * `assertCapabilityCoverage` is the module contract the homepage also runs — repeated here + * deliberately, since either page can be built alone and each should fail on its own. + * `assertDetailCoverage` is this page's own: a capability with no detail renders as a + * heading with nothing under it, and nothing else in the repo would notice. + * + * --------------------------------------------------------------------------------------- + * THREE THINGS THE MARKUP SAYS THAT THE HOMEPAGE DOES NOT + * --------------------------------------------------------------------------------------- + * 1. WHERE A CAPABILITY COMES FROM. Every group states whether core supplies it or the + * installed module does. The homepage carries one chip on one group; here it is a full + * sentence on all five, because this is the page a reader arrives at wanting to know + * what they get on a deployment with no module at all. + * + * 2. WHAT NEEDS A MODULE TO FILL IT. Teams and Team forums are core machinery that cannot + * originate a Team — see the `needsModule` note in `capabilities.mjs` for what the tree + * actually says. That is neither "core" nor "module-supplied", and a page that offered + * only those two words would have to lie in one direction or the other (D24). + * + * 3. WHERE TO SEE IT RUNNING. Capabilities with a stable public route carry a deep link + * into the demo, hidden until a `demoUrl` is mounted (D25). The markup contract is + * exact and `scripts/checkBrand.mjs` enforces it: + * + * href="" data-demo-url="" data-demo-path="/uo/market" + * + * `applyBrand.mjs` recomputes all three attributes at boot. Do not reorder them, do not + * insert anything between them, and do not write a path into the `href` — the rewrite + * matches bytes, and a stock build hides every one of these links, so a mistake here is + * invisible until the day somebody configures a demo. + */ +assertCapabilityCoverage(platform.moduleUoCapabilities); +assertDetailCoverage(); + +const title = 'Features'; +const description = + 'What a Runic Gateway deployment does — core, and what the installed game module adds.'; +--- + + + +

+ Grouped the way the software is actually divided, because that division is the thing + most worth understanding before you install it: the core site is game-agnostic and does + not know what a shard is, and everything that does arrives as an installable module. +

+

+ Today there is one module and it covers Ultima Online, so the second group below is + what a UO deployment gets. On a deployment with no module, that group is simply absent + and the other four are unchanged. +

+
+ + { + capabilityGroups.map((group) => ( +
+
+

{group.title}

+ + {group.moduleSupplied ? 'From the installed module' : 'Core'} + +
+ +

{group.summary}

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

    {item.label}

    + {item.needsModule && Needs a module} +
    + +

    {item.detail}

    + + {item.demoPath && ( + + See it running + + )} +
  • + ))} +
+
+ )) + } + + + + + diff --git a/src/pages/integrations.astro b/src/pages/integrations.astro new file mode 100644 index 0000000..39d6d54 --- /dev/null +++ b/src/pages/integrations.astro @@ -0,0 +1,301 @@ +--- +import Base from '../layouts/Base.astro'; +import PageHeader from '../components/PageHeader.astro'; +import NotBuilt from '../components/NotBuilt.astro'; + +import platform from '../data/platform.json'; + +/** + * `/integrations/` — PLAN.md §13 phase 4. + * + * §10 gives it Discord, mobile and push, SSO, "with an explicit 'not built' list". The + * explicit list is the reason this page is worth writing carefully: an integrations page is + * the one a reader scans for the name of the thing they already use, and the honest answer + * for several of those names is no. §2 calls the absent-features list as load-bearing as the + * rest, and `NotBuilt` at the foot of this page is where that lands. + * + * --------------------------------------------------------------------------------------- + * THE TRADE-OFFS ARE ON THE PAGE + * --------------------------------------------------------------------------------------- + * Each integration carries a `caveat` — the thing you would find out in week two. Discord + * voice channels make Team membership visible on a member's Discord profile, because they + * are granted by role; the mobile app has no server of ours to point at; SSO will not create + * an account. None of those is a defect and all three change whether someone wants the + * feature, so leaving them for the documentation would be the dishonest kind of brevity. + * That is D8's "understated honesty" doing something other than adjusting adjectives. + * + * No version numbers are typed here. The app version and the platform's own numbers come + * from `platform.json` (§12), which `checkFacts.mjs` re-reads from each repository's + * authority on every build. + */ +const title = 'Integrations'; +const description = + 'What Runic Gateway connects to — Discord, mobile push, single sign-on — and what it ' + + 'deliberately does not.'; + +const integrations = [ + { + id: 'discord', + name: 'Discord', + summary: + 'A bot for the server your community is already sitting in, doing three separate jobs.', + points: [ + { + title: 'Slash commands', + body: + 'Commands registered with your guild that answer from your site — so the thing ' + + 'somebody wants to look up is available where the conversation is happening, ' + + 'rather than one tab away.', + }, + { + title: 'Notifications into channels', + body: + 'News and Team activity bridged into the channels you choose, with a per-Team ' + + 'override so one group can route its own notifications somewhere else. Delivery ' + + 'is best-effort and one-shot: a Discord outage never backs anything up on your ' + + 'site.', + }, + { + title: 'A voice channel per Team', + body: + 'A Team can be granted its own voice channel, with membership maintained by the ' + + 'bot rather than by whoever is online. The bot creates the category, and the ' + + 'panel reports how many roles your guild has left before Discord’s own limit.', + }, + ], + caveat: + 'Voice access is granted with a Discord role, and roles are visible on a member’s ' + + 'profile — so a Team with a voice channel is a Team anyone in your guild can see the ' + + 'membership of. That was a deliberate trade for a limit that counts per guild rather ' + + 'than per channel, and it is the right one for most communities, but it is not private.', + }, + { + id: 'mobile', + name: 'Mobile and push', + summary: + 'A native Android app against the same documented API the website uses, with push ' + + 'through a server you run.', + points: [ + { + title: 'The same API, not a second one', + body: + 'The app is a client of the API your deployment already publishes, authenticated ' + + 'with short-lived tokens and rotated, revocable refresh tokens. There is no ' + + 'mobile-only backend to keep in step.', + }, + { + title: 'Push through your own ntfy', + body: + 'Notifications are delivered by a self-hosted ntfy server rather than a vendor in ' + + 'the middle. Each person chooses which streams reach them; push arrives by ' + + 'default and can be switched off entirely.', + }, + { + title: 'Trusted devices and two-factor', + body: + 'The app shares the site’s account model, including time-based two-factor ' + + 'codes, recovery codes, and devices you can mark as trusted and revoke later.', + }, + ], + caveat: + 'The app points at no server of ours: the person installing it types the address of ' + + 'the deployment they belong to. That is what makes one app work for every community ' + + 'running this software, and it means the app is useless until somebody gives them a ' + + 'URL — which is a thing worth putting in your welcome message.', + }, + { + id: 'sso', + name: 'Single sign-on', + summary: + 'OAuth2 and OIDC, against Google, Discord, or any provider you already run.', + points: [ + { + title: 'Any OIDC provider', + body: + 'Google and Discord are configured by name; anything else that speaks OIDC is ' + + 'configured generically. Client secrets are encrypted at rest and never returned ' + + 'to any client.', + }, + { + title: 'It signs people in, not up', + body: + 'An external identity has to be linked to an account that already exists on your ' + + 'site. Signing in with a provider never creates a user — which means the way ' + + 'someone joins your community stays a decision you make, not one Google makes.', + }, + { + title: 'It respects the rest of the login rules', + body: + 'Two-factor, trusted devices and bans all still apply. An identity provider ' + + 'proves who someone is; it does not decide whether they may come in.', + }, + ], + caveat: + 'Link-only is a policy, not a limitation to be worked around. If you were expecting ' + + 'to open registration by turning on Google sign-in, this will not do that, and it is ' + + 'not configurable.', + }, +]; +--- + + + +

+ Three integrations exist and are in use. Each one below says what it does, and then the + thing you would otherwise discover in week two — because an integrations page that only + lists the good half is how somebody ends up rebuilding their community around an + assumption. +

+

+ Everything here is configured on your own deployment, against services you already run + or already have an account with. Nothing routes through us; there is no us to route + through. +

+
+ + { + integrations.map((integration) => ( +
+

{integration.name}

+

{integration.summary}

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

    {point.title}

    +

    {point.body}

    +
  • + ))} +
+ +
+

Worth knowing first

+

{integration.caveat}

+
+
+ )) + } + +
+

Email, deliberately quiet

+

+ Your deployment can send email — Team notifications and newsletters, through an account + you connect — and it only ever sends to someone who asked for it. Email is the one + channel that is opt-in rather than opt-out, because an unwanted push notification is an + annoyance and an unwanted email is a complaint to somebody’s provider. +

+

+ This website is a separate matter: runicgateway.com sends no email at all, has + no mailbox behind it and no account to make. The address in the footer is a human being. +

+
+ + + +
+
+

If you need another one

+

The API is the integration point

+

+ The whole backend is described by an OpenAPI 3.0 specification that ships with the + server, and an installed module merges its own routes into it — so whatever you build + against is documented by the thing that is actually running, at + {' '}Module API {platform.moduleApi}. The bridge to a game server is a documented wire + protocol on the same principle, currently protocol {platform.protocol}. +

+ +
+
+ + + diff --git a/src/pages/modules.astro b/src/pages/modules.astro new file mode 100644 index 0000000..fe2cc47 --- /dev/null +++ b/src/pages/modules.astro @@ -0,0 +1,404 @@ +--- +import Base from '../layouts/Base.astro'; +import PageHeader from '../components/PageHeader.astro'; +import NotBuilt from '../components/NotBuilt.astro'; + +import platform from '../data/platform.json'; +import { capabilityGroup } from '../data/capabilities.mjs'; + +/** + * `/modules/` — PLAN.md §13 phase 4. + * + * §10 gives this page four jobs: what a module is, `module-uo` as the worked example, + * writing your own, and the Integration Kit with its draft badge (D8). They are in that + * order because they are increasing commitment — a reader deciding whether to install one, + * a reader wondering what they get, a reader considering building one. + * + * --------------------------------------------------------------------------------------- + * THE WORKED EXAMPLE READS ITS OWN CAPABILITIES + * --------------------------------------------------------------------------------------- + * The `module-uo` section lists what the module publishes, and it takes that list from + * `capabilities.mjs` rather than retyping it — the same list the homepage names and + * `/features/` expands, which is already checked against the module's own manifest through + * `platform.json` (§12). A third hand-maintained copy on this page is exactly the failure + * that machinery exists to prevent, and this is the page where it would be least visible. + * + * --------------------------------------------------------------------------------------- + * THE DRAFT CHIP IS A DECISION, NOT A DISCLAIMER + * --------------------------------------------------------------------------------------- + * D8 marks the Integration Kit draft until a second module is successfully built against + * it by somebody outside this project, and requires that status to carry its removal + * condition. Both are here: the chip, and the sentence that says what takes it down. The + * same absence appears in `notBuilt.mjs`, so a reader who scrolls past the chip meets it + * again in the list of things that do not exist. + */ +const title = 'Modules'; +const description = + 'What a module is, what the Ultima Online module publishes, and what it takes to write ' + + 'one for another game.'; + +const gitea = `${platform.gitea.base}/${platform.gitea.org}`; +const docs = `${gitea}/docs/src/branch/main`; + +const gameIntelligence = capabilityGroup('game-intelligence'); + +/** The three ways a module reaches a running deployment. None of them is a build. */ +const installPaths = [ + { + name: 'From the admin panel', + body: + 'Paste the URL of a release manifest into Admin → Modules and press restart when it ' + + 'asks. The site downloads the artifact, verifies the checksum the manifest declares, ' + + 'inspects the whole archive before writing a single file, and unpacks it.', + fits: 'The click path, for a host you have no shell on.', + }, + { + name: 'From your environment', + body: + 'Name the module and its version in one environment variable and the container ' + + 'resolves it at every start. Already at that version means no network call at all, so ' + + 'a restart with the internet down comes up unchanged.', + fits: 'A compose-managed host, where the running set should be a line you version-control.', + }, + { + name: 'By hand', + body: + 'Unpack the tarball into the modules directory and restart. The bundle is already ' + + 'assembled — the client half is prebuilt and its one runtime dependency ships inside.', + fits: 'Development, and any host where the other two do not fit.', + }, +]; +--- + + + +

+ A module is the entire game-specific half of a deployment, packaged: its routes, its + screens, its database tables, its navigation rows and its slice of the API + documentation. The core site holds accounts, Teams, the wiki, posts, moderation and the + admin panel, and knows nothing about any game at all. +

+

+ That division is not an aspiration bolted on afterwards. The Ultima Online support was + extracted out of the site into a module, and every URL it had before the move it still + has — which is the only version of this claim worth making. +

+
+ +
+

What you get

+

What installing one actually does

+ +
    +
  • +

    It brings its own everything

    +

    + Server routes, React screens, tables, nav rows and an OpenAPI fragment the site + merges into its own spec. Nothing about it is a patch to the core site, so + upgrading either half does not involve reconciling the other. +

    +
  • +
  • +

    You never build it

    +

    + The client half ships prebuilt and the artifact is verified against a published + checksum before anything is written to disk. Production runs an image you pulled; + an operator who has to compile something has been handed a maintenance job. +

    +
  • +
  • +

    It cannot take the site down

    +

    + A module whose declared interface version does not match is marked failed and the + site starts without it — loudly, rather than half-loading. Disabling one closes its + connections and stops its routes answering. +

    +
  • +
  • +

    Your data outlives it

    +

    + Uninstalling removes the module and keeps its tables, so reinstalling picks up + exactly where it was. Destroying the data is a separate, opt-in choice made in its + own dialog, and it says what it is about to do. +

    +
  • +
+
+ +
+

Installing

+

Three ways in, and none of them is a build

+

+ Which one you use is a question about your host, not about the module. All three end + the same way: a restart, and the module's screens appear in the navigation. +

+ +
    + { + installPaths.map((path) => ( +
  1. +

    {path.name}

    +

    {path.body}

    +

    {path.fits}

    +
  2. + )) + } +
+
+ +
+
+

The worked example

+

module-uo

+ {platform.releases['Module-uo']} +
+ +

+ The Ultima Online module, and the reference every module that follows is measured + against. It is what turns a general-purpose community site into something that knows + what a shard is — and it is the proof that the seam described on + the architecture page is real, because the code on the far + side of it was moved there rather than designed there. +

+ +
+
+

What it publishes

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

+ The same list features expands, read from one file that is + checked against the module's own manifest on every build. +

+
+ +
+
+

It connects to a real server

+

+ The module talks to the sidecar beside your game server, not to the game. You + deploy that side with the installer and paste four values into the admin panel; + nothing here requires the game to exist, and with no server configured the site + renders normally and shows it offline. +

+
+
+

It owns its own tables

+

+ Its schema is applied by the site on every boot and its data is its own. The + module declares which versions of the core interface it speaks — the site runs + {' '}{platform.moduleApi} — and refuses to load against one it does not. +

+
+
+

It is a separate release

+

+ Versioned, tagged and published on its own cadence, independently of the site. + Upgrading one does not mean upgrading the other, as long as the declared interface + range still holds. +

+
+
+
+
+ +
+
+

Writing your own

+

The Integration Kit

+ Draft +
+ +

+ A four-chapter book on putting a different game on this platform — the module, the + sidecar beside your game server, the plugin inside it — plus a template module that + continuous integration builds against a pinned version of the core site, so the + instructions cannot quietly stop working. +

+ +
+

Why it says draft

+

+ Because nobody outside this project has yet followed it to a working module, and that + is the only test of a set of instructions that counts. The badge comes off when + somebody does — that is the stated condition, not a mood, and it is written down so a + future reader knows when to take it down. +

+

+ Everything it teaches is real and in use. What is untested is whether it is + sufficient: whether someone with no access to this project's context can get + from an empty repository to a running module using it alone. +

+
+ + +
+ + + + + diff --git a/src/styles/diagram.css b/src/styles/diagram.css new file mode 100644 index 0000000..1de9510 --- /dev/null +++ b/src/styles/diagram.css @@ -0,0 +1,194 @@ +/* ============================================================================ + The diagram vocabulary + ============================================================================ + §11 makes hand-drawn SVG the site's motif, "used where it explains something". + Phase 3 drew the first one on the homepage; phase 4 drew three more on + `/architecture/`, at which point the same fifteen rules existed in four files. + + Two things live here and nothing else does: + + 1. The SVG vocabulary — what a node, a spine, an arrow and the boundary look + like. Shared by name, so a diagram is markup and the drawing is one + decision. `DataPath.astro` reads these too; it keeps its own layout, + because its right-hand column is a numbered walk rather than notes. + + 2. The `.diagram` layout — figure beside prose on a wide screen, figure + above prose on a narrow one. + + Every colour is a class rather than a presentation attribute, and that is not + a style preference: `var()` is only substituted in style declarations, so + `fill="var(--line)"` on an element parses and draws nothing at all. It is also + what keeps `checkTokens.mjs` green, since no literal ever reaches the markup. + + The two rules every diagram here follows, learned in phase 3: + + - An inline SVG cannot reflow. A tall, ~380px-wide viewBox with only node + titles inside it is legible on a phone AND useful at 1440px; a wide + horizontal diagram is neither. + - The picture is `aria-hidden` because the prose beside it says the same + thing better. The consequence is a rule: a diagram must never carry a fact + the prose does not. + -------------------------------------------------------------------------- */ + +/* ---- Layout ------------------------------------------------------------- */ + +.diagram__head h2 { + margin: 0 0 0.75rem; + font-size: clamp(1.6rem, 3.2vw, 2.1rem); +} + +.diagram__head .prose { + margin: 0; + color: var(--muted); +} + +.diagram__body { + display: grid; + gap: clamp(1.75rem, 4vw, 3rem); + margin-top: 2.5rem; + grid-template-columns: minmax(0, 380px) minmax(0, 1fr); + align-items: start; +} + +.diagram__figure { + position: sticky; + top: calc(var(--header-h) + 1.5rem); +} + +.diagram__caption { + margin: 1rem 0 0; + max-width: 380px; + color: var(--dim); + font-size: 0.85rem; +} + +.diagram__notes section + section { + margin-top: 1.5rem; +} + +.diagram__notes h3 { + margin: 0 0 0.4rem; + color: var(--gold); + font-size: 1.06rem; +} + +.diagram__notes p { + margin: 0; + max-width: var(--measure); + color: var(--muted); +} + +@media (max-width: 900px) { + .diagram__body { + grid-template-columns: minmax(0, 1fr); + } + + /* Sticky is a wide-screen affordance only. Once the figure sits above the + prose rather than beside it, pinning it would cover the thing it explains. */ + .diagram__figure { + position: static; + justify-self: center; + } +} + +/* ---- The drawing -------------------------------------------------------- */ + +.flow { + display: block; + width: 100%; + max-width: 380px; +} + +/* An outer grouping: a machine, a process boundary, a side of a contract. Sits + under the nodes it contains, so it reads as the thing they are inside. */ +.host { + fill: var(--panel-flat); + stroke: var(--line-soft); + stroke-width: 1; +} + +.host-title { + fill: var(--head); + font-family: var(--sans); + font-size: 16px; + font-weight: 600; +} + +.host-sub { + fill: var(--dim); + font-family: var(--sans); + font-size: 11.5px; +} + +.node { + fill: var(--panel-b); + stroke: var(--line); + stroke-width: 1; +} + +/* The one node that is the reader's own site. Gold edge, because gold is + emphasis everywhere else on the site too. */ +.node--self { + fill: var(--panel-a); + stroke: var(--gold-deep); +} + +.node-title { + fill: var(--head); + font-family: var(--sans); + font-size: 15px; + font-weight: 600; +} + +.node-sub { + fill: var(--dim); + font-family: var(--sans); + font-size: 11.5px; +} + +.spine { + fill: none; + stroke: var(--gold-deep); + stroke-width: 2; +} + +/* Cyan is the live signal everywhere on this site — the same colour the portal + in the emblem is, and the same one the homepage draws the event feed in. A + spine in this colour means data actually moving, not a relationship. */ +.spine--live { + stroke: var(--portal); + filter: drop-shadow(0 0 6px var(--portal-deep)); +} + +.arrow { + fill: var(--gold-deep); + stroke: none; +} + +.arrow--live { + fill: var(--portal); +} + +.boundary { + fill: none; + stroke: var(--line); + stroke-width: 1; + stroke-dasharray: 4 5; +} + +.boundary-label { + fill: var(--dim); + font-family: var(--sans); + font-size: 11px; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +/* The emblem's concentric rings, used as a ground behind the one place a + diagram's argument actually happens. */ +.rings { + fill: none; + stroke: var(--gold-deep); + stroke-width: 1; + opacity: 0.16; +} diff --git a/src/styles/global.css b/src/styles/global.css index 3a84cf8..c20e1e8 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -8,6 +8,12 @@ @import '@fontsource-variable/cinzel'; @import '@fontsource-variable/inter'; +/* The SVG diagram vocabulary and the figure-beside-prose layout, shared by the + homepage's data path and `/architecture/`'s three. Its own file because it is + a self-contained language rather than part of the shell — see its header for + the two rules every diagram on this site follows. */ +@import './diagram.css'; + *, *::before, *::after { @@ -437,8 +443,25 @@ svg { Written here, before phase 3 writes that markup, because the rule and the rewrite have to agree and they live in different files. */ +/* `!important`, and it is earning its keep rather than papering over something. + + This selector is specificity 0,1,0. So is a class — including the scoped class an + Astro component puts on the very same element — and a component's styles are emitted + AFTER this file, so any component that gives one of these elements a `display` wins on + source order alone. Phase 4 did exactly that: `/features/`'s `.demo-link` set + `display: inline-flex` for its arrow, and twelve links to a demo that does not exist + appeared on the page, each one pointing at `href=""` — which a browser resolves to the + page it is already on. + + Nothing caught it. checkBrand.mjs verifies the ATTRIBUTES, and they were perfect; the + defect was three files away in the cascade. It was found by looking at the rendered + page, which is not a mechanism. + + So the rule is stated as one: while there is no demo, these elements do not render, and + no component style may overrule that by accident. A component that genuinely needs to + lay one of these out sets every property except `display`. */ [data-demo-url=''] { - display: none; + display: none !important; } /* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button