feat(home): phase 3 — the homepage #6
48
PLAN.md
48
PLAN.md
@@ -519,14 +519,57 @@ Organised by what a reader is trying to do. A reader should never need to know t
|
||||
**Feature grouping**, using project terminology:
|
||||
|
||||
- **Community** — Teams, Team forums, notifications, wiki, news and newsletter, player self-service
|
||||
- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, player-vendor
|
||||
marketplace, houses and IDOCs, character sheets, spawn atlas, champion boards, points leaderboards
|
||||
- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, character
|
||||
sheets, points and loyalty boards, player-vendor marketplace, houses and IDOCs, spawn atlas,
|
||||
champion boards, guilds, city governors
|
||||
- **Administration** — roles, moderation and appeals, content reports, audit log, bot scoring and IP
|
||||
bans, module management, the shard connection
|
||||
- **Integration** — modules, the sidecar bridge, Discord (slash commands, notifications, voice),
|
||||
mobile and push, SSO
|
||||
- **Infrastructure** — self-hosted, Docker, prebuilt pull-only images, branding as data, OpenAPI
|
||||
|
||||
Guilds and city governors were added to Game intelligence in phase 3: `module-uo` declares them as
|
||||
capabilities and the site was omitting two of the eight. That correction is now mechanical rather
|
||||
than editorial — see D18.
|
||||
|
||||
### How phase 3 built the homepage
|
||||
|
||||
Three decisions taken before the page was written (org lead, 2026-08-20).
|
||||
|
||||
**D17 — the data path is drawn generically, and captioned specifically.** The diagram's nodes read
|
||||
"your game server", "sidecar", "Runic Gateway", "browser and app", because a reader should not have
|
||||
to know this org's repository layout to understand the picture, and because the tagline promises a
|
||||
platform. It does not hide what ships: the sub-labels and the caption name ServUO and uo-link
|
||||
outright, since there is exactly one implementation of the shape today and §1 says the technical
|
||||
truth wins. Rejected: naming the real components in the nodes (reads as a UO product), and omitting
|
||||
UO entirely (advertises a generality one module proves).
|
||||
|
||||
**D18 — all five groups on the homepage, named only.** Not three with a link out: Integration and
|
||||
Infrastructure carry the module and self-hosted arguments, which are the differentiators, and hiding
|
||||
them until phase 4 would have made the front page look smaller than the product. The per-capability
|
||||
argument stays `/features/`'s job so there is one copy of it.
|
||||
|
||||
The list is **data with a check behind it** (`src/data/capabilities.mjs`). Every Game-intelligence
|
||||
item names the `module-uo` capability slug it comes from, and the build fails if the page and
|
||||
`platform.json` disagree in either direction. Closing that loop needed a fifteenth fact in
|
||||
`checkFacts.mjs`: §12 listed the capability list as an externally-sourced fact and nothing re-read
|
||||
it, so the whole chain rested on someone remembering. Manifest → `platform.json` → page is now
|
||||
checked end to end.
|
||||
|
||||
**D19 — the hero leads with the emblem.** Chosen over a type-only hero: the mark is already the
|
||||
site logo, the Android launcher icon and the Play listing, and showing it large is what makes the
|
||||
three read as one product (D11). It costs what D16 already accepted — raster art a mounted
|
||||
`theme.css` cannot recolour — but every size is derived from whichever `logo.png` is in force
|
||||
(D14), so the hero, the header, the tab icon and the installed icon still change together from one
|
||||
file.
|
||||
|
||||
**A convention, not a decision:** the homepage links the final routes — `/features/`,
|
||||
`/modules/`, `/integrations/` — which phases 4 to 6 have not written yet. The header and footer
|
||||
already did this from phase 1. Nothing is deployed until phase 12, so no visitor meets a 404, and
|
||||
nothing has to be rewritten later. Links *into the documentation* are the exception: they point at
|
||||
`/docs/`, because phases 7 and 8 own those slugs and a guessed one would be a stale URL nothing
|
||||
checks.
|
||||
|
||||
### Documentation
|
||||
|
||||
```
|
||||
@@ -636,6 +679,7 @@ a mechanism rather than diligence:
|
||||
| Protocol version | `link` `main:sidecar/src/main.rs` → `PROTOCOL_VERSION` |
|
||||
| Overlay protocol | `servuo-plugins` `main:overlay.toml` → `protocol` |
|
||||
| Module API | `website` `main:server/src/modules/version.js` → `MODULE_API_VERSION` |
|
||||
| Module capability list | `Module-uo` `main:module.json` → `capabilities` (added in phase 3) |
|
||||
| Bundle + component pins | `installer` branch `bundles`, **root** `current.json` |
|
||||
| Release versions | Gitea releases API per repo |
|
||||
|
||||
|
||||
@@ -216,6 +216,80 @@ if (brand) {
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
4. The demo slot's markup contract (§15 / D12)
|
||||
=======================================================================================
|
||||
|
||||
`applyBrand.mjs` reveals the demo link by string-replacing an exact pair of empty
|
||||
attributes in the built HTML. That is a contract between a script and a template that
|
||||
share no code, and it fails in the quietest possible way: an attribute inserted between
|
||||
the two, or `href` written after `data-demo-url`, produces a build where the demo URL is
|
||||
set in the mount, the boot log says nothing, and the link is simply never there.
|
||||
|
||||
Both halves are checked, and neither is retyped from memory — the literal is derived from
|
||||
the same expression `applyBrand.mjs` uses, so the two cannot drift apart. */
|
||||
|
||||
const applyForCheck = existsSync(path.join(ROOT, 'scripts/applyBrand.mjs'))
|
||||
? readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8')
|
||||
: '';
|
||||
|
||||
const attrTemplate = applyForCheck.match(
|
||||
/`href="\$\{escapeHtml\(value\)\}" data-demo-url="\$\{escapeHtml\(value\)\}"`/
|
||||
);
|
||||
|
||||
if (!attrTemplate) {
|
||||
fail(
|
||||
'applyBrand.mjs no longer builds the demo attributes as `href="..." data-demo-url="..."`.\n' +
|
||||
' Update the expected pair below to match, and re-check every template that writes it.'
|
||||
);
|
||||
} else {
|
||||
// What the script will look for when the applied value is the stock empty string.
|
||||
const EMPTY_PAIR = 'href="" data-demo-url=""';
|
||||
|
||||
let slots = 0;
|
||||
const strays = [];
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||
if (path.extname(file) !== '.astro') continue;
|
||||
|
||||
// Comments discuss the contract at length, including in the template that implements
|
||||
// it. Scanning them would make the check fail on its own documentation.
|
||||
// Blanked rather than removed: keeping every newline and every offset means the line
|
||||
// numbers reported below are the ones in the file, not the ones in a shortened copy.
|
||||
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
||||
const source = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
|
||||
const relative = path.relative(ROOT, file);
|
||||
|
||||
slots += source.split(EMPTY_PAIR).length - 1;
|
||||
|
||||
for (const match of source.matchAll(/data-demo-url/g)) {
|
||||
const start = match.index - EMPTY_PAIR.indexOf('data-demo-url');
|
||||
if (source.slice(start, start + EMPTY_PAIR.length) !== EMPTY_PAIR) {
|
||||
strays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!slots) {
|
||||
fail(
|
||||
`no demo slot found in src/**/*.astro — expected the literal \`${EMPTY_PAIR}\`.\n` +
|
||||
' §15 reserves this slot so that gaining a demo instance is one line in the mounted\n' +
|
||||
' brand.json. Removing it makes that a rebuild.'
|
||||
);
|
||||
}
|
||||
|
||||
for (const site of strays) {
|
||||
fail(
|
||||
`${site} writes data-demo-url outside the exact pair \`${EMPTY_PAIR}\`.\n` +
|
||||
' applyBrand.mjs replaces that literal at boot; anything else is invisible to it and\n' +
|
||||
' the slot will never appear.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================================= */
|
||||
|
||||
if (failures.length) {
|
||||
@@ -227,5 +301,5 @@ if (failures.length) {
|
||||
|
||||
console.log(
|
||||
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
|
||||
`and every rewritable string is safe to replace.`
|
||||
`every rewritable string is safe to replace, and the demo slot matches its contract.`
|
||||
);
|
||||
|
||||
@@ -117,7 +117,29 @@ async function checkModuleApi() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. The current bundle
|
||||
// 4. The capabilities the installed module actually declares
|
||||
//
|
||||
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
|
||||
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the
|
||||
// homepage renders the list rather than merely storing it: `src/data/capabilities.mjs`
|
||||
// asserts at build time that every declared slug is claimed by a named capability on the
|
||||
// page and vice versa. Without this check that assertion was anchored to a local copy
|
||||
// nobody was verifying, so the whole chain rested on someone remembering.
|
||||
//
|
||||
// Sorted before comparing: the manifest's order is the module's business, and a reordered
|
||||
// array is not a changed capability set. A slug appearing or disappearing is.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkModuleCapabilities() {
|
||||
const authority = 'Module-uo main:module.json';
|
||||
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main'));
|
||||
const declared = [...(manifest.capabilities || [])].sort();
|
||||
const expected = [...platform.moduleUoCapabilities].sort();
|
||||
|
||||
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. The current bundle
|
||||
//
|
||||
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
|
||||
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
|
||||
@@ -141,7 +163,7 @@ async function checkBundle() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Release versions, per repo
|
||||
// 6. Release versions, per repo
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkReleases() {
|
||||
for (const [repo, expected] of Object.entries(platform.releases)) {
|
||||
@@ -152,7 +174,7 @@ async function checkReleases() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. `website` still publishes nothing
|
||||
// 7. `website` still publishes nothing
|
||||
//
|
||||
// It ships as container images and is never tagged, so the site refers to the platform by
|
||||
// bundle tag and Module API version instead. The day that changes, this repo should notice
|
||||
@@ -165,7 +187,7 @@ async function checkWebsiteHasNoReleases() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. D13 — the contact address lives in exactly one file
|
||||
// 8. D13 — the contact address lives in exactly one file
|
||||
// ---------------------------------------------------------------------------
|
||||
const CONTACT_CHECK = 'contact address (D13)';
|
||||
|
||||
@@ -244,6 +266,7 @@ async function main() {
|
||||
checkProtocol,
|
||||
checkOverlayProtocol,
|
||||
checkModuleApi,
|
||||
checkModuleCapabilities,
|
||||
checkBundle,
|
||||
checkReleases,
|
||||
checkWebsiteHasNoReleases,
|
||||
|
||||
167
src/components/home/Capabilities.astro
Normal file
167
src/components/home/Capabilities.astro
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs';
|
||||
|
||||
/**
|
||||
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
|
||||
* each one is `/features/`'s job in phase 4, and repeating it here would create a second
|
||||
* copy to keep true.
|
||||
*
|
||||
* The call below is the point of the exercise: it throws, and therefore fails the build, if
|
||||
* the "Game intelligence" list and the module's own declared capabilities have drifted
|
||||
* apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
|
||||
* this makes the page honest against `platform.json`, which is the half that was missing.
|
||||
*
|
||||
* The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features
|
||||
* list is described there as "as load-bearing as the rest", and a homepage that lists only
|
||||
* what exists while quietly omitting the well-known things that do not is the exact failure
|
||||
* §1 is written to prevent.
|
||||
*/
|
||||
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
---
|
||||
|
||||
<section class="page section caps">
|
||||
<p class="eyebrow">What it does</p>
|
||||
<h2>A community site, and a window into the game</h2>
|
||||
<p class="prose caps__lede">
|
||||
The core is game-agnostic: it does not know what a shard is. Everything that does arrives
|
||||
as an installable <a href="/modules/">module</a>, which is why the same platform can carry
|
||||
a different game without a fork.
|
||||
</p>
|
||||
|
||||
<div class="caps__grid">
|
||||
{
|
||||
capabilityGroups.map((group) => (
|
||||
<section class:list={['panel', 'caps__group', group.items.length > 8 && 'caps__group--wide']}>
|
||||
<header class="caps__group-head">
|
||||
<h3>{group.title}</h3>
|
||||
{group.moduleSupplied && <span class="chip">Module-supplied</span>}
|
||||
</header>
|
||||
|
||||
<p class="caps__summary">{group.summary}</p>
|
||||
|
||||
<ul class="caps__items">
|
||||
{group.items.map((item) => (
|
||||
<li>{item.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="caps__foot prose">
|
||||
Some things people reasonably expect are <strong>deliberately not built</strong> — a Matrix
|
||||
integration, more than one game module active at once, a second game module. They are
|
||||
listed rather than left out, on <a href="/features/">features</a> and{' '}
|
||||
<a href="/integrations/">integrations</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.caps h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.caps__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.caps__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 2.25rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.caps__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.caps__group-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.caps__group h3 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.caps__summary {
|
||||
margin: 0.6rem 0 1rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.caps__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.caps__items li {
|
||||
position: relative;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.caps__items li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* A drawn marker rather than a list bullet: it takes the portal colour, so it
|
||||
tracks a mounted theme the way a `list-style` glyph would not. */
|
||||
.caps__items 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;
|
||||
}
|
||||
|
||||
/* Five groups in a three-column grid leaves a hole, and the one group that is
|
||||
twice the length of the others is the obvious thing to put in it. Game
|
||||
intelligence takes both remaining slots on the top row and sets its items
|
||||
in two columns, which fills the row and gives the module-supplied group the
|
||||
prominence it has earned by being the only one that is module-supplied.
|
||||
|
||||
The width is read from the content — a group long enough to need it gets it
|
||||
— rather than named, so a future group of that size lands the same way.
|
||||
|
||||
Guarded by a width query because `span 2` in a grid that is only one column
|
||||
wide is an overflow, not a layout. */
|
||||
@media (min-width: 62rem) {
|
||||
.caps__group--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.caps__group--wide .caps__items {
|
||||
columns: 2;
|
||||
column-gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* `columns` would otherwise break an item across the column boundary, and a
|
||||
capability split over two columns reads as two capabilities. */
|
||||
.caps__group--wide .caps__items li {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
.caps__foot {
|
||||
margin: 2rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
302
src/components/home/DataPath.astro
Normal file
302
src/components/home/DataPath.astro
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn
|
||||
* geometry, used where it explains something, and no raster anywhere.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE LABELS ARE GENERIC, WITH UO AS THE CAPTION
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The org lead settled this before the diagram was drawn. The nodes say "your game server"
|
||||
* and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader
|
||||
* should never need to know that `link`, `servuo-plugins` and `installer` are three
|
||||
* repositories in order to connect a game server — and because the tagline promises a
|
||||
* platform, not a UO product.
|
||||
*
|
||||
* It does NOT hide what actually ships. The sub-labels and the caption name ServUO and
|
||||
* uo-link outright, because §1 says the technical truth wins and today there is exactly one
|
||||
* implementation of this shape. An operator running a shard has to see themselves in the
|
||||
* picture on the first screen.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SVG IS aria-hidden
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not because it is decorative — it is the opposite — but because the steps beside it carry
|
||||
* the same four stages in full prose, at real font sizes, in reading order. A `role="img"`
|
||||
* with a `<desc>` would make a screen reader read the same path twice, and the second
|
||||
* telling would be the worse one. The picture is for people who can see it; the list is the
|
||||
* canonical version and everyone gets it.
|
||||
*
|
||||
* That also means the diagram must never gain a fact the list does not have.
|
||||
*
|
||||
* The concentric rings behind the nodes are the emblem's own geometry, centred on the
|
||||
* boundary line — the one place in the picture where the argument actually happens.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section datapath">
|
||||
<div class="datapath__head">
|
||||
<p class="eyebrow">How it works</p>
|
||||
<h2>One path, one direction</h2>
|
||||
<p class="prose">
|
||||
Everything the website knows about your game arrives the same way. There is no second
|
||||
route in, and nothing on the internet can reach the game to ask.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="datapath__body">
|
||||
<div class="datapath__figure">
|
||||
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- The emblem's concentric rings, centred on the boundary. Drawn first so the
|
||||
panels sit over them. -->
|
||||
<g class="rings">
|
||||
<circle cx="190" cy="252" r="112" />
|
||||
<circle cx="190" cy="252" r="158" />
|
||||
<circle cx="190" cy="252" r="204" />
|
||||
</g>
|
||||
|
||||
<!-- Loopback hop: same host, no network involved. -->
|
||||
<path class="spine" d="M190 92 V140" />
|
||||
<path class="arrow" d="M190 148 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The network hop, and the only one. Drawn in the portal colour because this is
|
||||
the live feed, and the live signal is cyan everywhere on the site. -->
|
||||
<path class="spine spine--live" d="M190 224 V272" />
|
||||
<path class="arrow arrow--live" d="M190 280 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="spine" d="M190 356 V404" />
|
||||
<path class="arrow" d="M190 412 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The boundary the whole design exists to draw. -->
|
||||
<path class="boundary" d="M8 252 H372" />
|
||||
<text class="boundary-label" x="372" y="245" text-anchor="end">the network</text>
|
||||
|
||||
<rect class="node" x="20" y="16" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="50">Your game server</text>
|
||||
<text class="node-sub" x="42" y="72">ServUO today · opens no inbound port</text>
|
||||
|
||||
<rect class="node" x="20" y="148" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="182">Sidecar</text>
|
||||
<text class="node-sub" x="42" y="204">uo-link · the only network-facing part</text>
|
||||
|
||||
<rect class="node node--self" x="20" y="280" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="314">Runic Gateway</text>
|
||||
<text class="node-sub" x="42" y="336">your public website</text>
|
||||
|
||||
<rect class="node" x="20" y="412" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="446">Browser and app</text>
|
||||
<text class="node-sub" x="42" y="468">anyone you choose to let in</text>
|
||||
</svg>
|
||||
|
||||
<p class="datapath__caption">
|
||||
Today that game server is a ServUO shard and that sidecar is uo-link. The shape is the
|
||||
contract; the implementations are what plug into it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol class="datapath__steps">
|
||||
<li>
|
||||
<h3>Your game server</h3>
|
||||
<p>
|
||||
A plugin inside the server dials <strong>out</strong> to the sidecar over loopback.
|
||||
The game never listens for anything, so there is nothing on it to find. Events go
|
||||
onto a bounded queue and the game moves on — a sidecar that is wedged or missing
|
||||
cannot slow the world down.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>The sidecar</h3>
|
||||
<p>
|
||||
A small service beside the game, and the only piece of the bridge anything else can
|
||||
reach. It speaks a versioned wire protocol — protocol {platform.protocol} today — so
|
||||
a mismatched pair is refused rather than misread, and it answers only your website's
|
||||
backend, over an authenticated WebSocket and REST.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Runic Gateway</h3>
|
||||
<p>
|
||||
Your site ingests the live feed and fans it back out on two streams: a public one
|
||||
carrying an allowlist of safe events, and a staff-only one carrying the rest. That
|
||||
split is a security boundary, not a preference. When the game is down the site stays
|
||||
up and shows it as offline.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Browser and app</h3>
|
||||
<p>
|
||||
The web client reads same-origin JSON and server-sent events. The Android app talks
|
||||
to the same documented API with bearer tokens. Neither has any idea where the game
|
||||
server is, because neither is ever told.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.datapath__head h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.datapath__head .prose {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.datapath__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;
|
||||
}
|
||||
|
||||
.datapath__figure {
|
||||
position: sticky;
|
||||
top: calc(var(--header-h) + 1.5rem);
|
||||
}
|
||||
|
||||
.flow {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.datapath__caption {
|
||||
margin: 1rem 0 0;
|
||||
max-width: 380px;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---- The drawing ------------------------------------------------------
|
||||
SVG presentation attributes cannot take a var(), so every colour here is
|
||||
set as a CSS property on a class instead. That is also what keeps
|
||||
checkTokens.mjs satisfied: no literal reaches the markup. */
|
||||
.node {
|
||||
fill: var(--panel-b);
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.node--self {
|
||||
fill: var(--panel-a);
|
||||
stroke: var(--gold-deep);
|
||||
}
|
||||
|
||||
.node-title {
|
||||
fill: var(--head);
|
||||
font-family: var(--sans);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-sub {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.spine {
|
||||
fill: none;
|
||||
stroke: var(--gold-deep);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.spine--live {
|
||||
stroke: var(--portal);
|
||||
filter: drop-shadow(0 0 6px var(--portal-deep));
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--gold-deep);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.arrow--live {
|
||||
fill: var(--portal);
|
||||
}
|
||||
|
||||
.boundary {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 5;
|
||||
}
|
||||
|
||||
.boundary-label {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rings {
|
||||
fill: none;
|
||||
stroke: var(--gold-deep);
|
||||
stroke-width: 1;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
/* ---- The list ---------------------------------------------------------- */
|
||||
.datapath__steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: step;
|
||||
}
|
||||
|
||||
.datapath__steps li {
|
||||
position: relative;
|
||||
padding-left: 3.25rem;
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.datapath__steps li + li {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.datapath__steps li::before {
|
||||
content: counter(step);
|
||||
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;
|
||||
}
|
||||
|
||||
.datapath__steps h3 {
|
||||
margin: 0.3rem 0 0.4rem;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.datapath__steps p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.datapath__body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Sticky is a wide-screen affordance: the figure should scroll away with
|
||||
everything else once it is above the list rather than beside it. */
|
||||
.datapath__figure {
|
||||
position: static;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/components/home/GetStarted.astro
Normal file
117
src/components/home/GetStarted.astro
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
import { brand } from '../../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's
|
||||
* "installation path": a "Runic Gateway install" is two independent installs. The installer
|
||||
* binary sets up the shard side only and never contacts the website; the website is a
|
||||
* separate Docker deployment.
|
||||
*
|
||||
* That belongs on the homepage rather than being saved for the docs. It is the single
|
||||
* misunderstanding most likely to make an evaluator think the software is broken, it costs
|
||||
* two sentences to prevent, and §13 calls the installation path the priority of the whole
|
||||
* project. Saying it here is what makes the docs a confirmation rather than a surprise.
|
||||
*
|
||||
* The two halves are ordered site-first because that is the order they must be done in: the
|
||||
* shard side ends by pasting four values into the site's admin panel, which has to exist.
|
||||
*
|
||||
* Both "read the docs" links point at `/docs/` rather than at a page inside the journey.
|
||||
* Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in
|
||||
* this file that nothing checks and that a later phase would have to remember to fix.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section start">
|
||||
<div class="panel start__panel">
|
||||
<p class="eyebrow">Getting started</p>
|
||||
<h2>An install is two installs</h2>
|
||||
<p class="start__lede prose">
|
||||
This trips up almost everyone once. The website and the game-side bridge are separate
|
||||
deployments on separate machines, and neither one installs the other. Doing them in
|
||||
order takes an evening.
|
||||
</p>
|
||||
|
||||
<div class="start__halves">
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">1</span> The site</h3>
|
||||
<p>
|
||||
A Docker Compose deployment on whatever host serves your community — a small VPS is
|
||||
plenty. Pull the images, bring it up, create the first admin, then install a game
|
||||
module from the admin panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">2</span> The game side</h3>
|
||||
<p>
|
||||
One binary, run on the machine the game server already lives on. It syncs the plugin,
|
||||
installs the sidecar as a service, and prints four values. You paste those into
|
||||
Admin → Shard, and the two halves find each other.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="start__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Read the install guide</a>
|
||||
<a class="btn btn--ghost" href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
|
||||
<a class="btn btn--ghost" href={brand.discordInvite} rel="noopener noreferrer">Ask on Discord</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.start__panel {
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.start h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.start__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.start__halves {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.start__half h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.start__num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex: none;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.start__half p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.start__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 2.25rem;
|
||||
}
|
||||
</style>
|
||||
183
src/components/home/Hero.astro
Normal file
183
src/components/home/Hero.astro
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
import { brand } from '../../lib/brand.mjs';
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The hero (PLAN.md §13 phase 3).
|
||||
*
|
||||
* The org lead chose an emblem hero over a type-only one: the mark carries recognition
|
||||
* across the site, the Android launcher icon and the Play listing, and showing it large is
|
||||
* what makes those three read as one product (D11, §11).
|
||||
*
|
||||
* It costs what D16 already accepted — the emblem is raster illustration, so a mounted
|
||||
* `theme.css` recolours everything around it and not the mark itself. Replacing the mark
|
||||
* means replacing `logo.png`, and because every size here is derived on request from
|
||||
* whichever `logo.png` is in force (D14), that one file changes the hero, the header, the
|
||||
* tab icon and the installed app icon together.
|
||||
*
|
||||
* The glow behind it is drawn in CSS from the portal tokens, so it DOES follow a mounted
|
||||
* theme. That is deliberate: the part that can track the operator's palette does.
|
||||
*
|
||||
* The <h1> is the tagline rather than the product name. The name is in the header, in the
|
||||
* page title and in the footer; a visitor who has just arrived needs the sentence more than
|
||||
* the noun. Both strings are brand fields, rewritten at boot by `applyBrand.mjs` (D15).
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<div class="page hero__inner">
|
||||
<div class="hero__copy">
|
||||
<p class="eyebrow">Self-hosted community platform</p>
|
||||
|
||||
<h1>{brand.tagline}</h1>
|
||||
|
||||
<p class="hero__lede">
|
||||
{brand.siteName} is a community website for a game server — accounts, teams, forums, a
|
||||
wiki, news and a full admin panel — with a one-way bridge that puts the server's live
|
||||
world on the public site. The game itself never listens on the internet.
|
||||
</p>
|
||||
|
||||
<div class="hero__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Install it</a>
|
||||
<a class="btn btn--ghost" href="/features/">See what it does</a>
|
||||
|
||||
{/*
|
||||
The demo slot (§15 / D12). `global.css` hides `[data-demo-url='']`, so a stock
|
||||
build renders nothing here; `applyBrand.mjs` fills both attributes at boot when a
|
||||
mounted `brand.json` sets `demoUrl`, and the link appears.
|
||||
|
||||
The attribute pair is a literal contract with that script — `href` immediately
|
||||
followed by `data-demo-url`, both empty, in this order. Astro preserves attribute
|
||||
order, so what is written here is what ends up in the HTML it searches for. Do not
|
||||
insert an attribute between them.
|
||||
*/}
|
||||
<a class="btn demo-cta" href="" data-demo-url="">See it running</a>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero__mark">
|
||||
{/*
|
||||
`alt=""` because the emblem is the product's mark sitting beside the product's own
|
||||
sentence — announcing it would add nothing a reader of the <h1> does not have.
|
||||
|
||||
Sizes are on `brandAssets.mjs`'s allowlist; `checkBrand.mjs` puts every URL below
|
||||
through the route's own classifier, so a plausible-but-underivable size fails the
|
||||
build rather than 404ing in production.
|
||||
*/}
|
||||
<picture>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset="/brand/logo-256.avif 256w, /brand/logo-384.avif 384w, /brand/logo-512.avif 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
/>
|
||||
<img
|
||||
src="/brand/logo-384.webp"
|
||||
srcset="/brand/logo-256.webp 256w, /brand/logo-384.webp 384w, /brand/logo-512.webp 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
width="384"
|
||||
height="384"
|
||||
alt=""
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-block: clamp(2.5rem, 7vw, 5rem) clamp(2rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
.hero__inner {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: clamp(1.5rem, 5vw, 3.5rem);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.hero__copy {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: clamp(2.1rem, 5.2vw, 3.35rem);
|
||||
}
|
||||
|
||||
.hero__lede {
|
||||
margin: 1.15rem 0 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: clamp(1rem, 1.6vw, 1.13rem);
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.9rem;
|
||||
}
|
||||
|
||||
.hero .chips {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
/* ---- The mark ---------------------------------------------------------
|
||||
The glow is a radial gradient mixed from the portal tokens rather than a
|
||||
literal, so a mounted theme.css moves it with the rest of the palette.
|
||||
It is behind the emblem and outside the flow, so it costs no layout. */
|
||||
.hero__mark {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.hero__mark::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 50% auto auto 50%;
|
||||
translate: -50% -50%;
|
||||
width: 150%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-pill);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--portal-deep) 34%, transparent) 0%,
|
||||
color-mix(in srgb, var(--portal-deep) 8%, transparent) 45%,
|
||||
transparent 68%
|
||||
);
|
||||
}
|
||||
|
||||
.hero__mark img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: clamp(176px, 26vw, 320px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero__inner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
/* The mark leads on a narrow screen: it is the fastest thing to recognise,
|
||||
and stacking it under the copy would push it below the fold entirely. */
|
||||
.hero__mark {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
src/components/home/SelfHosted.astro
Normal file
107
src/components/home/SelfHosted.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* The self-hosted argument (PLAN.md §10, the `/` row).
|
||||
*
|
||||
* Every claim below is from §2's verified state, and each is deliberately the kind of thing
|
||||
* that can be checked by running the software rather than by trusting the page. Where a
|
||||
* claim would need a qualifier, the qualifier is on the card — "understated honesty" (D8)
|
||||
* is a house style, and a hedge in small print is the opposite of it.
|
||||
*
|
||||
* Nothing here is a version or a number, so nothing here needs `platform.json`. If a card
|
||||
* ever gains one, it reads it from there like everything else (§12).
|
||||
*/
|
||||
|
||||
const points = [
|
||||
{
|
||||
title: 'It runs on your box',
|
||||
body:
|
||||
'Docker Compose, with prebuilt images that are pulled rather than built — nothing ' +
|
||||
'compiles on your server. One command up, one command back.',
|
||||
},
|
||||
{
|
||||
title: 'The game stays off the internet',
|
||||
body:
|
||||
'The game host opens no inbound port. The sidecar beside it is the only exposed ' +
|
||||
'part of the bridge, and it answers exactly one caller: your website.',
|
||||
},
|
||||
{
|
||||
title: 'Branding is data, not a rebuild',
|
||||
body:
|
||||
'Name, colours, logo and contact address live in a mounted file. The same image ' +
|
||||
'runs as any community — including this site, which is built the same way.',
|
||||
},
|
||||
{
|
||||
title: 'No analytics, anywhere',
|
||||
body:
|
||||
'This site has no trackers, no third-party requests and no cookie banner, because ' +
|
||||
'it collects nothing. Your deployment talks to the services you configure, and to ' +
|
||||
'nothing you did not.',
|
||||
},
|
||||
{
|
||||
title: 'Documented, not just working',
|
||||
body:
|
||||
'The whole backend is described by an OpenAPI 3.0 spec that ships with it, so the ' +
|
||||
'API you build against is the API that is actually there.',
|
||||
},
|
||||
{
|
||||
title: 'Free software',
|
||||
body:
|
||||
'GPL-3.0-or-later, every repository in the open. If this project stops, what you ' +
|
||||
'are running does not.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="page section selfhosted">
|
||||
<p class="eyebrow">Why self-hosted</p>
|
||||
<h2>Your server, your data, your rules</h2>
|
||||
<p class="prose selfhosted__lede">
|
||||
There is no hosted tier and no account with us. The whole thing is software you run,
|
||||
which is the only arrangement under which "the game is not on the internet" can mean
|
||||
anything.
|
||||
</p>
|
||||
|
||||
<ul class="selfhosted__grid">
|
||||
{
|
||||
points.map((point) => (
|
||||
<li class="panel">
|
||||
<h3>{point.title}</h3>
|
||||
<p>{point.body}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selfhosted h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.selfhosted__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.selfhosted__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
}
|
||||
|
||||
.selfhosted__grid h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.selfhosted__grid p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
172
src/data/capabilities.mjs
Normal file
172
src/data/capabilities.mjs
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* capabilities.mjs — the five capability groups of PLAN.md §10, as data.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS DATA AND NOT MARKUP
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The homepage names these groups, `/features/` (phase 4) expands them, and `/modules/`
|
||||
* explains the core/module split they encode. Three pages listing the same capabilities in
|
||||
* three hand-maintained lists is how a site ends up advertising something that was removed,
|
||||
* which §1 forbids. One list, read by all three.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE PART THAT IS A CHECK, NOT A LIST
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* "Game intelligence" is the only group core does not supply — it comes from whichever
|
||||
* module is installed, and today that is `module-uo`. Its items therefore carry the
|
||||
* capability slugs the module actually declares in its `module.json`, and
|
||||
* `assertCapabilityCoverage()` fails the build if the two lists drift apart.
|
||||
*
|
||||
* That closes a real gap. `platform.json` holds `moduleUoCapabilities` and
|
||||
* `scripts/checkFacts.mjs` re-reads it from the module's manifest on every build — so the
|
||||
* day `module-uo` gains a capability, the JSON goes red and someone updates it. Before this
|
||||
* function, updating the JSON was the end of it and the page kept the old list. Now the
|
||||
* page is what goes red next.
|
||||
*
|
||||
* Note that slugs are NOT one-per-item in either direction: `shard` is the source of four
|
||||
* separate user-facing capabilities, and the marketplace draws on `market` and `cliloc`
|
||||
* together (item names arrive as cliloc ids and are resolved against the shard's own
|
||||
* string table). The check is coverage in both directions, not a bijection.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Community — core, game-agnostic. Everything here works on a deployment with no game
|
||||
* module installed at all.
|
||||
*/
|
||||
const community = {
|
||||
id: 'community',
|
||||
/**
|
||||
* Core, not module-supplied. Stated on every group rather than only on the one that is
|
||||
* true, so the shape of a group is uniform — the homepage reads this field on all five,
|
||||
* and an inferred union that carries it on one member is an error waiting for the next
|
||||
* template that touches it.
|
||||
*/
|
||||
moduleSupplied: false,
|
||||
title: 'Community',
|
||||
summary: 'The site your players actually use, none of which knows what game you run.',
|
||||
items: [
|
||||
{ label: 'Teams' },
|
||||
{ label: 'Team forums' },
|
||||
{ label: 'Notifications' },
|
||||
{ label: 'Wiki' },
|
||||
{ label: 'News and newsletter' },
|
||||
{ label: 'Player self-service' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Game intelligence — module-supplied. The `caps` arrays are the contract with
|
||||
* `platform.json`; see `assertCapabilityCoverage` below.
|
||||
*/
|
||||
const gameIntelligence = {
|
||||
id: 'game-intelligence',
|
||||
title: 'Game intelligence',
|
||||
moduleSupplied: true,
|
||||
summary:
|
||||
'Supplied by the installed game module, not by the core site. Today that module is ' +
|
||||
'module-uo, and this is what it publishes from a live shard.',
|
||||
items: [
|
||||
{ label: 'Live server status', caps: ['shard'] },
|
||||
{ label: 'Economy and activity', caps: ['shard'] },
|
||||
{ label: 'Character sheets', caps: ['shard'] },
|
||||
{ label: 'Points and loyalty boards', caps: ['shard'] },
|
||||
{ label: 'Player-vendor marketplace', caps: ['market', 'cliloc'] },
|
||||
{ label: 'Houses and IDOC decay', caps: ['houses'] },
|
||||
{ label: 'Spawn atlas', caps: ['atlas'] },
|
||||
{ label: 'Champion boards', caps: ['champs'] },
|
||||
{ label: 'Guilds', caps: ['guilds'] },
|
||||
{ label: 'City governors', caps: ['governors'] },
|
||||
],
|
||||
};
|
||||
|
||||
const administration = {
|
||||
id: 'administration',
|
||||
moduleSupplied: false,
|
||||
title: 'Administration',
|
||||
summary: 'Running the place, with a record of who did what.',
|
||||
items: [
|
||||
{ label: 'Roles and permissions' },
|
||||
{ label: 'Moderation and appeals' },
|
||||
{ label: 'Content reports' },
|
||||
{ label: 'Append-only audit log' },
|
||||
{ label: 'Bot scoring and IP bans' },
|
||||
{ label: 'Module management' },
|
||||
{ label: 'The game-server connection' },
|
||||
],
|
||||
};
|
||||
|
||||
const integration = {
|
||||
id: 'integration',
|
||||
moduleSupplied: false,
|
||||
title: 'Integration',
|
||||
summary: 'The seams that let other things reach in — and one game reach out.',
|
||||
items: [
|
||||
{ label: 'Modules' },
|
||||
{ label: 'The sidecar bridge' },
|
||||
{ label: 'Discord: slash commands, notifications, voice' },
|
||||
{ label: 'Mobile and push' },
|
||||
{ label: 'SSO over OAuth2 / OIDC' },
|
||||
],
|
||||
};
|
||||
|
||||
const infrastructure = {
|
||||
id: 'infrastructure',
|
||||
moduleSupplied: false,
|
||||
title: 'Infrastructure',
|
||||
summary: 'How it runs, and who it answers to.',
|
||||
items: [
|
||||
{ label: 'Self-hosted, start to finish' },
|
||||
{ label: 'Docker, with prebuilt pull-only images' },
|
||||
{ label: 'Branding as data, not a rebuild' },
|
||||
{ label: 'OpenAPI 3.0 for the whole API' },
|
||||
],
|
||||
};
|
||||
|
||||
export const capabilityGroups = [
|
||||
community,
|
||||
gameIntelligence,
|
||||
administration,
|
||||
integration,
|
||||
infrastructure,
|
||||
];
|
||||
|
||||
/**
|
||||
* Fails the build when the module's declared capabilities and this page's list disagree.
|
||||
*
|
||||
* Called from the component rather than from a check script on purpose: the failure needs
|
||||
* to reach whoever is editing the page, and an Astro build error names the component. It
|
||||
* also means the rule cannot be skipped by running `astro build` without `npm run verify`.
|
||||
*/
|
||||
export function assertCapabilityCoverage(declared) {
|
||||
const claimed = new Set();
|
||||
for (const item of gameIntelligence.items) {
|
||||
for (const cap of item.caps || []) claimed.add(cap);
|
||||
}
|
||||
|
||||
const known = new Set(declared);
|
||||
|
||||
const unlisted = declared.filter((cap) => !claimed.has(cap));
|
||||
const invented = [...claimed].filter((cap) => !known.has(cap));
|
||||
|
||||
if (!unlisted.length && !invented.length) return;
|
||||
|
||||
const lines = [];
|
||||
if (unlisted.length) {
|
||||
lines.push(
|
||||
`the installed module declares ${unlisted.map((c) => `"${c}"`).join(', ')}, which no ` +
|
||||
`capability on the homepage claims — the site is under-selling what it can show.`
|
||||
);
|
||||
}
|
||||
if (invented.length) {
|
||||
lines.push(
|
||||
`the homepage claims ${invented.map((c) => `"${c}"`).join(', ')}, which the module no ` +
|
||||
`longer declares — the site is advertising something that is gone (§1).`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`src/data/capabilities.mjs disagrees with platform.json's moduleUoCapabilities:\n` +
|
||||
lines.map((line) => ` - ${line}`).join('\n') +
|
||||
`\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n`
|
||||
);
|
||||
}
|
||||
@@ -327,6 +327,13 @@ export function warmCache() {
|
||||
'favicon-32.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
// The homepage hero (phase 3). Its <picture> offers 256/384/512 in both formats and
|
||||
// the browser picks one, so warming all six would be five wasted encodes; these are
|
||||
// the two the common viewport-and-DPR combinations resolve to, plus the WebP the
|
||||
// `src` attribute names for anything without AVIF.
|
||||
'logo-384.avif',
|
||||
'logo-512.avif',
|
||||
'logo-384.webp',
|
||||
];
|
||||
return Promise.allSettled(names.map((name) => resolveBrandFile(name)));
|
||||
}
|
||||
|
||||
@@ -1,79 +1,34 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
import Hero from '../components/home/Hero.astro';
|
||||
import DataPath from '../components/home/DataPath.astro';
|
||||
import SelfHosted from '../components/home/SelfHosted.astro';
|
||||
import Capabilities from '../components/home/Capabilities.astro';
|
||||
import GetStarted from '../components/home/GetStarted.astro';
|
||||
|
||||
/**
|
||||
* Phase 1 is the foundation, not the homepage — phase 3 builds the real one (hero, the
|
||||
* data-path diagram as inline SVG, the grouped capability sections, the reserved demo
|
||||
* slot). This page exists so the shell is provably assembled: layout, header, footer,
|
||||
* tokens, both typefaces, and a fact read from platform.json rather than typed.
|
||||
* The homepage — PLAN.md §13 phase 3.
|
||||
*
|
||||
* Everything it claims is from §2's verified state. Nothing here is marketing copy yet.
|
||||
* Ordered as an argument rather than as a brochure: what it is (hero), how the hard part
|
||||
* works (the data path), why you would want it on your own hardware, what you actually get,
|
||||
* and how to start. The data path comes second on purpose — it is the claim in the tagline,
|
||||
* and a visitor who does not believe it has no reason to read the feature list.
|
||||
*
|
||||
* The page itself holds no copy and no facts. Each section reads versions from
|
||||
* `platform.json` and brand text from `brand.mjs`, so nothing on this route can go stale
|
||||
* without a check going red first (§12).
|
||||
*
|
||||
* `bareTitle` because the hero's own <h1> is the tagline: the default suffix would render
|
||||
* "Runic Gateway — Put your … — Runic Gateway".
|
||||
*/
|
||||
---
|
||||
|
||||
<Base
|
||||
title={`${brand.siteName} — ${brand.tagline}`}
|
||||
description={brand.tagline}
|
||||
bareTitle
|
||||
>
|
||||
<section class="page hero">
|
||||
<p class="eyebrow">Foundation</p>
|
||||
<h1>{brand.siteName}</h1>
|
||||
<p class="hero__tagline prose">{brand.tagline}</p>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
<span class="chip chip--live">Verified {platform.verifiedOn}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page">
|
||||
<div class="panel prose">
|
||||
<h2>This is the phase 1 scaffold</h2>
|
||||
<p>
|
||||
The layout shell, the token file, the self-hosted typefaces, the documentation
|
||||
theme and the two build-time checks are in place. The homepage itself is phase 3;
|
||||
the marketing pages are phase 4; the documentation — the installation path, which
|
||||
is the priority of the whole project — is phase 7.
|
||||
</p>
|
||||
<p>
|
||||
Every version above was read from <code>src/data/platform.json</code>, and{' '}
|
||||
<code>scripts/checkFacts.mjs</code> re-reads each one from its authority on every
|
||||
build. No version number is written in prose anywhere on this site.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/docs/">Read the documentation</a> ·{' '}
|
||||
<a href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
|
||||
<Hero />
|
||||
<DataPath />
|
||||
<SelfHosted />
|
||||
<Capabilities />
|
||||
<GetStarted />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: clamp(3rem, 9vw, 6rem) 2rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.4rem, 7vw, 4rem);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.hero__tagline {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -204,11 +204,33 @@ svg {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* The nav collapses to the docs link alone until phase 3 gives it a real
|
||||
disclosure control; a hamburger with nothing behind it is worse than none. */
|
||||
/* Phase 1 left the mobile nav to phase 3, expecting a disclosure control. It got
|
||||
a wrap instead, and deliberately: with four links there is nothing to disclose.
|
||||
The lockup keeps the first row, the links take the second, and the whole thing
|
||||
stays four keyboard stops with no state, no script and no duplicate markup —
|
||||
all three of which a hamburger would have cost.
|
||||
|
||||
Phase 3 found the bug this fixes by rendering the homepage in a 390px frame:
|
||||
the four links plus the lockup measured 433px against a 390px viewport, so
|
||||
every phone got a horizontally scrolling page. Shrinking the type further was
|
||||
the tempting fix and would only have moved the failure to the next narrow
|
||||
screen. */
|
||||
@media (max-width: 720px) {
|
||||
.site-header__inner {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
row-gap: 0.15rem;
|
||||
padding-block: 0.55rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
/* Pull the first link's own padding back to the gutter so the row of links
|
||||
lines up with the lockup above it rather than sitting indented. */
|
||||
margin-left: -0.45rem;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
@@ -333,6 +355,73 @@ svg {
|
||||
color: var(--mode-live);
|
||||
}
|
||||
|
||||
/* ---- Sections ------------------------------------------------------------
|
||||
The vertical rhythm every marketing page is built from. Here rather than in
|
||||
a component because phases 4 to 6 add pages that must sit on the same grid,
|
||||
and a per-page `padding-block` is how that stops being true. */
|
||||
|
||||
.section {
|
||||
padding-block: clamp(2.5rem, 6vw, 4.5rem);
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* ---- Buttons -------------------------------------------------------------
|
||||
Three variants, all the same box: solid for the one action a page wants,
|
||||
outlined for the alternatives, and the demo's own below. Anchors, not
|
||||
buttons — every one of them navigates, and the site runs no client JS. */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.62rem 1.15rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-flat);
|
||||
color: var(--ink);
|
||||
font-size: 0.96rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--gold-deep);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The primary action reads as gold-on-dark rather than a filled gold slab: at
|
||||
7.91:1 the token is a text colour, and `--gold-deep` is explicitly annotated
|
||||
"rules, borders, UI edges. NEVER text." — so it carries the edge and the
|
||||
wash, and the gold carries the label. */
|
||||
.btn--primary {
|
||||
border-color: var(--gold);
|
||||
background: color-mix(in srgb, var(--gold-deep) 18%, transparent);
|
||||
color: var(--gold-bright);
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background: color-mix(in srgb, var(--gold-deep) 30%, transparent);
|
||||
color: var(--gold-bright);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---- The demo slot -------------------------------------------------------
|
||||
PLAN.md §15 / D12. A public demo instance is planned and out of scope, but
|
||||
the site is built so that gaining one is a line in the mounted brand.json
|
||||
@@ -351,3 +440,18 @@ svg {
|
||||
[data-demo-url=''] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button
|
||||
like its neighbours and takes the portal colour — the live signal, for the
|
||||
one link on the site that leads to something actually running. */
|
||||
.demo-cta {
|
||||
border-color: var(--portal);
|
||||
background: color-mix(in srgb, var(--portal-deep) 22%, transparent);
|
||||
color: var(--portal-bright);
|
||||
}
|
||||
|
||||
.demo-cta:hover {
|
||||
border-color: var(--portal-bright);
|
||||
background: color-mix(in srgb, var(--portal-deep) 34%, transparent);
|
||||
color: var(--portal-bright);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user