feat(marketing): phase 4 — the marketing pages #7
@@ -44,6 +44,19 @@ jobs:
|
|||||||
- name: Production build
|
- name: Production build
|
||||||
run: npm run 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
|
- name: Platform facts
|
||||||
# PLAN.md §12 — every version, protocol number and bundle tag is re-read from
|
# 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.
|
# its authority over the Gitea API and must agree with src/data/platform.json.
|
||||||
|
|||||||
118
PLAN.md
118
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. |
|
| **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. |
|
| **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
|
## 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
|
capabilities and the site was omitting two of the eight. That correction is now mechanical rather
|
||||||
than editorial — see D18.
|
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
|
### How phase 3 built the homepage
|
||||||
|
|
||||||
Three decisions taken before the page was written (org lead, 2026-08-20).
|
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
|
`/docs/`, because phases 7 and 8 own those slugs and a guessed one would be a stale URL nothing
|
||||||
checks.
|
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
|
### 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
|
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
|
||||||
mechanism, diligence is not.
|
mechanism, diligence is not.
|
||||||
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
|
- **`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).
|
- **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
|
||||||
- `astro check` plus a production build, in CI on every PR.
|
- `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` |
|
| **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 |
|
| **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 |
|
| **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) |
|
| **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) |
|
| **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** |
|
| **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) |
|
| **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 |
|
| **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 |
|
| **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) |
|
| **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
|
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
"check:facts": "node scripts/checkFacts.mjs",
|
"check:facts": "node scripts/checkFacts.mjs",
|
||||||
"check:tokens": "node scripts/checkTokens.mjs",
|
"check:tokens": "node scripts/checkTokens.mjs",
|
||||||
"check:brand": "node scripts/checkBrand.mjs",
|
"check:brand": "node scripts/checkBrand.mjs",
|
||||||
|
"check:links": "node scripts/checkLinks.mjs",
|
||||||
"brand:assets": "node scripts/buildBrandAssets.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": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.1.4",
|
"@astrojs/node": "^11.1.4",
|
||||||
|
|||||||
@@ -175,6 +175,34 @@ if (demoFrom !== demoTo) {
|
|||||||
replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(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:
|
||||||
|
*
|
||||||
|
* <a class="demo-link" href="" data-demo-url="" data-demo-path="/uo/market">see it live</a>
|
||||||
|
*
|
||||||
|
* 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) {
|
if (!replacements.length) {
|
||||||
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
|
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -198,6 +226,7 @@ function* walk(dir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const counts = new Map(replacements.map((r) => [r.field, 0]));
|
const counts = new Map(replacements.map((r) => [r.field, 0]));
|
||||||
|
counts.set('demoDeep', 0);
|
||||||
let filesTouched = 0;
|
let filesTouched = 0;
|
||||||
|
|
||||||
for (const file of walk(CLIENT)) {
|
for (const file of walk(CLIENT)) {
|
||||||
@@ -210,6 +239,15 @@ for (const file of walk(CLIENT)) {
|
|||||||
after = after.split(from).join(to);
|
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) {
|
if (after !== before) {
|
||||||
writeFileSync(file, after);
|
writeFileSync(file, after);
|
||||||
filesTouched++;
|
filesTouched++;
|
||||||
@@ -226,6 +264,11 @@ for (const { field, from, to } of replacements) {
|
|||||||
if (demoFrom !== demoTo) {
|
if (demoFrom !== demoTo) {
|
||||||
console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`);
|
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
|
// 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
|
// applied here reaches the pages but not the search results. Worth fixing when search
|
||||||
|
|||||||
@@ -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(/<!--[\s\S]*?-->/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) {
|
if (failures.length) {
|
||||||
@@ -301,5 +382,6 @@ if (failures.length) {
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
|
`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.`
|
||||||
);
|
);
|
||||||
|
|||||||
311
scripts/checkLinks.mjs
Normal file
311
scripts/checkLinks.mjs
Normal file
@@ -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 <path>]
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 `/<owner>/<repo>/src/<kind>/<ref>/…`, 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);
|
||||||
|
// <owner>/<repo>/<kind>/<refkind>/<ref>/…
|
||||||
|
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.`
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/components/NotBuilt.astro
Normal file
132
src/components/NotBuilt.astro
Normal file
@@ -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);
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="page section notbuilt">
|
||||||
|
<p class="eyebrow">Not built</p>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p class="prose notbuilt__lede">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="notbuilt__grid">
|
||||||
|
{
|
||||||
|
entries.map((entry) => (
|
||||||
|
<li class="panel notbuilt__item">
|
||||||
|
<h3>{entry.title}</h3>
|
||||||
|
<p class="notbuilt__body">{entry.body}</p>
|
||||||
|
<p class="notbuilt__resolved">
|
||||||
|
<span class="notbuilt__resolved-label">What would change it</span>
|
||||||
|
{entry.resolvedBy}
|
||||||
|
</p>
|
||||||
|
{entry.link && (
|
||||||
|
<p class="notbuilt__link">
|
||||||
|
<a href={entry.link.href} rel="noopener noreferrer">
|
||||||
|
{entry.link.label}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.notbuilt h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notbuilt__lede {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notbuilt__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 2.25rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.notbuilt__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notbuilt__item h3 {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Takes the slack, so the exit condition sits at the foot of every card in a row
|
||||||
|
rather than immediately under a body of whatever length — the same kind of
|
||||||
|
statement in the same place on each, which is what makes them readable as a row. */
|
||||||
|
.notbuilt__body {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notbuilt__resolved {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notbuilt__resolved-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notbuilt__link {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
src/components/PageHeader.astro
Normal file
55
src/components/PageHeader.astro
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The opening of every marketing page except the homepage — eyebrow, `<h1>`, 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 `<h1>` 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 `<h1>` 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;
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="page section pagehead">
|
||||||
|
<p class="eyebrow">{eyebrow}</p>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<div class="prose pagehead__lede">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* The section rhythm gives generous space below; the header wants less, because the
|
||||||
|
first section under it is part of the same thought. */
|
||||||
|
.pagehead {
|
||||||
|
padding-bottom: clamp(1rem, 2.5vw, 1.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagehead h1 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: clamp(2rem, 5vw, 2.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagehead__lede {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.06rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagehead__lede :global(p) {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagehead__lede :global(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
142
src/components/architecture/Allowlist.astro
Normal file
142
src/components/architecture/Allowlist.astro
Normal file
@@ -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.
|
||||||
|
*/
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="page section diagram" id="allowlist">
|
||||||
|
<div class="diagram__head">
|
||||||
|
<p class="eyebrow">What reaches the public</p>
|
||||||
|
<h2>One feed in, two feeds out</h2>
|
||||||
|
<p class="prose">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diagram__body">
|
||||||
|
<div class="diagram__figure">
|
||||||
|
<svg viewBox="0 0 380 470" class="flow" aria-hidden="true" focusable="false">
|
||||||
|
<!-- Centred on the filter: the one place in the picture where the argument is. -->
|
||||||
|
<g class="rings">
|
||||||
|
<circle cx="190" cy="178" r="96" />
|
||||||
|
<circle cx="190" cy="178" r="136" />
|
||||||
|
<circle cx="190" cy="178" r="176" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect class="node" x="20" y="12" width="340" height="60" rx="10" />
|
||||||
|
<text class="node-title" x="40" y="38">Everything the game emits</text>
|
||||||
|
<text class="node-sub" x="40" y="58">one authenticated stream, from the sidecar</text>
|
||||||
|
|
||||||
|
<path class="spine spine--live" d="M190 80 V132" />
|
||||||
|
<path class="arrow arrow--live" d="M190 140 l-6 -10 h12 Z" />
|
||||||
|
|
||||||
|
<rect class="node node--self" x="20" y="142" width="340" height="72" rx="10" />
|
||||||
|
<text class="node-title" x="40" y="172">Your site decides</text>
|
||||||
|
<text class="node-sub" x="40" y="192">one allowlist, in one place, on your server</text>
|
||||||
|
|
||||||
|
<!-- Diverging: the public leg in cyan because it is still a live feed; the staff
|
||||||
|
leg in gold because it is the privileged one. -->
|
||||||
|
<path class="spine spine--live" d="M120 222 C120 268 96 268 96 306" />
|
||||||
|
<path class="arrow arrow--live" d="M96 314 l-6 -10 h12 Z" />
|
||||||
|
|
||||||
|
<path class="spine" d="M260 222 C260 268 284 268 284 306" />
|
||||||
|
<path class="arrow" d="M284 314 l-6 -10 h12 Z" />
|
||||||
|
|
||||||
|
<rect class="node" x="8" y="316" width="176" height="128" rx="10" />
|
||||||
|
<text class="node-title" x="26" y="344">Public pages</text>
|
||||||
|
<text class="node-sub" x="26" y="366">an allowlist of event</text>
|
||||||
|
<text class="node-sub" x="26" y="382">kinds, and nothing</text>
|
||||||
|
<text class="node-sub" x="26" y="398">outside it</text>
|
||||||
|
<text class="node-audience" x="26" y="424">anyone at all</text>
|
||||||
|
|
||||||
|
<rect class="node" x="196" y="316" width="176" height="128" rx="10" />
|
||||||
|
<text class="node-title" x="214" y="344">Staff console</text>
|
||||||
|
<text class="node-sub" x="214" y="366">the rest: audit trail,</text>
|
||||||
|
<text class="node-sub" x="214" y="382">login attempts,</text>
|
||||||
|
<text class="node-sub" x="214" y="398">addresses, cheat flags</text>
|
||||||
|
<text class="node-audience" x="214" y="424">signed-in staff only</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<p class="diagram__caption">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diagram__notes">
|
||||||
|
<section>
|
||||||
|
<h3>It is an allowlist, not a blocklist</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>The decision lives on your server</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>More than two audiences, in practice</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>When the game is down</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Each outcome node ends with a line naming its audience, set apart from the
|
||||||
|
description above it rather than reading as another line of it.
|
||||||
|
|
||||||
|
Its own class, not `:nth-last-of-type`: an index into a list of `<text>`
|
||||||
|
siblings is correct only until somebody adds a label, and it fails by
|
||||||
|
styling the wrong words rather than by failing. */
|
||||||
|
.node-audience {
|
||||||
|
fill: var(--muted);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
166
src/components/architecture/ModuleSeam.astro
Normal file
166
src/components/architecture/ModuleSeam.astro
Normal file
@@ -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.
|
||||||
|
*/
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="page section diagram" id="module-seam">
|
||||||
|
<div class="diagram__head">
|
||||||
|
<p class="eyebrow">Where the game stops</p>
|
||||||
|
<h2>A seam, with a version on it</h2>
|
||||||
|
<p class="prose">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diagram__body">
|
||||||
|
<div class="diagram__figure">
|
||||||
|
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||||
|
<!-- Core: what ships in the image, on every deployment, module or not. -->
|
||||||
|
<rect class="host" x="8" y="8" width="364" height="186" rx="14" />
|
||||||
|
<text class="host-title" x="28" y="42">Runic Gateway core</text>
|
||||||
|
<text class="host-sub" x="28" y="62">game-agnostic; the same image everywhere</text>
|
||||||
|
|
||||||
|
<rect class="node node--self" x="28" y="80" width="156" height="46" rx="10" />
|
||||||
|
<text class="node-title" x="46" y="108">Accounts</text>
|
||||||
|
|
||||||
|
<rect class="node node--self" x="196" y="80" width="156" height="46" rx="10" />
|
||||||
|
<text class="node-title" x="214" y="108">Teams</text>
|
||||||
|
|
||||||
|
<rect class="node node--self" x="28" y="134" width="156" height="46" rx="10" />
|
||||||
|
<text class="node-title" x="46" y="162">Wiki and posts</text>
|
||||||
|
|
||||||
|
<rect class="node node--self" x="196" y="134" width="156" height="46" rx="10" />
|
||||||
|
<text class="node-title" x="214" y="162">Admin and API</text>
|
||||||
|
|
||||||
|
<!-- The seam. Both boundary lines and the label between them: this is the one
|
||||||
|
thing in the picture that is neither core nor module. -->
|
||||||
|
<path class="boundary" d="M8 224 H372" />
|
||||||
|
<text class="seam-label" x="190" y="252" text-anchor="middle">
|
||||||
|
Module API {platform.moduleApi}
|
||||||
|
</text>
|
||||||
|
<path class="boundary" d="M8 272 H372" />
|
||||||
|
|
||||||
|
<!-- Registers upward; is asked downward. Two arrows, opposite directions, because
|
||||||
|
the traffic across a seam is not one-way and drawing it as one-way is what
|
||||||
|
makes people think a module is a plugin that only listens. -->
|
||||||
|
<path class="spine" d="M120 300 V206" />
|
||||||
|
<path class="arrow" d="M120 198 l-6 10 h12 Z" />
|
||||||
|
<text class="seam-arrow" x="136" y="216">registers</text>
|
||||||
|
|
||||||
|
<path class="spine" d="M260 200 V294" />
|
||||||
|
<path class="arrow" d="M260 302 l-6 -10 h12 Z" />
|
||||||
|
<text class="seam-arrow" x="244" y="290" text-anchor="end">calls</text>
|
||||||
|
|
||||||
|
<!-- The module: everything that knows a game exists. -->
|
||||||
|
<rect class="host" x="8" y="306" width="364" height="186" rx="14" />
|
||||||
|
<text class="host-title" x="28" y="340">Game module</text>
|
||||||
|
<text class="host-sub" x="28" y="360">one per deployment; UO today</text>
|
||||||
|
|
||||||
|
<rect class="node" x="28" y="378" width="156" height="46" rx="10" />
|
||||||
|
<text class="node-title" x="46" y="406">Routes</text>
|
||||||
|
|
||||||
|
<rect class="node" x="196" y="378" width="156" height="46" rx="10" />
|
||||||
|
<text class="node-title" x="214" y="406">Screens</text>
|
||||||
|
|
||||||
|
<rect class="node" x="28" y="432" width="156" height="46" rx="10" />
|
||||||
|
<text class="node-title" x="46" y="460">Its own tables</text>
|
||||||
|
|
||||||
|
<rect class="node" x="196" y="432" width="156" height="46" rx="10" />
|
||||||
|
<text class="node-title" x="214" y="460">Nav rows</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<p class="diagram__caption">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diagram__notes">
|
||||||
|
<section>
|
||||||
|
<h3>The module brings its own everything</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Failure is contained by design</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Teams is the shape of the contract</h3>
|
||||||
|
<p>
|
||||||
|
Core owns the Teams primitive — the roster, the forum, the notifications, the voice
|
||||||
|
channel — and does not own the <em>word</em>. 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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>What this does not yet prove</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* The seam label sits between the two boundary rules rather than beside them: it is
|
||||||
|
the name of the gap, not an annotation on either side of it. Gold, because it is
|
||||||
|
the one contract in the picture. */
|
||||||
|
.seam-label {
|
||||||
|
fill: var(--gold);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two words, because two arrows crossing a boundary in opposite directions is
|
||||||
|
ambiguous without them — and the ambiguity is the exact misreading this diagram
|
||||||
|
exists to prevent, that a module is something core talks at. */
|
||||||
|
.seam-arrow {
|
||||||
|
fill: var(--dim);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
122
src/components/architecture/TwoHosts.astro
Normal file
122
src/components/architecture/TwoHosts.astro
Normal file
@@ -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.
|
||||||
|
*/
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="page section diagram" id="two-hosts">
|
||||||
|
<div class="diagram__head">
|
||||||
|
<p class="eyebrow">What you deploy</p>
|
||||||
|
<h2>Two hosts, two installs</h2>
|
||||||
|
<p class="prose">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diagram__body">
|
||||||
|
<div class="diagram__figure">
|
||||||
|
<svg viewBox="0 0 380 546" class="flow" aria-hidden="true" focusable="false">
|
||||||
|
<!-- The web host, and everything that runs on it. -->
|
||||||
|
<rect class="host" x="8" y="8" width="364" height="232" rx="14" />
|
||||||
|
<text class="host-title" x="28" y="42">Your web host</text>
|
||||||
|
<text class="host-sub" x="28" y="62">a VPS, a home server, anything running Docker</text>
|
||||||
|
|
||||||
|
<rect class="node node--self" x="28" y="80" width="324" height="60" rx="10" />
|
||||||
|
<text class="node-title" x="46" y="106">Runic Gateway</text>
|
||||||
|
<text class="node-sub" x="46" y="126">one container, pulled not built</text>
|
||||||
|
|
||||||
|
<rect class="node" x="28" y="150" width="156" height="60" rx="10" />
|
||||||
|
<text class="node-title" x="46" y="176">Game module</text>
|
||||||
|
<text class="node-sub" x="46" y="196">installed, not built</text>
|
||||||
|
|
||||||
|
<rect class="node" x="196" y="150" width="156" height="60" rx="10" />
|
||||||
|
<text class="node-title" x="214" y="176">Database</text>
|
||||||
|
<text class="node-sub" x="214" y="196">your data, your disk</text>
|
||||||
|
|
||||||
|
<!-- The one hop between them, and the only one. Two arrowheads because the traffic
|
||||||
|
genuinely goes both ways: the site calls the sidecar for point-in-time reads,
|
||||||
|
and the sidecar pushes the live feed back up. -->
|
||||||
|
<path class="spine spine--live" d="M190 248 V312" />
|
||||||
|
<path class="arrow arrow--live" d="M190 240 l-6 10 h12 Z" />
|
||||||
|
<path class="arrow arrow--live" d="M190 320 l-6 -10 h12 Z" />
|
||||||
|
|
||||||
|
<path class="boundary" d="M8 280 H372" />
|
||||||
|
<text class="boundary-label" x="372" y="273" text-anchor="end">the network</text>
|
||||||
|
|
||||||
|
<!-- The game host. Nothing here is reachable from outside except the sidecar. -->
|
||||||
|
<rect class="host" x="8" y="320" width="364" height="214" rx="14" />
|
||||||
|
<text class="host-title" x="28" y="354">Your game host</text>
|
||||||
|
<text class="host-sub" x="28" y="374">where the game server already runs</text>
|
||||||
|
|
||||||
|
<rect class="node" x="28" y="392" width="324" height="60" rx="10" />
|
||||||
|
<text class="node-title" x="46" y="418">Sidecar</text>
|
||||||
|
<text class="node-sub" x="46" y="438">the only part of this with a port open</text>
|
||||||
|
|
||||||
|
<rect class="node" x="28" y="462" width="324" height="60" rx="10" />
|
||||||
|
<text class="node-title" x="46" y="488">Game server</text>
|
||||||
|
<text class="node-sub" x="46" y="508">dials out over loopback; listens for nothing</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<p class="diagram__caption">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diagram__notes">
|
||||||
|
<section>
|
||||||
|
<h3>The web host</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>The game host</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Why they share a host</h3>
|
||||||
|
<p>
|
||||||
|
The game talks to the sidecar over loopback, on the same machine, and dials
|
||||||
|
<em>out</em> 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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>What crosses between them</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -159,12 +159,6 @@ import platform from '../../data/platform.json';
|
|||||||
top: calc(var(--header-h) + 1.5rem);
|
top: calc(var(--header-h) + 1.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datapath__caption {
|
.datapath__caption {
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
max-width: 380px;
|
max-width: 380px;
|
||||||
@@ -172,75 +166,14 @@ import platform from '../../data/platform.json';
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- The drawing ------------------------------------------------------
|
/* The SVG vocabulary this diagram draws with -- .node, .spine, .arrow,
|
||||||
SVG presentation attributes cannot take a var(), so every colour here is
|
.boundary, .rings -- now lives in src/styles/diagram.css, shared with
|
||||||
set as a CSS property on a class instead. That is also what keeps
|
/architecture/'s three. It was duplicated in four files the moment the
|
||||||
checkTokens.mjs satisfied: no literal reaches the markup. */
|
second diagram existed, and the rules it holds are decisions about what a
|
||||||
.node {
|
diagram on this site looks like rather than about this one.
|
||||||
fill: var(--panel-b);
|
|
||||||
stroke: var(--line);
|
|
||||||
stroke-width: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node--self {
|
The layout below stays here: the right-hand column is a numbered walk,
|
||||||
fill: var(--panel-a);
|
not the notes column .diagram__body assumes. */
|
||||||
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 list ---------------------------------------------------------- */
|
/* ---- The list ---------------------------------------------------------- */
|
||||||
.datapath__steps {
|
.datapath__steps {
|
||||||
|
|||||||
@@ -4,10 +4,16 @@
|
|||||||
* ---------------------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------------------
|
||||||
* WHY THIS IS DATA AND NOT MARKUP
|
* WHY THIS IS DATA AND NOT MARKUP
|
||||||
* ---------------------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------------------
|
||||||
* The homepage names these groups, `/features/` (phase 4) expands them, and `/modules/`
|
* The homepage names these groups, `/features/` expands them, and `/modules/` explains the
|
||||||
* explains the core/module split they encode. Three pages listing the same capabilities in
|
* core/module split they encode. Three pages listing the same capabilities in three
|
||||||
* three hand-maintained lists is how a site ends up advertising something that was removed,
|
* hand-maintained lists is how a site ends up advertising something that was removed, which
|
||||||
* which §1 forbids. One list, read by all three.
|
* §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
|
* 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`
|
* 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
|
* 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.
|
* 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
|
* The shape of a capability, written out because TypeScript otherwise infers it per group
|
||||||
* module installed at all.
|
* 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 = {
|
const community = {
|
||||||
id: 'community',
|
id: 'community',
|
||||||
@@ -43,20 +112,66 @@ const community = {
|
|||||||
*/
|
*/
|
||||||
moduleSupplied: false,
|
moduleSupplied: false,
|
||||||
title: 'Community',
|
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: [
|
items: [
|
||||||
{ label: 'Teams' },
|
{
|
||||||
{ label: 'Team forums' },
|
label: 'Teams',
|
||||||
{ label: 'Notifications' },
|
needsModule: true,
|
||||||
{ label: 'Wiki' },
|
demoPath: '/uo/guilds',
|
||||||
{ label: 'News and newsletter' },
|
detail:
|
||||||
{ label: 'Player self-service' },
|
'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
|
* Game intelligence — module-supplied. The `caps` arrays are the contract with
|
||||||
* `platform.json`; see `assertCapabilityCoverage` below.
|
* `platform.json`; see `assertCapabilityCoverage` below.
|
||||||
|
*
|
||||||
|
* @type {CapabilityGroup}
|
||||||
*/
|
*/
|
||||||
const gameIntelligence = {
|
const gameIntelligence = {
|
||||||
id: 'game-intelligence',
|
id: 'game-intelligence',
|
||||||
@@ -66,59 +181,225 @@ const gameIntelligence = {
|
|||||||
'Supplied by the installed game module, not by the core site. Today that module is ' +
|
'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.',
|
'module-uo, and this is what it publishes from a live shard.',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Live server status', caps: ['shard'] },
|
{
|
||||||
{ label: 'Economy and activity', caps: ['shard'] },
|
label: 'Live server status',
|
||||||
{ label: 'Character sheets', caps: ['shard'] },
|
caps: ['shard'],
|
||||||
{ label: 'Points and loyalty boards', caps: ['shard'] },
|
demoPath: '/uo/shard',
|
||||||
{ label: 'Player-vendor marketplace', caps: ['market', 'cliloc'] },
|
detail:
|
||||||
{ label: 'Houses and IDOC decay', caps: ['houses'] },
|
'Whether the server is up, who is on it, and how long ago the site last heard from ' +
|
||||||
{ label: 'Spawn atlas', caps: ['atlas'] },
|
'it. When the game is down this page is the thing that says so — the site does not ' +
|
||||||
{ label: 'Champion boards', caps: ['champs'] },
|
'go down with it.',
|
||||||
{ label: 'Guilds', caps: ['guilds'] },
|
},
|
||||||
{ label: 'City governors', caps: ['governors'] },
|
{
|
||||||
|
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 = {
|
const administration = {
|
||||||
id: 'administration',
|
id: 'administration',
|
||||||
moduleSupplied: false,
|
moduleSupplied: false,
|
||||||
title: 'Administration',
|
title: 'Administration',
|
||||||
summary: 'Running the place, with a record of who did what.',
|
summary: 'Running the place, with a record of who did what.',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Roles and permissions' },
|
{
|
||||||
{ label: 'Moderation and appeals' },
|
label: 'Roles and permissions',
|
||||||
{ label: 'Content reports' },
|
detail:
|
||||||
{ label: 'Append-only audit log' },
|
'Admin, moderator and player. Admin access is re-checked against the database on ' +
|
||||||
{ label: 'Bot scoring and IP bans' },
|
'every request rather than trusted from whatever the session was issued with, so ' +
|
||||||
{ label: 'Module management' },
|
'demoting someone takes effect on their next click and not at their next login.',
|
||||||
{ label: 'The game-server connection' },
|
},
|
||||||
|
{
|
||||||
|
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 = {
|
const integration = {
|
||||||
id: 'integration',
|
id: 'integration',
|
||||||
moduleSupplied: false,
|
moduleSupplied: false,
|
||||||
title: 'Integration',
|
title: 'Integration',
|
||||||
summary: 'The seams that let other things reach in — and one game reach out.',
|
summary: 'The seams that let other things reach in — and one game reach out.',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Modules' },
|
{
|
||||||
{ label: 'The sidecar bridge' },
|
label: 'Modules',
|
||||||
{ label: 'Discord: slash commands, notifications, voice' },
|
detail:
|
||||||
{ label: 'Mobile and push' },
|
'The whole game-specific half of a deployment is an installable module: routes, ' +
|
||||||
{ label: 'SSO over OAuth2 / OIDC' },
|
'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 = {
|
const infrastructure = {
|
||||||
id: 'infrastructure',
|
id: 'infrastructure',
|
||||||
moduleSupplied: false,
|
moduleSupplied: false,
|
||||||
title: 'Infrastructure',
|
title: 'Infrastructure',
|
||||||
summary: 'How it runs, and who it answers to.',
|
summary: 'How it runs, and who it answers to.',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Self-hosted, start to finish' },
|
{
|
||||||
{ label: 'Docker, with prebuilt pull-only images' },
|
label: 'Self-hosted, start to finish',
|
||||||
{ label: 'Branding as data, not a rebuild' },
|
detail:
|
||||||
{ label: 'OpenAPI 3.0 for the whole API' },
|
'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,
|
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.
|
* 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`
|
`\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`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
165
src/data/notBuilt.mjs
Normal file
165
src/data/notBuilt.mjs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* notBuilt.mjs — the deliberate absences of PLAN.md §2, as data (D22).
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* WHY THIS IS A LIST AND NOT A PARAGRAPH
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* §2 calls its absent-features list "as load-bearing as the rest", and the homepage already
|
||||||
|
* promises a reader they will find it on both `/features/` and `/integrations/`. Two pages
|
||||||
|
* each writing their own version of "what we did not build" is how the inconvenient half
|
||||||
|
* quietly stops being mentioned on one of them — the same failure `capabilities.mjs` exists
|
||||||
|
* to prevent, pointed the other way.
|
||||||
|
*
|
||||||
|
* So: one list, tagged with the pages that show it. `/modules/` reads it too, because the
|
||||||
|
* three absences a module author most needs to know about are all here.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* THE RULE FOR ADDING ONE
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* An entry belongs here when a reasonable reader would assume the thing exists. That is a
|
||||||
|
* higher bar than "we have not built it" — the site is not an inventory of everything
|
||||||
|
* absent from it — and a lower bar than "someone asked for it". Matrix is here because the
|
||||||
|
* original brief for this site listed it as a feature; the installer's missing platforms
|
||||||
|
* are here because every other tool in the world ships a macOS build.
|
||||||
|
*
|
||||||
|
* Each entry says what it is, and then why not. The "why not" is the point: an absence with
|
||||||
|
* a reason reads as a decision, and an absence without one reads as a gap. Where the
|
||||||
|
* reasoning was written down somewhere in the open, the entry links to it on a BRANCH path
|
||||||
|
* — `scripts/checkLinks.mjs` fails a commit permalink, because a permalink is a fact frozen
|
||||||
|
* at a sha while the document keeps moving.
|
||||||
|
*
|
||||||
|
* `resolvedBy` is not decoration. D8 gives the Integration Kit's draft status a defined
|
||||||
|
* removal condition, and stating the exit condition on the others too is what stops this
|
||||||
|
* file becoming a list of permanent apologies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GITEA = 'https://gitea.whitlocktech.com/RunicGateway';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `scope` — which pages render the entry.
|
||||||
|
*
|
||||||
|
* `features` /features/, under the capability groups
|
||||||
|
* `integrations` /integrations/, under the integrations that do exist
|
||||||
|
* `modules` /modules/, where a module author is deciding whether to start
|
||||||
|
*
|
||||||
|
* Typed rather than inferred, for the same reason `capabilities.mjs` is: `link` is present
|
||||||
|
* on four entries out of six, and an inferred union makes `entry.link` unreadable on the
|
||||||
|
* page that renders all of them.
|
||||||
|
*
|
||||||
|
* @typedef {object} Absence
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string[]} scope
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} body
|
||||||
|
* @property {string} resolvedBy What would make this entry go away. Never optional.
|
||||||
|
* @property {{ href: string, label: string }} [link]
|
||||||
|
*
|
||||||
|
* @type {Absence[]}
|
||||||
|
*/
|
||||||
|
export const notBuilt = [
|
||||||
|
{
|
||||||
|
id: 'matrix',
|
||||||
|
scope: ['integrations'],
|
||||||
|
title: 'Matrix',
|
||||||
|
body:
|
||||||
|
'Researched properly and then declined. Matrix has no channel-with-overwrites, no ' +
|
||||||
|
'role object, no voice channel of its own — voice is an RTC session needing a media ' +
|
||||||
|
'server the homeserver does not ship — and no way to register a slash command. Of ' +
|
||||||
|
'the five things a shared chat interface would have to name, an honest Matrix ' +
|
||||||
|
'implementation could provide two. What came out of that work was a capability ' +
|
||||||
|
'contract rather than an integration.',
|
||||||
|
resolvedBy:
|
||||||
|
'Nothing planned. If the protocol grows the missing four, the contract is already ' +
|
||||||
|
'the shape a second platform would plug into.',
|
||||||
|
link: { href: `${GITEA}/docs/src/branch/main/website/TEAMS.md`, label: 'The research, in full' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'multi-module',
|
||||||
|
scope: ['features', 'integrations', 'modules'],
|
||||||
|
title: 'More than one game module at a time',
|
||||||
|
body:
|
||||||
|
'One active module per deployment. The database columns that would scope data to a ' +
|
||||||
|
'module exist and are populated, so the door is not nailed shut, but nothing ' +
|
||||||
|
'exercises them and no interface offers it. A community running two games runs two ' +
|
||||||
|
'deployments.',
|
||||||
|
resolvedBy:
|
||||||
|
'Someone needing it. The schema was shaped to keep it possible, which is a different ' +
|
||||||
|
'thing from planning it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'second-module',
|
||||||
|
scope: ['integrations', 'modules'],
|
||||||
|
title: 'A second game module',
|
||||||
|
body:
|
||||||
|
'There is exactly one, and it is Ultima Online. A paper dry-run for a Rust module ' +
|
||||||
|
'exists and is deliberately unimplemented — it was written to test whether the ' +
|
||||||
|
'module contract generalises, not to ship. Until a second one exists, "any game" is ' +
|
||||||
|
'an argument about a shape rather than a demonstration.',
|
||||||
|
resolvedBy: 'The first module built for a game that is not Ultima Online.',
|
||||||
|
link: { href: `${GITEA}/docs/src/branch/main/modules/rust-dryrun.md`, label: 'The dry-run' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integration-kit-draft',
|
||||||
|
scope: ['integrations', 'modules'],
|
||||||
|
title: 'A finished Integration Kit',
|
||||||
|
body:
|
||||||
|
'The kit that teaches you to put a different game on this platform describes itself ' +
|
||||||
|
'as a draft, and it is right to. It has four chapters, a working template and a CI ' +
|
||||||
|
'job that builds that template against a pinned core — but nobody outside this ' +
|
||||||
|
'project has yet followed it to a working module, which is the only test of a set of ' +
|
||||||
|
'instructions that counts.',
|
||||||
|
resolvedBy:
|
||||||
|
'Someone outside this project building a working module for a new game by following ' +
|
||||||
|
"it alone. That is the kit's own stated condition, not one invented here.",
|
||||||
|
link: { href: `${GITEA}/Integration-kit/src/branch/main/README.md`, label: 'The kit' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'installer-platforms',
|
||||||
|
scope: ['features'],
|
||||||
|
title: 'A macOS or Windows-on-ARM installer',
|
||||||
|
body:
|
||||||
|
'Linux and Windows, on x86-64, plus Linux on arm64. The missing builds are missing ' +
|
||||||
|
'on purpose: the installer runs on the machine the game server lives on, because the ' +
|
||||||
|
'game and the bridge have to share a host, and no game server anybody runs is on ' +
|
||||||
|
'either of those platforms.',
|
||||||
|
resolvedBy: 'A game server that runs there.',
|
||||||
|
link: { href: `${GITEA}/docs/src/branch/main/installer/INSTALL.md`, label: 'The operator guide' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'public-demo',
|
||||||
|
scope: ['features'],
|
||||||
|
title: 'A public demo you can click through',
|
||||||
|
body:
|
||||||
|
'Planned and out of scope today: a virtual machine running the whole stack including ' +
|
||||||
|
'a game server, with settings locked down and an hourly reset. Until it exists this ' +
|
||||||
|
'site does not link to one, and there is no screenshot here of something that is not ' +
|
||||||
|
'running somewhere.',
|
||||||
|
resolvedBy:
|
||||||
|
'The machine being stood up. The site is already built to gain it by way of one line ' +
|
||||||
|
'in a configuration file, rather than a rebuild.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The entries a given page renders, in file order. */
|
||||||
|
export function notBuiltFor(scope) {
|
||||||
|
return notBuilt.filter((entry) => entry.scope.includes(scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fails the build when a scope renders nothing.
|
||||||
|
*
|
||||||
|
* The homepage tells a reader in as many words that the absences are listed "on features
|
||||||
|
* and integrations". A tag typo, or an entry removed without checking who was showing it,
|
||||||
|
* turns that sentence into a promise the site does not keep — and an empty section is the
|
||||||
|
* one defect that looks deliberate, because a page with nothing under a heading reads as a
|
||||||
|
* page with nothing to admit.
|
||||||
|
*/
|
||||||
|
export function assertScopeNonEmpty(scope) {
|
||||||
|
if (notBuiltFor(scope).length) return;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`src/data/notBuilt.mjs has no entry tagged "${scope}", but a page is rendering that scope.\n` +
|
||||||
|
`\nThe homepage promises this list appears on /features/ and /integrations/ (§2, D22).\n` +
|
||||||
|
`Tag an entry with "${scope}", or take the section off the page that asks for it.\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/pages/architecture.astro
Normal file
172
src/pages/architecture.astro
Normal file
@@ -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`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title={title} description={description}>
|
||||||
|
<PageHeader eyebrow="How it is built" title="The parts, and the lines between them">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<TwoHosts />
|
||||||
|
<Allowlist />
|
||||||
|
<ModuleSeam />
|
||||||
|
|
||||||
|
<section class="page section deeper">
|
||||||
|
<div class="panel deeper__panel">
|
||||||
|
<p class="eyebrow">Going deeper</p>
|
||||||
|
<h2>The documents this page is a summary of</h2>
|
||||||
|
<p class="prose deeper__lede">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="deeper__list">
|
||||||
|
<li>
|
||||||
|
<a href={`${docs}/link/INTEGRATION.md`} rel="noopener noreferrer">
|
||||||
|
The bridge protocol
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
>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.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={`${docs}/website/MODULE_API.md`} rel="noopener noreferrer">
|
||||||
|
The module contract
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
>The normative interface between core and a module — currently
|
||||||
|
{platform.moduleApi}. This is the document that decides whether your module
|
||||||
|
loads.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={`${docs}/installer/INSTALL.md`} rel="noopener noreferrer">
|
||||||
|
The operator guide
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
>Setting the game side up end to end, including the failure modes and what each
|
||||||
|
step should look like when it worked.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/docs/">The documentation on this site</a>
|
||||||
|
<span
|
||||||
|
>The same ground as a guided path rather than a specification, starting from an
|
||||||
|
empty server.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="deeper__actions">
|
||||||
|
<a class="btn btn--primary" href="/docs/">Start the install guide</a>
|
||||||
|
<a class="btn btn--ghost" href="/modules/">How modules work</a>
|
||||||
|
<a class="btn btn--ghost" href={brand.giteaOrg} rel="noopener noreferrer">
|
||||||
|
Read the source
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.deeper__panel {
|
||||||
|
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deeper h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deeper__lede {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deeper__list {
|
||||||
|
margin: 1.75rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.deeper__list li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding-left: 0.9rem;
|
||||||
|
border-left: 2px solid var(--gold-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deeper__list span {
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deeper__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
288
src/pages/community.astro
Normal file
288
src/pages/community.astro
Normal file
@@ -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}`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title={title} description={description}>
|
||||||
|
<PageHeader eyebrow="Getting in touch" title="Three doors, and which one to use">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<section class="page section chan">
|
||||||
|
<ul class="chan__grid">
|
||||||
|
<li class="panel chan__card chan__card--primary">
|
||||||
|
<div class="chan__head">
|
||||||
|
<h2>Discord</h2>
|
||||||
|
<span class="chip chip--live">The front door</span>
|
||||||
|
</div>
|
||||||
|
<p class="chan__lede">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p class="chan__use">
|
||||||
|
<span class="chan__use-label">Use it for</span>
|
||||||
|
Anything you would otherwise open an issue for, and everything you would not.
|
||||||
|
</p>
|
||||||
|
<a class="btn btn--primary" href={brand.discordInvite} rel="noopener noreferrer">
|
||||||
|
Join the Discord
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="panel chan__card">
|
||||||
|
<div class="chan__head">
|
||||||
|
<h2>The code</h2>
|
||||||
|
<span class="chip">Read freely</span>
|
||||||
|
</div>
|
||||||
|
<p class="chan__lede">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p class="chan__use">
|
||||||
|
<span class="chan__use-label">One caveat</span>
|
||||||
|
{/*
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<a class="btn btn--ghost" href={gitea} rel="noopener noreferrer">Browse the source</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="panel chan__card">
|
||||||
|
<div class="chan__head">
|
||||||
|
<h2>Security</h2>
|
||||||
|
<span class="chip chip--draft">Private</span>
|
||||||
|
</div>
|
||||||
|
<p class="chan__lede">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p class="chan__use">
|
||||||
|
<span class="chan__use-label">Use it for</span>
|
||||||
|
Anything that would let somebody reach a deployment, an account or a game server
|
||||||
|
they should not.
|
||||||
|
</p>
|
||||||
|
<a class="btn btn--ghost" href={`mailto:${brand.contactEmail}`}>{brand.contactEmail}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page section contrib">
|
||||||
|
<div class="panel contrib__panel">
|
||||||
|
<p class="eyebrow">Contributing</p>
|
||||||
|
<h2>What is useful, in order</h2>
|
||||||
|
|
||||||
|
<ol class="contrib__list">
|
||||||
|
<li>
|
||||||
|
<h3>Run it and say what broke</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h3>Build a module for another game</h3>
|
||||||
|
<p>
|
||||||
|
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
|
||||||
|
<a href="/modules/">Integration Kit</a> exists to be followed by somebody outside
|
||||||
|
this project — and it stays marked draft until it has been.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h3>Fix the documentation</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p class="contrib__note">
|
||||||
|
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 <code>CONTRIBUTING.md</code> has the
|
||||||
|
details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chan__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: clamp(1.25rem, 3vw, 1.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one channel that actually answers gets the portal edge — the live signal, used
|
||||||
|
here for the same reason it is used on a running shard. */
|
||||||
|
.chan__card--primary {
|
||||||
|
border-color: color-mix(in srgb, var(--portal) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan__card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan__lede {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Takes the slack so the button sits at the foot of every card in the row. */
|
||||||
|
.chan__use {
|
||||||
|
flex: 1;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan__use-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan__card .btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrib__panel {
|
||||||
|
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrib h2 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrib__list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
counter-reset: item;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrib__list li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 3.25rem;
|
||||||
|
counter-increment: item;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrib__list li + li {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrib__list li::before {
|
||||||
|
content: counter(item);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border: 1px solid var(--gold-deep);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrib__list h3 {
|
||||||
|
margin: 0.3rem 0 0.4rem;
|
||||||
|
font-size: 1.06rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrib__list p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: var(--measure);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contrib__note {
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
max-width: var(--measure);
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
223
src/pages/features.astro
Normal file
223
src/pages/features.astro
Normal file
@@ -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.';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title={title} description={description}>
|
||||||
|
<PageHeader eyebrow="What you get" title="Everything the platform does">
|
||||||
|
<p>
|
||||||
|
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 <a href="/modules/"
|
||||||
|
>installable module</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{
|
||||||
|
capabilityGroups.map((group) => (
|
||||||
|
<section class="page section group" id={group.id}>
|
||||||
|
<div class="group__head">
|
||||||
|
<h2>{group.title}</h2>
|
||||||
|
<span class:list={['chip', group.moduleSupplied && 'chip--module']}>
|
||||||
|
{group.moduleSupplied ? 'From the installed module' : 'Core'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="prose group__summary">{group.summary}</p>
|
||||||
|
|
||||||
|
<ul class="group__items">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<li class="panel group__item">
|
||||||
|
<div class="group__item-head">
|
||||||
|
<h3>{item.label}</h3>
|
||||||
|
{item.needsModule && <span class="chip chip--needs">Needs a module</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="group__detail">{item.detail}</p>
|
||||||
|
|
||||||
|
{item.demoPath && (
|
||||||
|
<a
|
||||||
|
class="demo-link"
|
||||||
|
href="" data-demo-url="" data-demo-path={item.demoPath}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
See it running
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
<NotBuilt scope="features" title="Things a reader could reasonably expect, that are not here" />
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.group__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.5rem, 3vw, 1.95rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group__summary {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group__items {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1.75rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.group__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chip is taller than the heading's line box, so a card that has one starts its
|
||||||
|
body a few pixels lower than the card beside it. Reserving the chip's height on
|
||||||
|
every head lines the row up whether or not the marker is there. */
|
||||||
|
.group__item-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group__item h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 1.04rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group__detail {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The module-supplied chip takes the portal colour rather than gold: it is the same
|
||||||
|
distinction the data-path diagram draws in cyan on the homepage — the parts that
|
||||||
|
know about a game — and using one colour for one idea across the site is cheaper
|
||||||
|
for a reader than two decorative ones. */
|
||||||
|
.chip--module {
|
||||||
|
border-color: color-mix(in srgb, var(--portal) 45%, transparent);
|
||||||
|
color: var(--portal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Not a warning. It says which of the two halves supplies the thing, on the two
|
||||||
|
capabilities where the answer is "both" — core builds it, a module fills it. */
|
||||||
|
.chip--needs {
|
||||||
|
border-color: color-mix(in srgb, var(--portal) 30%, transparent);
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Set as a link rather than a `.btn`: there is one of these per capability and a row
|
||||||
|
of buttons inside a card grid would read as the primary action of the page, which
|
||||||
|
it is not — the primary action is reading the list. `.demo-cta` in global.css stays
|
||||||
|
the button treatment, for the homepage's single slot.
|
||||||
|
|
||||||
|
It is the flex item itself rather than a paragraph wrapping one, so that
|
||||||
|
`global.css`'s `[data-demo-url=''] { display: none }` takes the margin away with
|
||||||
|
it. A wrapper would survive its hidden child and leave a 1rem gap at the foot of
|
||||||
|
every card in a stock build — and hiding the wrapper with `:has()` would have put a
|
||||||
|
second `data-demo-url` in the file, which `checkBrand.mjs` reads as a demo slot
|
||||||
|
written outside its contract. The check is right to: it cannot tell a selector from
|
||||||
|
an attribute, and it should not have to guess. */
|
||||||
|
.demo-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: var(--portal);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--portal) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-link:hover {
|
||||||
|
color: var(--portal-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-link::after {
|
||||||
|
content: '\2197'; /* north-east arrow: this leaves the site */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
301
src/pages/integrations.astro
Normal file
301
src/pages/integrations.astro
Normal file
@@ -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.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title={title} description={description}>
|
||||||
|
<PageHeader eyebrow="What it connects to" title="The things it talks to, and the things it does not">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{
|
||||||
|
integrations.map((integration) => (
|
||||||
|
<section class="page section integ" id={integration.id}>
|
||||||
|
<h2>{integration.name}</h2>
|
||||||
|
<p class="prose integ__summary">{integration.summary}</p>
|
||||||
|
|
||||||
|
<ul class="integ__grid">
|
||||||
|
{integration.points.map((point) => (
|
||||||
|
<li class="panel">
|
||||||
|
<h3>{point.title}</h3>
|
||||||
|
<p>{point.body}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="panel integ__caveat">
|
||||||
|
<p class="integ__caveat-label">Worth knowing first</p>
|
||||||
|
<p>{integration.caveat}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="page section integ" id="email">
|
||||||
|
<h2>Email, deliberately quiet</h2>
|
||||||
|
<p class="prose integ__summary">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p class="prose integ__note">
|
||||||
|
This website is a separate matter: <em>runicgateway.com</em> sends no email at all, has
|
||||||
|
no mailbox behind it and no account to make. The address in the footer is a human being.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NotBuilt scope="integrations" title="Integrations that do not exist" />
|
||||||
|
|
||||||
|
<section class="page section integ" id="build">
|
||||||
|
<div class="panel integ__build">
|
||||||
|
<p class="eyebrow">If you need another one</p>
|
||||||
|
<h2>The API is the integration point</h2>
|
||||||
|
<p class="prose">
|
||||||
|
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}.
|
||||||
|
</p>
|
||||||
|
<div class="integ__actions">
|
||||||
|
<a class="btn btn--primary" href="/modules/">How modules work</a>
|
||||||
|
<a class="btn btn--ghost" href="/architecture/">The architecture</a>
|
||||||
|
<a class="btn btn--ghost" href="/docs/">The documentation</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.integ h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__summary {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__note {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__grid h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__grid p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The caveat is a panel like the others rather than a warning box. It is information
|
||||||
|
of the same kind and the same weight — the difference is that it is the half a
|
||||||
|
reader is not expecting, which is a reason to give it its own line, not a reason
|
||||||
|
to make it look like an error message. */
|
||||||
|
.integ__caveat {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-left: 3px solid var(--gold-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__caveat p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: var(--measure);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__caveat-label {
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__caveat .integ__caveat-label {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__build {
|
||||||
|
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__build h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__build .prose {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integ__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
404
src/pages/modules.astro
Normal file
404
src/pages/modules.astro
Normal file
@@ -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.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base title={title} description={description}>
|
||||||
|
<PageHeader eyebrow="The extension model" title="One platform, whichever game you run">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<section class="page section mod" id="what">
|
||||||
|
<p class="eyebrow">What you get</p>
|
||||||
|
<h2>What installing one actually does</h2>
|
||||||
|
|
||||||
|
<ul class="mod__grid">
|
||||||
|
<li class="panel">
|
||||||
|
<h3>It brings its own everything</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li class="panel">
|
||||||
|
<h3>You never build it</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li class="panel">
|
||||||
|
<h3>It cannot take the site down</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li class="panel">
|
||||||
|
<h3>Your data outlives it</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page section mod" id="installing">
|
||||||
|
<p class="eyebrow">Installing</p>
|
||||||
|
<h2>Three ways in, and none of them is a build</h2>
|
||||||
|
<p class="prose mod__lede">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol class="mod__paths">
|
||||||
|
{
|
||||||
|
installPaths.map((path) => (
|
||||||
|
<li class="panel">
|
||||||
|
<h3>{path.name}</h3>
|
||||||
|
<p>{path.body}</p>
|
||||||
|
<p class="mod__fits">{path.fits}</p>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page section mod" id="module-uo">
|
||||||
|
<div class="mod__head">
|
||||||
|
<p class="eyebrow">The worked example</p>
|
||||||
|
<h2>module-uo</h2>
|
||||||
|
<span class="chip chip--version">{platform.releases['Module-uo']}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="prose mod__lede">
|
||||||
|
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
|
||||||
|
<a href="/architecture/">the architecture page</a> is real, because the code on the far
|
||||||
|
side of it was moved there rather than designed there.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mod__example">
|
||||||
|
<div class="panel mod__caps">
|
||||||
|
<h3>What it publishes</h3>
|
||||||
|
<ul>
|
||||||
|
{gameIntelligence.items.map((item) => <li>{item.label}</li>)}
|
||||||
|
</ul>
|
||||||
|
<p class="mod__caps-note">
|
||||||
|
The same list <a href="/features/">features</a> expands, read from one file that is
|
||||||
|
checked against the module's own manifest on every build.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mod__facts">
|
||||||
|
<section>
|
||||||
|
<h3>It connects to a real server</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>It owns its own tables</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>It is a separate release</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="page section mod" id="writing">
|
||||||
|
<div class="mod__head">
|
||||||
|
<p class="eyebrow">Writing your own</p>
|
||||||
|
<h2>The Integration Kit</h2>
|
||||||
|
<span class="chip chip--draft">Draft</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="prose mod__lede">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="panel mod__draft">
|
||||||
|
<h3>Why it says draft</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Everything it teaches is real and in use. What is untested is whether it is
|
||||||
|
<em>sufficient</em>: whether someone with no access to this project's context can get
|
||||||
|
from an empty repository to a running module using it alone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mod__links">
|
||||||
|
<a class="btn btn--primary" href={`${gitea}/Integration-kit`} rel="noopener noreferrer">
|
||||||
|
Read the Integration Kit
|
||||||
|
</a>
|
||||||
|
<a class="btn btn--ghost" href={`${docs}/website/MODULE_API.md`} rel="noopener noreferrer">
|
||||||
|
The module contract
|
||||||
|
</a>
|
||||||
|
<a class="btn btn--ghost" href={`${docs}/modules/uo/README.md`} rel="noopener noreferrer">
|
||||||
|
module-uo in depth
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NotBuilt scope="modules" title="What the module system does not do" />
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mod h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__head .eyebrow {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__lede {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__grid,
|
||||||
|
.mod__paths {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__grid h3,
|
||||||
|
.mod__paths h3,
|
||||||
|
.mod__caps h3,
|
||||||
|
.mod__facts h3,
|
||||||
|
.mod__draft h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__grid p,
|
||||||
|
.mod__paths p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__paths li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Which host each path suits, set apart from what it does — a reader is choosing
|
||||||
|
between three, so the distinguishing line should not be buried in the paragraph. */
|
||||||
|
.mod__fits {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__example {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__caps ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__caps li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__caps li + li {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The same drawn marker the homepage's capability lists use, so a reader who has
|
||||||
|
seen this list once recognises it as the same list. */
|
||||||
|
.mod__caps li::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0.62em;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--portal);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__caps-note {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__facts section + section {
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__facts p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: var(--measure);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__draft {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__draft p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: var(--measure);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__draft p + p {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod__links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.mod__example {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
194
src/styles/diagram.css
Normal file
194
src/styles/diagram.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
@import '@fontsource-variable/cinzel';
|
@import '@fontsource-variable/cinzel';
|
||||||
@import '@fontsource-variable/inter';
|
@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,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -437,8 +443,25 @@ svg {
|
|||||||
Written here, before phase 3 writes that markup, because the rule and the
|
Written here, before phase 3 writes that markup, because the rule and the
|
||||||
rewrite have to agree and they live in different files. */
|
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=''] {
|
[data-demo-url=''] {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button
|
/* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button
|
||||||
|
|||||||
Reference in New Issue
Block a user